|
這是一篇介紹C語言中的函數(shù)調(diào)用是如何用實現(xiàn)的文章。寫給那些對C語言各種行為的底層實現(xiàn)感興趣人的入門級文章。如果你是C語言或者匯編、底層技術(shù)
的老鳥或是對這個問題不感興趣,那么這篇文章只會耽誤您的時間,您大可不必閱讀他。當(dāng)然如果前輩們愿意為我指出不足,我將十分感謝您的指導(dǎo),并對耽誤您寶
貴的時間致歉。
好了,廢話少說!要研究這個問題,讓我們先打開VC++吧。最好是6.0的,:-P。(什么你沒有VC++,倒!....趕快裝一個!@#$,要快!)
首先,讓我們在VC++里建立一個Win32 Console Application項目,并建立主文件fun.c。并輸入以下內(nèi)容。
int fun(int a, int b) {
a = 0x4455;
b = 0x6677;
return a + b;
}
int main() {
fun(0x8899,0x1100);
return 0;
}之
后,最關(guān)鍵的是在項目設(shè)置里關(guān)閉優(yōu)化功能。也就是把Project->Setting->C/C++->Optimizations選
為Disabled。編譯器的優(yōu)化在分析底層實現(xiàn)時大多數(shù)情況不太受歡迎。 按鍵盤上的F10鍵,進(jìn)入單步調(diào)試模式(Step
Over)。看到你的main函數(shù)左側(cè)有個黃色的小箭頭了嗎?那個就是程序即將執(zhí)行的語句。按Alt +
8。打開反編譯窗口,看到匯編語句了嗎?是不是想這個樣子 ==> 00401078 push 1100h
0040107D push 8899h
00401082 call @ILT+5(fun) (0040100a)
00401087 add esp,8看
到兩個PUSH指令了嗎?再看看后面的數(shù)字,不正是我們要傳遞的參數(shù)嗎。奇怪阿?我們明明是先傳遞的0x8899怎么反倒先push
1100h呢?呵呵,這個現(xiàn)象就叫Calling
conversion。究竟是何方神圣,我在后面會詳細(xì)的給你解釋的。先別著急。隨后的Call指令的作用就是開始調(diào)用函數(shù)了。
接下來關(guān)掉反匯編窗口,在源代碼窗口按F11(Step
Into)進(jìn)入函數(shù)體。當(dāng)看到那個黃色的小箭頭指向函數(shù)名的時候再調(diào)出反匯編窗口(Alt+8)。你會看到類似下面的代碼: 1: int 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 retVC++就是好,還在難懂的匯編語句前加入了C語言的源代碼。不過同時也有不少我們不需要的代碼。因此,你只需要關(guān)心紅色的部分就可以了。
奇怪阿?不是參數(shù)都用push傳遞了嗎?怎么沒看到被pop出來?問題其實是這樣,當(dāng)你調(diào)用Call進(jìn)入函數(shù)的時候Call背著你做了一件事。call把
它下一條語句的地址push進(jìn)了堆棧。(旁人:
什么!這是為什么?)原因很簡單,因為函數(shù)調(diào)用完了,要用ret返回。而ret怎么知道返回哪里呢?對了,
ret指令pop了call指令push給他的地址(搞清楚這個關(guān)系哦),然后返回到了這個地址。call和ret配合的如此絕妙,一個PUSH一個
POP肯定不會讓堆棧不平衡的(老外叫no stack unwinding)。現(xiàn)在明白了,如果你來個pop
eax,那eax里面是什么?當(dāng)然是ret要用的返回地址了。好啦,你要是pop
eax就等于搶了ret要用的東西了。不論曾程序流程和道德標(biāo)準(zhǔn)上你做的都不對 :-P。
可是怎么在函數(shù)體里使用參數(shù)呢?問題其實并不難,既然參數(shù)在堆棧里我們就可以使用esp(堆棧指針)來訪問了。不過,我相信你也想到了。esp是個經(jīng)常變
化的值。一旦,函數(shù)里出現(xiàn)pop或push他就會變化。這樣很不容易定位參數(shù)的于內(nèi)存中的位置。因此,我們需要一個不會變化的東西作為訪問參數(shù)的基準(zhǔn)。看
看函數(shù)體的開頭部分: 00401000 push ebp
00401001 mov ebp,esp先
用push ebp保存了原來ebp的值再把esp的值給ebp。原來ebp就是用來做基準(zhǔn)的。也難怪他被稱為ebp(Base
Pointer)。很自然ret返回前的pop
ebp就是恢復(fù)原來ebp的數(shù)值嘍。當(dāng)然一定要恢復(fù),因為函數(shù)里也可以調(diào)用函數(shù)嘛。每個函數(shù)都用ebp,自然要保證使用完后完璧歸趙了。現(xiàn)在當(dāng)函數(shù)執(zhí)行到
mov ebp, esp后堆棧應(yīng)該變成這個樣子了。 /-------------------\ Higher Address
| 參數(shù)2: 0x1100h |
+-----------------+
| 參數(shù)1: 0x8899h |
+-----------------+
| 函數(shù)返回地址 |
| 0x00401087 |
+-----------------+
| ebp |
\-------------------/ Lower Address <== stack pointer
& ebp all point to here, now由
于我們在VC++上使用的int類型是一個32位類型,ebp和函數(shù)返回值也是32位的。因此每個量要占去4個字節(jié)。另外還需要注意堆棧的擴展方向是高地
址到低地址。有了這些指示。我們就可以分析出,第一個參數(shù)的地址是ebp + 08h,第二個參數(shù)就是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原來的數(shù)值完璧歸趙,調(diào)用ret指令,ret指令pop出返回地址,之后返回到調(diào)用函數(shù)的call指令的下一條語句。ret之后,堆棧應(yīng)該變成這個樣子了 /-------------------\ Higher Address
| 參數(shù)2: 0x1100h |
+-----------------+
| 參數(shù)1: 0x8899h |
\-------------------/ Lower Address <== stack pointer哈
哈,問題出現(xiàn)了,再函數(shù)返回后堆棧出現(xiàn)了不平衡的情況(Stack Unwinding)。怎么辦呢?好辦啊,直接 pop cx pop cx
把堆棧平衡過來就好了。幸好我們只有兩個參數(shù),要是有20個的話,那就要有20個pop
cx。不說影響美觀,程序效率也會很低。所以VC++使用了這個辦法解決問題: 00401082 call @ILT+5(fun) (0040100a)
00401087 add esp,8看紅色的語句,直接將esp的值加8,讓堆棧變成 /-------------------\ Higher Address <== stack pointer
| 參數(shù)2: 0x1100h |
+-----------------+
| 參數(shù)1: 0x8899h |
\-------------------/ Lower Address通過改變esp從根本上解決了Stack unwinding。(push,pop指令本質(zhì)上不就是通過改變esp來實現(xiàn)堆棧平衡的嗎) 現(xiàn)在,明白了函數(shù)如何傳遞參數(shù),如何調(diào)用,如何返回。下一個問題就是看看函數(shù)如何傳遞返回值了。相信你早就注意到了 4: return a + b;
00401026 mov eax,dword ptr [ebp+8]
00401029 add eax,dword ptr [ebp+0Ch]可
見,函數(shù)正式用eax寄存器來保存返回值的。如果你想使用函數(shù)的返回值,那么一定要在函數(shù)一返回就把eax寄存器的值讀出來。至于為什么不用ebx,
ecx...,這個雖然沒有規(guī)定,但是習(xí)慣上大家都是用eax的。而且windows程序中也明確指出了,函數(shù)的返回值必須放入eax內(nèi)。
OK,現(xiàn)在來解決什么是calling
conversion這個歷史遺留問題。如果認(rèn)真思考過,你一定想函數(shù)的參數(shù)為什么偏用堆棧轉(zhuǎn)遞呢,寄存器不也可以傳遞嗎?而且很快阿。參數(shù)的傳遞順序不
一定要是由后到前的,從前到后傳遞也不會出現(xiàn)任何問題啊?再有為什么一定要等到函數(shù)返回了再處理堆棧平衡的問題呢,能否在函數(shù)返回前就讓堆棧平衡呢?
所有上述提議都是絕對可行的,而他們之間不同的組合就造就了函數(shù)不同的調(diào)用方法。也就是你常看到或聽到的stdcall,pascal,
fastcall,WINAPI,cdecl等等。這些不同的處理函數(shù)調(diào)用方式就叫做calling convention。
默認(rèn)情況下C語言使用的是cdecl方式,也就是上面提到的。參數(shù)由右到左進(jìn)棧,調(diào)用函數(shù)者處理堆棧平衡。如果你在我們剛才的程序中fun函數(shù)前加入
__stdcall,再來用上面的方法分析一下。 8: fun(0x8899,0x1100);
00401058 push 1100h ; <== 參數(shù)仍然是由右到左傳遞的
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。看!堆棧平衡在函數(shù)內(nèi)完成了。
; ret指令這個語法設(shè)計就是專門用來實現(xiàn)函數(shù)
; 內(nèi)完成堆棧平衡的于
是得出結(jié)論,stdcall是由右到左傳遞參數(shù),被調(diào)用函數(shù)恢復(fù)堆棧的calling convention. 其他幾種calling
convention的修飾關(guān)鍵詞分別是__pascal,__fastcall,
WINAPI(這個要包含windows.h才可以用)。現(xiàn)在,你可以用上面說的方法自己分析一下他們各自的特點了。
|
|