利用汇编分析C语言函数栈帧
我们通过把一段具有函数调用的C语言代码编译成汇编,逐步分析函数栈帧生命周期的完整变化过程。
有如下C语言代码:
1 | long callee(long arg1, long arg2, long arg3, long arg4, long arg5, long arg6, long arg7, long arg8) { |
代码中callee
函数有8个参数分别是arg1~arg8
,它返回arg7
和arg8
相加后的结果。由于我们的代码是运行在X64
的机器上,所以arg1~arg6
会通过寄存器来传递,arg7
和arg8
通过栈来传递。main
中定义了两个局部变量a
和b
,它对callee
发起调用,局部变量a,b
分别对应callee
函数的arg7,arg8
。
将上面C语言代码编译成汇编,把由编译器产生的其他与我们分析函数栈帧不相关的指令删除,得到如下指令(根据编译器版本以及操作系统的不同编译出的汇编指令会有所差异,这里使用的编译器和操作系统分别是gcc9.0
和ubuntu 20.04 x-64
):
1 | callee: |
指令中的callee:
和main:
表示的是为它下面的指令取一个名字,可以理解为函数名。
在X64
中栈指针
和栈基址
分别叫rsp,rbp
。
我们逐步分析这些指令对堆栈的影响,首先是main
的第前两行指令:
1 | pushq %rbp |
实际上main
函数是被系统内一个叫做_start
的函数所调用,所以第1条指令将调用者
_start的栈基址
压栈保存,第2条指令移动栈基址
使它指向和栈指针
同样的位置。
栈变化过程(在一开始我们栈指针
和栈基址
指向_start
的栈帧,这里没有画出_start
的栈帧也就没有标注它们):
初始化局部变量并准备callee
的后两个参数:
1 | subq $16, %rsp |
第1条指令中的subq
是减法指令,这里用于把栈指针
减去16,使它向低地址移动来给局部变量a,b
分配16字节的空间(long占8个字节)。
第2~3条指令中的movq
是数据移动指令,在这里它通过对栈基址
进行适当偏移来对a,b
赋值。例如movq $7, -16(%rbp)
表示把7放到相对于栈基址
-16的内存中。下图中可以看到这两条指令执行完以后-16
和-8
的位置刚好放置了变量a,b
。
第4~5条指令把栈帧中a,b
的值复制一份然后把它们压栈,这是为了准备函数callee
的后2个参数arg7
与arg8
。
栈变化过程(图中右侧的数字表示基于栈基址
的偏移量,每个格子8字节,由于栈空间是向低地址发展的,所以相对于栈基址
上面内存的偏移量是负的):
把callee
的前6个参数arg1~arg6
存放在寄存器中:
1 | movl $6, %r9d |
开始调用callee
:
1 | call callee |
这条指令对应了两个操作,首先将它下面一条指令的地址也就是addq $16, %rsp
的地址(返回地址)压入栈中,随后修改程序计数器
(PC)为callee
的第一条指令的地址,最后CPU就从callee
处开始执行。
这条指令执行后的栈:
现在CPU从callee
开始执行:
1 | pushq %rbp |
先压入main
栈帧的栈基址
,然后移动栈基址
使它指向和栈指针
同样的位置。此时产生了callee
的栈帧:
第2条指令使栈基址
发生了改变,相应的右图中基于栈基址
的偏移量也需要发生变化(由于栈空间是向低地址发展的,所以相对于栈基址
下面内存的偏移量是正的):
复制前6个参数到栈帧中:
1 | movq %rdi, -8(%rbp) |
这6个寄存器rdi,rsi,rdx,rcx,r8,r9
分别存放了callee
的前6个参数,现在将它们从寄存器中复制到callee
自己的栈帧中。我对这个操作有点迷,为啥还需要复制呢,直接从寄存器中取出来使用不就行了吗?我想可能是为了腾出寄存器吧。
执行之后的栈结构:
执行到这里奇怪的事情发生了,栈指针
并没有继续向低地址移动,让它指向栈顶的位置。其实这里是编译器的优化,栈指针
的目的是为了确定被调用函数的栈基址
,由于callee
没有再继续调用其他任何函数,因此也无需修改栈指针
。
执行相加:
1 | movq 16(%rbp), %rdx |
将arg7
与arg8
相加,然后把结果放在rdx
寄存器,mian
函数可以访问rdx
寄存器来获取返回值,以此来达到函数返回值传递的目的。
返回到main
:
1 | popq %rbp |
恢复栈基址
并跳转到返回地址处开始继续执行。
栈变化过程:
返回到_start
:
1 | addq $16, %rsp |
第1条指令中的addq
是加法指令,这里用于把栈指针
加上16,使它向高地址移动以此来释放arg7,arg8
的内存。通过这一步可以发现,函数执行完后会立马释放参数的栈内存。
第2条指令把main
的返回值0放入到eax
寄存器中。
第3条指令leave
隐含执行了两个操作,它等价与下面两条指令:
1 | movq %rbp, %rsp |
先修改栈指针
使它与栈基址
指向同样的位置,这一步主要用于释放局部空间,然后恢复栈基址
。此时栈指针
和栈基址
就分别指向了_start
的栈帧。
到这里我们发现main
和callee
的返回过程不一样,这是由于在callee
一开始没有修改栈基址
,所以返回的时候也就不需要再对它进行恢复。由此我们可以得出一个结论:如果某个函数调用了其他函数和没有调用其他函数,它们在返回时的过程有略微的不一致。
第4条指令ret
使CPU返回到_start
中去执行,最后一切又恢复了平静。
栈变化过程:
最后我们发现数据仍然还保存在栈中,因为释放栈空间并不会修改里面的数据,只是对栈指针
和栈基址
做了移动。