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

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

C語言的經典swap問題

在學習C語言的時候,咱們大都遇到過一些經典例題,這些經典例題背後所表明的是每每是C/C++背後的一些運行原理,好比下面這個示例:程序員

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

稍微有些經驗的程序員確定要脫口而出:不行!!數據結構

爲何不行呢?函數

這個題我都看過十遍了,由於要用指針!!學習

好吧,確實是要用指針,估計十我的有九個能寫出標準答案:ui

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

嗯,很是不錯!那咱們再來作作這個題:this

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

這時就有一些朋友想也不用想:能夠啊,用指針進行交換確定是能夠的。spa

那麼,到底這個「交換數據要用指針」的概念是否是徹底正確的呢?仍是其中另有隱情?操作系統

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

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 5code

結果是不能交換,不是用了指針麼,爲何會是這樣的結果??

這得從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不沾身.

相關文章
相關標籤/搜索