調用約定

調用約定

在windows平臺上的C++編程中經常會看到一些__stdcall, __cdecl, WINAPI, CALLBACK等等關鍵字在函數前面,在.NET中還有__clrcall, __thiscall等關鍵字,有時加不加它們都可以,但是有時必須加上,不然編譯不過。本文要討論的就是這些關鍵字:調用約定(Calling Convention),有時也叫做“函數調用約定”或者“調用規範”。本文采用MSDN的官方翻譯:“調用約定”。

    那什麼是調用約定呢?首先讓我們看看一個函數調用到底需要經歷哪幾個過程,編譯器到底爲我們做了些什麼。
1. 把函數的參數壓棧或者儲存到寄存器
2. 跳轉到函數
3. 把函數使用到的一些寄存器壓棧
4. 執行函數
5. 處理函數返回值
6. 對於第3步中壓棧的那些寄存器,恢復它們原來的值
7. 根據不同的調用約定,清除第1步中壓棧的參數,然後返回,或者先返回然後清除。

    可以看到第6步是第3步的逆操作,而第7步是第1,2步的逆操作,調用約定主要是定義了第1,7步驟中的規則:怎麼去傳遞參數,誰負責去清除棧上的參數。

    在正式開始介紹各種調用約定之前,有必要說明一下:這些調用約定是和編譯器相關的,所以這些關鍵字前都有兩個下劃線,不同的編譯器有不同的實現。比如VC和C++ Builder對於__fastcall的定義很不一樣,以至於C++ Builder引入了__msfastcall關鍵字來和VC的__fastcall兼容。本文將要介紹的是VC的各種調用約定,文中所有的代碼在Windows 2003, Visual Studio2005中測試通過,反編譯工具使用的是VS2005和WinDbg。(代碼被編譯成debug版本。因爲在release版本中,編譯器會作代碼優化)


幾乎我們寫的每一個WINDOWS API函數都是__stdcall類型的,首先,需要了解兩者之間的區別:WINDOWS的函數調用時需要用到棧(STACK,一種先入後出的存儲結構)。當函數調用完成後,棧需要清除,這裏就是問題的關鍵,如何清除?如果我們的函數使用了_cdecl,那麼棧的清除工作是由調用者,用COM的術語來講就是客戶來完成的。這樣帶來了一個棘手的問題,不同的編譯器產生棧的方式不盡相同,那麼調用者能否正常的完成清除工作呢?答案是不能。如果使用__stdcall,上面的問題就解決了,函數自己解決清除工作。所以,在跨(開發)平臺的調用中,我們都使用__stdcall(雖然有時是以WINAPI的樣子出現)。那麼爲什麼還需要_cdecl呢?當我們遇到這樣的函數如fprintf()它的參數是可變的,不定長的,被調用者事先無法知道參數的長度,事後的清除工作也無法正常的進行,因此,這種情況我們只能使用_cdecl。到這裏我們有一個結論,如果你的程序中沒有涉及可變參數,最好使用__stdcall關鍵字。


1. __cdecl
    這個是Visual C++中最最常用的調用約定,但是在代碼裏並不常見。爲什麼呢?原因就是它太常用了,VC把它作爲了默認值,也就是說一個函數如果不聲明任何的調用約定,那這個函數用的就是__cdecl。下面兩句是等同的。
void f(int x);
void __cdecl f(int x);
現在讓我們看看編譯器到底怎麼實現這種調用約定的。假設我們現在編譯下面這段代碼:

// 調用函數f1 f1(1, 2, 3, 4); // 函數f1的實現 int __cdecl f1(int a, int b, int c, int d) { return a + b + c + d; }

編譯後的反彙編是:
;調用函數f1,4個參數分別是1,2,3和4
00401093 push 4 ;參數從右到左開始壓棧,先壓最後一個
00401095 push 3 ;第3個參數壓棧
00401097 push 2 ;第2個參數壓棧
00401099 push 1 ;第1個參數壓棧
0040109B call f1 (401005h) ;調用函數f1
004010A0 add esp,10h ;清除棧上的4個參數

;函數f1的實現
push ebp ;保存寄存器ebp
mov ebp,esp ;將當前棧指針賦值給ebp
mov eax,dword ptr [ebp+8] ;eax爲參數a
add eax,dword ptr [ebp+0Ch] ;eax = eax + 參數b
add eax,dword ptr [ebp+10h] ;eax = eax + 參數c
add eax,dword ptr [ebp+14h] ;eax = eax + 參數d
pop ebp ;恢復寄存器ebp的值
ret ;函數返回,返回值是eax

可以看到清除參數的工作是由caller(調用者,就是調用函數f1的地方)來負責。因爲我們一共有4個int的參數,每個int是 4個byte,一共16個byte,換算成16進制是10h,所以上面粗體的反彙編(add esp,10h),通過直接把esp加10h來清除4個參數。(esp是指向棧頂的寄存器)

如果上面的反彙編有困難的話,可以記住這麼一句話:__cdecl是由調用者來清除棧上的參數。


2. __stdcall 
    這個調用約定的使用也十分廣泛,這也就是爲什麼它的名字是stdcall(standard call,標準調用)。WINAPI, CALLBACK實際上都是定義成__stdcall。Windows的大多數API函數都是採用這種調用約定。

;調用函數f2,4個參數分別是1,2,3和4
push 4 ;和f1一樣
push 3 ;和f1一樣
push 2 ;和f1一樣
push 1 ;和f1一樣
call f2 (40100Ah)

;函數f2的實現
push ebp ;和f1一樣
mov ebp,esp ;和f1一樣
mov eax,dword ptr [ebp+8] ;和f1一樣
add eax,dword ptr [ebp+0Ch] ;和f1一樣
add eax,dword ptr [ebp+10h] ;和f1一樣
add eax,dword ptr [ebp+14h] ;和f1一樣
pop ebp ;和f1一樣
ret 10h ;函數返回,返回值是eax,並清除棧上的參數

    通過比較,我們可以立刻發現__stdcall和__cdecl的反彙編有兩個不同點:
    a. __stdcall函數返回的時候使用了“ret 10h”,而__cdecl使用的是“ret”,這表明__stdcall函數在返回的時候就清除了4個參數(大小爲10h),這個是函數實現部分來做的,而不是由調用者來做
    b. 正因爲函數本身已經清除了棧上的參數,調用者不需要在"call f2"之後再使用“add esp,10h”了。
    可以看到__stdcall把函數返回和清除棧上函數合二爲一,用一句“ret xxx”搞定,比__cdecl方便很多,那爲什麼不全部使用__stdcall呢?

    這是因爲 __stdcall有一個不足之處:它不能使用於那些可變參數個數的函數,比如printf, sprintf沒有辦法使用__stdcall。因爲函數本身不知道每次調用時到底有幾個參數,所以它無法確定ret後面的數字,這項工作只能讓調用者自己去做。因此類似於printf, sprintf的函數都是使用__cdecl。注意:在VS2005中,如果給可變參數個數的函數用了__stdcall關鍵字,編譯器不會報錯,但是它實際上還是按照__cdecl調用約定進行編譯,通過查看反彙編,然後和前面列出的反彙編進行比較,就會發現它用的是__cdecl。


3. __fastcall
    在VC中這種調用約定和前兩種比較起來,使用的比較少。(Borland C++的默認調用約定就是這個,但是和VC的實現有點不同。)還是讓我們先看看編譯器的工作。

// 調用函數f3 f3(1, 2, 3, 4); // 函數f3的實現 int __fastcall f3(int a, int b, int c, int d) { return a + b + c + d; }



;調用函數f3,4個參數分別是1,2,3和4,前兩個參數放在ecx和edx寄存器中,後兩個壓棧
push 4 ;參數從右到左開始壓棧,先壓第4個參數
push 3 ;第3個參數壓棧
mov edx,2 ;第2個參數放在edx寄存器中
mov ecx,1 ;第1個參數放在ecx寄存器中
call f3 (401014h) ;調用函數

;函數f3的實現
push ebp ;和f1,f2一樣
mov ebp,esp ;和f1,f2一樣
sub esp,8 ;在棧上空出8個byte的空間,用來存放兩個int的臨時變量
mov dword ptr [ebp-8],edx ;把第2個參數(edx)放到第2個變量
mov dword ptr [ebp-4],ecx ;把第1個參數(ecx)放到第1個變量
mov eax,dword ptr [ebp-4] ;eax = 第1個變量(第1個參數)
add eax,dword ptr [ebp-8] ;eax = eax + 第2個變量(第2個參數)
add eax,dword ptr [ebp+8] ;eax = eax + 參數c
add eax,dword ptr [ebp+0Ch] ;eax = eax + 參數d
mov esp,ebp ;清除臨時變量
pop ebp ;和f1,f2一樣
ret 8 ;函數返回,返回值是eax,並清除棧上的參數

從上面最後一行反彙編"ret 8"可以看到,__fastcall和__stdcall一樣,也是函數本身來清除棧上的參數,這也就意味着__fastcall也有__stdcall的缺點:不支持可變參數個數的函數。
和__stdcall不同的是,__fastcall把第一,第二個參數放到了寄存器中,而不是壓棧,因爲寄存器的讀寫速度比棧快很多,這也就是爲什麼它叫快速調用(fast call。注意:在VC中的某些情況下,__fastcall比__stdcall和__cdecl慢)。
再介紹其他調用約定之前,讓我們先回顧一下__cdecl,__stdcall和__fastcall。並且補充一些它們之前的區別(這些區別不太重要,所以上面沒有討論,只在這裏列出) 

 
__cdecl
__stdcall
__fastcall
 壓棧順序
從右到左
從右到左
從右到左,前兩個參數放在ecx, edx
誰清除棧上參數
調用者(caller)
函數(被調用者callee)
函數(被調用者callee)
默認調用約定的編譯器參數
/Gd
/Gz
/Gr
可變參數個數的函數
支持
不支持
不支持
C的函數名修飾規範Name-decoration convention
加下劃線前綴,如:_func
下劃線開頭,函數名,然後@符號,最後是參數的總byte數。如:int f(int a, double b ),名字爲_f@12
以@開頭,其他和__stdcall一樣。如:@f@12

4. 一些過時的調用約定
    __pascal, __fortran 和__syscall是三種已經過時的調用約定,MSDN的建議是使用WINAPI宏,也就是__stdcall來代替原來的PASCAL和 __far __pascal。

5. thiscall
    在VS2005之前,這種調用約定僅僅應用於C++的成員函數:把this指針存放於CX寄存器,參數從右到左壓棧,函數運行後,由函數來負責清除參數。我們前面已經討論過由函數本身來清除參數的缺點:不支持可變參數個數的函數。所以對於那些可變參數個數的成員函數,C++使用的還是__cdecl調用約定。如下面這個class 
 
 
    f()成員函數使用的是thiscall調用約定,而v()成員函數使用的是__cdecl調用約定。還有一點需要注意,在VS2005之前,thiscall不能在程序中指定,因爲它不是C++關鍵詞。
在VS2005裏,包括以後的VS版本中,__thiscall可以在託管VC程序中指定,它表明函數可以被原生代碼調用。

6. __clrcall
    看名字CLR call就知道這個調用約定和.NET Framework有關係,的確,使用__clrcall調用約定表明函數只能被託管代碼(managed code)調用。如果您有一些VC++.NET的經驗,可能會想:函數不是既可以被託管代碼調用也可以被非託管代碼(unmanaged code)調用嗎?爲什麼要指定它只能被託管代碼調用呢?
爲了解釋這個問題,必須介紹Double Thunking問題。先看下面一段代碼,然後想想運行後會打印出什麼結果。 

#include <stdio.h> struct T { T(){}; T(const T&) { printf("copy constructor\n"); } ~T() { printf("destructor\n"); } }; struct S { virtual void f(T t) {}; } s; int main() { S* pS = &s; T t; printf("BEGIN\n"); pS->f(t); printf("END\n"); }
    
    
    想好運行結果了嗎?想好後讓我們新建一個C++項目,輸入這段代碼,然後編譯運行。有一點需要注意,我們先使用“No Common Language Runtime support”編譯選項,這個選項告訴編譯器按照我們以前的C++風格(比如:VC6,非託管的C++)進行編譯。 
   


看到運行結果了嗎?

BEGIN copy constructor destructor END destructor

 請注意BEGIN和END之間的輸出:一個"copy constructor"和一個"destructor",現在讓我們改變一下剛纔那個編譯選項,改成"Common Language Runtime Support(/clr)"[1],然後再編譯,運行,看到運行結果了嗎? 
 

BEGIN copy constructor copy constructor destructor destructor END destructor
    竟然有了兩個"copy constructor"和兩個"destructor",在BEGIN和END之間明明只有一個函數調用,爲什麼會有兩個copy constructor?這個就是由Double Thunking 引起的問題[3]。(題外話:以後各位面試C++職位的時候要看清題目了,在不同的編譯環境下,有很大的不同)

    當我們使用/clr選項(不是/clr:pure)進行編譯的時候,一個託管函數(managed function),會導致編譯器生成一個託管的入口點(managed entry point)和一個原生的入口點(native entry point),這樣可以使得託管函數既可以被託管代碼調用,也可以被原生代碼調用。但是,當一個原生的入口點存在的時候,它將成爲所有調用的入口點。也就是說如果調用者是託管的,它還是會先去調用原生入口點,然後原生的入口點再去調用託管的入口點,這就意味着調用了兩次函數入口點(Double Thunking)。
爲了解決這個問題,VS2005中引入了__clrcall調用約定,它表明一個函數只能被託管代碼調用,這樣編譯器就不會生成原生的入口點了,也就不會有Double Thunking問題了。讓我們把
struct S {
virtual void f(T t) {};
} s;
改成
struct S {
virtual void __clrcall f(T t) {};
} s;
然後編譯運行,是不是就沒有Double Thunking問題了?
VC2005裏還有一個編譯選項"Pure MSIL Common Language Runtime Support (/clr:pure)",它會把所有的函數都按照__clrcall調用約定來編譯。

7. Naked 函數調用
這是VC 裏一種給高級用戶使用的調用約定,它實際上就是沒有規範,用戶可以通過內嵌彙編來實現任意想要得調用約定。由於我們平時編程時基本上不會去使用它,所以我在這裏不再展開了。可以參考[2]。
我們已經迎來了64bit時代,雖然大多數人裝的操作系統還是32bit,用的還是32bit的軟件,玩的還是32bit的遊戲,但是我們的技術知識應該開始向64bit延伸了。調用約定在64bit的CPU上有些變化,總體來說64bit的調用約定和__fastcall類似,主要通過寄存器來傳遞參數。大家可以在intel網站上得到幫助信息。
參考文獻:
[1] Common Language Runtime Compilation. http://msdn2.microsoft.com/en-us/library/k8d11d4s(VS.80).aspx
[2] Naked Function Calls. http://msdn2.microsoft.com/en-us/library/5ekezyy2(VS.80).aspx
[3] Double Thunking. http://msdn2.microsoft.com/en-us/library/ms235292(VS.80).aspx
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章