經過VC6直觀地看一看C語言的棧管理

前言

VC6 雖然是幾乎被淘汰的IDE,可是它在調試時容許咱們直觀地查看寄存器和內存空間中的值和地址,轉換爲彙編語言後每條指令在內存空間的地址的特性,可讓咱們更直觀地看到一些操做。算法

本文但願經過 VC6 更直觀一些地看到C語言在爲局部變量和函數調用分配內存空間的具體細節,從而驗證從書上學到的知識。相關知識主要參考《深刻理解計算機系統》(中文第二版)第三章,其中3.1節和3.7節尤其重要。函數

幀棧結構1

棧幀結構2

總結性文字

代碼:spa

#include<stdio.h>

void functionA(int, double);

int main(){
    
    int i = 1;
    double d = 8.0;
    
    functionA(i,d);

    short int s = 10;

    return 0;
}

void functionA(int i, double d){
    int fa_i = i;
    double fa_d=d;
}

VC6調試視角:
VC6調試視角3d

正文

main過程的局部變量

7:        int i = 1;
00401038   mov         dword ptr [ebp-4],1
8:        double d = 8.0;
0040103F   mov         dword ptr [ebp-0Ch],0
00401046   mov         dword ptr [ebp-8],40200000h

解讀:
首先要明確一下這些彙編指令中的取值規則。
ebp:取出ebp中存儲的值(一個地址)做爲取來用的值。
[ebp]:取出ebp中存儲的值(一個地址)後,取出改值(地址)指向的內存空間中存儲的值做爲用的值。
所以mov dword ptr [ebp-4],1,要求將1存入ebp-4這個地址對應的內存空間。
留意一下此時的寄存器和內存窗口:
執行前
執行完00401038 mov dword ptr [ebp-4],1
執行後
那麼對於指針

8:        double d = 8.0;
0040103F   mov         dword ptr [ebp-0Ch],0
00401046   mov         dword ptr [ebp-8],40200000h

會發生什麼,也就沒必要多說了。調試

爲了更加直觀地查看內存空間的變化,用excel製做了下圖,記該圖的狀態爲狀態一。excel

狀態1

main函數中調用其它函數

10:       functionA(i,d);
0040104D   mov         eax,dword ptr [ebp-8]
00401050   push        eax
00401051   mov         ecx,dword ptr [ebp-0Ch]
00401054   push        ecx
00401055   mov         edx,dword ptr [ebp-4]
00401058   push        edx
00401059   call        @ILT+0(functionA) (00401005)
0040105E   add         esp,0Ch

解讀了前兩行,就能解讀前六行。
那麼前兩行作了什麼呢?把ebp-8對應的內存空間的值取出,並存入寄存器eax中,而後將eax中的值壓入棧。
觀察執行後的寄存器以及內存空間值:

結合變化能夠發現,push操做將數據壓入(向低地址方向)棧中,棧指針指向新的棧頂。
此時咱們已經明白了內存中的行爲,那麼這個被操做的數據有什麼意義呢?也就是說ebp-8對應的內存空間的值是什麼?其實看完整個六行就知道了,ebp-8ebp-0Ch組合起來就正好是main函數幀棧中的局部變量d(double d),而ebp-4則是局部變量i(int i)。這二者都是要被調用函數的傳入的參數。也就是說這一步將被傳入的參數的值複製了一份增加在main的棧幀空間的棧中(也就是圖3-21的參數u構造區域)。
當斷點繼續執行,知道停在00401059 call @ILT+0(functionA) (00401005)時,獲得狀態二:
狀態2code

以後執行call @ILT+0(functionA) (00401005),關於call指令,書本上其實有較爲詳細的描述,我我的總結以下——寄存器eip用來存儲當前指令執行完成後應該執行的指令所對應的地址(由於指令歸根結底也是數據,顯然也須要空間存放)。調用call指令時,將寄存器eip中的值壓入棧中(push操做)後算做保存了函數調用結束了以後要執行的指令的地址,而後將eip寄存器的值改成functionA函數相關指令流的首指令的地址。
結合執行後寄存器和內存空間中的值變化,可能更爲直觀。blog

被調用的函數的自我修養

繼續執行下去,斷點前進,將會進入函數的內部。函數內部在執行int fa_i = i前尚有本身的一些初始化操做:
ip

首先來解析int fa_i=i前的彙編指令

00401090   push        ebp    //將ebp寄存器中的值壓入棧中
00401091   mov         ebp,esp    //將ebp=esp
00401093   sub         esp,4Ch    //讓esp=esp-0x4C
00401096   push        ebx    //將ebx的值壓入棧
00401097   push        esi    //將esi的值壓入棧
00401098   push        edi    //將edi的值壓入棧
00401099   lea         edi,[ebp-4Ch]    //將&(ebp-4Ch)存入寄存器edi。mov a b是讓a=b,而lea a b則是讓a=&b.
0040109C   mov         ecx,13h    //將常數0x13存入寄存器ecx
004010A1   mov         eax,0CCCCCCCCh //將0xCCCCCCCC存入寄存器eax(帶e的寄存器中的e就是extend的意思,這些寄存器均可以存儲32bits的數據)
004010A6   rep stos    dword ptr [edi] //將eax寄存器中的值存入edi寄存器中的值(一個地址)指向的內存空間,而後edi的值加一個eax的長度(即4,4個字節),eax中的值-1。

執行完這麼一套東西后,咱們獲得狀態三:
狀態3

到此,其實對於以後的代碼,即functionA中局部變量的賦值,內存空間會如何分配,咱們不該該能夠推理出來了麼?0x0019FEBC會用來存放fa_i,0x0019FEB8和0x0019FEB4會被用來聯合存放fa_d,即本地局部變量等以幀指針(ebp)爲基準低地址生長,而棧指針被調用者(如本例中的main函數)爲被調用過程準備參數,備份返回地址,備份調用結束後回到本身的幀棧空間的幀指針。

狀態四:
狀態4

被調用的函數結束了,被調用者如何收尾

20:   }
004010BA   pop         edi    //從棧中彈出棧頂的值,存到edi寄存器中
004010BB   pop         esi    //彈出
004010BC   pop         ebx    //彈出
004010BD   mov         esp,ebp    //將esp=ebp
004010BF   pop         ebp    //彈出棧頂的值並存到ebp

這些指令都沒必要多說。棧彈出數據後棧指針指向上一個舊數據的位置,即仍保持棧指針指向棧頂。

將esp=ebp意味着esp和ebp之間的空間沒有了,即functionA的幀棧空間已經被銷燬。
esp回到ebp的位置,請看狀態四的圖,此時彈出的值將會是舊ebp的值,即main函數的幀棧空間的幀指針的值。將其存到ebp後,幀棧指針指針已經回到main函數的幀棧空間內。

004010C0   ret

ret指令和call指令正好相反。當該執行ret指令時,正常狀況下棧指針必然指向「返回地址」的存儲空間。執行ret指令時,彈出棧的值並存入eip中。即將返回地址存入eip,從而使下一條指令將會回到被調用函數結束後該執行的位置繼續執行。

這一段結束後獲得狀態五:
狀態五

調用者調用完後的收尾工做

這樣函數的調用就完全結束了,回到main函數的指令流了。此時後面待執行的有:

00401059   call        @ILT+0(functionA) (00401005)
0040105E   add         esp,0Ch
11:
12:       short int s = 10;
00401061   mov         word ptr [ebp-10h],offset main+45h (00401065)
13:
14:       return 0;
00401067   xor         eax,eax
15:   }

值得注意的是0040105E add esp,0Ch使得esp=esp+0xC=esp+12 而12正好是一個int加一個double的空間所用字節數,所以esp又從新回到最開始的棧指針位置。

到此,咱們就認爲這個函數調用的過程所有結束。幀棧指針所有復位到未調用時的狀態。而爲functionA所使用的空間中的值雖然沒有被重置,但能夠預見這塊空間再度被使用時,舊值要麼被0xCCCCCCCC覆蓋,要麼被有意義的新值覆蓋,等同於已經被銷燬了。

最終狀態狀態六:
狀態六

總結

調用者調用前的工做

調用者的棧被依次壓入被調用者所需的參數(第一個參數最早被壓入)。

返回地址和舊ebp的壓入工做不禁調用者負責。

調用者在壓入參數後(若沒有參數則直接)使用call彙編指令喚醒被調用者開始它的工做。

call彙編指令的工做

將被調用者調用結束後應當執行的指令所在的空間的地址壓入。

被調用者的準備工做

將ebp中的值——即調用者棧幀空間的幀空間起始位置的地址——壓入棧中保存。

將ebp中的值改成本身的棧幀空間的的幀空間的起始地址——使ebp=esp。這裏請注意:esp由於壓入舊ebp值,esp保存的地址值又減少了4(通常來講ebp的長度是四個字節)。

若是須要使用參數,被調用者經過ebp+4+4*i來獲取「調用者調用前的工做」所準備的參數。其中i表明參數在參數表中的位置(第一個參數則i=1)。其中之因此會有一個+4是由於幀指針+4所指向的空間存儲的是返回地址的變量。

使esp=esp-x(esp表明棧空間的基地址)從而擴充了幀空間。其中x是編譯器根據必定算法獲得的被調用者應有的幀空間的大小。算法具體細節此處不作討論。以後將分配出來的幀空間用0xCC填滿(初始化。)

將ebx,esi,edi等由被調用者保存其值的寄存器的值壓入棧中。

被調用者完成一切任務後的收尾工做

將edi,esi,ebx等數據從棧中彈出到它們對應的寄存器中。而後將棧指針收回到幀指針處,意味着被調用者的棧幀空間已被銷燬——儘管實際上那些數據並無被覆蓋(銷燬)。

繼續彈出,此時彈出的是舊的ebp值,即調用者的棧幀空間的幀指針的值。彈出的值被賦給ebp寄存器,意味着調用者幀指針復位。

調用ret彙編指令。

ret彙編指令的工做

繼續要求棧彈出,此時彈出的是以前保存的被調用者調用結束後要執行的指令所存儲的空間的地址。彈出給edi寄存器。彈出後棧指針指在被調用者最後一個參數所在空間。

調用者的收尾工做

經過esp=esp+x復位esp至調用者棧幀空間的棧指針。其中x由被調用者的參數的個數和類型所決定。由於此時esp距離復位的位置中間只隔着它以前爲參數準備的空間了。

相關文章
相關標籤/搜索