程序的執行過程可看作連續的函數調用。當一個函數執行完畢時,程序要回到調用指令的下一條指令(緊接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
- 主調函數將參數按照調用約定依次入棧(C/C++ 中參數由右向左一次入棧, 這樣出棧是就能保持參數傳遞的順序)
- 將指令指針EIP入棧以保存主調函數的返回地址(下一條待執行指令的地址, 也就是
printf
函數的地址)。 - 此時進入
test
執行指令, 先將主調函數(main
)的棧幀基指針EBP入棧 - 將主調函數(
main
)函數的棧頂指針ESP值賦給被調函數的EBP(作為被調函數的棧底) - 改變ESP值來為函數局部變量預留空間
此時函數空間已調整完畢
此時被調函數幀基指針(EBP
)指向被調函數的棧底。以該地址為基準,向上(棧底方向)可獲取主調函數的返回地址、參數值,向下(棧頂方向)能獲取被調函數的局部變量值,而該地址處又存放著上一層主調函數的幀基指針值。
函數調用完成之后
- 將EBP指針值賦給ESP,使ESP再次指向被調函數棧底以釋放局部變量;(其實數據還在內存中, 不過調整指針之后, 已經變成不安全空間)
- 再將已壓棧的主調函數幀基指針彈出到EBP
- 彈出返回地址到EIP
- 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中實際生成的匯編代碼
這里注意,如果用模擬器調試,使用的是mac電腦的CPU架構,只有使用真機調試,才能看到arm64的匯編代碼
ARM64開始,取消32位的 LDM,STM,PUSH,POP指令! 取而代之的是ldr\ldp str\stp
ARM64里面 對棧的操作是16字節對齊的!!
在ARM64中,x0-x7寄存器,用來存放參數、返回值(x0)
入棧順序:
- 分別將兩個參數賦值給
w0,w1
寄存器 - bl指令 調用函數, 此時會將下一條指令賦值給
LR
寄存器(也就是printf
) - 進入
test
中-
sub sp, sp, #0x40
提升棧空間sp
棧頂指針 -
stp x29, x30, [sp, #0x30]
將x29, x30
放入棧中,x29(FP)
:棧底指針x30(LR)
: 子程序結束后需要執行的下一條指令 -
add x29, sp, #0x30
將當前函數棧幀基地址存儲到x29(FP)
中
-
- 此時函數入棧完成開始執行業務代碼.......
出棧順序:
-
ldp x29, x30, [sp, #0x30]
將之前保存在棧中的x29,x30
數據,重新復制給x29,x30
寄存器,相當于將x29 x30
恢復到調用函數之前的狀態。 -
add sp, sp, #0x40
恢復SP
指針, 抵消入棧時的 提升棧空間的操作。 相當于回收函數調用開辟的棧空間 -
ret
返回指令,將LR
中存儲的值賦值給PC
,結束子程序,回到函數調用前的狀態繼續執行。