深入理解 C 語言的函式呼叫過程

發表於2016-09-06

本文主要從程式棧空間的層面複習一下C語言中函式呼叫的具體過程,以加深對一些基礎知識的理解。

先看一個最簡單的程式:

主函式main裡定義了4個區域性變數,然後呼叫同檔案裡的foo1()函式。4個區域性變數毫無疑問都在程式的棧空間上,當程式執行起來後我們逐步瞭解一下main函式裡是如何基於棧實現了對foo1()的呼叫過程,而foo1()又是怎麼返回到main函式裡的。為了便於觀察的粒度更細緻一些,我們對test.c生成的彙編程式碼進行除錯。如下:

上面的彙編原始碼和最終生成的可執行程式主體結構上已經非常類似了:

用GDB除錯可執行程式test:

深入理解 C 語言的函式呼叫過程

在main函式第一條指令執行前我們看一下程式test的棧空間佈局。因為我們最終的可執行程式是通過glibc庫啟動的,在main的第一條指令執行前,其實還有很多故事的,這裡就不展開了,以後有時間再細究,這裡只要記住一點:main函式執行前,其程式空間的棧裡已經有了相當多的資料。我的系統裡此時棧頂指標esp的值是0xbffff63c,棧基址指標ebp的值0xbffff6b8,指令暫存器eip的值是0x80483de正好是下一條馬上即將執行的指令,即main函式內的第一條指令“push %ebp”。那麼此時,test程式的棧空間佈局大致如下:

深入理解 C 語言的函式呼叫過程

然後執行如下三條指令:

深入理解 C 語言的函式呼叫過程

行完上述三條指令後棧裡的資料如上圖所示,從0xbffff630到0xbffff638的8位元組是為了實現地址對齊的填充資料。此時ebp的值0xbffff638,該地址處存放的是ebp原來的值0xbffff6b8。詳細布局如下:

深入理解 C 語言的函式呼叫過程

第28條指令“subl $32, %esp”是在棧上為函式裡的本地區域性變數預留空間,這裡我們看到main主函式有4個int型的變數,理論上說預留16位元組空間就可以了,但這裡卻預留了32位元組。GCC編譯器在生成彙編程式碼時,已經考慮到函式呼叫時其輸入引數在棧上的空間預留的問題,這一點我們後面會看到。當第28條指令執行完後棧空間裡的資料和佈局如下:

深入理解 C 語言的函式呼叫過程

深入理解 C 語言的函式呼叫過程

然後main函式裡的變數x,y,z的值放到棧上,就是接下來的三條指令:

這是三條暫存器間接定址指令,將立即數11,22,33分別放到esp暫存器所指向的地址0xbffff610向高位分別偏移16、20、24個位元組處的記憶體單元裡,最後結果如下:

深入理解 C 語言的函式呼叫過程

注意:這三條指令並沒有改變esp暫存器的值。

接下來main函式裡就要為了呼叫foo1函式而做準備了。由於mov指令的兩個運算元不能都是記憶體地址,所以要將x,y和z的值傳遞給foo1函式,則必須藉助通用暫存器來完成,這裡我們看到eax承擔了這樣的任務:

深入理解 C 語言的函式呼叫過程

當foo1函式所需要的所有輸入引數都已經按正確的順序入棧後,緊接著就需要呼叫call指令來執行foo1函式的程式碼了。前面的博文說過,call指令執行時分兩步:首先會將call指令的下一條指令(movl %eax, 28(%esp))的地址(0x0804841b)壓入棧,然後跳轉到函式foo1入口處開始執行。當第38條指令“call foo1”執行完後,棧空間佈局如下:

深入理解 C 語言的函式呼叫過程

call指令自動將下一條要執行的指令的地址0x0804841b壓入棧,棧頂指標esp自動向低地址處“增長”4位元組。所以,我們以前在C語言裡所說的函式返回地址,應該理解為:當被呼叫函式執行完之後要返回到它的呼叫函式裡下一條馬上要執行的程式碼的地址。為了便於觀察,我們把foo1函式最後生成指令再列出來:

進入到foo1函式裡,開始執行該函式裡的指令。當執行完第6、7、8條指令後,棧裡的資料如下。這三條指令就是彙編層面函式的“序幕”,分別是儲存ebp到棧,讓ebp指向當前棧頂,然後為函式裡的區域性變數預留空間:

深入理解 C 語言的函式呼叫過程

深入理解 C 語言的函式呼叫過程

接下來第9和第10條指令,也並沒有改變棧上的任何資料,而是將函式輸入引數列表中的的x和y的值分別轉載到eax和edx暫存器,和main函式剛開始時做的事情一樣。此時eax=22、edx=11。然後用了一條leaf指令完成x和y的加法運算,並將運算結果存在eax裡。第12條指令“addl 16(%ebp), %eax”將第三個輸入引數p的值,這裡是實參z的值為33,同樣用暫存器間接定址模式累加到eax裡。此時eax=11+22+33=66就是我們最終要得計算結果。

深入理解 C 語言的函式呼叫過程

因為我們foo1()函式的C程式碼中,最終計算結果是儲存到foo1()裡的區域性變數x裡,最後用return語句將x的值通過eax暫存器返回到mian函式裡,所以我們看到接下來的第13、14條指令有些“多此一舉”。這足以說明gcc人家還是相當嚴謹的,C原始碼的函式裡如果有給區域性變數賦值的語句,生成彙編程式碼時確實會在棧上為本地變數預留的空間裡的正確位置為其賦值。當然gcc還有不同級別的優化技術來提高程式的執行效率,這個不屬於本文所討論的東西。讓我們繼續,當第13、14條指令執行完後,棧佈局如下:

深入理解 C 語言的函式呼叫過程

將ebp-4的地址處0xbffff604(其實就是foo1()裡的第一個區域性引數x的地址)的值設定為66,然後再將該值複製到eax暫存器裡,等會兒在main函式裡就可以通過eax暫存器來獲取最終的計算結果。當第15條指令leave執行完後,棧空間的資料和佈局如下:

深入理解 C 語言的函式呼叫過程

 

我們發現,雖然棧頂從0xbffff5f8移動到0xbffff60c了,但棧上的資料依然存在。也就是說,此時你通過esp-8依舊可以訪問foo1函式裡的區域性變數x的值。當然,這也是說得通的,因為函式此時還沒有返回。我們看棧佈局可以知道當前的棧頂0xbffff60c處存放的是下一條即將執行的指令的地址,對照反彙編結果可以看到這正是main函式裡的第18條指令(在整個彙編原始檔test.s裡的行號是39)“movl %eax, 28(%esp)”

leave指令其實完成了兩個任務:

1、將棧上為函式預留的空間“收回”;
2、恢復ebp;

也就是說leave指令等價於下面兩條指令,你將leave替換成它們編譯執行,結果還是對的:

前面我們也說過,ret指令會自動到棧上去pop資料,相當於執行了“popl %eip”,會使esp增大4位元組。所以當執行完第16條指令ret後,esp從0xbffff60c增長到0xbffff610處,棧空間結構如下:

深入理解 C 語言的函式呼叫過程

現在已經從foo1裡返回了,但是由於還沒執行任何push操作,棧頂“上部”的資料依舊還是可以訪問到了,即esp-12的值就是foo1裡的區域性變數x的值、esp-4的值就是函式的返回地址,當執行第39條指令“movl %eax,28(%esp)”後棧佈局變成下面的樣子:

深入理解 C 語言的函式呼叫過程

第39條指令就相當於給main裡的result變數賦值66,如上紅線標註的地方。接下來main函式裡要執行printf(“result=%d\n”,result)語句了,而printf又是C庫的一個常用的輸出函式,這裡就又會像前面呼叫foo1那樣,初始化棧,然後用“call printf的地址”來呼叫C函式。當40~43這4條指令執行完後,棧裡的資料如下:

深入理解 C 語言的函式呼叫過程

上圖為了方便理解,將棧頂的0x08048504替換了成字串“result=%d\n”,但程式實際執行時此時棧頂esp的值是字串所在的記憶體地址。當第44條指令執行完後,棧佈局如下:

深入理解 C 語言的函式呼叫過程

由於此時棧已經用來呼叫printf了,所以棧頂0xbffff610“以上”部分的空間裡就找不到foo1的任何影子了。最後在main函式裡,當第46、47條指令執行完後棧的佈局分別是:

深入理解 C 語言的函式呼叫過程

當main函式裡的ret執行完,其實是返回到了C庫裡繼續執行剩下的清理工作。
所以,最後關於C的函式呼叫,我們可以總結一下:

1、函式輸入引數的入棧順序是函式原型中形參從右至左的原則;
2、組合語言裡呼叫函式通常情況下都用call指令來完成;
3、組合語言裡的函式大部分情況下都符合以下的函式模板:

如果我們有個函式原型:int funtest(int x,int y int z char* ptr),在彙編層面,當呼叫它時棧的佈局結構一般是下面這個樣子:

深入理解 C 語言的函式呼叫過程

而有些資料上將ebp指向函式返回地址的地方,這是不對的。正常情況下應該是ebp指向old ebp才對,這樣函式末尾的leave和ret指令才可以正常工作。

相關文章