詳解_C語言可變參數_va_list和_vsnprintf及printf實現

C語言的變長參數在平時做開發時很少會在自己設計的接口中用到,但我們最常用的接口printf就是使用的變長參數接口,在感受到printf強大的魅力的同時,是否想挖據一下到底printf是如何實現的呢?這裏我們一起來挖掘一下C語言變長參數的奧祕。


先考慮這樣一個問題:如果我們不使用C標準庫(libc)中提供的Facilities,我們自己是否可以實現擁有變長參數的函數呢?我們不妨試試。

一步一步進入正題,我們先看看固定參數列表函數,
void fixed_args_func(int a, double b, char *c)

{
        printf("a = 0x%p\n",&a);
        printf("b = 0x%p\n",&b);
        printf("c = 0x%p\n",&c);
}
對於固定參數列表的函數,每個參數的名稱、類型都是直接可見的,他們的地址也都是可以直接得到的,比如:通過&a我們可以得到a的地址,並通過函數原型聲明瞭解到a是int類型的; 通過&b我們可以得到b的地址,並通過函數原型聲明瞭解到b是double類型的; 通過&c我們可以得到c的地址,並通過函數原型聲明瞭解到c是char*類型的。

但是對於變長參數的函數,我們就沒有這麼順利了。還好,按照C標準的說明,支持變長參數的函數在原型聲明中,必須有至少一個最左固定參數(這一點與傳統C有區別,傳統C允許不帶任何固定參數的純變長參數函數),這樣我們可以得到其中固定參數的地址,但是依然無法從聲明中得到其他變長參數的地址,比如:
void var_args_func(const char * fmt,... )

{
    ... ...
}
這裏我們只能得到fmt這固定參數的地址,僅從函數原型我們是無法確定"..."中有幾個參數、參數都是什麼類型的,自然也就無法確定其位置了。那麼如何可以做到呢?在大腦中回想一下函數傳參的過程,無論"..."中有多少個參數、每個參數是什麼類型的,它們都和固定參數的傳參過程是一樣的,簡單來講都是棧操作,而棧這個東西對我們是開放的。這樣一來,一旦我們知道某函數幀的棧上的一個固定參數的位置,我們完全有可能推導出其他變長參數的位置,順着這個思路,我們繼續往下走,通過一個例子來詮釋一下:(這裏要說明的是:函數參數進棧以及參數空間地址分配都是"實現相關"的,不同平臺、不同編譯器都可能不同,所以下面的例子僅在IA-32,Windows XP, MinGW gcc v3.4.2下成立)

我們先用上面的那個fixed_args_func函數確定一下這個平臺下的入棧順序。
int main()

{
    fixed_args_func(17, 5.40, "hello world");
    return 0;
}
a = 0x0022FF50
b = 0x0022FF54
c = 0x0022FF5C

從這個結果來看,顯然參數是從右到左,逐一壓入棧中的(棧的延伸方向是從高地址到低地址,棧底的佔領着最高內存地址,先入棧的參數,其地理位置也就最高了)。

我們基本可以得出這樣一個結論:
 c.addr = b.addr + x_sizeof(b); 
/*注意:  x_sizeof != sizeof,後話再說 */
 b.addr = a.addr + x_sizeof(a);

有了以上的"等式",我們似乎可以推導出 void var_args_func(constchar * fmt, ... ) 函數中,可變參數的位置了。起碼第一個可變參數的位置應該是:first_vararg.addr = fmt.addr +x_sizeof(fmt);  根據這一結論我們試着實現一個支持可變參數的函數:

void var_args_func(const char * fmt, ... )

{
    char    *ap;

    ap = ((char*)&fmt) + sizeof(fmt);
    printf("%d\n", *(int*)ap);  
        
    ap =  ap + sizeof(int);
    printf("%d\n", *(int*)ap);

    ap =  ap + sizeof(int);
    printf("%s\n", *((char**)ap));
}

int main()

{
    var_args_func("%d %d %s\n", 4, 5, "helloworld");
}

輸出結果:
4
5
hello world

var_args_func只是爲了演示,並未根據fmt消息中的格式字符串來判斷變參的個數和類型,而是直接在實現中寫死了,如果你把這個程序拿到solaris 9下,運行後,一定得不到正確的結果,爲什麼呢,後續再說。先來解釋一下這個程序。我們用ap獲取第一個變參的地址,我們知道第一個變參是4,一個int型,所以我們用(int*)ap以告訴編譯器,以ap爲首地址的那塊內存我們要將之視爲一個整型來使用,*(int*)ap獲得該參數的值;接下來的變參是5,又一個int型,其地址是ap +sizeof(第一個變參),也就是ap + sizeof(int),同樣我們使用*(int*)ap獲得該參數的值;最後的一個參數是一個字符串,也就是char*,與前兩個int型參數不同的是,經過ap + sizeof(int)後,ap指向棧上一個char*類型的內存塊(我們暫且稱之tmp_ptr,char *tmp_ptr)的首地址,即ap -> &tmp_ptr,而我們要輸出的不是printf("%s\n",ap),而是printf("%s\n", tmp_ptr);printf("%s\n", ap)是意圖將ap所指的內存塊作爲字符串輸出了,但是ap ->&tmp_ptr,tmp_ptr所佔據的4個字節顯然不是字符串,而是一個地址。如何讓&tmp_ptr是char **類型的,我們將ap進行強制轉換(char**)ap<=> &tmp_ptr,這樣我們訪問tmp_ptr只需要在(char**)ap前面加上一個*即可,即printf("%s\n", *(char**)ap);

前面說過,如果將var_args_func放到solaris上,一定是得不到正確結果的?爲什麼呢?由於內存對齊。編譯器在棧上壓入參數時,不是一個緊挨着另一個的,編譯器會根據變參的類型將其放到滿足類型對齊的地址上的,這樣棧上參數之間實際上可能會是有空隙的。上述例子中,我是根據反編譯後的彙編碼得到的參數間隔,還好都是4,然後在代碼中寫死了。

爲了滿足代碼的可移植性,C標準庫在stdarg.h中提供了諸多Facilities以供實現變長長度參數時使用。這裏也列出一個簡單的例子,看看利用標準庫是如何支持變長參數的:
#include <stdarg.h>

void std_vararg_func(const char *fmt, ... ) {
        va_list ap;
        va_start(ap, fmt);

        printf("%d\n", va_arg(ap,int));
        printf("%f\n", va_arg(ap, double));
        printf("%s\n", va_arg(ap,char*));

        va_end(ap);
}

int main() {
        std_vararg_func("%d %f%s\n", 4, 5.4, "hello world");
}
輸出:
4
5.400000
hello world

對比一下 std_vararg_func和var_args_func的實現,va_list似乎就是char*, va_start似乎就是 ((char*)&fmt)+ sizeof(fmt),va_arg似乎就是得到下一個參數的首地址。沒錯,多數平臺下stdarg.h中va_list, va_start和var_arg的實現就是類似這樣的。一般stdarg.h會包含很多宏,看起來比較複雜。在有的系統中stdarg.h的實現依賴some special functions built into thethe compilation system to handle variable argument lists and stack allocations,多數其他系統的實現與下面很相似:(Visual C++ 6.0的實現較爲清晰,因爲windows上的應用程序只需要在windows平臺間做移植即可,沒有必要考慮太多的平臺情況)。


C語言va_list_vsnprintf的使用

 

先舉一個例子

#define bufsize 80
char buffer[bufsize];

/* 這個函數用來格式化帶參數的字符串*/
int vspf(char *fmt, ...)
{
    va_list argptr;
//聲明一個轉換參數的變量
    int cnt;
    va_start(argptr, fmt);
//初始化變量  
    cnt = vsnprintf(buffer,bufsize ,fmt, argptr);

//將帶參數的字符串按照參數列表格式化到buffer中
    va_end(argptr);
//結束變量列表,和va_start成對使用  
    return(cnt);
}

int main(int argc, char* argv[])
{
    int inumber = 30;
    float fnumber = 90.0;
    char string[4] = "abc";

vspf("%d %f %s", inumber, fnumber, string);
{
    printf("%s\n", buffer);
    return 0;
}

下面我們來探討如何寫一個簡單的可變參數的C函數.

 

寫可變參數的C函數要在程序中用到以下這些宏:

使用可變參數應該有以下步驟:
1)首先在函數裏定義一個va_list型的變量,這裏是arg_ptr,這個變量是指向參數的指針.
2)然後用va_start宏初始化變量arg_ptr,這個宏的第二個參數是第一個可變參數的前一個參數,是一個固定的參數.
3)然後用va_arg返回可變的參數,並賦值給整數j. va_arg的第二個參數是你要返回的參數的類型,這裏是int型.
4)最後用va_end宏結束可變參數的獲取.然後你就可以在函數裏使用第二個參數了.如果函數有多個可變參數的,依次調用va_arg獲取各個參數.
如果我們用下面三種方法調用的話,都是合法的,但結果卻不一樣:

可變參數在編譯器中的處理

我們知道va_start,va_arg,va_end是在stdarg.h中被定義成宏的,由於:

1)硬件平臺的不同

2)編譯器的不同

Microsoft Visual Studio\VC98\Include\stdarg.h中,
typedef char *  va_list;
   /*把va_list被定義成char*,這是因爲在我們目前所用的PC機上,字符指針類型可以用來存儲內存單元地址。而在有的機器上va_list是被定義成void*的*/
#define  _INTSIZEOF(n)   ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )

/*_INTSIZEOF (n)宏是爲了考慮那些內存地址需要對齊的系統,從宏的名字來應該是跟sizeof(int)對齊。一般的sizeof(int)=4,也就是參數在內存中的地址都爲4的倍數。比如,如果sizeof(n)在1-4之間,那麼_INTSIZEOF(n)=4;如果sizeof(n)在5-8之間,那麼 _INTSIZEOF(n)=8。*/
#define  va_start(ap,v)     ( ap = (va_list)&v + _INTSIZEOF(v) )

/*va_start 的定義爲 &v+_INTSIZEOF(v) ,這裏&v是最後一個固定參數的起始地址,再加上其實際佔用大小後,就得到了第一個可變參數的起始內存地址。所以我們運行va_start (ap, v)以後,ap指向第一個可變參數在的內存地址*/
#define  va_arg(ap,t)       ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )

/*這個宏做了兩個事情,

①用用戶輸入的類型名對參數地址進行強制類型轉換,得到用戶所需要的值

②計算出本參數的實際大小,將指針調到本參數的結尾,也就是下一個參數的首地址,以便後續處理。*/
#define  va_end(ap)         ( ap = (va_list)0 )
   /*x86 平臺定義爲ap=(char*)0;使ap不再指向堆棧,而是跟NULL一樣.有些直接定義爲((void*)0),這樣編譯器不會爲va_end產生代碼,例如gcc在linux的x86平臺就是這樣定義的. 在這裏大家要注意一個問題:由於參數的地址用於va_start宏,所以參數不能聲明爲寄存器變量或作爲函數或數組類型. */


這裏有兩個地方需要深入挖掘一下:
1
#define  _INTSIZEOF(n)   ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )
我們這裏簡化一下這個宏:
#define  _INTSIZEOF(n)  ((sizeof(n) + x) & ~(x))
x = sizeof(int) - 1 = 3 = 0000 0000 0000 0011(b)
~x = 1111 1111 1111 1100(b)

當一個數 & (-x)時,得到的值始終是sizeof(int)的倍數,也就是說_INTSIZEOF(n)的功能是將n圓整到sizeof(int)的倍數上去。sizeof(n) >= 1, sizeof(n)+sizeof(int)-1經過圓整後,一定會是>=4的整數;在其他系統平臺上,圓整的目標值有的是4,有的則是8,視具體系統而定。

2
#define  va_arg(ap,t)    ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
其實有了var_args_func的實現,這裏也就不難理解了。不過這裏有一個trick,很多人一開始肯定對先加上_INTSIZEOF(t),又減去_INTSIZEOF(t)很不理解,其實這裏是一點就透的:整個表達式((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) 返回的值其實和最初的ap所指向的地址是一致的,關鍵就是在整個表達式被evaluated後,ap卻指向了下一個參數的地址了,就這麼簡單。

 

C語言的函數是從右向左壓入堆棧的,圖(1)是函數的參數在堆棧中的分佈位置.我們看到va_list被定義成char*,有一些平臺或操作系統定義爲void*.再看va_start的定義,定義爲&v+_INTSIZEOF(v),而&v是固定參數在堆棧的地址,所以我們運行va_start(ap, v)以後,ap指向第一個可變參數在堆棧的地址,如圖:

高地址|-------------------------------------------|
|
函數返回地址                 |
|-------------------------------------------|
|
…….                       |
|-------------------------------------------|
|
n個參數(第一個可變參數)    |
|-------------------------------------------|<--va_start
ap指向
|
n-1個參數(最後一個固定參數)|

低地址|-------------------------------------------|<-- &v
            圖( 1 )

然後,我們用va_arg()取得類型t的可變參數值,以上例爲int型爲例,我們看一下va_arg取int型的返回值:    j= ( *(int*)((ap += _INTSIZEOF(int))-_INTSIZEOF(int)) );
首先ap+=sizeof(int),已經指向下一個參數的地址了.然後返回ap-sizeof(int)的int*指針,這正是第一個可變參數在堆棧裏的地址(圖2).然後用*取得這個地址的內容(參數值)賦給j.

高地址|--------------------------------------------|
|
函數返回地址                  |
|--------------------------------------------|
|
…….                         |
|--------------------------------------------|<--va_arg
ap指向
|
n個參數(第一個可變參數)    |
|--------------------------------------------|<--va_start
ap指向
|
n-1個參數(最後一個固定參數)|

低地址|--------------------------------------------|<-- &v
             圖( 2 )

最後要說的是va_end宏的意思,x86平臺定義爲ap=(char*)0;使ap不再指向堆棧,而是跟NULL一樣.有些直接定義爲((void*)0),這樣編譯器不會爲va_end產生代碼,例如gcc在linux的x86平臺就是這樣定義的.

在這裏大家要注意一個問題:由於參數的地址用於va_start宏,所以參數不能聲明爲寄存器變量或作爲函數或數組類型.
   關於va_start, va_arg, va_end的描述就是這些了,我們要注意的是不同的操作系統和硬件平臺的定義有些不同,但原理卻是相似的.

可變參數在編程中要注意的問題

因爲va_start, va_arg, va_end等定義成宏,所以它顯得很愚蠢,可變參數的類型和個數完全在該函數中由程序代碼控制,它並不能智能地識別不同參數的個數和類型.
有人會問:那麼printf中不是實現了智能識別參數嗎?那是因爲函數
printf是從固定參數format字符串來分析出參數的類型,再調用va_arg的來獲取可變參數的.也就是說,你想實現智能識別可變參數的話是要通過在自己的程序裏作判斷來實現的.
另外有一個問題,因爲編譯器對可變參數的函數的原型檢查不夠嚴格,對編程查錯不利.如果simple_va_fun()改爲:
void simple_va_fun(int i, ...)
{
    va_list arg_ptr;
    char *s=NULL;

va_start(arg_ptr, i);
    s=va_arg(arg_ptr, char*);
    va_end(arg_ptr);
    printf("%d %s\n", i, s);
    return 0;
}
可變參數爲char*型,當我們忘記用兩個參數來調用該函數時,就會出現core dump(Unix) 或者頁面非法的錯誤(window平臺).但也有可能不出錯,但錯誤卻是難以發現,不利於我們寫出高質量的程序.
以下提一下va系列宏的兼容性.
System V Unix把va_start定義爲只有一個參數的宏:    va_start(va_list arg_ptr);
而ANSI C則定義爲:    va_start(va_list arg_ptr, prev_param);
如果我們要用system V的定義,應該用vararg.h頭文件中所定義的宏,ANSI C的宏跟system V的宏是不兼容的,我們一般都用ANSI C,所以用ANSI C的定義就夠了,也便於程序的移植.
小結:

可變參數的函數原理其實很簡單,而va系列是以宏定義來定義的,實現跟堆棧相關.我們寫一個可變函數的C函數時,有利也有弊,所以在不必要的場合,我們無需用到可變參數.如果在C++裏,我們應該利用C++的多態性來實現可變參數的功能,儘量避免用C語言的方式來實現.

printf研究

下面是一個簡單的printf函數的實現:

#include "stdio.h"

#include "stdlib.h"

void myprintf(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;

}

 

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

{

 int i = 1234;

 int j = 5678;

 

 myprintf("thefirst test:i=%d",i,j);

 myprintf("thesecend test:i=%d; %x;j=%d;",i,0xabcd,j);

 system("pause");

 return 0;

}

 

在intel+win2k+vc6的機器執行結果如下:

the first test:i=1234

the secend test:i=1234; 0xabcd;j=5678;


轉自:http://wenku.baidu.com/view/ecb33901de80d4d8d15a4fe3.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章