C/C++堆棧指引
前言
我們經常會討論這樣的問題:什麼時候數據存儲在堆棧(Stack)中,什麼時候數據存儲在堆(Heap)中。我們知道,局部變量是存儲在堆棧中的;debug時,查看堆棧可以知道函數的調用順序;函數調用時傳遞參數,事實上是把參數壓入堆棧,聽起來,堆棧象一個大雜燴。那麼,堆棧(Stack)到底是如何工作的呢? 本文將詳解C/C++堆棧的工作機制。閱讀時請注意以下幾點:
1)本文討論的編譯環境是 Visual C/C++,由於高級語言的堆棧工作機制大致相同,因此對其他編譯環境或高級語言如C#也有意義。
2)本文討論的堆棧,是指程序爲每個線程分配的默認堆棧,用以支持程序的運行,而不是指程序員爲了實現算法而自己定義的堆棧。
3) 本文討論的平臺爲intel x86。
4)本文的主要部分將盡量避免涉及到彙編的知識,在本文最後可選章節,給出前面章節的反編譯代碼和註釋。
5)結構化異常處理也是通過堆棧來實現的(當你使用try…catch語句時,使用的就是c++對windows結構化異常處理的擴展),但是關於結構化異常處理的主題太複雜了,本文將不會涉及到。
從一些基本的知識和概念開始
1) 程序的堆棧是由處理器直接支持的。在intel x86的系統中,堆棧在內存中是從高地址向低地址擴展(這和自定義的堆棧從低地址向高地址擴展不同),如下圖所示:
因此,棧頂地址是不斷減小的,越後入棧的數據,所處的地址也就越低。
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完成的。
開始討論堆棧是如何工作的
我們來討論堆棧的工作機制。堆棧是用來支持函數的調用和執行的,因此,我們下面將通過一組函數調用的例子來講解,看下面的代碼:
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; } |
這段代碼本身並沒有實際的意義,我們只是用它來跟蹤堆棧。下面的章節我們來跟蹤堆棧的建立,堆棧的使用和堆棧的銷燬。
堆棧的建立
我們從main函數執行的第一行代碼,即int result=foo(3,4); 開始跟蹤。這時main以及之前的函數對應的堆棧幀已經存在在堆棧中了,如下圖所示:
圖1
參數入棧
當foo函數被調用,首先,caller(此時caller爲main函數)把foo函數的兩個參數:a=3,b=4壓入堆棧。參數入棧的順序是由函數的調用約定(Calling Convention)決定的,我們將在後面一個專門的章節來講解調用約定。一般來說,參數都是從右往左入棧的,因此,b=4先壓入堆棧,a=3後壓入,如圖:
圖2
返回地址入棧
我們知道,當函數結束時,代碼要返回到上一層函數繼續執行,那麼,函數如何知道該返回到哪個函數的什麼位置執行呢?函數被調用時,會自動把下一條指令的地址壓入堆棧,函數結束時,從堆棧讀取這個地址,就可以跳轉到該指令執行了。如果當前"call foo"指令的地址是0x00171482,由於call指令佔5個字節,那麼下一個指令的地址爲0x00171487,0x00171487將被壓入堆棧:
圖3
代碼跳轉到被調用函數執行
返回地址入棧後,代碼跳轉到被調用函數foo中執行。到目前爲止,堆棧幀的前一部分,是由caller構建的;而在此之後,堆棧幀的其他部分是由callee來構建。
EBP指針入棧
在foo函數中,首先將EBP寄存器的值壓入堆棧。因爲此時EBP寄存器的值還是用於main函數的,用來訪問main函數的參數和局部變量的,因此需要將它暫存在堆棧中,在foo函數退出時恢復。同時,給EBP賦於新值。
1)將EBP壓入堆棧
2)把ESP的值賦給EBP
圖4
這樣一來,我們很容易發現當前EBP寄存器指向的堆棧地址就是EBP先前值的地址,你還會發現發現,EBP+4的地址就是函數返回值的地址,EBP+8就是函數的第一個參數的地址(第一個參數地址並不一定是EBP+8,後文中將講到)。因此,通過EBP很容易查找函數是被誰調用的或者訪問函數的參數(或局部變量)。
爲局部變量分配地址
接着,foo函數將爲局部變量分配地址。程序並不是將局部變量一個個壓入堆棧的,而是將ESP減去某個值,直接爲所有的局部變量分配空間,比如在foo函數中有ESP=ESP-0x00E4,(根據燭秋兄在其他編譯環境上的測試,也可能使用push命令分配地址,本質上並沒有差別,特此說明)如圖所示:
圖5
奇怪的是,在debug模式下,編譯器爲局部變量分配的空間遠遠大於實際所需,而且局部變量之間的地址不是連續的(據我觀察,總是間隔8個字節)如下圖所示:
圖6
我還不知道編譯器爲什麼這麼設計,或許是爲了在堆棧中插入調試數據,不過這無礙我們今天的討論。
通用寄存器入棧
最後,將函數中使用到的通用寄存器入棧,暫存起來,以便函數結束時恢復。在foo函數中用到的通用寄存器是EBX,ESI,EDI,將它們壓入堆棧,如圖所示:
圖7
至此,一個完整的堆棧幀建立起來了。
堆棧特性分析
上一節中,一個完整的堆棧幀已經建立起來,現在函數可以開始正式執行代碼了。本節我們對堆棧的特性進行分析,有助於瞭解函數與堆棧幀的依賴關係。
1)一個完整的堆棧幀建立起來後,在函數執行的整個生命週期中,它的結構和大小都是保持不變的;不論函數在什麼時候被誰調用,它對應的堆棧幀的結構也是一定的。
2)在A函數中調用B函數,對應的,是在A函數對應的堆棧幀“下方”建立B函數的堆棧幀。例如在foo函數中調用foo1函數,foo1函數的堆棧幀將在foo函數的堆棧幀下方建立。如下圖所示:
圖8
3)函數用EBP寄存器來訪問參數和局部變量。我們知道,參數的地址總是比EBP的值高,而局部變量的地址總是比EBP的值低。而在特定的堆棧幀中,每個參數或局部變量相對於EBP的地址偏移總是固定的。因此函數對參數和局部變量的的訪問是通過EBP加上某個偏移量來訪問的。比如,在foo函數中,EBP+8爲第一個參數的地址,EBP-8爲第一個局部變量的地址。
4)如果仔細思考,我們很容易發現EBP寄存器還有一個非常重要的特性,請看下圖中:
圖9
我們發現,EBP寄存器總是指向先前的EBP,而先前的EBP又指向先前的先前的EBP,這樣就在堆棧中形成了一個鏈表!這個特性有什麼用呢,我們知道EBP+4地址存儲了函數的返回地址,通過該地址我們可以知道當前函數的上一級函數(通過在符號文件中查找距該函數返回地址最近的函數地址,該函數即當前函數的上一級函數),以此類推,我們就可以知道當前線程整個的函數調用順序。事實上,調試器正是這麼做的,這也就是爲什麼調試時我們查看函數調用順序時總是說“查看堆棧”了。
返回值是如何傳遞的
堆棧幀建立起後,函數的代碼真正地開始執行,它會操作堆棧中的參數,操作堆棧中的局部變量,甚至在堆(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) { ... } |
1
2
3
4
5
6
|
struct MyStruct { int value1; __int64 value2; bool value3; }; |
這時,在調用foo函數時參數的入棧過程會有所不同,如下圖所示:
圖10
caller會在壓入最左邊的參數後,再壓入一個指針,我們姑且叫它ReturnValuePointer,ReturnValuePointer指向caller局部變量區的一塊未命名的地址,這塊地址將用來存儲callee的返回值。函數返回時,callee把返回值拷貝到ReturnValuePointer指向的地址中,然後把ReturnValuePointer的地址賦予EAX寄存器。函數返回後,caller通過EAX寄存器找到ReturnValuePointer,然後通過ReturnValuePointer找到返回值,最後,caller把返回值拷貝到負責接收的局部變量上(如果接收返回值的話)。
你或許會有這樣的疑問,函數返回後,對應的堆棧幀已經被銷燬,而ReturnValuePointer是在該堆棧幀中,不也應該被銷燬了嗎?對的,堆棧幀是被銷燬了,但是程序不會自動清理其中的值,因此ReturnValuePointer中的值還是有效的。
堆棧幀的銷燬
當函數將返回值賦予某些寄存器或者拷貝到堆棧的某個地方後,函數開始清理堆棧幀,準備退出。堆棧幀的清理順序和堆棧建立的順序剛好相反:(堆棧幀的銷燬過程就不一一畫圖說明了)
1)如果有對象存儲在堆棧幀中,對象的析構函數會被函數調用。
2)從堆棧中彈出先前的通用寄存器的值,恢復通用寄存器。
3)ESP加上某個值,回收局部變量的地址空間(加上的值和堆棧幀建立時分配給局部變量的地址大小相同)。
4)從堆棧中彈出先前的EBP寄存器的值,恢復EBP寄存器。
5)從堆棧中彈出函數的返回地址,準備跳轉到函數的返回地址處繼續執行。
6)ESP加上某個值,回收所有的參數地址。
前面1-5條都是由callee完成的。而第6條,參數地址的回收,是由caller或者callee完成是由函數使用的調用約定(calling convention )來決定的。下面的小節我們就來講解函數的調用約定。
函數的調用約定(calling convention)
函數的調用約定(calling convention)指的是進入函數時,函數的參數是以什麼順序壓入堆棧的,函數退出時,又是由誰(Caller還是Callee)來清理堆棧中的參數。有2個辦法可以指定函數使用的調用約定:
1)在函數定義時加上修飾符來指定,如
1
2
3
4
|
void __thiscall
mymethod(); { ... } |
常用的調用約定有以下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寄存器來傳遞了。
反編譯代碼的跟蹤(不熟悉彙編可跳過)
以下代碼爲和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未清理參數) |
參考
Debug Tutorial Part 2: The Stack
http://msdn.microsoft.com/zh-cn/library/46t77ak2(VS.80).aspx
聲明
本文爲Binhua Liu原創作品。本文允許複製,修改,傳遞,但不允許用於商業用途。轉載請註明出處。本文發表於2010年8月24日。
#1樓 菜鳥進博客園眼花撩亂 2010-08-24 12:40
還有一個問題:
if (??)
{
int a;
}
else
{
int b,c;
}
這裏 a b c 都會入棧嗎?
for(int i = 0;i<10;i++}
{
int x;
}
這裏有多少個變量入棧呢?(如果編譯器不優化循環體外申明變量)
#2樓[樓主] Binhua Liu 2010-08-24 13:34
其實你自己調試一下就知道了,看一下代碼:
for(int i=0;i<10;i++)
{
int y=10;(斷點1)
}
for(int i=0;i<10;i++)
{
int y=10;(斷點2)
}
在斷點1有:
&y=0x0033fd80
&i=0x0033fd8c
在斷點2有:
&y=0x0033fd68
&i=0x0033fd74
這是在debug模式下測試的結果,不清楚release模式有沒有優化。
#3樓 阿斯蘭 2010-08-24 15:01
a=3
...
m=5
n=4
爲什麼a、b和m、n的入棧順序不一樣啊?
#4樓[樓主] Binhua Liu 2010-08-24 15:22
畫圖時大意了,updating.感謝指正:)
#5樓 燭秋 2010-08-24 19:23
1、圖2上邊的文字有疏忽:“參數都是從左往右入棧的”。應該是從右向左。
2、分配局部變量空間不一定用sub esp, XX。可以使用push XX,foo1函數裏邊只需4個字節的局部變量空間,VS2005/2008編譯器就是這麼做的。另外,不存在圖5下方所說的,間隔的問題。這些可以通過對生成的EXE文件反彙編看出來。
3、圖10下邊的解釋有點問題,你的那個疑問其實不存在。堆棧是順序增長的,不存在MyStrcut指向那麼遠的地方。實際上通過反彙編發現,MyStruct指向的地址是在main()函數裏邊分配了的空間(這是臨時對象空間),這個空間就在foo()函數的堆棧區上方,離開foo()函數後,會有一個拷貝過程把臨時對象空間的內容拷貝到變量result中。注:這是沒有NRV優化(debug模式)的反彙編結果。
4、對於__thiscall的解釋,this指針的傳遞跟編譯器相關。你可以試試看GCC能不能使用__thiscall,我試過用不了。
5、我比較喜歡用OllyDbg作爲反彙編工具,那樣更清晰,建議你試試。
6、寫得不錯,適合沒學怎麼過彙編的人。精神可嘉,畫那麼多的圖,要是我可要費上大把時間,不知道你用什麼畫圖軟件?
#6樓[樓主] Binhua Liu 2010-08-24 22:00
謝謝指正。我的回覆如下:(討論範圍爲VC++)
2、我沒有見過用push分配局部地址空間的情況。在debug模式下,確實總是分配比實際需要多的多的空間給局部變量,而且地址之間確有空隙。
3、我重新讀了下代碼,發現我確實想錯了,挺大一個bug,感謝指正。
4、還是前面說的,討論範圍爲VC++。因爲編譯器的實現確不相同啊。
5、謝謝:),回頭試試,我一般用VS或者Windbg看反彙編代碼。
6、用Office工具裏面的Visio畫的。
總之,第1點和第3點一定改正,第2點還請你再重讀一下代碼再議。
#7樓 燭秋 2010-08-24 22:16
噢,聽說Windbg很重要,不過沒學...
第2點是這樣的。利用(push 寄存器)來分配局部變量空間是存在的。在《加密與解密》第三版的81頁有一個例子。
另外,我之前做的測試:
代碼:
#include <iostream>
using
namespace
std;
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()
{
cout <<
"start"
<< endl;
int
result = foo(3, 4);
cout << result << endl;
cout <<
"start"
<< endl;
return
0;
}
VS2005/2008編譯,OD反彙編,其中foo1函數的截圖如下: