理解 stack --- 函数是如何调用的
理解 Stack
high address +---------------+
| |
| Stack |
| |
+---------------+
| | |
| v |
| |
| |
+---------------+
| Mmap |
+---------------+
| |
| |
| ^ |
| | |
+---------------+
| |
| Heap |
| |
+---------------+
| Data |
+---------------+
| Code |
low address +---------------+
上图是 Linux 进程的地址空间,笼统的说,进程的空间可以分为两个大类:指令和数据,指令即是上图的 code 段,其它的均属于数据。根据数据在生命周期以及作用范围的特点,又可分为 data, heap 和 stack,data 通常用于存放全局变量,它伴随着程序的整个生命周期,可被所有的线程共享;动态分配的数据通常存放在 heap,同样可被所有的线程共享;stack 存放的是函数以及函数的局部变量,它们被动态的分配和释放,但是不被其它线程共享。
在数据结构领域,stack 是一种后进先出的结构,设想某个函数被调用时,我们需要为该函数开辟空间,当调用结束后,空间将被释放,所以 stack 这种数据结构非常适用函数的调用。本文主要介绍 stack 以及函数是如何调用的。
Stack Frame
要实现函数调用,必须解决以下几个问题:
- 参数的传递
- 返回值的传递
- stack 的维护
我们通常使用栈帧(stack frame)实现函数的调用,它的结构如下:
high address
+---------------+
| |
| Old |
| Stack |
| Frame |
| |
-------------- +---------------+
^ ^ | Parameters |
| caller +---------------+
| v | Return addr |
--- +---------------+
Stack Frame ^ | Old ebp | <------- ebp Frame Pointer
| +---------------+
| callee | Registers |
| | +---------------+
v v | Local vars |
-------------- +---------------+ <------- esp Stack Pointer
low address
首先介绍 x86 下的两个寄存器:
- esp:栈寄存器,永远指向程序的栈顶。
- ebp:帧寄存器,指向当前的帧
我们把主动调用函数的一方称为 caller,被调用的一方称为 callee,当函数被调用时,caller 首先把参数压栈,然后压栈返回地址,之后跳到相应的函数执行。当跳到相应的函数时,callee 需保存 caller 的帧地址,即把上个函数的帧地址压入栈中,并更新当前的帧寄存器,之后保存某些寄存器的值,最后才是为 callee 函数体内的局部变量开辟空间。当函数调用结束后,当前栈帧所占用的空间已经没有用处,所以需要释放这片空间,即把当前栈帧的所有数据全部弹出。
函数的调用规则有多种,这些规则主要表现在某些细节的差异,比如参数的压栈顺序,参数的出栈。Cdecl 是 C 语言默认的规则,它规定参数从右到左压栈,调用方负责参数的出栈。
Example
以下是个简单的 c 样例:
int add_1(int a){
return a + 1;
}
void main(){
int a = 1;
a = add_1(a);
}
编译所得的汇编如下:
main:
......
movl $1, -4(%ebp) # set a = 1
leal -4(%ebp), %eax # set eax = a
movl %eax, 4(%esp) # store on stack
call add_1 # call add_1
......
add_1:
pushq %ebp # save old ebp
movq %esp, %ebp # update the ebp
movl -4(%ebp), %eax # set eax = a
addl $1, %eax # eax = eax + 1
popq %ebp # restore ebp
ret # return