最近研究了一下c语言可变长参数,查阅了很多资料,由于本人比较平庸,借用了九牛和二虎的力量才略有收获,为了我所追求的黑客精神,便整理成尽可能通俗易懂的语言与思路来帮助其他人学习,读者只需要具有一般的c语言基础以及初步了解指针和带参数的宏就可以。
首先我们得先来补充点关于内存的基础知识:
一个由C/C++编译的程序占用的内存分为以下几个部分
1、栈区(stack)—
由编译器自动分配释放
,存放函数的参数值,局部变量的值等。其
操作方式类似于数据结构中的栈。
2、堆区(heap)
— 一般由程序员分配释放,
若程序员不释放,程序结束时可能由OS回
收
。注意它与数据结构中的堆是两回事,分配方式类似于链表。
3、全局区(静态区)(static)—
全局变量和静态变量的存储是放在一块的,初始化的
全局变量和静态变量在一块区域,
未初始化的全局变量和未初始化的静态变量在相邻的另
一块区域。
-
程序结束后由系统释放。
4、文字常量区 —
常量字符串就是放在这里的。
程序结束后由系统释放 。
5、程序代码区 —
存放函数体的二进制代码。
上面的栈区(stack)就是我们今天的主角。下面开始正题:
初学c语言大家一般都会学到一个printf函数,《The
C Programming Language》中说这个函数正确声明形式是int
printf(char *fmt, ...),省略号代表的参数表中参数的数量和类型是可变的,省略号只能出现在参数表的尾部。
C语言实现可变长参数呢,其实道理很简单,由于可变长参数无法像固定参数那样以行参来定位参数内容,
但是C语言的参数都会按照特定顺序压到栈中,所以呢就通过几个宏可以巧妙的从栈中一个一个把参数取出来。
那么具体是怎么实现的呢,这几个宏呢被定义在c标准库的stdarg.h头文件中,
针对不同平台有不同的宏定义,虽然我先在工作在linux下,但是gcc的stdarg.h头文件中发现这几个关键的宏竟然在编译器中给实现了,所以我找来了x86下的vc的几个宏定义并做相应解释:
typedef
char *va_list;
把va_list被定义成char*,这是因为在我们目前所用的PC机上,字符指针类型可以用来存储内存单元地址。而在有的机器上va_list是被定义成void*的。
#define
_INTSIZEOF(n) (
(sizeof(n) +
sizeof(int) -
1) &
~(sizeof(int) -
1)
)
由于你需要根据每个参数的实际大小,从而往后面推算下一个参数的位置,这里_INTSIZEOF(n)宏是为了考虑那些内存地址需要对齐的系统来计算实际参数所占的大小的,从宏的名字来应该是跟sizeof(int)对齐。一般的
sizeof(int)=4,也就是参数在内存中的地址都为4的倍数。比如,如果sizeof(n)在1-4之间,那么_INTSIZEOF(n)=4;如果sizeof(n)在5-8之间,那么_INTSIZEOF(n)=8。
#define va_start(ap,v)
( ap
= (va_list)&v
+ _INTSIZEOF(v)
)
这里就是解决问题的入手位置了,c语言可变长参数是规定格式的:类型
函数名(firstfix,…,lastfix,…)
,其中“firstfix,…,lastfix”表示函数参数列表中的第一个固定参数一
直到最后一个固定参数,该参数列表至少要有一个固定参数,其作用是为了给变参函数确定列表中参数的个数和参数的类型,并且代表可变参数的省略号只能出现在
尾部。如果不这样,编译可能通过,但是跟通常实现可变长参数的方法就有所出入,执行会出错的,所以说指针是把双刃剑,用起来要小心。
va_start的定义为
&v+_INTSIZEOF(v),这里&v应该让其等于最后一个固定参数的起始地址,再加上其实际占用大小后,就得到了第一个可变参数的起始内存地址。所以我们运行
va_start(ap,
v)以后,ap指向第一个可变参数在的内存地址。
#define va_arg(ap,t)
( *(t
*)((ap += _INTSIZEOF(t))
- _INTSIZEOF(t))
)
这个宏做了两个事情,
①用用户输入的类型名对参数地址进行强制类型转换,得到用户所需要的值
②计算出本参数的实际大小,将指针调到本参数的结尾,也就是下一个参数的首地址,以便后续处理。
#define
va_end(ap) (
ap =
(va_list)0 )
x86平台定义为ap=(char*)0;使ap不再指向堆栈,而是跟NULL一样.有些直接定义为((void*)0),这样编译器不会为va_end产生代码,例如gcc在linux的x86平台就是这样定义的.
在这里大家要注意一个问题:由于参数的地址用于va_start宏,所以参数不能声明为寄存器变量或作为函数或数组类型.
所以一个可变长参数的一个大概的使用过程类似:
void f1(int n,...)
{
va_list ap;
va_start(ap,n);
//初始化参数列表,ap变为指向固定参数n下一个参数即第一个可变参数
double
first=va_arg(ap,double);
//取第一个参数
int second=va_arg(ap,int);
//取第二个参数
...
va_end(ap);
//清理工作
}
好了,我再通过形象的内存分布图来解释,假设我现在这样调用printf这个函数
char val=94;
//定义一个字符变量并赋值
char* str="gediaosi";
//定义个字符串指针变量,并对字符串赋值
printf("liyuepeng%d%s",val,str);
//打印“liyuepeng94gediaosi”
好了当程序执行到上面的printf函数里面的时候,内存分布的形势应该是酱婶(东北话)的:
所以进入函数里面调用 va_start(ap,v)宏,
把&v指向最后一个固定参数,这里是行参fmt,fmt是个字符串指针,在32位机中地址指针占4个子节,所以ap就是&v加上4就指向
了第一个可变参数val的最后被压栈的字节,同时也是val变量本身的地址所指向的位置。然后就是一边解析前面的字符串,一边调用变量,一直到字符串结束
的标志符停止。
另外在补充在一点再网上看到的,这样就能加深理解:
C程序栈底为高地址,栈顶为低地址,因此上面的实例可以说明函数参数入栈顺序的确是从右至左的。可到底为什么呢?查了一直些文献得知,参数入栈顺序是和具
体编译器实现相关的。比如,Pascal语言中参数就是从左到右入栈的,有些语言中还可以通过修饰符进行指定,如Visual
C++.即然两种方式都可以,为什么C语言要选择从右至左呢?
进一步发现,Pascal语言不支持可变长参数,而C语言支持这种特色,正是这个原因使得C语言函数参数入栈顺序为从右至左。具体原因为:C方式参数入栈
顺序(从右至左)的好处就是可以动态变化参数个数。通过栈堆分析可知,自左向右的入栈方式,最前面的参数被压在栈底。除非知道参数个数,否则是无法通过栈
指针的相对位移求得最左边的参数。这样就变成了左边参数的个数不确定,正好和动态参数个数的方向相反。
2013-3-10更新:
关于可变参数的注意:
《C程序设计》中说,在没有函数原型的情况下,char与short类型都将被转换为int类型,float类型将被转换为double类型。这里其实就是说C语言的默认实际参数提升规则,因为如果一个参数没有声明,编译器就没有信息去对它执行标准的类型检查和转换。
所以printf中float会自动转换为double,所以float和double在栈中都占用8个子节,都用%f输出,至于%lf是针对long
double,这种类型是新增加的类型,不同编译器编译成不同大小,但是肯定大于等于double型,我认为如果double用%lf是正常的就说明这个编译器把long
double和double定义成同样大小。
至于scanf函数这个函数虽然也是未由于是传递进入的指针,更改指针指向的变量,并不能对相应变量类型进行改变,所以float用%f,double用%lf。
好了,关于可变长参数的实现就讲到这里,至于printf函数的实现还有一些格式化字符串和与系统交互的问题,这个就以后再研究了。
其实printf不只有上面的那种声明还有一种:int printf( const char*
format,
...),这个不过是限定了format这个指针变量由编译器限定不可改变,而且在不同的环境下实现会有所不同,不过接口都是一致的,但这样使其不可改变我个人觉比较完备,因为format的值在可变参数的处理中起到决定性作用,但是我还不明白有什么情况这个值会被不发觉地改变,如果有哪位读者知道了希望能告诉我。
另外在研究过程中我还有几个问题不明白望请教:
1:大小端对字节对齐的影响,假如一个字符型由于对齐而扩充到了4个字节,那么指向这个字符的地址指的是存有原来数据的字节还是扩充的字节?
2:不同类型的参数(传值和指针)压栈的情况和是否加const压栈的情况是否有所不同?
最后感谢一下查阅和引用所有资料的作者,因为比较多实在无法一一注明出处,感谢这些在我成长的道路上帮助我的人,我也会努力地帮助其他人。
作者水平有限,难免有错误和疏漏,欢迎指正,允许转载,尽量注明出处,因为我可能有所更正。
|