5 函數調用約定
創建一個棧幀的最重要步驟是主調函數如何向棧中傳遞函數參數。主調函數必須精確存儲這些參數,以便被調函數能夠訪問到它們。函數通過選擇特定的調用約定,來表明其希望以特定方式接收參數。此外,當被調函數完成任務後,調用約定規定先前入棧的參數由主調函數還是被調函數負責清除,以保證程序的棧頂指針完整性。
函數調用約定通常規定如下幾方面內容:
1) 函數參數的傳遞順序和方式
最常見的參數傳遞方式是通過堆棧傳遞。主調函數將參數壓入棧中,被調函數以相對於幀基指針的正偏移量來訪問棧中的參數。對於有多個參數的函數,調用約定需規定主調函數將參數壓棧的順序(從左至右還是從右至左)。某些調用約定允許使用寄存器傳參以提高性能。
2) 棧的維護方式
主調函數將參數壓棧後調用被調函數體,返回時需將被壓棧的參數全部彈出,以便將棧恢復到調用前的狀態。該清棧過程可由主調函數負責完成,也可由被調函數負責完成。
3) 名字修飾(Name-mangling)策略
又稱函數名修飾(Decorated Name)規則。編譯器在鏈接時為區分不同函數,對函數名作不同修飾。
若函數之間的調用約定不匹配,可能會產生堆棧異常或鏈接錯誤等問題。因此,為了保證程序能正確執行,所有的函數調用均應遵守一致的調用約定。
5.1 常見調用約定
下面分別介紹常見的幾種函數調用約定。
1. cdecl調用約定
又稱C調用約定,是C/C++編譯器默認的函數調用約定。所有非C++成員函數和未使用stdcall或fastcall聲明的函數都默認是cdecl方式。函數參數按照從右到左的順序入棧,函數調用者負責清除棧中的參數,返回值在EAX中。由於每次函數調用都要產生清除(還原)堆棧的代碼,故使用cdecl方式編譯的程序比使用stdcall方式編譯的程序大(後者僅需在被調函數內產生一份清棧代碼)。但cdecl調用方式支持可變參數函數(即函數帶有可變數目的參數,如printf),且調用時即使實參和形參數目不符也不會導致堆棧錯誤。對於C函數,cdecl方式的名字修飾約定是在函數名前添加一個下劃線;對於C++函數,除非特別使用extern "C",C++函數使用不同的名字修飾方式。
【擴展閱讀】可變參數函數支持條件
若要支持可變參數的函數,則參數應自右向左進棧,並且由主調函數負責清除棧中的參數(參數出棧)。
首先,參數按照從右向左的順序壓棧,則參數列表最左邊(第一個)的參數最接近棧頂位置。所有參數距離幀基指針的偏移量都是常數,而不必關心已入棧的參數數目。只要不定的參數的數目能根據第一個已明確的參數確定,就可使用不定參數。例如printf函數,第一個參數即格式化字符串可作為後繼參數指示符。通過它們就可得到後續參數的類型和個數,進而知道所有參數的尺寸。當傳遞的參數過多時,以幀基指針為基准,獲取適當數目的參數,其他忽略即可。若函數參數自左向右進棧,則第一個參數距離棧幀指針的偏移量與已入棧的參數數目有關,需要計算所有參數占用的空間後才能精確定位。當實際傳入的參數數目與函數期望接受的參數數目不同時,偏移量計算會出錯!
其次,調用函數將參數壓棧,只有它才知道棧中的參數數目和尺寸,因此調用函數可安全地清棧。而被調函數永遠也不能事先知道將要傳入函數的參數信息,難以對棧頂指針進行調整。
C++為兼容C,仍然支持函數帶有可變的參數。但在C++中更好的選擇常常是函數多態。
2. stdcall調用約定(微軟命名)
Pascal程序缺省調用方式,WinAPI也多采用該調用約定。stdcall調用約定主調函數參數從右向左入棧,除指針或引用類型參數外所有參數采用傳值方式傳遞,由被調函數負責清除棧中的參數,返回值在EAX中。stdcall調用約定僅適用於參數個數固定的函數,因為被調函數清棧時無法精確獲知棧上有多少函數參數;而且如果調用時實參和形參數目不符會導致堆棧錯誤。對於C函數,stdcall名稱修飾方式是在函數名字前添加下劃線,在函數名字後添加@和函數參數的大小,如_functionname@number。
3. fastcall調用約定
stdcall調用約定的變形,通常使用ECX和EDX寄存器傳遞前兩個DWORD(四字節雙字)類型或更少字節的函數參數,其余參數按照從右向左的順序入棧,被調函數在返回前負責清除棧中的參數,返回值在 EAX 中。因為並不是所有的參數都有壓棧操作,所以比stdcall和cdecl快些。編譯器使用兩個@修飾函數名字,後跟十進制數表示的函數參數列表大小(字節數),如@function_name@number。需注意fastcall函數調用約定在不同編譯器上可能有不同的實現,比如16位編譯器和32位編譯器。另外,在使用內嵌匯編代碼時,還應注意不能和編譯器使用的寄存器有沖突。
4. thiscall調用約定
C++類中的非靜態函數必須接收一個指向主調對象的類指針(this指針),並可能較頻繁的使用該指針。主調函數的對象地址必須由調用者提供,並在調用對象非靜態成員函數時將對象指針以參數形式傳遞給被調函數。編譯器默認使用thiscall調用約定以高效傳遞和存儲C++類的非靜態成員函數的this指針參數。
thiscall調用約定函數參數按照從右向左的順序入棧。若參數數目固定,則類實例的this指針通過ECX寄存器傳遞給被調函數,被調函數自身清理堆棧;若參數數目不定,則this指針在所有參數入棧後再入棧,主調函數清理堆棧。thiscall不是C++關鍵字,故不能使用thiscall聲明函數,它只能由編譯器使用。
注意,該調用約定特點隨編譯器不同而不同,g++中thiscall與cdecl基本相同,只是隱式地將this指針當作非靜態成員函數的第1個參數,主調函數在調用返回後負責清理棧上參數;而在VC中,this指針存放在%ecx寄存器中,參數從右至左壓棧,非靜態成員函數負責清理棧上參數。
5. naked call調用約定
對於使用naked call方式聲明的函數,編譯器不產生保存(prologue)和恢復(epilogue)寄存器的代碼,且不能用return返回返回值(只能用內嵌匯編返回結果),故稱naked call。該調用約定用於一些特殊場合,如聲明處於非C/C++上下文中的函數,並由程序員自行編寫初始化和清棧的內嵌匯編指令。注意,naked call並非類型修飾符,故該調用約定必須與__declspec同時使用,如VC下定義求和函數:
代碼示例如下(Windows采用Intel匯編語法,注釋符為;):
1 __declspec(naked) int __stdcall function(int a, int b) {
2 ;mov DestRegister, SrcImmediate(Intel) vs. movl $SrcImmediate, %DestRegister(AT&T)
3 __asm mov eax, a
4 __asm add eax, b
5 __asm ret 8
6 }
注意,__declspec是微軟關鍵字,其他系統上可能沒有。
6. pascal調用約定
Pascal語言調用約定,參數按照從左至右的順序入棧。Pascal語言只支持固定參數的函數,參數的類型和數量完全可知,故由被調函數自身清理堆棧。pascal調用約定輸出的函數名稱無任何修飾且全部大寫。
Win3.X(16位)時支持真正的pascal調用約定;而Win9.X(32位)以後pascal約定由stdcall約定代替(以C約定壓棧以Pascal約定清棧)。
上述調用約定的主要特點如下表所示:
調用方式
stdcall(Win32)
cdecl
fastcall
thiscall(C++)
naked call
參數壓棧順序
從右至左
從右至左
從右至左,Arg1在ecx,Arg2在edx
從右至左,this指針在ecx
自定義
參數位置
棧
棧
棧 + 寄存器
棧,寄存器ecx
自定義
負責清棧的函數
被調函數
主調函數
被調函數
被調函數
自定義
支持可變參數
否
是
否
否
自定義
函數名字格式
_name@number
_name
@name@number
自定義
參數表開始標識
"@@YG"
"@@YA"
"@@YI"
自定義
注:C++因支撐函數重載、命名空間和成員函數等語法特征,采用更為復雜的名字修飾策略。
C++函數修飾名以"?"開始,後面緊跟函數名、參數表開始標識和按照類型代號拼出的返回值參數表。
例如,函數int Function(char *var1,unsigned long)對應的stdcall修飾名為"?Function@@YGHPADK@Z"。
Windows下可直接在函數聲明前添加關鍵字__stdcall、__cdecl或__fastcall等標識確定函數的調用方式,如int __stdcall func()。Linux下可借用函數attribute 機制,如int __attribute__((__stdcall__)) func()。
代碼示例如下:
1 int __attribute__((__cdecl__)) CalleeFunc(int i, int j, int k){
2 // int __attribute__((__stdcall__)) CalleeFunc(int i, int j, int k){
3 //int __attribute__((__fastcall__)) CalleeFunc(int i, int j, int k){
4 return i+j+k;
5 }
6 void CallerFunc(void){
7 CalleeFunc(0x11, 0x22, 0x33);
8 }
9 int main(void){
10 CallerFunc();
11 return 0;
12 }
復制代碼
被調函數CalleeFunc分別聲明為cdecl、stdcall和fastcall約定時,其匯編代碼比較如下表所示:
cdecl
stdcall
fastcall
主調函數職責
sub $0xc,%esp
movl $0x33,0x8(%esp)
movl $0x22,0x4(%esp)
movl $0x11,(%esp)
call 8048354 <CalleeFunc>
sub $0xc,%esp
movl $0x33,0x8(%esp)
movl $0x22,0x4(%esp)
movl $0x11,(%esp)
call 8048354 <CalleeFunc>
sub $0xc,%esp
sub $0x4,%esp
movl $0x33,(%esp)
mov $0x22,%edx
mov $0x11,%ecx
call 8048354 <CalleeFunc>
sub $0x4,%esp
被調函數職責
push %ebp
mov %esp,%ebp
mov 0xc(%ebp),%eax
add 0x8(%ebp),%eax
add 0x10(%ebp),%eax
pop %ebp
ret
push %ebp
mov %esp,%ebp
mov 0xc(%ebp),%eax
add 0x8(%ebp),%eax
add 0x10(%ebp),%eax
pop %ebp
ret $0xc
//執行ret指令並清理參數占用的堆棧(棧頂指針上移參數個數*4=12個字節,以釋放壓棧的參數)
push %ebp
mov %esp,%ebp
sub $0x8,%esp
mov %ecx,0xfffffffc(%ebp)
mov %edx,0xfffffff8(%ebp)
mov 0xfffffff8(%ebp),%eax
add 0xfffffffc(%ebp),%eax
add 0x8(%ebp),%eax
leave
ret $0x4
//ret <壓棧參數字節數>。若參數不超過兩個,則ret指令不帶立即數,因為無參數被壓棧
5.2 調用約定影響
當函數導出被其他程序員所使用(如庫函數)時,該函數應遵循主要的調用約定,以便於程序員使用。若函數僅供內部使用,則其調用約定可只被使用該函數的程序所了解。
在多語言混合編程(包括A語言中使用B語言開發的第三方庫)時,若函數的原型聲明和函數體定義不一致或調用函數時聲明了不同的函數約定,將可能導致嚴重問題(如堆棧被破壞)。
以Delphi調用C函數為例。Delphi函數缺省采用stdcall調用約定,而C函數缺省采用cdecl調用約定。一般將C函數聲明為stdcall約定,如:int __stdcall add(int a, int b);
在Delphi中調用該函數時也應聲明為stdcall約定:
1 function add(a: Integer; b: Integer): Integer; stdcall; //參數類型應與DLL中的函數或過程參數類型一致,且引用時使用stdcall參數
2 external 'a.dll'; //指定被調DLL文件的路徑和名稱
不同編譯器產生棧幀的方式不盡相同,主調函數不一定能正常完成清棧工作;而被調函數必然能自己完成正常清棧,因此,在跨(開發)平台調用中,通常使用stdcall調用約定(不少WinApi均采用該約定)。
此外,主調函數和被調函數所在模塊采用相同的調用約定,但分別使用C++和C語法編譯時,會出現鏈接錯誤(報告被調函數未定義)。這是因為兩種語言的函數名字修飾規則不同,解決方式是使用extern "C"告知主調函數所在模塊:被調函數是C語言編譯的。采用C語言編譯的庫應考慮到使用該庫的程序可能是C++程序(使用C++編譯器),通常應這樣聲明頭文件:
View Code
這樣C++編譯器就會按照C語言修飾策略鏈接Func函數名,而不會出現找不到函數的鏈接錯誤。
5.3 x86函數參數傳遞方法
x86處理器ABI規范中規定,所有傳遞給被調函數的參數都通過堆棧來完成,其壓棧順序是以函數參數從右到左的順序。當向被調函數傳遞參數時,所有參數最後形成一個數組。由於采用從右到左的壓棧順序,數組中參數的順序(下標0~N-1)與函數參數聲明順序(Para1~N)一致。因此,在函數中若知道第一個參數地址和各參數占用字節數,就可通過訪問數組的方式去訪問每個參數。
5.3.1 整型和指針參數的傳遞
整型參數與指針參數的傳遞方式相同,因為在32位x86處理器上整型與指針大小相同(均為四字節)。下表給出這兩種類型的參數在棧幀中的位置關系。注意,該表基於tail函數的棧幀。
調用語句
參數
棧幀地址
tail(1, 2, 3, (void *)0);
1
8(%ebp)
2
12(%ebp)
3
16(%ebp)
(void *)0
20(%ebp)
5.3.2 浮點參數的傳遞
浮點參數的傳遞與整型類似,區別在於參數大小。x86處理器中浮點類型占8個字節,因此在棧中也需要占用8個字節。下表給出浮點參數在棧幀中的位置關系。圖中,調用tail函數的第一個和第三個參數均為浮點類型,因此需各占用8個字節,三個參數共占用20個字節。表中word類型的大小是4字節。
調用語句
參數
棧幀地址
tail(1.414, 2, 3.998e10);
word 0: 1.414
8(%ebp)
word 1: 1.414
12(%ebp)
2
16(%ebp)
word 0: 3.998e10
20(%ebp)
word 1: 3.998e10
24(%ebp)
5.3.3 結構體和聯合體參數的傳遞
結構體和聯合體參數的傳遞與整型、浮點參數類似,只是其占用字節大小視數據結構的定義不同而異。x86處理器上棧寬是4字節,故結構體在棧上所占用的字節數為4的倍數。編譯器會對結構體進行適當的填充以使得結構體大小滿足4字節對齊的要求。
對於一些RISC處理器(如PowerPC),其參數傳遞並不是全部通過棧來實現。PowerPC處理器寄存器中,R3~R10共8個寄存器用於傳遞整型或指針參數,F1~F8共8個寄存器用於傳遞浮點參數。當所需傳遞的參數少於8個時,不需要用到棧。結構體和long double參數的傳遞通過指針來完成,這與x86處理器完全不同。PowerPC的ABI規范中規定,結構體的傳遞采用指針方式,而不是像x86處理器那樣將結構從一個函數棧幀中拷貝到另一個函數棧幀中,顯然x86處理器的方式更低效。可見,PowerPC程序中,函數參數采用指向結構體的指針(而非結構體)並不能提高效率,不過通常這是良好的編程習慣。
5.4 x86函數返回值傳遞方法
函數返回值可通過寄存器傳遞。當被調用函數需要返回結果給調用函數時:
1) 若返回值不超過4字節(如int、short、char、指針等類型),通常將其保存在EAX寄存器中,調用方通過讀取EAX獲取返回值。
2) 若返回值大於4字節而小於8字節(如long long或_int64類型),則通過EAX+EDX寄存器聯合返回,其中EDX保存返回值高4字節,EAX保存返回值低4字節。
3) 若返回值為浮點類型(如float和double),則通過專用的協處理器浮點數寄存器棧的棧頂返回。
4) 若返回值為結構體或聯合體,則主調函數向被調函數傳遞一個額外參數,該參數指向將要保存返回值的地址。即函數調用foo(p1, p2)被轉化為foo(&p0, p1, p2),以引用型參數形式傳回返回值。具體步驟可能為:a.主調函數將顯式的實參逆序入棧;b.將接收返回值的結構體變量地址作為隱藏參數入棧(若未定義該接收變量,則在棧上額外開辟空間作為接收返回值的臨時變量);c. 被調函數將待返回數據拷貝到隱藏參數所指向的內存地址,並將該地址存入%eax寄存器。因此,在被調函數中完成返回值的賦值工作。
注意,函數如何傳遞結構體或聯合體返回值依賴於具體實現。不同編譯器、平台、調用約定甚至編譯參數下可能采用不同的實現方法。如VC6編譯器對於不超過8字節的小結構體,會通過EAX+EDX寄存器返回。而對於超過8字節的大結構體,主調函數在棧上分配用於接收返回值的臨時結構體,並將地址通過棧傳遞給被調函數;被調函數根據返回值地址設置返回值(拷貝操作);調用返回後主調函數根據需要,再將返回值賦值給需要的臨時變量(二次拷貝)。實際使用中為提高效率,通常將結構體指針作為實參傳遞給被調函數以接收返回值。
5) 不要返回指向棧內存的指針,如返回被調函數內局部變量地址(包括局部數組名)。因為函數返回後,其棧幀空間被“釋放”,原棧幀內分配的局部變量空間的內容是不穩定和不被保證的。
函數返回值通過寄存器傳遞,無需空間分配等操作,故返回值的代價很低。基於此原因,C89規范中約定,不寫明返回值類型的函數,返回值類型默認為int。但這會帶來類型安全隱患,如函數定義時返回值為浮點數,而函數未聲明或聲明時未指明返回值類型,則調用時默認從寄存器EAX(而不是浮點數寄存器)中獲取返回值,導致錯誤!因此在C++中,不寫明返回值類型的函數返回值類型為void,表示不返回值。
【擴展閱讀】GCC返回結構體和聯合體
通常GCC被配置為使用與目標系統一致的函數調用約定。這通過機器描述宏來實現。但是,在一些目標機上采用不同方式返回結構體和聯合體的值。因此,使用PCC編譯的返回這些類型的函數不能被使用GCC編譯的代碼調用,反之亦然。但這並未造成麻煩,因為很少有Unix庫函數返回結構體或聯合體。
GCC代碼使用存放int或double類型返回值的寄存器來返回1、2、4或8個字節的結構體和聯合體(GCC通常還將此類變量分配在寄存器中)。其它大小的結構體和聯合體在返回時,將其存放在一個由調用者傳遞的地址中(通常在寄存器中)。
相比之下,PCC在大多目標機上返回任何大小的結構體和聯合體時,都將數據復制到一個靜態存儲區域,再將該地址當作指針值返回。調用者必須將數據從那個內存區域復制到需要的地方。這比GCC使用的方法要慢,而且不可重入。
在一些目標機上(如RISC機器和80386),標准的系統約定是將返回值的地址傳給子程序。在這些機器上,當使用這種約定方法時,GCC被配置為與標准編譯器兼容。這可能會對於1,2,4或8字節的結構體不兼容。
GCC使用系統的標准約定來傳遞參數。在一些機器上,前幾個參數通過寄存器傳遞;在另一些機器上,所有的參數都通過棧傳遞。原本可在所有機器上都使用寄存器來傳遞參數,而且此法還可能顯著提高性能。但這樣就與使用標准約定的代碼完全不兼容。所以這種改變只在將GCC作為系統唯一的C編譯器時才實用。當擁有一套完整的GNU 系統,能夠用GCC來編譯庫時,可在特定機器上實現寄存器參數傳遞。
在一些機器上(特別是SPARC),一些類型的參數通過“隱匿引用”(invisible reference)來傳遞。這意味著值存儲在內存中,將值的內存地址傳給子程序。