x86下的C函數調用慣例

1 從彙編到C

1.1 彙編語言的侷限性

彙編語言是一種符號化了的機器語言,即用指令助記符、符號地址、標號等符號書寫程序的語言。彙編語句與機器語句一一對應,它只是把每條指令及數據用便於記憶的符號書寫而已。

彙編語言,使用人類語言的單詞作爲助記符與機器碼建立一一對應關係。彙編器維護了對應關係映射表,並在彙編階段將彙編代碼翻譯成機器碼指令。相對於直接紙帶打孔而言,彙編程序已經前進了一步。但是第一個編寫彙編程序的人,或者說彙編器的設計本身還是需要手工彙編機器碼,當然存儲設備也不再是紙帶,掃描譯碼設備也更先進。

彙編器甚至將常用指令序列有機組合成僞指令,以合成指令的形式向彙編程序員提供更簡單和智能的操作接口。當然,在預編譯階段,彙編器需要將合成指令擴展分解成機器可識別的原子指令序列

儘管彙編程序消除了手工彙編機器碼缺少創造性這一問題,但是彙編語言還是存在兩個問題:第一,彙編語言冗長、乏味,因爲在微處理器芯片級編程,必須熟悉每一個指令細節。第二個問題是彙編語言不可移植,各個型號的CPU的IA不同,彙編語言也不同,這樣導致了彙編語言不可移植。爲Intelx86編寫的彙編程序無法在MIPSCPU上運行。

比如,我們要設計一個函數,計算兩個整數的加和——A=B+C。無論哪種CPU中都提供了基本的算術加法運算支持(加法器),但是這麼一種簡單的加法運算,如果用彙編語言編寫,在不同的CPU IA中表現出不同的代碼形式。在Intel x86的機器上用x86彙編語言編寫一次,下一次在MIPS機器上又得用MIPS彙編語言編寫一次。但這種通用的處理過程或者說運算都有着相似的邏輯(語義),不同語言的詞彙集合和語法結構差異導致語言組織的不同。

1.2 高級程序設計語言

不同的語言造成了溝通隔閡,一個不懂德語的中國人和一個不懂中文的德國人的交談無異於對牛彈琴。這個時候,他們需要一種雙方都能聽懂的第三方語言(比如國際通用的英語)來介入交流,然後雙方將第三方語言還原到各自的語境文化中理解對應的意象和表義。這種第三方的語言爲大家的共同語言,用共同語言發出的聲音或編寫的文檔能夠爲大家所理解。

我們需要設計一種更高級的共同語言,這種語言將更接近人類語言的概念描述和語法組織。我們的一個重要目的就是實現可移植性,對於同一功能模塊,只需使用共同語言編寫一次,避免重複勞動。這種高級語言就是C語言,它具有更高的抽象性,提供更完善更自然的話語體系。

那麼C語言是如何實現跨平臺移植的呢?機器只能識別IA指令集對應的機器碼,工具鏈中的彙編器實現了低級彙編語言到機器碼的映射。鑑於此,我們只需要將高級C語言“映射”成彙編語言就行了。但是C語言文件本質是一種由字符串(C語言關鍵字)序列組成的文本文件,故首先需要字符串文本解析;其次,C語言的高度抽象性和語法複雜性導致無法與彙編語言建立簡單的映射關係。將C語言翻譯成彙編語言的工作,是一種叫做編譯器的特殊程序處理的。特定體系架構工具鏈中的編譯器負責將C語言文件進行一系列詞法分析、語法分析、語義分析及優化後生成相應的彙編代碼,然後彙編器負責將彙編代碼翻譯成指令機器碼。相比只是建立簡單映射關係的彙編器,編譯器要複雜得多。

有了編譯器和彙編器的支持,高級語言C就可以實現跨平臺移植了。C++、Java等面向對象的更高級語言,則更進一步的接近人類語言和思維習慣,它們使得現代軟件更加工程化和模式化。當然,更高一級的語言,需要更高一級的編譯處理支持或其他設施支持。實際上C++是基於C實現的,例如修飾符實現了重載類型識別,虛函數表實現了繼承覆蓋,這些都要求C++編譯器提供比C編譯器更多的內置支持。面嚮對象語言Java採用中間碼和虛擬機機制實現了跨操作系統平臺的開發、移植和運行。無論是C++或Java字節碼,最終都必須轉化成彙編語言,然後再翻譯成指令機器碼才能在特定CPU上執行。

高級語言能夠實現跨平臺開發,並使軟件工程逐漸規模化。但是高級語言的運行需要建立一定的環境,比如C函數調用需要堆棧支持。在系統啓動之初的引導階段(bootstrap),往往需要使用彙編語言編寫代碼實現對CPU或硬件設備的配置,待相關硬件和內存初始化好了之後,建立堆棧即可跳轉到C函數執行。故一般在開發嵌入式系統的bootloader的bootstrap部分,還是需要使用更接近硬件的彙編語言編寫。在涉及性能敏感性操作問題時,也可在C語言中嵌入彙編代碼實現混合編程。

下面將重點梳理C函數調用慣例(Calling Convention),主要以x86體系架構爲例,偶爾也會和MIPS對比。

2 程序、可執行文件、進程和線程

2.1 程序

程序是計算機的一組指令的集合,它告訴計算機如何實現特殊的功能,程序設計中的程序=數據結構+算法。我們通常說“編程序”或“寫程序”,可見程序往往具有文件屬性,例如使用彙編語言編寫的彙編程序保存爲.s/.asm文件、使用C語言編寫的C程序保存爲.c文件。程序經過編譯鏈接成可執行文件,被加載到操作系統中,最終執行特定CPU的指令流才能完成特定的計算任務。

2.2 可執行文件

編譯器(彙編器)後端的代碼生成器(Code Generator)負責將C代碼或中間代碼(AT&T彙編)轉換成目標機器代碼。這個過程十分依賴於目標機器,因爲不同機器的CPU字長、寄存器、整數數據類型和浮點數數據類型等都不同,而且還要考慮流水線、多發射、超標量等諸多複雜的特性。

可執行文件 (executable file) ,可移植可執行文件格式的文件,它可以加載到內存中,並由操作系統加載程序執行。例如,Windows下的可執行文件格式爲COFF/PE(.exe),Linux下的可執行文件格式爲ELF(一般無後綴)。可執行文件一般由編譯器(彙編器)生成的多個目標文件鏈接生成,它包含着組成可執行程序的全部目標機器代碼。程序在可執行文件中被劃分爲代碼段(.text)和數據段(.data+.rodata+.bss)。可執行文件頭部有自描述性信息,其中定義了目標機器平臺、OS/ABI、入口地址、段表等重要信息。軀幹部分的代碼段一般爲只讀,數據段爲可讀可寫,這種分段管理,便於實現共享庫和多進程實例共享一份代碼。我們可以通過VC(COFF/PE)的dumpbin工具或gcc(ELF)的readelf/objdump工具查看可執行文件中的信息或對機器碼進行反彙編。

2.3 進程

PC上存放在磁盤上的可執行文件是靜態的,當鼠標點擊或者通過命令行呼叫可執行文件,可執行文件被加載到內存中開始執行。在Windows/Linux下,將爲可執行文件創建一個進程實例。進程(Process)是具有一定獨立功能的程序關於某個數據集合上的一次運行過程,是系統進行資源分配和調度的獨立單位。進程是由進程控制塊(PCB)、程序段、數據段三部分組成,這裏的程序段和數據段當然來源於可執行文件,但加載器配合進程內存管理會做一些地址重定位。可見,進程是一個正在運行的程序,它是可執行程序(文件)的實例。

進程只是一個容器,它是不活潑的。進程要完成任何事情,必須由運行在其地址空間上的線程完成。線程是進程內執行代碼的獨立實體,它負責執行該進程地址空間的代碼。每個進程至少擁有一個在其地址空間中運行的線程,對一個不包含任何線程的進程來說,它是沒有理由繼續存 在下去的,系統會自動地銷燬此進程和它的地址空間。

2.4 線程

線程(Thread)是進程內的一個獨立執行單元,是CPU調度和分派的基本單位。一個線程就是運行在一個進程上下文中的一個邏輯流,它描述了進程內代碼的執行路徑。每個線程都有自己的線程上下文(Thread Context),包括唯一的整數線程id,棧(Stack),棧指針(Stack Pointer),程序計數器(Program Counter),通用寄存器等。

線程的運行體現爲函數的調用,具體來說創建線程時會指定一個線程入口函數,線程從這個入口點函數開始執行。之後的線程運行可以當做一系列的嵌套函數調用,這些函數都運行於線程上下文中。

在進程中運行的所有線程共享該進程的整個虛擬地址空間,所有線程的動態申請內存都從進程堆空間分配,每個線程在創建時都會指定申請的棧空間。

在Windows中,通過CreateThread()調用創建線程,通過參數dwStackSize指定棧空間大小。在Linux中通過pthread_attr_init()初始化線程屬性參數pthread_attr_t,再調用pthread_attr_setstacksize()設置棧空間大小,然後調用pthread_create(pthread_attr_t)創建線程。棧空間也是在線程所在的虛擬地址空間開闢的。

3 C函數調用慣例

3.1 函數調用

子函數(SubRoutine)往往被設計用來完成計算子任務,以期提高代碼的模塊化和可重用性。調用子函數,就是中斷當前調用函數,完成計算子任務後,再將計算結果和控制權回交給調用函數。


函數調用流程圖

函數調用涉及到調用函數和被調用函數,我們在下文中將使用“caller”指代調用函數,使用“callee”指代被調用函數。以下代碼只是演示demo,無法編譯運行。

  1. /*線程(任務)函數:taskEntry*/    
  2. thread_start_routine()  
  3. {  
  4.     /*…*/  
  5.     caller();  
  6.     /*…*/  
  7. }  
  8.   
  9. /*調用函數*/  
  10. caller()  
  11. {  
  12.     int x=1, y=2;  
  13.     int z = x+y;  
  14.     /* callee_before(); */   
  15.     callee(z);  
  16.     /* callee_after(); */  
  17. }  
  18.   
  19. /*被調用子函數:sub routine*/  
  20. callee(int i)  
  21. {  
  22.     int x=1, y=2;  
  23.     /*...*/  
  24.     printf(“%d\n”, i);  
  25.     /* add(x, y); */ /* further nest */  
  26. }  

3.2函數活動記錄——棧幀

(1)棧

在經典數據結構中,棧是一種特殊的容器,用戶可以將數據壓入棧中(入棧,push),也可以將已經壓入棧中的數據彈出(出棧,pop)。但是入棧和出棧操作遵循一條規則:先入棧的數據後出棧,即先進後出(FirstIn Last Out,FIFO)。漢諾塔和紙牌遊戲規則是典型的棧模型。

在計算機系統中,棧是一種具有以上棧容器屬性的動態內存區域。程序可以將數據壓入棧中,也可以將數據從棧頂彈出。壓棧操作使得棧(存儲量)增大,而出棧操作使得棧減小。

在經典的操作系統中,棧通常是向下增長的(stack grows down)。在i386中,棧頂由寄存器esp(Stack Pointer)標識定位。在棧上壓入數據將會是esp減小,彈出數據則使esp增大。i386的提供了push/pop指令用於壓棧、出棧操作,它們使用esp對棧進行尋址。

由於出棧操作需要將兩個值寫回到寄存器中(棧中的數據以及遞增的棧指針值),因此它不適合流水線。所以,MIPS沒有提供對棧(Stack)操作的硬件支持,沒有像x86那樣的push/pop指令。編譯器通過函數開頭減小sp開闢空間,增大sp回收空間。

<1> 入棧——push src

源操作數允許爲16位或32位通用寄存器、存儲器和立即數以及16位段寄存器。將src壓入堆棧後,esp將減小sizeof(src)。

<2> 出棧——pop dst

目的操作數允許爲16或32位通用寄存器、存儲器和16位段寄存器。將esp匹配dst數據類型的字節彈出到dst,esp將增大sizeof(dst)。

<3> 棧的初始化

系統或進程初始化後,esp被初始化爲某個內存地址——“lea esp mem”。線程/任務都有獨立的棧,在進程空間中開闢空間,一般由線程控制塊的StackBase(high top)/StackEnd(lowbottom)界定。線程上下文維護了各自的棧指針,它表明了當前棧活動(使用)狀態。

可參考《程序員的自我修養——鏈接、裝載和庫》的6.4.2<堆和棧>和6.4.5<進程棧初始化>。

<4> 棧空間的管理

直接減小esp的值,等效於在棧上開闢空間;直接增大esp的值,等效於在站上回收空間,如果需要取得棧上的變量則需要出棧操作。可見,通過調整棧頂位置可以實現對棧空間的管理。

(2)棧幀

棧在程序運行中具有舉足輕重的地位。函數調用作爲一種“中斷”,需要棧保存需要維護的現場信息、提供函數調用的交互以及臨時開銷,這常常被稱爲棧幀(Stack Frame)或活動記錄(Activate Record)。棧幀一般包括如下幾方面的內容:

<1>函數調用是一種粗魯的中斷,故首先需要保存函數的返回地址。callee返回後,caller的繼續執行點。

<2>函數調用是caller與callee的交互,故需要保存函數傳遞的參數。由caller壓入棧中,傳遞給callee訪問。

<3>函數體可能會破壞既有寄存器,故需要保存寄存器上下文。保存寄存器,以便臨時借用它們週轉,完畢恢復。

<4>函數體自身邏輯週轉需要藉助臨時變量,這個自動變量控件也需要在棧中開闢。臨時變量包括函數的非靜態局部變量以及編譯器自動生成的其他臨時變量。

在i386中,函數棧幀的基地址保存在ebp(Base Pointer)寄存器中。一個函數的活動記錄用ebp和esp這兩個寄存器劃定了範圍,注意這裏的“劃定”並非嚴格的起始邊界。esp寄存器始終指向當前棧頂,同時也就指向了當前函數活動記錄的頂部。而相對的,ebp寄存器指向了函數活動記錄的一個固定位置,ebp寄存器又稱爲幀指針(Frame Pointer)。

ebp本質上是函數入口的esp。考慮caller調用callee的情況,caller準備就緒,執行call跳轉到callee,此時caller的esp將作爲callee的棧幀起點,也就是callee的ebp。callee從ebp開始開闢自己的臨時空間,保存現場,然後執行代碼邏輯。callee在使用ebp寄存器標識新棧幀時,必須保存舊的棧幀指針,即caller的ebp。

前面提到,線程中調用的函數都運行於線程上下文中,所有嵌套函數都在線程棧中做道場,即運行於線程上下文。調用函數和被調用函數的活動在時間和空間上都具有延續性,它們的活動記錄(棧幀)也是連續的。下圖演示了thread_start_routine()->caller()->callee()三層函數調用路徑中的活動記錄佈局,其中子函數的棧幀緊隨父函數,但它們都在線程棧內活動。

 

線程上下文中的嵌套函數棧框架佈局圖

從上圖看以看出,函數在執行期間,棧指針esp會不斷變化,棧指針ebp是固定的。固定不變的ebp可以用來定位函數活動記錄中的各個數據。參數位於ebp的偏移量處:ebp+4爲返回地址;ebp+8、ebp+12爲參數;本地局部變量位於ebp的偏移量處:ebp-4、ebp-8爲局部變量。

MIPS也有對應的fp($8)寄存器,如果編譯時選擇使用幀指針,則對幀內部的訪問都可以通過fp來實現。有時本地變量增長過大以致一些棧幀的內容距離sp太遠,無法通過單一的MIPS load/store指令(只能訪問sp上下偏移32KB的內容),因此這個時候藉助fp指針就比較方便。gcc編譯器有個-fomit-frame-pointer可以取消幀指針,即不使用幀指針,而是通過esp直接計算幀上變量的位置,這麼做的好處是可以多出一個ebp寄存器,但壞處是幀上尋址速度變慢。另外,幀指針存放在棧的某個已知位置且具有嵌套迭代性,使得調試器能夠跟蹤定位函數的調用軌跡(Invoking Path),這對於棧回溯(Stack Backtrace)非常有利。

3.3 調用慣例

caller和callee交互涉及到參數傳遞方式和順序、返回值傳遞、堆棧平衡。

(1)調用約定

<1> 參數的傳遞方式

儘管大部分時候,x86中都是使用棧傳遞參數,但有些情況下編譯器優化使用寄存器傳遞參數,以提高性能。如果caller使用寄存器傳遞參數,而callee仍然以爲參數放在棧上,那麼顯然callee無法獲取正確的參數。

MIPS CPU預留了4個32位參數槽a0~a3,在舊的調用約定中必須爲這四個參數槽預留空間。

<2> 參數的傳遞順序

caller將參數壓入棧中,callee再從棧中將參數取出。對於有多個參數的函數調用,參數是按照“從左至右”還是“從右至左”的順序入棧,雙方必須有一個明確的約定。

<3> 返回值的傳遞

除了參數的傳遞之外,caller與callee的交互還有一個重要的渠道就是返回值。caller調用callee就是爲了讓callee完成一定的計算子任務,因此caller調用callee後,期望能看到結果。callee的返回值即callee的運算結果,一般存儲到約定的寄存器中。

在x86中,eax寄存器往往作爲傳遞返回值的通道。eax寄存器寬度爲4字節,對於返回值大於4個字節(5~8字節)的情況,使用edx和eax聯合返回,eax爲低4字節,edx爲高處的1~4個字節。對於返回對象超過8字節的返回類型,將使用caller棧空間內存區域作爲中轉,在callee返回前將返回值的對象(callee棧上的臨時變量,即將銷燬)拷貝到caller棧空間中。

在MIPS中,往往通過v0和v1存儲返回值,類似x86中的eax和edx。當返回大於8字節的數據時,需要caller在棧裏分配一個匿名的結構變量,設置一個指向該參數的指針,該指針藏在所有顯式的參數之後,callee將結果存儲到這個模板中。

關於函數返回值傳遞方式,可參考《程序員的自我修養——編譯、鏈接與庫》10.2.3<函數返回值的傳遞>。

<4> 棧平衡

callee返回時,一般會回收自己的棧空間,但是傳遞的參數作爲交互部分,該由caller還是callee負責修正棧指針到調用前的狀態(回收參數空間,以維持棧平衡),雙方也必須有一個約定。

(2)調用慣例

毫無疑問,caller和callee對於如何調用須有一個明確的約定,只有雙方都遵守同樣的約定,callee才能正確被調用。這樣的約定稱爲“調用約定”或“調用慣例”(Calling Convention)。

調用慣例屬於二進制程序接口標準(ABI,Application Binary Interface)範疇,需要參考特定體系架構CPU的芯片文檔說明。除了跟硬件設施(寄存器約定、堆棧)支持背景外,調用慣例還跟工具鏈中的編譯器有很大的關係,因爲編譯器要將C語言翻譯成彙編語言。對於不同的編譯器,分配局部變量和保存寄存器的策略不同,會導致棧佈局不同。

爲了在鏈接的時候對調用慣例進行區分,調用管理要對函數本身的名字進行修飾。不同的調用慣例有不同的名字修飾策略(Name-mangling Policy)。這屬於鏈接過程中的符號管理範疇,可參考《程序員的自我修養——鏈接、裝載與庫》中3.5.3<符號修飾語函數簽名>。

cdecl和stdcall

在C語言中,存在多種調用慣例,默認調用慣例爲cdecl。在默認調用慣例cdecl中,參數從右至左的順序入棧,這樣左邊第一個參數先出棧;參數出棧(回收棧空間)的平衡操作由caller完成。cdecl的名字修飾策略爲“下劃線+函數名”,例如callee在編譯後其函數名被修飾爲“_callee”。

在MS Visual Studio的“項目屬性->配置屬性->C/C++->高級->調用約定”默認爲“__cdecl(/Gd)”,該屬性可由函數重寫。

在Windows的windef.h中定義了WindowsAPI的默認調用約定爲stdcall

#define WINAPI     __stdcall

/*線程入口函數原型*/

typedefDWORD (WINAPI*PTHREAD_START_ROUTINE)(

   LPVOID lpThreadParameter

   );

__stdcall是WindowsAPI的標準調用方法。stdcall和cdecl的參數入棧順序一樣,都是從右到左,但是__stdcall 的參數棧平衡維護方爲callee自身。stdcall的名字修飾策略爲“下劃線+函數名+@+參數的字節數”,如函數callee(inti)將被修飾爲“_callee@4”。

除了cdecl和stdcall調用慣例外,常見的還有fastcall和pascal調用慣例。

4 C函數調用流程

由於編譯器將C語言翻譯成彙編語言,無法通過簡單的映射完成,而是經歷了一次從C語言到機器語言的大躍遷。首先,從代碼規模上來說,彙編代碼比C代碼大很多,一個簡單的C函數可能需要擴展爲數十條指令;其次,彙編語言是最接近硬件的底層語言,它需要充分考慮各種指令細節和控制邏輯。因此,在閱讀彙編代碼時,需要直接站在硬件的角度以二進制思維來演繹復原C語義。

現代C函數調用都是依賴於棧這一容器,不同體系架構CPU的IA及其彙編語言不同,但是C函數調用慣例大同小異。鑑於x86的滲透性,以下調用流程主要基於x86/MSVC平臺下的C語言彙編展開闡述,其他平臺的大同小異,可類比分析。在分析時,重點注意寄存器(ebp/esp/eax)約定、局部變量分配和棧平衡策略,釐清caller和callee各自的維護任務和出入口操作

x86彙編中是通過call指令實現對子函數的調用——“call subroutine”。callee將中斷當前caller,因此必須保護好caller被中斷現場,以便後期能恢復現場。

4.1 保護caller現場

caller運行期間可能需要臨時借用寄存器輔助週轉,這些寄存器有可能也被callee使用。爲了保證借用前後完好無損,需要藉助棧來保存寄存器。例如caller在調用callee之前往往會執行“pusheax”保存當前eax寄存器,因爲callee將使用eax存儲返回值。caller可能需要將callee調用的結果和上一次調用callee_before的結果做進一步計算。

4.2 callercallee傳遞參數

假如實參通過臨時變量計算放在eax中,則在call之前可能有以下指令傳遞實參:

mov esp, eax;

4.3 call

x86的call指令將函數的返回地址壓棧,這個返回地址就是EIP寄存器的值。大概擴展爲“push eip; jmpcallee”。

保存call callee指令的下一句指令地址,是爲了callee返回時繼續執行caller的後續流程。

MIPS中的ra寄存器用於保存返回地址(return address)。

說明:儘管參數和返回地址壓棧都是caller負責的,但是參數和返回地址屬於callee的活動記錄範疇,因爲callee需要訪問參數,並且在退出時正確返回中斷現場。

4.4 enter callee

(1)保存舊棧幀,標識新棧幀

push        ebp

mov         ebp,esp

第一句保存caller的棧幀基址(old_ebp),esp將指向old_ebp。第二句將棧頂指針esp賦值給ebp,ebp作爲callee棧幀基址。

由於每次函數調用都用這兩句迭代更新棧幀,故Intel提供了簡化的合成指令enter來替代以上兩句指令。

(2)開闢棧空間

sub         esp, 0C0h;

對於最簡單的Debug版C程序,VC往往也會開闢一個192字節的棧空間,用於存儲局部變量、某些臨時數據以及調試信息。

(3)保存寄存器

callee運行期間可能需要借用寄存器輔助週轉臨時騰挪,這些寄存器極有可能也正在被caller使用。爲了保證借用前後完好無損,需要藉助棧來保存寄存器。

[push        reg1]

...

[push        regn]

注意,上一步爲局部變量開闢空間後,esp已經調整,這裏的push將使esp繼續向低地址增長。

(4)訪問參數

每次調用函數時,都會重新創建該函數所有的形參,此時所傳遞的參數將會初始化對應的形參。

mov 0x8(%ebp), %eax

mov %eax, 0x4(%esp)

第一句將實參數一(ebp+8)複製到eax,第二句再將eax賦值給形參局部變量(esp+4)。後面的操作都是針對形參(esp+4),而並沒有訪問caller所傳遞的實參本身(ebp+8)。

(5)局部變量

編譯器在C函數轉換爲彙編代碼時,已經爲臨時變量在ebp的負偏移量處分配了內存。

假設在callee()中調用int add(int x, int y);執行加法計算,以下爲callee創建臨時變量“int x=1,y=2;”的彙編代碼:

mov         dword ptr [ebp-4],1
mov         dword ptr [ebp-8],2

以下爲使用寄存器ecx和eax傳遞參數的彙編代碼:

mov         eax,dword ptr [ebp-8]
push        eax
mov         ecx,dword ptr [ebp-4]
push        ecx
call        _add

注意,編譯器對於普通函數有優化措施,會使用寄存器來傳遞參數。這裏已經將形參x/y拷貝到了寄存器ecx/eax,而未使用棧傳遞參數,因此在add()中直接訪問寄存器取參數,而無需使用ebp正偏移索引。

在開闢棧空間時,編譯器已經連估帶猜分配了一塊內存(ebp~ebp-0xC0)供創建局部變量,這個在將C翻譯成彙編時已經塵埃落定。正常情況下,運行時不會出現超容。

(6)運行函數體

經歷了以上準備工作之後,才真正進入到函數實體,即我們編寫的C函數實際代碼內容。

(7)返回值

callee運行完畢,使命結束,一般將返回值存儲到約定的寄存器中,以便caller獲取計算結果。可參考調用約定中關於返回值傳遞的相關說明。

4.5 leave callee

callee函數實體執行完畢,需要執行進入逆過程。

(1)恢復寄存器(保存寄存器的逆步驟)

依次逆序pop之前保存的各個寄存器,恢復到調用前的狀態。

[pop        regn]

...

[pop        reg1]

(2)回收棧空間(開闢棧空間的逆步驟)

add         esp,0C0h

釋放當初使用sub開闢的棧空間,主要是局部變量數據。說到這裏,就順便梳理一下局部變量的生存週期問題。

當callee返回到caller時,輔助運算的局部變量的使命結束,ebp已經恢復爲caller當初的ebp,callee棧幀不復存在。此時,曾經callee棧上的臨時變量處於懸浮狀態,即使沒有清零銷燬,也無法通過ebp訪問了。前面提到當callee需要返回大數據對象時,在caller的棧中分配一塊內存空間,並將這塊空間的地址作爲隱藏參數傳遞給callee,這樣做是爲了讓callee將棧上的臨時對象拷貝到caller的內存空間上。當然,我們也可以使用堆來創建管理多線程和嵌套函數之間的交互對象,在線程或嵌套函數間傳遞malloc/new出來的指針,這樣可以避免拷貝,但要在某個路徑上確定不再引用時調用free/delete銷燬釋放這塊堆上的內存。

(3)恢復棧幀(保存舊棧幀的逆步驟)

mov        esp,ebp

pop         ebp

恢復到caller調用callee(callcallee)之前的狀態,實際上就是恢復caller的棧幀,此時callee棧幀已經是過去式。

由於每次函數調用都用這兩句恢復棧幀,故Intel提供了簡化的合成指令leave來替代以上兩句指令。

(4)跳轉到返回地址(call的逆步驟)

ret

從棧中取得返回地址,並跳轉到該位置,將控制權回交給caller,大概擴展爲“pop eip; jmp eip”。

ret指令後面可以追加一個操作數,表示在ret後把棧指針esp加上操作數,例如“ret 8”表示在ret之後執行sp=sp+8。

4.6恢復caller現場

(1)回收參數空間(參數壓棧的逆步驟)

如果caller在棧上向callee傳遞了2個int參數,那麼會調用以下指令回收8字節的參數空間:

add esp, 8;

當然,如上所述,此步也可以合併到ret中:ret 8。

(2)繼續前行

callee調用結束,控制權回交給caller後,caller可到約定的寄存器中讀取利用callee的返回值(計算結果),繼續執行。可參考調用約定中關於返回值傳遞的相關說明。


參考:

《程序員的自我修養——鏈接、裝載與庫》

C++函數調用棧空間結構探究&《程序員的自我修養》糾錯


x86寄存器說明

x86指令編碼格式解析

從X86指令RET和CALL的意義看進程的自由切換


彙編調用c函數爲什麼要設置棧

函數調用堆棧變化分析

windows下的函數調用棧

函數調用棧,通過彙編語言分析

深入剖析GCC函數調用堆棧變化過程


函數調用過程探究

x86平臺上的函數調用及返回機制

從彙編角度看英特爾x86函數調用規範

C函數調用機制(x86的linux環境下)

linux下C函數調用機制(X86平臺)


棧及函數調用慣例

x86、arm、mips架構函數調用實例分析


x86體系結構下Linux-2.6.26啓動流程


Linux學習筆記 - 程序的執行(一)

Linux學習筆記 - 程序的執行(完結)

出自:http://blog.csdn.net/phunxm/article/details/8985321

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章