31.1
举个简单的例子。
在程序内部,C++类的表示基本和结构体一样。让我们试试这个有2个变量,2个构造函数和1个方法的类。
#include <stdio.h>
class c
{
private:
int v1;
int v2;
public:
c() // default ctor
{
v1=667;
v2=999;
};
c(int a, int b) // ctor
{
v1=a;
v2=b;
};
void dump()
{
printf ("%d; %d", v1, v2);
};
};
int main()
{
class c c1;
class c c2(5,6);
c1.dump();
c2.dump();
return 0;
};
31.1.1 MSVC-X86
这里可以看到main()函数是如何被翻译成汇编代码的:
_c2$ = -16 ; size = 8
_c1$ = -8 ; size = 8
_main PROC
push ebp
mov ebp, esp
sub esp, 16 ; 00000010H
lea ecx, DWORD PTR _c1$[ebp]
call ??0c@@QAE@XZ ; c::c
push 6
push 5
lea ecx, DWORD PTR _c2$[ebp]
call ??0c@@QAE@HH@Z ; c::c
lea ecx, DWORD PTR _c1$[ebp]
call ?dump@c@@QAEXXZ ; c::dump
lea ecx, DWORD PTR _c2$[ebp]
call ?dump@c@@QAEXXZ ; c::dump
xor eax, eax
mov esp, ebp
pop ebp
ret 0
_main ENDP
所以,发生什么了。对每个对象来说(而不是类c),会分配8个字节。这正好是2个变量存储所需的大小。 对c1来说一个默认的无参数构造函数??0c@@QAE@XZ会被调用。对c2来说另一个??0c@@QAE@HH@Z会被调用,有两个数字会被作为参数传递。 指向对象的指针(C++术语的“this”)会被通过ECX寄存器传递。这被叫做thiscall(31.1.1)–这是一个指向对象的指针传递方式。 MSVC使用ECX来传递它。无需说明的是,它并不是一个标准化的方法,其他编译器可能用其他方法,例如通过第一个函数参数,比如GCC就是这么做的。 为什么函数的名字这么奇怪?这是因为名字打碎方式的缘故。 C++类可能有多个同名的重载函数,因此,不同的类也可能有相同的函数名。 名字打碎可以把类的类名+函数名+参数类型编码到一个字符串里面,然后它就会被用作内部名称。这完全是因为编译器和DLL OS加载器都 不知道C++或者面向对象的缘故。 Dump()函数在之后被调用了2次。 让我们看看构造函数的代码。
_this$ = -4 ; size = 4
??0c@@QAE@XZ PROC ; c::c, COMDAT
; _this$ = ecx
push ebp
mov ebp, esp
push ecx
mov DWORD PTR _this$[ebp], ecx
mov eax, DWORD PTR _this$[ebp]
mov DWORD PTR [eax], 667 ; 0000029bH
mov ecx, DWORD PTR _this$[ebp]
mov DWORD PTR [ecx+4], 999 ; 000003e7H
mov eax, DWORD PTR _this$[ebp]
mov esp, ebp
pop ebp
ret 0
??0c@@QAE@XZ ENDP ; c::c
_this$ = -4 ; size = 4
_a$ = 8 ; size = 4
_b$ = 12 ; size = 4
??0c@@QAE@HH@Z PROC ; c::c, COMDAT
; _this$ = ecx
push ebp
mov ebp, esp
push ecx
mov DWORD PTR _this$[ebp], ecx
mov eax, DWORD PTR _this$[ebp]
mov ecx, DWORD PTR _a$[ebp]
mov DWORD PTR [eax], ecx
mov edx, DWORD PTR _this$[ebp]
mov eax, DWORD PTR _b$[ebp]
mov DWORD PTR [edx+4], eax
mov eax, DWORD PTR _this$[ebp]
mov esp, ebp
pop ebp
ret 8
??0c@@QAE@HH@Z ENDP ; c::c
构造函数只是函数,它们会使用ECX中存储的指向结构体的指针,然后把指针指向自己的本地变量,但是,这个操作并不是必须的。 对C++标准来说我们知道构造函数不应该返回任何值。事实上,构造函数会返回指向新创建对象的指针,比如“this”。 现在看看dump()函数:
_this$ = -4 ; size = 4
?dump@c@@QAEXXZ PROC ; c::dump, COMDAT
; _this$ = ecx
push ebp
mov ebp, esp
push ecx
mov DWORD PTR _this$[ebp], ecx
mov eax, DWORD PTR _this$[ebp]
mov ecx, DWORD PTR [eax+4]
push ecx
mov edx, DWORD PTR _this$[ebp]
mov eax, DWORD PTR [edx]
push eax
push OFFSET ??_C@_07NJBDCIEC@?$CFd?$DL?5?$CFd?6?$AA@
call _printf
add esp, 12 ; 0000000cH
mov esp, ebp
pop ebp
ret 0
?dump@c@@QAEXXZ ENDP ; c::dump
简单的可以:dump()会把带有2个int的结构体传给ecx,然后从他里面取出2个值,然后传给printf()。 如果使用/Ox优化,代码会更短。
??0c@@QAE@XZ PROC ; c::c, COMDAT
; _this$ = ecx
mov eax, ecx
mov DWORD PTR [eax], 667 ; 0000029bH
mov DWORD PTR [eax+4], 999 ; 000003e7H
ret 0
??0c@@QAE@XZ ENDP ; c::c
_a$ = 8 ; size = 4
_b$ = 12 ; size = 4
??0c@@QAE@HH@Z PROC ; c::c, COMDAT
; _this$ = ecx
mov edx, DWORD PTR _b$[esp-4]
mov eax, ecx
mov ecx, DWORD PTR _a$[esp-4]
mov DWORD PTR [eax], ecx
mov DWORD PTR [eax+4], edx
ret 8
??0c@@QAE@HH@Z ENDP ; c::c
?dump@c@@QAEXXZ PROC ; c::dump, COMDAT
; _this$ = ecx
mov eax, DWORD PTR [ecx+4]
mov ecx, DWORD PTR [ecx]
push eax
push ecx
push OFFSET ??_C@_07NJBDCIEC@?$CFd?$DL?5?$CFd?6?$AA@
call _printf
add esp, 12 ; 0000000cH
ret 0
?dump@c@@QAEXXZ ENDP ; c::dump
还要说的就是栈指针在调用add esp ,x之后并不正确。所以构造函数还需要ret 8来返回,而不是ret。 这是因为这儿调用方式是thiscall(31.1.1),这个方法会使用栈来传递参数,和stdcall对比(47.2)来看,他将为被调用者维护正确的栈,而不是调用者。Ret x指令会额外的给esp加上x,然后会把控制流交还给调用者函数。 调用转换见47章。 还有需要注意的是,编译器会决定什么时候调用构造函数什么时候调用析构函数,但是我们从c++语言基础里面已经知道调用时机了。
31.1.2 MSVC-x86-64
像我们已经知道的那样,x86-64中前4个函数参数是通过RCX/RDX/R8/R9寄存器传递的,剩余的通过栈传递。但是this是用RCX传递的 ,而第一个函数参数是从RDX开始传递的。我们可以通过c(int a, int b)这个函数看出来。
; void dump()
?dump@c@@QEAAXXZ PROC ; c::dump
mov r8d, DWORD PTR [rcx+4]
mov edx, DWORD PTR [rcx]
lea rcx, OFFSET FLAT:??_C@_07NJBDCIEC@?$CFd?$DL?5?$CFd?6?$AA@ ; ’%d; %d’
jmp printf
?dump@c@@QEAAXXZ ENDP ; c::dump
; c(int a, int b)
??0c@@QEAA@HH@Z PROC ; c::c
mov DWORD PTR [rcx], edx ; 1st argument: a
mov DWORD PTR [rcx+4], r8d ; 2nd argument: b
mov rax, rcx
ret 0
??0c@@QEAA@HH@Z ENDP ; c::c
; default ctor
??0c@@QEAA@XZ PROC ; c::c
mov DWORD PTR [rcx], 667 ; 0000029bH
mov DWORD PTR [rcx+4], 999 ; 000003e7H
mov rax, rcx
ret 0
??0c@@QEAA@XZ ENDP ; c::c
X64中,Int数据类型依然是32位的。所以这里也使用了32位寄存器部分。 我们还可以看到dump()里的JMP printf,而不是RET,这个技巧我们已经在11.1.1里面见过了。
31.1.3 GCC-x86
几乎和GCC4.4.1一样的结果,除了几个例外。
public main
main proc near ; DATA XREF: _start+17
var_20 = dword ptr -20h
var_1C = dword ptr -1Ch
var_18 = dword ptr -18h
var_10 = dword ptr -10h
var_8 = dword ptr -8
push ebp
mov ebp, esp
and esp, 0FFFFFFF0h
sub esp, 20h
lea eax, [esp+20h+var_8]
mov [esp+20h+var_20], eax
call _ZN1cC1Ev
mov [esp+20h+var_18], 6
mov [esp+20h+var_1C], 5
lea eax, [esp+20h+var_10]
mov [esp+20h+var_20], eax
call _ZN1cC1Eii
lea eax, [esp+20h+var_8]
mov [esp+20h+var_20], eax
call _ZN1c4dumpEv
lea eax, [esp+20h+var_10]
mov [esp+20h+var_20], eax
call _ZN1c4dumpEv
mov eax, 0
leave
retn
main endp
我们可以看到另一个命名破碎模式,这个GNU特殊的模式可以看到指向对象的this时针其实是作为函数的第一个参数被传入的,当然,这个对程序员来说是透明的。 第一个构造函数:
public _ZN1cC1Ev ; weak
_ZN1cC1Ev proc near ; CODE XREF: main+10
arg_0 = dword ptr 8
push ebp
mov ebp, esp
mov eax, [ebp+arg_0]
mov dword ptr [eax], 667
mov eax, [ebp+arg_0]
mov dword ptr [eax+4], 999
pop ebp
retn
_ZN1cC1Ev endp
他所做的无非就是使用第一个传来的参数写入两个数字。 第二个构造函数:
public _ZN1cC1Eii
_ZN1cC1Eii proc near
arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch
arg_8 = dword ptr 10h
push ebp
mov ebp, esp
mov eax, [ebp+arg_0]
mov edx, [ebp+arg_4]
mov [eax], edx
mov eax, [ebp+arg_0]
mov edx, [ebp+arg_8]
mov [eax+4], edx
pop ebp
retn
_ZN1cC1Eii endp
这是个函数,原型类似于:
void ZN1cC1Eii (int *obj, int a, int b)
{
*obj=a;
*(obj+1)=b;
};
这是完全可以预测到的,现在看看dump():
public _ZN1c4dumpEv
_ZN1c4dumpEv proc near
var_18 = dword ptr -18h
var_14 = dword ptr -14h
var_10 = dword ptr -10h
arg_0 = dword ptr 8
push ebp
mov ebp, esp
sub esp, 18h
mov eax, [ebp+arg_0]
mov edx, [eax+4]
mov eax, [ebp+arg_0]
mov eax, [eax]
mov [esp+18h+var_10], edx
mov [esp+18h+var_14], eax
mov [esp+18h+var_18], offset aDD ; "%d; %d
"
call _printf
leave
retn
_ZN1c4dumpEv endp
在这个函数的内部表达中有一个单独的参数,被用作指向当前对象,也即this。 因此,如果从这些简单的例子来看,MSVC和GCC的区别也就只有函数名编码的区别和传入this指针的区别(ECX寄存器或通过第一个参数)。
31.1.14 GCC-X86-64
前6个参数,会通过RDI/RSI/RDX/RCX/R8/R9[21章]的顺序传递,this指针会通过第一个RDI来传递,我们可以接着看到。 Int数据类型也是一个32位的数据,JMP替换RET的技巧这里也用到了。
; default ctor
_ZN1cC2Ev:
mov DWORD PTR [rdi], 667
mov DWORD PTR [rdi+4], 999
ret
; c(int a, int b)
_ZN1cC2Eii:
mov DWORD PTR [rdi], esi
mov DWORD PTR [rdi+4], edx
ret
; dump()
_ZN1c4dumpEv:
mov edx, DWORD PTR [rdi+4]
mov esi, DWORD PTR [rdi]
xor eax, eax
mov edi, OFFSET FLAT:.LC0 ; "%d; %d
"
jmp printf