變長引數探究
前言
變長引數,指的是函式引數數量可變,或者說函式接受引數的數量可以不固定。實際上,我們最開始學C語言的時候,就用到了這樣的函式:printf,它接受任意數量的引數,向終端格式化輸出字串。本文就來探究一下,變長引數函式的實現機制是怎樣的,以及我們自己如何實現一個變長引數函式。在此之前,我們先來了解一下引數入棧順序是怎樣的。
函式引數入棧順序
我們可能知道,引數入棧順序是從右至左,是不是這樣的呢?我們可以通過一個小程式驗證一下。小程式做的事情很簡單,main函式呼叫了傳入8個引數的test函式,test函式列印每個引數的地址。
#include<stdio.h> void test(int a,int b,int c,int d,int e,int f,int g,int h) { printf("%p\n%p\n%p\n%p\n%p\n%p\n%p\n%p\n",&a,&b,&c,&d,&e,&f,&g,&h); } int main(int argc,char *argv[]) { int a = 1; int b = 2; int c = 3; int d = 4; int e = 5; int f = 6; int g = 7; int h = 8; test(a,b,c,d,e,f,g,h); return 0; }
編譯成32位程式:
gcc -m32 -o paraTest paraTest.c
執行(不同的機器執行結果不同,且每次執行結果也不一定相同):
0xffdadff0
0xffdadff4
0xffdadff8
0xffdadffc
0xffdae000
0xffdae004
0xffdae008
0xffdae00c
觀察打印出來的地址,可以發現,從a到h地址值依次增加4。我們知道,棧是從高地址向低地址增長的,從地址值可以推測h是最先入棧,a是最後入棧的。也就是說,引數是從右往左入棧的(注:並非所有語言都是如此)。
但是如果將函式test引數b改為char 型呢?執行結果如下:
0xffb29500 0xffb294ec 0xffb29508 0xffb2950c 0xffb29510 0xffb29514 0xffb29518 0xffb2951c
觀察結果可以發現,b的地址並非是a的地址值加4,也不是在a和c的地址值之間,這是為何?這是編譯器出於對空間,壓棧速度等因素的考慮,對其進行了優化,但這並不影響變長引數的實現。
對於上面的情況,如果我們編譯成64位程式又是什麼樣的情況呢?
gcc -o paraTest paraTest.c
./paraTest
執行結果如下:
0x7fff4b83cbcc
0x7fff4b83cbc8
0x7fff4b83cbc4
0x7fff4b83cbc0
0x7fff4b83cbbc
0x7fff4b83cbb8
0x7fff4b83cbe0
0x7fff4b83cbe8
通過觀察可以發現,從引數a到f,其地址似乎是遞減的,而從g到h地址又變成遞增的了,這是為什麼呢?事實上,對於x86-64,當引數個數超過6時,前6個引數可以通過暫存器傳遞,而第7~n個引數則會通過棧傳遞,並且資料大小都向8的倍數對齊。也就是說,對於7~n個引數,依然滿足從右往左入棧,只是對於前6個引數,它們是通過暫存器來傳遞的。另外,暫存器的訪問速度相對於記憶體來說要快得多,因此為了提高空間和時間效率,實際中其實不建議引數超過6個。
對於函式引數入棧順序我們就瞭解到這裡,但是引數入棧順序和變長引數又有什麼關係呢?
變長引數實現分析
通過前面的例子,我們瞭解到函式引數是從右往左依次入棧的,而且第一個引數位於棧頂。那麼,我們就可以通過第一個引數進行地址偏移,來得到第二個,第三個引數的地址,是不是可以實現呢?我們來看一個32位程式的例子。例子同樣很簡單,我們通過a的地址來獲取其他引數的地址:
#include<stdio.h>
void test( int a, char b, int c, int d, int e)
{
printf("%d\n%d\n%d\n%d\n%d\n\n",a,*(&a+1),*(&a+2),*(&a+3),*(&a+4));
}
int main(int argc,char *argv[])
{
int a = 1;
char b = 2;
int c = 3;
int d = 4;
int e = 5;
test(a,b,c,d,e);
return 0;
}
編譯為32位程式執行:
gcc -m32 -o paraTest paraTest.c
./paraTest
1
2
3
4
5
通過觀察執行結果我們可以發現,即使只有a的地址也可以訪問到其他引數。也就是說,即便傳入的引數是多個,只要我們知道每個引數的型別,只需通過第一個引數就能夠通過地址偏移正確訪問到其他引數。同時我們也注意到,即便b是char型別,訪問c的值也是偏移4的倍數地址,這是位元組對齊的緣故,有興趣的可以閱讀理一理位元組對齊的那些事。
變長引數實現
經過前面的理解分析,我們知道,正是由於引數從右往左入棧(但是要注意的是,對於x86-64,它的引數不是完全從右往左入棧,且引數可能不在一個連續的區域中,它的變長引數實現也更為複雜,我們這裡不展開)可以實現變長引數。當然了,這一切,C已經有現成可用的一些東西來幫我們實現變長引數。
它主要通過一個型別(va_list)和三個巨集(va_start、va_arg、va_end)來實現
va_list :儲存引數的型別資訊,32位和64位實現不一樣。
void va_start ( va_list ap, paramN );
引數:
ap: 可變引數列表地址
paramN: 確定的引數
功能:初始化可變引數列表,會把paraN之後的引數放入ap中
type va_arg ( va_list ap, type );
功能:返回下一個引數的值。
void va_end ( va_list ap );
功能:完成清理工作。
可變引數函式實現的步驟如下:
-
1.在函式中建立一個va_list型別變數
-
2.使用va_start對其進行初始化
-
3.使用va_arg訪問引數值
-
4.使用va_end完成清理工作
接下來我們來實現一個變長引數函式來對給定的一組整數進行求和。程式清單如下:
#include <stdio.h>
/*要使用變長引數的巨集,需要包含下面的標頭檔案*/
#include <stdarg.h>
/*
* getSum:用於計算一組整數的和
* num:整數的數量
*
* */
int getSum(int num,...)
{
va_list ap;//定義引數列表變數
int sum = 0;
int loop = 0;
va_start(ap,num);
/*遍歷引數值*/
for(;loop < num ; loop++)
{
/*取出並加上下一個引數值*/
sum += va_arg(ap,int);
}
va_end(ap);
return sum;
}
int main(int argc,char *argv[])
{
int sum = 0;
sum = getSum(5,1,2,3,4,5);
printf("%d\n",sum);
return 0;
}
上面的小程式接受變長引數,第一個引數表明將要計算和的整數個數,後面的引數是要計算的值。
編譯執行可得結果:15。
但是我們要注意的是,這個小程式不像printf那樣,對傳入的引數做了校驗,因此一但傳入的引數num和實際引數不匹配,或者傳入型別與要計算的int型別不匹配,將會出現不可預知的錯誤。我們舉一個簡單的例子,如果第二個引數傳入一個浮點數,程式清單如下:
#include <stdio.h>
/*要使用變長引數的巨集,需要包含下面的標頭檔案*/
#include <stdarg.h>
/*
* getSum:用於計算一組整數的和
* num:整數的數量
*
* */
int getSum(int num,...)
{
va_list ap;//定義引數列表變數
int sum = 0;
int loop = 0;
int value = 0;
va_start(ap,num);
for(;loop < num ; loop++)
{
value = va_arg(ap,int);
printf("the %d value is %d\n",loop.value);
sum += value;
}
va_end(ap);
return sum;
}
int main(int argc,char *argv[])
{
int sum = 0;
float a = 8.25f;
printf("a to int=%d\n",*(int*)&a);
sum = getSum(5,a,2,3,4,5);
printf("%d\n",sum);
return 0;
}
編譯執行:
gcc -m32 -o multiPara multiPara.c
./multiPara
a to int=1090781184
the 0 loop value is 0
the 1 loop value is 1075871744
the 2 loop value is 2
the 3 loop value is 3
the 4 loop value is 4
the sum is1075871753
觀察上面的執行結果,發現結果與我們所預期大相徑庭,我們可能會有以下幾個疑問:
-
1.把a的地址上的值轉換為int,為什麼會是1090781184?
-
2.getSum函式中,為什麼第一個值是0?
-
3.getSum函式中,為什麼第二個值是1075871744?
-
4.getSum函式中,為什麼沒有獲取到5?
-
5.為什麼最後的結果不是我們預期的值?
我們逐一解答
-
第一個問題,我們不在本文解釋,但可以通過對浮點數的一些理解來找到答案。
-
對於第二個、第三個問題以及第四個問題,涉及到型別提升。也就是說在C語言中,呼叫一個不帶原型宣告的函式時,呼叫者會對每個引數執行“預設實際引數提升",提升規則如下:
——float將提升到double
——char、short和相應的signed、unsigned型別將提升到int
——如果int不能儲存原值,則提升到unsigned int
那麼也就可以理解了,呼叫者會將提升之後的引數傳給被呼叫者。也就是說a被提升為了8位元組的double型別,自然而然,而我們取值是按int4位元組取值,第一次取值取的double的前4位元組,第二次取的後4位元組,而由於總共取數5次,因此最後的5也就不會被取到。 -
瞭解了前面幾個問題的答案,那麼最後一個問題的答案也就隨之而出了。前面取值已經不對了,最後的結果自然不是我們想要的。
總結
通過前面的分析和示例,我們來做一些總結
-
變長引數實現的基本原理
對於x86來說,函式引數入棧順序為從右往左,因此,在知道第一個引數地址之後,我們能夠通過地址偏移獲取其他引數,雖然x86-64在實現上略有不同,但`對於開發者使用來說,實現變長引數函式沒有32位和64位的區別。 -
變長引數實現注意事項
1.…前的引數可以有1個或多個,但前一個必須是確定型別。
2.傳入引數會可能會出現型別提升。
3.va_arg的type型別不能是char,short int,float等型別,否則取值不正確,原因為第2點。
4.va_arg不能往回取引數,但可以使用va_copy拷貝va_list,以備後用。
5.變長引數型別注意做好檢查,例如可以採用printf的佔位符方式等等。
6.即便printf有型別檢查,但也要注意引數匹配,例如,將int型別匹配%s列印,將會出現嚴重問題。
7.當傳入引數個數少於使用的個數時,可能會出現嚴重問題,當傳入引數大於使用的個數時,多出的引數不會被處理使用。
8.注意位元組對齊問題。