天天看點

透析C語言可變參數問題

一、是什麼

我們學習C語言時最經常使用printf()函數,但我們很少了解其原型。其實printf()的參數就是可變參數,想想看,我們可以利用它列印出各種類型的資料。下面我們來看看它的原型:

int printf( const char* format, ...);

它的第一個參數是format,屬于固定參數,後面跟的參數的個數和類型都是可變的(用三個點“…”做參數占位符),實際調用時可以有以下的形式:

printf("%d",i);

printf("%s",s);

printf("the number is %d ,string is:%s", i, s);

那麼它的原型是怎樣實作的呢?我今天在看核心代碼時碰到了vsprintf,花了大半天時間,終于把它搞的有點明白了。

二、先看兩個例子

不必弄懂,先大緻了解其用法,繼續往下看。

①一個簡單的可變參數的C函數

在函數simple_va_fun參數清單中至少有一個整數參數,其後是占位符…表示後面參數的個數不定.。在這個例子裡,所有輸入參數必須都是整數,函數的功能隻是列印所有參數的值。

#include <stdio.h>

#include <stdarg.h>

void simple_va_fun(int start, ...)

{

       va_list arg_ptr;

       int nArgValue =start;

       int nArgCout=0;     //可變參數的數目

       va_start(arg_ptr,start); //以固定參數的位址為起點确定變參的記憶體起始位址。

       do

       {

              ++nArgCout;

              printf("the %d th arg: %d/n",nArgCout,nArgValue);     //輸出各參數的值

              nArgValue = va_arg(arg_ptr,int);                      //得到下一個可變參數的值

       } while(nArgValue != -1);               

       return;

}

int main(int argc, char* argv[])

{

       simple_va_fun(100,-1);

       simple_va_fun(100,200,-1);

       return 0;

}

②格式化到一個檔案流,可用于日志檔案

FILE *logfile;

int WriteLog(const char * format, ...)

{

va_list arg_ptr;

va_start(arg_ptr, format);

int nWrittenBytes = vfprintf(logfile, format, arg_ptr);

va_end(arg_ptr);

return nWrittenBytes;

}

稍作解釋上面兩個例子。

【這部分的引用位址http://www.cppblog.com/lmlf001/archive/2006/04/19/5874.html】

從這個函數的實作可以看到,我們使用可變參數應該有以下步驟:

⑴在程式中用到了以下這些宏:

void va_start( va_list arg_ptr, prev_param );

type va_arg( va_list arg_ptr, type );

void va_end( va_list arg_ptr );

va在這裡是variable-argument(可變參數)的意思.

這些宏定義在stdarg.h中,是以用到可變參數的程式應該包含這個頭檔案.

⑵函數裡首先定義一個va_list型的變量,這裡是arg_ptr,這個變量是存儲參數位址的指針.因為得到參數的位址之後,再結合參數的類型,才能得到參數的值。

⑶然後用va_start宏初始化⑵中定義的變量arg_ptr,這個宏的第二個參數是可變參數清單的前一個參數,即最後一個固定參數.

⑷然後依次用va_arg宏使arg_ptr傳回可變參數的位址,得到這個位址之後,結合參數的類型,就可以得到參數的值。

⑸設定結束條件,①是判斷參數值是否為-1。注意被調的函數在調用時是不知道可變參數的正确數目的,程式員必須自己在代碼中指明結束條件。②是調用宏va_end。

三、剖析可變參數真相

1. va_* 宏定義

我們已經知道va_start,va_arg,va_end是在stdarg.h中被定義成宏的, 由于1)硬體平台的

不同 2)編譯器的不同,是以定義的宏也有所不同。下面看一下VC++6.0中stdarg.h裡的代碼

(檔案的路徑為VC安裝目錄下的/vc98/ include/stdarg.h)

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 )

再來看看linux中的定義

typedef char *va_list;

#define __va_rounded_size(TYPE) (((sizeof (TYPE) + sizeof (int) - 1) / sizeof (int)) * sizeof (int))

#define va_start(AP, LASTARG) (AP=((char*)&(LASTARG) + __va_rounded_size (LASTARG))

void va_end (va_list);

#define va_end(AP) (AP= (char *)0)

#define va_arg(AP,TYPE) (AP+=__va_rounded_size(TYPE),/

*((TYPE *)(AP - __va_rounded_size (TYPE))))

要了解上面這些宏定義的意思,需要首先了解:

①棧的方向②參數的入棧順序③CPU的對齊方式④記憶體位址的表達方式。

2.棧——以Intel 32位的CPU為分析基礎

在Intel CPU中,棧的生長方向是向下的,即棧底在高位址,而棧頂在低位址;從棧底向棧頂看過去,位址是從高位址走向低位址的,因為稱它為向下生長,如圖。

透析C語言可變參數問題

【圖1 引用自 http://www.yuanma.org/data/2008/0504/article_3027_1.htm,這部分内容,我認為作者講的很詳細,是以引來共享】

從上面壓棧前後的兩個圖可明顯看到棧的生長方向,在Intel 32位的CPU中,windown或linux都使用了它的保護模式,ss指定棧所有在的段,ebp指向棧基址,esp指向棧頂。顯然執行push指令後,esp的值會減4,而pop後,esp值增加4。 棧中每個元素存放空間的大小決定push或pop指令後esp值增減和幅度。Intel 32位CPU中的棧元素大小為16位或32位,由定義堆棧段時定義。在Window和Linux系統中,核心代碼已定義好棧元素的大小為32位,即一個字長(sizeof(int))。是以使用者空間程棧元素的大小肯定為32位,這樣每個棧元素的位址向4位元組對齊。

C語言的函數調用約定對編寫可變參數函數是非常重要的,隻有清楚了,才更欲心所欲地控制程式。在進階程式設計語言中,函數調用約定有如下幾種,stdcall,cdecl,fastcall ,thiscal,naked call。cdel是C語言中的标準調用約定,如果在定義函數中不指明調用約定(在函數名前加上約定名稱即可),那編譯器認為是cdel約定,從上面的幾種約定來看,隻有cdel約定才可以定義可變參數函數。下面是cdel約定的重要特征:如果函數A調用函數B,那麼稱函數A為調用者(caller),函數B稱為被調用者(callee)。caller把向callee傳遞的參數存放在棧中,并且壓棧順序按參數清單中從右向左的順序;callee不負責清理棧,而是由caller清理。 我們用一個簡單的例子來說明問題,并采用Nasm的彙編格式寫相應的彙編代碼,程式段如下:

void callee(int a, int b)

{

int c = 0;

c = a +b;

}

void caller()

{

callee(1,2);

}

來分析一下在調用過程發生了什麼事情。程式執行點來到caller時,那将要執行調用callee函數,在跳到callee函數前,它先要把傳遞的參數壓到棧上,并按右到左的順序,即翻譯成彙編指令就是push 2; push 1;

透析C語言可變參數問題

圖2

函數棧如圖中(a)所示。接着跳到callee函數,即指令call calle。CPU在執行call時,先把目前的EIP寄存器的值壓到棧中,然後把EIP值設為callee(位址),這樣,棧的圖變為如圖2(b)。程式執行點跳到了callee函數的第一條指令。C語言在函數調用時,每個函數占用的棧段稱為stack frame。用ebp來記住函數stack frame的起始位址。故在執行callee時,最前的兩條指令為:

push ebp

mov esp, ebp

經過這兩條語句後,callee函數的stack frame就建好了,棧的最新情況如圖2(c)所示。 函數callee定義了一個局部變量int c,該變量的儲存空間配置設定在callee函數占用的棧中,大小為4位元組(insizeof int)。那麼callee會在如下指令:

sub esp, 4

mov [ebp-4], 0

這樣棧的情況又發生了變化,最新情況如圖2(d)所示。注意esp總是指向棧頂,而ebp作為函數的stack frame基址起到很大的作用。ebp位址向下的空間用于存放局部變量,而它向上的空間存放的是caller傳遞過來的參數,當然編譯器會記住變量c相對ebp的位址偏移量,在這裡為-4。跟着執行c = a + b語句,那麼指令代碼應該類似于:

mov eax , [ebp +  8] ;這裡用eax存放第一個傳遞進來的參數,記住第一個參數與ebp的偏移量肯定為8

add eax,  [ebp + 12] ;第二個參數與ebp的偏移量為12,故計算eax = a+b

mov [ebp -4], eax  ;執行 c = eax, 即c = a+b

棧又有了新了變化,如圖2(e)。至此,函數callee的計算指令執行完畢,但還要做一些事情:釋放局部變量占用的棧空間,銷除函數的stack-frame過程會生成如下指令:

mov esp, ebp;把局部變量占用的空間全部略過,即不再使用,ebp以下的空間全部用于局部變量

pop ebp;彈出caller函數的stack-frame 基址

在Intel CPU裡上面兩條指令可以用指令leave來代替,功能是一樣。這樣棧的内容如圖2(f)所示。最後,要傳回到caller函數,是以callee的最後一條指令是

ret

ret指令用于把棧上的儲存的斷點彈出到EIP寄存器,新的棧内容如圖2(g)所示。函數callee的調用與傳回全部結束,跟着下來是執行call callee的下一條語句。

從caller函數調用callee前,把傳遞的參數壓到棧中,并且按從右到左的順序;函數傳回時,callee并不清理棧,而是由caller清楚傳遞參數所占用的棧(如上圖,函數傳回時,1和2還放在棧中,讓caller清理)。棧元素的大小為4個位元組,每個參數占用棧空間大小為4位元組的倍數,并且任何兩個參數都不能共用同一個棧元素。

從C語言的函數調用約定可知,參數清單從右向左依次壓棧,故可變參數壓在棧的位址比最後一個命名參數還大,如下圖3所示:

透析C語言可變參數問題

由圖3可知,最後一個命名參數a上面都放着可變參數,每個參數占用棧的大小必為4的倍數。是以:可變參數1的位址 = 參數a的位址 + a占用棧的大小,可變參數2的位址 = 可變參數1的位址 + 可變參數1占用棧的大小,可變參數3的位址 = 可變參數2的位址 + 可變參數2占用棧的大小,依此類推。如何計算每個參數占用棧的大小呢?

3.資料對齊問題

對于兩個正整數 x, n 總存在整數 q, r 使得

x = nq + r, 其中  0<= r <n                  //最小非負剩餘

q, r 是唯一确定的。q = [x/n], r = x - n[x/n]. 這個是帶餘除法的一個簡單形式。在 c 語言中, q, r 容易計算出來: q = x/n, r = x % n.

所謂把 x 按 n 對齊指的是:若 r=0, 取 qn, 若 r>0, 取 (q+1)n. 這也相當于把 x 表示為:

x = nq + r', 其中 -n < r' <=0                //最大非正剩餘  

nq 是我們所求。關鍵是如何用 c 語言計算它。由于我們能處理标準的帶餘除法,是以可以把這個式子轉換成一個标準的帶餘除法,然後加以處理:

x+n = qn + (n+r'),其中 0<n+r'<=n            //最大正剩餘

x+n-1 = qn + (n+r'-1), 其中 0<= n+r'-1 <n    //最小非負剩餘

是以 qn = [(x+n-1)/n]n. 用 c 語言計算就是:

((x+n-1)/n)*n

若 n 是 2 的方幂, 比如 2^m,則除為右移 m 位,乘為左移 m 位。是以把 x+n-1 的最低 m 個二進制位清 0就可以了。得到:

(x+n-1) & (~(n-1))

【來自CSDN部落格:http://blog.csdn.net/swell624/archive/2008/11/03/3210779.aspx】

根據這些推導,相信已經了解#define __va_rounded_size(TYPE)  (((sizeof (TYPE) + sizeof (int) - 1) / sizeof (int)) * sizeof (int))的涵義。

       4.再看va_* 宏定義

va_start(va_list ap, last)

last為最後一個命名參數,va_start宏使ap記錄下第一個可變參數的位址,原理與“可變參數1的位址 = 參數a的位址 + a占用棧的大小”相同。從ap記錄的記憶體位址開始,認為參數的資料類型為type并把它的值讀出來;把ap記錄的位址指向下一個參數,即ap記錄的位址 += occupy_stack(type)

va_arg(va_lit ap, type)

這裡是獲得可變參數的值,具體工作是:從ap所指向的棧記憶體中讀取類型為type的參數,并讓ap根據type的大小記錄它的下一個可變參數位址,便于再次使用va_arg宏。從ap記錄的記憶體位址開始,認為存的資料類型為type并把它的值讀出來;把ap記錄的位址指向下一個參數,即ap記錄的位址 += occupy_stack(type)

va_end(va_list ap)

用于“釋放”ap變量,它與va_start對稱使用。在同一個函數内有va_start必須有va_end。

5.可變參數函數問題

考慮了參數大小和資料對齊問題,使得可變參數的類型不但可以是基本類型,同樣适用于使用者定義類型。值的注意的是,如果是使用者定義類型,最好用typedef定義的名字作為類型名,這樣就會減少在va_arg進行宏展開時出錯的機率。

在可變參數函數中,由va_list變量來記錄(或獲得)可變參數部分,但是va_list中并沒有記錄下它們的名字,事實上也是不可能的。要想把可變參數部分傳遞給下一個函數,唯有通過va_list變量去傳遞,而原來定義的函數用"..."來表示可變參數部分,而不是用va_list來表示。為了友善程式的标準化,ANSIC在标準庫代碼中就作出了很好的榜樣:在任何形如: type fun( type arg1, type arg2, ...)的函數,都同時定義一個與它功能完全一樣的函數,但用va_list類型來替換"...",即type fun(type arg1, type arg2, va_list ap)。以printf函數為例:

int printf(const char *format, ...);    

int vprintf(const char *format, va_list ap);

第一個函數用"..."表示可變參數,第二個用va_list類型表示可變參數,目的是用于被其它可變參數調用,兩者在功能功能上是完全上一樣。隻是在函數名字相差一個'"v"字母。

四、可變參數函數的應用

一個<The C Programming Language>中的例子:一個簡單的實作printf函數的例子:

#include <stdio.h>

#include <stdlib.h>

#include <stdarg.h>

void minprintf(char *fmt, ...)

{

       va_list ap;

       char *p, *sval;

       int ival;

       double dval;

       va_start(ap, fmt);

       for (p = fmt; *p; p++) {

              if (*p != '%') {

                     putchar(*p);

                     continue;

              }

              switch (*++p) {

                 case 'd':

                        ival = va_arg(ap, int);

                        printf("%d", ival);

                        break;

                 case 'x':

                        ival=va_arg(ap,int);

                        printf("%#x",ival);

                        break;

                 case 'f':

                        dval = va_arg(ap, double);

                        printf("%f", dval);

                        break;

                 case 's':

                        for (sval = va_arg(ap, char *); *sval; sval++)

                               putchar(*sval);

                        break;

                 default:

                        putchar(*p);

                        break;

              }

       }

       va_end(ap);

}

int main(int argc, char* argv[])

{

       int i = 1234;

       int j = 5678;

       char *s="nihao";

       double f=0.11f;

       minprintf("the first test:i=%d/n",i,j);

       minprintf("the secend test:i=%d; %x;j=%d;",i,0xabcd,j);

       minprintf("the 3rd test:s=%s/n",s);

       minprintf("the 4th test:f=%f/n",f);

       minprintf("the 5th test:s=%s,f=%f/n",s,f);

       system("pause");

       return 0;

}

不使用va_*宏定義的實作:

void minprintf(char* fmt, ...) //一個簡單的類似于printf的實作不過參數必須都是int 類型

{

       char* pArg=NULL;               //等價于原來的va_list

       char c;

       pArg = (char*) &fmt; //注意不要寫成p = fmt !因為這裡要對//參數取址,而不是取值

       pArg += sizeof(fmt);         //等價于原來的va_start       

       do

       {

              c =*fmt;

              if (c != '%')

              {

                     putchar(c);            //照原樣輸出字元

              }

              else

              {

                     //按格式字元輸出資料

                     switch(*++fmt)

                     {

                     case 'd':

                            printf("%d",*((int*)pArg));          

                            break;

                     case 'x':

                            printf("%#x",*((int*)pArg));

                            break;

                     default:

                            break;

                     }

                     pArg += sizeof(int);               //等價于原來的va_arg

              }

              ++fmt;

       }while (*fmt != '/0');

       pArg = NULL;                               //等價于va_end

       return;

}

五、參考引用:

1.       http://www.cppblog.com/lmlf001/archive/2006/04/19/5874.html

2.       http://hi.baidu.com/phps/blog/item/1fe5768d628c6112b21bba87.html

3.       http://www.yuanma.org/data/2008/0504/article_3027_2.htm

4.       http://hi.baidu.com/vama/blog/item/f188603f2cd315cc9f3d62cf.html

感謝以上 !

繼續閱讀