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 /* 這個函數用來格式化帶參數的字符串*/ //將帶參數的字符串按照參數列表格式化到buffer中 int main(int argc, char* argv[]) 下面我們來探討如何寫一個簡單的可變參數的C函數.
寫可變參數的C函數要在程序中用到以下這些宏: 使用可變參數應該有以下步驟: 可變參數在編譯器中的處理 我們知道va_start,va_arg,va_end是在stdarg.h中被定義成宏的,由於: 1)硬件平臺的不同 2)編譯器的不同 Microsoft Visual Studio\VC98\Include\stdarg.h中, /*_INTSIZEOF (n)宏是爲了考慮那些內存地址需要對齊的系統,從宏的名字來應該是跟sizeof(int)對齊。一般的sizeof(int)=4,也就是參數在內存中的地址都爲4的倍數。比如,如果sizeof(n)在1-4之間,那麼_INTSIZEOF(n)=4;如果sizeof(n)在5-8之間,那麼 _INTSIZEOF(n)=8。*/ /*va_start 的定義爲 &v+_INTSIZEOF(v) ,這裏&v是最後一個固定參數的起始地址,再加上其實際佔用大小後,就得到了第一個可變參數的起始內存地址。所以我們運行va_start (ap, v)以後,ap指向第一個可變參數在的內存地址*/ /*這個宏做了兩個事情, ①用用戶輸入的類型名對參數地址進行強制類型轉換,得到用戶所需要的值 ②計算出本參數的實際大小,將指針調到本參數的結尾,也就是下一個參數的首地址,以便後續處理。*/
C語言的函數是從右向左壓入堆棧的,圖(1)是函數的參數在堆棧中的分佈位置.我們看到va_list被定義成char*,有一些平臺或操作系統定義爲void*.再看va_start的定義,定義爲&v+_INTSIZEOF(v),而&v是固定參數在堆棧的地址,所以我們運行va_start(ap, v)以後,ap指向第一個可變參數在堆棧的地址,如圖: 高地址|-------------------------------------------|
低地址|-------------------------------------------|<-- &v
然後,我們用va_arg()取得類型t的可變參數值,以上例爲int型爲例,我們看一下va_arg取int型的返回值: j= ( *(int*)((ap += _INTSIZEOF(int))-_INTSIZEOF(int)) );
高地址|--------------------------------------------|
低地址|--------------------------------------------|<-- &v
最後要說的是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(arg_ptr, i); 可變參數的函數原理其實很簡單,而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