x86_64汇编之四:函数调用、调用约定

x86_64汇编系列:

一、栈

在这里插入图片描述
在这里插入图片描述

栈一般从高地址往低地址生长,每个函数都在栈空间中对应一个栈帧。关于栈帧有两个重要的指针——栈基址指针BP和栈顶指针SP。其中,除了在函数的开头和结尾(后面会讲到),BP指针一般是固定不变的,通常以它为基准来寻址参数和局部变量,例如将第一个参数放入栈中的汇编代码可以是movq %rdi, -8(%rbp)

和栈相关的两个指令是pushpop。在x86_64架构的计算机上,push operand指令的作用是:

  • 将栈顶指针rsp减去8(即8字节)
  • 然后将目标操作数放入更新后的rsp所指向的地址处

pop指令的作用正好和push指令相反:

  • 先将当前rsp指向的地址处的数弹出
  • 然后将rsp增加8

注意:虽然在x86_64机器上也可以push/pop长度小于8字节的操作数,但是一般并不推荐这样做。

二、callret 指令

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值