c里面的变长参数,c++里面也有。提供了:
一个类型
va_list
3个宏
va_start
va_arg
va_end
使用的例子是这样的
int foo(char* fmt, ...){
va_list args;
va_start(args, fmt);
int i = va_arg(args, int);
double f = va_arg(args, double);
va_end(args);
}
使用还是很方便的,但是实现是怎么样的呢? 要讲实现,先得讲讲c里面的函数调用约定。
在x86下,c语言多多种调用约定,而支持变长参数的只有__cdecl。 参数是放在栈上的,调用者负责调整栈.
所以,参数实际上是从第一个到最后一个从低到高排列在栈上的,实现的方法就是
va_list是一个char*.让va_list指向最后一个不是变长参数的参数的后面,也就是第一个变长参数.
va_arg就是按照类型取值,并且把指针往后移动sizeof(type)就行了。
但是到了x64上,就没这么简单了。x64上不论是vc还是gcc都只有一种类似fastcall的调用约定。参数首先是放在寄存器里,不够了再往栈上放。这样一来,参数就不是在栈的内存上连续排列了。变长参数的实现就需要多做点事了。
先讲比较简单的一种,VC的处理方式
vc的调用约定[1] 是,前4个参数,如果是整数,指针或者其他1,2,4,8个字节的参数,放在rcx,rdx,r8,r9里,如果是浮点数,就放在xmm0,xmm1,xmm2,xmm3里,如果大小不满足要求,把参数的指针放在寄存器里。后面的参数,如果大小不符,也是放指针,大小符号,就放值在栈上。强调一下,第一到第四个参数如果是需要放寄存器,一定是放在rcx,rdx,r8,r9或者xmm0,xmm1,xmm2,xmm3。比如,如果第一个参数是int,第二个是double,第三个char*,第四个double,那么就是依次放在rcx,xmm1,r8,xmm3里。
在调用函数之前,需要预留32个字节的寄存器区,这个区域就是用来存放前4个参数的。
在这种调用约定下,实现的va_list的方式倒也简单。如果函数使用了va_start,就会把前4个参数从寄存器里rcx,rdx,r8,r9拷贝到寄存器区,也就跟后面的栈上参数连成一块了。后面就简单了。
但是,如果是浮点数怎么办。这就需要调用者解决了,如果是要对变长参数传参,就需要在把浮点数放到xmm寄存器的同时,给对应的通用寄存器也放一份。这就看出来保证前4个参数和寄存器对应关系的重要性了。
而GCC的调用约定跟VC不同。前6个整数参数会依次放到rdi, rsi, rdx, rcx, r8, r9中,前8个浮点参数放到xmm0到xmm7中。除了使用了更多的寄存器,与vc不同的是,整数和浮点数寄存器是混合使用的不用为没用的参数预留。还是刚才的例子,第一个参数是int,第二个是double,第三个char*,第四个double,参数数会依次放到 rdi,xmm0,rsi,xmm1. 另外,没有在栈上预留寄存器区。 更多的参数和vc一样,放在栈上。
这样一来,变长参数就比较麻烦了。va_list 不再是一个简单的char*了,gcc在x64位下的va_list大概是这样的:
typedef struct {
unsigned int gp_offset;
unsigned int fp_offset;
char *overflow_arg_area;
char *reg_save_area;
} va_list[1];
进入函数的时候,会把6个通用寄存器和8个浮点寄存器连续的拷贝到这个函数的栈帧里。其中一个整数8个字节,一个浮点数16个字节。然后把第一个整数的地址放到reg_save_area里,overflow_arg_area里面是栈上的参数的起始地址。gp_offset设为0,表示整数参数相对reg_save_area的偏移地址,fp_offset设为48,也就是第一个浮点参数的位置。
于是,取参数的时候,整形或者指针类型的,如果gp_offset 不到48 就从reg_save_area + gp_offset 的位置取值,并且把gp_offset加8,否则就从overflow_arg_area取值并且把overflow_arg_area加8
如果是取浮点参数,如果fp_offset 不到 128+48, 就会从reg_save_area + fp_offset的位置取值,并且把fp_offset加16,否则就是从overflow_arg_area取值并且把overflow_arg_area加16.
下面的代码是是一个示意,说明了va_arg的处理过程
#define va_arg(ap, type) /
(*(type*)(__builtin_types_compatible_p(type, long double) /
? (ap->overflow_arg_area += 16, /
ap->overflow_arg_area - 16) /
: __builtin_types_compatible_p(type, double) /
? (ap->fp_offset < 128 + 48 /
? (ap->fp_offset += 16, /
ap->reg_save_area + ap->fp_offset - 16) /
: (ap->overflow_arg_area += 8, /
ap->overflow_arg_area - 8)) /
: (ap->gp_offset < 48 /
? (ap->gp_offset += 8, /
ap->reg_save_area + ap->gp_offset - 8) /
: (ap->overflow_arg_area += 8, /
ap->overflow_arg_area - 8)) /
))
这样的va_list设计会导致一个问题,注意,va_list其实是一个数组,数组在做参数的时候,是转换为指向第一个元素的指针传递的。传递va_list的时候,传进去的是指针,然后在里面改变va_list里面的结构体的值。如果想第二次使用这个va_list,起始就是已经越界了。看下面的例子
void logmessage(const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
vfprintf(stdout, fmt, ap);
vfprintf(stderr, fmt, ap);
va_end(ap);
}
int main()
{
logmessage("%s %s %s/n", "a", "b", "c");
return 0;
}
这个代码在gcc x64下会出不可预料的问题,因为第二次再使用ap的时候,ap已经指向了最后的参数的后面了,再调用就会越界,不能访问到正确的参数。
解决的办法就是使用gcc提供的va_copy宏,改成下面这样
void logmessage(const char *fmt, ...)
{
va_list ap;
va_list ap2;
va_start(ap, fmt);
va_copy(ap2, ap);
vfprintf(stdout, fmt, ap);
vfprintf(stderr, fmt, ap2);
va_end(ap);
}
gcc的调用约定可以更充分的使用寄存器,但是给va_list的实现带来了很大的麻烦。
参考文献
[1] "x64 Software Conventions: Calling Conventions" . msdn.microsoft.com. 2010.