其实我知道这个标题实在是太广泛了,但是下面所有东西都是和汇编,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。