第51章 C++

51.1 类

51.1.1 一个简单的例子

从汇编层面看,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
51.1.2 类继承

继承而来的类与前文的简单结构体相似,但是它可以对父类进行扩展。

我们先来看一个简单的例子:

#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