1. 程式人生 > >變長引數探究

變長引數探究

前言

變長引數,指的是函式引數數量可變,或者說函式接受引數的數量可以不固定。實際上,我們最開始學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.注意位元組對齊問題。

微信公眾號:程式設計珠璣

守望的筆記本