C/C++堆棧指引

[轉載]C/C++堆棧指引

轉載:http://www.cnblogs.com/Binhua-Liu/archive/2010/08/24/1803095.htmlhtml

document_thumb_thumb前言

    咱們常常會討論這樣的問題:何時數據存儲在堆棧(Stack)中,何時數據存儲在堆(Heap)中。咱們知道,局部變量是存儲在堆棧中的;debug時,查看堆棧能夠知道函數的調用順序;函數調用時傳遞參數,事實上是把參數壓入堆棧,聽起來,堆棧象一個大雜燴。那麼,堆棧(Stack)究竟是如何工做的呢? 本文將詳解C/C++堆棧的工做機制。閱讀時請注意如下幾點:c++

    1)本文討論的編譯環境是 Visual C/C++,因爲高級語言的堆棧工做機制大體相同,所以對其餘編譯環境或高級語言如C#也有意義。程序員

    2)本文討論的堆棧,是指程序爲每一個線程分配的默認堆棧,用以支持程序的運行,而不是指程序員爲了實現算法而本身定義的堆棧。算法

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

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

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

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

    1) 程序的堆棧是由處理器直接支持的。在intel x86的系統中,堆棧在內存中是從高地址向低地址擴展(這和自定義的堆棧從低地址向高地址擴展不一樣),以下圖所示:this

image

    所以,棧頂地址是不斷減少的,越後入棧的數據,所處的地址也就越低。spa

    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開始討論堆棧是如何工做的

    咱們來討論堆棧的工做機制。堆棧是用來支持函數的調用和執行的,所以,咱們下面將經過一組函數調用的例子來說解,看下面的代碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int  foo1( int  m, int  n)
{
     int  p=m*n;
     return  p;
}
int  foo( int  a, int  b)
{
     int  c=a+1;
     int  d=b+1;
     int  e=foo1(c,d);
     return  e;
}
 
int  main()
{
     int  result=foo(3,4);
     return  0;
}

    這段代碼自己並無實際的意義,咱們只是用它來跟蹤堆棧。下面的章節咱們來跟蹤堆棧的創建,堆棧的使用和堆棧的銷燬。

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,(根據燭秋兄在其餘編譯環境上的測試,也可能使用push命令分配地址,本質上並無差異,特此說明)如圖所示:

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函數的定義以下並將它的代碼作適當的修改:

1
2
3
4
MyStruct foo( int  a, int  b)
{
...
}

         MyStruct定義爲:

1
2
3
4
5
6
struct  MyStruct
{
     int  value1;
     __int64  value2;
     bool  value3;
};

     這時,在調用foo函數時參數的入棧過程會有所不一樣,以下圖所示:

image

圖10

    caller會在壓入最左邊的參數後,再壓入一個指針,咱們姑且叫它ReturnValuePointer,ReturnValuePointer指向caller局部變量區的一塊未命名的地址,這塊地址將用來存儲callee的返回值。函數返回時,callee把返回值拷貝到ReturnValuePointer指向的地址中,而後把ReturnValuePointer的地址賦予EAX寄存器。函數返回後,caller經過EAX寄存器找到ReturnValuePointer,而後經過ReturnValuePointer找到返回值,最後,caller把返回值拷貝到負責接收的局部變量上(若是接收返回值的話)。

    你或許會有這樣的疑問,函數返回後,對應的堆棧幀已經被銷燬,而ReturnValuePointer是在該堆棧幀中,不也應該被銷燬了嗎?對的,堆棧幀是被銷燬了,可是程序不會自動清理其中的值,所以ReturnValuePointer中的值仍是有效的。

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)在函數定義時加上修飾符來指定,如

1
2
3
4
void  __thiscall mymethod();
{
     ...
}

    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); 的反彙編:

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
008A13F0  push        ebp                  //把ebp壓入堆棧
008A13F1  mov         ebp,esp              //ebp指向先前的ebp,到達圖4的狀態
008A13F3  sub         esp,0E4h             //爲局部變量分配0E4字節的空間,到達圖5的狀態
008A13F9  push        ebx                  //壓入EBX
008A13FA  push        esi                  //壓入ESI
008A13FB  push        edi                  //壓入EDI,到達圖7的狀態
008A13FC  lea         edi,[ebp-0E4h]       //如下4行把局部變量區初始化爲每一個字節都等於cch
008A1402  mov         ecx,39h
008A1407  mov         eax,0CCCCCCCCh
008A140C  rep stos    dword ptr es:[edi]
......                                      //省略代碼執行N行
......
008A1436  pop         edi                   //恢復EDI 
008A1437  pop         esi                   //恢復ESI
008A1438  pop         ebx                   //恢復EBX
008A1439  add         esp,0E4h              //回收局部變量地址空間
008A143F  cmp         ebp,esp               //如下3行爲Runtime Checking,檢查ESP和EBP是否一致  
008A1441  call        @ILT+330(__RTC_CheckEsp) (8A114Fh)
008A1446  mov         esp,ebp
008A1448  pop         ebp                   //恢復EBP
008A1449  ret                               //彈出函數返回地址,跳轉到函數返回地址執行                                            //(__cdecl調用約定,Callee未清理參數)
相關文章
相關標籤/搜索