1. 程式人生 > >C/C++中用va_start/va_arg/va_end實現可變引數函式的原理

C/C++中用va_start/va_arg/va_end實現可變引數函式的原理

C/C++中用va_start/va_arg/va_end實現可變引數函式的原理與例項詳解

        在C/C++中,我們經常會用到可變引數的函式(比如printf/snprintf等),本篇筆記旨在講解編譯器藉助va_start/va_arg/va_end這簇巨集來實現可變引數函式的原理,並在文末給出簡單的例項。
        備註:本文的分析適用於Linux/Windows,其它作業系統平臺的可變引數函式的實現原理大體相似。

1. 基礎知識
        如果想要真正理解可變引數函式背後的執行機制,建議先理解兩部分基礎內容:
         1)函式呼叫棧
         2)函式呼叫約定
        

2. 三個巨集:va_start/va_arg/va_end
        由man va_start可知,這簇巨集定義在stdarg.h中,在我的測試機器上,該標頭檔案路徑為:/usr/lib/gcc/x86_64-redhat-linux/3.4.5/include/stdarg.h,在gcc原始碼中,其路徑為:gcc/include/stdarg.h。
        在stdarg.h中,巨集定義的相關程式碼如下:

    #define va_start(v,l)  __builtin_va_start(v,l)
    #define va_end(v)      __builtin_va_end(v)
    #define va_arg(v,l)    __builtin_va_arg(v,l)
    #if !defined(__STRICT_ANSI__) || __STDC_VERSION__ + 0 >= 199900L
    #define va_copy(d,s)    __builtin_va_copy(d,s)
    #endif
    #define __va_copy(d,s)  __builtin_va_copy(d,s)

        其中,前3行就是我們所關心的va_start & var_arg & var_end的定義(至於va_copy,man中有所提及,但通常不會用到,想了解的同學可man檢視之)。可見,gcc將它們定義為一組builtin函式。
        關於這組builtin函式的實現程式碼,我曾試圖在gcc原始碼中沿著呼叫路徑往下探索,無奈gcc為實現這組builtin函式引入了很多自定義的資料結構和巨集,對非編譯器研究者的我來說,實在有點晦澀,最終探索過程無疾而終。在這裡,我列出目前跟蹤到的呼叫路徑,以便有興趣的童鞋能繼續探索下去或指出我的不足,先在此謝過。
        __builtin_va_start()函式的呼叫路徑:

// file: gcc/builtins.c
/* The "standard" implementation of va_start: just assign `nextarg' to the variable.  */
void std_expand_builtin_va_start (tree valist, rtx nextarg)                        
{                                                                             
    rtx va_r = expand_expr (valist, NULL_RTX, VOIDmode, EXPAND_WRITE);
    convert_move (va_r, nextarg, 0);  // definition is in gcc/expr.c
}
// 上述程式碼中呼叫了expand_expr()來展開表示式,我猜測該函式呼叫完後,va_list指向了可變引數list前的最後一個已知型別引數
//  file: gcc/expr.h
/* Generate code for computing expression EXP.
    An rtx for the computed value is returned.  The value is never null.
    In the case of a void EXP, const0_rtx is returned.  
*/
static inline rtx expand_expr (tree exp, rtx target, enum machine_mode mode,enum expand_modifier modifier)
{
   return expand_expr_real (exp, target, mode, modifier, NULL);
}

 

3. Windows系統VS內建編譯器對va_start/va_arg/va_end的實現
        如前所述,我沒能在gcc原始碼中找出va_startva_arg/va_end這3個巨集的實現程式碼(⊙﹏⊙b汗),所幸的是,Windows平臺VS2008整合的編譯器中,對這三個函式有很明確的實現程式碼,摘出如下。

/* file path: Microsoft Visual Studio 9.0\VC\include\stdarg.h */
#include <vadefs.h>

#define va_start _crt_va_start
#define va_arg _crt_va_arg
#define va_end _crt_va_end

        可見,Windows系統下,仍然將va_start/va_arg/va_end定義為一組巨集。他們對應的實現在vadefs.h中:

/* file path: Microsoft Visual Studio 9.0\VC\include\vadefs.h */
#ifdef  __cplusplus
#define _ADDRESSOF(v)   ( &reinterpret_cast<const char &>(v) )
#else
#define _ADDRESSOF(v)   ( &(v) )
#endif

#define _INTSIZEOF(n)   ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )

#define _crt_va_start(ap,v)  ( ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v) )
#define _crt_va_arg(ap,t)    ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
#define _crt_va_end(ap)      ( ap = (va_list)0 )

        備註:在VS2008提供的vadefs.h檔案中,定義了若干組巨集以支援不同的作業系統平臺,上面摘出的程式碼片段是針對IA x86_32的實現。
        下面對上面的程式碼做個解釋:
         a. 巨集_ADDRESSOF(v)作用:取引數v的地址。
         b. 巨集_INTSIZEOF(n)作用:返回引數n的size並保證4位元組對齊(32-bits平臺)。這個巨集應用了一個小技巧來實現位元組對齊:~(sizeof(int) - 1)的值對應的2進位制值的低k位一定是0,其中sizeof(int) = 2^k,因此,在IA x86_32下,k=2。理解了這一點,那麼(sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1)的作用就很直觀了,它保證了sizeof(n)的值按sizeof(int)的值做對齊,例如在32-bits平臺下,就是按4位元組對齊;在64-bits平臺下,按8位元組對齊。至於為什麼要保證對齊,與編譯器的底層實現有關,這裡不再展開。
         c. _crt_va_start(ap,v)作用:通過v的記憶體地址來計算ap的起始地址,其中,v是可變引數函式的引數中,最後一個型別已知的引數,執行的結果是ap指向可變引數列表的第1個引數。以int snprintf(char *str, size_t size, const char *format, ...)為例,其函式引數列表中最後一個已知型別的引數是const char *format,因此,引數format對應的就是_crt_va_start(ap, v)中的v, 而ap則指向傳入的第1個可變引數。
        特別需要理解的是:為什麼ap = address(v) + sizeof(v),這與函式棧從高地址向低地址的增長方向 及函式呼叫時引數從右向左的壓棧順序有關,這裡預設大家已經搞清楚了這些基礎知識,不再展開詳述。
         d. _crt_va_arg(ap,t)作用:更新指標ap後,取型別為t的變數的值並返回該值。
         e. _crt_va_end(ap)作用:指標ap置0,防止野指標。
        概括來說,可變引數函式的實現原理是:
         1)根據函式引數列表中最後一個已知型別的引數地址,得到可變引數列表的第一個可變引數
         2)根據程式設計師指定的每個可變引數的型別,通過地址及引數型別的size獲取該引數值
         3)遍歷,直到訪問完所有的可變引數
        從上面的實現過程可以注意到,可變引數的函式實現嚴重依賴於函式棧及函式呼叫約定(主要是引數壓棧順序),同時,依賴於程式設計師指定的可變引數型別。因此,若指定的引數型別與實際提供的引數型別不符時,程式出core簡直就是一定的。

4. 程式例項
        經過上面對可變引數函式實現機制的分析,很容易實現一個帶可變引數的函式。程式例項如下:

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

void foo(char *fmt, ...) 
{
    va_list ap;
    int d;
    char c, *p, *s;

    va_start(ap, fmt);
    while (*fmt) 
    {
        if('%' == *fmt) {
            switch(*(++fmt)) {
                case 's': /* string */
                    s = va_arg(ap, char *);
                    printf("%s", s);
                    break;
                case 'd': /* int */
                    d = va_arg(ap, int);
                    printf("%d", d);
                    break;
                case 'c': /* char */
                    /* need a cast here since va_arg only takes fully promoted types */
                    c = (char) va_arg(ap, int);
                    printf("%c", c);
                    break;
                default:
                    c = *fmt;
                    printf("%c", c);
            }  // end of switch
        }  
        else {
            c = *fmt;
            printf("%c", c);
        }
        ++fmt;
    }
    va_end(ap);
}

int main(int argc, char * argv[])
{
    foo("sdccds%%, string=%s, int=%d, char=%c\n", "hello world", 211, 'k');
    return 0;
}

 

        上面的程式碼很簡單,旨在拋磚引玉,只要真正搞清楚了可變引數函式的原理,相信各位會寫出更加複雜精細的可變參函式。