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

  • 18
    点赞
  • 49
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
函数调用过程是程序中常见的一种操作,它通常涉及到参数传递、栈帧的建立与销毁、返回值的传递等多个方面。从汇编的角度来看,函数调用过程可以分为以下几个步骤: 1. 将函数的参数压入栈中。在调用函数时,需要将函数所需的参数传递给它。这些参数通常以一定的顺序压入栈中,以便在函数内部使用。在 x86 架构中,参数的传递是通过将参数压入栈顶实现的。 2. 调用函数。函数调用的指令通常是 CALL 指令。在调用函数前,需要将函数的入口地址压入栈中,以便在函数执行完毕后返回到调用位置。CALL 指令会将当前的程序计数器(PC)压入栈中,并将函数的入口地址作为新的 PC。 3. 建立栈帧。在函数被调用时,需要为函数建立一个独立的栈帧,以便在函数内部使用局部变量和临时变量。栈帧通常包括以下几个部分:返回地址、旧的基址指针、局部变量和临时变量。在 x86 架构中,栈帧的建立是通过将 ESP 寄存器减去一个固定的值实现的。 4. 执行函数。在函数被调用后,CPU 会跳转到函数的入口地址并开始执行函数。函数内部可以通过栈中的参数和局部变量完成相应的计算和操作。 5. 返回值传递。在函数执行完毕后,需要将函数的返回值传递给调用者。在 x86 架构中,函数的返回值通常通过 EAX 寄存器传递。 6. 销毁栈帧。在函数执行完毕后,需要将栈帧销毁,以便释放栈空间。栈帧的销毁通常是通过将 ESP 寄存器还原到旧的基址指针处实现的。 7. 返回到调用位置。在函数执行完毕后,需要返回到函数被调用的位置。在 x86 架构中,返回指令通常是 RET 指令。RET 指令会将栈顶的返回地址弹出,并将其作为新的 PC。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ponnylv

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值