x86_64汇编系列:
- x86_64汇编之一:AT&T汇编语法
- x86_64汇编之二:x86_64的基本架构(寄存器、寻址模式、指令集概览)
- x86_64汇编之三:x86_64汇编和x86_32汇编的区别
- x86_64汇编之四:函数调用、调用约定
- x86_64汇编之五:System V AMD64调用约定下的函数调用
一、栈
栈一般从高地址往低地址生长,每个函数都在栈空间中对应一个栈帧。关于栈帧有两个重要的指针——栈基址指针BP
和栈顶指针SP
。其中,除了在函数的开头和结尾(后面会讲到),BP
指针一般是固定不变的,通常以它为基准来寻址参数和局部变量,例如将第一个参数放入栈中的汇编代码可以是movq %rdi, -8(%rbp)
。
和栈相关的两个指令是push
和pop
。在x86_64架构的计算机上,push operand
指令的作用是:
- 将栈顶指针
rsp
减去8(即8字节) - 然后将目标操作数放入更新后的
rsp
所指向的地址处
pop
指令的作用正好和push
指令相反:
- 先将当前
rsp
指向的地址处的数弹出 - 然后将
rsp
增加8
注意:虽然在x86_64机器上也可以push/pop
长度小于8字节的操作数,但是一般并不推荐这样做。
二、call
和 ret
指令
1. call
指令
在汇编语言中,我们可以使用call
命令来调用一个函数,例如:
call add
其中,add
是一个标签,它表示add
函数的起始地址(即add
函数第一条指令的地址)。上面的指令会调用add
函数,具体来说,该指令完成了以下两步操作:
- 将当前指令寄存器的内容压入栈中,指令寄存器中存放
call add
的下一条指令的地址,也就是返回地址。 - 跳转到
add
函数对应的地址开始执行
所以说,call add
就相当于:
push %rip
jmp add
2. ret
指令
该指令负责从一个函数返回,完成的功能如下:
- 弹出当前栈顶的地址到指令寄存器,这个地址就是之前
call
指令压入的返回地址 - 然后跳转到上述返回地址执行
三、调用约定 (Calling Convention)
在详细介绍基于栈的函数调用细节之前,先了解一下调用约定。
1. 什么是调用约定
函数的调用过程中有两个参与者,一个是调用方 caller,另一个是被调用方 callee。
调用约定规定了 caller 和 callee 之间如何相互配合来实现函数调用,具体包括的内容如下:
- 函数的参数存放在哪的问题。是放在寄存器中?还是放在栈中?放在哪个寄存器中?放在栈中的哪个位置?
- 函数的参数按何种顺序传递的问题。是从左到右将参数入栈,还是从右到左将参数入栈?
- 返回值如何传递给 caller 的问题。是放在寄存器里面,还是放在其他地方?
- 等等
那么,为什么需要调用约定呢?
举个例子,如果我们用汇编语言编写代码没有一个统一的规范来遵守的话。那么A
习惯将参数放在栈中,B
习惯将参数放在寄存器中,C
习惯 …,每个人编写的代码都按照自己的想法来。这样,当 A
尝试调用其他人的代码时,就不得不遵循其他人的习惯,比如说调用B
的,那么A
需要将参数放入B规定好的寄存器中;调用C
的,又是另一个样子…
调用约定就是为了解决上述问题,它对函数调用的细节作出了规定,这样的话,每个人都遵守一个约定,当我们想调用别人编写的代码时,就不需要做啥修改了。
2. caller保存的寄存器 和 callee保存的寄存器
在调用约定的规定中:
(1) 有些寄存器是由caller保存的 (caller-saved register),这类寄存器也叫易失性寄存器(volatile register)。
之所以叫易失性寄存器,是因为加入caller调用了其他函数,那么这些寄存器的值是会被改变的。但是 callee 并不负责这些寄存器的保存和恢复,需要 caller 对这些寄存器进行保存,以在函数调用返回之后能恢复这些寄存器的值。
(2) 其他的寄存器就叫做 callee保存的寄存器 (callee-saved register), 也叫做非易失性寄存器 (non-volatile register)。
之所以叫非易失性寄存器,是因为callee可以对这些寄存器的值进行保存和恢复,确保callee调用前后这些寄存器的值不变,因此对于caller来说,它不需要担心这些寄存器,不需要进行保存和恢复。
注:易失和非易失是对caller而言的
3. 有哪些调用约定
根据不同的计算机架构和操作系统,产生了不同的调用规定,常见的调用规定如下:
- cdecl (C declaration):是32位平台常见的一种约定,GCC、Clang、Visual Studio的C编译器都默认使用这种调用约定。
- stdcall:它是用于32位Windows上的一种调用约定。
- Microsoft x64:微软提出的基于x86_64架构的Windows系统上的一种调用约定。
- System V AMD64:是基于x86_64架构Linux系统上广泛使用的一种调用约定。
其中,System V AMD64调用约定是64位Linux系统上广泛使用的一种调用约定,我们在Linux系统上用gcc编译的代码默认都是遵循这种调用约定,下文会对它进行详述。
四、System V AMD64调用约定
System V AMD64调用约定其实是System V AMD64 ABI文档的一部分,该文档的地址如下:
https://software.intel.com/sites/default/files/article/402129/mpx-linux64-abi.pdf
下面对System V AMD64调用约定的部分要点作出总结。
(1) caller-saved寄存器和calle-saved寄存器:
参考上述文档第21页的表格,callee-saved寄存器包括%rbx, %rsp, %rbp, %r12-r14, %r15, x87 CW
,其中%rsp
和%rbp
是重点;余下的寄存器则是caller-saved寄存器。
(2) 如何传递参数?
还是参考上述文档第21页的表格,可以看到System V AMD64调用约定规定把函数的前6个整型参数 通过寄存器传递,第1到第6个整型参数分别存放在%rdi, %rsi, %rdx, %rcx, %r8, %r9
,第7个及以后的整型参数放在栈中,按从右到左的顺序。
对于浮点型参数,System V AMD64调用约定规定把函数的前8个浮点型参数依次放在%xmm0-xmm7
寄存器中。第9个及以后的浮点型参数放在栈中,按从右到左的顺序。
caller在调用callee之前,会把参数放入上述对应的寄存器(或是栈)中,然后callee按照上述规定从对应的寄存器中取值即可。
(3) 如何传递返回值?
还是参考上述文档第21页的表格,System V AMD64调用约定规定,对于整型返回值,会放在%rax
中;对于浮点型返回值,会放在%xmm0
中。
(4) 栈的对齐问题
其实,栈的对齐问题来源于上述文档中的一段话:
The end of the input argument area shall be aligned on a 16 (32 or 64, if
__m256 or __m512 is passed on stack) byte boundary. In other words, the value
(%rsp + 8) is always a multiple of 16 (32 or 64) when control is transferred to
the function entry point. The stack pointer, %rsp, always points to the end of the
latest allocated stack frame.
System V AMD64调用约定要求栈必须按16字节对齐,也就是说,在调用call
指令之前(the end of the input argument area),%rsp
指针必须是16的倍数(对应16进制是最后1位是0)。按16字节对齐的原因是,现代x86_64计算机引入了SSE和AVX指令,这些指令支持SIMD,能极大提升处理速度。但是这些指令要求必须从16字节的整数倍的地址处取数据,为了顾及这些指令,才有了上述对齐要求。
上面这段文档第二句为什么又说是%rsp + 8
必须是16的倍数呢?实际上,它说的%rsp
是执行完call
指令之后的%rsp
,执行call
指令会导致栈中压入一个8个字节的返回地址。原来%rsp
是按16字节对齐的,压入8字节地址后,显然是%rsp + 8
才是16的倍数。
此外,在该调用约定下,函数的起始地址始终是按8字节对齐的(misaligned by 8 bytes),即起始地址是8的倍数。(至于为什么函数起始地址是8字节对齐没大看懂)
参考:https://stackoverflow.com/a/56066628/6570986
总结一下,函数起始地址按8字节对齐 和 栈按16字节对齐 这两点是我们在编写汇编代码时需要注意的,如果不注意,就会编写出错误的代码。
更多关于栈的对齐,可以参考:
- https://stackoverflow.com/questions/4175281/what-does-it-mean-to-align-the-stack
- https://stackoverflow.com/questions/38335212/calling-printf-in-x86-64-using-gnu-assembler
- https://nickdesaulniers.github.io/blog/2014/04/18/lets-write-some-x86-64/
(5) 变长参数问题
根据上述文档第22页中的内容:
For calls that may call functions that use varargs or stdargs (prototype-less
calls or calls to functions containing ellipsis (. . . ) in the declaration) %al16 is used
as hidden argument to specify the number of vector registers used. The contents
of %al do not need to match exactly the number of registers, but must be an upper
bound on the number of vector registers used and is in the range 0–8 inclusive.
如果在汇编代码中,如果调用了变长参数的函数,例如printf
,那么在调用之前需要将本次函数调用使用的向量寄存器(vector register)的数量的上限存储%al
,%al
中存放的是上限,取值范围0-8
,不一定是真正使用的向量寄存器的数量。虽然上述文档是这样规定的,但是编译器可以足够只能,使得编译器生成的应该放入%al
的值就是实际用到的向量寄存器的数量。
向量寄存器包括xmm, ymm, zmm
等,所以说处理浮点数会用到向量寄存器。参考https://godbolt.org/z/G1r6Pc 中的代码示例,我们调用printf
时传入了一个float
变量,用到了%xmm0
寄存器,因此放入%al
的值是1,对应movl $1, %eax
。
最后
限于篇幅原因,关于System V AMD64调用约定的实际的例子,以及如何在该调用约定下编写汇编代码请参考本系列的后续博客。
参考
[1] Guide to x86_64