x86-64函数调用参数传递

Posted rtoax

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了x86-64函数调用参数传递相关的知识,希望对你有一定的参考价值。

公众号原文链接:https://mp.weixin.qq.com/s/qXYnIezKNuXhOBN7nZouZw

 

问题引入

不多解释,先写个小程序:

void func1(a,b,c)int a,b,c;{}void func2(a,b,c,d,e,f)int a,b,c,d,e,f;{}void func3(a,b,c,d,e,f,g,h)int a,b,c,d,e,f,g,h;{}int main(){  int a,b,c,d,e,f,g,h;  func1(a,b,c);  func2(a,b,c,d,e,f);  func3(a,b,c,d,e,f,g,h);}

从程序中可以看出,函数分别带有3个,6个,8个入参,那么这些入参是如何做的呢?如果站在汇编的角度,实际上可以采用两种方案:

  • 采用通用寄存器传参;

  • 采用栈传参;

为了证明函数如何传参,首先用GCC生成汇编代码:

gcc arg-pass.c -S

这样,我们就获的C代码对应的x86平台的汇编代码(为了显示清晰,我删除了以"."开头的标记性语句):

func1:  pushq  %rbp  movq  %rsp, %rbp  movl  %edi, -4(%rbp)  movl  %esi, -8(%rbp)  movl  %edx, -12(%rbp)  popq  %rbp  retfunc2:  pushq  %rbp  movq  %rsp, %rbp  movl  %edi, -4(%rbp)  movl  %esi, -8(%rbp)  movl  %edx, -12(%rbp)  movl  %ecx, -16(%rbp)  movl  %r8d, -20(%rbp)  movl  %r9d, -24(%rbp)  popq  %rbp  retfunc3:  pushq  %rbp  movq  %rsp, %rbp  movl  %edi, -4(%rbp)  movl  %esi, -8(%rbp)  movl  %edx, -12(%rbp)  movl  %ecx, -16(%rbp)  movl  %r8d, -20(%rbp)  movl  %r9d, -24(%rbp)  popq  %rbp  retmain:  pushq  %rbp  movq  %rsp, %rbp  subq  $48, %rsp  movl  -12(%rbp), %edx  movl  -8(%rbp), %ecx  movl  -4(%rbp), %eax  movl  %ecx, %esi  movl  %eax, %edi  movl  $0, %eax  call  func1  movl  -24(%rbp), %r8d  movl  -20(%rbp), %edi  movl  -16(%rbp), %ecx  movl  -12(%rbp), %edx  movl  -8(%rbp), %esi  movl  -4(%rbp), %eax  movl  %r8d, %r9d  movl  %edi, %r8d  movl  %eax, %edi  movl  $0, %eax  call  func2  movl  -24(%rbp), %r9d  movl  -20(%rbp), %r8d  movl  -16(%rbp), %ecx  movl  -12(%rbp), %edx  movl  -8(%rbp), %esi  movl  -4(%rbp), %eax  movl  -32(%rbp), %edi  movl  %edi, 8(%rsp)  movl  -28(%rbp), %edi  movl  %edi, (%rsp)  movl  %eax, %edi  movl  $0, %eax  call  func3  leave  ret

接下来,我们就可以进行一次分析。

x86寄存器

首先,我需要先给出X86相关寄存器,又例如我们分析上面的汇编代码。

因为x86都是向后兼容的,所以在x86-64架构下,32bit和16bit的寄存器都是可以使用的,32bit通用寄存器如下:

寄存器描述
eax操作数的运算、结果
ebx指向DS段中的数据的指针
ecx字符串操作或者循环计数器
edx输入输出指针
esi指向DS寄存器所指示的段中某个数据的指针,字符串操作中的复制源
edi指向ES寄存器所指示的段中某个数据的指针,字符串操作中的目的
espSP寄存器
ebp指向栈上数据的指针

x86-64架构寄存器如下:

寄存器描述
rdi传递第一个参数
rsi传递第二个参数
rdx传递第三个参数或者第二个返回值
rcx传递第四个参数
r8传递第五个参数
r9传递第六个参数
rax临时寄存器或者第一个返回值
rspsp寄存器
rbp栈帧寄存器

在了解了寄存器之后,我们就可以分析上面说的函数调用过程了。

栈结构分析

首先我们看函数1:

void func1(a,b,c)int a,b,c;{}

调用方式为:

  func1(a,b,c);

上面对应的在main中调用汇编为:

  movq  %rsp, %rbp  subq  $48, %rsp  movl  -12(%rbp), %edx  movl  -8(%rbp), %ecx  movl  -4(%rbp), %eax  movl  %ecx, %esi  movl  %eax, %edi  movl  $0, %eax  call  func1

上面做了什么?注意栈指针rsp的移动就能理解了。

栈的起始状态为:

给rsp减值48,这很好玩,就这么个操作,直接在栈上申请了48字节的空间,是不是比brk()系统调用还快。

为啥这里会有

  movl  -12(%rbp), %edx  movl  -8(%rbp), %ecx  movl  -4(%rbp), %eax

到底谁才是abc?说实话,我也糊涂了,所以说,我们还是给他们初始值吧。

int main(){  int a,b,c,d,e,f,g,h;  a = 1;  b = 2;  c = 3;  d = 4;  e = 5;  f = 6;  g = 7;  h = 8;  func1(a,b,c);  func2(a,b,c,d,e,f);  func3(a,b,c,d,e,f,g,h);}

反汇编为:

main:  pushq  %rbp  movq  %rsp, %rbp  subq  $48, %rsp  movl  $1, -4(%rbp)  movl  $2, -8(%rbp)  movl  $3, -12(%rbp)  movl  $4, -16(%rbp)  movl  $5, -20(%rbp)  movl  $6, -24(%rbp)  movl  $7, -28(%rbp)  movl  $8, -32(%rbp)  movl  -12(%rbp), %edx  movl  -8(%rbp), %ecx  movl  -4(%rbp), %eax  movl  %ecx, %esi  movl  %eax, %edi  movl  $0, %eax  call  func1    movl  -24(%rbp), %r8d  movl  -20(%rbp), %edi  movl  -16(%rbp), %ecx  movl  -12(%rbp), %edx  movl  -8(%rbp), %esi  movl  -4(%rbp), %eax  movl  %r8d, %r9d  movl  %edi, %r8d  movl  %eax, %edi  movl  $0, %eax  call  func2    movl  -24(%rbp), %r9d  movl  -20(%rbp), %r8d  movl  -16(%rbp), %ecx  movl  -12(%rbp), %edx  movl  -8(%rbp), %esi  movl  -4(%rbp), %eax  movl  -32(%rbp), %edi  movl  %edi, 8(%rsp)  movl  -28(%rbp), %edi  movl  %edi, (%rsp)  movl  %eax, %edi  movl  $0, %eax  call  func3    leave  ret

那我们重新分析,main是如何调用func1的吧!还是这个代码:

  movq  %rsp, %rbp  subq  $48, %rsp  movl  $1, -4(%rbp)  movl  $2, -8(%rbp)  movl  $3, -12(%rbp)  movl  $4, -16(%rbp)  movl  $5, -20(%rbp)  movl  $6, -24(%rbp)  movl  $7, -28(%rbp)  movl  $8, -32(%rbp)  movl  -12(%rbp), %edx  movl  -8(%rbp), %ecx  movl  -4(%rbp), %eax  movl  %ecx, %esi  movl  %eax, %edi  movl  $0, %eax  call  func1

函数调用前的栈

根据上面的汇编代码分析,实际上就变成了这样:

上面的流程实际上只是main函数中的局部变量的初始化,接下来的汇编就要开始调用func1了,首先使用通用寄存器存放abc:

下面就要像我在描述通用寄存器的时候说的

寄存器描述
rdi传递第一个参数
rsi传递第二个参数
rdx传递第三个参数或者第二个返回值
rax临时寄存器或者第一个返回值

分别使用通用寄存器edi、esi、edx保存了三个参数a、b、c,eax会保存函数的返回值,所以需要置零。后续就可以调用func1了。

函数调用的栈

首先看下func1的汇编代码:

func1:  pushq  %rbp  movq  %rsp, %rbp  movl  %edi, -4(%rbp)  movl  %esi, -8(%rbp)  movl  %edx, -12(%rbp)  popq  %rbp  ret

在刚进入func1时,栈空间是下面这样的:

首先将rbp压栈,实际上保存的不是rbp,而是保存的rbp的值:

为了更加清楚动人,还是给之前的rbp设定个数值(0x40000)吧:

接下来,需要把通用寄存器存至函数栈:

为什么要做上面这一步?我直接用寄存器不好吗?为什么非要存?

答案很简单,因为我这里写的函数为:

void func1(a,b,c)int a,b,c;{}

函数体为空,也就是完全没有任何其他的操作,在复杂的函数体中,通用寄存器的使用还有很多,所以编译器必须这么做。【这里抛出一个问题,如果编译器能知道当前函体为空,那么我使用gcc -O3优化编译,会省略上图的压栈操作吗?感兴趣可以试一试】。

执行完上面的操作,函数可以返回了,在返回前(调用ret前),需要恢复栈空间为调用func1之前:

接着就可以返回了。下面我们分析超过6个参数的函数调用。

超过6个入参的函数调用

下面我们分析超过6个参数的函数调用。假定func2已经返回【因为func2位六个入参,流程基本上和func1函数相同,直接看超过6个入参的情况】,此时的栈空间为:

直接看main函数中的函数调用部分汇编代码:

movl  -24(%rbp), %r9dmovl  -20(%rbp), %r8dmovl  -16(%rbp), %ecxmovl  -12(%rbp), %edxmovl  -8(%rbp), %esimovl  -4(%rbp), %eaxmovl  -32(%rbp), %edimovl  %edi, 8(%rsp)movl  -28(%rbp), %edimovl  %edi, (%rsp)movl  %eax, %edimovl  $0, %eaxcall  func3

首先,先给寄存器赋值:

接着使用栈上的空闲空间存储edi【8】:

接着,在将【7】入栈:

然后,将【1】保存到edi寄存器,这与我们上面表格中给出的rdi保存第一个参数是一致的。

在这一波操作之后,就可以调用func3了,当然,还是先看一眼func3的汇编代码:

func3:  pushq  %rbp  movq  %rsp, %rbp  movl  %edi, -4(%rbp)  movl  %esi, -8(%rbp)  movl  %edx, -12(%rbp)  movl  %ecx, -16(%rbp)  movl  %r8d, -20(%rbp)  movl  %r9d, -24(%rbp)  popq  %rbp  ret

函数原型为:

void func3(a,b,c,d,e,f,g,h)int a,b,c,d,e,f,g,h;{}

首先还是先将rbp入栈:

然后分别将通用寄存器入栈:

然后将rbp出栈,恢复到main调用func3之前的栈结构:

结论

在x86平台上,函数调用过程中,如果没有超过6个入参,那么直接使用寄存器就可以了,如果超过6个入参,那么超出6个的参数将通过栈传递。

以上是关于x86-64函数调用参数传递的主要内容,如果未能解决你的问题,请参考以下文章

将 C 代码转换为 x86-64 位程序集?

如何计算程序(或函数调用)中两点之间执行的 x86-64 指令的数量?

为啥 x86-64 Linux 系统调用使用 6 个寄存器集?

JS常用代码片段-127个常用罗列-值得收藏

x86-64 C Calling Convention

C#线程调用方法时,怎么传参数过去