x86-64 C Calling Convention
ASM层面的例程调用
在x86-64中,指令集本身提供了用于实现子例程调用(函数调用)的一些指令。其它指令集架构,如risc-v、arm,也都提供了这些指令。
x86-64以4条核心指令提供了一个调用栈的模型,以实现子例程调用。
push指令
语法
- push
- push
- push
语义
push
指令将它的操作数放在内存中硬件支持栈的顶端。具体地说,它首先将RSP减少8,然后将它的操作数放到在地址[RSP]
处的64位内容区。RSP(栈指针)被push递减,因为x86中栈向下增长。
Examples:
push rax 将rax中的内容压到栈上
push [var] 将在地址`var`上的8个字节放到栈上
pop指令
语法
- pop
- pop
语义
pop
指令从硬件支持栈上移除8字节数据并放到指定的操作数上(比如寄存器或内存位置)。具体地说,pop首先移动在内存位置[RSP]
上的8字节到特定的寄存器或内存位置上,然后将SP增加8。
Examples:
push rdi 将栈顶元素弹出到RDI
push [rbx] 将栈顶元素弹出到
call指令
语法
- call
语义
这个指令实现了一个子例程调用,它与子例程返回指令
ret
协作。这个指令首先将当前代码位置push到硬件支持栈上,然后执行一个非条件的跳转到label操作数指定的代码位置。该指令添加的值用于保存当子例程完成时返回的位置。
Examples:
call my_subroutine 跳转到'my_subroutine'标签,将当前的返回地址push到栈上
ret指令
语法
- ret
语义
在于call指令的协作中,ret指令实现了一个子例程返回机制。这个指令首先从硬件支持栈中弹出一个代码位置,然后执行一个非条件跳转到获取到的代码位置。
Examples:
ret 返回到栈顶的代码位置
最小call-ret示例
section .text
global _start
_start:
mov rbx, 4 ; 将4存到rbx
call subroutine ; 调用子例程
mov rax, 60 ; exit系统调用号
mov rdi, rbx ; 系统调用参数为rbx中的值,我们可以推断该进程的退出状态码肯定是14
syscall ; 执行系统调用
global subroutine
subroutine:
add rbx, 10 ; 将rbx中的值加10,这次就是14了
ret ; 返回到_start
➜ ✗ yasm -f elf64 subroutine.asm
# 链接可执行文件,-export-dynamic是保存符号信息,方便我们后面gdb
➜ ✗ ld -export-dynamic -s -o subroutine subroutine.o
# 执行程序并输出该程序的返回状态
➜ ✗ ./subroutine; echo $?
14
gdb调试,目前我们已经进入到_start
的第一条指令。
目前,我们的栈顶内容还是1
直到call发生,栈顶指针发生了变化,向下减少了8,并且栈顶指针上的值变成了0x0040100c,就是_start
中call
的下一条指令:
至此可以说明,call按照上面的语义执行了。
如果按照ret的语义,那么,执行完ret,rsp的值应该恢复成0x7fffffffda60
,并且指令流会跳回0x40100c
的位置。
如果我在ret前往栈顶放点东西?
嘶,从call
和ret
的语义来看,如果我想它们正常协作,那么在一个例程里,我push了多少次,就得有多少次对应的pop,否则ret
弹出栈顶作为例程的返回地址时会拿到错误的返回地址,也就回不到调用者原来的位置!
假如,我把subroutine
修改成这样......
subroutine:
add rbx, 10
push subroutine ; 把subroutine的地址放到栈顶
ret
那不是死循环了吗?
果然,它不动了......
如果你用gdb调试它,会发现我们一直在subroutine的三条指令中来回跳转,很有趣。
什么是Calling Convention
上面我们大概已经知道了处理器指令集架构提供的基于硬件支持栈的例程调用模型,但是,它和高级语言中的函数、方法等概念还有一些区别。如果想用这个实现函数调用,至少还要解决一些问题:
- 参数如何传递给一个子例程?
- 子例程可以覆盖寄存器中的值吗?
- 调用者希望寄存器中的内容得到保持吗?
- 子例程中的本地变量在哪里保存?
- 返回值如何保存?
对于参数和返回值的保存,基于上面的栈模型,我们大概可以想到在例程相互调用的场景下,怎样进行传值。我们可以使用有限的寄存器和稍大一些的栈空间来保存。
我们仍有很多问题,一个例程可以清楚的知道自己使用了哪些寄存器,但是它不知道在调用链中处于上游的其它人使用了哪些寄存器,所以它能否轻易的决定覆盖一个寄存器呢?
对于几个例程之间的调用,我们当然可以约定出一套规范,编写它们时都遵循这一套规范来回答上面的问题,但若想所有库之间都能共同工作,一套更大的规范是很有必要的。C编译器在编译C代码时会按照一种规范来回答上面的问题,而这个规范就是Calling Convention,这样,所有用这套规范编译出来的代码就能协同工作了。
C在x86上的Calling Convention将一次调用的发起者(Caller)和被调用的目标例程(Callee)分开看待,利用寄存器和栈协作实现规范。对于任何语言和任何架构几乎都是一样的。
Caller
- caller需要向被callee传参,传参会利用到6个寄存器(rdi,rsi,rdx,rcx,r8,r9),就像开始的代码演示里的那样,caller将参数写入到一个寄存器,callee读取这个寄存器。子例程有可能覆盖这些寄存器,因为它也可能作为caller调用其它例程,所以,caller若希望后续还要用到这些寄存器,它需要自己在栈上保存这6个寄存器的值,外加r10和r11。rdi, rsi, rdx, rcx, r8, r9, r10, r11共同被称作caller-saved寄存器
- 如果一次函数调用的参数多于6个,将多出的反向保存在栈上,由于栈反向增长,所以第一个这样的额外参数被存在最低位置
call
函数会将返回地址(也就是call的下一条指令的地址)压入栈顶- 当方法调用返回,caller需要将栈上保存的额外参数弹出
- caller可以从rax寄存器中找到子例程的返回结果
- caller从栈上弹出并恢复所有caller-saved寄存器的值,并且可以假设其它寄存器都没被修改,加上刚刚恢复的caller-saved,就可以当作全部寄存器都没被修改
总结一下
- 保存所有caller-saved寄存器
- 向6个参数寄存器中写入参数
- 倒序push额外参数
- call(隐式push返回地址)
- pop所有额外参数
- 通过rax读取返回值
- pop&restore caller-saved寄存器
Caller实践
有下面这样一段代码:
void sum(int arg1, int arg2,
int arg3, int arg4,
int arg5, int arg6,
int arg7) {
printf("sum is => %d\n", arg1 + arg2 + arg3 + arg4 + arg5 + arg6 + arg7);
}
它接收七个参数,将它们相加并将结果打印。
有这样一个汇编代码框架:
section .text
global _start
extern sum
_start:
call sum ; call sum
; 下面的代码是为了让程序正常退出,不用管下面的代码
mov rax, 60 ; system call for exit
mov rdi, 0 ; exit code 0
syscall ; invoke operating system to exit
现在我们用gcc编译出sum的二进制文件。
gcc -g -c sum.c -o sum.o
我们的目标是按照C Calling Convention来改写汇编代码以实现sum
方法的调用,稍后我们会汇编代码,并使用如下命令将它和sum.o链接。
yasm -f elf64 func_call1.asm -o func_call1.o
ld -o func_call1 -dynamic-linker /lib64/ld-linux-x86-64.so.2 -lc func_call1.o sum.o /lib/x86_64-linux-gnu/libc.so.6
直接执行肯定是不行的,每次显示的数都是随机的,这取决于那些寄存器里当时的内容:
修改_start
:
_start:
sub rsp, 8 ; x86-64栈对齐
mov rdi, 1 ; 将前六个参数存到指定寄存器中
mov rsi, 2
mov rdx, 3
mov rcx, 4
mov r8, 5
mov r9, 6
push 7 ; 第七个额外参数 入栈
call sum ; call sum
pop rax ; 弹出栈额外参数到rax,反正我们也不用
mov rax, 60 ; system call for exit
mov rdi, 0 ; exit code 0
syscall ; invoke operating system to exit
程序正常运行:
这里我们还是省略了相当多的步骤,比如我们都没保存caller-saved寄存器,也没恢复,因为我们知道_start
就是系统启动的第一个函数,并且执行完立即通过exit
系统调用退出。
我不知道我这里说的有没有问题,关于这些内容,我还是个小垃圾。有说错的欢迎指正。
Callee
- 通过使用寄存器或在栈上分配空间来分配本地变量(通过sub命令递减rsp寄存器)
- 保存所有函数中用到的
callee-saved
寄存器中的值,将它们压到栈上(rbx, rbp, r12到r15。rsp也需要被保留,但在这一步中不需要被push到栈上) - 当函数终止,返回值应该被放到rax中
- 函数必须恢复任何被修改的callee-saved寄存器的旧值。通过弹出栈来恢复内容
- 然后我们归还本地变量的空间。最简单的办法就是递增rsp(第一步的反向操作)
- 最后通过执行
ret
指令返回到caller,这个指令将找到并移除栈上的返回地址
sum:
; prologue,目的是把rsp的值给到rbp
pushq %rbp
movq %rsp, %rbp
; 申请本地变量空间 7个int类型变量应该是28,但为了16字节对齐必须再多4
subq $32, %rsp
; 从caller-saved的参数寄存器中读取内容到本地变量
; 本地变量被存在了栈上,注意这里只有6个变量,因为只有6个参数通过寄存器传入
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl %edx, -12(%rbp)
movl %ecx, -16(%rbp)
movl %r8d, -20(%rbp)
movl %r9d, -24(%rbp)
; 将栈上刚刚保存的东西倒回寄存器,并在rax(eax)上求和
movl -4(%rbp), %edx
movl -8(%rbp), %eax
addl %eax, %edx
movl -12(%rbp), %eax
addl %eax, %edx
movl -16(%rbp), %eax
addl %eax, %edx
movl -20(%rbp), %eax
addl %eax, %edx
movl -24(%rbp), %eax
addl %eax, %edx
; 有一个参数是通过栈传递的,这里取那个,并继续求和
movl 16(%rbp), %eax
addl %edx, %eax
; 调用printf
movl %eax, %esi
leaq .LC0(%rip), %rax
movq %rax, %rdi
movl $0, %eax
call printf@PLT
ret
问题解决
链接两个文件后执行,显示No such file or directory
通过readelf
查看正常运行的二进制文件的解释器:
➜ p4 readelf -l peterson | grep interpreter [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
查看不能正常运行的二进制文件的解释器:
➜ examples git:(main) ✗ readelf -l a.out |grep interpreter [Requesting program interpreter: /lib/ld64.so.1]
/lib/ld64.so.1
好像是ld默认的行为,但是我们的系统中没有这个解释器。需要在链接时通过-dynamic-linker
指定解释器。
详情查看:Can't run executable linked with libc - Stack Overflow