本文主要介绍C语言中的函数调用约定,通过汇编代码结合实时观察栈的变化情况,直观地了解函数调用的过程。本文只讨论x86/64架构、Linux/GCC环境下的情况,不过其他环境在整体思想上应该是类似的,都需要处理这些问题。
前言
什么是调用约定(Calling Convention)?
它主要是为了方便代码的共享以及简化子函数的使用方式。参数和返回值如何进行传递、栈帧如何建立和销毁、调用者和被调者分别负责哪些事情?调用约定对这些都作出了规定,函数的定义者和调用者只要都遵循这个约定,那么就可以无错地进行交互;否则,不一致的状态可能导致程序的致命错误。
x86 C编译器默认通常使用cdecl调用约定,这也是C语言事实上的标准。当然还有一些其他的调用约定,读者可以通过文末的参考资料进行更多的了解。
典型的栈帧结构
上图是一个典型的调用子函数期间的栈帧结构。栈从下面高地址往上生长,每嵌套一层函数调用,就往上生长一个栈帧。其中的返回地址可以看作是每个栈帧的分界线,它上面的部分归被调者管,它下面的部分归调用者管。
ESP和EBP分别指示当前栈顶位置和当前栈帧的基址。通过EBP加适当的偏移可以方便地访问参数以及局部变量,同时也可以快速地进行关帧的操作。
调用约定可以分成两个部分,即规定调用者(caller)的部分和规定被调者(callee)的部分。下面分别进行介绍。
调用者规则
在发起一个子函数调用时,调用者需要:
- 在调用子函数前,首先需要保存某些寄存器的值。这些寄存器设计为由调用者保存,所以被调函数是允许修改它们的。如果调用者在子函数返回后还依赖这些寄存器的值,就必须在调用子函数前将这些寄存器的值压栈保存,在子函数返回之后再将其出栈恢复。调用者保存的寄存器有EAX、ECX、EDX。
- 接着,调用者需要把调用子函数的参数压栈,压栈顺序为从右往左,所以第一个参数在栈的最上面(低地址)。
- 使用
call
指令调用子函数,这个指令会将返回地址(也即当前函数下一条指令的地址)压栈,然后跳到子函数处开始执行。
被调者规则我们稍后再看,现在先假设子函数已经返回,所以正常情况下栈已经恢复到了调用call
指令之前的情况。调用者可以从EAX寄存器中获取子函数的返回值。要完全恢复子函数调用前的状态,还需要:
- 将栈上的参数移出
- 将之前压栈的调用者保存的寄存器的内容出栈恢复(跟入栈时相反的顺序)。调用者可以假设其他寄存器没有被子函数修改。
被调者规则
子函数在开头需要:
- 将EBP的值压栈,然后将ESP的值拷贝到EBP上。可以把这个当作是开帧的操作,首先保存上一个栈帧的基址,然后设置当前栈帧的基址(即子函数刚开始执行时的栈指针的值),参数和局部变量跟EBP有着固定的偏移,所以可以通过EBP访问到。
- 接下来,分配局部变量的栈空间,这个可以通过减小ESP的值来实现。
- 然后需要将被调者保存的寄存器的值入栈(如果子函数中用到了它们的话)。被调者保存的寄存器包括EBX、EDI和ESI。
执行完这3步操作之后,就开始执行实际的函数体了。当函数体执行结束即将返回的时候,它需要:
- 将返回值放在EAX中
- 将被调者保存的寄存器的值入栈(跟入栈时相反的顺序)
- 释放局部变量的栈空间,这个可以通过增大ESP的值来实现,更好的方法是将EBP的值恢复到ESP。
- 然后恢复上一个栈帧的EBP,将其出栈
- 最后,执行
ret
指令返回。该指令会将之前入栈的返回地址出栈,然后跳转到这个返回地址处继续执行。
C语言实例
光说不练假把式,我们通过如下一个简单的例子来实际看一下:
1 | int foo(int a, int b) |
我们将其编译之后,使用objdump进行反汇编:
1 | # 内核版本 2.6.8-2-686-smp |
接下来我们将结合汇编代码以及栈的实时情况,来直观地了解下函数的调用过程,即上面foo函数的调用过程。
调用者部分
首先来看main函数的前面部分
1 | int main() |
最前面两个指令是main函数的开帧操作
接下来的
sub $0x18, %esp
分配栈空间,这里分配了较多的空间,包括了局部变量以及调用子函数时参数的空间,而且还有余量。接下来的
and $0xfffffff0,%esp
,是将esp 16字节对齐。接下来的两条指令没有什么实际的影响,不确定是什么目的,估计也是某个编译器行为。
movl $0x0,0xfffffffc(%ebp)
将第一个局部变量(即ret)赋值为0。
接下来的就是调用foo函数相关的部分了,此时栈的情况如下:
此时的EBP指向main函数栈帧的基址,ESP指向栈顶位置。因为我们的程序非常简单,后面也没有用到调用者保存的寄存器,所以这里省略了保存寄存器的步骤。又因为前面一开始已经分配了参数的栈空间,所以接下来直接对栈中对应位置的形参赋值即可。
参数的传递是从右往左的顺序,所以先传递第二个参数,ESP+4的位置的值被赋值为2。
接着传递第一个参数,ESP位置的值被赋值为1。参数传递完毕接下来就执行call
指令进入子函数foo的范围了。
我们暂且跳过,假设现在foo函数已经返回,来看最后几条指令的操作。现在EAX中存放了返回值。
mov %eax,0xfffffffc(%ebp)
将返回值赋值给局部变量ret- 然后main函数返回了,所以要将返回值放到EAX中。(因为我们编译时是用的-O0,所以这一步显得有点多余)
- 接下来要将参数出栈,这里并没有单独执行这个操作,而是合并到
leave
指令里了 leave
指令,相当于mov esp,ebp; pop ebp
。直接将关闭当前栈帧,将参数以及局部变量统统清了。从这个例子中我们可以看到,编译器并没有傻瓜式地每次调用函数都执行参数的入栈和出栈操作。事实上,它做了一定的优化,在一开始就分配了充足的栈空间,足够存放局部变量以及后面要调用的子函数的参数,最后在关帧的统一释放栈空间。- 如果前面有将调用者保存的寄存器入栈的话,这里要执行相应的出栈操作恢复相应的寄存器的值。在我们这个例子中并没有。
被调者部分
现在我们来看被调者foo函数中的部分,在main函数中执行call
指令之后,栈的情况如下:
此时,返回地址已经入栈,EBP还是上一个main函数的基址。
首先需要创建foo函数的栈帧,将EBP入栈,保存main函数栈帧的基址。
紧接着让EBP指向当前ESP的位置,此时EBP变成了foo函数栈帧的基址。后续就可以通过EBP加上一定的偏移来访问形参和局部变量了。
接下来一条指令分配局部变量的栈空间,因为有两个int型局部变量,所以这里将esp减了8。
正常情况下,如果foo函数用到了被调者保存的寄存器的话,需要现在这里执行入栈的操作,保存相应寄存器的值。因为我们程序比较简单,所以没有这个过程。
接下来将EBP-4的位置赋值为0x10,对应C代码中的int fa = 0x10;
同样地,将EBP-8的位置赋值为0x20,对应C代码中的int fb = 0x10;
接下来执行将形参a的值赋值给fa,首先将EBP+8位置的形参a赋值到EAX寄存器。
然后再将EAX寄存器的值赋值到EBP-4的位置。
接下来是形参b的值赋值给fb,执行的操作类似,通过寄存器EAX做了一下中转。
接下来执行fa和fb相加的操作,先将EBP-8的值放到EAX中,因为-O0的关系这一步又显得有些多余,然后EBP-4的值加到EAX中,执行完之后EAX就变成了3。
到这里函数体已经执行完毕,但是还有一些善后工作需要做:
- 此时返回值已经在EAX中了。
- 如果前面有被调者保存的寄存器入栈的话,这里需要执行相应的出栈操作进行恢复。
- 接下来需要释放局部变量的栈空间,然后恢复上一个栈帧的EBP,将其出栈。这两步合并到了
leave
指令中。执行完leave
之后的栈情况如下。栈顶为返回地址,EBP已经恢复为main函数的基址。
当最后一个ret
指令执行之后,返回地址出栈,栈恢复到执行call
指令前的情况,程序跳转到返回地址继续执行main函数后续的代码。
x64上的区别
在x64上,不仅寄存器的位数扩展到了64位,可用的寄存器也更多了。Linux在x64上使用System V AMD64 ABI调用约定,其主要内容如下:
1 | %rax %eax 返回值 |
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 | // 内核版本 5.4.0-91-generic |
可以看到两个参数并没有通过栈传递,而是分别通过edi和esi寄存器传递。
1 | int foo(int a, int b) |
再来看看foo函数的情况,注意到它里面并没有为局部变量分配栈空间,而是直接通过rbp进行赋值了。这是因为foo函数就是我们前面提到的叶子节点函数,它没有再调用其他函数,所以可以直接使用red-zone的空间。