C|函數調用約定與堆棧平衡的彙編代碼分析

在C中,函數是程序的核心,函數不能嵌套定義但可以嵌套調用,最簡單的方式就是在main函數中調用其它自定義函數或庫函數,調用函數時,代碼執行順序會發生跳轉(函數名是存儲函數代碼序列的起始地址),如何確保正確地回溯到原來的調用處是編譯器所需要考量的,C++編譯器的做法是維護一個棧來確保函數的調用和返回。就如果去某一個地方,經過了若干路口,用一張紙記下路口及路口間的標誌性景觀,回程時,按倒序回溯即可。

1 順序存儲與堆棧平衡

內存存儲是一個線性結構,數據和代碼在內存中都是順序存儲,以字節(8個位)為單位進行順序編址。

棧是程序設計中的一種經典數據結構,每個程序都擁有自己的程序棧,每個函數都有自己的棧幀。很重要的一點是,棧是向下生長的(由編譯器計算函數棧幀所需內存空間)。所謂向下生長是指從內存高地址向低地址的路徑延伸,棧有棧底和棧頂,那麼棧頂的地址要比棧底低。對x86體系的CPU而言,其中:

ESP:棧指針寄存器(extended stack pointer),其內存放著一個指針,該指針永遠指向系統棧最上面一個棧幀的棧頂。
EBP:基址指針寄存器(extended base pointer),其內存放著一個指針,該指針永遠指向系統棧最上面一個棧幀的底部。

函數棧幀:ESP和EBP之間的內存空間為當前棧幀,EBP標識了當前棧幀的底部,ESP標識了當前棧幀的頂部。


C|函數調用約定與堆棧平衡的彙編代碼分析

在C和C++語言中,函數的臨時變量分配在棧中,臨時變量擁有函數級的生命週期,即“在當前函數中有效,在函數外無效”。這種現象就是函數調用過程中的參數壓棧,堆棧平衡所帶來的。

堆棧平衡這個概念指的是函數調完成後,要返還所有使用過的棧空間(也就是將esp、ebp恢復到原來的位置)。

C|函數調用約定與堆棧平衡的彙編代碼分析

函數棧的push和pop操作隱含改變esp的指向:

push ebx ↔ *(esp-4)=ebx

pop ebx ↔ ebx=*(esp+4)

正如同迷宮探索一樣,需確保從一個路徑逐層深入後能夠回溯回來,這就是棧的機制,通過維護一個棧頂和棧頂指針來確保平衡。

2 代碼順序執行

數據和代碼順序存儲,代碼順序執行。CPU使用一個寄存器(程序計數器)存放下一條指令所在單元的地址。

當執行一條指令時,首先需要根據PC中存放的指令地址,將指令由內存取到指令寄存器中,此過程稱為“取指令”。與此同時,PC中的地址或自動加1或由轉移指針給出下一條指令的地址。此後經過分析指令,執行指令。完成第一條指令的執行,而後根據PC取出第二條指令的地址,如此循環,執行每一條指令。

可以看到,程序計數器是一個cpu執行指令代碼過程中的關鍵寄存器:它指向了當前計算機要執行的指令地址,CPU總是從程序計數器取出當前指令來執行。當指令執行後,程序計數器的值自動增加,指向下一條將要執行的指令。

在x86彙編中,執行程序計數器功能的寄存器被叫做EIP,也叫作指令指針寄存器。

指令寄存器(extended instruction pointer), 其內存放著一個指針,該指針永遠指向下一條待執行的指令地址。

當然,在控制結構中有選擇和循環結構,通過一個產生邏輯結果的比較表達式或邏輯表達式來決定程序的跳轉地址,比較表達式通過一個減法表達式來計算,影響標誌寄存器的值。所以,選擇和循環結果的實質也在一個順序的代碼序列中進行跳轉而已。

3 函數的參數傳遞和調用約定

函數的參數傳遞是一個參數壓棧的過程。函數的所有參數,都會依次被push到棧中。那調用約定又是什麼呢?

C和C++程序員應該對所謂的調用約定有一定的印象,就像下面這種代碼:

void __stdcall add(int a,int b);

函數聲明中的__stdcall就是關於調用約定的聲明。其中標準C函數的默認調用約定是__stdcall,C++全局函數和靜態成員函數的默認調用約定是__cdecl,類的成員函數的調用約定是__thiscall。剩下的還有__fastcall,__naked等。

為什麼要用所謂的調用約定?調用約定其實是一種約定方式,它指明瞭函數調用中的參數傳遞方式和堆棧平衡方式。

3.1 參數傳遞方式

<code>int func(int n,int *out)
{
\tint sum=0;
\tfor(int i=0;i<=n;i++)
\t\tsum+=i;
\t*out = sum;
\treturn sum;
}/<code>

func函數有2個參數,int n,int *out。這兩個參數,入棧的順序誰先誰後?

其實是從左到右入棧還是從右到左入棧都可以,只要函數調用者和函數內部使用相同的順序存取參數即可。在上述的所有調用約定中,參數總是從右到左壓棧,也就是最後一個參數先入棧。

<code>14:       int b = 5;
0040DE68 mov dword ptr [ebp-4],5
15: int c = 0;
0040DE6F mov dword ptr [ebp-8],0
16: int d = func(b,&c);
0040DE76 lea eax,[ebp-8] //
0040DE79 push eax
0040DE7A mov ecx,dword ptr [ebp-4]
0040DE7D push ecx
0040DE7E call @ILT+15(ff) (00401014)
0040DE83 add esp,8
0040DE86 mov dword ptr [ebp-0Ch],eax/<code>

其實從這裡我們就可以理解為什麼在函數內部,不能改變函數外部參數的值:因為函數內部訪問到的參數其實是壓入棧的變量值,對它的修改只是修改了棧中的"副本"。指針和引用參數才能真正地改變外部變量的值。

3.2 堆棧平衡方式

因為函數調用過程中,參數需要壓棧,所以在函數調用結束後,用於函數調用的壓棧參數也需要退棧。那這個工作是交給調用者完成,還是在函數內部自己完成?其實兩種都可以。調用者負責平衡堆棧的主要好處是可以實現可變參數,因為在參數可變的情況下,只有調用者才知道具體的壓棧參數有幾個。

下面列出了常見調用約定的堆棧平衡方式:

調用約定 堆棧平衡方式

__stdcall 函數自己平衡

__cdecl 調用者負責平衡

__thiscall 調用者負責平衡

__fastcall 調用者負責平衡

__naked 編譯器不負責平衡,由編寫者自己負責

在C++中,堆棧平衡的代碼由編譯器自動生成(調試時,可以調出反彙編窗口查看)。

4 函數調用過程

函數調用過程主要由參數傳遞、地址跳轉、局部變量分配和賦初值、執行函數體、結果返回、堆棧平衡等幾個步驟組成。

a 參數入棧:將實參值從右向左依次壓入系統棧中;
b 返回地址入棧:將當前代碼區調用指令的下一條指令地址壓入棧中,供函數返回時繼續執行,一般由call指令完成;
c 代碼區跳轉:處理器從當前代碼區跳轉到被調用函數的入口處,一般也是由call指令完成;
d 棧幀調整:具體包括:
① 保存當前棧幀狀態值,以便在後面恢復本棧幀時使用(EBP入棧);
② 將當前棧幀切換到新棧幀。(將ESP值裝入EBP,更新棧幀底部);
③ 給新棧幀分配空間。(把ESP減去所需空間的大小,抬高棧頂);

以下以下面的實例對應的彙編來分析:

<code>#include <stdio.h>

int func(int n,int *out)
{
\tint sum=0;
\tfor(int i=0;i<=n;i++)
\t\tsum+=i;
\t*out = sum;
\treturn sum;
}

int main()
{
\tint b = 5;
\tint c = 0;
\tint d = func(b,&c);
\tprintf("%d %d",c,d); //15 15

\tgetchar();
\treturn 0;
}/<stdio.h>/<code>

4.1 從main()函數開始

<code>12:   int main()
13: {
0040DE50 push ebp
0040DE51 mov ebp,esp
0040DE53 sub esp,50h
0040DE56 push ebx
0040DE57 push esi
0040DE58 push edi
0040DE59 lea edi,[ebp-50h]
0040DE5C mov ecx,14h
0040DE61 mov eax,0CCCCCCCCh
0040DE66 rep stos dword ptr [edi]
14: int b = 5;
0040DE68 mov dword ptr [ebp-4],5
15: int c = 0;
0040DE6F mov dword ptr [ebp-8],0
/<code>

debug跟release在初始化變量時所做的操作是不同的,debug是將每個字節位都賦成0xcc,而release不會。上述彙編的rep stos即重複將50h字節的棧空間的每一字節填充0xCC。

C|函數調用約定與堆棧平衡的彙編代碼分析


4.2 調用func(),包括壓參、函數跳轉、堆棧平衡和值返回。

實參傳遞給形參。在底層實現上,即是實參按照函數調用規定壓入堆棧。參數傳遞完成後就通過CALL指令由當前程序跳轉到子程序處。

jmp修改EIP的值實現跳轉。

返回地址壓棧。

<code>16:       int d = func(b,&c);
0040DE76 lea eax,[ebp-8]
0040DE79 push eax
0040DE7A mov ecx,dword ptr [ebp-4]
0040DE7D push ecx
0040DE7E call @ILT+15(ff) (00401014)
/<code>


C|函數調用約定與堆棧平衡的彙編代碼分析

在進行call操作之後,會自動將call的下一條語句作為函數的返回地址保存在棧中。

call指令會把它的下一條指令的地址作為函數的返回地址壓入堆棧中,然後跳轉到它調用函數的開頭處。而單純的jmp是不會這樣做的。
call的本質相當於push+jmp。

4.3 跳轉進入函數代碼塊

相關寄存器壓棧,編譯器計算並分配函數所需棧幀空間並將空間進行初始化:

<code>3:    int func(int n,int *out)
4: {
00401080 push ebp
00401081 mov ebp,esp
00401083 sub esp,48h
00401086 push ebx
00401087 push esi
00401088 push edi
00401089 lea edi,[ebp-48h]
0040108C mov ecx,12h
00401091 mov eax,0CCCCCCCCh
00401096 rep stos dword ptr [edi]/<code>

上面彙編代碼對應上圖的③-⑥。

4.4 局部變量分配並賦值

函數的"{"被認為是分配局部變量空間的時機。在彙編層面局部變量分配體現為堆棧中以EBP寄存器為基址向低地址端分配的一個連續區域,通過EBP寄存器的相對尋址方式來尋址函數內的局部變量。由於堆棧增長的方向是高地址端到低地址端,因此函數中先定義的局部變量地址較大,後定義的變量地址逐漸變小,相鄰定義的變量其地址一定相鄰。由於全局數據和局部數據定義在不用的數據區而並不與局部變量相鄰,根據程序局部性原理,相鄰的數據會被緩存,因此對相同的運算,局部變量作為操作數的運算效率就可能高於有全局變量參與的運算。同時,局部變量分配和回收只需要移動堆棧指針ESP,因此效率最高。

4.5 尋址函數的參數

參數存放在以EBP為基址的高地址端。對參數的訪問同樣是通過EBP寄存器相對尋址操作來實現。

4.6 執行函數體內的語句

函數內和具體功能相關的語句被轉化成一系列彙編語句。

4.7 返回值

return語句將返回值返回到主調函數。在底層,參數是通過EAX寄存器或EDX寄存器傳遞給主調函數。或浮點計算單元的寄存器(浮點數返回值),或在主調函數中預先開闢棧空間來保存被調函數的返回值(自定義函數返回值)。

<code>5:        int sum=0;
00401098 mov dword ptr [ebp-4],0
6: for(int i=0;i<=n;i++)
0040109F mov dword ptr [ebp-8],0
004010A6 jmp func+31h (004010b1)
004010A8 mov eax,dword ptr [ebp-8]
004010AB add eax,1
004010AE mov dword ptr [ebp-8],eax
004010B1 mov ecx,dword ptr [ebp-8]
004010B4 cmp ecx,dword ptr [ebp+8]
004010B7 jg func+44h (004010c4)
7: sum+=i;
004010B9 mov edx,dword ptr [ebp-4]
004010BC add edx,dword ptr [ebp-8]
004010BF mov dword ptr [ebp-4],edx
004010C2 jmp func+28h (004010a8)
8: *out = sum;
004010C4 mov eax,dword ptr [ebp+0Ch]
004010C7 mov ecx,dword ptr [ebp-4]
004010CA mov dword ptr [eax],ecx

9: return sum;
004010CC mov eax,dword ptr [ebp-4]
10: }
/<code>

4.8 堆棧平衡

堆棧平衡指的是將函數調用前壓入堆棧的參數彈出堆棧,使堆棧恢復到其調用前的狀態。由於函數調用完成後,參數就是無用的數據了,因此需要將其移出堆棧。

在C語言中不需要進行堆棧平衡(由編譯器自動生成堆棧平衡代碼)。而在彙編層面上卻根據調用約定來確定由主調函數或是被調函數完成堆棧平衡。

<code>10:   }
004010CF pop edi
004010D0 pop esi
004010D1 pop ebx
004010D2 mov esp,ebp
004010D4 pop ebp
004010D5 ret
/<code>


C|函數調用約定與堆棧平衡的彙編代碼分析


ret會自動彈出棧頂的返回地址,修改EIP的值,從而實現近轉移。ret的本質相當於pop+jmp。

4.9 返回主調函數

函數的"}"被解釋為函數體已經執行完。遇到"}"時,會將堆棧中的局部變量、程序中壓入堆棧的寄存器的值全部彈出,將之前CALL指令執行時壓入堆棧的函數返回地址彈到指令指針寄存器EIP,從而返回到主調函數。

<code>0040DE83   add         esp,8
0040DE86 mov dword ptr [ebp-0Ch],eax/<code>

上面的8代表兩個參數所需要的字節數,如果被調函數是int func(int n,int *out,double d); 則彙編代碼應該是add esp, 10h,其中的10h即是十進制的16。

在函數調用過程中,所有調用信息(返回地址、參數)以及自動變量都是放在棧中的。若函數的聲明與實現不同(參數、返回值、調用方式),就會產生錯誤――――但 Debug 方式下,棧的訪問通過 EBP 寄存器保存的地址實現,如果沒有發生數組越界之類的錯誤(或是越界“不多”),函數通常能正常執行;Release 方式下,優化會省略 EBP 棧基址指針,這樣通過一個全局指針訪問棧就會造成返回地址錯誤使程序崩潰。

-End-


分享到:


相關文章: