C語言中可變參數函數實現原理

2020-08-10 15:54:55

C函數呼叫的棧結構

 可變參數函數的實現與函數呼叫的棧結構密切相關,正常情況下C的函數參數入棧規則爲__stdcall, 它是從右到左的,即函數中的最右邊的參數最先入棧。例如,對於函數:

  void fun(int a, int b, int c)
  {
        int d;
        ...
  }

其棧結構爲

    0x1ffc-->d

    0x2000-->a

    0x2004-->b

    0x2008-->c

對於在32位元系統的多數編譯器,每個棧單元的大小都是sizeof(int), 而函數的每個參數都至少要佔一個棧單元大小,如函數 void fun1(char a, int b, double c, short d) 對一個32的系統其棧的結構就是

    0x1ffc-->a  (4位元組)(爲了字對齊)

    0x2000-->b  (4位元組)

    0x2004-->c  (8位元組)

    0x200c-->d  (4位元組)

因此,函數的所有參數是儲存線上性連續的棧空間中的,基於這種儲存結構,這樣就可以從可變參數函數中必須有的第一個普通參數來定址後續的所有可變參數的型別及其值。

先看看固定參數列表函數:

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型別的。

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

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

這裏我們只能得到fmt這固定參數的地址,僅從函數原型我們是無法確定"..."中有幾個參數、參數都是什麼型別的。回想一下函數傳參的過程,無論"..."中有多少個參數、每個參數是什麼型別的,它們都和固定參數的傳參過程是一樣的,簡單來講都是棧操作,而棧這個東西對我們是開放的。這樣一來,一旦我們知道某函數幀的棧上的一個固定參數的位置,我們完全有可能推導出其他變長參數的位置。

我們先用上面的那個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(const char * fmt, ... ) 函數中,可變參數的位置了。起碼第一個可變參數的位置應該是:first_vararg.addr = fmt.addr + x_sizeof(fmt);  根據這一結論我們試着實現一個支援可變參數的函數:

复制代码

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

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, "hello world");
   return 0;
}

复制代码

期待輸出結果:
4
5
hello world


  先來解釋一下這個程式。我們用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);


   一切似乎很完美,編譯也很順利通過,但執行上面的程式碼後,不但得不到預期的結果,反而整個編譯器會強行關閉(大家可以嘗試着執行一下),原來是ap指針在後來並沒有按照預期的要求指向第二個變參數,即並沒有指向5所在的首地址,而是指向了未知記憶體區域,所以編譯器會強行關閉。其實錯誤開始於:ap =  ap + sizeof(int);由於記憶體對齊,編譯器在棧上壓入參數時,不是一個緊挨着另一個的,編譯器會根據變參的型別將其放到滿足型別對齊的地址上的,這樣棧上參數之間實際上可能會是有空隙的。(C語言記憶體對齊詳解(1) C語言記憶體對齊詳解(2) C語言記憶體對齊詳解(3))所以此時的ap計算應該改爲:ap =  (char *)ap +sizeof(int) + __va_rounded_size(int);

改正後的程式碼如下:

复制代码

#include<stdio.h>

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

void var_args_func(const char * fmt, ...) 
{
    char *ap;

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

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

int main()
{
    var_args_func("%d %d %s\n", 4, 5, "hello world"); 
    return 0;
}

复制代码

var_args_func只是爲了演示,並未根據fmt訊息中的格式字串來判斷變參的個數和型別,而是直接在實現中寫死了。

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

复制代码

 1 #include <stdarg.h>#include <stdio.h>
 2 
 3 void std_vararg_func(const char *fmt, ...) {
 4         va_list ap;
 5         va_start(ap, fmt);
 6 
 7         printf("%d\n", va_arg(ap, int));
 8         printf("%f\n", va_arg(ap, double));
 9         printf("%s\n", va_arg(ap, char*));
10 
11         va_end(ap);
12 }
13 
14 int main() {
15         std_vararg_func("%d %f %s\n", 4, 5.4, "hello world");        return 0;}

复制代码

對比一下 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會包含很多宏,看起來比較複雜。

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

使用可變參數應該有以下步驟: 
1)首先在函數裡定義一個va_list型的變數,這裏是arg_ptr,這個變數是指向參數的指針. 
2)然後用va_start宏初始化變數arg_ptr,這個宏的第二個參數是第一個可變參數的前一個參數,是一個固定的參數. 
3)然後用va_arg返回可變的參數,並賦值給整數j. va_arg的第二個參數是你要返回的參數的型別,這裏是int型. 
4)最後用va_end宏結束可變參數的獲取.然後你就可以在函數裡使用第二個參數了.如果函數有多個可變參數的,依次呼叫va_arg獲取各個參數.

在《C程式設計語言》中,Ritchie提供了一個簡易版printf函數:

复制代码

 1 #include<stdarg.h>
 2 
 3 void minprintf(char *fmt, ...)
 4 {
 5     va_list ap;
 6     char *p, *sval;
 7     int ival;
 8     double dval;
 9 
10     va_start(ap, fmt);
11     for (p = fmt; *p; p++) {
12         if(*p != '%') {
13             putchar(*p);
14             continue;
15         }
16         switch(*++p) {
17         case 'd':
18             ival = va_arg(ap, int);
19             printf("%d", ival);
20             break;
21         case 'f':
22             dval = va_arg(ap, double);
23             printf("%f", dval);
24             break;
25         case 's':
26             for (sval = va_arg(ap, char *); *sval; sval++)
27                 putchar(*sval);
28             break;
29         default:
30             putchar(*p);
31             break;
32         }
33     }
34     va_end(ap);
35 }