函數調用棧

程序的執行過程可看作連續的函數調用。當一個函數執行完畢時,程序要回到調用指令的下一條指令(緊接call指令)處繼續執行。函數調用過程通常使用堆棧實現,每個用戶態進程對應一個調用棧結構(call stack)。編譯器使用堆棧傳遞函數參數、保存返回地址、臨時保存寄存器原有值(即函數調用的上下文)以備恢復以及存儲本地局部變量。
不同處理器和編譯器的堆棧布局、函數調用方法都可能不同,但堆棧的基本概念是一樣的。

1 寄存器

寄存器是處理器加工數據或運行程序的重要載體,用于存放程序執行中用到的數據和指令。因此函數調用棧的實現與處理器寄存器組密切相關。
在函數調用過程中, 除通用寄存器外, 還使用了三個特殊的寄存器

  • IP(Instruction Pointer)是指令寄存器, 指向處理器下條等待執行的指令地址(代碼段內的偏移量),每次執行完相應匯編指令IP值就會增加
  • SP(Stack Pointer)是堆棧指針寄存器,存放執行函數對應棧幀的棧頂地址(也是系統棧的頂部),且始終指向棧頂
  • BP(Base Pointer)是棧幀基址指針寄存器,存放執行函數對應棧幀的棧底地址,用于C運行庫訪問棧中的局部變量和參數。

不同架構的CPU,寄存器名稱被添加不同前綴以指示寄存器的大小。例如x86架構用字母“e(extended)”作名稱前綴,指示寄存器大小為32位;x86_64架構用字母“r”作名稱前綴,指示各寄存器大小為64位。

2 棧幀

函數調用經常是嵌套的,在同一時刻,堆棧中會有多個函數的信息。每個未完成運行的函數占用一個獨立的連續區域,稱作棧幀(Stack Frame)。棧幀是堆棧的邏輯片段,當調用函數時邏輯棧幀被壓入堆棧, 當函數返回時邏輯棧幀被從堆棧中彈出。棧幀存放著函數參數,局部變量及恢復前一棧幀所需要的數據等。

編譯器利用棧幀,使得函數參數和函數中局部變量的分配與釋放對程序員透明。編譯器將控制權移交函數本身之前,插入特定代碼將函數參數壓入棧幀中,并分配足夠的內存空間用于存放函數中的局部變量。使用棧幀的一個好處是使得遞歸變為可能,因為對函數的每次遞歸調用,都會分配給該函數一個新的棧幀,這樣就巧妙地隔離當前調用與上次調用。

棧幀是一個邏輯概念, 可以認為一個函數所占用的空間就是一個棧幀(包括 BP, SP 局部變量, 下個函數參數 返回地址 IP....), 其中BP就指向當前函數棧幀底部, SP永遠指向棧幀頂部(也是整個調用棧的頂部);
當程序執行時SP會隨著數據的入棧和出棧而移動。因此函數中對大部分數據的訪問都基于BP進行。

函數調用棧的典型內存布局
// 偽代碼
void test(int a, int b){
    int c = 12;
    int d = 13;
}

int main(int argc, const char * argv[]) {
    
    int a = 10;
    int b = 11;
    test(a, b);
    printf("下一條指令")
    
    return 0;
}

從圖中可以看出,函數調用時入棧順序為

實參N-1→主調函數返回地址→主調函數幀基指針EBP→被調函數局部變量1-n

  1. 主調函數將參數按照調用約定依次入棧(C/C++ 中參數由右向左一次入棧, 這樣出棧是就能保持參數傳遞的順序)
  2. 將指令指針EIP入棧以保存主調函數的返回地址(下一條待執行指令的地址, 也就是printf函數的地址)。
  3. 此時進入test執行指令, 先將主調函數(main)的棧幀基指針EBP入棧
  4. 將主調函數(main)函數的棧頂指針ESP值賦給被調函數的EBP(作為被調函數的棧底)
  5. 改變ESP值來為函數局部變量預留空間

此時函數空間已調整完畢
此時被調函數幀基指針(EBP)指向被調函數的棧底。以該地址為基準,向上(棧底方向)可獲取主調函數的返回地址、參數值,向下(棧頂方向)能獲取被調函數的局部變量值,而該地址處又存放著上一層主調函數的幀基指針值。
函數調用完成之后

  1. 將EBP指針值賦給ESP,使ESP再次指向被調函數棧底以釋放局部變量;(其實數據還在內存中, 不過調整指針之后, 已經變成不安全空間)
  2. 再將已壓棧的主調函數幀基指針彈出到EBP
  3. 彈出返回地址到EIP
  4. ESP繼續上移越過參數,最終回到函數調用前的狀態,即恢復原來主調函數的棧幀。如此遞歸便形成函數調用棧

EBP指針在當前函數運行過程中(未調用其他函數時)保持不變。在函數調用前,ESP指針指向棧頂地址,也是棧底地址。在函數完成現場保護之類的初始化工作后,ESP會始終指向當前函數棧幀的棧頂,此時,若當前函數又調用另一個函數,則會將此時的EBP視為舊EBP壓棧,而與新調用函數有關的內容會從當前ESP所指向位置開始壓棧。

3 iOS中的函數調用棧

iPhone 5s以上設備都是用了arm64 (64位的CPU架構), 而ARM64 有34個寄存器,包括31個通用寄存器, 所以當參數少的情況下, 用寄存器就可以完成函數參數的傳遞, 所以棧幀結構與上圖有所區別.

void test(int a, int b){
    int c = 12;
    int d = 13;
    printf("%d, %d", a, b);
}

int main(int argc, const char * argv[]) {
    
    int a = 10;
    int b = 11;
    test(a, b);
    printf("下一條指令");
    
    return 0;
}

下圖是根據上面代碼在Xcode中實際生成的匯編代碼


main 函數匯編指令

test 函數匯編指令

這里注意,如果用模擬器調試,使用的是mac電腦的CPU架構,只有使用真機調試,才能看到arm64的匯編代碼
ARM64開始,取消32位的 LDM,STM,PUSH,POP指令! 取而代之的是ldr\ldp str\stp
ARM64里面 對棧的操作是16字節對齊的!!

在ARM64中,x0-x7寄存器,用來存放參數、返回值(x0)

入棧順序:

  1. 分別將兩個參數賦值給w0,w1寄存器
  2. bl指令 調用函數, 此時會將下一條指令賦值給LR寄存器(也就是printf)
  3. 進入test
    • sub sp, sp, #0x40 提升棧空間 sp 棧頂指針
    • stp x29, x30, [sp, #0x30]x29, x30放入棧中, x29(FP):棧底指針 x30(LR): 子程序結束后需要執行的下一條指令
    • add x29, sp, #0x30 將當前函數棧幀基地址存儲到x29(FP)
  4. 此時函數入棧完成開始執行業務代碼.......

出棧順序:

  1. ldp x29, x30, [sp, #0x30] 將之前保存在棧中的x29,x30數據,重新復制給x29,x30寄存器,相當于將x29 x30恢復到調用函數之前的狀態。
  2. add sp, sp, #0x40 恢復SP指針, 抵消入棧時的 提升棧空間的操作。 相當于回收函數調用開辟的棧空間
  3. ret 返回指令,將LR中存儲的值賦值給PC,結束子程序,回到函數調用前的狀態繼續執行。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 原文地址:C語言函數調用棧(一)C語言函數調用棧(二) 0 引言 程序的執行過程可看作連續的函數調用。當一個函數執...
    小豬啊嗚閱讀 4,674評論 1 19
  • 棧: 在函數調用時,第一個進棧的是主函數中函數調用后的下一條指令(函數調用語句的下一條可執行語句)的地址,然后是函...
    zjfclimin閱讀 4,094評論 0 5
  • 閱讀經典——《深入理解計算機系統》04 函數調用時的棧結構變化是一個很有趣的話題,本文就來詳細剖析這個過程。 棧幀...
    金戈大王閱讀 23,378評論 14 36
  • 由于不是科班出生,又是自學開發,對很多方面的知識都是只知其然而不知其所以然。加上最近公司事情不多,剛好乘此機會把長...
    寒咯閱讀 13,094評論 3 8
  • 首先先看圖: 在main函數調用func_A的時候,首先在自己的棧幀中壓入函數返回地址,然后為func_A創建新棧...
    zjfclimin閱讀 7,386評論 1 2