在阅读本文之前,请先阅读 x86_64汇编之四:函数调用、调用约定
之前提到了,System V AMD64调用约定是x86_64 Linux系统上使用最广泛的调用约定,gcc/g++
等编译器都默认使用该调用约定。
凡是遵循System V AMD64调用约定的汇编代码,都有一定的固定模式,比如函数开头做哪些处理,函数末尾做哪些处理等等。本文将会分析caller和callee进行函数调用时的通用模板。
请结合https://godbolt.org/z/x8rIGD中的汇编代码阅读本文,效果更佳。
caller在调用callee之前,通常有以下操作:
步骤1:设置参数到寄存器/栈中
mov xxx, %rdi # 第一个参数
mov xxx, %rsi # 第二个参数
mov xxx, %rdx # 第三个参数
...
步骤2:调用函数
call func # 调用函数 (当前指令寄存器地址入栈 + 执行权调转到func函数的起始地址)
该步骤要特别留意call
指令将当前指令寄存器的地址压入栈中了。该步骤执行完后栈指针的状态如下:
步骤3:callee返回,获取返回值
mov %rax, %rcx # 整型返回值存在%rax中
inc %rcx
...
从相应的寄存器中取返回值并使用
步骤1:保存caller的%rbp
, 设置%rbp
指向%rsp
pushq %rbp
movq %rsp, %rbp
保存caller的%rbp
是为了在callee返回之后可以恢复caller的栈帧状态。
执行pushq %rbp
后栈指针的状态如下:
然后执行movq
指令将%rbp
设置为当前的%rsp
对应的位置(如上图所示)。
步骤2:减小%rsp
的值,为参数和局部变量腾出空间,然后设置参数和局部变量
# 缩减%rsp, 为参数和局部变量腾出空间
subq $40, %rsp
# 下面将寄存器中的参数放入栈中
mov %rdi, -24(%rbp) # 第一个参数,占8字节
mov %rsi, -32(%rbp) # 第二个参数,占8字节
...
将寄存器中的函数参数放入栈的过程中,前面的参数放在高地址,后面的参数放在低地址。因此从低地址开始看,参数是从右到左入栈的。
注意:如果callee是调用链中最后一个函数,那么它可以不修改
%rsp
指针,也能实现正确的函数调用,结合步骤3理解。
步骤1和步骤2修改了%rbp, %rsp
的内容,回顾一下上一篇博客,%rbp, %rsp
是由callee保存的寄存器,也就是由callee负责保存和恢复。前面两个步骤我们对它们作出了修改,后面势必要对恢复它俩原来的值,这也是下面步骤4的内容。
步骤3:保存返回值到%rax/%xmm0
寄存器
步骤4:恢复%rbp, %rsp
的值
恢复它俩的值分两种情况,callee是调用链中的最后一个函数 以及 callee不是调用链中的最后一个函数。
(1) 如果callee是调用链中的最后一个函数 (也就是它没再调用其他函数了),那么首先它不会也没必要修改%rsp
指针。正因为它没修改%rsp
指针,所以其恢复%rbp, %rsp
的代码如下:
popq %rbp
ret
因为它没修改%rsp
指针,所以%rsp
和%rbp
指向同一个地址,该地址存放的是caller的%rbp
,所以popq
就是将之前栈中保存的caller的%rbp
再次设置到%rbp
中,并且%rsp
减去8个字节。减去8个字节后的%rsp
指向的地址存放的是返回地址,因此调用ret
指令可以返回到caller函数,并且%rsp
再减去8。(结合上面的图理解)
(2) 如果callee不是调用链中的最后一个函数,那么它恢复%rbp, %rsp
的代码如下:
leave
retq
leave指令是步骤1的逆操作,它将%rbp
的值复制到%rsp
,然后恢caller的%rbp
的值到%rbp
中,同时%rsp
减去8个字节。然后,ret
指令的操作和(1)一样。(结合上面的图理解)
相信经过上面的分析,相信已经可以对System V AMD64调用约定下的函数调用过程有了一个详细的认识,可以自己编写一段C代码然后用gcc -S
命令进行编译获取对应的汇编代码并进行分析。