函式的呼叫,棧幀的建立和銷燬
一、什麼是棧幀?
在進行函式的呼叫(棧幀)的分析前,我們先了解下 main 函式的呼叫吧,通過以前的學習,我們知道 main 函式也是被呼叫的,先在 __tmainCRTStartup 函式中呼叫,而 __tmainCRTStartup 函式是在 mainCRTStartup 中被呼叫的。
恰如這樣的呼叫,我們發現每一次函式呼叫都是一個過程,這個過程我們稱之為:函式的呼叫過程。
函式呼叫過程要為函式開闢棧空間,用於本次函式的呼叫中臨時變數的儲存、現場保護。這塊棧空間我們稱之為函式棧幀。棧幀棧幀(stack
frame)也叫過程活動記錄,是編譯器用來實現函式呼叫過程的一種資料結構。C語言中,每
個棧幀對應著一個未執行完的函式。從邏輯上講,棧幀就是一個函式執行的環境:函式呼叫框架、函式引數、函式的
局部變數、函式執行完後返回到哪裡等等。棧是從高地址向低地址延伸的。每個函式的每次呼叫,都有它自己獨立的
一個棧幀,這個棧幀中維持著所需要的各種資訊。
棧幀的維護需要我們瞭解 ebp 和 esp 兩個暫存器,在函式呼叫的過程中這兩個暫存器存放了維護這個棧的棧底和棧頂指標。ebp 存放了指向函式棧幀棧底的地址;esp 存放了指向函式棧幀棧頂的地址。
暫存器ebp稱為“基址指標”,在未受改變之前始終指向棧底,用途是:在堆疊中定址。
暫存器esp稱為“棧指標”,會隨著資料的入棧出棧移動,也就是說始終指向棧 頂。
棧幀結構如下所示:
二、棧幀的主要作用:
是用來控制和儲存一個過程的所有資訊的。我們平時說的堆疊其實是指棧,而實際上堆和棧是兩種不
同的記憶體分配。簡單羅列如下各方面的異同點。
1).堆需要使用者在程式中顯式申請,棧不用,由系統自動完成。申請/釋放堆記憶體的API,在C中是malloc/free,在
C++中是new/delete。申請與釋放一定要配對使用,否則會造成記憶體洩漏(memory leak),久而久之系統就無記憶體
可用了,出現OOM(Out Of Memory)錯誤。一般在return/exit或break/continue等語句時容易忘記釋放記憶體,
所以檢查記憶體洩漏的程式碼時要關注這些語句,看它們前面是否有必要的釋放語句free/delete。
2).堆的空間比較大,棧比較小。所以申請大的記憶體一般在堆中申請;棧上不要有較大的記憶體使用,比如大的靜態數
組;而且除非演算法必要,否則一般不要使用較深的迭代函式呼叫,那樣棧消耗記憶體會隨著迭代次數的增加飛漲。
3).關於生命週期。棧較短,隨著函式退出或返回,本函式的棧就完成了使用;堆就要看什麼時候釋放,生命週期就什
麼時候結束。
三、棧幀結構和函式呼叫過程
棧在函式呼叫中的作用:引數傳遞、區域性變數分配、儲存呼叫的返回地址、儲存暫存器以供恢復。
棧幀(stack Frame):一次函式呼叫包括將資料和控制程式碼從的一個部分傳遞到另外一個部分,棧幀與某個過程呼叫一
一對映。每個函式的每次呼叫,都有它自己獨立的一個棧幀,這個棧幀中維持著所需要的各種資訊。暫存器ebp指向
當前的棧幀的底部(高地址),暫存器esp指向當前的棧幀的頂部(低址地)。
函式呼叫規則:
l _cdecl:按從右至左的順序壓引數入棧,由呼叫者把引數彈出棧。由於每次函式呼叫都要由編譯器產生清楚堆
棧的程式碼,所以使用_cdecl的程式碼比使用_stdcall的程式碼要大很多,但是這種方式支援可變引數。對於C函式,名字修
飾約定為在函式名前加下劃線。對於C++,除非特變使用extern C,C++使用不同的名字修飾方式。
l _stdcall:按從右至左的順序壓引數入棧,由被呼叫者把引數彈出棧。呼叫約定在輸出函式名前加上一個下劃
線字首,後面加上一個“@”符號和其引數的位元組數。
l _fastcall:主要特點就是快,因為它是通過暫存器來傳送引數的,和__stdcall很象,唯一差別就是頭兩個引數
通過暫存器傳送。注意通過暫存器傳送的兩個引數是從左向右的,即第一個引數進ECX,第2個進EDX,其他引數是
從右向左的入stack。返回仍然通過EAX。
四、堆和棧
(1)首先要清楚的是程式對記憶體的使用分為以下幾個區:
1) 棧區(stack):由編譯器自動分配和釋放,存放函式的引數值,區域性變數的值等。操作方式類似於資料結構中
的棧。
2) 堆區(heap):一般由程式設計師分配和釋放,若程式設計師不釋放,程式結束時可能由作業系統回收。與資料結構中
的堆是兩碼事,分配方式類似於連結串列。
3) 全域性區(static):全域性變數和靜態變數存放在此。
4) 文字常量區:常量字串放在此,程式結束後由系統釋放。
5) 程式程式碼區:存放函式體的二進位制程式碼。
(2)其次是堆和棧的申請方式:
棧由系統自動分配,速度較快,在windows下棧是向低地址擴充套件的資料結構,是一塊連續的記憶體區域,大小是
2MB。
堆需要程式設計師自己申請,並指明大小,速度比較慢。在C中用malloc,C++中用new。另外,堆是向高地址擴充套件的
資料結構,是不連續的記憶體區域,堆的大小受限於計算機的虛擬記憶體。因此堆空間獲取和使用比較靈活,可用空間較
大。
現在我們以Add()函式為例深入的研究一下函式的呼叫過程。
先看一段簡單的程式碼
當我們要詳細研究函式呼叫過程,必須得對照的彙編程式碼。
從main函式的地方開始,要展開main函式的呼叫就得為main函式建立棧幀。
1)我們知道ebp和esp是用來維護函式的棧底指標和棧頂指標,push ebp,將__mainCRTStarup函式的ebp壓棧,在
它的棧頂開闢一塊空間放入它的ebp;
2)將esp給ebp,此時ebp與esp同時指向剛剛開闢空間的頂端;(建立棧幀的過程)
3)esp減去0e4h大小的值,我們知道棧空間中元素存放順序是由高地址到低地址,則該步驟在ebp的上面開闢了0e4h
大小的記憶體空間;
4)ebx,esi,edi三塊空間進行壓棧,隨著壓棧的進行,esp指向edi的頂端;
5)將10賦給a,20賦給b。
6)b的值,將它mov給eax暫存器並且壓棧;同理a的值,將它mov給ecx暫存器並且壓棧。
7)接著呼叫call指令。注意,在呼叫call指令的時候,在ecx的上方又開闢了一塊空間用於存放call指令下一條指令的地
址(這個地址的作用是在call指令呼叫add函式結束的的時候jump指令能夠找到call指令下一條指令的地址,從而回
到main函式中)
8)由call指令進入add函式之後,第一步就是進行壓棧,將main函式的ebp壓棧儲存在上面開闢的空間中。 建立一個空間,放進去內容為0;存放了指向函式棧幀棧底的地址。esp存放了指向函式棧幀棧頂的地址。
注意:ebp指向當前位於系統棧最上邊一個棧幀的底部,而不是系統棧的底部。嚴格說來,“棧幀底部”和“棧
底”是不同的概念;ESP所指的棧幀頂部和系統棧的頂部是同一個位置。
五、棧幀的一般總結:
1. 堆疊是C語言程式執行時必須的一個記錄呼叫路徑和引數的空間:
➢ 函式呼叫框架;
➢ 傳遞引數;
➢ 儲存返回地址;
➢ 提供區域性變數空間;
以x86體系結構為例
2. 堆疊暫存器和堆疊操作
堆疊相關的暫存器
➢ esp,堆疊指標(stack pointer)
➢ ebp,基址指標(base pointer)
堆疊操作
➢ push 棧頂地址減少4個位元組(32位)
➢ pop 棧頂地址增加4個位元組
❖ ebp在C語言中用作記錄當前函式呼叫基址
3. 利用堆疊實現函式呼叫和返回
❖其他關鍵暫存器
➢ cs : eip:總是指向下一條的指令地址
● 順序執行:總是指向地址連續的下一條指令
● 跳轉/分支:執行這樣的指令的時候, cs : eip的值會根據程式需要被修改
● call:將當前cs : eip的值壓入棧頂, cs : eip指向被呼叫函式的入口地址
● ret:從棧頂彈出原來儲存在這裡的cs : eip的值,放在cs : eip中