C++幕后故事(五)--数据你在哪里?

读者如果觉得我文章还不错的,希望可以多多支持下我,文章可以转发,但是必须保留原出处和原作者署名。更多内容请关注我的微信公众号:cpp手艺人
image

这个章节我们主要学习以下几个知识点:

1.数据成员绑定时机。

2.多种模型下数据成员布局。

3.数据成员如何读取的。

4.进程内存布局

1.数据成员绑定时机

大家一看标题可能有点懵了,什么叫数据成员的绑定时机。请随我看段代码,这段代码节选自《深入探索C++对象模型》

extern float x;
class Point3D
{
public:
    Point3D(float, float, float);
    float X() { return x; }
    void X(float new_x) { x = new_x; }
private:
    float x, y, z;
};

如果我调用了Point3D的X()返回的这个x是Point3D的成员变量值,还是外部定义的x。现在看来的话,很显然返回的Point3D的成员变量值。但是在C++诞生没多久的时代,编译器返回的是外部定义的x。这样的结果对于现在的你来说是不是有点惊讶。所以你现在看到如下的代码风格,请不要惊讶也不要彷徨。注意下面的高亮部分,将成员变量的定义提前,这样就防御了之前的成员绑定问题。

extern float x;
class Point3D
{
private:
    float x, y, z;  
public:
Point3D(float, float, float);
float X() { return x; }
    void X(float new_x) { x = new_x; }
};

可以看出来现代C++编译器,在绑定成员变量时,是延迟到整个类解析完之后才会进行成员变量的绑定。
但是大家请注意,这里有一个小坑。就是成员函数的参数列表绑定时机。
看如下的代码:

#include <string>
using std::string;

typedef string mytype;

class TestDataAnalyse {
public:
    int Fun();
    // 对于成员函数参数的解析,编译器是第一次遇到这个类型的mytype类型的时候被决定的
    // mytype第一次遇到的时候就看到了type string mytype
    void CopyValue(mytype value);
private:
int tempvalue;
typedef int mytype;
};

void TestDataAnalyse::CopyValue(mytype value)
{
    tempvalue = value;
}

void test_data_analyse()
{
    TestDataAnalyse data;
    data.Fun();
}

编译器报错的错误:
error C2511: “void TestDataAnalyse::CopyValue(TestDataAnalyse::mytype)”:“TestDataAnalyse”中没有找到重载的成员函数看到这样的错误,你可能找半天都找不到头绪。mytype我已经定义为int类型的,但是为什么说没有找到重载的成员函数,这和重载函数有什么关系。

原因就是:成员函数参数的绑定是利用就近原理,当解析到它的时候,它的定义就是最近的定义这个类型的类型。什么意思呢,就是mytype这个类型最近定义这个类型的为string类型(从上往下解析)。CopyValue中将string类型复制给int类型,当然是编译失败。这个时候我们把类中嵌套的定义typedef int mytype,这句话放在类的开始处,就可以避免这个问题,这个时候mytype的类型就为int。

在这里可以看出成员变量和成员函数参数的绑定时机是不同的。

2.数据成员布局

2.1未继承任何父类

class Child
{
public:
    int m_a;
    int m_b;
    int m_c;
    int m_d;
    int m_e;
};
void test_member_layout()
{
    Child child;
    printf("m_a = 0x%p\n", &child.m_a);
    printf("m_b = 0x%p\n", &child.m_b);
    printf("m_c = 0x%p\n", &child.m_c);
    printf("m_d = 0x%p\n", &child.m_d);
    printf("m_e = 0x%p\n", &child.m_e);

    // m_a = 0x010FFD14
    // m_b = 0x010FFD18
    // m_c = 0x010FFD1C
    // m_d = 0x010FFD20
    // m_e = 0x010FFD24
}

未继承任何的父类的类,类的成员变量都是按照声明的顺序排列的。根据打印出的地址,如下图所示:

image

2.2单继承无虚函数父类

class Base
{
public:
    int m_base_a;
    int m_base_b;
};

class Child : public Base
{
public:
    int m_a;
    int m_b;
    int m_c;
    int m_d;
    int m_e;
};

void test_member_layout()
{
    Child child;
    printf("m_base_a = 0x%p\n", &child.m_base_a);
    printf("m_basse_b = 0x%p\n", &child.m_base_b);
    printf("m_a = 0x%p\n", &child.m_a);
    printf("m_b = 0x%p\n", &child.m_b);
    printf("m_c = 0x%p\n", &child.m_c);
    printf("m_d = 0x%p\n", &child.m_d);
    printf("m_e = 0x%p\n", &child.m_e);
    // m_base_a = 0x00B3FCDC
    // m_basse_b = 0x00B3FCE0
    // m_a = 0x00B3FCE4
    // m_b = 0x00B3FCE8
    // m_c = 0x00B3FCEC
    // m_d = 0x00B3FCF0
    // m_e = 0x00B3FCF4
}

单继承无虚函数,先是父类声明的先后顺序,再按照子类的声明的先后顺序,根据打印出的地址,如下图所示:

image

2.3 多重继承父类

class Base3
{
public:
    int m_base3_a;
    int m_base3_b;
};

class Child : public Base, public Base3
{
public:
    int m_a;
    int m_b;
    int m_c;
    int m_d;
    int m_e;
};

void test_member_layout()
{
    Child child;
    printf("m_base_a = 0x%p\n", &child.m_base_a);
    printf("m_basse_b = 0x%p\n", &child.m_base_b);
    printf("m_base3_a = 0x%p\n", &child.m_base3_a);
    printf("m_basse3_b = 0x%p\n", &child.m_base3_b);
    printf("m_a = 0x%p\n", &child.m_a);
    printf("m_b = 0x%p\n", &child.m_b);
    printf("m_c = 0x%p\n", &child.m_c);
    printf("m_d = 0x%p\n", &child.m_d);
    printf("m_e = 0x%p\n", &child.m_e);

    // m_base_a = 0x010FF6B0
    // m_basse_b = 0x010FF6B4
    // m_base3_a = 0x010FF6B8
    // m_basse3_b = 0x010FF6BC
    // m_a = 0x010FF6C0
    // m_b = 0x010FF6C4
    // m_c = 0x010FF6C8
    // m_d = 0x010FF6CC
    // m_e = 0x010FF6D0
}

当类中出现多个继承父类,成员变量的排列顺序,按照继承的先后顺序排列。若下图所示:

image

2.4 多重继承父类+(单/双)虚函数

class Base
{
public:
    int m_base_a;
    int m_base_b;
    virtual ~Base() {}
};

class Base3
{
public:
    int m_base3_a;
int m_base3_b;
// virtual ~Base3() {}
};

class Child : public Base, public Base3
{
public:
    int m_a;
    int m_b;
    int m_c;
    int m_d;
    int m_e;
};

int main()
{
    return 0;
}

将上面的代码保存为member_layout.cpp,我们利用在第四节文章里面的用到的工具,在windows菜单找到vs2013 开发人员命令提示工具,双击进入命令行的界面。

运行cl /d1 reportSingleClassLayoutChild member_layout.cpp,在不改变继承的顺序下,手动的添加和删除虚析构函数,导出的结果如下:
image

从表对比可以看出,继承的父类有没有虚函数是会影响子类的成员的布局。

1.子类继承了多个父类,其中有多个父类有虚函数,会优先排列有虚函数的父类并且按照继承的先后顺序排列,其次再排序无虚函数的父类。

2.如果继承的父类都是有虚函数或者是都没有虚函数,那么都按照继承的先后顺序排列。

image
image

2.5 虚基类继承无虚函数

将如下代码保存为member_layout.cpp,注意在这里为了探究内存布局,我删除了父类的所有虚函数,这样的设计不合理的,大家请注意。

class Grand
{
public:
    int G1;
    int G11;
};
class Parent1 : virtual public Grand
{
public:
    int P1;
};
class Parent2: virtual public Grand
{
public:
    int P2;
};
class Child3: public Parent1, public Parent2
{
public:
    int C3;
};
int main()
{
    return 0;
}

使用vs2013开发人员命令提示工具,定位你自己的member_layout.cpp目录,比如我的:J:\code\code_git\ \polymorphism_virtual\source,输入命令:cl /d1 reportSingleClassLayoutChild3 member_layout.cpp,Child3表示你要导出的类布局。

我截取重要的内容贴出来:

image
image
从表中我们可以清晰的看出来,Child3虚继承之后的布局。从Child3角度每个父类都会带一个vbptr(虚基类表指针),它指向一个虚基类表。我们先画出内存结构图的

image

image

image
image虚基类表中包含两项,第一项我们先跳过不解释,第二项是什么意思呢?
我们来写段测试代码看看:

void test_virtual_base_table()
{
Child3 c3;
c3.G1 = 0;
    c3.G11 = 11;
    c3.P1 = 1;
    c3.P2 = 2;
    c3.C3 = 3;
}

我们反汇编看下:

00C1DF48  push 1  
00C1DF4A  lea ecx,[c3]  ; 虚基类表指针复制给ecx
00C1DF4D  call data_semantics::Child3::Child3 (0C11758h)  
    c3.G1 = 0;
00C1DF52  mov eax,dword ptr [c3]  ; 通过基类表指针-->找到虚基类表首地址
    c3.G1 = 0;
00C1DF55  mov ecx,dword ptr [eax+4]  ; 跳过虚基类表的前4个字节,找到第二项
00C1DF58  mov dword ptr c3[ecx],0  ; c3+[ecx]偏移,复制给G1所在内存地址为0
    c3.G11 = 11;
00C1DF60  mov eax,dword ptr [c3]  ; 通过基类表指针-->找到虚基类表首地址
00C1DF63  mov ecx,dword ptr [eax+4]  ; 跳过虚基类表的前4个字节,找到第二项
00C1DF66  mov dword ptr [ebp+ecx-20h],0Bh  ; 复制给G1所在内存地址为0BH,ebp-20h其实为P1的地址
    c3.P1 = 1;
00C1DF6E  mov dword ptr [ebp-20h],1  
    c3.P2 = 2;
00C1DF75  mov dword ptr [ebp-18h],2  
    c3.C3 = 3;
00C1DF7C  mov dword ptr [ebp-14h],3

如果你阅读过《第四章 虚函数的原理》,你会发现这个对虚函数的寻址机制是完全相同的。
都是根据指针->虚表->虚表中的内容,看下图的成员变量的偏移量值。

image
我们看下Child3中G1是怎么寻址的,看下图所示。

image
所以可以得出结论,子类虚基类表中的第二项内容其实存储是个偏移量。虚基类中的内容是放到子类的最后面,然后子类根据虚基类表中的偏移找到虚基类成员的位置。

同样的道理G11也是按照这种原理寻址的。需要注意下mov dword ptr [ebp+ecx-20h],ebp-20h其实P1的首地址,所以从P1的地址开始(跳过前面的vbptr 4个字节)+20正好就是G11的地址。

Child3中的Parent1,Parent2中都包含一个vbptr。这和vptr是十分类似的原理,懂了虚函数的原理,那么你搞懂虚基类表也是非常容易的。

2.6 虚继承+虚函数继承

在2.4中为了探究虚继承的内存布局,我把Grand类中的虚析构函数删除。这样的设计是不合理的,这里我们给Grand加上虚析构函数。关于为什么父类中一定要虚析构函数可以参考《第四章 虚函数原理》。

class Grand
{
public:
    int G1;
    int G11;
    virtual ~Grand() {}
};

我们再使用工具导出类布局

image

image

image

可以看出Grand中增加一个虚函数,在子类中Child3中的布局中增加了一个vfptr指针,这个指针指向一个表,表中就存储了虚函数的地址,其实就是我们说的虚函数表,vfptr就是相当于我们之前讨论的vptr。

还剩下的几种情况虚继承+虚函数的情形,都是相同的分析机制。这里我就不再分析了。

3.数据成员的读取

前面我们详细的分析了各种模式下,类中内存中的布局。既然我们知道了数据在哪里,那么接下来讨论数据是怎么读取的。

其实,我们已经知道数据在内存的位置,将会非常有利于我们理解对于成员变量的读取,甚至可以说我们已经理解了一半。

3.1 数据成员

3.1.1 static成员

static成员是属于整个类的,并不单独属于某一个实例化的对象。我们接着借用上面的代码Child3类,在里面增加几行代码。

class Child3: public Parent1, public Parent2
{
public:
    int C3;
    static int s_Age;
};
int Child3::s_Age = -1;

void test_call_member()
{
    Child3::s_Age = 0;

    Child3 c3;
    c3.s_Age = 1;

    Child3 *pc3 = new Child3();
    pc3->s_Age = 2;
}

接下来我们看下test_call_member反汇编的调用方式有什么不同。

    Child3::s_Age = 0;
0092ADC0  mov         dword ptr ds:[93B000h],0  
    Child3 c3;
0092ADCA  push        1  
0092ADCC  lea         ecx,[c3]  
0092ADCF  call        data_semantics::Child3::Child3 (09217A8h)  
0092ADD4  mov         dword ptr [ebp-4],0  
    c3.s_Age = 1;
0092ADDB  mov         dword ptr ds:[93B000h],1  
Child3 *pc3 = new Child3(); 
0092AE26  mov         dword ptr ds:[93B000h],2  

从上述代码中我们看出,如果是静态成员,不管你的调用方式如何。其实他们最后的汇编代码都是一样的。从侧面可以看出静态数据是放在全局的数据区,和对象是没有关系的。

3.1.2 no static成员

前面我们学习了static成员的是怎么找到,但是和实例对象相关的成员我们该怎么定位呢。

我们看下面的代码:

class Child4
{
public:
    int m1;
    int m2;
    int m3;
    int m4;
};

void test_member_initialize()
{
    Child4 *c5 = new Child4();
    c5->m1 = 0;ss
    c5->m2 = 1;
    c5->m3 = 2;
    c5->m4 = 3;

    Child4 c4;
    c4.m1 = 0;
    c4.m2 = 1;
    c4.m3 = 2;
    c4.m4 = 3;
}

接下来我们通过反汇编代码看下是如何定位成员变量。

; 第一段
    c5->m1 = 0;
011AC94F  mov         eax,dword ptr [c5]  
011AC952  mov         dword ptr [eax],0  
    c5->m2 = 1;
011AC958  mov         eax,dword ptr [c5]  
011AC95B  mov         dword ptr [eax+4],1  
    c5->m3 = 2;
011AC962  mov         eax,dword ptr [c5]  
011AC965  mov         dword ptr [eax+8],2  
    c5->m4 = 3;
011AC96C  mov         eax,dword ptr [c5]  
011AC96F  mov         dword ptr [eax+0Ch],3  
; 第二段
    Child4 c4;
    c4.m1 = 0;
011AC976  mov         dword ptr [c4],0  
    c4.m2 = 1;
011AC97D  mov         dword ptr [ebp-1Ch],1  
    c4.m3 = 2;
011AC984  mov         dword ptr [ebp-18h],2  
    c4.m4 = 3;
011AC98B  mov         dword ptr [ebp-14h],3  

我们先看第一段汇编代码,首先[c5]中保存对象的首地址给eax,对eax解引用然后赋值0。再对(eax+4)解引用赋值1,再对(eax+8)解引用赋值2,再对(eax+12)解引用赋值3。我就可以看出这里根据对象的首地址+成员变量的偏移,就能找到对应的对象然后进行读取操作。

这时我们再看第二段的汇编代码,大家可能就奇怪了,为什么第二种的定位方式第一种不同呢。

其实这里定位的原理是一致的。第一种方式,c5对象是new出来,也就是说它的内存是堆中的,堆区是个非常灵活的区域申请和释放都是自己可以控制的,同时它的增长的方向是从低地址->高地址。而第二种方式呢,c4是在栈中申请的内存,相比较堆呢,它的可控性就弱了很多,而且它的增长方向是从高地址->低地址,这一点和堆是完全相反的,记住ebp表示的基址指针寄存器,该指针指向系统栈最上面一个栈帧的底部。

我们做个表格对比下两种:

image

采用的原理都是一样的,都是利用成员变量的偏移量,只不过它们的增长的方式不一样。堆往上增长,栈是往下增长。

3.2读取的效率

class Base6
{
public:
    int m_base6_a;
    virtual ~Base6() {}
    static int s_base6_b;
};
int Base6::s_base6_b = 0;

class Base4 : virtual public Base6
{
public:
    int m_base4_a;
    int m_base4_b;
    static int s_base4_c;
    virtual ~Base4() {}
};
int Base4::s_base4_c = 0;

class Base5 : virtual public Base6
{
public:
    int m_base5_a;
    int m_base5_b;
    static int s_base5_c;
    virtual ~Base5() {}
};
int Base5::s_base5_c = 0;

class Child5 : public Base4, public Base5
{
public:
    int m_a;
    int m_b;
    int m_c;
    int m_d;
    int m_e;
};

void test_member_effective()
{
Child5::s_base6_b = 10;
    Child5::s_base4_c = 1;
    Child5::s_base5_c = 2;

    Child5 c5;
    c5.m_a = 3;
    c5.m_base4_a = 4;
    c5.m_base5_a = 5;
    c5.m_base6_a = 6;
}

我们从汇编的角度(debug 模式下)看下他们的效率。

image

3.3 Data Member指针

指向类的成员变量指针,其实不是指针,实际的内容其实是在整个类中的偏移值。举个例子:

class Class7
{
public:
    virtual void VirFunc() {}

    int m_a;
    int m_b;
    int m_c;
    int m_d;
};

void test_member_point()
{
    printf("&Class7::m_a:%x\n", &Class7::m_a);
    printf("&Class7::m_b:%x\n", &Class7::m_b);
    printf("&Class7::m_c:%x\n", &Class7::m_c);
    printf("&Class7::m_d:%x\n", &Class7::m_d);
    // &Class7::m_a:4
    // &Class7::m_b:8
    // &Class7::m_c:c
// &Class7::m_d:10
    // 用法
    int Class7::*cpoint = &Class7::m_a;
    Class7 c7;
    c7.*cpoint = 1; 
    // *(&c7+cpoint) = 1;
}

从代码中可以看出来,打印的是成员变量的偏移值。值的注意下就是这里打印的偏移值是从4开始的,因为Class7含有虚函数,存在一个虚表指针。

我们再看下成员指针的初始化和使用,int Class7::cpoint = &Class7::m_a; Class7 c7; c7.cpoint = 1;用法其实很简单,但是困扰我的是这个成员变量指针到底有啥用。我尝试google下也没有发现有价值的资料,从《深入探索C++对象模型》里面提到了这个可以帮我们看出成员变量的偏移位置,但是我觉得这个意义不大的。这个让我很困惑。

4.进程内存布局

变量在内存是如何分布的,我们借助linux上nm(names)命令看下,代码如下

#include <stdio.h>
#include <iostream>

using std::cout;
using std::endl;

int g1;
int g2;

int g3 = 3;
int g4 = 4;

int g5;
int g6 = 1;  

static int gs_7;
static int gs_8 = 0;
static int gs_9 = 1; 

void g_func()
{
    return;
}

class MyClass
{
public:
    int m_i;
    static int m_si;
    int m_j;
    // 声明
    static int m_sj;
    int m_k;
    static int m_sk;
};
// 定义
int MyClass::m_sj = 1;

int main(int argc ,char *argv[])
{
    int temp_i = 0;
    printf("tempi address = %p\n", &temp_i);
    printf("g1 address = %p\n", &g1);
    printf("g2 address = %p\n", &g2);
    printf("g3 address = %p\n", &g3);
    printf("g4 address = %p\n", &g4);
    printf("g5 address = %p\n", &g5);
    printf("g6 address = %p\n", &g6);
    printf("gs_7 address = %p\n", &gs_7);
    printf("gs_8 address = %p\n", &gs_8);
    printf("gs_9 address = %p\n", &gs_9);
    printf("MyClass::m_sj address = %p\n", &(MyClass::m_sj));
    printf("g_func() address = %p\n", g_func);
    printf("main() address = %p\n", main);
    cout << "g_test " << (void *)g_test << endl;
    return 0;
}

1.使用g++ process_member_layout -o process_memeber_layout

2.在使用nm process_memeber_layout导出的数据如下所示。

image

这里解释下nm中间字段的含义

image

我们再对比下nm导出的数据和上述代码的打印的地址

image

我从网上找了张内存映射图

image

5.总结

image


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 635672377@qq.com

文章标题:C++幕后故事(五)--数据你在哪里?

文章字数:4.2k

本文作者:刘世雄

发布时间:2019-11-19, 17:14:15

最后更新:2019-12-31, 05:36:53

原始链接:http://lsxcpp.com/2019/11/20/C-%E5%B9%95%E5%90%8E%E6%95%85%E4%BA%8B%EF%BC%88%E4%BA%94%EF%BC%89-%E6%95%B0%E6%8D%AE%E4%BD%A0%E5%9C%A8%E5%93%AA%E9%87%8C%EF%BC%9F/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录