關於函式引數入棧的思考
程式碼開發執行環境: VS2017+Win32+Debug
1.呼叫約定簡介
首先,要實現函式呼叫,除了要知道函式的入口地址外,還要向函式傳遞合適的引數。向被調函式傳遞引數,可以有不同的方式實現。這些方式被稱為“呼叫規範”或“呼叫約定”。C/C++中常見的呼叫規範有__cdecl、__stdcall、__fastcall和__thiscall。
1.1__cdecl呼叫約定
又稱為C呼叫約定,是C/C++預設的函式呼叫約定,它的定義語法是:
int function (int a ,int b) // 不加修飾就是C呼叫約定
int __cdecl function (int a,int b) // 明確指出C呼叫約定
約定的內容有:
(1)引數入棧順序是從右向左;
(2)在被呼叫函式 (Callee) 返回後,由呼叫方 (Caller)調整堆疊。
C呼叫約定允許函式的引數個數不固定,這也是C語言的一大特色,因為每個呼叫的地方都需要生成一段清理堆疊的程式碼,所以最後生成的目標檔案較__stdcall、__fastcall呼叫方式要大。
1.2__stdcall呼叫約定
又稱為標準呼叫約定,申明語法是:
int __stdcall function(int a,int b)
約定的內容有:
(1)引數從右向左壓入堆疊;
(2)函式自身清理堆疊;
(3)函式名自動加前導下劃線,後面緊跟一個@符號,其後緊跟引數的尺寸;
(4)函式引數個數不可變。
1.3__fastcall呼叫約定
又稱為快速呼叫方式。其宣告語法為:
int __fastcall function(int a,int b);
和__stdcall類似,它約定的內容有:
(1) 函式的第一個和第二個DWORD引數(或者尺寸更小的)通過ecx和edx傳遞,其他引數通過從右向左的順序壓棧;
(2)被呼叫者清理堆疊;
(3)函式名定義規則同__stdcall。
注意:不同編譯器編譯的程式規定的暫存器不同。在Intel 386平臺上,使用ECX和EDX暫存器。使用__fastcall方式無法用作跨編譯器的介面。
1.4__thiscall呼叫約定
是唯一一個不能明確指明的函式修飾符,因為__thiscall不是關鍵字。它是C++類成員函式預設的呼叫約定。由於成員函式呼叫還有一個this指標,因此必須特殊處理,__thiscall意味著:
(1) 引數從右向左入棧;
(2) 如果引數個數確定,this指標通過ecx傳遞給被呼叫者。如果引數個數不確定,this指標在所有引數壓棧後被壓入堆疊;
(3)對引數個數不定的,呼叫者清理堆疊,否則函式自己清理堆疊。
2.cout<<++i<<- -i<< i++;輸出結果的討論
在Visual C++的函式呼叫規範中,如果函式的任何一個引數表示式包含自增(自減)運算,所有這些運算會在第一個push操作之前全部完成,然後再完成其他的運算並將結果入棧。考察如下程式。
#include <iostream>
using namespace std;
int main(int argc,char* argv[])
{
int i=10;
cout<<++i<<--i<<i++;
return 0;
}
按照“正常”思維,標準輸出操作符<<是從左向右結合的,所以應該依次計算表示式++i,–i和i++的值,那麼最終應該依次輸出11,10,和10。但是在Visual C++中執行結果是11,11和10。考察此程式的彙編程式碼,發現語句cout<<++i<<--i<<i++;
所對應的彙編程式碼是:
00EF6ED5 mov eax,dword ptr [i]
00EF6ED8 mov dword ptr [ebp-0D0h],eax //儲存i的值
00EF6EDE mov ecx,dword ptr [i]
00EF6EE1 add ecx,1 //變數i自增1
00EF6EE4 mov dword ptr [i],ecx
00EF6EE7 mov edx,dword ptr [i]
00EF6EEA sub edx,1 //變數i自減1
00EF6EED mov dword ptr [i],edx
00EF6EF0 mov eax,dword ptr [i]
00EF6EF3 add eax,1 //變數i自增1
00EF6EF6 mov dword ptr [i],eax
00EF6EF9 mov esi,esp
00EF6EFB mov ecx,dword ptr [ebp-0D0h]
00EF6F01 push ecx //將儲存的數值10入棧
00EF6F02 mov edi,esp
00EF6F04 mov edx,dword ptr [i]
00EF6F07 push edx //將變數i入棧
00EF6F08 mov ebx,esp
00EF6F0A mov eax,dword ptr [i]
00EF6F0D push eax //將變數i入棧
//獲取cout物件地址,this指標通過ecx傳遞
00EF6F0E mov ecx,dword ptr [[email protected]std@@3[email protected][email protected]@std@@@1@A (0F002E0h)]
//cout<<++i;
00EF6F14 call dword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (0F002E8h)]
00EF6F1A cmp ebx,esp
00EF6F1C call __RTC_CheckEsp (0EF12DFh)
//cout<<--i;
00EF6F21 mov ecx,eax
00EF6F23 call dword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (0F002E8h)]
00EF6F29 cmp edi,esp
00EF6F2B call __RTC_CheckEsp (0EF12DFh)
//cout<<i++;
00EF6F30 mov ecx,eax
00EF6F32 call dword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (0F002E8h)]
00EF6F38 cmp esi,esp
00EF6F3A call __RTC_CheckEsp (0EF12DFh)
這段彙編程式碼比較複雜,先解釋關鍵的地方。首先,雖然<<運算子是從左向右結合,但在<<運算子構成的鏈式操作中,各表示式的入棧順序還是從右向左,只有這樣才能實現<<運算從左向右進行。所以,先計算的是表示式i++的值。因為i自增之後無法提供入棧的值,所以另外開闢了一個記憶體單元dword ptr [ebp-0D0h]來存放第一個入棧的表示式的值。
接著計算--i
的值,自減運算完成之後,編譯器認為i的值可以直接作為引數入棧,所以並沒有開闢別的記憶體單元存放這一個入棧引數的值。
再接下來計算++i情形跟計算- -i類似。這些操作完成之後,分別將dword ptr [ebp-0D0h]處的值、最終的i和i入棧。再三次呼叫cout.operator<<函式將它們輸出。所以程式的最終結果是11,11,10。
彙編程式碼中cmp ebx,esp
和call __RTC_CheckEsp (0EF12DFh)
表示VC編譯器提供了執行時刻對程式正確性/安全性的一種動態檢查,可以在專案屬性的C++選項中開啟來啟用Runtime Check。開啟與開啟步驟如下圖:
在程式中cout.operator<<執行完後,會將物件cout的地址存放在暫存器eax中作為該函式的返回值。由於在Visual C++中,呼叫物件的成員函式之前會先將物件的地址存放在暫存器ecx中,所以在下一次呼叫cout.operator<<之前,會先將eax的值送入ecx中。
如果生成Release版本,發現輸出結果變成10,10和10。這是編譯器對程式碼所做的優化導致的結果。
從上面的程式中,我們可以看出,自增(自減)運算雖然可以使表示式更為緊湊,但很容易帶來副作用。過分追求小的技巧正式很多程式缺陷的緣由,應該編寫哪些可讀性較好的程式碼,避免那些看似簡單但蘊藏危機的表示式。
假設i的值是10,執行語句i=i++;之後,i的值是多少呢?其實,這樣的程式碼在不同的編譯器中有著不同的實現,輸出結果是不一樣的,所以,編寫這樣的程式碼沒有什麼意思,且儘量避免。
參考文獻
[1]陳剛.C++高階進階教程[M].武漢:武漢大學出版社,2008.
[2]百度百科.__stdcall
[3]百度百科.__cdecl