从汇编指令看函数调用堆栈的详细过程(详细图解)
从汇编指令看函数调用堆栈的详细过程(详细图解)
本文通过一个简单的C++函数调用示例,详细解析了函数调用堆栈的底层实现机制。从汇编指令层面展示了参数传递、堆栈空间分配、函数返回等关键步骤,帮助读者深入理解计算机系统的工作原理。
实验准备
我们以下述代码为例来说明函数调用堆栈的详细过程
int sum(int a, int b)
{
int temp = 0;
temp = a + b;
return temp;
}
int main()
{
int a = 10;
int b = 20;
int ret = sum(a, b);
return 0;
}
将上述代码进行汇编输出
g++ -S ./main.cc -o main.S
得到的汇编文件内容为
.file "main.cc"
.text
.globl _Z3sumii
.type _Z3sumii, @function
_Z3sumii:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl %edi, -20(%rbp)
movl %esi, -24(%rbp)
movl $0, -4(%rbp)
movl -20(%rbp), %edx
movl -24(%rbp), %eax
addl %edx, %eax
movl %eax, -4(%rbp)
movl -4(%rbp), %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size _Z3sumii, .-_Z3sumii
.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
movl $10, -12(%rbp)
movl $20, -8(%rbp)
movl -8(%rbp), %edx
movl -12(%rbp), %eax
movl %edx, %esi
movl %eax, %edi
call _Z3sumii
movl %eax, -4(%rbp)
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE1:
.size main, .-main
.ident "GCC: (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0"
.section .note.GNU-stack,"",@progbits
为方便我们观察,去除掉与链接有关的内容,最终得到的汇编文件为
_Z3sumii:
pushq %rbp
movq %rsp, %rbp
movl %edi, -20(%rbp)
movl %esi, -24(%rbp)
movl $0, -4(%rbp)
movl -20(%rbp), %edx
movl -24(%rbp), %eax
addl %edx, %eax
movl %eax, -4(%rbp)
movl -4(%rbp), %eax
popq %rbp
ret
main:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl $10, -12(%rbp)
movl $20, -8(%rbp)
movl -8(%rbp), %edx
movl -12(%rbp), %eax
movl %edx, %esi
movl %eax, %edi
call _Z3sumii
movl %eax, -4(%rbp)
movl $0, %eax
leave
ret
再次对上述汇编内容进行分析
我们在代码中定义的sum函数对应的汇编内容为
_Z3sumii:
pushq %rbp
movq %rsp, %rbp
movl %edi, -20(%rbp)
movl %esi, -24(%rbp)
movl $0, -4(%rbp)
movl -20(%rbp), %edx
movl -24(%rbp), %eax
addl %edx, %eax
movl %eax, -4(%rbp)
movl -4(%rbp), %eax
popq %rbp
ret
其中_Z3sumii是编译器对sum函数设置的标号,具体设置规则可参考《深入理解计算机系统》第七章的内容,这里仅说明标号是为了方便汇编代码的书写,它代表了一个内存地址
同理main函数对应汇编文件内容为
main:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl $10, -12(%rbp)
movl $20, -8(%rbp)
movl -8(%rbp), %edx
movl -12(%rbp), %eax
movl %edx, %esi
movl %eax, %edi
call _Z3sumii
movl %eax, -4(%rbp)
movl $0, %eax
leave
ret
接下来,我们逐行分析代码的执行
main函数汇编分析
为局部变量分配空间
首先,当代码执行到main函数后,会为main函数开辟栈帧空间,需要说明的是
- 栈这个数据结构需要两个指针来维护,分别是栈底指针和栈顶指针
- 在x86结构里,负责存放栈底指针的寄存器是rbp,负责存放栈顶指针的寄存器是rsp
- 栈空间是向下增长的,故内存地址随着栈帧的开辟会减小
- 64位操作系统下一个内存单元占8个字节
- rip寄存器负责取出指令,并交由控制器执行指令
- 默认情况下rip寄存器是按照汇编代码的编写顺序一条一条向下执行的
我们首先看以下这段代码
subq $16, %rsp ;将rsp指针向下一定16个字节
movl $10, -12(%rbp) ;将10赋值给rbp-12的内存位置
movl $20, -8(%rbp) ;将20赋值给rbp-8的内存位置
首先是subq $16, %rsp对应的执行过程,即为main函数的两个局部变量开辟内存空间,由于在64位操作系统下,一个内存单元的大小是8个字节,因此rsp向下移动16个字节的位置,相当于rsp指针向下移动了两个内存单元的位置
接下来是movl $10, -12(%rbp)的运行示意,将数字10存放到rbp-12所指向的内存位置
同理,将数字20存放到rbp-8的内存位置
实参传递准备
以下这段代码的实现功能是将实参10和20分别放到esi和edi寄存器中,以便将来sum函数从中取出对应的参数
可见,函数的传参是通过寄存器实现的
movl -8(%rbp), %edx ;将rbp-8的内存位置的数值赋值给edx寄存器
movl -12(%rbp), %eax ;将rbp-12的内存位置的数值赋值给eax寄存器
movl %edx, %esi ;将edx中的数值赋值给esi寄存器
movl %eax, %edi ;将eax中的数值赋值给edi寄存器
具体过程如下所示
函数调用
参数准备
call _Z3sumii
对call指令的几点说明
call指令其实是对rip寄存器的修改和使用,由于rip寄存器不能被用户使用和修改(比较rip寄存器负责取出指令交给控制器执行,其重要性不言而喻),因此编译器封装了call指令让用户来间接使用rip寄存器
那么call指令做了哪些事情呢?
- 将call指令的下一条指令push到栈中
- 修改rip寄存器的值为_Z3sumii
示意图如下
因此,所谓函数调用的过程,其实是通过call指令修改代码执行流的过程,而被调用函数的返回地址其实就是call指令的下一条指令
sum函数汇编分析
至此,由于rip寄存器中存放的是sum函数的地址,因此接下来将正式对sum函数执行汇编代码
同样的,rip寄存器会按照顺序一条一条的去处指令进行执行
函数调用的堆栈框架开辟
pushq %rbp
movq %rsp, %rbp
我们首先来看这两行代码,这两行代码其实是一个堆栈调用框架,类似与cc文件里的"{"
前边我们说过,函数堆栈空间的开辟需要有栈底指针rbp和栈顶指针rsp,我们需要这两个寄存器来获取栈里的数据,但是现在rip已经开始要执行sum函数中的代码了,因此rbp和rsp也要修改为sum函数对应栈帧空间,但是为了将来sum函数执行结束后rbp和rsp能恢复到main函数的栈帧空间,需要将rbp的值进行保存,
假如原来的rbp寄存器中的值为0x08,则其执行图如下所示
对原来的rbp进行保存之后,就开始重置sum的栈帧空间,如下所示
sum函数内代码的执行
形参传递
movl %edi, -20(%rbp)
movl %esi, -24(%rbp)
在之前编译器已经将要传递给sum函数的实参拷贝到了esi和edi寄存器中,接下来sum函数要用到这两个参数,因此就要从这两个寄存器中取出参数值放到栈帧空间中,如下图所示
函数内部执行
为局部变量temp分配栈帧空间并赋值
movl $0, -4(%rbp)
执行加法操作
movl -20(%rbp), %edx
movl -24(%rbp), %eax
addl %edx, %eax
addl命令会将edx的值与eax相加,并将结果存放到eax中,因此eax中最后是求和后的结果
接下来,编译器首先将求和结果放到栈帧空间中,然后再放到eax寄存器中
注意,这次将求和结果放到eax中的意义是,将返回结果(求和结果30)放到eax中,就像当初使用寄存器传参一样,函数的返回值也要放到寄存器中供调用函数取得
至于为什么不直接将值进行返回,而是先放到栈空间,再拿出来放到eax,这是编译器内部的实现
movl %eax, -4(%rbp);将求和结果放到栈帧空间中
movl -4(%rbp), %eax;将值放到eax寄存器中,main函数返回时取
函数堆栈的退出
popq %rbp
ret
此过程是当sum函数执行结束之后,恢复调用者(在这里是main函数)的栈帧空间
ret指令对应的是call指令,即弹出原先调用call指令时压栈在内存中的返回地址(call指令的下一条指令的地址),同时置rip为该返回指令
最后main函数最后的这条指令就是从eax寄存器中取出sum函数的返回值了
至此,整个的函数调用的堆栈过程就结束了
至于main最后的leave指令,想必大家也能猜出来,它就是popq %rbp的另外一种写法,用于恢复调用者函数的堆栈指针
是的,main函数其实也是被调用的,只不过是由系统进行调用的罢了
其余说明
- 函数调用时的参数传递使用寄存器进行传参,返回时也使用的是寄存器将返回值进行保存,由调用者进行获取,使用寄存器传参的原因是当参数量少的时候使用寄存器效率会高一点
- 然而最多只能有6个寄存器传参,再多的参数就要使用栈帧本身进行传参了,具体细节可参考《深入理解计算机系统》第三章