变长参数va_list va_start va_arg va_end

对于int printf(const char *format, ...);这种变长参数,需要使用va_list va_start va_end va_arg来访问参数。
下面是一个tutorialspoint 的一个使用demo,示范如何使用这几个接口

#include
#include
int sum(int num_args, ...) {
   int val = 0, i;
   va_list ap;
   va_start(ap, num_args);
   for(i = 0; i < num_args; i++) {
      val += va_arg(ap, int);
   }
   va_end(ap);
   return val;
}
int main(void) {
   printf("Sum of 10, 20 and 30 = %d\n",  sum(3, 10, 20, 30) );
   printf("Sum of 4, 20, 25 and 30 = %d\n",  sum(4, 4, 20, 25, 30) );

   return 0;
}

编译运行之后的输出:

Sum of 10, 20 and 30 = 60
Sum of 4, 20, 25 and 30 = 79

从上面看,访问变长参数需要遵循特定的接口,步骤如下:

  • 在调用参数表之前,定义一个 va_list 类型的变量ap;
  • 通过 va_start对ap进行初始化,之后ap指向可变参数表里面的第一个参数
  • 通过va_arg遍历参数,需要知道参数的类型,上面demo的参数全部是int型
  • 调用va_end关闭ap,以免发生误使用

而对于printf这类变长参数接口,没有num_args传递参数的个数,而且变长参数的类型也各不相同,又是如何访问他们的。在glibc printf实现中,需要根据format中限定词Conversion specifiers来决定如何遍历变长参数,例如printf("str:%s num:%d\n", "nihao", a);,处理时遍历根据format中,当发现转义%之后开始解析,对于s说明第一个变长参数是char *类型,对于d说明第二个变长参数是int类型,即format中隐含变长参数的个数和类型。

实现

接着分析一下背后的原理是如何能够访问变长参数的。

reflink:
https://zh.wikipedia.org/zh-hans/X86%E8%B0%83%E7%94%A8%E7%BA%A6%E5%AE%9A#cdecl
https://www.codeproject.com/Articles/1388/Calling-Conventions-Demystified
https://gcc.gnu.org/onlinedocs/gcc/x86-Function-Attributes.html

下面是程序员的自我修养-编译和链接中的示例,并且是baidu出的最广泛的结果

      typedef char * va_list;     // TC中定义为void*
      #define _INTSIZEOF(n)    ((sizeof(n)+sizeof(int)-1)&~(sizeof(int) - 1) ) //为了满足需要内存对齐的系统
      #define va_start(ap,v)    ( ap = (va_list)&v + _INTSIZEOF(v) )     //ap指向第一个变参的位置,即将第一个变参的地址赋予ap
      #define va_arg(ap,t)       ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )   /*获取变参的具体内容,t为变参的类型,如有多个参数,则通过移动ap的指针来获得变参的地址,从而获得内容*/
      #define va_end(ap) ( ap = (va_list)0 )   //清空va_list,即结束变参的获取

不过这种只适用于早期的cdecl函数调用方式(不信的可以自己试试结果),在x86平台上更是只能在32bit上使用,在64bit上就算限定使用cdecl约定也会被直接忽略。

typedef char * va_list;
#define _INTSIZEOF(n)    ((sizeof(n)+sizeof(int)-1)&~(sizeof(int) - 1) )
#define va_start(ap,v)    ( ap = (va_list)&v + _INTSIZEOF(v) )
#define va_arg(ap,t)       ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
#define va_end(ap) ( ap = (va_list)0 )

int __attribute__((__cdecl__)) sum(int num_args, ...) {	//使用__attribute__((__cdecl__))指定使用
。。。
}

之后使用gcc -m32编译成32bit应用,这样可以得到我们期望的结果。

不过目前的调用约定默认都是fastcall了,在x86_64上前6个参数通过rdi, rsi, rdx, rcx, r8, r9寄存器传递,剩余的参数放在栈上传递。

所以变长参数的访问方式和上面cdecl就不再相同了,下面探索一下现代的fastcall中是如何实现访问变长参数的。

仍然使用最开始的例子,通过gcc --save-temps保留中间文件,特别是.i文件,看到其中sum的展开,它不再是个宏

int sum(int num_args, ...) {
   int val = 0;
   va_list ap; 
   int i;

   __builtin_va_start(ap,num_args);
   for(i = 0; i < num_args; i++) {
      val += __builtin_va_arg(ap,int);
   }
   __builtin_va_end(ap);                                                                                                                       

   return val;
}

发现很难找到__builtin_va_start,__builtin_va_end的实现,它和va_list是arch相关的,在gcc内部实现的gcc/builtins.c

/usr/lib/gcc/x86_64-linux-gnu/5/include/stdarg.h
#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的烂脚布了,后面我直接从汇编上分析这个过程,代码不多可以手动解析,不感兴趣的可以略过后面的汇编注释部分,直接看下面的文字描述

  1. 将所有上一帧的传参寄存器入栈保存到栈上addr处
  2. 构建va_list变量,struct __va_list_tag类型的,初始化其中的成员。reg_save_area指向寄存器传参保存的地址,当参数大于能够直接通过寄存器传递的数量时,保存到caller的栈上,overflow_arg_area指向caller中保存参数的位置。其中gp_offset代表addr中的偏移,fp_offset代表寄存器传参区域的虽大范围,x86_64上允许最多6个参数通过寄存器传参,而第一个rdi寄存器不可能传递的是变长参数,所以做多允许5个变长参数通过寄存器传递过来。
  3. 遍历变长参数列表,如果变长参数少于5个,则只需要访问reg_save_area区域,gp_offset代表偏移,每次遍历,gp_offset每次增加8个字节的偏移以指向下一个参数的地址。如果变长参数大于5个,则剩下的参数开始访问overflow_arg_area区域,每次遍历,overflow_arg_area地址增加8指向一个参数的地址。
    变长参数va_list va_start va_arg va_end_第1张图片

究其背后,也就是一种函数调用约定的改变需要一种新的实现方式来访问变长参数,它只是约定的附属产物,如果你看不惯了,也可以自己实现一种,另一篇linux函数调用过程中的寄存器中已经说明了函数调用的约定,以及我们什么时候需要遵守这些约定。

软件发展的这么快,我们不可能知道所有的知识,但是掌握其背后的原理能够帮助我们迅速掌握其他关联的知识。

gdb main
(gdb) ptype va_list	             //看一下va_list的类型,它不再是char *
type = struct __va_list_tag {
    unsigned int gp_offset;
    unsigned int fp_offset;
    void *overflow_arg_area;
    void *reg_save_area;
} [1]
sum:
.LFB1:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16 
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    subq    $92, %rsp
    movq    %rsi, -168(%rbp)		//寄存器保存到栈上,没有包括rdi寄存器,它不属于变长参数列表中
    movq    %rdx, -160(%rbp)
    movq    %rcx, -152(%rbp)
    movq    %r8, -144(%rbp)
    movq    %r9, -136(%rbp)
    testb   %al, %al 				//直接跳转到L4 label处
    je  .L4 
    movaps  %xmm0, -128(%rbp)	    //这里的浮点处理暂时不关心
    movaps  %xmm1, -112(%rbp)
    movaps  %xmm2, -96(%rbp)
    movaps  %xmm3, -80(%rbp)
    movaps  %xmm4, -64(%rbp)
    movaps  %xmm5, -48(%rbp)
    movaps  %xmm6, -32(%rbp)
    movaps  %xmm7, -16(%rbp)
.L4:
    movl    %edi, -212(%rbp)    //num_arg保存到这里                                                                                                                  
    movl    $0, -180(%rbp)      //val = 0
    //va_start初始化
    movl    $8, -208(%rbp)      //gp_offset = 8,第一个寄存器肯定不能存储变长参数
    movl    $48, -204(%rbp)     //fp_offset = 48
    leaq    16(%rbp), %rax      //如果参数多于寄存器所能传的最大数量时,入栈保存。在rbp需要跨越16个字节,分别保存了上一个栈帧的rbp和返回地址
    movq    %rax, -200(%rbp)    //overflow_arg_area=上一个栈帧中可能保存参数的位置
    leaq    -176(%rbp), %rax    //参数寄存器保存到栈上的位置,并且加上了一个rdi寄存器的位置
    movq    %rax, -192(%rbp)    //reg_save_area=寄存器参数保存的位置
    movl    $0, -184(%rbp)		//i = 0
    jmp .L5
.L8:
    movl    -208(%rbp), %eax
    cmpl    $48, %eax			//比较gp_offset和fp_offset,即最多支持5((48-8)/8)个直接参数
    jnb .L6                     //如果变长参数大于6个,到overflow_arg_area中取参数
    movq    -192(%rbp), %rdx    //根据gp_offset和reg_save_area找到参数的地址
    movl    -208(%rbp), %eax
    movl    %eax, %eax			//看不懂O(∩_∩)O~
    addq    %rdx, %rax
    movl    -208(%rbp), %edx
    addl    $8, %edx			//gp_offset += 8,更新gp_offset
    movl    %edx, -208(%rbp)    
    jmp .L7
.L6:
    movq    -200(%rbp), %rdx	//剩余参数的获取
    movq    %rdx, %rax
    addq    $8, %rdx
    movq    %rdx, -200(%rbp)   //随着遍历参数,更新overflow_arg_area地址
.L7:
    movl    (%rax), %eax
    addl    %eax, -180(%rbp)	//val+=va_arg(ap, int); 加法
    addl    $1, -184(%rbp)		//i++
.L5:
    movl    -184(%rbp), %eax   //eax = i
    cmpl    -212(%rbp), %eax   //i < num_args
    jl  .L8
    movl    -180(%rbp), %eax
    leave
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc   

你可能感兴趣的:(linux)