C语言中的函数调用约定

本文主要介绍C语言中的函数调用约定,通过汇编代码结合实时观察栈的变化情况,直观地了解函数调用的过程。本文只讨论x86/64架构、Linux/GCC环境下的情况,不过其他环境在整体思想上应该是类似的,都需要处理这些问题。

前言

什么是调用约定(Calling Convention)?

它主要是为了方便代码的共享以及简化子函数的使用方式。参数和返回值如何进行传递、栈帧如何建立和销毁、调用者和被调者分别负责哪些事情?调用约定对这些都作出了规定,函数的定义者和调用者只要都遵循这个约定,那么就可以无错地进行交互;否则,不一致的状态可能导致程序的致命错误。

x86 C编译器默认通常使用cdecl调用约定,这也是C语言事实上的标准。当然还有一些其他的调用约定,读者可以通过文末的参考资料进行更多的了解。

典型的栈帧结构

典型的栈帧结构

上图是一个典型的调用子函数期间的栈帧结构。栈从下面高地址往上生长,每嵌套一层函数调用,就往上生长一个栈帧。其中的返回地址可以看作是每个栈帧的分界线,它上面的部分归被调者管,它下面的部分归调用者管。

ESP和EBP分别指示当前栈顶位置和当前栈帧的基址。通过EBP加适当的偏移可以方便地访问参数以及局部变量,同时也可以快速地进行关帧的操作。

调用约定可以分成两个部分,即规定调用者(caller)的部分和规定被调者(callee)的部分。下面分别进行介绍。

调用者规则

在发起一个子函数调用时,调用者需要:

  1. 在调用子函数前,首先需要保存某些寄存器的值。这些寄存器设计为由调用者保存,所以被调函数是允许修改它们的。如果调用者在子函数返回后还依赖这些寄存器的值,就必须在调用子函数前将这些寄存器的值压栈保存,在子函数返回之后再将其出栈恢复。调用者保存的寄存器有EAX、ECX、EDX。
  2. 接着,调用者需要把调用子函数的参数压栈,压栈顺序为从右往左,所以第一个参数在栈的最上面(低地址)。
  3. 使用call指令调用子函数,这个指令会将返回地址(也即当前函数下一条指令的地址)压栈,然后跳到子函数处开始执行。

被调者规则我们稍后再看,现在先假设子函数已经返回,所以正常情况下栈已经恢复到了调用call指令之前的情况。调用者可以从EAX寄存器中获取子函数的返回值。要完全恢复子函数调用前的状态,还需要:

  1. 将栈上的参数移出
  2. 将之前压栈的调用者保存的寄存器的内容出栈恢复(跟入栈时相反的顺序)。调用者可以假设其他寄存器没有被子函数修改。

被调者规则

子函数在开头需要:

  1. 将EBP的值压栈,然后将ESP的值拷贝到EBP上。可以把这个当作是开帧的操作,首先保存上一个栈帧的基址,然后设置当前栈帧的基址(即子函数刚开始执行时的栈指针的值),参数和局部变量跟EBP有着固定的偏移,所以可以通过EBP访问到。
  2. 接下来,分配局部变量的栈空间,这个可以通过减小ESP的值来实现。
  3. 然后需要将被调者保存的寄存器的值入栈(如果子函数中用到了它们的话)。被调者保存的寄存器包括EBX、EDI和ESI。

执行完这3步操作之后,就开始执行实际的函数体了。当函数体执行结束即将返回的时候,它需要:

  1. 将返回值放在EAX中
  2. 将被调者保存的寄存器的值入栈(跟入栈时相反的顺序)
  3. 释放局部变量的栈空间,这个可以通过增大ESP的值来实现,更好的方法是将EBP的值恢复到ESP。
  4. 然后恢复上一个栈帧的EBP,将其出栈
  5. 最后,执行ret指令返回。该指令会将之前入栈的返回地址出栈,然后跳转到这个返回地址处继续执行。

C语言实例

光说不练假把式,我们通过如下一个简单的例子来实际看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int foo(int a, int b)
{
int fa = 0x10;
int fb = 0x20;
fa = a;
fb = b;
return fa + fb;
}

int main()
{
int ret = 0;
ret = foo(1, 2);
return ret;
}

我们将其编译之后,使用objdump进行反汇编:

1
2
3
4
5
6
# 内核版本 2.6.8-2-686-smp
# gcc版本 3.3.5
$ gcc -g -O0 test.c
$ objdump -Sd a.out > a.s
# 如果只想编译的话,可以直接
# gcc -S test.c

接下来我们将结合汇编代码以及栈的实时情况,来直观地了解下函数的调用过程,即上面foo函数的调用过程。

调用者部分

首先来看main函数的前面部分

1
2
3
4
5
6
7
8
int main()
{
804837c: 55 push %ebp
804837d: 89 e5 mov %esp,%ebp
804837f: 83 ec 18 sub $0x18,%esp
8048382: 83 e4 f0 and $0xfffffff0,%esp
8048385: b8 00 00 00 00 mov $0x0,%eax
804838a: 29 c4 sub %eax,%esp
  1. 最前面两个指令是main函数的开帧操作

  2. 接下来的sub $0x18, %esp分配栈空间,这里分配了较多的空间,包括了局部变量以及调用子函数时参数的空间,而且还有余量。

  3. 接下来的and $0xfffffff0,%esp,是将esp 16字节对齐。

  4. 接下来的两条指令没有什么实际的影响,不确定是什么目的,估计也是某个编译器行为。

  5. movl $0x0,0xfffffffc(%ebp)将第一个局部变量(即ret)赋值为0。

接下来的就是调用foo函数相关的部分了,此时栈的情况如下:

C语言函数调用01

此时的EBP指向main函数栈帧的基址,ESP指向栈顶位置。因为我们的程序非常简单,后面也没有用到调用者保存的寄存器,所以这里省略了保存寄存器的步骤。又因为前面一开始已经分配了参数的栈空间,所以接下来直接对栈中对应位置的形参赋值即可。

C语言函数调用02

参数的传递是从右往左的顺序,所以先传递第二个参数,ESP+4的位置的值被赋值为2。

C语言函数调用03

接着传递第一个参数,ESP位置的值被赋值为1。参数传递完毕接下来就执行call指令进入子函数foo的范围了。

我们暂且跳过,假设现在foo函数已经返回,来看最后几条指令的操作。现在EAX中存放了返回值。

  1. mov %eax,0xfffffffc(%ebp)将返回值赋值给局部变量ret
  2. 然后main函数返回了,所以要将返回值放到EAX中。(因为我们编译时是用的-O0,所以这一步显得有点多余)
  3. 接下来要将参数出栈,这里并没有单独执行这个操作,而是合并到leave指令里了
  4. leave指令,相当于mov esp,ebp; pop ebp 。直接将关闭当前栈帧,将参数以及局部变量统统清了。从这个例子中我们可以看到,编译器并没有傻瓜式地每次调用函数都执行参数的入栈和出栈操作。事实上,它做了一定的优化,在一开始就分配了充足的栈空间,足够存放局部变量以及后面要调用的子函数的参数,最后在关帧的统一释放栈空间。
  5. 如果前面有将调用者保存的寄存器入栈的话,这里要执行相应的出栈操作恢复相应的寄存器的值。在我们这个例子中并没有。

被调者部分

现在我们来看被调者foo函数中的部分,在main函数中执行call指令之后,栈的情况如下:

C语言函数调用04

此时,返回地址已经入栈,EBP还是上一个main函数的基址。

C语言函数调用05

首先需要创建foo函数的栈帧,将EBP入栈,保存main函数栈帧的基址。

C语言函数调用06

紧接着让EBP指向当前ESP的位置,此时EBP变成了foo函数栈帧的基址。后续就可以通过EBP加上一定的偏移来访问形参和局部变量了。C语言函数调用07

接下来一条指令分配局部变量的栈空间,因为有两个int型局部变量,所以这里将esp减了8。

C语言函数调用08

正常情况下,如果foo函数用到了被调者保存的寄存器的话,需要现在这里执行入栈的操作,保存相应寄存器的值。因为我们程序比较简单,所以没有这个过程。

接下来将EBP-4的位置赋值为0x10,对应C代码中的int fa = 0x10;

C语言函数调用09

同样地,将EBP-8的位置赋值为0x20,对应C代码中的int fb = 0x10;

接下来执行将形参a的值赋值给fa,首先将EBP+8位置的形参a赋值到EAX寄存器。

C语言函数调用10

然后再将EAX寄存器的值赋值到EBP-4的位置。

C语言函数调用11

接下来是形参b的值赋值给fb,执行的操作类似,通过寄存器EAX做了一下中转。

C语言函数调用12

C语言函数调用13

接下来执行fa和fb相加的操作,先将EBP-8的值放到EAX中,因为-O0的关系这一步又显得有些多余,然后EBP-4的值加到EAX中,执行完之后EAX就变成了3。

C语言函数调用14

到这里函数体已经执行完毕,但是还有一些善后工作需要做:

  1. 此时返回值已经在EAX中了。
  2. 如果前面有被调者保存的寄存器入栈的话,这里需要执行相应的出栈操作进行恢复。
  3. 接下来需要释放局部变量的栈空间,然后恢复上一个栈帧的EBP,将其出栈。这两步合并到了leave指令中。执行完leave之后的栈情况如下。栈顶为返回地址,EBP已经恢复为main函数的基址。

C语言函数调用15

当最后一个ret指令执行之后,返回地址出栈,栈恢复到执行call指令前的情况,程序跳转到返回地址继续执行main函数后续的代码。

x64上的区别

在x64上,不仅寄存器的位数扩展到了64位,可用的寄存器也更多了。Linux在x64上使用System V AMD64 ABI调用约定,其主要内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
%rax     %eax    返回值
%rbx %ebx 被调用者保存
%rcx %ecx 第四个参数
%rdx %edx 第三个参数,128位返回值
%rsi %esi 第二个参数
%rdi %edi 第一个参数
%rbp %ebp 基址指针,被调用者保存
%rsp %esp 堆栈指针,被调用者保存
%r8 %r8d 第五个参数
%r9 %r9d 第六个参数
%r10 %r10d 调用者保存
%r11 %r11d 调用者保存
%r12 %r12d 被调用者保存
%r13 %r13d 被调用者保存
%r14 %r14d 被调用者保存
%r15 %r15d 被调用者保存
xmm0-78个浮点参数
xmm0-1 浮点返回值

RDI, RSI, RDX, RCX, R8, R9寄存器分别传递前6个整型或指针参数,XMM0-7用于传递前8个浮点参数。如果还有额外的参数,那么还是通过栈进行传递。

不超过64位的返回值通过RAX传递,不超过128位的返回值通过RAX和RDX传递。浮点数返回值使用XMM0和XMM1。

其中RBX、RBP、RSP、R12-R15这几个寄存器是被调用者保存的,其余的都是调用者保存。

另外还有一点值得提一下,对于叶子节点函数,函数的栈指针下方保留了一个128字节的空间(red-zone),编译器可以使用这个区域保存局部变量,这样可以省去开头的一些指令。

我们重新在x64环境下编译前面的示例代码,看看有什么区别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 内核版本 5.4.0-91-generic
// gcc版本 7.5.0
int main()
{
628: 55 push %rbp
629: 48 89 e5 mov %rsp,%rbp
62c: 48 83 ec 10 sub $0x10,%rsp
int ret = 0;
630: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
ret = foo(1, 2);
637: be 02 00 00 00 mov $0x2,%esi
63c: bf 01 00 00 00 mov $0x1,%edi
641: e8 b4 ff ff ff callq 5fa <foo>
646: 89 45 fc mov %eax,-0x4(%rbp)
return ret;
649: 8b 45 fc mov -0x4(%rbp),%eax
}
64c: c9 leaveq
64d: c3 retq
64e: 66 90 xchg %ax,%ax

可以看到两个参数并没有通过栈传递,而是分别通过edi和esi寄存器传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int foo(int a, int b)
{
5fa: 55 push %rbp
5fb: 48 89 e5 mov %rsp,%rbp
5fe: 89 7d ec mov %edi,-0x14(%rbp)
601: 89 75 e8 mov %esi,-0x18(%rbp)
int fa = 0x10;
604: c7 45 f8 10 00 00 00 movl $0x10,-0x8(%rbp)
int fb = 0x20;
60b: c7 45 fc 20 00 00 00 movl $0x20,-0x4(%rbp)
fa = a;
612: 8b 45 ec mov -0x14(%rbp),%eax
615: 89 45 f8 mov %eax,-0x8(%rbp)
fb = b;
618: 8b 45 e8 mov -0x18(%rbp),%eax
61b: 89 45 fc mov %eax,-0x4(%rbp)
return fa + fb;
61e: 8b 55 f8 mov -0x8(%rbp),%edx
621: 8b 45 fc mov -0x4(%rbp),%eax
624: 01 d0 add %edx,%eax
}
626: 5d pop %rbp
627: c3 retq

再来看看foo函数的情况,注意到它里面并没有为局部变量分配栈空间,而是直接通过rbp进行赋值了。这是因为foo函数就是我们前面提到的叶子节点函数,它没有再调用其他函数,所以可以直接使用red-zone的空间。

参考资料

-------------本文结束感谢您的阅读-------------

欢迎关注我的其它发布渠道