函數調用約定與 call 指令雜談

首先本文關於函數調用約定部分來自轉載和整理,參考文章:
C/C++函數調用約定
函數調用約定解析

一:函數調用約定;

函數調用約定是函數調用者和被調用的函數體之間關於參數傳遞、返回值傳遞、堆棧清除、寄存器使用的一種約定;
它是需要二進制級別兼容的強約定,函數調用者和函數體如果使用不同的調用約定,將可能造成程序執行錯誤,必須把它看作是函數聲明的一部分;

二:常見的函數調用約定;
VC6中的函數調用約定;
    調用約定        堆棧清除    參數傳遞
    __cdecl         調用者      從右到左,通過堆棧傳遞
    __stdcall       函數體      從右到左,通過堆棧傳遞
    __fastcall      函數體      從右到左,優先使用寄存器(ECX,EDX),然後使用堆棧
    thiscall        函數體      this指針默認通過ECX傳遞,其它參數從右到左入棧

__cdecl是C/C++的默認調用約定; VC的調用約定中並沒有thiscall這個關鍵字,它是類成員函數默認調用約定;(後來的VC 版本是可以使用這個關鍵字的
C/C++中的main(或wmain)函數的調用約定必須是__cdecl,不允許更改;
默認調用約定一般能夠通過編譯器設置進行更改,如果你的代碼依賴於調用約定,請明確指出需要使用的調用約定;

Delphi6中的函數調用約定;
    調用約定        堆棧清除    參數傳遞
    register        函數體      從左到右,優先使用寄存器(EAX,EDX,ECX),然後使用堆棧
    pascal          函數體      從左到右,通過堆棧傳遞
    cdecl           調用者      從右到左,通過堆棧傳遞(與C/C++默認調用約定兼容)
    stdcall         函數體      從右到左,通過堆棧傳遞(與VC中的__stdcall兼容)
    safecall        函數體      從右到左,通過堆棧傳遞(同stdcall)

Delphi中的默認調用約定是register,它也是我認爲最有效率的一種調用方式,而cdecl是我認爲綜合效率最差的一種調用方式;
VC中的__fastcall調用約定一般比register效率稍差一些;

C++Builder6中的函數調用約定;
    調用約定        堆棧清除    參數傳遞
    __fastcall      函數體      從左到右,優先使用寄存器(EAX,EDX,ECX),然後使用堆棧 (兼容Delphi的register)
    (register與__fastcall等同)
    __pascal        函數體      從左到右,通過堆棧傳遞
    __cdecl         調用者      從右到左,通過堆棧傳遞(與C/C++默認調用約定兼容)
    __stdcall       函數體      從右到左,通過堆棧傳遞(與VC中的__stdcall兼容)
    __msfastcall    函數體      從右到左,優先使用寄存器(ECX,EDX),然後使用堆棧(兼容VC的__fastcall)

常見的函數調用約定中,只有cdecl約定需要調用者來清除堆棧;
C/C++中的函數支持參數數目不定的參數列表,比如printf函數;由於函數體不知道調用者在堆棧中壓入了多少參數,
所以函數體不能方便的知道應該怎樣清除堆棧,那麼最好的辦法就是把清除堆棧的責任交給調用者;
這應該就是cdecl調用約定存在的原因吧;

VB一般使用的是stdcall調用約定;(ps:有更強的保證嗎)
Windows的API中,一般使用的是stdcall約定;(ps: 有更強的保證嗎)
建議在不同語言間的調用中(如DLL)最好採用stdcall調用約定,因爲它在語言間兼容性支持最好;

三:函數返回值傳遞方式

其實,返回值的傳遞從處理上也可以想象爲函數調用的一個out形參數; 函數返回值傳遞方式也是函數調用約定的一部分;
有返回值的函數返回時:一般int、指針等32bit數據值(包括32bit結構)通過eax傳遞,(bool,char通過al傳遞,short通過ax傳遞),特別的__int64等64bit結構(struct) 通過edx,eax兩個寄存器來傳遞(同理:32bit整形在16bit環境中通過dx,ax傳遞); 其他大小的結構(struct)返回時把其地址通過eax返回;(所以返回值類型不是1,2,4,8byte時,效率可能比較差)
參數和返回值傳遞中,引用方式的類型可以看作與傳遞指針方式相同;
float/double(包括Delphi中的extended)都是通過浮點寄存器st(0)返回;

========================================================================
本文環境使用 VC 2013 release win32 編譯,禁用優化,非特別說明都是這個環境。
debug release,是否優化,win32 和 x64 選項都對彙編有影響,一定要注意。
先看整個代碼:

int __stdcall FunStdcall(int a, int b, int c)
{
	return a + b + c;
}

int __cdecl FunCdecl(int a, int b, int c)
{
	return a + b + c;
}

int __fastcall FunFastcall(int a, int b, int c)
{
	return a + b + c;
}

int __vectorcall FunVectorcall(int a, int b, int c)
{
	return a + b + c;
}

int _tmain(int argc, _TCHAR* argv[])
{
	if (1)
	{
		int a = FunStdcall(1, 2, 3);
		int b = FunCdecl(1, 2, 3);
		int c = FunFastcall(1, 2, 3);
		int d = FunVectorcall(1, 2, 3);
		if (a < b)
		{
			return 1;
		}
	}
}

在我的VC 版本上發現了這四種約定, thiscall 不討論。

StdCall

在這裏插入圖片描述
首先看 stdcall 調用部分,也就是 A函數調用B函數中的A。
首先參數按照從右到左的順序依次 push 壓入棧中,然後調用函數,調用結束後將 eax 的值拷貝給指針 ebp-4 處。可以猜測 ebp - 4 就是變量 a 的地址。
不妨就在這裏直接看一下,在監視器裏面看看:
在這裏插入圖片描述
顯然,ebp-4 就是 a 的地址,那麼誰能保證函數執行完的時候他們依然相等呢?
可以說,這就是函數調用約定的一部分。只有約定俗成,達成規範,才能保證這一點。
上圖中也可以看到 esp 的值是 0xa30(前面部分不關心,除非剛需要關心)。執行三次 push看看:
在這裏插入圖片描述
發現 esp 變成了 0xa24,比 0xa30 減小了0xC(注意是16進制)。也正是三個 int 的大小。
也就是棧的變化是從大到小,壓棧會導致棧頂地址減小。記住現在的大小 0xa24
接下來就是 call 了。
在這裏插入圖片描述
對比之前的圖,發現 a esp 都變化了 ebp 沒變。
a 變化的原因是這裏的a 是函數裏面,也就是參數 a,而不是之前的a了。同時會發現這裏的a 的地址就是之前提到的 esp !
因爲 push c push b puch a 三個指令執行後,當然 a 地址就是 esp。(這裏 abc 說的是三個參數)。
因此這裏解釋了爲什麼 a會變化並且恰好就是之前的 esp。
至於爲什麼 esp 會變化?參考:
http://www.360doc.com/content/15/0602/00/12129652_474998519.shtml

也就是說,如果當次函數調用是段內偏移,Call Func 等價於:
push eip
jump Func
因此導致 esp 變化,減小了 4。現在驗證一下,也就是這時候棧頂存的應該就是 eip。
在這裏插入圖片描述
可以看到 eip = 0xeb38a0。
esp = 0x 5dfa20。跳轉到這個內存地址看看:
在這裏插入圖片描述
看到內容是 0xeb38fa 和 0xeb38a0 很像,爲什麼不一樣???再仔細想一想,往回看一下:
在這裏插入圖片描述
可以看到 地址 0xeb38a0 其實是被調用函數的地址,而 0xeb38fa 是 call 之後代碼的地址!而打斷點的時候已經進入函數內部了,這時候的 eip (指令指針)已經變化了,隨意他們不一致。
所以這裏代碼可以猜測一下應該是:
push 3;
push 2;
push 1;
push eip;// eip = 0xeb38fa
jmp 0xeb38a0;
// 執行函數

// 將 eax 的值給 a
mov dword ptr [ebp-4],eax

進入函數後執行兩條指令:
在這裏插入圖片描述
這裏是把 ebp 壓棧保存,然後將最新的 esp 當作 ebp。
接下來三條指令就是做加法了,等效於: eax = a + b + c。
等等,這個 abc 哪裏來的?之前 push 3 push 2 push 1 有啥用? 上面兩條有關 ebp 的指令有啥用?
看圖:
在這裏插入圖片描述
可以看到這裏有個選項,再右上角,有個顯示符號名。也就是 abc 其實是方便我們查看的,並不是本身就是 abc。如果不勾選:變成了 ebp + 8 (0ch/10h)了。回顧整理之前的代碼:
push 3;
push 2;
push 1;
push eip;// eip = 0xeb38fa
jmp 0xeb38a0;
// 執行函數
push ebp;
mov ebp, esp;
eax = a + b + c;// 這裏是用意思代表實際操作

pop ebp;
ret 0ch;
// 將 eax 的值給 a
mov dword ptr [ebp-4],eax
可以看到當前的棧頂 esp 被 ebp保存了。從棧頂往下的數據分別是:
原來ebp的值 原來eip的值 1 2 3。
因此 ebp + 8/0ch/10h 分別就是 1 2 3。
而 ebp 的一個作用也正是方便去使用參數。

接下來看只有2 條指令:
pop ebp;
ret 0ch;
參考之前的入棧操作:
push 3;
push 2;
push 1;
push eip;// eip = 0xeb38fa
push ebp;
除了做了一條 ebp 的逆操作,無法還原棧!
顯然重點就在 ret 0Ch這裏了。
看幾張圖:
在這裏插入圖片描述
在這裏插入圖片描述
可以看到 pop ebp 前後導致 esp 增加了 4。下一句指令就是 ret 0ch了,先記住當前 esp 的值:0x5dfa20
執行語句後:
在這裏插入圖片描述
可以看到 esp 變成l 0x5dfa30。剛好恢復到了 push 3 之前的狀態。
也就是這一條指令做了很多事情,參考:
http://www.360doc.com/content/15/0602/00/12129652_474998519.shtml
其實就是:
pop eip;
pop 3;
pop 2;
pop 1;
// 注意這裏的 pop 3 只是一種邏輯意義,也就是彈出之前壓入的三個參數。
ret 0ch。稍微準確點是:
pop eip;
add esp 0ch;
看當前狀態:
在這裏插入圖片描述
執行一條語句:
在這裏插入圖片描述
可以看到 確實把 eax 的值給 a了,變成了6。本函數完成!

有了上面的一個詳細的分析,可以大概看一下調用約定的一些差異了:
在這裏插入圖片描述
圖中框出來的是調用方對四次調用的相關代碼,可以看到 push 參數 3 2 1。順序都是一樣的。
也就是最開始說的從右到左的順序。
push 完之後都是一句 call 。再款選的最後都是 mov xxx eax;其實就是對應的四條賦值語句。
同時看到 stdcall cdecl 都是 push 3 2 1。三個參數直接入棧。
而 fastcall vectorcall 是把第1 2 個參數分別放到了 ecx edx,第三個參數入棧。
同時看到 cdecl 是調用者做的平衡堆棧:
add esp, 0ch。
猜想:其他都是被調用者做的:
在這裏插入圖片描述
在這裏插入圖片描述
可以看到 cdecl 函數裏面是一句 ret。而其他的是 ret 0ch ret 4(這後面的數據是根據入棧參數個數而定的)。同時發現沒有 vectorcall 對應的函數彙編!因爲他和 fastcall 是一個東西,至少這裏是這樣的:
在這裏插入圖片描述
接下來對比一下,用一個 FunStdcall(double, double, double)測試一下:
在這裏插入圖片描述
可以看到執行了三次圖中方框中的類似操作,不難猜測,這其實就是三次:
push double。至少 double 不能直接 push 而已。爲例驗證,我們看到最後一次是邏輯意義:
push ds:[4108F8h]。也就是 push 第一個參數 push 1.0f。
觀察監視發現:
*(double*)0x4108F8 確實就是 1.0f
同樣*(double*)esp 是 1.0f。因爲剛好把 1.0f 入棧了。

本文只粗略提供一些思路,也是備忘。後面再深入學習了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章