1. 程式人生 > >C/C++堆疊模型 轉載兩篇經典

C/C++堆疊模型 轉載兩篇經典

C/C++堆疊指引

Binhua Liu

document_thumb_thumb前言 via:http://blog.csdn.net/mynote/article/details/5835615

    我們經常會討論這樣的問題:什麼時候資料儲存在飛鴿傳書堆疊(Stack)中,什麼時候資料儲存在堆(Heap)中。我們知道,區域性變數是儲存在堆疊中的;debug時,檢視堆疊可以知道函式的呼叫順序;函式呼叫時傳遞引數,事實上是把引數壓入堆疊,聽起來,堆疊象一個大雜燴。那麼,堆疊(Stack)到底是如何工作的呢? 本文將詳解C/C++堆疊的工作機制。閱讀時請注意以下幾點:

    1)本文討論的語言是 Visual C/C++,由於高階語言的堆疊工作機制大致相同,因此對其他高階語言如C#也有意義。

    2)本文討論的堆疊,是指程式為每個執行緒分配的預設堆疊,用以支援程式的執行,而不是指程式設計師為了實現演算法而自己定義的堆疊。

    3)  本文討論的平臺為intel x86。

    4)本文的主要部分將盡量避免涉及到彙編的知識,在本文最後可選章節,給出前面章節的反編譯程式碼和註釋。

    5)結構化異常處理也是通過堆疊來實現的(當你使用try…catch語句時,使用的就是c++對windows結構化異常處理的擴充套件),但是關於結構化異常處理的主題太複雜了,本文將不會涉及到。

document_thumb_thumb[4]從一些基本的知識和概念開始

    1) 程式的堆疊是由處理器直接支援的。在intel x86的系統中,堆疊在記憶體中是從高地址向低地址擴充套件(這和自定義的堆疊從低地址向高地址擴充套件不同),如下圖所示:

image

    因此,棧頂地址是不斷減小的,越後入棧的資料,所處的地址也就越低。

    2) 在32位系統中,堆疊每個資料單元的大小為4位元組。小於等於4位元組的資料,比如位元組、字、雙字和布林型,在堆疊中都是佔4個位元組的;大於4位元組的資料在堆疊中佔4位元組整數倍的空間。

    3) 和堆疊的操作相關的兩個暫存器是EBP暫存器和ESP暫存器的,本文中,你只需要把EBP和ESP理解成2個指標就可以了。ESP暫存器總是指向堆疊的棧頂,執行PUSH命令向堆疊壓入資料時,ESP減4,然後把資料拷貝到ESP指向的地址;執行POP命令時,首先把ESP指向的資料拷貝到記憶體地址/暫存器中,然後ESP加4。EBP暫存器是用於訪問堆疊中的資料的,它指向堆疊中間的某個位置(具體位置後文會具體講解),函式的引數地址比EBP的值高,而函式的區域性變數地址比EBP的值低,因此引數或區域性變數總是通過EBP加減一定的偏移地址來訪問的,比如,要訪問函式的第一個引數為EBP+8。

    4) 堆疊中到底儲存了什麼資料? 包括了:函式的引數,函式的區域性變數,暫存器的值(用以恢復暫存器),函式的返回地址以及用於結構化異常處理的資料(當函式中有try…catch語句時才有,本文不討論)。這些資料是按照一定的順序組織在一起的,我們稱之為一個堆疊幀(Stack Frame)。一個堆疊幀對應一次函式的呼叫。在函式開始時,對應的堆疊幀已經完整地建立了(所有的區域性變數在函式幀建立時就已經分配好空間了,而不是隨著函式的執行而不斷建立和銷燬的);在函式退出時,整個函式幀將被銷燬。

    5) 在文中,我們把函式的呼叫者稱為Caller(呼叫者),被呼叫的函式稱為Callee(被呼叫者)。之所以引入這個概念,是因為一個函式幀的建立和清理,有些工作是由Caller完成的,有些則是由Callee完成的。

document_thumb_thumb4開始討論堆疊是如何工作的

    我們來討論堆疊的工作機制。堆疊是用來支援函式的呼叫和執行的,因此,我們下面將通過一組函式呼叫的例子來講解,看下面的程式碼:

view source print?
01 intfoo1(intm,intn)
02 {
03 intp=m*n;
04 returnp;
05 }
06 intfoo(inta,intb)
07 {
08 intc=a+1;
09 intd=b+1;
10 inte=foo1(c,d);
11 returne;
12 }
13
14 intmain()
15 {
16 intresult=foo(3,4);
17 return0;
18 }

    這段程式碼本身並沒有實際的意義,我們只是用它來跟蹤堆疊。下面的章節我們來跟蹤堆疊的建立,堆疊的使用和堆疊的銷燬。

document_thumb_thumb4堆疊的建立

    我們從main函式執行的第一行程式碼,即int result=foo(3,4); 開始跟蹤。這時main以及之前的函式對應的堆疊幀已經存在在堆疊中了,如下圖所示:

image

圖1

    引數入棧 

   當foo函式被呼叫,首先,caller(此時caller為main函式)把foo函式的兩個引數:a=3,b=4壓入堆疊。引數入棧的順序是由函式的呼叫約定(Calling Convention)決定的,我們將在後面一個專門的章節來講解呼叫約定。一般來說,引數都是從左往右入棧的,因此,b=4先壓入堆疊,a=3後壓入,如圖:

image

圖2

   返回地址入棧

    我們知道,當函式結束時,程式碼要返回到上一層函式繼續執行,那麼,函式如何知道該返回到哪個函式的什麼位置執行呢?函式被呼叫時,會自動把下一條指令的地址壓入堆疊,函式結束時,從堆疊讀取這個地址,就可以跳轉到該指令執行了。如果當前"call foo"指令的地址是0x00171482,由於call指令佔5個位元組,那麼下一個指令的地址為0x00171487,0x00171487將被壓入堆疊:

image

圖3

    程式碼跳轉到被呼叫函式執行

    返回地址入棧後,程式碼跳轉到被呼叫函式foo中執行。到目前為止,堆疊幀的前一部分,是由caller構建的;而在此之後,堆疊幀的其他部分是由callee來構建。

   EBP指標入棧

    在foo函式中,首先將EBP暫存器的值壓入堆疊。因為此時EBP暫存器的值還是用於main函式的,用來訪問main函式的引數和區域性變數的,因此需要將它暫存在堆疊中,在foo函式退出時恢復。同時,給EBP賦於新值。

    1)將EBP壓入堆疊

    2)把ESP的值賦給EBP

image

圖4

    這樣一來,我們很容易發現當前EBP暫存器指向的堆疊地址就是EBP先前值的地址,你還會發現發現,EBP+4的地址就是函式返回值的地址,EBP+8就是函式的第一個引數的地址(第一個引數地址並不一定是EBP+8,後文中將講到)。因此,通過EBP很容易查詢函式是被誰呼叫的或者訪問函式的引數(或區域性變數)。

    為區域性變數分配地址

    接著,foo函式將為區域性變數分配地址。程式並不是將區域性變數一個個壓入堆疊的,而是將ESP減去某個值,直接為所有的區域性變數分配空間,比如在foo函式中有ESP=ESP-0x00E4,如圖所示:

image

圖5

     奇怪的是,在debug模式下,編譯器為區域性變數分配的空間遠遠大於實際所需,而且區域性變數之間的地址不是連續的(據我觀察,總是間隔8個位元組)如下圖所示:

  image

圖6

    我還不知道編譯器為什麼這麼設計,或許是為了在堆疊中插入除錯資料,不過這無礙我們今天的討論。

通用暫存器入棧

     最後,將函式中使用到的通用暫存器入棧,暫存起來,以便函式結束時恢復。在foo函式中用到的通用暫存器是EBX,ESI,EDI,將它們壓入堆疊,如圖所示:

image

圖7

   至此,一個完整的堆疊幀建立起來了。

document_thumb_thumb4堆疊特性分析

   上一節中,一個完整的堆疊幀已經建立起來,現在函式可以開始正式執行程式碼了。本節我們對堆疊的特性進行分析,有助於瞭解函式與堆疊幀的依賴關係。

   1)一個完整的堆疊幀建立起來後,在函式執行的整個生命週期中,它的結構和大小都是保持不變的;不論函式在什麼時候被誰呼叫,它對應的堆疊幀的結構也是一定的。

   2)在A函式中呼叫B函式,對應的,是在A函式對應的堆疊幀“下方”建立B函式的堆疊幀。例如在foo函式中呼叫foo1函式,foo1函式的堆疊幀將在foo函式的堆疊幀下方建立。如下圖所示:

image圖8 

  3)函式用EBP暫存器來訪問引數和區域性變數。我們知道,引數的地址總是比EBP的值高,而區域性變數的地址總是比EBP的值低。而在特定的堆疊幀中,每個引數或區域性變數相對於EBP的地址偏移總是固定的。因此函式對引數和區域性變數的的訪問是通過EBP加上某個偏移量來訪問的。比如,在foo函式中,EBP+8為第一個引數的地址,EBP-8為第一個區域性變數的地址。

   4)如果仔細思考,我們很容易發現EBP暫存器還有一個非常重要的特性,請看下圖中:

image

圖9

   我們發現,EBP暫存器總是指向先前的EBP,而先前的EBP又指向先前的先前的EBP,這樣就在堆疊中形成了一個連結串列!這個特性有什麼用呢,我們知道EBP+4地址儲存了函式的返回地址,通過該地址我們可以知道當前函式的上一級函式(通過在符號檔案中查詢距該函式返回地址最近的函式地址,該函式即當前函式的上一級函式),以此類推,我們就可以知道當前執行緒整個的函式呼叫順序。事實上,偵錯程式正是這麼做的,這也就是為什麼除錯時我們檢視函式呼叫順序時總是說“檢視堆疊”了。

document_thumb_thumb4返回值是如何傳遞的

    堆疊幀建立起後,函式的程式碼真正地開始執行,它會操作堆疊中的引數,操作堆疊中的區域性變數,甚至在堆(Heap)上建立物件,balabala….,終於函式完成了它的工作,有些函式需要將結果返回給它的上一層函式,這是怎麼做的呢?

    首先,caller和callee在這個問題上要有一個“約定”,由於caller是不知道callee內部是如何執行的,因此caller需要從callee的函式宣告就可以知道應該從什麼地方取得返回值。同樣的,callee不能隨便把返回值放在某個暫存器或者記憶體中而指望Caller能夠正確地獲得的,它應該根據函式的宣告,按照“約定”把返回值放在正確的”地方“。下面我們來講解這個“約定”: 
    1)首先,如果返回值等於4位元組,函式將把返回值賦予EAX暫存器,通過EAX暫存器返回。例如返回值是位元組、字、雙字、布林型、指標等型別,都通過EAX暫存器返回。

    2)如果返回值等於8位元組,函式將把返回值賦予EAX和EDX暫存器,通過EAX和EDX暫存器返回,EDX儲存高位4位元組,EAX儲存低位4位元組。例如返回值型別為__int64或者8位元組的結構體通過EAX和EDX返回。

    3)  如果返回值為double或float型,函式將把返回值賦予浮點暫存器,通過浮點暫存器返回。

    4)如果返回值是一個大於8位元組的資料,將如何傳遞返回值呢?這是一個比較麻煩的問題,我們將詳細講解:

        我們修改foo函式的定義如下並將它的程式碼做適當的修改:

view source print?
1 MyStruct foo(inta,intb)
2 {
3 ...
4 }

         MyStruct定義為:

view source print?
1 structMyStruct
2 {
3 intvalue1;
4 __int64value2;
5 boolvalue3;
6 };

     這時,在呼叫foo函式時引數的入棧過程會有所不同,如下圖所示:

image

圖10

    caller會在壓入最左邊的引數後,再壓入一個指標,我們姑且叫它ReturnValuePointer,ReturnValuePointer指向當前ESP值下方很遠的一個地址,這個地址將用來儲存函式的返回值。函式返回時,把返回值拷貝到ReturnValuePointer指向的地址中,然後把ReturnValuePointer的地址賦予EAX暫存器。函式返回後,caller通過EAX暫存器找到ReturnValuePointer,然後通過ReturnValuePointer找到返回值。

    你或許會有這樣的疑問,函式返回後,對應的堆疊幀已經被銷燬,而ReturnValuePointer是在該堆疊幀中,不也應該被銷燬了嗎?對的,堆疊幀是被銷燬了,但是程式不會自動清理其中的值,因此ReturnValuePointer中的值還是有效的。

    但是,這裡還有一個問題我沒有答案。ReturnValuePointer指向的地址是由caller決定的,而才caller並不知道callee對應的堆疊幀會有多大,如果callee對應的堆疊幀很大那麼就可能會和返回值的地址重合。我還不知道VS編譯器通過什麼策略來避免這個問題。

document_thumb_thumb4堆疊幀的銷燬

    當函式將返回值賦予某些暫存器或者拷貝到堆疊的某個地方後,函式開始清理堆疊幀,準備退出。堆疊幀的清理順序和堆疊建立的順序剛好相反:(堆疊幀的銷燬過程就不一一畫圖說明了)

   1)如果有物件儲存在堆疊幀中,物件的解構函式會被函式呼叫。

    2)從堆疊中彈出先前的通用暫存器的值,恢復通用暫存器。

    3)ESP加上某個值,回收區域性變數的地址空間(加上的值和堆疊幀建立時分配給區域性變數的地址大小相同)。

    4)從堆疊中彈出先前的EBP暫存器的值,恢復EBP暫存器。

    5)從堆疊中彈出函式的返回地址,準備跳轉到函式的返回地址處繼續執行。

    6)ESP加上某個值,回收所有的引數地址。

    前面1-5條都是由callee完成的。而第6條,引數地址的回收,是由caller或者callee完成是由函式使用的呼叫約定(calling convention )來決定的。下面的小節我們就來講解函式的呼叫約定。

document_thumb_thumb4函式的呼叫約定(calling convention)

    函式的呼叫約定(calling convention)指的是進入函式時,函式的引數是以什麼順序壓入堆疊的,函式退出時,又是由誰(Caller還是Callee)來清理堆疊中的引數。有2個辦法可以指定函式使用的呼叫約定:

    1)在函式定義時加上修飾符來指定,如

view source print?
1 void__thiscall mymethod();
2 {
3 ...
4 }

    2)在VS工程設定中為工程中定義的所有的函式指定預設的呼叫約定:在工程的主選單開啟Project|Project Property|Configuration Properties|C/C++|Advanced|Calling Convention,選擇呼叫約定(注意:這種做法對類成員函式無效)。

    常用的呼叫約定有以下3種:

    1)__cdecl。這是VC編譯器預設的呼叫約定。其規則是:引數從右向左壓入堆疊,函式退出時由caller清理堆疊中的引數。這種呼叫約定的特點是支援可變數量的引數,比如printf方法。由於callee不知道caller到底將多少引數壓入堆疊,因此callee就沒有辦法自己清理堆疊,所以只有函式退出之後,由caller清理堆疊,因為caller總是知道自己傳入了多少引數。

    2)__stdcall。所有的Windows API都使用__stdcall。其規則是:引數從右向左壓入堆疊,函式退出時由callee自己清理堆疊中的引數。由於引數是由callee自己清理的,所以__stdcall不支援可變數量的引數。

    3) __thiscall。類成員函式預設使用的呼叫約定。其規則是:引數從右向左壓入堆疊,x86構架下this指標通過ECX暫存器傳遞,函式退出時由callee清理堆疊中的引數,x86構架下this指標通過ECX暫存器傳遞。同樣不支援可變數量的引數。如果顯式地把類成員函式宣告為使用__cdecl或者__stdcall,那麼,將採用__cdecl或者__stdcall的規則來壓棧和出棧,而this指標將作為函式的第一個引數最後壓入堆疊,而不是使用ECX暫存器來傳遞了。

document_thumb_thumb4反編譯程式碼的跟蹤(不熟悉彙編可跳過)

    以下程式碼為和foo函式對應的堆疊幀建立相關的程式碼的反編譯程式碼,我將逐行給出註釋,可對照前文中對堆疊的描述:

    main函式中 int result=foo(3,4); 的反彙編:

view source print?
1 008A147E  push        4                    //b=4 壓入堆疊   
2 008A1480  push        3                    //a=3 壓入堆疊,到達圖2的狀態
3 008A1482  call        foo (8A10F5h)        //函式返回值入棧,轉入foo中執行,到達圖3的狀態 
4 008A1487  add         esp,8                //foo返回,由於採用__cdecl,由Caller清理引數
5 008A148A  mov         dword ptr [result],eax//返回值儲存在EAX中,把EAX賦予result變數

    下面是foo函式程式碼正式執行前和執行後的反彙編程式碼

view source print?
01 008A13F0  push        ebp                 //把ebp壓入堆疊 
02 008A13F1  mov         ebp,esp             //ebp指向先前的ebp,到達圖4的狀態
03 008A13F3  sub         esp,0E4h            //為區域性變數分配0E4位元組的空間,到達圖5的狀態
04 008A13F9  push        ebx                 //壓入EBX
05 008A13FA  push        esi                 //壓入ESI
06 008A13FB  push        edi                 //壓入EDI,到達圖7的狀態
07 008A13FC  lea         edi,[ebp-0E4h]      //以下4行把區域性變數區初始化為每個位元組都等於cch
08 008A1402  mov         ecx,39h 
09 008A1407  mov         eax,0CCCCCCCCh 
10 008A140C  rep stos    dword ptr es:[edi] 
11 ......                                     //省略程式碼執行N行
12 ......
13 008A1436  pop         edi                  //恢復EDI  
14 008A1437  pop         esi                  //恢復ESI
15 008A1438  pop         ebx                  //恢復EBX
16 008A1439  add         esp,0E4h             //回收區域性變數地址空間
17 008A143F  cmp         ebp,esp              //以下3行為Runtime Checking,檢查ESP和EBP是否一致   
18 008A1441  call        @ILT+330(__RTC_CheckEsp) (8A114Fh) 
19 008A1446  mov         esp,ebp 
20 008A1448  pop         ebp                  //恢復EBP 
21 008A1449  ret                              //彈出函式返回地址,跳轉到函式返回地址執行                                            //(__cdecl呼叫約定,Callee未清理引數)

document_thumb_thumb4[1]參考

Debug Tutorial Part 2: The Stack

Intel組合語言程式設計(第四版) 第8章

http://msdn.microsoft.com/zh-cn/library/46t77ak2(VS.80).aspx

document_thumb_thumb4[1]宣告

本文為Binhua Liu原創作品。本文允許複製,修改,傳遞,但不允許用於商業用途。轉載請註明出處。本文發表於2010年8月24日。

_____________________________________________________________________________________________________________________________________

另一篇寫的也很棒的文章,轉載 出處不詳

一、預備知識—程式的記憶體分配

一個由c/C++編譯的程式佔用的記憶體分為以下幾個部分
1、棧區(stack)— 由編譯器自動分配釋放 ,存放函式的引數值,區域性變數的值等。其操作方式類似於資料結構中的棧。
2、堆區(heap) — 一般由程式設計師分配釋放, 若程式設計師不釋放,程式結束時可能由OS回收 。注意它與資料結構中的堆是兩回事,分配方式倒是類似於連結串列,呵呵。
3、全域性區(靜態區)(static)—,全域性變數和靜態變數的儲存是放在一塊的,初始化的全域性變數和靜態變數在一塊區域, 未初始化的全域性變數和未初始化的靜態變數在相鄰的另一塊區域。 - 程式結束後有系統釋放
4、文字常量區—常量字串就是放在這裡的。 程式結束後由系統釋放
5、程式程式碼區—存放函式體的二進位制程式碼。

二、例子程式

這是一個前輩寫的,非常詳細

//main.cpp
int a = 0; //全域性初始化區
int a = 0; //全域性初始化區
char *p1; //全域性未初始化區
main() {
    int b; //棧
    char s[] = "abc"; //棧
    char *p2; //棧
    char *p3 = "123456"; //123456\0在常量區,p3在棧上。
    static int c = 0; //全域性(靜態)初始化區
    p1 = (char *)malloc(10);
    p2 = (char *)malloc(20);
    //分配得來得10和20位元組的區域就在堆區。
    strcpy(p1, "123456"); //123456\0放在常量區,編譯器可能會將它與p3所指向的"123456"優化成一個地方。
}

二、堆和棧的理論知識

2.1申請方式

stack:
由系統自動分配。 例如,宣告在函式中一個區域性變數 int b; 系統自動在棧中為b開闢空間
heap:
需要程式設計師自己申請,並指明大小,在c中malloc函式
p1 = (char *)malloc(10);
在C++中用new運算子
p2 = (char *)malloc(10);
但是注意p1、p2本身是在棧中的。

2.2 申請後系統的響應

棧:只要棧的剩餘空間大於所申請空間,系統將為程式提供記憶體,否則將報異常提示棧溢位。
堆:首先應該知道作業系統有一個記錄空閒記憶體地址的連結串列,當系統收到程式的申請時,
會遍歷該連結串列,尋找第一個空間大於所申請空間的堆結點,然後將該結點從空閒結點連結串列中刪除,並將該結點的空間分配給程式,另外,對於大多數系統,會在這塊記憶體空間中的首地址處記錄本次分配的大小,這樣,程式碼中的delete語句才能正確的釋放本記憶體空間。另外,由於找到的堆結點的大小不一定正好等於申請的大小,系統會自動的將多餘的那部分重新放入空閒連結串列中。

2.3 申請大小的限制

棧:在Windows下,棧是向低地址擴充套件的資料結構,是一塊連續的記憶體的區域。這句話的意思是棧頂的地址和棧的最大容量是系統預先規定好的,在WINDOWS下,棧的大小是2M(也有的說是1M,總之是一個編譯時就確定的常數),如果申請的空間超過棧的剩餘空間時,將提示overflow。因此,能從棧獲得的空間較小。
堆:堆是向高地址擴充套件的資料結構,是不連續的記憶體區域。這是由於系統是用連結串列來儲存的空閒記憶體地址的,自然是不連續的,而連結串列的遍歷方向是由低地址向高地址。堆的大小受限於計算機系統中有效的虛擬記憶體。由此可見,堆獲得的空間比較靈活,也比較大。

2.4 申請效率的比較:

棧由系統自動分配,速度較快。但程式設計師是無法控制的。
堆是由new分配的記憶體,一般速度比較慢,而且容易產生記憶體碎片,不過用起來最方便.
另外,在WINDOWS下,最好的方式是用VirtualAlloc分配記憶體,他不是在堆,也不是在棧是直接在程序的地址空間中保留一快記憶體,雖然用起來最不方便。但是速度快,也最靈活。

2.5 堆和棧中的儲存內容

棧: 在函式呼叫時,第一個進棧的是主函式中後的下一條指令(函式呼叫語句的下一條可執行語句)的地址,然後是函式的各個引數,在大多數的C編譯器中,引數是由右往左入棧的,然後是函式中的區域性變數。注意靜態變數是不入棧的。
當本次函式呼叫結束後,區域性變數先出棧,然後是引數,最後棧頂指標指向最開始存的地址,也就是主函式中的下一條指令,程式由該點繼續執行。
堆:一般是在堆的頭部用一個位元組存放堆的大小。堆中的具體內容有程式設計師安排。

2.6 存取效率的比較

char s1[] = "aaaaaaaaaaaaaaa";
char *s2 = "bbbbbbbbbbbbbbbbb";
aaaaaaaaaaa是在執行時刻賦值的;
而bbbbbbbbbbb是在編譯時就確定的;
但是,在以後的存取中,在棧上的陣列比指標所指向的字串(例如堆)快。
比如:

#include
void main() {
    char a = 1;
    char c[] = "1234567890";
    char *p ="1234567890";
    a = c[1];
    a = p[1];
    return;
}
  • 1
  • 2
  • 3
  • 4
  • 5

對應的彙編程式碼

10: a = c[1];
00401067 8A 4D F1 mov cl,byte ptr [ebp-0Fh]
0040106A 88 4D FC mov byte ptr [ebp-4],cl
11: a = p[1];
0040106D 8B 55 EC mov edx,dword ptr [ebp-14h]
00401070 8A 42 01 mov al,byte ptr [edx+1]
00401073 88 45 FC mov byte ptr [ebp-4],al
  • 1
  • 2
  • 3
  • 4

第一種在讀取時直接就把字串中的元素讀到暫存器cl中,而第二種則要先把指標值讀到edx中,在根據edx讀取字元,顯然慢了。

2.7小結:

堆和棧的區別可以用如下的比喻來看出:
使用棧就象我們去飯館裡吃飯,只管點菜(發出申請)、付錢、和吃(使用),吃飽了就走,不必理會切菜、洗菜等準備工作和洗碗、刷鍋等掃尾工作,他的好處是快捷,但是自由度小。
使用堆就象是自己動手做喜歡吃的菜餚,比較麻煩,但是比較符合自己的口味,而且自由度大。

三 、windows程序中的記憶體結構

在閱讀本文之前,如果你連堆疊是什麼多不知道的話,請先閱讀文章後面的基礎知識。

接觸過程式設計的人都知道,高階語言都能通過變數名來訪問記憶體中的資料。那麼這些變數在記憶體中是如何存放的呢?程式又是如何使用這些變數的呢?下面就會對此進行深入的討論。下文中的C語言程式碼如沒有特別宣告,預設都使用VC編譯的release版。

首先,來了解一下 C 語言的變數是如何在記憶體分部的。C 語言有全域性變數(Global)、本地變數(Local),靜態變數(Static)、暫存器變數(Regeister)。每種變數都有不同的分配方式。先來看下面這段程式碼:

#include <stdio.h>
int g1=0, g2=0, g3=0;
int main()
{
    static int s1=0, s2=0, s3=0;
    int v1=0, v2=0, v3=0;
    //打印出各個變數的記憶體地址    
    printf("0x%08x\n",&v1); //列印各本地變數的記憶體地址
    printf("0x%08x\n",&v2);
    printf("0x%08x\n\n",&v3);
    printf("0x%08x\n",&g1); //列印各全域性變數的記憶體地址
    printf("0x%08x\n",&g2);
    printf("0x%08x\n\n",&g3);
    printf("0x%08x\n",&s1); //列印各靜態變數的記憶體地址
    printf("0x%08x\n",&s2);
    printf("0x%08x\n\n",&s3);
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

編譯後的執行結果是:

0x0012ff78
0x0012ff7c
0x0012ff80

0x004068d0
0x004068d4
0x004068d8

0x004068dc
0x004068e0
0x004068e4
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

輸出的結果就是變數的記憶體地址。其中v1,v2,v3是本地變數,g1,g2,g3是全域性變數,s1,s2,s3是靜態變數。你可以看到這些變數在記憶體是連續分佈的,但是本地變數和全域性變數分配的記憶體地址差了十萬八千里,而全域性變數和靜態變數分配的記憶體是連續的。這是因為本地變數和全域性/靜態變數是分配在不同型別的記憶體區域中的結果。對於一個程序的記憶體空間而言,可以在邏輯上分成3個部份:程式碼區,靜態資料區和動態資料區。動態資料區一般就是“堆疊”。“棧(stack)”和“堆(heap)”是兩種不同的動態資料區,棧是一種線性結構,堆是一種鏈式結構。程序的每個執行緒都有私有的“棧”,所以每個執行緒雖然程式碼一樣,但本地變數的資料都是互不干擾。一個堆疊可以通過“基地址”和“棧頂”地址來描述。全域性變數和靜態變數分配在靜態資料區,本地變數分配在動態資料區,即堆疊中。程式通過堆疊的基地址和偏移量來訪問本地變數。

├———————┤低端記憶體區域
│ …… │
├———————┤
│ 動態資料區 │
├———————┤
│ …… │
├———————┤
│ 程式碼區 │
├———————┤
│ 靜態資料區 │
├———————┤
│ …… │
├———————┤高階記憶體區域
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

堆疊是一個先進後出的資料結構,棧頂地址總是小於等於棧的基地址。我們可以先了解一下函式呼叫的過程,以便對堆疊在程式中的作用有更深入的瞭解。不同的語言有不同的函式呼叫規定,這些因素有引數的壓入規則和堆疊的平衡。windows API的呼叫規則和ANSI C的函式呼叫規則是不一樣的,前者由被調函式調整堆疊,後者由呼叫者調整堆疊。兩者通過“__stdcall”和“__cdecl”字首區分。先看下面這段程式碼:

#include <stdio.h>
void __stdcall func(int param1,int param2,int param3)
{
    int var1=param1;
    int var2=param2;
    int var3=param3;
    printf("0x%08x\n",param1); //打印出各個變數的記憶體地址
    printf("0x%08x\n",param2);
    printf("0x%08x\n\n",param3);
    printf("0x%08x\n",&var1);
    printf("0x%08x\n",&var2);
    printf("0x%08x\n\n",&var3);
    return;
}

int main() {
    func(1,2,3);
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 12
  • 13

編譯後的執行結果是:

0x0012ff78
0x0012ff7c
0x0012ff80

0x0012ff68
0x0012ff6c
0x0012ff70
  • 1
  • 2
  • 3
  • 4
├———————┤<—函式執行時的棧頂(ESP)、低端記憶體區域
│ …… │
├———————┤
│ var 1 │
├———————┤
│ var 2 │
├———————┤
│ var 3 │
├———————┤
│ RET │
├———————┤<—“__cdecl”函式返回後的棧頂(ESP)
│ parameter 1 │
├———————┤
│ parameter 2 │
├———————┤
│ parameter 3 │
├———————┤<—“__stdcall”函式返回後的棧頂(ESP)
│ …… │
├———————┤<—棧底(基地址 EBP)、高階記憶體區域

上圖就是函式呼叫過程中堆疊的樣子了。首先,三個引數以從右到左的次序壓入堆疊,先壓“param3”,再壓“param2”,最後壓入“param1”;然後壓入函式的返回地址(RET),接著跳轉到函式地址接著執行(這裡要補充一點,介紹UNIX下的緩衝溢位原理的文章中都提到在壓入RET後,繼續壓入當前EBP,然後用當前ESP代替EBP。然而,有一篇介紹windows下函式呼叫的文章中說,在windows下的函式呼叫也有這一步驟,但根據我的實際除錯,並未發現這一步,這還可以從param3和var1之間只有4位元組的間隙這點看出來);第三步,將棧頂(ESP)減去一個數,為本地變數分配記憶體空間,上例中是減去12位元組(ESP=ESP-3*4,每個int變數佔用4個位元組);接著就初始化本地變數的記憶體空間。由於“__stdcall”呼叫由被調函式調整堆疊,所以在函式返回前要恢復堆疊,先回收本地變數佔用的記憶體(ESP=ESP+3*4),然後取出返回地址,填入EIP暫存器,回收先前壓入引數佔用的記憶體(ESP=ESP+3*4),繼續執行呼叫者的程式碼。參見下列彙編程式碼:

;--------------func 函式的彙編程式碼-------------------

:00401000 83EC0C sub esp, 0000000C //建立本地變數的記憶體空間
:00401003 8B442410 mov eax, dword ptr [esp+10]
:00401007 8B4C2414 mov ecx, dword ptr [esp+14]
:0040100B 8B542418 mov edx, dword ptr [esp+18]
:0040100F 89442400 mov dword ptr [esp], eax
:00401013 8D442410 lea eax, dword ptr [esp+10]
:00401017 894C2404 mov dword ptr [esp+04], ecx

……………………(省略若干程式碼)

:00401075 83C43C add esp, 0000003C ;恢復堆疊,回收本地變數的記憶體空間
:00401078 C3 ret 000C ;函式返回,恢復引數佔用的記憶體空間
;如果是“__cdecl”的話,這裡是“ret”,堆疊將由呼叫者恢復

;-------------------函式結束-------------------------

;--------------主程式呼叫func函式的程式碼--------------

:00401080 6A03 push 00000003 //壓入引數param3
:00401082 6A02 push 00000002 //壓入引數param2
:00401084 6A01 push 00000001 //壓入引數param1
:00401086 E875FFFFFF call 00401000 //呼叫func函式
;如果是“__cdecl”的話,將在這裡恢復堆疊,“add esp, 0000000C”
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

聰明的讀者看到這裡,差不