Binhua Liu
我們經常會討論這樣的問題:什麼時候數據存儲在堆棧(Stack)中,什麼時候數據存儲在堆(Heap)中。我們知道,局部變量是存儲在堆棧中的;debug時,查看堆棧可以知道函數的調用順序;函數調用時傳遞參數,事實上是把參數壓入堆棧,聽起來,堆棧象一個大雜燴。那麼,堆棧(Stack)到底是如何工作的呢? 本文將詳解C/C++堆棧的工作機制。閱讀時請注意以下幾點:
1)本文討論的編譯環境是 Visual C/C++,由於高級語言的堆棧工作機制大致相同,因此對其他編譯環境或高級語言如C#也有意義。
2)本文討論的堆棧,是指程序為每個線程分配的默認堆棧,用以支持程序的運行,而不是指程序員為了實現算法而自己定義的堆棧。
3) 本文討論的平台為intel x86。
4)本文的主要部分將盡量避免涉及到匯編的知識,在本文最後可選章節,給出前面章節的反編譯代碼和注釋。
5)結構化異常處理也是通過堆棧來實現的(當你使用try…catch語句時,使用的就是c++對windows結構化異常處理的擴展),但是關於結構化異常處理的主題太復雜了,本文將不會涉及到。
1) 程序的堆棧是由處理器直接支持的。在intel x86的系統中,堆棧在內存中是從高地址向低地址擴展(這和自定義的堆棧從低地址向高地址擴展不同),如下圖所示:
開始討論堆棧是如何工作的
我們來討論堆棧的工作機制。堆棧是用來支持函數的調用和執行的,因此,我們下面將通過一組函數調用的例子來講解,看下面的代碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18int
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來構建。
在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