C++对象在内存中的布局(读汇编代码)

时间:2021-04-09 14:54:53


1.研究方法以及ARM调用规范

最近在对C++编写的SO库进行逆向,如果掌握了对象的布局,那么逆向也能轻松些,所以萌发了研究对象布局的想法。

本文采用的研究方法是:编写C++代码,用gcc编译。通过IDA查看编译后的代码,分析ARM汇编代码,总结出内存布局。由于能力有限,本文研究的情形还是比较简单的。如果想深入研究的话,要读一读《深入C++对象模型》了。

在进行具体分析之前,先大致总结下ARM中的调用规范:

1、函数的参数分别放到R0~R3中,如果这四个寄存器无法容纳所有的参数,则剩余的参数放到堆栈中。参数放置时还要注意另外两个问题:

①参数对齐问题。如果第一个参数为int型(32位,ARM中按4字节对齐),第二个参数为long long型(64位,ARM中按8字节对齐),则参数放置结果为:第一参数放到R0,第二个参数放到R2~R3中,R1被空下了。

②精度提升问题。在可变参数中,char类型被提升为int,float类型被提升为double,在ARM中double要按8字节对齐。

2、函数的返回值放到R0中(32位的返回值),或者R0~R1中(64的返回值)。有一个例外是软浮点库中的__aeabi_ldivmod函数,该函数对两个长整形(longlong)进行除法以及求余操作,两数相除的商放到R0~R1中,而两数相除的余数即模放到R2~R3中。类似的还有__aeabi_idivmod函数,该函数对两个整数进行操作。但这与ARM的调用规范无关,只要软浮点库的调用者与实现者约定好就可以了。

3、子函数通过BL或BLX指令调用,BL或BLX会将函数的返回地址放置到LR寄存器中,函数运行结束时通过LR返回。

2.成员函数与静态成员函数

2.1 C++代码如下

class ClassLayout
{
public:
  //构造函数
  ClassLayout()
  {
      printf("Constructor!\n");
      mIValue = 0;
       mFValue = 1.0;
  }
   //非虚成员函数
  int PrintMember()
  {
      printf("%d -%f\n", mIValue, mFValue);
       return 0;
  }
   //静态成员函数
   static voidSetAndPrintStaticValue(int value)
    {
        //静态成员函数中只能引用静态成员变量
        mIStaticValue = value;
       printf("%d\n", mIStaticValue);
    }
public:
    //内置类型成员变量
    int mIValue;
    float mFValue;
    
    //静态成员变量
    static intmIStaticValue;    
};
//静态成员变量初始化
int ClassLayout::mIStaticValue = 0;
 
void TestInvoke()
{
    ClassLayout *layout = newClassLayout();
    layout->PrintMember();   
    //通过类名调用静态成员变量
   ClassLayout::SetAndPrintStaticValue(0);   
    delete layout;
}

对C++代码的说明如下:

1、静态成员变量

在类中,静态成员可以实现多个对象之间的数据共享,因此,静态成员是类的所有对象*享的成员,而不是某个对象的成员。同样,静态成员变量不能在类的构造函数中初始化,要在类的实现体外初始化。

2、静态成员函数

静态成员函数和静态数据成员一样,它们都属于类的静态成员,它们都不是对象成员。可以直接通过类名调用,而不依赖类对象。在静态成员函数的实现中不能直接引用类中声明的非静态成员,可以引用类中声明的静态成员

2.2 编译后的代码如下(省略掉成员函数的汇编代码)

; 构造函数
; ClassLayout::ClassLayout(void)
_ZN11ClassLayoutC2Ev
var_C           = -0xC
var_4           = -4
    STR     LR, [SP,#var_4]!
    SUB     SP, SP, #0xC
    ;R0即构造函数的入参,指向本对象的起始地址,这里将R0暂存到栈中
    STR     R0, [SP,#0x10+var_C]
    ;printf("Constructor!\n")     LDR     R3, =(aConstructor - 0x1040)
    ADD     R3, PC, R3      ; "Constructor!"
    MOV     R0, R3          ; s
    BL      puts
    ;mIValue = 0     LDR     R3, [SP,#0x10+var_C]
    MOV     R2, #0
    STR     R2, [R3]
    ;mFValue = 1.0     LDR     R3, [SP,#0x10+var_C]
    LDR     R2, =0x3F800000
    STR     R2, [R3,#4]
    LDR     R3, [SP,#0x10+var_C]
    MOV     R0, R3   ;构造函数的返回值就是构造好的对象的基址
    ADD     SP, SP, #0xC
    LDMFD   SP!, {PC}
; End of function ClassLayout::ClassLayout(void)
 
; =============== S U B R O U T I N E=======================================
;成员函数
;ClassLayout::PrintMember(void) 
_ZN11ClassLayout11PrintMemberEv
    ;这里省略
 
; =============== S U B R O U T I N E=======================================
;成员函数
; ClassLayout::SetAndPrintStaticValue(int)
_ZN11ClassLayout22SetAndPrintStaticValueEi
    ;这里省略
 
; =============== S U B R O U T I N E =======================================
; 全局函数
; TestInvoke(void)
_Z10TestInvokev
var_C           = -0xC
    STMFD   SP!, {R4,LR}
    SUB     SP, SP, #8
    ;ClassLayout*layout = new ClassLayout()     MOV     R0, #8
    BL      _Znwj           ; 调用new先为对象申请内存空间,再在该内存上调用构造函数
    MOV     R4, R0
    MOV     R0, R4
    BL      _ZN11ClassLayoutC2Ev ;ClassLayout::ClassLayout(void)
    STR     R4, [SP,#0x10+var_C]
    ;layout->PrintMember()     LDR     R0, [SP,#0x10+var_C]
    BL      _ZN11ClassLayout11PrintMemberEv ;ClassLayout::PrintMember(void)
    ;ClassLayout::SetAndPrintStaticValue(0)     MOV     R0, #0
    BL     _ZN11ClassLayout22SetAndPrintStaticValueEi ;     
    ;delete layout     LDR     R0, [SP,#0x10+var_C] ;
    BL      _ZdlPv          ; operator delete(void *)
    ADD     SP, SP, #8
    LDMFD   SP!, {R4,PC}
; End of function TestInvoke(void)

对汇编代码的说明如下

1、符号修饰机制

众所周知,C++拥有封装、继承、多态三大特性,此外还支持命名空间与函数重载。最简单的重载例子如下,两个同名函数,参数列表不同即构成重载。

void func(int)

void func (float)

编译器如何区分这两个函数呢?这就引入了符号修饰机制。

不同的编译器,符号修饰机制不同,GCC的符号修饰机制如下:

①所有的符号都以“_Z”开头

②对于嵌套的名字(在命名空间或者在类里面),后面紧跟“N”,然后是各个命名空间或类的名字,每个名字前有一个数字,表示名字的长度。嵌套的名字以“E”结尾。如果是一个函数,则参数列表紧跟在“E”之后。

根据上面的原则,类的3个成员函数编译后的名称如下:

函数签名

修饰后的名称

ClassLayout:: ClassLayout()

_ZN11ClassLayoutC2Ev  (C表示构造函数)

int ClassLayout::PrintMember()

_ZN11ClassLayout11PrintMemberEv

void ClassLayout:: SetAndPrintStaticValue(int)

ZN11ClassLayout22SetAndPrintStaticValueEi

正因为符号修饰机制,在C++代码中引用C语言编写的库时,会遇到名字解析的问题。这时需要用到extern“C”关键字。详情不再赘述

2、类的大小

分析TestInvoke(void)的汇编代码,在new ClassLayout的对象时,编译器只给我们预留了8个字节的空间,这是为成员变量mIValue和mFValue预留的。在构造函数中,我们也能看到,在对象的内存布局中,成员变量的存储顺序与声明顺序一致。内存布局如下:

C++对象在内存中的布局(读汇编代码)

对象的内存布局中,只有两个成员变量,静态成员变量以及成员方法都不在对象的内存布局中。

3、构造函数

new一个对象时,底层的操作是先分配内存,再在内存上执行构造函数。本例中的构造函数虽然C++代码中没有参数,但汇编代码中却有一个入参,该入参就是通过new分配的内存块的起始地址。虽然构造函数没有指明返回值,但汇编代码中构造函数是有返回值的,返回值就是对象的基地址。

4、成员函数中的this指针

分析TestInvoke(void)的汇编代码,在调用成员函数ClassLayout::PrintMember(void)之前,将对象的地址放到R0寄存器中,此即this指针。可见成员函数中都隐含有this指针,指向对象自己。

5、静态成员函数中没有this指针

分析TestInvoke(void)的汇编代码,在调用静态成员函数ClassLayout::SetAndPrintStaticValue(void)时,R0存放的是该函数的第一参数(整形数0),并没有传入this指针。

3.单一继承与虚函数表

3.1 C++代码如下

class GrandFather
{
public:
    GrandFather():mIGrandFather(10){}
    virtual void f() { printf("GrandFather : f\n"); }
    virtual void g() { printf("GrandFather :g\n"); }
    virtual void h() { printf("GrandFather :h\n"); }
    int mIGrandFather;
};
class Father : public GrandFather
{
public:
    Father():mIFather(100){}
    //改写GrandFather的f方法
    virtual void f() { printf("Father : f\n"); }    
    //新增虚方法
    virtual void j() { printf("Father : j\n"); }    
    virtual void k() { printf("Father : k\n"); }    
    int mIFather;
};
 
class Child : public Father
{
public:
    Child():mIChild(1000){}
    //改写Father的f方法
    virtual void f() { printf("Child : f\n"); } 
    //改写Father的j方法
    virtual void j() { printf("Child : j\n"); } 
    //新增虚方法
    virtual void m() { printf("Child : m\n"); }
    intmIChild;
};
 
void TestInvoke()
{
    Child *child = new Child();
    //遍历虚方法
    child->f();
    child->g();
    child->h();
    child->j();
    child->k();
    child->m();
    //打印成员变量
    printf("GrandFather:mIGrandFather-%d\n",child->mIGrandFather);
    printf("Father:mIFather-%d\n",child->mIFather);
    printf("Child:mIChild-%d\n",child->mIChild); 
    delete child;
}

C++正是通过虚拟函数与继承实现多态的(多个子类继承自同一个父类,并且各个子类改写继承自父类的虚拟成员函数,这样当用父类指针指向子类对象,通过父类指针调用父类中声明的虚拟成员函数,不同的子类,表现出不同的状态,此即多态)。继承关系如下:

C++对象在内存中的布局(读汇编代码)

在GrandFather类中定义了三个虚方法;在Father类中,改写了(override)继承自GrandFather的虚方f,并新增了两个虚方法k和j。在Child类中,改写了继承自Father的虚方法f和k,并新增了一个虚拟方法m。这三个类分别有一个数据成员,分别初始化为(构造函数中的成员初始化列表)10、100和1000。

在VC++中,运行结果如下:

C++对象在内存中的布局(读汇编代码)

3.2 ARM的汇编代码如下

先来看构造函数

;GrandFather构造函数
;GrandFather::GrandFather(void)
_ZN11GrandFatherC2Ev
var_4          = -4
    SUB     SP, SP, #8
    ;R0是构造函数入参,指向对象的基址,这里将R0暂存到栈中
    STR     R0, [SP,#8+var_4]   
    LDR     R3, =(_GLOBAL_OFFSET_TABLE_ - 0x105E)
    ADD     R3, PC
    LDR     R2, [SP,#8+var_4]
    LDR     R1, =(_ZTV11GrandFather_ptr - 0x8FA0)
    LDR     R3, [R3,R1]     ;R3中存放的是GrandFather的虚函数表
    ADDS    R3, #8
    STR     R3, [R2]        ;GrandFather的虚函数表偏移8字节放入对象内存布局的起始位置
    LDR     R3, [SP,#8+var_4]
    MOVS    R2, #0xA
    STR     R2, [R3,#4]    ;[起始位置+0x4]存入mIGrandFather
    LDR     R3, [SP,#8+var_4]
    MOVS    R0, R3         ;构造函数的返回值就是对象的基址
    ADD     SP, SP, #8
    BX      LR
 
  
 ;Father构造函数
 ;Father::Father(void)
 _ZN6FatherC2Ev   
 var_C           = -0xC
    PUSH    {R4,LR}
    SUB     SP, SP, #8
    ;R0是构造函数入参,指向对象的基址,这里将R0暂存到栈中
    STR     R0, [SP,#0x10+var_C]
    LDR     R4, =(_GLOBAL_OFFSET_TABLE_ - 0x10D0)
    ADD     R4, PC
    LDR     R3, [SP,#0x10+var_C]
    MOVS    R0, R3
    ;在对象的地址上调用父类GrandFather的构造函数GrandFather::GrandFather(void)
    BL      _ZN11GrandFatherC2Ev 
    LDR     R3, [SP,#0x10+var_C]
    LDR     R2, =(_ZTV6Father_ptr - 0x8FA0)
    LDR     R2, [R4,R2]     ;获取Father的虚函数表
    ADDS    R2, #8
    STR     R2, [R3]       ;Father的虚函数表偏移8个字节放入对象内存布局的起始地址处
    LDR     R3, [SP,#0x10+var_C]
    MOVS    R2, #0x64
    STR     R2, [R3,#8]      ;[起始地址+0x8]存放mIFather
    LDR     R3, [SP,#0x10+var_C]
    MOVS    R0, R3           ;构造函数的返回值就是对象的基址
    ADD     SP, SP, #8
    POP     {R4,PC}
 
 ;Child构造函数
 ;Child::Child(void)
 _ZN5ChildC2Ev
 var_C           = -0xC
    PUSH    {R4,LR}
    SUB     SP, SP, #8
    ;R0是构造函数入参,指向对象的基址,这里将R0暂存到栈中
    STR     R0, [SP,#0x10+var_C]
    LDR     R4, =(_GLOBAL_OFFSET_TABLE_ - 0x114C)
    ADD     R4, PC
    LDR     R3, [SP,#0x10+var_C]
    MOVS    R0, R3
    BL      _ZN6FatherC2Ev  ; 在当前对象的内存布局上调用父类构造函数Father::Father(void)
    LDR     R3, [SP,#0x10+var_C]
    LDR     R2, =(_ZTV5Child_ptr - 0x8FA0)
    LDR     R2, [R4,R2]     ;获取Child的虚函数表
    ADDS    R2, #8 
    STR     R2, [R3]        ;Child虚函数表偏移8个字节放到对象的起始地址处
    LDR     R3, [SP,#0x10+var_C]
    MOVS    R2, 0x3E8
    STR     R2, [R3,#0xC]    ;[起始地址 + 0xC]存放mIChild
    LDR     R3, [SP,#0x10+var_C]
    MOVS    R0, R3            ;构造函数的返回值就是对象的基址
    ADD     SP, SP, #8
    POP     {R4,PC}
 

三个类的虚函数表如下:

;Child类的虚表
.data.rel.ro:00008E48 _ZTV5Child  DCD 0,  0, 
                                      _ZN5Child1fEv+1, 
                                      _ZN11GrandFather1gEv+1, 
                                      _ZN11GrandFather1hEv+1
                                      _ZN5Child1jEv+1, 
                                      _ZN6Father1kEv+1,
                                      _ZN5Child1mEv+1
;Father类的虚表
.data.rel.ro:00008E68 _ZTV6Father   DCD 0,  0, 
                                      _ZN6Father1fEv+1, 
                                      _ZN11GrandFather1gEv+1, 
                                      _ZN11GrandFather1hEv+1
                                       _ZN6Father1jEv+1, 
                                      _ZN6Father1kEv+1
;GrandFather类的虚表
.data.rel.ro:00008E88 _ZTV11GrandFather DCD 0,  0, 
                                      _ZN11GrandFather1fEv+1, 
                                      _ZN11GrandFather1gEv+1
                                      _ZN11GrandFather1hEv+1

三个类的虚函数表说明如下:

1、虚表可以理解为函数地址(32位)的一维数组,前两个元素为0,用来分割两个相邻的虚表。在构造函数中,在为对象设置虚表时,要跳过前两个元素。

2、虚表中存放的是函数的地址,函数名即函数地址,这里在函数地址上+1,是因为thumb指令约定地址的最低位为1。

3、虚表中函数的顺序按声明的顺序排列。

4、在继承关系中,被override的虚函数,在虚函数表中会被更新,比如在Father的虚表中,f函数就被更新为Father类中的f方法。

5、类的虚函数表可以这样理解:首先从父类继承该表;然后被子类override过的虚函数,对应虚表中的地址会被更新;再有子类新加的虚函数会追加到虚表结尾。

构造函数说明如下:

1、子类负责父类的创建,在子类的构造函数中会先调用父类的构造函数“构造父类子对象”。

2、如果类定义了虚方法,则有虚方法表VTable,VTable的地址存入对象内存布局的首地址。成员变量依据继承与声明的顺序,放到后面。

3、对象的虚表被设置了三次,分别在GrandFather、Father以及Child的构造函数中设置,最终以Child的虚表为准。

通过上面的分析,我们不难得到Child对象的内存布局:

C++对象在内存中的布局(读汇编代码)


对象本身的sizeof为16个字节,包含一个指针(指向虚函数表)和三个数据成员

下面来看看虚函数的调用:

; TestInvoke(void)
 _Z10TestInvokev
 var_C           = -0xC
    PUSH    {R4,LR}
    SUB     SP, SP, #8
    ;Child *child = new Child()     MOVS    R0, #0x10       ;Child的sizeof为16个字节
    BLX     _Znwj           ;operator new(uint)
    MOVS    R4, R0
    MOVS    R0, R4
    BL      _ZN5ChildC2Ev   ;Child::Child(void)
    STR     R4, [SP,#0x10+var_C]
    ;child->f()     LDR     R3, [SP,#0x10+var_C]
    LDR     R3, [R3]           ;取虚表地址
    LDR     R3, [R3]           ;取虚表的第一个元素,即虚函数f的地址
    LDR     R2, [SP,#0x10+var_C] 
    MOVS    R0, R2             ;this指针作为f的入参
    BLX     R3                 ;调用f方法
    ;child->g()     LDR     R3, [SP,#0x10+var_C]
    LDR     R3, [R3]           ;取虚表的地址
    ADDS    R3, #4             ;虚表偏移4个字节,
    LDR     R3, [R3]           ;取虚表的第二个元素,即虚函数g的地址
    LDR     R2, [SP,#0x10+var_C]
    MOVS    R0, R2             ;准备thid指针作为g的入参
    BLX     R3                 ;调用g方法
    ;后面代码省略

说明:

调用虚方法时,先根据对象的内存布局找到虚函数表,再找到虚表中对应的函数地址,直接调用该函数(地址)即可。

4.多重继承与类型转换

4.1 C++代码如下:

class Base1
{
public:
    Base1():mIBase1(10){}
    virtual void f() { printf("Base1 : f\n"); }
    virtual void g() { printf("Base1 :g\n");   }
    int mIBase1;
};
 
class Base2
{
public:
    Base2():mIBase2(100){}
    virtual void h() { printf("Base2 : h\n"); }
    virtual void j() { printf("Base2 :j\n");   }
    int mIBase2;
};
 
class Derived : public Base1, public Base2
{
public:
    Derived():mIDerived(100){}
    //改写虚方法
    virtual void f() { printf("Derived : f\n"); }   
    virtual void h() { printf("Derived : h\n"); }   
    //新增虚方法
    virtual void k() { printf("Derived :k\n"); }
    int mIDerived;
};
 
void TestInvoke()
{
     Derived *pd = new Derived();
    
    //遍历虚方法
    printf("===Callvirtual functions via Derived===\n");
    pd->f();
    pd->g();
    pd->h();
    pd->j();
    pd->k();
 
    //以父类Base1调用相关的虚函数
    Base1 *pb1= (Base1*)pd;
    printf("\n===Callvirtual functions via Base1===\n");
    pb1->f();
    pb1->g();
 
    //以父类Base2调用相关的虚函数
    Base2 *pb2= (Base2*)pd;
    printf("\n===Callvirtual functions via Base2===\n");
    pb2->h();
    pb2->j();
 
    deletepd;
}

对C++代码的说明:

这次定义了两个基类,Base1和Base2,每个基类各定义了两个虚方法。派生类继承自Base1和Base2。继承关系如下:

C++对象在内存中的布局(读汇编代码)

运行结果如下:

C++对象在内存中的布局(读汇编代码)

我们可以以基类的指针指向子类对象,不同的基类,可以调用的虚方法只限于本类可见的方法(本类定义的方法以及本类继承得到的方法),底层是如何实现的呢?

4.2 ARM的汇编代码

先看构造函数:

;Base1构造函数
;Base1::Base1(void)
_ZN5Base1C2Ev
var_4          = -4
    SUB     SP, SP, #8
    ;R0是构造函数入参,指向对象的基址,这里将R0暂存到栈中
    STR     R0, [SP,#8+var_4]
    LDR     R3, =(_GLOBAL_OFFSET_TABLE_ - 0xFCE)
    ADD     R3, PC
    LDR     R2, [SP,#8+var_4]
    LDR     R1, =(_ZTV5Base1_ptr - 0x8FA0)
    LDR     R3, [R3,R1]          ;Base1的虚函数表
    ADDS    R3, #8
    STR     R3, [R2]             ;Base1的虚函数表偏移8字节存放到对象的起始地址处
    LDR     R3, [SP,#8+var_4]
    MOVS    R2, #0xA
    STR     R2, [R3,#4]          ;[起始地址+0x4]存入mIBase1,初始值为10
    LDR     R3, [SP,#8+var_4]
    MOVS    R0,R3               ;构造函数的返回值就是对象的基址
    ADD     SP, SP, #8
    BX      LR
 
 ;Base2构造函数
 ;Base2::Base2(void)
 _ZN5Base2C2Ev
 var_4           = -4
    SUB     SP, SP, #8
    ;R0是构造函数入参,指向对象的基址,这里将R0暂存到栈中
    STR     R0, [SP,#8+var_4]
    LDR     R3, =(_GLOBAL_OFFSET_TABLE_ - 0x1026)
    ADD     R3, PC
    LDR     R2, [SP,#8+var_4]
    LDR     R1, =(_ZTV5Base2_ptr - 0x8FA0)
    LDR     R3, [R3,R1]          ;获取Base2的虚函数表
    ADDS    R3, #8
    STR     R3, [R2]             ;Base2的虚函数表存入对象的基地址
    LDR     R3, [SP,#8+var_4]
    MOVS    R2, #0x64
    STR     R2, [R3,#4]          ;[起始地址+0x4]存入mIBase2,初始值为100
    LDR     R3, [SP,#8+var_4]
    MOVS    R0, R3               ;构造函数的返回值就是对象的基址
    ADD     SP, SP, #8
    BX      LR
 
 ;Derived构造函数
 ;Derived::Derived(void)
 _ZN7DerivedC2Ev
 var_C           = -0xC
    PUSH    {R4,LR}
    SUB     SP, SP, #8
    ;R0是构造函数入参,指向对象的基址,这里将R0暂存到栈中
    STR     R0, [SP,#0x10+var_C]
    LDR     R4, =(_GLOBAL_OFFSET_TABLE_ - 0x1080)
    ADD     R4, PC
    LDR     R3, [SP,#0x10+var_C]
    MOVS   R0, R3                 ;以对象的基址调用Base1的构造函数
    BL      _ZN5Base1C2Ev          ;Base1::Base1(void)
    LDR     R3, [SP,#0x10+var_C]
    ADDS    R3, #8
    MOVS    R0, R3                 ;以对象的基址偏移8个字节,再调用Base2的构造函数
    BL      _ZN5Base2C2Ev          ;Base2::Base2(void)
    LDR     R3, [SP,#0x10+var_C]
    LDR     R2, =(_ZTV7Derived_ptr - 0x8FA0)
    LDR     R2, [R4,R2]            ;Derived的虚函数表
    ADDS    R2, #8                 ;虚函数表偏移8个字节
    ;Derived虚表偏移8个字节然后存入对象的基址(会覆盖Base1构造函数存入的Base1虚函数表)
    STR     R2, [R3]               
    LDR     R3, [SP,#0x10+var_C]
    LDR     R2, =(_ZTV7Derived_ptr - 0x8FA0)
    LDR     R2, [R4,R2]            ;Derived的虚函数表
    ADDS    R2, #0x20              ;虚函数表偏移0x20个字节,
    ;虚表偏移0x20个字节然后存入[对象基址+8](会覆盖掉Base2构造函数存入的 Base2虚函数表)
    STR     R2, [R3,#8]            
    LDR     R3, [SP,#0x10+var_C]
    MOVS    R2, #0x64
    STR     R2, [R3,#0x10]         ;[对象基址+0x10]存入mIDerived,初始值为100
    LDR     R3, [SP,#0x10+var_C]
    MOVS    R0, R3                 ;构造函数的返回值就是对象的基址
    ADD     SP, SP, #8
    POP     {R4,PC}
 
;虚函数表如下:
;Derived的虚表
.data.rel.ro:00008E58 _ZTV7Derived    DCD 0, 0, 
                        _ZN7Derived1fEv+1,
                        _ZN5Base11gEv+1, 
                        _ZN7Derived1hEv+1
                        _ZN7Derived1kEv+1, 
                        0xFFFFFFF8, 
                        0, 
                        _ZThn8_N7Derived1hEv ;`non-virtual thunk to'Derived::h(void)
                        _ZN5Base21jEv+1
 
;Base2的虚表
.data.rel.ro:00008E80 _ZTV5Base2      DCD0, 0, 
                        _ZN5Base21hEv+1,
                        _ZN5Base21jEv+1
             
;Base1的虚表
.data.rel.ro:00008E90 _ZTV5Base1      DCD 0, 0, 
                        _ZN5Base11fEv+1,
                        _ZN5Base11gEv+1

虚函数表说明如下:

Derived的虚表被分割成两部分(表中的0xFFFFFFF8、0这两个标志进行分割),前一部分中的虚函数包括:

①    Derived继承自Base1的虚函数f;

②    Derived改写的Base1中的虚函数g;

③    Derived改写的Base2中的函数h(注意这里)

④    Derived新增的虚函数k。

后一部分包括:

①    Derived改写的Base2中的函数h的“非虚版本”(参考_ZThn8_N7Derived1hEv后面的注释,注意与上面的③是两个函数哦)。为什么要有这个“非虚”版本的函数呢?这与多重继承情况下,将子类指针转换成父类指针的实现有关。类型转换的问题后面还会讲到。我们先来看看这个“非虚”函数的实现。

;'non-virtual thunk to'Derived::h(void)
_ZThn8_N7Derived1hEv
    LDR     R12, =(_ZN7Derived1hEv+1 - 0x10E0)
    ADD     R12, PC, R12
    ;R0是this指针即对象的基址,这里是Derived中“Base2的子对象”的基址,
    ;将这个地址减8个字节,即减去Base1的size,这样,R0就指向了Derived的基址了。
    SUB     R0, R0, #8
    BX      R12     ;最终还是调用子类中对应的实现Derived::h(void)

是不是有点开始理解这个非虚函数了?因为这个函数改写自Base2,那么我们可以通过地址强转将Dervied指针强转成Base2的指针,然后通过Base2类型的指针调用该方法。在地址转换时,会将Derived对象的地址加Base1的size,这样就定位到了Base2子对象的地址。那么通过Base2的指针调用Derived中的函数时,当然要将this指针定位到Derived对象的基址啦,所以要在this指针上减去Base1的size。

②    Derived继承自Base2中函数j。

我们不难得到Derived的内存布局:

C++对象在内存中的布局(读汇编代码)

Derived的sizeof为20,其中有两个虚函数表的指针。一个指针指向继承自Base1的虚函数、Dervied改写的虚函数和Derived新增的虚函数;一个指针指向继承自Base2的虚函数以及Derive改写的Base2中虚函数的“非虚”版本函数。

 

下面通过TestInvoke函数的代码,分析下类型转换过程。

; TestInvoke(void)
_Z10TestInvokev
 
var_14         = -0x14
var_10         = -0x10
var_C          = -0xC
    PUSH    {R4,LR}
    SUB     SP, SP, #0x10  
    ;Derived*pd = new Derived()
    MOVS    R0, #0x14
    BLX     _Znwj           ;申请内存,可见Derived类的sizeof为20
    MOVS    R4, R0
    MOVS    R0, R4
    BL      _ZN7DerivedC2Ev ;调用Derived的构造函数Derived::Derived(void)
    STR     R4, [SP,#0x18+var_14]  
    ;printf("===Call virtual functions via Derived===\n")     LDR     R3, =(aCallVirtualFun - 0x1132)
    ADD     R3, PC          ; "===Call virtual functions viaDerived=="...
    MOVS    R0, R3          ; s
    BLX     puts
    ;pd->f()     LDR     R3, [SP,#0x18+var_14]    ;对象的地址
    LDR     R3, [R3]              ;虚函数表基址
    LDR     R3, [R3]              ;虚表中的第一个函数,即f
    LDR     R2, [SP,#0x18+var_14]
    MOVS    R0, R2
    BLX     R3                    ;调用f
    ;pd->g()     LDR     R3, [SP,#0x18+var_14]
    LDR     R3, [R3]
    ADDS    R3, #4                ;虚函数表偏移4字节,虚表中的第二个函数g
    LDR     R3, [R3]
    LDR     R2, [SP,#0x18+var_14]
    MOVS    R0, R2
    BLX     R3
    ;pd->h()     LDR     R3, [SP,#0x18+var_14]
    LDR     R3, [R3]
    ADDS    R3, #8                ;虚函数表偏移8字节,虚表中的第三个函数h
    LDR     R3, [R3]
    LDR     R2, [SP,#0x18+var_14]
    MOVS    R0, R2
    BLX    R3
    ;pd->j()     LDR     R3, [SP,#0x18+var_14]
    ;对象的基址偏移8字节,跳过Base1的size。此处存储的是第二个虚表的指针
    LDR     R3, [R3,#8] 
    ADDS    R3, #4                 ;第二个虚表偏移4字节,此处存的是继承自Base2的j函数
    LDR     R3, [R3]
    LDR     R2, [SP,#0x18+var_14]
    ADDS    R2,#8
    MOVS    R0, R2
    BLX     R3
    ;pd->k()     LDR     R3, [SP,#0x18+var_14]
    LDR     R3, [R3]
    ADDS    R3, #0xC
    LDR     R3, [R3]
    LDR     R2, [SP,#0x18+var_14]
    MOVS    R0, R2
    BLX     R3
    ;Base1 *pb1 = (Base1*)pd     LDR     R3, [SP,#0x18+var_14]
    ;局部变量pb1存放在堆栈中,其值就是对象的地址,可见这次地址转换,地址并没有改变。
    STR     R3, [SP,#0x18+var_10]
    ;printf("\n===Call virtual functions via Base1===\n")     LDR     R3, =(aCallVirtualF_0 - 0x1186)
    ADD     R3, PC          ; "\n===Call virtual functionsvia Base1==="...
    MOVS    R0, R3          ; s
    BLX     puts
    ;pb1->f() 以pb1为this指针进行虚函数调用
    LDR     R3, [SP,#0x18+var_10]
    LDR     R3, [R3]
    LDR     R3, [R3]
    LDR     R2, [SP,#0x18+var_10]
    MOVS    R0, R2
    BLX     R3
    ;pb1->f()     LDR     R3, [SP,#0x18+var_10]
    LDR     R3, [R3]
    ADDS    R3, #4
    LDR     R3, [R3]
    LDR     R2, [SP,#0x18+var_10]
    MOVS    R0, R2
    BLX     R3
    ;Base2 *pb2 = (Base2*)pd     ;这次转换,编译器显得小心翼翼。先判断指针pd是否为NULL,为何要加这个判断,
    ;因为这次转换要将地址后移,跳过Base1的成分,所以要保证pd是正确的。
    LDR     R3, [SP,#0x18+var_14]
    CMP     R3, #0
    BEQ     loc_11B0
    LDR     R3, [SP,#0x18+var_14]
    ;对象的基址偏移8字节,跳过Base1的size,即跳过Derived中"父类Base1的子对象"
    ADDS    R3, #8              
    B       loc_11B2
; ---------------------------------------------------------------------------
loc_11B0
    MOVS    R3, #0     ;如果pd为NULL,则pb2也为NULL
loc_11B2
    ;局部变量pb2存放在堆栈中,它代表这Derived中"Base2成分"的起始地址
    STR     R3, [SP,#0x18+var_C]    
    ;printf("\n===Call virtual functions via Base2===\n")     LDR     R3, =(aCallVirtualF_1 - 0x11BA)
    ADD     R3, PC          ; "\n===Call virtual functionsvia Base2==="...
    MOVS    R0, R3          ; s
    BLX     puts
    ;pb2->h()     LDR     R3, [SP,#0x18+var_C]
    LDR     R3, [R3]
    LDR     R3,[R3]
    LDR     R2, [SP,#0x18+var_C]
    MOVS    R0, R2
    BLX     R3         ;调用h的"的非虚版本"
    ;pb2->j()     LDR     R3, [SP,#0x18+var_C]
    LDR     R3, [R3]
    ADDS    R3, #4
    LDR     R3, [R3]
    LDR     R2, [SP,#0x18+var_C]
    MOVS    R0, R2
    BLX     R3
    LDR     R3, [SP,#0x18+var_14]
    MOVS    R0, R3          ; void *
    BLX     _ZdlPv          ; operator delete(void *)
    ADD     SP, SP, #0x10
    POP     {R4,PC}

分析:在执行“Base2*pb2 = (Base2*)pd时,将pd 加上Base1的size,这样就定位到了Derived中Base2的子对象,经过这个转换后,pb2的值与pd的值已经不相等了。地址强转过程以及通过pb2调用h的过程如下图:

C++对象在内存中的布局(读汇编代码)