C/C++ 可变参数表的深层探索


参考网文: http://www.hur.cn/program/cc/cc15/200703/120782.html

C语言中有些函数使用可变参数,比如常见的int printf( const char* format, ...),第一个参数format是固定的,其余的参数的个数和类型都不固定。

C语言用va_start等宏来处理这些可变参数。这些宏看起来很复杂,其实原理挺简单,就是根据参数入栈的特点从最靠近第一个可变参数的固定参数开始,依次获取每个可变参数的地址。下面我们来分析这些宏。

在stdarg.h头文件中,针对不同平台有不同的宏定义,我们选取X86平台下的宏定义:
Quote:

        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 )

_INTSIZEOF(n)宏是为了考虑那些内存地址需要对齐的系统,从宏的名字来应该是跟sizeof(int)对齐。一般的sizeof(int)= 4,也就是参数在内存中的地址都为4的倍数。比如,如果sizeof(n)在1-4之间,那么_INTSIZEOF(n)=4;如果sizeof(n)在 5-8之间,那么_INTSIZEOF(n)=8。具体细节可以参见:数据在机器内的存储与运算章节。

在这些宏中,va就是variable argument(可变参数)的意思;arg_ptr是指向可变参数表的指针;prev_param则指可变参数表的前一个固定参数;type为可变参数的类型。va_list也是一个宏,其定义为typedef char * va_list,实质上是一char型指针。char型指针的特点是++、--操作对其作用的结果是增1和减1(因为sizeof(char)为1),与之不同的是int等其它类型指针的++、--操作对其作用的结果是增sizeof(type)或减sizeof(type),而且sizeof (type)大于1。

结论:
(1)通过va_start宏我们可以取得可变参数表的首指针;显而易见,其含义为将最后那个固定参数的地址加上可变参数对其的偏移后赋值给ap,这样ap就是可变参数表的首地址。
(2)va_arg宏的意思则指取出当前arg_ptr所指的可变参数并将ap指针指向下一可变参数。
(3)va_end宏被用来结束可变参数的获取;可以看出,va_end ( list )实际上被定义为空,没有任何真实对应的代码,用于代码对称,与va_start对应;另外,它还可能发挥代码的"自注释"作用。所谓代码的"自注释",指的是代码能自己注释自己。

为了能从固定参数依次得到每个可变参数,va_start,va_arg充分利用下面两点:

  1. C语言在函数调用时,先将最后一个参数压入栈

  2. X86平台下的内存分配顺序是从高地址内存到低地址内存

  高位地址

  第N个可变参数

  。。。

  第二个可变参数

  第一个可变参数      ? ap

  固定参数          ? v

  低位地址

  由上图可见,v是固定参数在内存中的地址,在调用va_start后,ap指向第一个可变参数。这个宏的作用就是在v的内存地址上增加v所占的内存大小,这样就得到了第一个可变参数的地址。

  接下来,可以这样设想,如果我能确定这个可变参数的类型,那么我就知道了它占用了多少内存,依葫芦画瓢,我就能得到下一个可变参数的地址。

  让我再来看看va_arg,它先ap指向下一个可变参数,然后减去当前可变参数的大小即得到当前可变参数的内存地址,再做个类型转换,返回它的值。

   要确定每个可变参数的类型,有两种做法,要么都是默认的类型,要么就在固定参数中包含足够的信息让程序可以确定每个可变参数的类型。比如,printf,程序通过分析format字符串就可以确定每个可变参数大类型。

  最后一个宏就简单了,va_end使得ap不再指向有效的内存地址。

  看了这几个宏,不禁让我再次感慨,C语言太灵活了,而且代码可以写得非常简洁,虽然有时候让人看得不是很明白,但是一旦明白 过来,你肯定会为它击掌叫好!

  其实在varargs.h头文件中定义了UNIX System V实行的va系列宏,而上面在stdarg.h头文件中定义的是ANSI C形式的宏,这两种宏是不兼容的,一般说来,我们应该使用ANSI C形式的va宏。

举一个使用的例子:
Quote:

#include <stdarg.h>
/* 函数名:max
* 功能:返回n个整数中的最大值
* 参数:num:整数的个数 ...:num个输入的整数
* 返回值:求得的最大整数
*/
int max ( int num, ... )
{
 int m = -0x7FFFFFFF; /* 32系统中最小的整数 */
 va_list ap;
 va_start ( ap, num );
 for ( int i= 0; i< num; i++ )
 {
  int t = va_arg (ap, int);
  if ( t > m )
  {
   m = t;
  }
 }
 va_end (ap);
 return m;
}
/* 主函数调用max */
int main ( int argc, char* argv[] )
{
 int n = max ( 5, 5, 6 ,3 ,8 ,5); /* 求5个整数中的最大值 */
 cout << n;
 return 0;
}


以下摘录自参考的网文,有待进一步仔细推敲:

高级应用

  下面这个程序是我们为某嵌入式系统(该系统中CPU的字长为16位)编写的在屏幕上显示格式字符串的函数 DrawText,它的用法类似于int printf ( const char *format, ... )函数,但其输出的目标为嵌入式系统的液晶显示屏幕(LED)。
Quote:

///
//  函数名称: DrawText
//  功能说明: 在显示屏上绘制文字
//  参数说明: xPos ---横坐标的位置 [0 .. 30]
//  yPos ---纵坐标的位置 [0 .. 64]
//  ... 可以同数字一起显示,需设置标志(%d、%l、%x、%s)
///
extern   void  DrawText ( BYTE xPos, BYTE yPos, LPBYTE lpStr, ... )
{
 BYTE lpData[
100]; //缓冲区
 BYTE byIndex;
 BYTE byLen;
 DWORD dwTemp;
 WORD wTemp;
 
int i;
 va_list lpParam;

 memset( lpData, 
0100);
 byLen 
= strlen( lpStr );
 byIndex 
= 0;
 va_start ( lpParam, lpStr );

 
for ( i = 0; i < byLen; i++ )
 
{
  
if( lpStr != ’%’ ) //不是格式符开始
  {
   lpData[byIndex
++= lpStr;
  }

  
else
  
{
   
switch (lpStr[i+1])
   
{
    
//整型
    case ’d’:
    
case ’D’:
     wTemp 
= va_arg ( lpParam, int );
     byIndex 
+= IntToStr( lpData+byIndex, (DWORD)wTemp );
     i
++;
     
break;
     
//长整型
    case ’l’:
    
case ’L’:
     dwTemp 
= va_arg ( lpParam, long );
     byIndex 
+= IntToStr ( lpData+byIndex, (DWORD)dwTemp );
     i
++;
     
break;
     
//16进制(长整型)
    case ’x’:
    
case ’X’:
     dwTemp 
= va_arg ( lpParam, long );
     byIndex 
+= HexToStr ( lpData+byIndex, (DWORD)dwTemp );
     i
++;
     
break;
    
default:
     lpData[byIndex
++= lpStr;
     
break;
   }

  }

 }

 va_end ( lpParam );
 lpData[byIndex] 
= ’0’;
 DisplayString ( xPos, yPos, lpData, TRUE); 
//在屏幕上显示字符串lpData
}



在这个函数中,需通过对传入的格式字符串(首地址为lpStr)进行识别来获知可变参数个数及各个可变参数的类型,具体实现体现在for循环中。譬如,在识别为%d后,做的是va_arg ( lpParam, int ),而获知为%l和%x后则进行的是va_arg ( lpParam, long )。格式字符串识别完成后,可变参数也就处理完了。

  在项目的最初,我们一直苦于不能找到一个好的办法来混合输出字符串和数字,我们采用了分别显示数字和字符串的方法,并分别指定坐标,程序条理被破坏。而且,在混合显示的时候,要给各类数据分别人工计算坐标,我们感觉头疼不已。以前的函数为:
Quote:

//显示字符串
showString ( BYTE xPos, BYTE yPos, LPBYTE lpStr )
//显示数字
showNum ( BYTE xPos, BYTE yPos, int num )
//以16进制方式显示数字
showHexNum ( BYTE xPos, BYTE yPos, int num )


最终,我们用DrawText ( BYTE xPos, BYTE yPos, LPBYTE lpStr, ... )函数代替了原先所有的输出函数,程序得到了简化。就这样,兄弟们用得爽翻了。

运行机制探索

  通过第2节我们学会了可变参数表的使用方法,相信喜欢抛根问底的读者还不甘心,必然想知道如下问题:

  (1)为什么按照第2节的做法就可以获得可变参数并对其进行操作?

  (2)C/C++在底层究竟是依靠什么来对这一语法进行支持的,为什么其它语言就不能提供可变参数表呢?

  我们带着这些疑问来一步步进行摸索。

  3.1 调用机制反汇编

  反汇编是研究语法深层特性的终极良策,先来看看2.2节例子中主函数进行max ( 5, 5, 6 ,3 ,8 ,5)调用时的反汇编:

1. 004010C8 push 5
2. 004010CA push 8
3. 004010CC push 3
4. 004010CE push 6
5. 004010D0 push 5
6. 004010D2 push 5
7. 004010D4 call @ILT+5(max) (0040100a)

  从上述反汇编代码中我们可以看出,C/C++函数调用的过程中:

  第一步:将参数从右向左入栈(第1~6行);

  第二步:调用call指令进行跳转(第7行)。

  这两步包含了深刻的含义,它说明C/C++默认的调用方式为由调用者管理参数入栈的操作,且入栈的顺序为从右至左,这种调用方式称为_cdecl调用。x86系统的入栈方向为从高地址到低地址,故第1至n个参数被放在了地址递增的堆栈内。在被调用函数内部,读取这些堆栈的内容就可获得各个参数的值,让我们反汇编到max函数的内部:

int  max (  int  num, ...)
{
100401020 push ebp
200401021 mov ebp,esp
300401023 sub esp,50h
400401026 push ebx
500401027 push esi
600401028 push edi
700401029 lea edi,[ebp-50h]
8. 0040102C mov ecx,14h
900401031 mov eax,0CCCCCCCCh
1000401036 rep stos dword ptr [edi]
va_list ap;
int m = -0x7FFFFFFF/* 32系统中最小的整数 */
1100401038 mov dword ptr [ebp-8],80000001h
va_start ( ap, num );
12. 0040103F lea eax,[ebp+0Ch]
1300401042 mov dword ptr [ebp-4],eax
for ( int i= 0; i< num; i++ )
1400401045 mov dword ptr [ebp-0Ch],0
15. 0040104C jmp max+37h (00401057)
16. 0040104E mov ecx,dword ptr [ebp-0Ch]
1700401051 add ecx,1
1800401054 mov dword ptr [ebp-0Ch],ecx
1900401057 mov edx,dword ptr [ebp-0Ch]
20. 0040105A cmp edx,dword ptr [ebp+8]
21. 0040105D jge max+61h (00401081)
{
int t= va_arg (ap, int);
22. 0040105F mov eax,dword ptr [ebp-4]
2300401062 add eax,4
2400401065 mov dword ptr [ebp-4],eax
2500401068 mov ecx,dword ptr [ebp-4]
26. 0040106B mov edx,dword ptr [ecx-4]
27. 0040106E mov dword ptr [t],edx
if ( t > m )
2800401071 mov eax,dword ptr [t]
2900401074 cmp eax,dword ptr [ebp-8]
3000401077 jle max+5Fh (0040107f)
= t;
3100401079 mov ecx,dword ptr [t]
32. 0040107C mov dword ptr [ebp-8],ecx
}

33. 0040107F jmp max+2Eh (0040104e)
va_end (ap);
3400401081 mov dword ptr [ebp-4],0
return m;
3500401088 mov eax,dword ptr [ebp-8]
}

36 . 0040108B pop edi
37 . 0040108C pop esi
38 . 0040108D pop ebx
39 . 0040108E mov esp,ebp
40 00401090  pop ebp
41 00401091  ret

  分析上述反汇编代码,对于一个真正的程序员而言,将是一种很大的享受;而对于初学者,也将使其受益良多。所以请一定要赖着头皮认真研究,千万不要被吓倒!

  行1~10进行执行函数内代码的准备工作,保存现场。第2行对堆栈进行移动;第3行则意味着max函数为其内部局部变量准备的堆栈空间为50h字节;第11行表示把变量n的内存空间安排在了函数内部局部栈底减8的位置(占用4个字节)。

  第12~13行非常关键,对应着va_start ( ap, num ),这两行将第一个可变参数的地址赋值给了指针ap。另外,从第12行可以看出num的地址为ebp+0Ch;从第13行可以看出ap被分配在函数内部局部栈底减4的位置上(占用4个字节)。

  第22~27行最为关键,对应着va_arg (ap, int)。其中,22~24行的作用为将ap指向下一可变参数(可变参数的地址间隔为4个字节,从add eax,4可以看出);25~27行则取当前可变参数的值赋给变量t。这段反汇编很奇怪,它先移动可变参数指针,再在赋值指令里面回过头来取先前的参数值赋给t(从mov edx,dword ptr [ecx-4]语句可以看出)。Visual C++同学玩得有意思,不知道碰见同样的情况Visual Basic等其它同学怎么玩?

  第36~41行恢复现场和堆栈地址,执行函数返回操作。

  痛苦的反汇编之旅差不多结束了,看了这段反汇编我们总算弄明白了可变参数的存放位置以及它们被读取的方式,顿觉全省轻松!

  2、特殊的调用约定

  除此之外,我们需要了解C/C++函数调用对参数占用空间的一些特殊约定,因为在_cdecl调用协议中,有些变量类型是按照其它变量的尺寸入栈的。

  例如,字符型变量将被自动扩展为一个字的空间,因为入栈操作针对的是一个字。

  参数n实际占用的空间为( ( sizeof(n) + sizeof(int) - 1 ) & ~( sizeof(int) - 1 ) ),这就是第2.1节_INTSIZEOF(v)宏的来历!

  既然如此,前面给出的va_arg ( list, mode )宏为什么玩这么大的飞机就很清楚了。这个问题就留个读者您来分析。 

 
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值