探索X86架构C可变参数函数实现原理

前两天公司论坛有同事在问C语言可变参数函数中的va_start,va_arg 和 va_end 这些接口怎么实现的?我毫不犹豫翻开箱底,将多年前前(算算有十年了)写的文章发给他,

然后开始了深入的技术交流,才有现在这篇文章,所以这篇文算是写给同事的,也分享给大家。

「亲密接触C可变参数函数」这篇文章讲的是i386架构下可变参数函数的实现原理,但是从i386到X86架构,两者的函数调用约定发生了天翻地覆的变化。X86不再完全依赖栈进行传递参数,而是通过寄存器传参数,这给运运行库实现va_list、va_start、va_arg和va_end接口带来更大挑战。

从样本程序开始

老规矩,为了更好说明可变参数的实现原理,先写个样本程序,后续所有分析都以它为蓝本,方便读者的理解,更好理解方案的本质。

#include <stdarg.h>
#include <stdio.h>

long sum(long num, ...)
{
        va_list ap;
        long sum = 0;

        va_start(ap, num);

        while(num--) {
                sum += va_arg(ap, long);
        }

        va_end(ap);
        return sum;
}

int main()
{
        long ret;
        ret = sum(8L, 1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L);
        printf("sum(1..8) = %ld\n", ret);

        return 0;
}

为了减少讨论的噪音,函数参数全是long类的,即64位整型,同时没有交杂着浮点类型参数。如果读者对C语言可变参数函了解不多,可参考拙文「亲密接触C可变参数函数」,本文不再讲述C语言可变参数函数本身的定义,以及va_start、va_arg和va_end接口的语义,重点讲解X86架构下va_start和 va_arg的实现原理。

X86架构函数调用约定

回到上述程序,main函数调用sum求和函数,一共传递了9个long类型参数。在X86架构下,函数调用通过call指令实现,但函数的参数是如何传递的呢?估计有很多读者理解不深,这里简单科普一下。

64位架构下,前6个基础类型(long、int、short 、 char和pointer)参数通过寄存器传递,第7个参数开始用栈传递。通过寄存器传递可减少压栈和弹栈开销,提升性能。具体来说,前6个参数分别通过寄存器rdi,rsi,rdx,rcx,r8和r9寄存器传递,剩下的参数,依次通过调用者的rsp + 0,rsp + 8,rsp + 16,……,rsp + 8*N 这些地址空间传递。

以上述main函数调用sum为例子:

ret = sum(8L, 1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L)

参数传递如图1所示:

图1:main调用sum的参数传递约定

图1中通过栈传递的参数,都标识了两个地址。caller为调用者,表示main函数在在执行时到的地址,而callee则表示被调用者,为sum函数执行第一条指令时看到的地址。图2展示了进入sum时栈空间结构,可能清晰看到传递参数阶段时栈的空间结构。

图2:进入sum函数时栈布局

main函数在执行call指令时,将返回地址压到栈上,并将rsp寄存减8字节。所以main在执行call前的rsp值,和进入sum时的rsp值刚好相差8字节,这就是为什么图1caller和callee看到参数地址刚好相差8字节。

资料直通车:最新Linux内核源码资料文档+视频资料https://docs.qq.com/doc/DTmFTc29xUGdNSnZ2

内核学习地址:Linux内核源码/内存调优/文件系统/进程管理/设备驱动/网络协议栈https://ke.qq.com/course/4032547?flowToken=1040236

图2可以清楚看到,传递sum函数的后3个参数,依次存储在返回地址顶上的高地址空间,每个参数占8字节,前6个参数通过寄存器传递。对X86调用约定感兴趣的读者,可以参考

wiki x86 calling conventions - System V AMD64 ABI​en.wikipedia.org/wiki/X86_calling_conventions#System_V_AMD64_ABI

词条。

初窥Linux实现方案

我们知道va_start的作用是告知哪个参数之后才是可变参数,以 sum函数为例:

va_start(ap, num);

这句话告诉我们,num参数之后的,就是可变参数了,即ap描述的参数列从是从第二个参数开始的。num为第一个参数,它通过rdi传递,所以第一次执行va_arg(ap, long)时,获取的是第二个参数,应该从rsi上获取,再次调用va_arg(ap,long)时应该从rdx获取……,如果通过va_arg(ap, long)获取第七个参数时,应该通过rsp + 8去获取。

怎么样,简单吧!

停……停……停……

如果稍为思考一下,发现有不可愈越的技术问题。首当其冲的是,va_start怎么知道num是第几个参数?这的确是个难点,因为传给va_start的只是个变量名。另一问题是,当调用到va_arg(ap, long)获取第7个参数时,rsp的值是否早已发生变化了,rsp + 8 是否正确,早已无法保正。

i386架构没有这个问题,因为它规定了必须所有参数都放在栈上,并且按顺序挨在一起,不必依赖num是第几个参数,反正num参数的地址值(即&num),加上sizeof(num),就是下个参数的首地址,跟esp寄存器没有任何关系。

而在x86通过寄存器传参标准下,这个方案失效了。于是,翻阅了Linux的stdarg.h文件定义,想看看它是怎么实现的。在ubuntu 12.04下面找到了/usr/lib/gcc/x86_64-linux-gnu/4.6/include/stdarg.h文件,却一无所获。下面是Linux glibc的实现方案:

#define va_start(v,l)   __builtin_va_start(v,l)
#define va_end(v)       __builtin_va_end(v)
#define va_arg(v,l)     __builtin_va_arg(v,l)

头文件使用了gcc提供的内建 __builtin_va_xxx语句来实现相应的功能,而__builtin_语句是gcc编译器在编译阶段处理的,想要搞清楚它的实现原理,必须要翻过gcc这座大山。

这就是网上所有资料,讲可变参数原理到这里,就嘎然止步了。然而这里才开始本文想跟大家讨论的原理,好戏在后头,让我们开始漫漫的长征路吧,打造第一个讲述X86可变参数函数实现原理的博文

当然,gcc代码不是我所能把握的,既然不能正向分析,那就来个逆向,一探gcc深处的秘密。

想细想一下,通常标准库语义是ANSI C标准来制定,Linux下的glibc只需遵守ANSI C标准实现就可以了,但为什么偏偏va_start,va_arg和va_end需要gcc出来搭把手呢?这里是有原因的。

原因是上面提及的两个技术问题,如果放到编译器里面,这两个问题根本算不上是问题。num参数是第几个参数,对编译器来说,这不是废话嘛。编译器看到sum函数签名时,就知道num是第一个参数,屈指一算,就知道va_start(ap, num)语句初始化ap时,标记从第二个参数开始取可变参数。编译器也可以在函prologue处,偷偷生成指令,将返回地址高地址处的栈参数区(即rsp + 8) 记录到ap变量内,无论后面rsp怎么变化,都不用操心。

好了,下面开始一段逆向之旅,请各位绑好安全带。

反编译破解gcc的密秘

我们先对代码进行编译链接:

$ gcc -Wall -g -O2  -o va va.c

同样为了减少噪音,采用-O2优化选项,在理解整个原理后,感兴趣的同学可不使用优化选项,再分析。

为防止程序编写不满足预期,先看运行结果:

$ ./va
sum(1..8) = 36

初步看来,没有什么问题。下面使用gdb对sum函数进行反编译:

$ gdb -q ./va
Reading symbols from /home/ivan/va...done.
(gdb) disassemble sum
Dump of assembler code for function sum:
   0x0000000000400590 <+0>:     lea    0x8(%rsp),%rax
   0x0000000000400595 <+5>:     mov    %rsi,-0x28(%rsp)
   0x000000000040059a <+10>:    mov    %rdx,-0x20(%rsp)
   0x000000000040059f <+15>:    mov    %rcx,-0x18(%rsp)
   0x00000000004005a4 <+20>:    mov    %r8,-0x10(%rsp)
   0x00000000004005a9 <+25>:    mov    %rax,-0x40(%rsp)
   0x00000000004005ae <+30>:    lea    -0x30(%rsp),%rax
   0x00000000004005b3 <+35>:    mov    %r9,-0x8(%rsp)
   0x00000000004005b8 <+40>:    movl   $0x8,-0x48(%rsp)
   0x00000000004005c0 <+48>:    mov    %rax,-0x38(%rsp)
   0x00000000004005c5 <+53>:    xor    %eax,%eax
   0x00000000004005c7 <+55>:    test   %rdi,%rdi
   0x00000000004005ca <+58>:    je     0x40061d <sum+141>
   0x00000000004005cc <+60>:    sub    $0x1,%rdi
   0x00000000004005d0 <+64>:    mov    -0x38(%rsp),%rsi
   0x00000000004005d5 <+69>:    jmp    0x4005f9 <sum+105>
   0x00000000004005d7 <+71>:    nopw   0x0(%rax,%rax,1)
   0x00000000004005e0 <+80>:    mov    %edx,%ecx
   0x00000000004005e2 <+82>:    sub    $0x1,%rdi
   0x00000000004005e6 <+86>:    add    $0x8,%edx
   0x00000000004005e9 <+89>:    add    %rsi,%rcx
   0x00000000004005ec <+92>:    mov    %edx,-0x48(%rsp)
   0x00000000004005f0 <+96>:    add    (%rcx),%rax
   0x00000000004005f3 <+99>:    cmp    $0xffffffffffffffff,%rdi
   0x00000000004005f7 <+103>:   je     0x40061d <sum+141>
   0x00000000004005f9 <+105>:   mov    -0x48(%rsp),%edx
   0x00000000004005fd <+109>:   cmp    $0x30,%edx
   0x0000000000400600 <+112>:   jb     0x4005e0 <sum+80>
   0x0000000000400602 <+114>:   mov    -0x40(%rsp),%rcx
   0x0000000000400607 <+119>:   sub    $0x1,%rdi
   0x000000000040060b <+123>:   lea    0x8(%rcx),%rdx
   0x000000000040060f <+127>:   mov    %rdx,-0x40(%rsp)
   0x0000000000400614 <+132>:   add    (%rcx),%rax
   0x0000000000400617 <+135>:   cmp    $0xffffffffffffffff,%rdi
   0x000000000040061b <+139>:   jne    0x4005f9 <sum+105>
   0x000000000040061d <+141>:   repz retq
End of assembler dump.
(gdb)

到这里,一切都还顺利,接下来是烧脑时刻了,准备好了吗?

sum函数大卸八块分析

顺着sum汇编,我们将该函数分成多个阶段:

1. 先分析进入sum函数时,看看栈空间结构

2. 函数prologue阶段,如何建立栈结构的

3. va_start语句,是怎样初始化ap变量的

4. 最后分析va_arg功能是怎么样实现的

阶段1:进入sum函数阶段

执行sum函数第一条指令时,栈结构已在图2展示了,这里不再赘述。

阶段2:prologue阶段

前面提到,前6个参数放在rdi,rsi,rdx,rcx,r8和r9寄存器里,尽管编译器知道num为第一个参数,通过rdi传递,剩下的可变参数从rsi寄存器开始。但sum函数代码里,全是通过va_arg(ap, long)访问可变参数,一样是很难遍历rsi,rdx,rcx,r8和r9寄存器,必须让ap记录当前是第几个,并且通过不停地switch…case做选择,才能实现第一次调用va_arg时从rsi访问,第二次从rdx,……,再到r9。switch…case语句块,本身就是一个查找表,那么能将代码表转换成数据表,让va_arg实现代码简单一些呢,答案是可以的。

gcc是采用讨巧的办法,就是在函数prologue阶段,将6个寄存器,传递可变参数的那些寄存器,全部压到称为参数保存区的栈空间上,这个空间刚好位于sum函数帧的高地址处。

X86有6个寄存器传递参数,每个寄存器位宽是8字节,所以参数保存区是48位字。图3是执行完sum函数prologue指令后的栈结构。

图3:sum函数prologue指令执行后栈结构

尽管gcc生成的参数保存区都是6个寄存空间,48字节,但实际上最多只可能有5个可变参数,所以总是用不完的。每个寄存器压到相应的位置,对于sum函数,第一个参数是固定参数,后5个为可变参数,所以参数保存区中第一个8字节留空,后面依赖保存rsi,rdx,rcx,r8和r9。

构建好参数保存区后,按顺序访问可变参数就变得轻而易举了。

阶段3:va_start阶段

噢,对了,va_list类型到底是什么类型呢?如果没搞清楚这个结构,上述的汇编指令根本无法入手。OK,有gdb帮助,难题轻而易举。

(gdb) ptype va_list
type = struct __va_list_tag {
    unsigned int gp_offset;
    unsigned int fp_offset;
    void *overflow_arg_area;
    void *reg_save_area;
} [1]

va_list类型可不简单,仔细对照反编译出来的指令, 发现va_list类型别的洞天,说明如下:

reg_save_area

顾名思义是寄存器保存区,是个指针,指向prologue指令建好的参数保存区

overflow_arg_area

顾名思义是其它参数保存区,是个指针,指向栈传递的参数区

gp_offset

顾名思义是通用寄存器偏移量,是指下个va_arg(ap, xxx)调用要获取的参数,在参数保存区的offset

fp_offset

顾名思义是浮点寄存器偏移量,本文不讨论浮点寄存器,这个变量暂时略过,有兴趣的读者可思考它是怎么用的

va_list类型语义明了,那va_start初始化ap的处理过程也跃然纸上,图4是va_start(ap, num)执行完成后的栈结构和ap变量值说明。

图4:va_start(ap, num)执行完成后栈结构和ap变量说明

唯一需要说明的是,gp_offset 值为8,表示下个参数存放在reg_save_area + 8这个地址上,同时也说明下一个要处理的参数是sum的第二个参数。其实这个gp_offset初始值,是编译器知道num为第一个参数,所以可变参数第二个算起,将偏移量初始化为8。

阶段4: va_arg阶段

理解完va_list结构和初始化过程,剩下的事情太简单了,每次执行va_start(ap, long)时,先读取gp_offset,然后计算gp_offset + reg_save_area地址,根据该地址就可以从参数保存区读取参数值,然后将gp_offset的值加8(即sizeof(long)),以便读取下个参数。

噢,慢……如果参数保存区读完了怎么办?不用担心,overflow_reg_area帮你顶着呢,有了它就可以访问那些通过栈传递的参数了。那怎么知道参数保存区读完了呢?不用急,有gp_offset当门卫,问问它就知道了。如果它的值大于等48(即6个寄存器位宽),就说明读完了,那开始从overflow_reg_area处读吧。

然而,overflow_reg_area没有对应的offset指标了,每次读完直接更新指针,指向下个参数即可。图5展示了读取第一个可变参数的执行过程,而图6展示读取栈传递参数的过程。

图5:va_arg 访问第一个可变参数过程

图6:va_arg访问栈传递参数过程

至此,可变参数原理关键过程都分析完了,至于va_end和va_stop原理,不作深入分析。

总结

时隔10年,再度分析C语言可变参数的实现原理,感觉绕了一圈又回到原点。实际上,自己加深了对语言本身和编译器的理解。由于没有阅读gcc源代码的经验,所以没有深入分析gcc提供的__builtin_va_start,__builtin_va_arg这几个内置功能的源代码,而通过二进制来反编译探索。希望有编译器背影的大牛可以写个姊妹篇,以飨读者。

那最后总结一下:

1. gcc一旦发现函数内使用va_start、va_arg这些接口,它会生成prologue指令,创建一个称为参数保存区的栈空间,并将第N个(N < 6)到第6个参数对应的寄存器保存在参数保存区

2. va_list变量结构有两个指针,分别指向参数保存区和栈传递参数区,gp_offset和fp_offset分别保存下个参数在参数保存区的偏移量,当超过48时,通过overflow_reg_area访问参数

3. va_start对va_list做初始化,va_arg根据va_list变量(本文程序是ap)状态,访问下个参数。如果gp_offset < 48,则是reg_save_area + gp_offset,否则是overflow_reg_area,当前访问完后,要更新值指向下个参数。

  • 7
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
函数调用是编程中的一个基本操作,它允许程序在不同的代码块之间进行切换,并传递参数和执行不同的任务。函数调用的底层实现原理涉及到许多复杂的计算机科学概念,包括内存管理、栈、寄存器、调用约定等。 在底层实现中,函数调用通常涉及以下几个步骤: 1. **参数传递**:当一个函数被调用时,它的参数会被压入调用函数的栈帧中。这些参数包括输入和输出参数,以及局部变量。 2. **代码执行**:当函数开始执行时,控制权会转移到该函数的代码上。这个过程通常涉及到将程序的执行上下文(包括寄存器的内容、内存中的数据等)保存到栈帧中,以便函数执行完毕后可以恢复这些信息。 3. **返回地址保存**:当函数执行完毕并准备返回时,它会将程序计数器的当前值(即下一条要执行的指令的地址)保存到一个特殊的寄存器(通常是EIP)中,以便函数可以返回调用它的代码。 4. **返回**:函数执行完毕后,会从栈帧中取出返回地址(通常是EIP),然后跳转到这个地址继续执行程序。此时,函数调用就完成了。 这个过程在许多不同的编程语言中都是相似的,但是实现方式可能会有所不同。具体实现会取决于所使用的编程语言和操作系统,以及硬件架构(如x86、ARM等)。此外,不同的编译器和运行时环境可能会有不同的调用约定,这也会影响函数调用的底层实现。 值得注意的是,函数调用的底层实现通常涉及到许多底层的细节和复杂性,对于大多数编程任务来说并不需要了解这些细节。如果你对这方面的知识感兴趣,可以进一步学习计算机体系结构和操作系统课程,以了解更多关于函数调用和程序执行的基础知识。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

简说Linux内核

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

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

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

打赏作者

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

抵扣说明:

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

余额充值