深度探索C語言函數可變長參數

來源:互聯網
上載者:User

標籤:

 通常我們使用的C函數的參數個數都是固定的,但也有不固定的。比如printf()與scanf()。如何自己動手實現一個可變參數函數,這個還是有點技巧的。

我們最常用的就是定義一個宏,使用printf或者printk,如下

#define wwlogk(fmt, args...) printk(fmt, ## args)

現在我們自己動手實現一個可變參數的函數,後面分析原理。首先看一個例子:

 

#include <stdio.h>

#include <stdarg.h>

int Sum(int first, int second, ...)//當無法列出傳遞函數的所有實

//參的類型和數目時,可用省略符號指定參數表

{

int sum = 0, t = first;

va_list vl;

va_start(vl, first);

while (t != -1){

sum += t;

t = va_arg(vl, int); //將當前參數轉換為int類型

}

va_end(vl);

return sum;

}

 

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

{

printf("The sum is %d\n", Sum(30, 20, 10, -1)); //-1是參數結束標誌

return 0;

}

在上面的例子中,實現了一個參數個數不定的求int型和的函數Sum()。 

其中有幾個變數需要說明一下。va_list、va_start()、va_end和va_arg。

Va_list:該類型變數用來訪問可變參數,實際上就是指標。

Va_start():是一個宏,用來擷取參數列表中的參數,使vl指向第一個可變參數,使用完畢後調用va_end()結束。

va_end:也是一個宏,用來結束va_start()的調用。

va_arg:宏,用來獲參數列表中的取下一個值。

 

在linux原始碼中,include/acpi/platform/acenv.h,標頭檔有詳細描述。

 

1、 va_list vl;

typedef char *va_list; //定義了一個新的類型,指向字串的指標。其實真實意圖是當指標移動是以"1"單位,因為sizeof(char) =1;即char類型佔一個位元組,int型佔4個位元組。

 

2、 va_start(vl, first) 使vl指向第一個可變參數,即。

#define va_start(ap, A) (void) ((ap) = (((char *) &(A)) + (_bnd (A,_AUPBND))))

#define _bnd(X, bnd) (((sizeof (X)) + (bnd)) & (~(bnd)))

_bnd(X, bnd)的定義主要是為了某些需要記憶體的對齊的系統,這個宏的目的是為了得到最後一個固定參數的實際記憶體大小。直接用sizeof也沒有影響。

#define _AUPBND (sizeof (acpi_native_int) - 1)

其中acpi_native_int是根據硬體平台來決定的,即一個int類型的寬度,即位元組。

typedef s64 acpi_native_int;

typedef s32 acpi_native_int;

typedef int s32;

typedef long long s64;

下面就這段宏代碼解釋一下:

進程運行時,將變數壓入棧,而(char *) &(A)指向first,即棧頂;

       使用宏_bnd (A,_AUPBND),主要是為了某些系統需要記憶體按照整數位元組對齊,因為C調用協議下面,參數入棧都是整數位元組(指標或者值)--- 所謂對齊,對Intel80x86 機器來說就是要求每個變數的地址都是sizeof(int)的倍數。那為什麼要對齊?因為在對齊下,CPU 的運行效率要快得多。

       樣本:如,當一個long 型數(中long1)在記憶體中的位置正好與記憶體的字邊界對齊時,CPU 存取這個數只需訪問一次記憶體,而當一個long 型數(中的long2)在記憶體中的位置跨越了字邊界時,CPU 存取這個數就需要多次訪問記憶體,如i960cx 訪問這樣的數需讀記憶體三次(一個BYTE、一個SHORT、一個BYTE,由CPU 的微代碼執行,對軟體透明),所以對齊下CPU 的運行效率明顯快多了。
1       8       16      24      32   
------- ------- ------- ---------
| long1 | long1 | long1 | long1 |
------- ------- ------- ---------
|        |        |         | long2 |
------- ------- ------- ---------
| long2 | long2 | long2 |        |
------- ------- ------- ---------

       因為_AUPBND為int寬度,那麼_bnd(A, _AUPBND)的意思就是不夠一個int寬度的資料,將還是跳過一個int寬度。比如char、short類型的資料sizeof後為1和2,假設現在是32位系統char類型,_bnd(X, bnd) = (1 + 3 ) & (~3) = 0x4; 2也一樣。因為32位系統int寬度為4。跳過以後,ap指向second,而first的值已經儲存在t中。這裡需要說明的是,因為題設已經指定first為int型。若為其他型(如char)則會出現錯誤,因為這裡首先跳過了4個位元組。

圖1 棧的結構

另外這裡還需要注意幾點:

  1. 因為C語言壓棧順序為從右至左。
  2. 棧的擴充方向是向下擴充,所以棧底為高地址,棧頂為低地址。

    比如假設f(a,b,c,d)按照從右至左壓棧,那麼d應該是第一個進棧的,a是最後一個進棧的,所以d的地址應該比a的高。在intel+ windows的機器上,函數棧的方向是向下的,棧頂指標的記憶體位址低於棧底指標,所以先進棧的資料是存放在記憶體的高地址處。

  3. C語言壓棧的時候,第一個進棧的是主函數中第一條指令的地址,然後依次是函數參數和局部變數。

    如上所述,va_start(vl,first)後,vl指向first後面的第一個可變參數。我們都知道Pascal的參數入棧順序時自左向右的但是C語言會是自右向左。為什麼呢?這也是C語言比pascal進階的一個地方--C語言通過這種參數入棧的順序實現了對變長參數函數的支援!

為了支援可變參數函數,C語言引入新的調用協議, 即C語言呼叫慣例 __cdecl . 採用C/C++語言編程的時候,預設使用這個呼叫慣例。如果要採用其它呼叫慣例,必須添加其它關鍵字聲明,例如WIN32 API使用PASCAL呼叫慣例,函數名字之前必須加__stdcall關鍵字。 採用C呼叫慣例時,函數的參數是從右至左入棧,個數可變。由於函數體不能預Crowdsourced Security Testing道傳進來的參數個數,因此採用本約定時必須由函數調用者負責堆棧清理。

3、#define va_arg(ap, T) (*(T *)(((ap) += (_bnd (T, _AUPBND))) - (_bnd (T,_ADNBND))))

    把這個宏展開可以看的更清楚:

  1. ap = ap + _bnd(T,_AUPBND) --首先將ap的跳過指定寬度,即指向下一個可變參數。
  2. *(T *)(ap - _bnd(T,_AUPBND)) --然後還原ap後,將其轉化為"T"型指標並求指標的值。

現在可以看清楚這個宏的意思是求當前ap指向的值,並將ap指向下一目標。

4、va_end(vl) 把vl指標清為NULL

    #define va_end(ap)    (void) 0

 

注意:這段代碼只有在windows下編譯和運行後,才能輸出正確的結果,在linux下,需要將int Sum(int first, int second, ...)修改為int Sum(int first, ...),即刪掉int second,因為second如果寫出來,就不是可變參數,first也不是可變參數。修改後的函數在linux下和windows下都可以正常運行。

 

    搞清楚上面範例程式碼的原理後,我們可以自己手動實現這樣一個函數。 

#include <stdio.h>

int Sum(int first, int second,...)

{

int sum = 0, t = first;

char * vl;//定義一個指標

 

vl = (char *)&first;//使指標指向第一個參數

while (*vl != -1)//-1是預先給定的結束符

{

sum += *(int *)vl;//類型轉換

vl += sizeof(int);//移動指標,使指標指向下一個參數

}

return sum;

}

 

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

{

printf("The sum is %d\n", Sum(30, 20, 10, -1));//-1是參數結束標誌

return 0;

}

 

    實際上聲明一個可變參數有兩種方式:建議使用第一種。

    第一種:包含標頭檔stdarg.h,採用ANSI標準形式,參數個數可變的函數的原型聲明是:

type funcname(type para1, type para2, ...)

第二種:包含標頭檔varargs.h,採用與UNIX System V相容的聲明方式時,參數個數可變的函數原型是:

type funcname(va_alist)

      va_dcl

va_dcl為宏,宏定義原型後已經包含分號,所以使用時不用加分號。Va_dcl是對va_alist的詳細聲明。Va_dcl在代碼中必須原樣給 出,va_alist在VC中可以原樣給出,也可以略去,但在UNIX上的CC或Linux上的GCC中都要省略掉。

 關於可變參數的傳遞問題

    有人問到這個問題,假如我定義了一個可變參數函數,在這個函數內部又要調用其它可變參數函數,那麼如何傳遞參數呢?上面的例子都是使用宏va_arg逐個把參數提取出來使用,能否不提取,直接把它們傳遞給另外的函數呢?

   我們先看printf的實現:

 int __cdecl printf (const char *format, ...)
{
        va_list arglist;
        int buffing;
        int retval;

        va_start(arglist, format); //arglist指向format後面的第一個參數

...//不關心其它代碼
retval = _output(stdout,format,arglist); //把format格式和參數傳遞給output函數

...//不關心其它代碼
    return(retval);

}

   我們先模仿這個函數寫一個:

#include <stdio.h>
#include <stdarg.h>

int mywrite(char *fmt, ...)
{
     va_list arglist;
     va_start(arglist, fmt);
     return printf(fmt,arglist);
}

void main()

{

               int i=10, j=20;
               char buf[] = "This is a test";
               double f= 12.345;
               mywrite("String: %s\nInt: %d, %d\nFloat :%4.2f\n", buf, i, j, f);

}

   運行一下看看,錯誤百出。仔細分析原因,根據宏的定義我們知道 arglist是一個指標,它指向第一個可變的參數,但是所有的參數都位於棧中,所以arglist指向棧中某個位置,通過arglist的值,我們可以直接查看棧裡面的內容:

   arglist -> 指向棧裡面,內容包括

   0067FD78 E0 FD 67 00 //指向字串"This is a test"

   0067FD7C 0A 00 00 00 //整數 i 的值

   0067FD80 14 00 00 00 //整數 j 的值

   0067FD84 71 3D 0A D7 //double 變數 f, 佔用8個位元組

   0067FD88 A3 B0 28 40

   0067FD8C 00 00 00 00

   如果直接調用 printf(fmt, arglist); 僅僅是把arglist指標的值0067FD78入棧,然後把格式字串入棧,相當於調用:

   printf(fmt, 0067FD78);

   自然這樣的調用肯定會出現錯誤。

   我們能不能逐個把參數提取出來,再傳遞給其它函數呢?先考慮一次性把所有參數傳遞進去的問題。

   如果調用的是系統庫函數,這種情況下是不可能的。因為提取參數是在運行態,而參數入棧是在編譯的時候確定的無法讓編譯器預知運行態的事情給出正確的參數入棧代碼。而我們在運行態雖然可以提取每個參數,但是無法將參數一次性全部壓棧,即使使用彙編代碼實現起來也是很困難的,因為不單是一個簡單的push代 碼就可以做到。

---------------------------------------------------------

問題一:

 上面這段代碼經測試可以正常輸出。也就是說,我們通過使用指標,實現了參數不定的函數。但這裡還有一個問題,就是sum函數的所有參數都是int類型的,事先我們知道要移動sizeof(int)位的指標,可是如果參數類型不同呢?

 答案與分析:這的確是個比較麻煩的問題,因為不同的資料類型佔用的位元組數可能是不一樣的(如double型為8個字元,short int型為2個),所以很難事先確定應該移動多少個位元組!但是辦法還是有的,這就是使用指標了,無論什麼類型的指標,都是佔用4個位元組,所以,可以把所有的傳如入參數都設定為指標,這樣一來,就可以通過移動固定的4個位元組來實現遍曆可變參數的目的了,至於如何取得指標中的內容並使用他們,當然也是無法預先得知的了。所以這大概也就是像printf(),scanf()之類的函數還需要一個格式控制符的原因吧^_^!不過實現起來還是有不少麻煩,暫且盜用vprintf()來實現一個與printf()函數一樣功能的函數了,代碼如下:

void myPrint(const char *frm, ...)

{

    va_list vl;

    va_start(vl, frm);

    vprintf(frm, vl);

    va_end(vl);

}  

-----------------------------------------------------------

問題二: 還有一個問題,是上述問題的變體,不過意思相同:有沒有辦法寫一個函數,這個函數參數的具體形式可以在運行時才確定?  

答案與分析:目前沒有"正規"的解決辦法,不過獨門偏方倒是有一個,因為有一個函數已經給我們做出了這方面的榜樣,那就是main(),它的原型是:
        int main(int argc,char *argv[]);
        函數的參數是argc和argv。
   深入想一下,"只能在運行時確定參數形式",也就是說你沒辦法從聲明中看到所接受的參數,也即是參數根本就沒有固定的形式。常用的辦法是你可以通過定 義一個void *類型的參數,用它來指向實際的參數區,然後在函數中根據根據需要任意解釋它們的含義。這就是main函數中argv的含義,而argc,則用來表明實際的參數個數,這為我們使用提供了進一步的方便,當然,這個參數不是必需的。
   雖然參數沒有固定形式,但我們必然要在函數中解析參數的意義,因此,理所當然會有一個要求,就是調用者和被調者之間要對參數區內容的格式,大小,有效性等所有方面達成一致,否則南轅北轍各說各話就慘了。

-------------------------------------------------------------
問題三:可變長參數的傳遞
   有時候,需要編寫一個函數,將它的可變長參數直接傳遞給另外的函數,請問,這個要求能否實現?
   答案與分析:目前,你尚無辦法直接做到這一點,但是我們可以迂迴前進,首先,我們定義被調用函數的參數為va_list類型,同時在調用函數中將可變長參數列錶轉換為va_list,這樣就可以進行變長參數的傳遞了。看如下所示:

void subfunc (char *fmt, va_list argp)
{
       ...
       arg = va_arg (fmt, argp); /* 從argp中逐一取出所要的參數 */
       ...
}
void mainfunc (char *fmt, ...)
{
       va_list argp;
       va_start (argp, fmt); /* 將可變長參數轉換為va_list */
       subfunc (fmt, argp); /* 將va_list傳遞給子函數 */
       va_end (argp);
       ...
}

-------------------------------------------------------------

問題四:如何判別可變參數函數的參數類型?
函數形式如下:
void   fun(char*   str,...)
{
......
}
若傳的參數個數大於1,如何判別第2個以後傳參的參數類型???最好有源碼說明! 
   答案與分析:無法判斷。可變參數實現主要通過三個宏實現:va_start,va_arg,va_end。
   如樓上所說,例如printf( "%d%c%s ",   ....)是通過格式串中的%d,%c,%s來確定後面參數的類型,其實你也可以參考這種方法來判斷不定參數的類型。

-------------------------------------------------------------
問題五:定義可變長參數的一個限制
   為什麼我的編譯器不允許我定義如下的函數,也就是可變長參數,但是沒有任何的固定參數?
int f (...)
{
   ...
}

答案與分析:不可以。這是ANSI C 所要求的,你至少得定義一個固定參數。
   這個參數將被傳遞給va_start(),然後用va_arg()和va_end()來確定所有實際調用時可變長參數的類型和值。

---------------------------------------------------------------------
問題六:可變長參數的擷取     
    有這樣一個具有可變長參數的函數,其中有下列代碼用來擷取類型為float的實參:
va_arg (argp, float);

這樣做可以嗎?
答案與分析:不可以。在可變長參數中,應用的是"加寬"原則。也就是float類型被擴充成double;char, short被擴充成int。因此,如果你要去可變長參數列表中原來為float類型的參數,需要用va_arg(argp, double)。對char和short類型的則用va_arg(argp, int)。
---------------------------------------------------------------------
問題七:可變長參數中類型為函數指標
   我想使用va_arg來提取出可變長參數中類型為函數指標的參數,結果卻總是不正確,為什嗎?
答案與分析:這個與va_arg的實現有關。一個簡單的、示範版的va_arg實現如下:
#define va_arg(argp, type) (*(type *)(((argp) += sizeof(type)) - sizeof(type)))
   其中,argp的類型是char *。
   如果你想用va_arg從可變參數列表中提取出函數指標類型的參數,例如
int (*)(),則va_arg(argp, int (*)())被擴充為:
     (*(int (*)() *)(((argp) += sizeof (int (*)())) -sizeof (int (*)())))
   顯然,(int (*)() *)是無意義的。
   解決這個問題的辦法是將函數指標用typedef定義成一個獨立的資料類型,例如:
typedef int (*funcptr)();
   這時候再調用va_arg(argp, funcptr)將被擴充為:
   (* (funcptr *)(((argp) += sizeof (funcptr)) - sizeof (funcptr)))
這樣就可以通過編譯檢查了。

知識擴充

可能大家也猜到了,我擴充要擴充什麼了?!^_^

簡單介紹兩種函數呼叫慣例

__stdcall (C++預設)

  1. 參數從右向左壓入堆棧
  2. 函數被調用者修改堆棧
  3. 函數名(在編譯器這個層次)自動加前置的底線,後面緊跟一個@符號,其後緊跟著參數的尺寸

__cdecl (C語言預設)

  1. 參數從右向左壓入堆棧
  2. 參數由調用者清楚,手動清棧,被調用函數不會要求調用者傳遞多少參數,調用者傳遞過多或者過少的參數,甚至完全不同的參數都不會產生編譯階段的錯誤。

那麼,變參函數的調用方式為(也只能是):__cdecl 。

深度探索C語言函數可變長參數

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.