一直以來都覺得printf似乎是c語言庫中功能最強大的函數之一,不僅因為它能格式化輸出,更在於它的參數個數沒有限制,要幾個就給幾個,來者不拒。printf這種對參數個數和參數類型的強大適應性,讓人產生了對它進行探索的濃厚興趣。
1.使用情形
int a =10;
double b = 20.0;
char *str = "Hello world";
printf("begin print\n");
printf("a=%d, b=%.3f, str=%s\n", a, b, str);
...
從printf的使用情況來看,我們不難發現一個規律,就是無論其可變的參數有多少個,printf的第一個參數總是一個字符串。而正是這第一個參數,使得它可以確認後面還有有多少個參數尾隨。而尾隨的每個參數占用的棧空間大小又是通過第一個格式字符串確定的。然而printf到底是怎樣取第一個參數後面的參數值的呢,請看如下代碼
2.printf 函數的實現
//acenv.h
typedef char *va_list;
#define _AUPBND (sizeof (acpi_native_int) - 1)
#define _ADNBND (sizeof (acpi_native_int) - 1)
#define _bnd(X, bnd) (((sizeof (X)) + (bnd)) & (~(bnd)))
#define va_arg(ap, T) (*(T *)(((ap) += (_bnd (T, _AUPBND))) - (_bnd (T,_ADNBND))))
#define va_end(ap) (void) 0
#define va_start(ap, A) (void) ((ap) = (((char *) &(A)) + (_bnd (A,_AUPBND))))
//start.c
static char sprint_buf[1024];
int printf(char *fmt, ...)
{
va_list args;
int n;
va_start(args, fmt);
n = vsprintf(sprint_buf, fmt, args);
va_end(args);
write(stdout, sprint_buf, n);
return n;
}
//unistd.h
static inline long write(int fd, const char *buf, off_t count)
{
return sys_write(fd, buf, count);
}
3.分析
從上面的代碼來看,printf似乎並不復雜,它通過一個宏va_start把所有的可變參數放到了由args指向的一塊內存中,然後再調用vsprintf.真正的參數個數以及格式的確定是在vsprintf搞定的了。由於vsprintf的代碼比較復雜,也不是我們這裡要討論的重點,所以下面就不再列出了。我們這裡要討論的重點是va_start(ap, A)宏的實現,它對定位從參數A後面的參數有重大的制導意義。現在把 #define va_start(ap, A) (void) ((ap) = (((char *) &(A)) + (_bnd (A,_AUPBND)))) 的含義解釋一下如下:
va_start(ap, A)
{
char *ap = ((char *)(&A)) + sizeof(A)並int類型大小地址對齊
}
在printf的va_start(args, fmt)中,fmt的類型為char *, 因此對於一個32為系統 sizeof(char *) = 4, 如果int大小也是32,則va_start(args, fmt);相當於 char *args = (char *)(&fmt) + 4; 此時args的值正好為fmt後第一個參數的地址。對於如下的可變參數函數
void fun(double d,...)
{
va_list args;
int n;
va_start(args, d);
}
則 va_start(args, d);相當於
char *args = (char *)&d + sizeof(double);
此時args正好指向d後面的第一個參數。
可變參數函數的實現與函數調用的棧結構有關,正常情況下c/c++的函數參數入棧規則為__stdcall, 它是從右到左的,即函數中的最右邊的參數最先入棧。對於函數
void fun(int a, int b, int c)
{
int d;
...
}
其棧結構為
0x1ffc-->d
0x2000-->a
0x2004-->b
0x2008-->c
對於任何編譯器,每個棧單元的大小都是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 fun1(char a, int b, double c, short d)
如果知道了參數a的地址,則要取後續參數的值則可以通過a的地址計算a後面參數的地址,然後取對應的值,而後面參數的個數可以直接由變量a指定,當然也可以像printf一樣根據第一個參數中的%模式個數來決定後續參數的個數和類型。如果參數的個數由第一個參數a直接決定,則後續參數的類型如果沒有變化並且是已知的,則我們可以這樣來取後續參數, 假定後續參數的類型都是double;
void fun1(int num, ...)
{
double *p = (double *)((&num)+1);
double Param1 = *p;
double Param2 = *(p+1);
...
double Paramn *(p+num);
}
如果後續參數的類型是變化而且是未知的,則必須通過一個參數中設定模式來匹配後續參數的個數和類型,就像printf一樣,當然我們可以定義自己的模式,如可以用i表示int參數,d表示double參數,為了簡單,我們用一個字符表示一個參數,並由該字符的名稱決定參數的類型而字符的出現的順序也表示後續參數的順序。 我們可以這樣定義字符和參數類型的映射表,
i---int
s---signed short
l---long
c---char
"ild"模式用於表示後續有三個參數,按順序分別為int, long, double類型的三個參數那麼這樣我們可以定義自己版本的printf 如下
void printf(char *fmt, ...)
{
char s[80] = "";
int paramCount = strlen(fmt);
write(stdout, "paramCount = " , strlen(paramCount = ));
itoa(paramCount,s,10);
write(stdout, s, strlen(s));
char *p = (char *)(&fmt) + sizeof(char *);
int *pi = (int *)p;
for (int i=0; i<paramCount; i++)
{
char line[80] = "";
strcpy(line, "param");
itoa(i+1, s, 10);
strcat(line, s);
strcat(line, "=");
switch(fmt[i])
{
case 'i':
case 's':
itoa((*pi),s,10);
strcat(line, s);
pi++;
break;
case 'c':
{
int len = strlen(line);
line[len] = (char)(*pi);
line[len+1] = '\0';
}
break;
case 'l':
ltoa((*(long *)pi),s,10);
strcat(line, s);
pi++;
break;
default:
break;
}
}
}
也可以這樣定義我們的Max函數,它返回多個輸入整型參數的最大值
int Max(int n, ...)
{
int *p = &n + 1;
int ret = *p;
for (int i=0; i<n; i++)
{
if (ret < *(p + i))
ret = *(p + i);
}
return ret;
}
可以這樣調用, 後續參數的個數由第一個參數指定
int m = Max(3, 45, 12, 56);
int m = Max(1, 3);
int m = Max(2, 23, 45);
int first = 34, second = 45, third=5;
int m = Max(5, first, second, third, 100, 4);
結論
對於可變參數函數的調用有一點需要注意,實際的可變參數的個數必須比前面模式指定的個數要多,或者不小於, 也即後續參數多一點不要緊,但不能少, 如果少了則會訪問到函數參數以外的堆棧區域,這可能會把程序搞崩掉。前面模式的類型和後面實際參數的類型不匹配也有可能造成把程序搞崩潰,只要模式指定的數據長度大於後續參數長度,則這種情況就會發生。如:
printf("%.3f, %.3f, %.6e", 1, 2, 3, 4);
參數1,2,3,4的默認類型為整型,而模式指定的需要為double型,其數據長度比int大,這種情況就有可能訪問函數參數堆棧以外的區域,從而造成危險。但是printf("%d, %d, %d", 1.0, 20., 3.0);這種情況雖然結果可能不正確,但是確不會造成災難性後果。因為實際指定的參數長度比要求的參數長度長,堆棧不會越界。