函數棧幀的創建和銷毀
在不同的編譯器下,函數調用過程中棧幀的創建是略有差異的,具體取決于編譯器的實現!
且需要注意的是,越高級的編譯器越不容易觀察到函數棧幀的內部的實現;
關于函數棧幀的維護這里我們要重點介紹兩個寄存器:ebp和esp;
這兩個寄存器存放的是用來維護函數棧幀的地址!
每一個函數調用,都要在棧區創建一個空間!
問題:什么是函數棧幀?
函數棧幀實際上就是函數運行時棧上的一塊空間!用于存儲相對應的臨時數據!
接下來講解以x86系統為例:
這里對函數棧幀管理就是靠這兩個寄存器:
- ebp:被稱為基址指針,指向棧幀的底部,(高地址處,且是固定位置!);
- esp:被稱為棧頂指針,指向棧幀的頂部,(低地址處,地址可變);
這里需要注意的是,對于棧來說,是優先使用高地址的(棧的特性!)!?
問題:那么函數的棧幀中都存放了哪些數據?
從低地址到高地址依次存放了:
- 調用棧幀的基址指針(ebp)--- 用于函數調用后恢復棧幀狀態;
- 下一條指令的地址 --- 執行完該函數后跳轉到下一條指令;
- 被調用的函數的參數(形參) --- 需要注意的是會從右到左進行壓棧;
- 被調用函數的局部變量和臨時數據;
- 寄存器的上下文(例如調用函數期間,使用的ebx、esi、edi等寄存器);
問題:main函數會被其他函數調用嗎??
需要注意的是,main函數也是可以被其他函數調用的:
即__tmainRTStartup會調用main函數!
問題:那么哪個誰調用?__tmainRTStartup這個函數?
__tmainRTStartup會被mainCRTStartup這個函數調用!
所以,這里我們總結一下:
因此,假如說當前我們的main函數里面調用了一個簡單的add函數,那么:
中間綠色的框是我們對應的main函數的調用堆棧,而在調用main函數之前,會先調用__tmainRTStartup和mainRTStartup這兩個函數!
而add函數在main函數上面,也就是對應的壓棧!
問題:但是函數棧幀中具體是怎么進行相關操作的?
示例代碼操作
這里我們以一個簡單的代碼為例,講解一下對應的相關操作:
其對應的匯編代碼如下所示:
需要注意的是,在調用main函數之前,調用main函數的那兩個函數的棧幀已經被創建好了!
這里我們對上面出現的匯編指令做一些簡單的解釋:
- push實際上就是壓棧,將對應的數據壓入棧中;
- mov:實際是就是賦值,這里mov ebp esp實際上就是把esp賦值給ebp;
- sub:減去對應的地址;
- lea(load effecitive address):計算內存地址并存入到寄存器當中(不訪問內存,僅計算結果);
這里實際上lea到rep stos這四行匯編代碼的作用就是將對應的棧的空間的數據都初始化為cc!
- 壓棧:在棧頂上放一個元素;
- 出棧:從棧頂刪除一個元素;
?截止到現在,做的都是初始化相關的任務,此時才開始到函數體內執行對應的任務;
假設每一行代表4個字節:
表示的就是將10這個值放到ebp-8的位置處;
?????????可以看到,也就是在ebp-8的位置上放10!
????????需要注意的是,上面這里我們是把10放進入了,如果沒有把10放進入,此時就是默認提供的隨機值,因此在C語言中,如果我們沒有進行初始化經常會打印出一堆燙燙燙燙(此時就是對應的內存棧上放的是一堆cccc);
此時可以看到對應的內存對其進行了修改(小端存儲)?
接下來我們再看int b = 20;這條匯編代碼:
dword ptr [ebp-14h], 14h
這段代碼實際上就是在ebp-14h這個地址處,填充數字20;
對應的示意圖如下所示:
?接下來我們再把int c = 0;也是在對應的棧上進行初始值:
當我們定義好對應的變量時,此時我們會調用add函數:
接下來我們按照對應的匯編代碼進行分析:
- 這里eax指向[ebp -14h],也就是讓eax指向b;
- 然后對eax進行壓棧;
- 接下來讓ecx的值指向[ebp-8],也就是ecx指向a;
- 然后在對ecx進行壓棧;
即截止到現在,我們進行的任務就是我們對應的傳參工作!
?接下來這里我們要調用對應的call指令:
call指令此時會跳轉地址,即這里會跳轉到我們對應的紅色線框對應的地址!
這里需要注意的是,call完成了兩個任務:
- 將下一條指令的地址(00C21450)進行壓棧,壓入到棧中;
- 跳轉到對應的地址執行函數體;
接下來就跳轉到對應的函數體當中:
其中,上面一堆的邏輯和main函數一樣,都是開辟對應的空間,然后進行初始化;
實際上代碼邏輯和我們上面講的是一樣的;
此時,我們依然假設每一行是4個字節,即此時每一行可以代表一個整形:
接下來我們依次看對應的匯編代碼:
int z = 0;
mov dword ptr [ebp-8,0]
?這里實際上就是把ebp-8指向的這個空間初始化為0;
然后這里把[ebp+8]的值賦值給eax當中:
這里[ebp+8]的值實際上就是之前我們的ecx的值也就是10!
然后再加上[ebp+och]的值,och換算為10進制為12,也就是這里我們之前eax的值!
加完之后,再把算出來的結果返回到ebp-8當中,也就是z!
問題:我們在函數棧幀中有創建對應的形參嗎?
沒有!在我們call進入函數體之前,我們就通過形參壓棧到對應的棧幀當中!
并且參數的壓棧順序是從右向左!
問題:如何再理解形參是實參的一份臨時拷貝呢?
這里我們在梳理下邏輯:
- 在調用add函數之前,會對形參從右到左進行一份拷貝;
- 而形參是調用函數之前,從main函數里面拷貝的實參!
可以看到上面的ecx和eax是對應的a和b的一份臨時拷貝!
所以改變形參不會改變對應的實參!
接下來我們再回到上面對應的代碼當中:上面我們只是把計算的值寫入到了z當中;
這里我們重點看return z的匯編代碼:
mov eax,dword ptr[ebp-8]
這里是把對應的返回值存入到了eax寄存器當中;
需要注意的是寄存器的值不會隨著函數棧幀被銷毀而丟失!
接下來執行pop對應的匯編代碼,這里也就是出棧,對應的esp棧頂指針會進行移動:
由于此時結果已經運行出來,保存到了eax寄存器當中,所以這里接下來直接對棧幀銷毀即可!
這里直接進行:
mov ebp,esp
此時棧頂指針直接指向棧底指針!
然后讓棧底指針進行出棧:
pop ebp
?實際上就是將add函數的棧底指針出棧,恢復到main函數當中;
pop:不僅對應的空間進行出棧,此時esp還需要+4個字節的地址;
ret
?ret指令實際上就是從棧頂跳出之前call的下一條指令的地址,然后跳轉過去;
需要注意的是:ebp和esp維護的是當前執行的函數的棧幀空間,而不是整個程序的棧幀空間!
接下來就返回到執行call之后的部分:
這里執行對esp執行add,實際上就是將對應的壓棧的形參進行銷毀;
此時上面壓棧的兩個形參也會被銷毀掉;?
然后將eax的值賦值給[ebp - 20h]這個位置!
那么這個ebp-20是什么呢?
實際上就是我們對應的參數c的值,這里把計算返回的值交到c當中!
講到這里,我們就實現了從計算值然后從函數棧幀返回出來的處理;
main函數的函數棧幀和add的大同小異,所以這里我們就不再過多介紹了!
所以接下來我們就可以回答一些問題了:
問題:為什么局部變量不初始化的時候是隨機值呢?
因為這里的局部變量是我們按要求設置的,例如vsstudio都初始化為cc;
問題:函數是如何進行傳參的?
實際上當我們還沒有調用函數的時候,此時形參就進行從右到左依次進行壓棧處理(臨時拷貝一份);
問題:函數調用的結果怎么返回?
值保存到寄存器當中,例如eax當中;且call會對下一條指令的地址進行壓棧,運行結束后再取出地址;