一、基本概念
什么是栈帧?根据《深入理解计算机系统》3.7.1节的解释:C语言过程调用(其实就是函数调用)机制的一个关键特性在于使用了栈数据结构提供的后进先出的内存管理原则。(中间省略)。。。当x86-64过程调用需要的存储空间超过寄存器能够存放的大小时,就会在栈上分配空间,这个部分就称为栈帧。
那么,根据这段描述可以比较直观的认为:C语言函数调用的内存申请和释放是通过对栈进行操作来完成的,一个函数在被调用时会在栈内申请一个内存区域(是通过移动%rsp实现的),这个区域被称之为栈帧。在汇编中,栈和栈帧的结构如下图所示:
结合图来理解,栈帧其实就是栈中的一个“块”,这个块存放了一个函数执行时需要的局部变量,参数等信息。块的边界由寄存器rbp和rsp来确定。
二、栈帧的工作过程
上面这个讲的还比较笼统,现在结合具体的例子讲讲栈帧在函数调用时的工作过程。以下面这段代码为例:
/*
* stackframe.c
*/
int foo(int para1){
return para1 +10;
}
int main(){
int var1 = 10;
int var2 = foo(var1);
return var1 + var2;
}
这段代码是一个简单的函数调用过程,main()->foo()。
对其使用gcc命令进行反汇编:
gcc -C stackframe.c -S
得到如下代码:
.file "stackframe.c"
.text
.globl foo
.type foo, @function
foo:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl %edi, -4(%rbp) # 约定%edi表示第一个参数,即para1,保存到-4(%rbp)的位置
movl -4(%rbp), %eax
addl $10, %eax # 计算para1 + 10
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size foo, .-foo
.globl main
.type main, @function
main:
.LFB1:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp # 申请16个字节的栈空间
movl $10, -4(%rbp) # var1存放的位置是-4(%rbp)
movl -4(%rbp), %eax
movl %eax, %edi # var1赋值给了%edi
call foo
movl %eax, -8(%rbp) # foo的返回值通过eax传回,并存放到
movl -4(%rbp), %edx # 取var1
movl -8(%rbp), %eax
addl %edx, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE1:
.size main, .-main
.ident "GCC: (Debian 9.2.1-19) 9.2.1 20191109"
.section .note.GNU-stack,"",@progbits
然后我们通过gdb调试来观察一下这段代码执行时候的内存使用情况
gcc -g stackframe.s -o stackframe.exe
gdb stackframe.exe
由于篇幅有限,我们只展示4个主要过程的内存使用情况。
· main函数调用foo前的情况
main函数调用foo前,做了如下几个关键操作:
pushq %rbp 将main的父函数的栈帧指针压栈
movq %rsp, %rbp 将当前栈顶地址,作为当前的栈帧指针
subq $16, %rsp 通过减少栈顶的大小,将栈分配16个字节
movl $10, -4(%rbp) 将变量var1存放在-4(%rbp)的位置上
概括之就是:
1、保存上一个函数的栈帧地址%rbp。
2、将当前的栈顶地址%rsp,作为新的栈帧的起始地址%rbp。
3、移动%rsp,分配内存。
4、以基址加偏移量的方式存储变量var1。
gdb调试看到的内存分布如下图,注意rbp和var1的位置关系。
· 调用foo()后的情况
foo的调用有如下几个关键步骤
pushq %rbp 将父函数即main的栈帧指针压栈
movq %rsp, %rbp 将栈顶地址%rsp作为栈帧地址%rbp
movl %edi, -4(%rbp) 根据约定,%edi表示第一个 参数,即para1,同样以基址加变址的方式,将其保存。
概括之就是:
1、保存上main的栈帧地址%rbp。
2、将当前栈的地址%rsp,作为栈帧的地址%rbp。
3、同样以基址加偏移量的方式保存参数。
4、另外还有个比较隐蔽的点是,在进入foo之前,call指令会将call的下一条指令地址压栈,以便函数返回时,能够在函数的下一条指令上继续执行。
这个部分的内存分布如下图,可以看到,在main和foo的栈帧之间,保存了恢复main执行所需要的栈帧地址和程序计数器地址。
这里有个地方比较奇怪,就是foo的栈帧并没有分配空间,且变量para1存在了栈帧之外,这个后面会讲。
·foo()退出时的情况
有如下几个关键步骤:
popq %rbp 将main函数的栈帧地址恢复到%rbp
ret 将程序计数器恢复到call foo的下一条指令的地址
步骤比较简单,直接看图,可以看到,rbp又恢复到了main执行时的状态。
·foo()退出后的情况
movl %eax, -8(%rbp) 将foo()的结果,即var2,赋值到-8(%rbp)的位置
movl -4(%rbp), %edx 将var1取出来赋值给%edx
如图所示:对var1和var2的访问仍旧是是通过对rbp的基址加偏移量寻址来实现的,所以说rbp的保存和恢复很重要。
由此可见:汇编在函数调用时,会使用rbp和rsp在栈上标定一个范围空间。对于函数中的变量和参数,会通过基址(rbp)加偏移量的方式进行读写。函数的进入和退出时,会保存和恢复父函数的上下文,这个上下文包括两个部分:
1、程序计数器:通过对%rip的压栈和出栈来实现。这个操作包含在call和ret的内部。
2、函数栈帧的基址:通过对%rbp的压栈和出栈实现。
这块最好自己亲自动手调试来验证一遍,印象会更深刻。
之前的问题:
foo函数的参数为什么放在了栈帧之外?这个查资料可能是x86-64的“红色区域”特性导致的,因为foo函数是叶子函数,所以可以用栈顶之后的128字节来存放临时的数据。
在%rsp指向的栈顶之后的128字节是被保留的——它不能被信号和终端处理程序使用。因此,函数可以在这个区域放一些临时的数据。特别地,叶子函数可能会将这128字节的区域作为它的整个栈帧,而不是像往常一样在进入函数和离开时靠移动栈指针获取栈帧和释放栈帧。这128字节被称作红色区域。
三、总结:
栈帧是栈的一个部分,它在函数调用过程中,起到一个划定函数所用到的内存区域的作用,函数访问的临时变量和参数就包含在这个区域之间。栈帧是一个虚拟的概念,真正干活的是rbp,rbp指向了当前栈帧的基址,访问函数内的临时变量和参数是通过以rbp作为基址加变址的方式进行的。所以父子函数调用时,内存部分的上下文的切换是通过将rbp压栈和退栈来实现的。指令地址的上下文是通过rip的压栈和退栈来实现的。不得不说:栈这种数据结构真是强大。