今天突然想分析一下函數在互相調用過程中棧幀的變化,還是想盡量以比較清晰的思路把這一過程描述出來,關于c函數調用原理的了解是很重要的。
1.關于棧
首先必須明确一點也是非常重要的一點,棧是向下生長的,所謂向下生長是指從記憶體高位址->低位址的路徑延伸,那麼就很明顯了,棧有棧底和棧頂,那麼棧頂的位址要比棧底低。對x86體系的cpu而言,其中
---> 寄存器ebp(base pointer )可稱為“幀指針”或“基址指針”,其實語意是相同的。
---> 寄存器esp(stack pointer)可稱為“ 棧指針”。
要知道的是:
---> ebp 在未受改變之前始終指向棧幀的開始,也就是棧底,是以ebp的用途是在堆棧中尋址用的。
---> esp是會随着資料的入棧和出棧移動的,也就是說,esp始終指向棧頂。
見下圖,假設函數a調用函數b,我們稱a函數為"調用者",b函數為“被調用者”則函數調用過程可以這麼描述:
(1)先将調用者(a)的堆棧的基址(ebp)入棧,以儲存之前任務的資訊。
(2)然後将調用者(a)的棧頂指針(esp)的值賦給ebp,作為新的基址(即被調用者b的棧底)。
(3)然後在這個基址(被調用者b的棧底)上開辟(一般用sub指令)相應的空間用作被調用者b的棧空間。
(4)函數b傳回後,從目前棧幀的ebp即恢複為調用者a的棧頂(esp),使棧頂恢複函數b被調用前的位置;然後調用者a再從恢複後的棧頂可彈出之前的ebp值(可以這麼做是因為這個值在函數調用前一步被壓入堆棧)。這樣,ebp和esp就都恢複了調用函數b前的位置,也就是棧恢複函數b調用前的狀态。
這個過程在at&t彙編中通過兩條指令完成,即:
leave
ret
這兩條指令更直白點就相當于:
mov %ebp , %esp
pop %ebp
2.舉個簡單的執行個體,從彙編的視角看函數調用
2.1建立一個簡單的程式,程式檔案名為 main.c
開發測試環境:
ubuntu 12.04
gcc版本:4.6.3 (ubuntu/linaro 4.6.3-1ubuntu5) (是ubuntu自帶的)
2.2編譯
#gcc -g -o main main.c
#objdump -d main > main.dump
#gcc -wall -s -o main.s main.c
這樣大家可以看main.s也可以看main.dump,這裡我們選擇使用main.dump。
截取關鍵的部分,即_start, swap , main,為什麼會有_start呢,因為elf格式的入口其實是_start而不是main()。下面的圖展示了main()函數調用swap()前後的棧空間的結構。右邊的數字代表相對幀指針的偏移位元組數。後面我們使用gdb調試就會發現棧的變化跟下圖是一緻的。
(!!!請注意,由于棧對齊的緣故,編譯器配置設定棧空間時可能會有沒用到的記憶體位址,而這些沒使用到的記憶體位址就沒在下圖表示出來,是以下圖隻能當作示意圖來了解函數棧幀結構!!具體的棧記憶體内容以下文的gdb調試的資訊為準!!!)
下面是main.dump中_start的代碼注釋,比較重要的是對esp的棧對齊操作,esp是16位元組對齊的,注意左邊行号的右邊的0x8048300一類的數字是指令位址。
下面是main.dump中swap()函數和main()函數的彙編代碼,代碼旁有詳細的注釋。
下面我們使用gdb調試main.c的代碼,使用剛才編譯好的main鏡像。
# gdb start (啟動gdb)
# (gdb) file main (加載鏡像檔案)
# (gdb) break main (把main()設定為斷點,注意gdb并沒有把斷點設定在main的第一條指令,而是設定在了調整棧指針為局部變量保留白間之後)
# (gdb) run (運作程式)
# (gdb) stepi (單步執行,不熟悉gdb的童鞋要注意了,stepi指令執行之後顯示出來的源代碼行或者指令位址,都是即将執行的指令,而不是剛剛執行完的指令!)