一、栈介绍
栈保存了一个函数调用所需要的维护信息,常被称为:堆栈帧(Stack Frame
)或活动记录。堆栈帧一般包括如下内容:
- 函数的返回地址和参数
- 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量
- 保存的上下文:包括在函数调用前后需要保持不变的寄存器
esp 寄存器始终指向栈的顶部,随着函数的执行而不断变化。
ebp 寄存器指向了函数栈帧的一个固定位置。不随这个函数的执行而变化。固定不变的 ebp 可以用来定位函数活动记录中的各个数据。在 ebp 之前首先是这个函数的返回地址,他的地址是 ebp-4
;再往前是压入栈中的参数,他们的地址视参数数量和大小而定。ebp 所直接指向的数据是调用该函数前 ebp 的值,这样在函数返回的时候,ebp 可以通过读取这个值恢复到调用前的值。

一个 i386
下的函数总是这样调用的:
- 把所有或一部分参数压入栈中,如果有其他参数没有入栈,那么使用某些特定的寄存器传递
- 把当前指令的下一条指令的地址压入栈中
- 跳转到函数体执行
二、函数体结构
i386
函数体的标准开头如下:
push ebp
:把 ebp 压入栈中(old ebp),为了在函数返回的时候便于恢复以前的 ebp 值mov ebp, esp
:此时 ebp 指向栈顶,而此时栈顶就是old ebp
- 【可选】
sub esp, xxx
:在栈上分配 xxx 字节的临时空间 - 【可选】
push xxx
:如有必要,保存名为 xxx 寄存器(可重复多个)。之所以可能要保存一些寄存器,在于编译器可能要求某些寄存器在调用前后保持不变,那么函数就可以在调用开始时将这些寄存器的值压入栈中,在结束后再取出。
i386
函数体的结尾如下:
- 【可选】
pop xxx
:如有必要,恢复保存过的寄存器(可重复多个) mov esp, ebp
:恢复 esp 同时回收局部变量空间pop ebp
:从栈中恢复保存的 ebp 的值ret
:从栈中取得返回地址,并跳转到该位置
反汇编一个函数实际看下:
1 | int foo() { |
进行编译并且反汇编
1 | gcc main.c -m32 -O0 -o main |
我们来看对应的反汇编
1 | 0000118d <foo>: |
在 foo 的反汇编中,我们看到
push ebp; mov ebp, esp;
这两行语句保存了旧的 ebp,并且让 ebp 指向当前的栈顶。call 11c0 <__x86.get_pc_thunk.ax>; add eax,0x2e47
这两行,call 之前会把下一条指令压栈,所以 eax 其实是拿到了 1195 这个值。所以<__x86.get_pc_thunk.ax>
他的作用其实是获取到当前的指令位置,存储在 eax 寄存器中。- 最后,
pop ebp; ret;
这两句指令,从栈上恢复 ebp 寄存器,然后使用 ret 指令返回。
三、调用惯例
函数的调用方和被调用方对于函数如何调用需要一个明确的约定,只有双方都遵守同样的约定,函数才能被正确的调用,这样的约定就称为 “调用惯例”。包括:
- 函数参数的传递顺序和方式。调用惯例规定函数调用方将参数压栈的顺序,是从左至右,还是从右至左。有些调用惯例还允许使用寄存器传递参数,以提高性能。
- 栈的维护方式。在函数将参数压栈之后,函数体会被调用,此后需要被压入栈中的参数全部弹出,以使得栈在函数调用前后保持一致。这个弹出的工作可以由函数的调用方来完成,也可以由函数本身来完成。
- 名字修饰的策略。为了链接的时候对调用惯例进行区分,调用惯例要对函数本身的名字进行修饰。不同的调用惯例有不同的名字修饰策略。
cdecl
这个调用惯例是 C 语言默认的调用惯例。他的规则如下:
- 参数传递:从右至左的顺序压参数入栈
- 出栈方:函数调用方
- 名字修饰:直接在函数名称前加一个下划线
四、函数返回值传递
函数和调用方的交互可以使用参数传递,也可以使用返回值。如上例子中他就是使用 eax 寄存器来传递返回值。
- 对于 4 字节的返回值,使用 eax 寄存器
- 对于
5-8
字节的返回值,一般采用 eax 和 edx 联合返回的方式,其中 eax 存储返回值的低 4 字节,而 edx 存储返回值的高 4 字节
1 | long long int foo() { |
- 对于大于 8 字节的返回值,
1 | struct big_thing { |
首先 main 函数在栈上额外开辟了一片空间,并将这块空间的一部分作为传递返回值的临时对象,这里称为 temp。
然后将 temp 对象的地址作为隐藏参数传递给 foo 函数。
foo 函数将数据拷贝给 temp 对象,并将 temp 对象的地址用 eax 传出
foo 返回之后,main 函数将 eax 指向的 temp 对象的内容拷贝给 n
因此,我们发现,如果返回值类型的尺寸太大,C 语言在函数返回时会使用一个临时的栈上内存区域作为中转,结果返回值对象会被拷贝两次。因而不到万不得已,不要轻易返回大尺寸的对象。