C/C++函數調用時傳參過程與可變參數實現原理


C/C++函數調用時傳參過程與可變參數實現原理

C語言的經典swap問題

在學習C語言的時候,我們大都遇到過一些經典例題,這些經典例題背后所代表的是往往是C/C++背后的一些運行原理,比如下面這個示例:

請問下面這個swap()函數能否用來進行值交換?
void swap(int x,int y)
{
    int temp=x;
    x=y;
    y=temp;
}

稍微有些經驗的程序員肯定要脫口而出:不行!!

為什么不行呢?

這個題我都看過十遍了,因為要用指針!!

好吧,確實是要用指針,估計十個人有九個能寫出標准答案:

void swap(int *px,int *py)
{
    int temp=*px;
    *px=*py;
    *py=temp;
}

嗯,非常不錯!那我們再來做做這個題:

下面這個swap函數能否用來進行值交換?
void swap(int *px,int *py)
{
    int *p;
    p = px;
    px = py;
    py = p;
}

這時就有一些朋友想也不用想:可以啊,用指針進行交換肯定是可以的。

那么,到底這個“交換數據要用指針”的概念是不是完全正確的呢?還是其中另有隱情?

是騾子是馬,拉出來遛遛!我們來實踐出真知:

void swap(int *px,int *py)
{
    int *p;
    p = px;
    px = py;
    py = p;
}
int main(int argc,char *argv[])
{
    int a=3,b=5;
    printf("Before swap:%d %d\n",a,b);
    swap(&a,&b);
    printf("After swap:%d %d\n",a,b);
    return 0;
}

編譯運行,輸出結果為:
Before swap:3 5
After swap:3 5

結果是不能交換,不是用了指針么,為什么會是這樣的結果??

這得從C語言的函數調用機制說起。

程序的運行

首先,我們需要知道的是,程序是怎么運行的?

從底層角度來看:我們將源代碼經過編譯鏈接階段生成二進制可執行文件,即bin文件。

單片機系統則是下載到片內flash中,上電啟動程序(在操作系統中運行這個可執行文件),然后CPU從內存中讀取指令到內部寄存器,再操作內部寄存器中的數據,執行完成之后將內部寄存器中的值寫回內存。

同時外設寄存器映射到相應的內存地址中,當需要操作硬件外設時,就對外設映射地址上的數據進行操作,如GPIO/I2C/SPI/TIMER等等。(這只是大概流程,具體實現會更復雜,這里不過多描述),其實CPU的運行就是對數據的處理過程。

從程序代碼的角度來看:一般情況下,程序從main()函數開始(main()是開發者可見的程序入口,但事實上main()函數也是被系統調用的一個函數,這里不再贅述)。

程序按順序執行,當遇到函數調用時,執行被調用函數,等被調用函數執行完畢(遞歸調用通常是存在的),函數返回,繼續執行main()函數,直到程序結束(而在操作系統中是進程結束)。

函數調用的過程

即使是現在的MCU,內部寄存器的資源也是極其有限,以目前非常流行的Cortex M3為例,15個內部寄存器,除去三個特殊寄存器(SP,PC,LR),共有12個通用寄存器,由於是32bit MCU,所以即使在極限狀態下,寄存器也只能存幾十個字節的數據。

所以一旦出現函數調用時,需要保存當前數據和狀態,內部寄存器是完全不夠用的。

而棧就是從專門內存中開辟出來用於保存程序運行時狀態的內存結構。

很多朋友對棧並不陌生,知道這是一種先進后出的數據結構,就像我們堆貨物,后來的放在上面,取得時候也是先取最上層的。

這里所說的棧不是數據結構,但是它也是遵循這個原理的內存實現。

實參和形參

我們都知道函數是帶有參數的,在函數定義和聲明時,這時候指定的參數叫形參,即形式參數,是在定義函數名和函數體的時候使用的參數,目的是用來接收調用該函數時傳入的參數.

在調用函數時,實參將賦值給形參。,傳入的參數叫實參,即實際參數,實參可以是常量、變量、表達式、函數等,無論實參是何種類型的量,在進行函數調用時,它們都必須具有確定的值,以便把這些值傳送給形參。

函數調用時棧的狀態

首先,我們繼續看上面那份代碼:

void swap(int *px,int *py)
{
    int *p;
    p = px;
    px = py;
    py = p;
}
int main(int argc,char *argv[])
{
    int a=3,b=5;
    swap(&a,&b);
    return 0;
}

在上述代碼中,我們可以看到,在main()函數中調用了swap()函數,我們來看看在這個執行過程中發生了什么?

  1. 系統將參數壓棧,壓棧順序為從右到左,即第一個參數最后壓棧(注1),值得注意的是:在現代操作系統中,參數的傳遞一般是通過寄存器直接傳遞,而不是棧上傳遞,只有當參數超過寄存器承受范圍時,使用棧傳遞參數。
  2. 系統將函數返回地址壓棧,以便程序執行完之后返回到調用前狀態。
  3. 系統為被調用函數的局部變量和其他參數分配內存空間
  4. 如果出現函數的嵌套調用,重復1-3過程
  5. 函數執行結束,如果有返回值,將返回值放入寄存器(如果返回值size太大,則放在內存)
  6. 讀取棧上返回地址,函數返回。
    這就是整個函數調用過程(這只是與參數返回值相關的調用結構,實際的實現要復雜得多,會涉及到數據對齊、上下文的保存等具體問題)。

注1:事實上隨着操作系統發展,最新的調用方式並不會直接將參數壓棧,而是先將參數存在寄存器中,因為直接操作寄存器總比操作內存效率要高,這樣可以提高運行效率,這里涉及到調用約定問題,有興趣的朋友可以自行了解。

再回到swap函數的結果

讓我們來看看上述函數調用過程中的第三步,即系統在棧上為局部變量(包括形參)分配地址空間,將寄存器中實參的值傳遞給形參。

所以,從這里我們可以知道,形參和實參是兩個地址獨立的變量,參數傳遞時事實上是變量值的傳遞,即傳值。

很多人提到參數傳遞有傳值和傳址兩種方式,但是事實上傳址是傳值的一種形式,本質上傳址傳的是指針變量的值(即地址值),也是一種值傳遞,所以嚴格來說是沒有傳址和傳值的區分的。

需要聲明的是,指針是一種數據變量類型,和int,char是同一個概念,
而類似

int *p,
char *str

int i
char c

是同一種定義行為,所以這里的p,str事實上是變量,只不過變量的值是地址。而不是某些書上說的"指針就是地址",搞清這個問題才能對指針有更清晰的了解。

我們回過頭來看第一個swap函數為什么不能交換:

void swap(int x,int y)
{
    int temp=x;
    x=y;
    y=temp;
}

我們調用這個函數,例如:

int a=3,b=5;
swap(a,b)

經過上面的討論,我們知道,系統在棧上給形參x,y分配了內存空間,然后將a的值賦值給x,b的值賦值給y,相當於進行了這樣的操作:

x=a=3;
y=b=5;

在函數執行的過程中,x與y成功進行了swap交換,即函數執行完,結果是這樣的:

x:5
y:3

但是根據我們列出的函數調用過程的第6點可以看到,在函數運行完之后,x和y被銷毀,這次x,y的交換行為根本沒有意義,因為a,b根本沒有參與到函數執行中來。

那為什么第二個函數就可以交換成功呢?

void swap(int *px,int *py)
{
    int temp=*px;
    *px=*py;
    *py=temp;
}

我們依舊調用這個函數:

int a=3,b=5;
//我們假設a的地址為0x1000,b的地址為0x1004
swap(&a,&b);

在這次調用中,系統為px,py分配空間(px和py為指針類型),然后將a,b的地址賦值給px,py,相當於執行了這樣的操作:

px=&a=0x1000;
py=&b=0x1004;

接下來的三行代碼:

int temp=*px;
*px=*py;
*py=temp;

用通俗的語言描述就是:

  • 系統取出px的值即0x1000,找到地址0x1000上存儲的變量,即a,將a賦值給temp,同temp=a;
  • 系統取出py的值即0x1004,找到地址0x1004上存儲的變量,即b,再取出px的值即0x1000,找到0x1000上存儲的變量,即a,將b賦值給a,同a=b。
  • 系統取出temp的值即原a的值,取出py的值即0x1004,找到地址0x1004上存儲的變量即b,將temp的值賦值給b,同b=temp。

函數結束,px,py被銷毀,此時a,b的值已進行交換。

那我們再來看看第三個swap函數為什么不能成功交換。

void swap(int *px,int *py)
{
    int *p;
    p = px;
    px = py;
    py = p;
}

我們還是調用這個函數,來一步步地分析:

int a=3,b=5;
//我們假設a的地址為0x1000,b的地址為0x1004
swap(&a,&b);

接下來的三行代碼:

p = px;
px = py;
py = p;

用通俗的語言表達就是:

  • 系統取出px的值即0x1000,將px的值賦值給p,此時p=0x1000;
  • 系統取出py的值即0x1004,將py的值賦值給px,此時px=0x1004;
  • 將p賦值給px,即px=0x1000;

函數結束,px,py被銷毀,此時a,b的值不受任何影響。

看到這里,我想你應該看出答案了,這個swap和第一個swap實現其實就是換湯不換葯,僅僅是將形參進行了互換,而a,b沒受到任何影響。

由此可見,這種參數傳遞問題根本就不能以是否是指針這種死板的方式來判斷是否有效。

思考

我想大家都應該已經懂了參數傳遞的原理,我來出個小題來驗證一下:

請問,下面的釋放動態內存的函數有什么問題?
void myFree(char *ptr){     //ptr為指向動態內存的指針
    free(ptr);
    ptr=NULL;
    return;
}

歡迎大家留言討論。


可變參數函數原理

在上面提到了,在參數壓棧的過程中,是從右到左的順序,即最后一個參數最先壓棧,既然提到了函數的參數傳遞,就必須來看看可變參數函數來怎么實現的。

printf()函數就是可變參數函數的一員,用過printf的盆友都知道,printf()並不固定參數的個數,pritnf()函數原型為:

int printf( const char* format , ... );

雖說是可變參數,但也並不是完全自由的,對於任意的可變參數函數,至少需要指定一個參數,通常這個參數包含對傳入參數的描述(下面會提到原因)。
可變參數的實現依賴下列幾個庫函數(宏定義)的定義:

va_list           //這是一個特殊的指針類型,指代棧中參數的開始地址
va_start(ap,T)    //ap為va_list類型,T為函數第一個參數
va_args(ap,A)     //ap為va_list類型,A為需要取出的參數類型,如int,char
va_end(ap)        //ap為va_list類型。


接下來我們便動手實現一個可變參數函數add(),返回所有傳入的int型參數之和:

int add(int cnt, ... )
{
    int sum=0;
    va_list args;
    va_start(args,cnt);
    for(int i=0;i<cnt;i++)
    {
        sum += va_arg(args,int);
    }
    va_end(args);
    return sum;
}
int main()
{
    
    printf("%d\r\n",add(4,1,2,3,4));
    return 0;
}

程序輸出結果:

10

老規矩,看完示例我們來探究一下示例實現的原理:

  • va_list args;這一條語句即定義一個va_list類型(可以看成是一種特殊的指針類型)的變量args,args變量指向的對象是棧上的數據。
  • va_start(args,cnt);這一條語句是初始化args,args指向第一個被壓棧的參數,即函數的最后一個參數,而cnt則是棧上最后一個參數,系統由此確定棧上參數內存的范圍。
  • va_arg(args,int); 這個函數需要傳入兩個參數,一個是指向棧上參數的指針args,這個指針每取出一個數據移動一次,總是指向棧上第一個未取出的參數,int指代需要取出參數的類型,CPU根據這個類型所占地址空間來進行尋址。
  • va_end(args);當args使用完之后,要將args置為空。
    整個函數實現的過程就是我們不需要通過形參來獲取實參的值,而是直接從棧上將一個個參數取出來。

在這里,我們需要關注幾個問題:

  1. 壓棧順序從右往左是怎樣實現可變參數傳遞的?
  2. printf()函數和上述的add()實現都在可變參數前至少提供了一個具體參數,可不可以省略這個參數呢?
  3. 在使用va_arg()取出函數的值時需要指定類型,如果指定一個錯誤的類型會怎么樣呢?

第一和第二個問題其實可以同時來解釋,參數從右往左壓棧,在可變參函數調用時,先將最后一個參數入棧,最后將第一個參數入棧,可變參數主要是通過第一個參數來確定參數列表,但是這時候如果第一個參數沒有被指定的話,編譯器將無法定位參數在棧上的范圍。

同時,如果可變參數函數在定義時沒有第一個參數的話,編譯器直接報錯。(gcc)

test.c:10:10: error: ISO C requires a named argument before ‘...’

va_arg對應類型問題

我們再回到第三個問題,如果在va_arg()函數中傳入一個錯誤的類型會發生什么情況呢?

下面是我傳入一個int型數據,但是在用va_arg()獲取參數時傳入了char類型,編譯時的信息:

warning: ‘char’ is promoted to ‘int’ when passed through ‘...’
sum += va_arg(args,char);                  ^
note: (so you should pass ‘int’ not ‘char’ to ‘va_arg’)
note: if this code is reached, the program will abort

警告信息,但是不會報錯,依然可以運行,那我們就運行看看結果:

Illegal instruction (core dumped)

果然,如編譯時的警告預料的,當執行到那部分代碼時,程序就會終止運行。

這是為什么呢?

其實原因也並不難想到,被調用函數並不知道參數的類型和個數,所以只能依靠用戶給的信息來尋址獲取數據,如果指定錯誤的類型,很可能會導致棧上數據的混亂,但是這里博主發現一個有意思的問題:

如果傳入的參數為char類型,我們在從棧上取參數的時候也指定char類型參數:

sum += va_arg(args,char);

按理說這是完全沒有問題的,但是在編譯的時候依然會有以下提示:

warning: ‘char’ is promoted to ‘int’ when passed through ‘...’
sum += va_arg(args,char);                  ^
note: (so you should pass ‘int’ not ‘char’ to ‘va_arg’)
note: if this code is reached, the program will abort

這是為何?

傳入的類型和指定接收的類型是匹配的,為什么提示有問題。然后我運行了一次,結果是這樣的:

Illegal instruction (core dumped)

我突然想到,printf中也會傳入char類型,我看看它是怎么實現的。

case 'c': 
    handle_char(va_arg(arg, int)); 
    continue;

看起來在printf實現中,對傳入的char類型的數據,也是根據int類型從棧上獲取數據,char是一個字節,int是4字節(32位),這樣不會出問題嗎?

理論上來說,當程序取一個int型數據時,就在棧上獲取了四字節數據,除了這個參數,還會把前一個參數(從右到左壓棧)的前三個字節取出來,勢必會導致數據的混亂。

但是,計算機系統中還有一個概念就是對齊,不管是數據結構填充還是指令和數據的存儲,這是為了尋址時的方便,所以即使是將一個char類型數據壓棧,也會占用一個int類型的空間。

所以我們再來分析為什么傳入char類型的同時取出char類型的實參會導致程序運行失敗:

當使用sum += va_arg(args,char);獲取參數時,獲取了一個字節的數據,但是由於對齊,后面填充的三個字節依然放在棧上。  

當下一次取參數時,仍然取一個字節,取出的事實上是第一個參數的第二個字節,這時候會有6個字節仍然在棧上,以此類推。  

最要命的是:棧上存儲着函數的返回地址,當參數都取完時,再取返回地址,這時候自然取不到真正的返回地址,而是取到了參數,程序跳轉到了未知的地方,所以程序運行自然失敗。


好了,關於C/C++函數調用時傳參過程的討論就到此為止啦,如果朋友們對於這個有什么疑問或者發現有文章中有什么錯誤,歡迎留言

原創博客,轉載請注明出處!

祝各位早日實現項目叢中過,bug不沾身.


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM