C語言函式呼叫的底層機制
這是一篇介紹C語言中的函式呼叫是如何用實現的文章。寫給那些對C語言各種行為的底層實現感興趣人的入門級文章。如果你是C語言或者彙編、底層技術
的老鳥或是對這個問題不感興趣,那麼這篇文章只會耽誤您的時間,您大可不必閱讀他。當然如果前輩們願意為我指出不足,我將十分感謝您的指導,並對耽誤您寶
貴的時間致歉。
好了,廢話少說!要研究這個問題,讓我們先開啟VC++吧。最好是6.0的,:-P。(什麼你沒有VC++,倒!....趕快裝一個[email protected]#$,要快!)
首先,讓我們在VC++裡建立一個Win32 ConsoleApplication專案,並建立主檔案fun.c。並輸入以下內容。
int fun(int a, int b) {
a = 0x4455;
b = 0x6677;
return a + b;
}
int main() {
fun(0x8899,0x1100);
return 0;
}
之
後,最關鍵的是在專案設定裡關閉優化功能。也就是把Project->Setting->C/C++->Optimizations選
為Disabled。編譯器的優化在分析底層實現時大多數情況不太受歡迎。 按鍵盤上的F10鍵,進入單步除錯模式(Step
Over)。看到你的main函式左側有個黃色的小箭頭了嗎?那個就是程式即將執行的語句。按Alt +
8。開啟反編譯視窗,看到彙編語句了嗎?是不是想這個樣子
==> 00401078 push 1100h
0040107D push 8899h
00401082 call @ILT+5(fun) (0040100a)
00401087 add esp,8
看
到兩個PUSH指令了嗎?再看看後面的數字,不正是我們要傳遞的引數嗎。奇怪阿?我們明明是先傳遞的0x8899怎麼反倒先push
1100h呢?呵呵,這個現象就叫Calling
conversion。究竟是何方神聖,我在後面會詳細的給你解釋的。先彆著急。隨後的Call指令的作用就是開始呼叫函數了。
接下來關掉反彙編視窗,在原始碼視窗按F11(Step
Into)進入函式體。當看到那個黃色的小箭頭指向函式名的時候再調出反彙編視窗(Alt+8)。你會看到類似下面的程式碼:
1: int fun(inta, int b) {
00401000 push ebp
00401001 mov ebp,esp
00401003 sub esp,40h
00401006 push ebx
00401007 push esi
00401008 push edi
00401009 lea edi,[ebp-40h]
0040100C mov ecx,10h
00401011 mov eax,0CCCCCCCCh
00401016 rep stos dword ptr [edi]
2: a =0x4455;
00401018 mov dword ptr [ebp+8],4455h
3: b = 0x6677;
0040101F mov dword ptr [ebp+0Ch],6677h
4: return a + b;
00401026 mov eax,dword ptr [ebp+8]
00401029 add eax,dword ptr [ebp+0Ch]
5: }
0040102C pop edi
0040102D pop esi
0040102E pop ebx
0040102F mov esp,ebp
00401031 pop ebp
00401032 ret
VC++就是好,還在難懂的彙編語句前加入了C語言的原始碼。不過同時也有不少我們不需要的程式碼。因此,你只需要關心紅色的部分就可以了。
奇怪阿?不是引數都用push傳遞了嗎?怎麼沒看到被pop出來?問題其實是這樣,當你呼叫Call進入函式的時候Call揹著你做了一件事。call把
它下一條語句的地址push進了堆疊。(旁人:
什麼!這是為什麼?)原因很簡單,因為函式呼叫完了,要用ret返回。而ret怎麼知道返回哪裡呢?對了,
ret指令pop了call指令push給他的地址(搞清楚這個關係哦),然後返回到了這個地址。call和ret配合的如此絕妙,一個PUSH一個
POP肯定不會讓堆疊不平衡的(老外叫no stackunwinding)。現在明白了,如果你來個pop
eax,那eax裡面是什麼?當然是ret要用的返回地址了。好啦,你要是pop
eax就等於搶了ret要用的東西了。不論曾程式流程和道德標準上你做的都不對 :-P。
可是怎麼在函式體裡使用引數呢?問題其實並不難,既然引數在堆疊裡我們就可以使用esp(堆疊指標)來訪問了。不過,我相信你也想到了。esp是個經常變
化的值。一旦,函式裡出現pop或push他就會變化。這樣很不容易定位引數的於記憶體中的位置。因此,我們需要一個不會變化的東西作為訪問引數的基準。看
看函式體的開頭部分:
00401000 push ebp
00401001 mov ebp,esp
先
用push ebp儲存了原來ebp的值再把esp的值給ebp。原來ebp就是用來做基準的。也難怪他被稱為ebp(Base
Pointer)。很自然ret返回前的pop
ebp就是恢復原來ebp的數值嘍。當然一定要恢復,因為函式裡也可以呼叫函式嘛。每個函式都用ebp,自然要保證使用完後完璧歸趙了。現在當函式執行到
mov ebp, esp後堆疊應該變成這個樣子了。
/-------------------\ Higher Address
| 引數2: 0x1100h |
+-----------------+
| 引數1: 0x8899h |
+-----------------+
| 函式返回地址 |
| 0x00401087 |
+-----------------+
| ebp |
\-------------------/ Lower Address<== stack pointer
& ebp all point to here, now
由
於我們在VC++上使用的int型別是一個32位型別,ebp和函式返回值也是32位的。因此每個量要佔去4個位元組。另外還需要注意堆疊的擴充套件方向是高地
址到低地址。有了這些指示。我們就可以分析出,第一個引數的地址是ebp + 08h,第二個引數就是ebp + 0ch。看看反彙編的程式碼:
2: a =0x4455;
00401018 mov dword ptr [ebp+8],4455h
3: b = 0x6677;
0040101F mov dword ptr [ebp+0Ch],6677h
與我們的計算吻合。之後呢:
00401031 pop ebp
00401032 ret
將ebp原來的數值完璧歸趙,呼叫ret指令,ret指令pop出返回地址,之後返回到呼叫函式的call指令的下一條語句。ret之後,堆疊應該變成這個樣子了
/-------------------\ Higher Address
| 引數2: 0x1100h |
+-----------------+
| 引數1: 0x8899h |
\-------------------/ LowerAddress <== stack pointer
哈
哈,問題出現了,再函式返回後堆疊出現了不平衡的情況(Stack Unwinding)。怎麼辦呢?好辦啊,直接 pop cx pop cx
把堆疊平衡過來就好了。幸好我們只有兩個引數,要是有20個的話,那就要有20個pop
cx。不說影響美觀,程式效率也會很低。所以VC++使用了這個辦法解決問題:
00401082 call @ILT+5(fun) (0040100a)
00401087 add esp,8
看紅色的語句,直接將esp的值加8,讓堆疊變成
/-------------------\ Higher Address <== stack pointer
| 引數2: 0x1100h |
+-----------------+
| 引數1: 0x8899h |
\-------------------/ Lower Address
通過改變esp從根本上解決了Stack unwinding。(push,pop指令本質上不就是通過改變esp來實現堆疊平衡的嗎) 現在,明白了函式如何傳遞引數,如何呼叫,如何返回。下一個問題就是看看函式如何傳遞返回值了。相信你早就注意到了
4: return a +b;
00401026 mov eax,dword ptr [ebp+8]
00401029 add eax,dword ptr [ebp+0Ch]
可
見,函式正式用eax暫存器來儲存返回值的。如果你想使用函式的返回值,那麼一定要在函式一返回就把eax暫存器的值讀出來。至於為什麼不用ebx,
ecx...,這個雖然沒有規定,但是習慣上大家都是用eax的。而且windows程式中也明確指出了,函式的返回值必須放入eax內。
OK,現在來解決什麼是calling
conversion這個歷史遺留問題。如果認真思考過,你一定想函式的引數為什麼偏用堆疊轉遞呢,暫存器不也可以傳遞嗎?而且很快阿。引數的傳遞順序不
一定要是由後到前的,從前到後傳遞也不會出現任何問題啊?再有為什麼一定要等到函式返回了再處理堆疊平衡的問題呢,能否在函式返回前就讓堆疊平衡呢?
所有上述提議都是絕對可行的,而他們之間不同的組合就造就了函式不同的呼叫方法。也就是你常看到或聽到的stdcall,pascal,
fastcall,WINAPI,cdecl等等。這些不同的處理函式呼叫方式就叫做callingconvention。
預設情況下C語言使用的是cdecl方式,也就是上面提到的。引數由右到左進棧,呼叫函式者處理堆疊平衡。如果你在我們剛才的程式中fun函式前加入
__stdcall,再來用上面的方法分析一下。
8: fun(0x8899,0x1100);
00401058 push 1100h ; <== 引數仍然是由右到左傳遞的
0040105D push 8899h
00401062 call fun (00401000)
;<== 這裡沒有了 add esp, 08h
1: int __stdcall fun(int a, int b) {
00401000 push ebp
00401001 mov ebp,esp
00401003 sub esp,40h
00401006 push ebx
00401007 push esi
00401008 push edi
00401009 lea edi,[ebp-40h]
0040100C mov ecx,10h
00401011 mov eax,0CCCCCCCCh
00401016 rep stos dword ptr [edi]
2: a = 0x4455;
00401018 mov dword ptr [ebp+8],4455h
3: b = 0x6677;
0040101F mov dword ptr [ebp+0Ch],6677h
4: return a + b;
00401026 mov eax,dword ptr [ebp+8]
00401029 add eax,dword ptr [ebp+0Ch]
5: }
0040102C pop edi
0040102D pop esi
0040102E pop ebx
0040102F mov esp,ebp
00401031 pop ebp
00401032 ret 8; <== ret 取出返回地址後,
; 給esp加上 8。看!堆疊平衡在函式內完成了。
; ret指令這個語法設計就是專門用來實現函式
; 內完成堆疊平衡的
於
是得出結論,stdcall是由右到左傳遞引數,被呼叫函式恢復堆疊的calling convention. 其他幾種calling
convention的修飾關鍵詞分別是__pascal,__fastcall,
WINAPI(這個要包含windows.h才可以用)。現在,你可以用上面說的方法自己分析一下他們各自的特點了。