赞
踩
在C语言中,我们不管是使用标准库函数还是使用自定义的函数,我们都需要注意函数参数的匹配,参数的匹配包括参数类型的匹配和参数个数的匹配,参数不匹配就会导致调用函数错误。
但是在C语言中还提供了一种变参数函数。下面我们以printf函数为例来说明一下。
可以参考一下之前写过的一篇博客:从main函数参数,printf多参数来了解C语言可变参数函数
在printf函数中,我们一般可以控制输入函数的参数:
printf("%d\n", i);
printf("%d %d\n", i, j);
这样看来,printf函数是有一种机制来处理这种可变参数。
我们先看看printf源码:
int printf ( const char * fmt, /* format string to write */ ... /* optional arguments to format string */ ) { va_list vaList; /* traverses argument list */ int nChars; va_start (vaList, fmt); nChars = fioFormatV (fmt, vaList, printbuf, 1); va_end (vaList); return (nChars); } typedef struct { char *a0; /* pointer to first homed integer argument */ int offset; /* byte offset of next parameter */ } 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 ) /******************************************************************************* * * printbuf - printf() support routine: print characters in a buffer */ LOCAL STATUS printbuf ( char *buf, int nbytes, int fd ) { return (write (fd, buf, nbytes) == nbytes ? OK : ERROR); }
C++中有函数重载这种方法,以供我们调用时要可以不确定实参的个数,其实 C 语言也可以,而且更高明!
我们在stdio.h 中可以看到 printf() 函数的原型:
int printf(char * format,…)
事实上,我们如果要写这样的函数也可以类似的写,那么在定义函数时用上这个符号“ … ” ,它叫占位符,喊它 “ 三个点 ” 也可以,只要你愿意!那么我可以这样定义我的函数:
fun(int a,…) { }
要实现可变参数函数,我们需要用到下面几个比较重要的东西:
va_list
va_arg()
va_start()
在此之前我们需要了解一下函数调用时的传参过程,在调用函数时,程序同样会把实参传入,在函数存储区保存起来,如果有很多参数,将一起保存起来。
函数的调用约定很多,常见的包括__stdcall,__cdecl,__fastcall,__thiscall等等。
主要的区别在于约束的三个事件,一个是***参数传递*是从左开始呢还是从右开始,还有就是堆栈清理的清理方*是调用者还是被调用者。另外来说不同的函数调用约定函数产生的符号名称不同*。
举个栗子,对于cdecl,参数是从右到左传递,堆栈平衡是由调用函数来执行的;而win32API一般使用的是stdcall,参数同样是采用了从右往左传递,而函数的堆栈平衡则是由被调用函数执行(不支持可变参数);fastcall参数直接放入寄存器而非栈中,规定前两个参数分别放入ecx和edx中,当寄存器用完时候参数才按照从右往左的顺序压入堆栈。
调用约定 | 使用场景 |
---|---|
_cdecl | c调用约定 |
_stdcall | windows标准调用约定 |
_fastcall | 快速调用约定 |
_thiscall | C++成员函数调用约定 |
int add(int a, int b)
{
return a+b;
}
int main()
{
int a = 1;
int b = 2;
int res = add(a,b);
return 0;
}
首先从main函数初始,ebp和esp分别存放函数的栈底地址和栈顶地址,此时ebp-4即是a,ebp-8则是b的地址。
然后调用函数add,第一先将参数从右往左依次入栈,push在调用方的函数栈当中,也就是说此时esp往里开辟了两个参数。
执行call指令,首先将下一条指令地址进行入栈,
随后开辟新栈,进行现场保护
执行add函数
开辟一个临时变量,值是***(a)*(ebp+8) + *(b)*(ebp+0Ch)**,将这个值放入eax中。
执行完成后回退栈针
上面的返回值是一个int类型,也就是C的内置类型,通过eax寄存器带出。
如果是一个double或者long long呢?那么可以通过eax、edx两个寄存器带出。
如果是一个自定义类型呢?其实也是类似的:
首先在参数传参过程中不能直接去push一个寄存器了,而现在是通过开辟内存后,将自定义类型的实参b的地址放入esi中,循环赋给实参。
例如说自定义类型的b参数
参数传递完成之后,再来看看返回值,返回值首先会在压入所有的形参之后,将main函数中返回值(临时量)的地址压入参数。
对返回值的操作也是类似的,通过esi和edi、ecx,循环拷贝到main函数的函数栈之中。
临时量返回值的地址是最后才会压栈的,那么它的地址一定是ebx+8
虽说是可变参数,但也并不是完全自由的,对于任意的可变参数函数,至少需要指定一个参数,通常这个参数包含对传入参数的描述(下面会提到原因)。
可变参数的实现依赖下列几个库函数(宏定义)的定义:
va_list //这是一个特殊的指针类型,指代栈中参数的开始地址
va_start(ap,T) //ap为va_list类型,T为函数第一个参数
va_args(ap,A) //ap为va_list类型,A为需要取出的参数类型,如int,char
va_end(ap) //ap为va_list类型。
这些变量类型或者宏定义的含义如下:
typedef struct {
char *a0; /* pointer to first homed integer argument */
int offset; /* byte offset of next parameter */
} 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 )
接下来我们便动手实现一个可变参数函数add(),返回所有传入的int型参数之和:
int add(int cnt, ... ) { int sum=0; va_list args; va_start(args,cnt); for(int i=0;i<cnt;i++) { sum += va_arg(args,int); } va_end(args); return sum; } int main() { printf("%d\r\n",add(4,1,2,3,4)); return 0; }
程序输出结果:
10
老规矩,看完示例我们来探究一下示例实现的原理:
下面我用图来说明一下这个过程:
#define va_start(ap,v) ( ap = (va_list)&v + _INTSIZEOF(v) )
这条语句就是获取add(4,1,2,3,4)中第二个参数的地址(即除去参数cnt外的第一个参数)
#define va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
这条语句有两个作用:一是返回 ap 指向的参数的值;二是让 指针 ap 移动了sizeof(t)的大小,这让我们在获取下一个参数时不需要再移动指针。
这样通过参数指针和参数类型,我们就可以一个一个的把参数取出来。
在这里,我们需要关注几个问题:
第一和第二个问题其实可以同时来解释,参数从右往左压栈,在可变参函数调用时,先将最后一个参数入栈,最后将第一个参数入栈,可变参数主要是通过第一个参数来确定参数列表,但是这时候如果第一个参数没有被指定的话,编译器将无法定位参数在栈上的范围。
同时,如果可变参数函数在定义时没有第一个参数的话,编译器直接报错。(gcc)
test.c:10:10: error: ISO C requires a named argument before ‘...’
va_arg对应类型问题
我们再回到第三个问题,如果在va_arg()函数中传入一个错误的类型会发生什么情况呢?
下面是我传入一个int型数据,但是在用va_arg()获取参数时传入了char类型,编译时的信息:
warning: ‘char’ is promoted to ‘int’ when passed through ‘...’
sum += va_arg(args,char); ^
note: (so you should pass ‘int’ not ‘char’ to ‘va_arg’)
note: if this code is reached, the program will abort
警告信息,但是不会报错,依然可以运行,那我们就运行看看结果:
Illegal instruction (core dumped)
果然,如编译时的警告预料的,当执行到那部分代码时,程序就会终止运行。
这是为什么呢?
其实原因也并不难想到,被调用函数并不知道参数的类型和个数,所以只能依靠用户给的信息来寻址获取数据,如果指定错误的类型,很可能会导致栈上数据的混乱,但是这里博主发现一个有意思的问题:
如果传入的参数为char类型,我们在从栈上取参数的时候也指定char类型参数:
sum += va_arg(args,char);
按理说这是完全没有问题的,但是在编译的时候依然会有以下提示:
warning: ‘char’ is promoted to ‘int’ when passed through ‘...’
sum += va_arg(args,char); ^
note: (so you should pass ‘int’ not ‘char’ to ‘va_arg’)
note: if this code is reached, the program will abort
这是为何?
传入的类型和指定接收的类型是匹配的,为什么提示有问题。然后我运行了一次,结果是这样的:
Illegal instruction (core dumped)
我突然想到,printf中也会传入char类型,我看看它是怎么实现的。
case 'c':
handle_char(va_arg(arg, int));
continue;
看起来在printf实现中,对传入的char类型的数据,也是根据int类型从栈上获取数据,char是一个字节,int是4字节(32位),这样不会出问题吗?
理论上来说,当程序取一个int型数据时,就在栈上获取了四字节数据,除了这个参数,还会把前一个参数(从右到左压栈)的前三个字节取出来,势必会导致数据的混乱。
但是,计算机系统中还有一个概念就是对齐,不管是数据结构填充还是指令和数据的存储,这是为了寻址时的方便,所以即使是将一个char类型数据压栈,也会占用一个int类型的空间。
所以我们再来分析为什么传入char类型的同时取出char类型的实参会导致程序运行失败:
当使用sum += va_arg(args,char);获取参数时,获取了一个字节的数据,但是由于对齐,后面填充的三个字节依然放在栈上。
当下一次取参数时,仍然取一个字节,取出的事实上是第一个参数的第二个字节,这时候会有6个字节仍然在栈上,以此类推。
最要命的是:栈上存储着函数的返回地址,当参数都取完时,再取返回地址,这时候自然取不到真正的返回地址,而是取到了参数,程序跳转到了未知的地方,所以程序运行自然失败。
ref: https://segmentfault.com/a/1190000016869401
https://www.cnblogs.com/downey-blog/p/10471220.html
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。