x86-64函数调用及栈帧原理
函数的调用
子函数调用时,调用者与被调用者的栈帧结构如下图所示:
在子函数调用时,执行的操作有:
- 父函数将调用参数从后向前压栈 ->
- 将返回地址压栈保存 ->
- 跳转到子函数起始地址执行 ->
- 子函数将父函数栈帧起始地址(%rpb) 压栈 ->
- 将 %rbp 的值设置为当前 %rsp 的值,即将 %rbp 指向子函数栈帧的起始地址。
上述过程中,保存返回地址和跳转到子函数处执行由 call 一条指令完成,在call 指令执行完成时,已经进入了子程序中,因而将上一栈帧%rbp 压栈的操作,需要由子程序来完成。函数调用时在汇编层面的指令序列如下:
... # 参数压栈
call FUNC # 将返回地址压栈,并跳转到子函数 FUNC 处执行
... # 函数调用的返回位置
FUNC: # 子函数入口
pushq %rbp # 保存旧的帧指针,相当于创建新的栈帧
movq %rsp, %rbp # 让 %rbp 指向新栈帧的起始位置
subq $N, %rsp # 在新栈帧中预留一些空位,供子程序使用,用 (%rsp+K) 或 (%rbp-K) 的形式引用空位
保存返回地址和保存上一栈帧的%rbp 都是为了函数返回时,恢复父函数的栈帧结构。在使用高级语言进行函数调用时,由编译器自动完成上述整个流程。对于”Caller Save” 和 “Callee Save” 寄存器的保存和恢复,也都是由编译器自动完成的。
需要注意的是,父函数中进行参数压栈时,顺序是从后向前进行的。但是,这一行为并不是固定的,是依赖于编译器的具体实现的,在gcc 中,使用的是从后向前的压栈方式,这种方式便于支持类似于 printf(“%d, %d”, i, j) 这样的使用变长参数的函数调用。
通过移动 %rsp 指针来改变帧的大小。%rbp 和 %rsp 之间的空间就是当前栈帧。而过程调用和退出过程,分别使用 call 指令和 ret 指令。“callq _fun1”是调用 _fun1 过程,这个指令相当于下面两句代码,它用到了栈来保存返回地址:
pushq %rip # 保存下一条指令的地址,用于函数返回继续执行
jmp _fun1 # 跳转到函数 _fun1
_fun1 函数用 ret 指令返回,它相当于:
popq %rip # 恢复指令指针寄存器
jmp %rip
函数的返回
函数返回时,我们只需要得到函数的返回值(保存在 %rax 中),之后就需要将栈的结构恢复到函数调用之前的状态,并跳转到父函数的返回地址处继续执行。由于函数调用时已经保存了返回地址和父函数栈帧的起始地址,要恢复到子函数调用之前的父栈帧,我们只需要执行以下两条指令:
movq %rbp, %rsp # 使 %rsp 和 %rbp 指向同一位置,即子栈帧的起始处
popq %rbp # 将栈中保存的父栈帧的 %rbp 的值赋值给 %rbp,并且 %rsp 上移一个位置指向父栈帧的结尾处
为了便于栈帧恢复,x86-64 架构中提供了 leave 指令来实现上述两条命令的功能。执行 leave 后,前面图中函数调用的栈帧结构如下:
可以看出,调用 leave 后,%rsp 指向的正好是返回地址,x86-64 提供的 ret 指令,其作用就是从当前 %rsp 指向的位置(即栈顶)弹出数据,并跳转到此数据代表的地址处,在leave 执行后,%rsp 指向的正好是返回地址,因而 ret 的作用就是把 %rsp 上移一个位置,并跳转到返回地址执行。可以看出,leave 指令用于恢复父函数的栈帧,ret 用于跳转到返回地址处,leave 和ret 配合共同完成了子函数的返回。当执行完成 ret 后,%rsp 指向的是父栈帧的结尾处,父栈帧尾部存储的调用参数由编译器自动释放。
#include <stdio.h>
int fun1(int x1, int x2, int x3, int x4, int x5, int x6, int x7, int x8){
int c = 10;
return x1 + x2 + x3 + x4 + x5 + x6 + x7 + x8 + c;
};
int main(int argc, char* argv[]){
printf("fun1: %d\n", fun1(1,2,3,4,5,6,7,8));
return 0;
}
使用MinGW gcc 11.3.0 编译后的汇编如下:
fun1(int, int, int, int, int, int, int, int):
# 函数调用的序曲, 设置栈指针
pushq %rbp # 把调用者的栈帧底部地址保存起来
movq %rsp, %rbp # 把调用者的栈帧顶部地址, 设置为本栈帧的底部
subq $16, %rsp
movl %ecx, 16(%rbp)
movl %edx, 24(%rbp)
movl %r8d, 32(%rbp)
movl %r9d, 40(%rbp)
movl $10, -4(%rbp) # 变量 c 赋值为 10, 也可以写成 movl $10, (%rsp)
movl 16(%rbp), %edx
movl 24(%rbp), %eax
addl %eax, %edx
movl 32(%rbp), %eax
addl %eax, %edx
movl 40(%rbp), %eax
addl %eax, %edx
movl 48(%rbp), %eax
addl %eax, %edx
movl 56(%rbp), %eax
addl %eax, %edx
movl 64(%rbp), %eax
addl %eax, %edx
movl 72(%rbp), %eax
addl %eax, %edx
movl -4(%rbp), %eax # 加上 c 的值
addl %edx, %eax
addq $16, %rsp
# 函数调用的尾声, 恢复栈指针为原来的值
popq %rbp # 恢复调用者栈帧的底部数值
ret # 返回
.LC0:
.ascii "fun1: %d\12\0"
main:
# 函数调用的序曲, 设置栈指针
pushq %rbp # 把调用者的栈帧底部地址保存起来
movq %rsp, %rbp # 把调用者的栈帧顶部地址, 设置为本栈帧的底部
subq $64, %rsp # 这里是为了让栈帧 16 字节对齐,实际使用可以更少
movl %ecx, 16(%rbp)
movq %rdx, 24(%rbp)
call __main
movl $8, 56(%rsp) # 参数 8
movl $7, 48(%rsp) # 参数 7
movl $6, 40(%rsp) # 参数 6
movl $5, 32(%rsp) # 参数 5
movl $4, %r9d # 参数 4
movl $3, %r8d # 参数 3
movl $2, %edx # 参数 2
movl $1, %ecx # 参数 1
call fun1(int, int, int, int, int, int, int, int) # 调用函数
movl %eax, %edx
leaq .LC0(%rip), %rax # 第一个参数是字符串的地址
movq %rax, %rcx # 第二个参数是前一个参数的返回值
call printf # 调用函数
movl $0, %eax # 设置返回值。这句也常用 xorl %esi, %esi 这样的指令, 都是置为零
addq $64, %rsp # 缩小栈
# 函数调用的尾声, 恢复栈指针为原来的值
popq %rbp # 恢复调用者栈帧的底部数值
ret # 返回