对于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
从上面看,访问变长参数需要遵循特定的接口,步骤如下:
而对于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的烂脚布了,后面我直接从汇编上分析这个过程,代码不多可以手动解析,不感兴趣的可以略过后面的汇编注释部分,直接看下面的文字描述
struct __va_list_tag
类型的,初始化其中的成员。reg_save_area指向寄存器传参保存的地址,当参数大于能够直接通过寄存器传递的数量时,保存到caller的栈上,overflow_arg_area指向caller中保存参数的位置。其中gp_offset代表addr中的偏移,fp_offset代表寄存器传参区域的虽大范围,x86_64上允许最多6个参数通过寄存器传参,而第一个rdi寄存器不可能传递的是变长参数,所以做多允许5个变长参数通过寄存器传递过来。究其背后,也就是一种函数调用约定的改变需要一种新的实现方式来访问变长参数,它只是约定的附属产物,如果你看不惯了,也可以自己实现一种,另一篇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