作者 | Gustavo Duarte
譯者 | qhwdw ? ? ? ? ? 共計翻譯:109 篇 貢獻時間:197 天
早些時候,我們探索了 “記憶體中的程式之秘”[1],我們欣賞了在一臺電腦中是如何執行我們的程式的。今天,我們去探索棧的呼叫,它在大多數程式語言和虛擬機器中都默默地存在。在此過程中,我們將接觸到一些平時很難見到的東西,像閉包、遞迴、以及緩衝上限溢位等等。但是,我們首先要作的事情是,描繪出棧是如何運作的。
棧非常重要,因為它追蹤著一個程式中執行的函式,而函式又是一個軟體的重要組成部分。事實上,程式的內部操作都是非常簡單的。它大部分是由函式向棧中推入資料或者從棧中彈出資料的相互呼叫組成的,而在堆上為資料分配記憶體才能在跨函式的呼叫中保持資料。不論是低階的 C 軟體還是像 JavaScript 和 C# 這樣的基於虛擬機器的語言,它們都是這樣的。而對這些行為的深刻理解,對排錯、效能調優以及大概瞭解究竟發生了什麼是非常重要的。
當一個函式被呼叫時,將會建立一個棧幀去支援函式的執行。這個棧幀包含函式的區域性變數和呼叫者傳遞給它的引數。這個棧幀也包含了允許被呼叫的函式(callee)安全傳回給其呼叫者的內部事務資訊。棧幀的精確內容和結構因處理器架構和函式呼叫規則而不同。在本文中我們以 Intel x86 架構和使用 C 風格的函式呼叫(cdecl
)的棧為例。下圖是一個處於棧頂部的一個單個棧幀:
在圖上的場景中,有三個 CPU 暫存器進入棧。棧指標 esp
(LCTT 譯註:擴充套件棧指標暫存器) 指向到棧的頂部。棧的頂部總是被最後一個推入到棧且還沒有彈出的東西所佔據,就像現實世界中堆在一起的一疊盤子或者 100 美元大鈔一樣。
儲存在 esp
中的地址始終在變化著,因為棧中的東西不停被推入和彈出,而它總是指向棧中的最後一個推入的東西。許多 CPU 指令的一個副作用就是自動更新 esp
,離開暫存器而使用棧是行不通的。
在 Intel 的架構中,絕大多數情況下,棧的增長是向著低位記憶體地址的方向。因此,這個“頂部” 在包含資料的棧中是處於低位的記憶體地址(在這種情況下,包含的資料是 local_buffer
)。註意,關於從 esp
到 local_buffer
的箭頭不是隨意連線的。這個箭頭代表著事務:它專門指向到由 local_buffer
所擁有的第一個位元組,因為,那是一個儲存在 esp
中的精確地址。
第二個暫存器跟蹤的棧是 ebp
(LCTT 譯註:擴充套件基址指標暫存器),它包含一個基指標或者稱為幀指標。它指向到一個當前執行的函式的棧幀內的固定位置,並且它為引數和區域性變數的訪問提供一個穩定的參考點(基址)。僅當開始或者結束呼叫一個函式時,ebp
的內容才會發生變化。因此,我們可以很容易地處理在棧中的從 ebp
開始偏移後的每個東西。如圖所示。
不像 esp
, ebp
大多數情況下是在程式程式碼中透過花費很少的 CPU 來進行維護的。有時候,完成拋棄 ebp
有一些效能優勢,可以透過 編譯標誌[2] 來做到這一點。Linux 核心就是一個這樣做的示例。
最後,eax
(LCTT 譯註:擴充套件的 32 位通用資料暫存器)暫存器慣例被用來轉換大多數 C 資料型別傳回值給呼叫者。
現在,我們來看一下在我們的棧幀中的資料。下圖清晰地按位元組展示了位元組的內容,就像你在一個除錯器中所看到的內容一樣,記憶體是從左到右、從頂部至底部增長的,如下圖所示:
區域性變數 local_buffer
是一個位元組陣列,包含一個由 null 終止的 ASCII 字串,這是 C 程式中的一個基本元素。這個字串可以讀取自任意地方,例如,從鍵盤輸入或者來自一個檔案,它只有 7 個位元組的長度。因為,local_buffer
只能儲存 8 位元組,所以還剩下 1 個未使用的位元組。這個位元組的內容是未知的,因為棧不斷地推入和彈出,除了你寫入的之外,你根本不會知道記憶體中儲存了什麼。這是因為 C 編譯器並不為棧幀初始化記憶體,所以它的內容是未知的並且是隨機的 —— 除非是你自己寫入。這使得一些人對此很困惑。
再往上走,local1
是一個 4 位元組的整數,並且你可以看到每個位元組的內容。它似乎是一個很大的數字,在8 後面跟著的都是零,在這裡可能會誤導你。
Intel 處理器是小端機器,這表示在記憶體中的數字也是首先從小的一端開始的。因此,在一個多位元組數字中,較小的部分在記憶體中處於最低端的地址。因為一般情況下是從左邊開始顯示的,這背離了我們通常的數字表示方式。我們討論的這種從小到大的機制,使我想起《格裡佛遊記》:就像小人國的人們吃雞蛋是從小頭開始的一樣,Intel 處理器處理它們的數字也是從位元組的小端開始的。
因此,local1
事實上只儲存了一個數字 8,和章魚的腿數量一樣。然而,param1
在第二個位元組的位置有一個值 2,因此,它的數學上的值是 2 * 256 = 512
(我們與 256 相乘是因為,每個位置值的範圍都是從 0 到 255)。同時,param2
承載的數量是 1 * 256 * 256 = 65536
。
這個棧幀的內部資料是由兩個重要的部分組成:前一個棧幀的地址(儲存的 ebp
值)和函式退出才會執行的指令的地址(傳回地址)。它們一起確保了函式能夠正常傳回,從而使程式可以繼續正常執行。
現在,我們來看一下棧幀是如何產生的,以及去建立一個它們如何共同工作的內部藍圖。首先,棧的增長是非常令人困惑的,因為它與你你預期的方式相反。例如,在棧上分配一個 8 位元組,就要從 esp
減去 8,去,而減法是與增長不同的奇怪方式。
我們來看一個簡單的 C 程式:
Simple Add Program - add.c
int add(int a, int b)
{
int result = a + b;
return result;
}
int main(int argc)
{
int answer;
answer = add(40, 2);
}
簡單的加法程式 - add.c
假設我們在 Linux 中不使用命令列引數去執行它。當你執行一個 C 程式時,實際執行的第一行程式碼是在 C 執行時庫裡,由它來呼叫我們的 main
函式。下圖展示了程式執行時每一步都發生了什麼。每個圖連結的 GDB 輸出展示了記憶體和暫存器的狀態。你也可以看到所使用的 GDB 命令[3],以及整個 GDB 輸出[4]。如下:
第 2 步和第 3 步,以及下麵的第 4 步,都只是函式的序言,幾乎所有的函式都是這樣的:ebp
的當前值被儲存到了棧的頂部,然後,將 esp
的內容複製到 ebp
,以建立一個新的棧幀。main
的序言和其它函式一樣,但是,不同之處在於,當程式啟動時 ebp
被清零。
如果你去檢查棧下方(右邊)的整形變數(argc
),你將找到更多的資料,包括指向到程式名和命令列引數(傳統的 C 的 argv
)、以及指向 Unix 環境變數以及它們真實的內容的指標。但是,在這裡這些並不是重點,因此,繼續向前呼叫 add()
:
在 main
從 esp
減去 12 之後得到它所需的棧空間,它為 a
和 b
設定值。在記憶體中的值展示為十六進位制,並且是小端格式,與你從除錯器中看到的一樣。一旦設定了引數值,main
將呼叫 add
,並且開始執行:
現在,有一點小激動!我們進入了另一個函式序言,但這次你可以明確看到棧幀是如何從 ebp
到棧建立一個連結串列。這就是除錯器和高階語言中的 Exception
物件如何對它們的棧進行跟蹤的。當一個新幀產生時,你也可以看到更多這種典型的從 ebp
到 esp
的捕獲。我們再次從 esp
中做減法得到更多的棧空間。
當 ebp
暫存器的值複製到記憶體時,這裡也有一個稍微有些怪異的位元組逆轉。在這裡發生的奇怪事情是,暫存器其實並沒有位元組順序:因為對於記憶體,沒有像暫存器那樣的“增長的地址”。因此,慣例上除錯器以對人類來說最自然的格式展示了暫存器的值:數位從最重要的到最不重要。因此,這個在小端機器中的副本的結果,與記憶體中常用的從左到右的標記法正好相反。我想用圖去展示你將會看到的東西,因此有了下麵的圖。
在比較難懂的部分,我們增加了註釋:
這是一個臨時暫存器,用於幫你做加法,因此沒有什麼警報或者驚喜。對於加法這樣的作業,棧的動作正好相反,我們留到下次再講。
對於任何讀到這裡的人都應該有一個小禮物,因此,我做了一個大的圖表展示了 組合到一起的所有步驟[5]。
一旦把它們全部佈置好了,看上起似乎很乏味。這些小方框給我們提供了很多幫助。事實上,在電腦科學中,這些小方框是主要的展示工具。我希望這些圖片和暫存器的移動能夠提供一種更直觀的構想圖,將棧的增長和記憶體的內容整合到一起。從軟體的底層運作來看,我們的軟體與一個簡單的圖靈機器差不多。
這就是我們棧探秘的第一部分,再講一些內容之後,我們將看到構建在這個基礎上的高階程式設計的概念。下週見!
via:https://manybutfinite.com/post/journey-to-the-stack/
作者:Gustavo Duarte[7] 譯者:qhwdw 校對:wxy
本文由 LCTT 原創編譯,Linux中國 榮譽推出