其實我知道這個標題實在是太廣泛了,但是下面所有東西都是和彙編,C++有關的。
- stdcall與cdecl
首先他們共屬於函數約定(calling convention),詳情參考wiki(https://en.wikipedia.org/wiki/Calling_convention).總結如下:
a. 兩者都是參數從右至左進棧.
b. stdcall是winapi標準默認調用方式(也就是WINAPI這個宏),cdecl是默認C調用方式.
c. stdcall在函數返回前清棧(函數自己清棧),cdecl在函數返回後清棧(由調用者清棧).所以可變參數列表只支持cdecl調用方式.
以上是瞎bb的理論階段,那麼上面所說的清棧到底是個什麼東西呢?我從C代碼層面明明沒有感覺到區別啊!
下面用vs2015彙編最簡單的函數比較一下(精華在註釋裏面):
void __stdcall func(int) {}
/*
01201690 push ebp
01201691 mov ebp,esp
01201693 sub esp,0C0h
01201699 push ebx
0120169A push esi
0120169B push edi
0120169C lea edi,[ebp-0C0h]
012016A2 mov ecx,30h
012016A7 mov eax,0CCCCCCCCh
012016AC rep stos dword ptr es:[edi]
012016AE pop edi
012016AF pop esi
012016B0 pop ebx
012016B1 mov esp,ebp
012016B3 pop ebp
// 上面代碼都可忽略,除了push,pop沒做什麼。
// 注意這裏在函數返回前用了棧平衡操作
// ret函數是爲了平衡下面012016E0 call func(0120123Fh)
// 4 纔是stdcall的精華,函數返回前平衡了012016DE push 1導致的棧變化。
012016B4 ret 4
*/
int main()
{
func(1);
//012016DE push 1
//012016E0 call func(0120123Fh)
// call函數後面沒有任何對ESP操作的代碼,因爲在函數ret之前就完成了清棧工作,這就是stdcall.
return 0;
}
同樣的我們比較一下把stdcall改爲cdecl,看下彙編代碼:
void __cdecl func(int) {}
/*
00B71690 push ebp
00B71691 mov ebp,esp
00B71693 sub esp,0C0h
00B71699 push ebx
00B7169A push esi
00B7169B push edi
00B7169C lea edi,[ebp-0C0h]
00B716A2 mov ecx,30h
00B716A7 mov eax,0CCCCCCCCh
00B716AC rep stos dword ptr es:[edi]
00B716AE pop edi
00B716AF pop esi
00B716B0 pop ebx
00B716B1 mov esp,ebp
00B716B3 pop ebp
// 同樣注意這裏,當改爲cdecl調用後,ret指令後面就沒東西了
00B716B4 ret
*/
int main()
{
func(1);
/*
00B716DE push 1
00B716E0 call func (0B71343h)
// 函數調用結束後,生成的彙編代碼函數ret之後,改變了ESP用以平衡之前的push 1操作
00B716E5 add esp,4
*/
return 0;
}
2 . C++的返回值在彙編的eax寄存器位置
void返回值類型的函數並不意味着一定不能返回參數.
void __stdcall func1()
{
__asm
{
mov eax, 10
}
}
int __stdcall func2()
{
__asm
{
mov eax, 11
}
}
int main()
{
int x=0;
__asm
{
call func1
mov x,eax
}
printf_s("x=%d func2:%d\n", x,func2());
return 0;
}
上面兩段函數,內部代碼是一樣的,雖然函數返回值定義一個是void類型,一個是int類型,但都可以傳遞值出來。因爲C/C++的返回值存在eax寄存器上。
3. 引用與指針
衆所周知,引用是變量的別名,那麼從彙編層面來看,引用時如何處理的呢?
void __stdcall f(int a,int b,int &c)
{
// c = a+b;
__asm
{
mov eax, dword ptr[a]
add eax, dword ptr[b]
mov ecx, dword ptr[c] // 取得c的地址,注意這裏用的是mov,不是lea
mov dword ptr [ecx],eax
}
}
int main()
{
int c=0;
// f(1,2,c);
__asm
{
// 從右至左壓棧,所以進棧順序時c的地址,2,1
// 最後通過call進入f函數
lea eax, [c]
push eax
push 2
push 1
call f
}
printf_s("%d", c);
return 0;
}
有趣的是,將上述代碼中f函數參數類型改爲int*代碼仍然是可以正確運行的。所以從彙編層面看,引用傳遞與指針相差無幾,都是傳遞變量地址。從C語言代碼層面看,引用傳遞時的所有操作之前地址都會被解引用。比如上面的c=a+b
。所以這也正是爲什麼引用的變量不能被賦爲空指針的原因。
4. 彙編調用printf
因爲printf允許接受可變參數,所以肯定是cdecl調用方式。其實還是亙古不變的老道理,參數由右至左壓棧,然後call函數,返回值保存在eax中。
const char* printf_str = "name:%s age:%d\n";
char name[] = "zhou";
int result = -1;
__asm
{
push 20 // age壓棧
lea eax,dword ptr[name]
push eax // name 壓棧
mov eax,dword ptr[printf_str]
push eax // 打印格式字符串壓棧
call printf
add esp,12 // cdecl自己清棧
mov result,eax // printf返回值
}
printf_s("last printf result:%d\n", result);
其中有兩點需要特殊說明:
a. 同樣的字符串操作,char*壓棧時直接push變量本身就好了(因爲指針保存的就是字符串的地址),在對char數組壓棧時,需要先lea獲取首字母地址再push。
b. printf其實是有返回值的,返回輸出字符的個數,若出錯,則返回負數。
5. c++調用實例方法
首先先區分函數(function)和方法(method)的區別: in-c-what-is-the-difference-between-a-method-and-a-function
那麼method相比於function會隱性傳入一個對象指針作爲this參數,那麼從彙編層次可以更清晰的看到這種傳參過程。(精華在註釋)
class A
{
public:
int f(int x)
{
// 此處省略部分彙編代碼
// 從ecx取出對象地址賦值給this指針
//00221850 mov dword ptr[this], ecx
return x;
//00221853 mov eax, dword ptr[x]
//00221857 pop esi
//00221858 pop ebx
//00221859 mov esp, ebp
//0022185B pop ebp
// 看起來採用stdcall的調用方式,函數內部返回前平衡棧。
//0022185C ret 4
}
};
int main()
{
A* a = new A();
a->f(10);
//002218C8 push 0Ah
//002218CA mov ecx, dword ptr[a] // 在正式調用實例方法之前將a對象地址放入了ecx中
//002218CD call A::f(02213E8h)
return 0;
}
總結:
1. 在調用實例方法之前將實例對象地址放入ecx寄存器並在函數開始將其取出賦值給this對象,這就是c++在調用method時this對象的隱性傳入方式。
在實例對象返回前完成了棧平衡操作,所以是calling convention是stdcall。