5 函數(shù)調(diào)用約定 創(chuàng)建一個棧幀的最重要步驟是主調(diào)函數(shù)如何向棧中傳遞函數(shù)參數(shù),。主調(diào)函數(shù)必須精確存儲這些參數(shù),,以便被調(diào)函數(shù)能夠訪問到它們,。函數(shù)通過選擇特定的調(diào)用約定,,來表明其希望以特定方式接收參數(shù),。此外,,當(dāng)被調(diào)函數(shù)完成任務(wù)后,調(diào)用約定規(guī)定先前入棧的參數(shù)由主調(diào)函數(shù)還是被調(diào)函數(shù)負責(zé)清除,,以保證程序的棧頂指針完整性,。 函數(shù)調(diào)用約定通常規(guī)定如下幾方面內(nèi)容: 1) 函數(shù)參數(shù)的傳遞順序和方式 最常見的參數(shù)傳遞方式是通過堆棧傳遞。主調(diào)函數(shù)將參數(shù)壓入棧中,,被調(diào)函數(shù)以相對于幀基指針的正偏移量來訪問棧中的參數(shù),。對于有多個參數(shù)的函數(shù),調(diào)用約定需規(guī)定主調(diào)函數(shù)將參數(shù)壓棧的順序(從左至右還是從右至左),。某些調(diào)用約定允許使用寄存器傳參以提高性能,。 2) 棧的維護方式 主調(diào)函數(shù)將參數(shù)壓棧后調(diào)用被調(diào)函數(shù)體,返回時需將被壓棧的參數(shù)全部彈出,,以便將?;謴?fù)到調(diào)用前的狀態(tài)。該清棧過程可由主調(diào)函數(shù)負責(zé)完成,也可由被調(diào)函數(shù)負責(zé)完成,。 3) 名字修飾(Name-mangling)策略 又稱函數(shù)名修飾(Decorated Name)規(guī)則,。編譯器在鏈接時為區(qū)分不同函數(shù),對函數(shù)名作不同修飾,。 若函數(shù)之間的調(diào)用約定不匹配,,可能會產(chǎn)生堆棧異常或鏈接錯誤等問題,。因此,,為了保證程序能正確執(zhí)行,所有的函數(shù)調(diào)用均應(yīng)遵守一致的調(diào)用約定,。 5.1 常見調(diào)用約定 下面分別介紹常見的幾種函數(shù)調(diào)用約定,。 1. cdecl調(diào)用約定 又稱C調(diào)用約定,是C/C++編譯器默認的函數(shù)調(diào)用約定,。所有非C++成員函數(shù)和未使用stdcall或fastcall聲明的函數(shù)都默認是cdecl方式。函數(shù)參數(shù)按照從右到左的順序入棧,,函數(shù)調(diào)用者負責(zé)清除棧中的參數(shù),,返回值在EAX中。由于每次函數(shù)調(diào)用都要產(chǎn)生清除(還原)堆棧的代碼,,故使用cdecl方式編譯的程序比使用stdcall方式編譯的程序大(后者僅需在被調(diào)函數(shù)內(nèi)產(chǎn)生一份清棧代碼),。但cdecl調(diào)用方式支持可變參數(shù)函數(shù)(即函數(shù)帶有可變數(shù)目的參數(shù),如printf),,且調(diào)用時即使實參和形參數(shù)目不符也不會導(dǎo)致堆棧錯誤,。對于C函數(shù),cdecl方式的名字修飾約定是在函數(shù)名前添加一個下劃線,;對于C++函數(shù),,除非特別使用extern "C",C++函數(shù)使用不同的名字修飾方式,。 【擴展閱讀】可變參數(shù)函數(shù)支持條件 若要支持可變參數(shù)的函數(shù),,則參數(shù)應(yīng)自右向左進棧,并且由主調(diào)函數(shù)負責(zé)清除棧中的參數(shù)(參數(shù)出棧),。 首先,,參數(shù)按照從右向左的順序壓棧,則參數(shù)列表最左邊(第一個)的參數(shù)最接近棧頂位置,。所有參數(shù)距離幀基指針的偏移量都是常數(shù),,而不必關(guān)心已入棧的參數(shù)數(shù)目。只要不定的參數(shù)的數(shù)目能根據(jù)第一個已明確的參數(shù)確定,,就可使用不定參數(shù),。例如printf函數(shù),第一個參數(shù)即格式化字符串可作為后繼參數(shù)指示符,。通過它們就可得到后續(xù)參數(shù)的類型和個數(shù),,進而知道所有參數(shù)的尺寸,。當(dāng)傳遞的參數(shù)過多時,以幀基指針為基準,,獲取適當(dāng)數(shù)目的參數(shù),,其他忽略即可。若函數(shù)參數(shù)自左向右進棧,,則第一個參數(shù)距離棧幀指針的偏移量與已入棧的參數(shù)數(shù)目有關(guān),,需要計算所有參數(shù)占用的空間后才能精確定位。當(dāng)實際傳入的參數(shù)數(shù)目與函數(shù)期望接受的參數(shù)數(shù)目不同時,,偏移量計算會出錯,! 其次,調(diào)用函數(shù)將參數(shù)壓棧,,只有它才知道棧中的參數(shù)數(shù)目和尺寸,,因此調(diào)用函數(shù)可安全地清棧。而被調(diào)函數(shù)永遠也不能事先知道將要傳入函數(shù)的參數(shù)信息,,難以對棧頂指針進行調(diào)整,。 C++為兼容C,仍然支持函數(shù)帶有可變的參數(shù),。但在C++中更好的選擇常常是函數(shù)多態(tài),。 2. stdcall調(diào)用約定(微軟命名) Pascal程序缺省調(diào)用方式,WinAPI也多采用該調(diào)用約定,。stdcall調(diào)用約定主調(diào)函數(shù)參數(shù)從右向左入棧,,除指針或引用類型參數(shù)外所有參數(shù)采用傳值方式傳遞,由被調(diào)函數(shù)負責(zé)清除棧中的參數(shù),,返回值在EAX中,。stdcall調(diào)用約定僅適用于參數(shù)個數(shù)固定的函數(shù),因為被調(diào)函數(shù)清棧時無法精確獲知棧上有多少函數(shù)參數(shù),;而且如果調(diào)用時實參和形參數(shù)目不符會導(dǎo)致堆棧錯誤,。對于C函數(shù),stdcall名稱修飾方式是在函數(shù)名字前添加下劃線,,在函數(shù)名字后添加@和函數(shù)參數(shù)的大小,,如_functionname@number。 3. fastcall調(diào)用約定 stdcall調(diào)用約定的變形,,通常使用ECX和EDX寄存器傳遞前兩個DWORD(四字節(jié)雙字)類型或更少字節(jié)的函數(shù)參數(shù),,其余參數(shù)按照從右向左的順序入棧,被調(diào)函數(shù)在返回前負責(zé)清除棧中的參數(shù),,返回值在 EAX 中,。因為并不是所有的參數(shù)都有壓棧操作,所以比stdcall和cdecl快些。編譯器使用兩個@修飾函數(shù)名字,,后跟十進制數(shù)表示的函數(shù)參數(shù)列表大小(字節(jié)數(shù)),,如@function_name@number。需注意fastcall函數(shù)調(diào)用約定在不同編譯器上可能有不同的實現(xiàn),,比如16位編譯器和32位編譯器,。另外,在使用內(nèi)嵌匯編代碼時,,還應(yīng)注意不能和編譯器使用的寄存器有沖突,。 4. thiscall調(diào)用約定 C++類中的非靜態(tài)函數(shù)必須接收一個指向主調(diào)對象的類指針(this指針),并可能較頻繁的使用該指針,。主調(diào)函數(shù)的對象地址必須由調(diào)用者提供,,并在調(diào)用對象非靜態(tài)成員函數(shù)時將對象指針以參數(shù)形式傳遞給被調(diào)函數(shù)。編譯器默認使用thiscall調(diào)用約定以高效傳遞和存儲C++類的非靜態(tài)成員函數(shù)的this指針參數(shù),。 thiscall調(diào)用約定函數(shù)參數(shù)按照從右向左的順序入棧,。若參數(shù)數(shù)目固定,則類實例的this指針通過ECX寄存器傳遞給被調(diào)函數(shù),,被調(diào)函數(shù)自身清理堆棧,;若參數(shù)數(shù)目不定,則this指針在所有參數(shù)入棧后再入棧,,主調(diào)函數(shù)清理堆棧。thiscall不是C++關(guān)鍵字,,故不能使用thiscall聲明函數(shù),,它只能由編譯器使用。 注意,,該調(diào)用約定特點隨編譯器不同而不同,,g++中thiscall與cdecl基本相同,只是隱式地將this指針當(dāng)作非靜態(tài)成員函數(shù)的第1個參數(shù),,主調(diào)函數(shù)在調(diào)用返回后負責(zé)清理棧上參數(shù),;而在VC中,this指針存放在%ecx寄存器中,,參數(shù)從右至左壓棧,,非靜態(tài)成員函數(shù)負責(zé)清理棧上參數(shù)。 5. naked call調(diào)用約定 對于使用naked call方式聲明的函數(shù),,編譯器不產(chǎn)生保存(prologue)和恢復(fù)(epilogue)寄存器的代碼,,且不能用return返回返回值(只能用內(nèi)嵌匯編返回結(jié)果),故稱naked call,。該調(diào)用約定用于一些特殊場合,,如聲明處于非C/C++上下文中的函數(shù),并由程序員自行編寫初始化和清棧的內(nèi)嵌匯編指令。注意,,naked call并非類型修飾符,,故該調(diào)用約定必須與__declspec同時使用,如VC下定義求和函數(shù): 代碼示例如下(Windows采用Intel匯編語法,,注釋符為;):
注意,,__declspec是微軟關(guān)鍵字,其他系統(tǒng)上可能沒有,。 6. pascal調(diào)用約定 Pascal語言調(diào)用約定,,參數(shù)按照從左至右的順序入棧。Pascal語言只支持固定參數(shù)的函數(shù),,參數(shù)的類型和數(shù)量完全可知,,故由被調(diào)函數(shù)自身清理堆棧。pascal調(diào)用約定輸出的函數(shù)名稱無任何修飾且全部大寫,。 Win3.X(16位)時支持真正的pascal調(diào)用約定,;而Win9.X(32位)以后pascal約定由stdcall約定代替(以C約定壓棧以Pascal約定清棧)。 上述調(diào)用約定的主要特點如下表所示: Windows下可直接在函數(shù)聲明前添加關(guān)鍵字__stdcall,、__cdecl或__fastcall等標(biāo)識確定函數(shù)的調(diào)用方式,,如int __stdcall func()。Linux下可借用函數(shù)attribute 機制,,如int __attribute__((__stdcall__)) func(),。 代碼示例如下:
被調(diào)函數(shù)CalleeFunc分別聲明為cdecl、stdcall和fastcall約定時,,其匯編代碼比較如下表所示: 5.2 調(diào)用約定影響 當(dāng)函數(shù)導(dǎo)出被其他程序員所使用(如庫函數(shù))時,,該函數(shù)應(yīng)遵循主要的調(diào)用約定,以便于程序員使用,。若函數(shù)僅供內(nèi)部使用,,則其調(diào)用約定可只被使用該函數(shù)的程序所了解。 在多語言混合編程(包括A語言中使用B語言開發(fā)的第三方庫)時,,若函數(shù)的原型聲明和函數(shù)體定義不一致或調(diào)用函數(shù)時聲明了不同的函數(shù)約定,,將可能導(dǎo)致嚴重問題(如堆棧被破壞)。 以Delphi調(diào)用C函數(shù)為例,。Delphi函數(shù)缺省采用stdcall調(diào)用約定,,而C函數(shù)缺省采用cdecl調(diào)用約定。一般將C函數(shù)聲明為stdcall約定,,如:int __stdcall add(int a, int b); 在Delphi中調(diào)用該函數(shù)時也應(yīng)聲明為stdcall約定:
不同編譯器產(chǎn)生棧幀的方式不盡相同,主調(diào)函數(shù)不一定能正常完成清棧工作,;而被調(diào)函數(shù)必然能自己完成正常清棧,,因此,,在跨(開發(fā))平臺調(diào)用中,通常使用stdcall調(diào)用約定(不少WinApi均采用該約定),。 此外,,主調(diào)函數(shù)和被調(diào)函數(shù)所在模塊采用相同的調(diào)用約定,但分別使用C++和C語法編譯時,,會出現(xiàn)鏈接錯誤(報告被調(diào)函數(shù)未定義),。這是因為兩種語言的函數(shù)名字修飾規(guī)則不同,解決方式是使用extern "C"告知主調(diào)函數(shù)所在模塊:被調(diào)函數(shù)是C語言編譯的,。采用C語言編譯的庫應(yīng)考慮到使用該庫的程序可能是C++程序(使用C++編譯器),,通常應(yīng)這樣聲明頭文件:
這樣C++編譯器就會按照C語言修飾策略鏈接Func函數(shù)名,而不會出現(xiàn)找不到函數(shù)的鏈接錯誤,。 5.3 x86函數(shù)參數(shù)傳遞方法 x86處理器ABI規(guī)范中規(guī)定,,所有傳遞給被調(diào)函數(shù)的參數(shù)都通過堆棧來完成,其壓棧順序是以函數(shù)參數(shù)從右到左的順序,。當(dāng)向被調(diào)函數(shù)傳遞參數(shù)時,,所有參數(shù)最后形成一個數(shù)組。由于采用從右到左的壓棧順序,,數(shù)組中參數(shù)的順序(下標(biāo)0~N-1)與函數(shù)參數(shù)聲明順序(Para1~N)一致,。因此,在函數(shù)中若知道第一個參數(shù)地址和各參數(shù)占用字節(jié)數(shù),,就可通過訪問數(shù)組的方式去訪問每個參數(shù),。 5.3.1 整型和指針參數(shù)的傳遞 整型參數(shù)與指針參數(shù)的傳遞方式相同,因為在32位x86處理器上整型與指針大小相同(均為四字節(jié)),。下表給出這兩種類型的參數(shù)在棧幀中的位置關(guān)系,。注意,該表基于tail函數(shù)的棧幀,。 5.3.2 浮點參數(shù)的傳遞 浮點參數(shù)的傳遞與整型類似,區(qū)別在于參數(shù)大小,。x86處理器中浮點類型占8個字節(jié),,因此在棧中也需要占用8個字節(jié)。下表給出浮點參數(shù)在棧幀中的位置關(guān)系,。圖中,,調(diào)用tail函數(shù)的第一個和第三個參數(shù)均為浮點類型,因此需各占用8個字節(jié),,三個參數(shù)共占用20個字節(jié),。表中word類型的大小是4字節(jié)。 5.3.3 結(jié)構(gòu)體和聯(lián)合體參數(shù)的傳遞 結(jié)構(gòu)體和聯(lián)合體參數(shù)的傳遞與整型,、浮點參數(shù)類似,,只是其占用字節(jié)大小視數(shù)據(jù)結(jié)構(gòu)的定義不同而異,。x86處理器上棧寬是4字節(jié),故結(jié)構(gòu)體在棧上所占用的字節(jié)數(shù)為4的倍數(shù),。編譯器會對結(jié)構(gòu)體進行適當(dāng)?shù)奶畛湟允沟媒Y(jié)構(gòu)體大小滿足4字節(jié)對齊的要求,。 對于一些RISC處理器(如PowerPC),其參數(shù)傳遞并不是全部通過棧來實現(xiàn),。PowerPC處理器寄存器中,,R3~R10共8個寄存器用于傳遞整型或指針參數(shù),F(xiàn)1~F8共8個寄存器用于傳遞浮點參數(shù),。當(dāng)所需傳遞的參數(shù)少于8個時,,不需要用到棧。結(jié)構(gòu)體和long double參數(shù)的傳遞通過指針來完成,,這與x86處理器完全不同,。PowerPC的ABI規(guī)范中規(guī)定,結(jié)構(gòu)體的傳遞采用指針方式,,而不是像x86處理器那樣將結(jié)構(gòu)從一個函數(shù)棧幀中拷貝到另一個函數(shù)棧幀中,,顯然x86處理器的方式更低效??梢?,PowerPC程序中,函數(shù)參數(shù)采用指向結(jié)構(gòu)體的指針(而非結(jié)構(gòu)體)并不能提高效率,,不過通常這是良好的編程習(xí)慣,。 5.4 x86函數(shù)返回值傳遞方法 函數(shù)返回值可通過寄存器傳遞。當(dāng)被調(diào)用函數(shù)需要返回結(jié)果給調(diào)用函數(shù)時: 1) 若返回值不超過4字節(jié)(如int,、short,、char、指針等類型),,通常將其保存在EAX寄存器中,,調(diào)用方通過讀取EAX獲取返回值。 2) 若返回值大于4字節(jié)而小于8字節(jié)(如long long或_int64類型),,則通過EAX+EDX寄存器聯(lián)合返回,,其中EDX保存返回值高4字節(jié),EAX保存返回值低4字節(jié),。 3) 若返回值為浮點類型(如float和double),,則通過專用的協(xié)處理器浮點數(shù)寄存器棧的棧頂返回。 4) 若返回值為結(jié)構(gòu)體或聯(lián)合體,,則主調(diào)函數(shù)向被調(diào)函數(shù)傳遞一個額外參數(shù),,該參數(shù)指向?qū)⒁4娣祷刂档牡刂贰<春瘮?shù)調(diào)用foo(p1, p2)被轉(zhuǎn)化為foo(&p0, p1, p2),,以引用型參數(shù)形式傳回返回值,。具體步驟可能為:a.主調(diào)函數(shù)將顯式的實參逆序入棧,;b.將接收返回值的結(jié)構(gòu)體變量地址作為隱藏參數(shù)入棧(若未定義該接收變量,則在棧上額外開辟空間作為接收返回值的臨時變量),;c. 被調(diào)函數(shù)將待返回數(shù)據(jù)拷貝到隱藏參數(shù)所指向的內(nèi)存地址,,并將該地址存入%eax寄存器。因此,,在被調(diào)函數(shù)中完成返回值的賦值工作,。 注意,函數(shù)如何傳遞結(jié)構(gòu)體或聯(lián)合體返回值依賴于具體實現(xiàn),。不同編譯器,、平臺、調(diào)用約定甚至編譯參數(shù)下可能采用不同的實現(xiàn)方法,。如VC6編譯器對于不超過8字節(jié)的小結(jié)構(gòu)體,,會通過EAX+EDX寄存器返回。而對于超過8字節(jié)的大結(jié)構(gòu)體,,主調(diào)函數(shù)在棧上分配用于接收返回值的臨時結(jié)構(gòu)體,,并將地址通過棧傳遞給被調(diào)函數(shù);被調(diào)函數(shù)根據(jù)返回值地址設(shè)置返回值(拷貝操作),;調(diào)用返回后主調(diào)函數(shù)根據(jù)需要,,再將返回值賦值給需要的臨時變量(二次拷貝)。實際使用中為提高效率,,通常將結(jié)構(gòu)體指針作為實參傳遞給被調(diào)函數(shù)以接收返回值,。 5) 不要返回指向棧內(nèi)存的指針,如返回被調(diào)函數(shù)內(nèi)局部變量地址(包括局部數(shù)組名),。因為函數(shù)返回后,,其棧幀空間被“釋放”,原棧幀內(nèi)分配的局部變量空間的內(nèi)容是不穩(wěn)定和不被保證的,。 函數(shù)返回值通過寄存器傳遞,,無需空間分配等操作,故返回值的代價很低,?;诖嗽颍珻89規(guī)范中約定,,不寫明返回值類型的函數(shù),,返回值類型默認為int,。但這會帶來類型安全隱患,,如函數(shù)定義時返回值為浮點數(shù),而函數(shù)未聲明或聲明時未指明返回值類型,,則調(diào)用時默認從寄存器EAX(而不是浮點數(shù)寄存器)中獲取返回值,,導(dǎo)致錯誤,!因此在C++中,不寫明返回值類型的函數(shù)返回值類型為void,,表示不返回值,。 【擴展閱讀】GCC返回結(jié)構(gòu)體和聯(lián)合體 通常GCC被配置為使用與目標(biāo)系統(tǒng)一致的函數(shù)調(diào)用約定。這通過機器描述宏來實現(xiàn),。但是,,在一些目標(biāo)機上采用不同方式返回結(jié)構(gòu)體和聯(lián)合體的值。因此,,使用PCC編譯的返回這些類型的函數(shù)不能被使用GCC編譯的代碼調(diào)用,,反之亦然。但這并未造成麻煩,,因為很少有Unix庫函數(shù)返回結(jié)構(gòu)體或聯(lián)合體,。 GCC代碼使用存放int或double類型返回值的寄存器來返回1、2,、4或8個字節(jié)的結(jié)構(gòu)體和聯(lián)合體(GCC通常還將此類變量分配在寄存器中),。其它大小的結(jié)構(gòu)體和聯(lián)合體在返回時,將其存放在一個由調(diào)用者傳遞的地址中(通常在寄存器中),。 相比之下,,PCC在大多目標(biāo)機上返回任何大小的結(jié)構(gòu)體和聯(lián)合體時,都將數(shù)據(jù)復(fù)制到一個靜態(tài)存儲區(qū)域,,再將該地址當(dāng)作指針值返回,。調(diào)用者必須將數(shù)據(jù)從那個內(nèi)存區(qū)域復(fù)制到需要的地方。這比GCC使用的方法要慢,,而且不可重入,。 在一些目標(biāo)機上(如RISC機器和80386),標(biāo)準的系統(tǒng)約定是將返回值的地址傳給子程序,。在這些機器上,,當(dāng)使用這種約定方法時,GCC被配置為與標(biāo)準編譯器兼容,。這可能會對于1,,2,4或8字節(jié)的結(jié)構(gòu)體不兼容,。 GCC使用系統(tǒng)的標(biāo)準約定來傳遞參數(shù),。在一些機器上,前幾個參數(shù)通過寄存器傳遞,;在另一些機器上,,所有的參數(shù)都通過棧傳遞。原本可在所有機器上都使用寄存器來傳遞參數(shù),,而且此法還可能顯著提高性能,。但這樣就與使用標(biāo)準約定的代碼完全不兼容,。所以這種改變只在將GCC作為系統(tǒng)唯一的C編譯器時才實用。當(dāng)擁有一套完整的GNU 系統(tǒng),,能夠用GCC來編譯庫時,,可在特定機器上實現(xiàn)寄存器參數(shù)傳遞。 在一些機器上(特別是SPARC),,一些類型的參數(shù)通過“隱匿引用”(invisible reference)來傳遞,。這意味著值存儲在內(nèi)存中,將值的內(nèi)存地址傳給子程序,。 |
|