1. 程式人生 > >C語言函式呼叫的底層機制

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才可以用)。現在,你可以用上面說的方法自己分析一下他們各自的特點了。