http://dev.yesky.com/424/2110924.shtml
引言
C/C++語言有一個不同於其它語言的特性,即其支援可變參數,典型的函數如printf、scanf等可以接受數量不定的參數。如:
printf ( "I love you" ); printf ( "%d", a ); printf ( "%d,%d", a, b ); |
第一、二、三個printf分別接受1、2、3個參數,讓我們看看printf函數的原型:
| int printf ( const char *format, ... ); |
從函數原型可以看出,其除了接收一個固定的參數format以外,後面的參數用"…"表示。在C/C++語言中,"…"表示可以接受不定數量的參數,理論上來講,可以是0或0以上的n個參數。
本文將對C/C++可變參數表的使用方法及C/C++支援可變參數表的深層機理進行探索。
可變參數表的用法
1、相關宏
標準C/C++包含標頭檔stdarg.h,該標頭檔中定義了如下三個宏:
void va_start ( va_list arg_ptr, prev_param ); /* ANSI version */ type va_arg ( va_list arg_ptr, type ); void va_end ( va_list arg_ptr ); |
在這些宏中,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。
通過va_start宏我們可以取得可變參數表的首指標,這個宏的定義為:
| #define va_start ( ap, v ) ( ap = (va_list)&v + _INTSIZEOF(v) ) |
顯而易見,其含義為將最後那個固定參數的地址加上可變參數對其的位移後賦值給ap,這樣ap就是可變參數表的首地址。其中的_INTSIZEOF宏定義為:
| #define _INTSIZEOF(n) ((sizeof ( n ) + sizeof ( int ) - 1 ) & ~( sizeof( int ) - 1 ) ) |
va_arg宏的意思則指取出當前arg_ptr所指的可變參數並將ap指標指向下一可變參數,其原型為:
#define va_arg(list, mode) ((mode *)(list =/ (char *) ((((int)list + (__builtin_alignof(mode)<=4?3:7)) &/ (__builtin_alignof(mode)<=4?-4:-8))+sizeof(mode))))[-1] |
對這個宏的具體含義我們將在後面深入討論。
而va_end宏被用來結束可變參數的擷取,其定義為:
可以看出,va_end ( list )實際上被定義為空白,沒有任何真實對應的代碼,用於代碼對稱,與va_start對應;另外,它還可能發揮代碼的"自注釋"作用。所謂代碼的"自注釋",指的是代碼能自己注釋自己。
下面我們以具體的例子來說明以上三個宏的使用方法。
2、一個簡單的例子
#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; } |
函數max中首先定義了可變參數表指標ap,而後通過va_start ( ap, num )取得了參數表首地址(賦給了ap),其後的for迴圈則用來遍曆可變參數表。這種遍曆方式與我們在資料結構教材中經常看到的遍曆方式是類似的。
函數max看起來簡潔明了,但是實際上printf的實現卻遠比這複雜。max函數之所以看起來簡單,是因為:
(1) max函數可變參數表的長度是已知的,通過num參數傳入;
(2) max函數可變參數表中參數的類型是已知的,都為int型。
而printf函數則沒有這麼幸運。首先,printf函數可變參數的個數不能輕易的得到,而可變參數的類型也不是固定的,需由格式字串進行識別(由%f、%d、%s等確定),因此則涉及到可變參數表的更複雜應用。
下面我們以執行個體來分析可變參數表的進階應用程式。
進階應用程式
下面這個程式是我們為某嵌入式系統(該系統中CPU的字長為16位)編寫的在螢幕上顯示格式字串的函數DrawText,它的用法類似於int printf ( const char *format, ... )函數,但其輸出的目標為嵌入式系統的液晶顯示螢幕(LED)。
/////////////////////////////////////////////////////////////////////////////// // 函數名稱: 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, 0, 100); byLen = strlen( lpStr ); byIndex = 0; va_start ( lpParam, lpStr ); for ( i = 0; i < byLen; i++ ) { if( lpStr[i] != ’%’ ) //不是格式符開始 { lpData[byIndex++] = lpStr[i]; } 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[i]; 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 )。格式字串識別完成後,可變參數也就處理完了。
在項目的最初,我們一直苦於不能找到一個好的辦法來混合輸出字串和數字,我們採用了分別顯示數字和字串的方法,並分別指定座標,程式條理被破壞。而且,在混合顯示的時候,要給各類資料分別人工計算座標,我們感覺頭疼不已。以前的函數為:
//顯示字串 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, ...) { 1. 00401020 push ebp 2. 00401021 mov ebp,esp 3. 00401023 sub esp,50h 4. 00401026 push ebx 5. 00401027 push esi 6. 00401028 push edi 7. 00401029 lea edi,[ebp-50h] 8. 0040102C mov ecx,14h 9. 00401031 mov eax,0CCCCCCCCh 10. 00401036 rep stos dword ptr [edi] va_list ap; int m = -0x7FFFFFFF; /* 32系統中最小的整數 */ 11. 00401038 mov dword ptr [ebp-8],80000001h va_start ( ap, num ); 12. 0040103F lea eax,[ebp+0Ch] 13. 00401042 mov dword ptr [ebp-4],eax for ( int i= 0; i< num; i++ ) 14. 00401045 mov dword ptr [ebp-0Ch],0 15. 0040104C jmp max+37h (00401057) 16. 0040104E mov ecx,dword ptr [ebp-0Ch] 17. 00401051 add ecx,1 18. 00401054 mov dword ptr [ebp-0Ch],ecx 19. 00401057 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] 23. 00401062 add eax,4 24. 00401065 mov dword ptr [ebp-4],eax 25. 00401068 mov ecx,dword ptr [ebp-4] 26. 0040106B mov edx,dword ptr [ecx-4] 27. 0040106E mov dword ptr [t],edx if ( t > m ) 28. 00401071 mov eax,dword ptr [t] 29. 00401074 cmp eax,dword ptr [ebp-8] 30. 00401077 jle max+5Fh (0040107f) m = t; 31. 00401079 mov ecx,dword ptr [t] 32. 0040107C mov dword ptr [ebp-8],ecx } 33. 0040107F jmp max+2Eh (0040104e) va_end (ap); 34. 00401081 mov dword ptr [ebp-4],0 return m; 35. 00401088 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 )宏為什麼玩這麼大的飛機就很清楚了。這個問題就留個讀者您來分析。