从汇编层面看,C++类(class)的组织方式和结构体数据完全一致。
我们演示一个含有两个变量、两个构造函数以及一个方法的类型数据:
#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\n", v1, v2);
};
};
int main()
{
class c c1;
class c c2(5,6);
c1.dump();
c2.dump();
return 0;
};
MSVC –x86
使用MSVC编译上述程序,可得到如下所示的代码。
指令清单51.1 MSVC
_c2$ = -16 ; size = 8
_c1$ = -8 ; size = 8
_main PROC
push ebp
mov ebp, esp
sub esp, 16
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
我们来看看程序是如何实现的。程序为每个对象(类的实例)分配了8个字节内存,正好能存储2个变量。
在初始化c1时,编译器调用了无参构造函数??0c@@QAE@XZ。在初始化另一个实例(即c2)时,编译器向有参构造函数??0c@@QAE@HH@Z传递了2个参数。
在传递整个类对象(C++的术语是this)的指针时,this指针通过ECX寄存器传递给被调用方函数。这种调用规范应当符合thiscall规范,详细讲解请参阅本书的51.1.1节。
MSVC通过ECX寄存器传递this指针。不过,这种调用约定并没有统一的技术规范。GCC编译器以传递第一个函数的参数的方式传递this指针,其他的编译器多数都遵循了GCC的thiscall规范。
为什么这些函数有这些很奇怪的名字(见上面)?其实这是编译器对函数名称进行的名称改编(name mangling)的结果。
C++的类可能包含同名的但是参数不同的方法(即类成员函数)。这就是所谓的多态性。当然,不同的类可以有重名却不同的方法。
名称改编(name mangling)是一种在编译过程中,用ASCII字符串将函数、变量的名称重新改编的机制。改编后的方法(类成员函数)名称就被用作该程序内部的函数名。这完全是因为编译器的Linker和加载DLL的OS装载器均不能识别C++或OOP(面向对象的编程语言)的数据结构。
函数dump()调用了两次。我们再来看看构造函数的指令代码。
指令清单51.2 MSVC
_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
mov ecx, DWORD PTR _this$[ebp]
mov DWORD PTR [ecx+4], 999
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++的标准(ISO13, P.12.1)可知,构造函数不必返回返回值。事实上,从指令层面来来看,构造函数的返回值是一个新建立的对象的指针,即this指针。
现在我们来看看dump()。
指令清单51.3 MSVC
_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
mov esp, ebp
pop ebp
ret 0
?dump@c@@QAEXXZ ENDP ; c::dump
很简单,dump()函数从ECX寄存器读取一个指向数据结构(这个结构体含有2个int型数据)的指针,然后再把这两个整型数据传递给printf()函数。
如果指定优化编译参数/Ox的话,那么MSVC能够生成更短的可执行程序。
指令清单51.4 MSVC (优化编译)
??0c@@QAE@XZ PROC ; c::c, COMDAT
; _this$ = ecx
mov eax, ecx
mov DWORD PTR [eax], 667
mov DWORD PTR [eax+4], 999
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
ret 0
?dump@c@@QAEXXZ ENDP ; c::dump
优化编译生产的代码就这么短。我们需要注意的是:在调用构造函数之后,栈指针不是通过“add esp, x”指令到恢复其初始状态的。另一方面,构造函数的最后一条指令是指令ret 8而不是RET。
这是因为此处不仅遵循了thiscall调用规范(参见51.1.1节),而且还同时遵循stdcall调用规范(64.2节)。Stdcall规范约定:应当由被调用方函数(而不是由调用方函数)恢复参数栈的初始状态。构造函数(也是本例中的被调用方函数)使用“add ESP,x”的指令把本地栈释放x字节,然后把程序控制权传递给调用方函数。
读者还可以参考本书第64章,了解各调用规范的详细约定。
必须指出的是,编译器自身能决定调用构造函数和析构函数。我们则可以通过C++语言的编程基础找到程序中的相应指令。
MSVC-x86-64
在x86-64环境里的64位应用程序使用RCX、RDX、R8以及R9这4个寄存器传递函数的前4项参数,而其他的参数则通过栈传递。然而,在调用那些涉及类成员函数的时候,编译器会通过RCX寄存器传递类对象的this指针,用RDX寄存器传递函数的第一个参数,依此类推。我们可以在类成员函数c(int a,int b)中看到这一点。
指令清单51.5 x64下的MSVC 2012优化
; 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
mov DWORD PTR [rcx+4], 999
mov rax, rcx
ret 0
??0c@@QEAA@XZ ENDP ; c::c
64位环境下[1]的int型数据依然是32位数据。因此,上述程序仍然使用32位寄存器传递整型数据。
类成员函数dump()还使用了JMP printf指令取代了RET指令。我们在13.1.1节中已经见过这个hack了。
GCC-x86
除了个别不同之处以外,GCC 4.4.1的编译方式和MSVC 2012的编译手段几乎一样。
指令清单51.6 GCC 4.4.1
public main
main proc near
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[2]的专用风格。必须注意的是,类对象的指针是以函数的第一个参数的方式传递的。当然,编程人员看不到这些技术细节。
第一个构造函数是:
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
它通过外部传来的第一个参数获取结构体的指针,然后在相应地址修改了2个数值。
第二个构造函数是:
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
上述函数的程序逻辑与下面的C语言代码大致相当:
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\n"
call _printf
leave
retn
_ZN1c4dumpEv endp
这个函数在其内部表征中只有一个参数。这个参数就是这个对象的this指针。
本函数可以用C语言重写如下:
void ZN1c4dumpEv (int *obj)
{
printf ("%d; %d\n", *obj, *(obj+1));
};
综合本节的各例可知,MSVC和GCC的区别在于函数名的名称编码风格以及传递this指针的具体方式 (MSVC通过ECX传递,而GCC以函数的第一个参数的方式传递)。
GCC-x86-64
在编译64位应用程序的时候,GCC通过RDI、RSI、RDX、RCX、R8以及R9这几个寄存器传递函数的前6个参数。它通过RDI寄存器,以第一个函数参数的形式传递this指针。另外,整数型int数据依然是32位数据。它还会不时使用转移指令JMP替代RET指令。
指令清单51.7 x64下的GCC 4.4.6
; 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\n"
jmp printf
继承而来的类与前文的简单结构体相似,但是它可以对父类进行扩展。
我们先来看一个简单的例子:
#include <stdio.h>
class object
{
public:
int color;
object() { };
object (int color) { this->color=color; };
void print_color() { printf ("color=%d\n", color); };
};
class box : public object
{
private:
int width, height, depth;
public:
box(int color, int width, int height, int depth)
{
this->color=color;
this->width=width;
this->height=height;
this->depth=depth;
};
void dump()
{
printf ("this is box. color=%d, width=%d, height=%d, depth=%d\n", color, width, ↙
↘ height, depth);
};
};
class sphere : public object
{
private:
int radius;
public:
sphere(int color, int radius)
{
this->color=color;
this->radius=radius;
};
void dump()
{
printf ("this is sphere. color=%d, radius=%d\n", color, radius);
};
};
int main()
{
box b(1, 10, 20, 30);
sphere s(2, 40);
b.print_color();
s.print_color();
b.dump();
s.dump();
return 0;
};
我们共同关注dump()函数(又称方法)以及object::print_color()的指令代码,重点分析32位环境下有关数据类型的内存存储格局。
下面所示的是几个不同的类的dump()方法,它们是在启用优化编译选项/Ox和/Ob0后,由MSVC 2008产生的代码。
指令清单51.8 MSVC 2008带参数/Ob0的优化
??_C@_09GCEDOLPA@color?$DN?$CFd?6?$AA@ DB 'color=%d', 0aH, 00H ; `string'
?print_color@object@@QAEXXZ PROC ; object::print_color, COMDAT
; _this$ = ecx
mov eax, DWORD PTR [ecx]
push eax
; 'color=%d', 0aH, 00H
push OFFSET ??_C@_09GCEDOLPA@color?$DN?$CFd?6?$AA@
call _printf
add esp, 8
ret 0
?print_color@object@@QAEXXZ ENDP ; object::print_color
指令清单51.9 MSVC 2008带参数/Ob0的优化
?dump@box@@QAEXXZ PROC ; box::dump, COMDAT
; _this$ = ecx
mov eax, DWORD PTR [ecx+12]
mov edx, DWORD PTR [ecx+8]
push eax
mov eax, DWORD PTR [ecx+4]
mov ecx, DWORD PTR [ecx]
push edx
push eax
push ecx
; 'this is box. color=%d, width=%d, height=%d, depth=%d', 0aH, 00H ; `string'
push OFFSET ??_C@_0DG@NCNGAADL@this?5is?5box?4?5color?$DN?$CFd?0?5width?$DN?$CFd?0@
call _printf
add esp, 20
ret 0
?dump@box@@QAEXXZ ENDP ; box::dump
指令清单51.10 MSVC 2008带参数/Ob0的优化
?dump@sphere@@QAEXXZ PROC ; sphere::dump, COMDAT
; _this$ = ecx
mov eax, DWORD PTR [ecx+4]
mov ecx, DWORD PTR [ecx]
push eax
push ecx
; 'this is sphere. color=%d, radius=%d', 0aH, 00H
push OFFSET ??_C@_0CF@EFEDJLDC@this?5is?5sphere?4?5color?$DN?$CFd?0?5radius@
call _printf
add esp, 12
ret 0
?dump@sphere@@QAEXXZ ENDP ; sphere::dump
因此,这里是内存的基本排列:
① 父类object对象的存储格局如下所示。
offset |
description |
---|---|
+0x0 |
int color |