栈帧(Stack Frame)是栈上的内存片段,用来实现函数调用。其主要作用有:为局部变量和临时变量分配空间,传递参数,保存返回地址。
每个函数在栈上拥有独立的帧,这些帧是在函数调用时分配的。在任一时刻,寄存器ebp(帧指针)都指向帧的起始处,寄存器esp(栈指针)指向帧的结尾处,它们之间的部分是当前函数的栈帧。我们可以这样认为,ebp是局部的,作用于当前帧,而esp是全局的,作用于整个栈。
------------ 栈底
| . |
| . |
| . |
------------
| 上一帧的ebp |
由被调用函数保存上一帧的ebp
|被保存的寄存器|
| 局部变量 |
| 临时变量 |
| . | 未分配区域
| . |
| . |
| 参数n | 注意参数的压栈顺序(从右向左)
| 参数n-1 |
| . |
| . |
| . |
| 参数1 |
| 返回地址 | 由调用者保存返回地址
------------
| 上一帧的ebp | <-ebp的值
| . |
| . |
| . | <-esp的值
------------ 栈顶
假设函数P(caller)调用函数Q(callee),一般来说在P中会执行如下操作:
- push %ebp; move %esp, %ebp; 保存上一帧的ebp,以便恢复,并将ebp指向帧头
- sub $NUM, %esp; 为局部变量和临时变量分配空间,其中NUM为字节数,在编译时已经确定
- 将参数从右到左依次压栈
- 将返回地址压栈
- 跳转到函数Q的第一条语句
- 将返回值(如果有)保存在eax中
- 释放空间,恢复信息(如ebp)
- 弹出返回地址并返回
举个例子:
int caller(int m, int n)
{
return m + n + callee(m, n);
}
int callee(int x, int y)
{
return x + y;
}
由gcc编译之后的汇编代码如下:
caller:
pushl %ebp //将上一帧的帧指针压栈
movl %esp, %ebp //令ebp指向帧头
pushl %ebx
subl $20, %esp //为何要分配20个字节
movl 12(%ebp), %eax //获取参数n
movl 8(%ebp), %edx //获取参数m
leal (%edx,%eax), %ebx //leal指令,相当于ebx = edx + eax = m + n
movl 12(%ebp), %eax
movl %eax, 4(%esp) //参数n压栈,注意n先于m压栈
movl 8(%ebp), %eax
movl %eax, (%esp) //参数m压栈
call callee //返回地址压栈,并跳转到callee
leal (%ebx,%eax), %eax //此时ebx的值为m+n,eax(前者)的值为callee的返回值
addl $20, %esp //释放空间
popl %ebx //恢复信息
popl %ebp
ret //弹出返回地址并返回
callee:
pushl %ebp
movl %esp, %ebp
movl 12(%ebp), %eax
movl 8(%ebp), %edx
leal (%edx,%eax), %eax
popl %ebp
ret
注意到一个细节,为了保存参数m和n,caller函数在栈上分配了20个字节,但实际只需要8个字节,这是什么原因呢?-----
| ebp |
| ebx |
| . |
| . |
| . |
| n |
| m |
| ra |
-----
这是因为在IA32架构中,栈帧的大小总是16的倍数。通过研究栈帧的内容,可以发现寄存器ebp、ebx再加上返回地址ra总共是12个字节,因此栈帧的总大小为32。