眼见为实(1):C++基本概念在编译器中的实现

眼见为实(1):C++基本概念在编译器中的实现


对于C++对象模型,相信很多程序员都耳熟能详。 本文试图通过一个简单的例子演示一些C++基本概念在编译器中的实现,以期达到眼见为实的效果。

本文的演示程序(http://www.fmddlmyy.cn/cpptest.zip)可以从我的个人主页下载。程序包中包含用VC6、VC7、BCB、Dev-C++和MinGW建立的项目。下文中的打印输出和汇编代码主要引自VC6环境。

1 对象空间和虚函数


1.1 对象空间

在我们为对象分配一块空间时,例如:

CChild1 *pChild = new CChild1();

这块空间里放着什么东西?

在CChild1没有虚函数时,CChild1对象空间里依次放着其基类的非静态成员和其自身的非静态成员。没有任何非静态成员的对象,会有一个字节的占位符。

如果CChild1有虚函数,VC6编译器会在对象空间的最前面加一个指针,这就是虚函数表指针(Vptr:Virtual function table
pointer)。我们来看这么一段代码:

class CMember1
{
public:
    CMember1(){a=0x5678;printf("构造
CMember1/n");}
    ~CMember1(){printf("析构
CMember1/n");}
    int a;
};

class CParent1
{
public:
    CParent1(){parent_data=0x1234;printf("构造
CParent1/n");}
    virtual ~CParent1(){printf("析构
CParent1/n");}
    virtual void
test(){printf("调用CParent1::test()/n/n");}
    void
real(){printf("调用CParent1::test()/n/n");}
    int
parent_data;
};

class CChild1 : public CParent1
{
public:
    CChild1(){printf("构造
CChild1/n");}
    virtual ~CChild1(){printf("析构
CChild1/n");}
    virtual void
test(){printf("调用CChild1::test()/n/n");}
    void
real(){printf("调用CChild1::test()/n/n");}
    CMember1
member;
    static int b;
};

CChild1对象的大小是多少?以下是演示程序的打印输出:

---->派生类对象
对象地址 0x00370FE0
对象大小 12
对象内容
00370FE0:
00410104 00001234 00005678
vptr内容
00410104: 004016a0 00401640
00401f70

CChild1对象的大小是12个字节,包括:Vptr、基类成员变量parent_data、派生类成员变量member。Vptr指向的虚函数表(VTable)就是虚函数地址组成的数组。

1.2 Vptr和VTable

如果我们用VC自带的dumpbin反汇编Debug版的输出程序:

dumpbin /disasm test_vc6.exe>a.txt

可以在a.txt中找到:

[email protected]@@UAEXXZ:
    00401640: 55 push
ebp
...
[email protected]@[email protected]:
    004016A0: 55
push ebp

可见VTable中的两个地址分别指向CChild1的析构函数和CChild1的成员函数test。这两个函数是CChild1的虚函数。如果打印两个CChild1对象的内容,可以发现它们Vptr是相同的,即每个有虚函数的类有一个VTable,这个类的所有对象的Vptr都指向这个VTable。

这里的函数名是不是有点奇怪,附录二简略介绍了C++的Name Mangling。

1.3 静态成员变量

在C++中,类的静态变量相当于增加了访问控制的全局变量,不占用对象空间。它们的地址在编译链接时就确定了。例如:如果我们在项目的Link设置中选择“Generate
mapfile”,build后,就可以在生成的map文件中看到:

0003:00002e18 [email protected]@@2HA 00414e18 test1.obj

从打印输出,我们可以看到CChild1::b的地址正是0x00414E18。其实类定义中的对变量b的声明仅是声明而已,如果我们没有在类定义外 (全局域)
定义这个变量,这个变量根本就不存在。

1.4 调用虚函数

通过在VC调试环境中设置断点,并切换到汇编显示模式,我们可以看到调用虚函数的汇编代码:

16: pChild->test();
(1) mov edx,dword ptr [pChild]
(2) mov
eax,dword ptr [edx]
(3) mov esi,esp
(4) mov ecx,dword ptr [pChild]
(5)
call dword ptr [eax+4]

语句(1)将对象地址放到寄存器edx,语句(2)将对象地址处的Vptr装入寄存器eax,语句(5)跳转到Vptr指向的VTable第二项的地址,即成员函数test。

语句(4)将对象地址放到寄存器ecx,这就是传入非静态成员函数的隐含this指针。非静态成员函数通过this指针访问非静态成员变量。

1.5 虚函数和非虚函数

在演示程序中,我们打印了成员函数地址:

    printf("CParent1::test地址 0x%08p/n",
&CParent1::test);
    printf("CChild1::test地址
0x%08p/n",
&CChild1::test);
    printf("CParent1::real地址
0x%08p/n",
&CParent1::real);
    printf("CChild1::real地址
0x%08p/n", &CChild1::real);

得到以下输出:

CParent1::test地址 0x004018F0
CChild1::test地址
0x004018F0
CParent1::real地址 0x00401460
CChild1::real地址
0x00401670

两个非虚函数的地址很容易理解,在dumpbin的输出中可以找到它们:

[email protected]@@QAEXXZ:
    00401460: 55 push
ebp
...
[email protected]@@QAEXXZ:
    00401670: 55
push ebp

为什么两个虚函数的“地址”是一样的?其实这里打印的是一段thunk代码的地址。通过查看dumpbin的输出,我们可以看到:

[email protected]$B3AE:
(6) mov eax,dword ptr [ecx]
(7) jmp dword ptr
[eax+4]

如果我们在跳转到这段代码前将对象地址放到寄存器ecx,语句(6)就会将对象地址处的Vptr装入寄存器eax,语句(7)跳转到Vptr指向的VTable第二项的地址,即成员函数test。基类和派生类VTable的虚函数排列顺序是相同的,所以可以共用一段thunk代码。

这段thunk代码的用途是通过函数指针调用虚函数。如果我们不取函数地址,编译器就不会产生这段代码。请注意不要将本节的thunk代码与VTable中虚函数地址混淆起来。Thunk代码根据传入的对象指针决定调用哪个函数,VTable中的虚函数地址才是真正的函数地址。

1.6 指向虚函数的指针

我们试验一下通过指针调用虚函数。非静态成员函数指针必须通过对象指针调用:

    typedef void
(Parent::*PMem)();
    printf("/n---->通过函数指针调用/n");
    PMem
pm = &Parent::test;
    printf("函数指针 0x%08p/n",
pm);
    (pParent->*pm)();

得到以下输出:

---->通过函数指针调用
函数指针 0x004018F0
调用CChild1::test()

我们从VC调试环境中复制出这段汇编代码:

13: (pParent->*pm)();
(8) mov esi,esp
(9) mov ecx,dword ptr
[pParent]
(10) call dword ptr [pm]

语句(9)将对象指针放到寄存器ecx中,语句(10)调用函数指针指向的thunk代码,就是1.5节的语句(6)。下面会发生什么,前面已经说过了。

1.7 多态的实现

经过前面的分析,多态的实现应该是显而易见的。当用指向派生类对象的基类指针调用虚函数时,因为派生类对象的Vptr指向派生类的VTable,所以调用的当然是派生类的函数。

通过函数指针调用虚函数同样要经过VTable确定虚函数地址,所以同样会发生多态,即调用当前对象VTable中的虚函数。

2 构造和析构


2.1 构造函数

下面的语句:

    printf("---->构造派生类对象/n");
    CChild1
*pChild = new CChild1();

产生以下输出:

---->构造派生类对象构造 CParent1
构造 CMember1
构造 CChild1

编译器会在用户定义的构造函数中加一些代码:先调用基类的构造函数,然后构造每个成员对象,最后才是程序中的构造函数代码(以下称用户代码)。下面这段汇编代码就是编译器修改过的CChild1类的构造函数:

[email protected]@[email protected]:

004014D0    push
ebp
            ...
(11)        call
CParent1::CParent1
(004013b0)
            ...
(12)        call
CMember1::CMember1
(00401550)
(13)        mov eax,dword
ptr [this]
(14)        mov dword ptr
[eax],offset CChild1::`vftable‘
(00410104)
(15)        push offset
string "/xb9/xb9/xd4/xec CChild1/n"
(004122a0)
            call
printf
(004022e0)
            ...
            ret

语句(11)调用基类的构造函数,语句(12)构造成员对象,语句(15)以后是用户代码。语句(13)和(14)也值得一提:语句(13)将对象地址放到寄存器eax,语句(14)将CChild1类的VTable指针放到对象地址(eax)的起始处。它们建立的正是对象的Vptr。

如果对象是通过new操作符构造的,编译器会先调用new函数分配对象空间,然后调用上面这个构造函数。

2.2 析构函数

删除指向派生类对象的指针产生以下输出:

---->删除指向派生类对象的基类指针
析构 CChild1
析构 CMember1
析构
CParent1

编译器会在用户定义的析构函数中加一些代码:即先调用用户代码,然后析构每个成员对象,最后析构基类的构造函数。下面这段汇编代码就是编译器修改过的CChild1类的析构函数:

[email protected]@[email protected]:
00401590    push
ebp
            ...
            push
offset string "/xce/xf6/xb9/xb9 CChild1/n"
(004122c0)
            call
printf
(004022e0)
            ...
(16)        call
CMember1::~CMember1
(00401610)
            ...
(17)        call
CParent1::~CParent1
(004013f0)
            ...
            ret

前面是用户代码,语句(16)调用成员对象的析构函数,语句(17)调用基类的析构函数。细心的朋友会发现这里的析构函数的地址与前面VTable中析构函数地址不同。其实,它们的名字也不一样,它们是两个函数:

[email protected]@[email protected]:
004016A0    push
ebp
            ...
(18)        call
CChild1::~CChild1
(00401590)
            ...
(19)        call
operator delete
(004023a0)
            ...
            ret
4

如果在调试器中看(或者用dem工具Demangling),第二个析构函数的名字是CChild1::`scalar
deleting destructor‘,前一个析构函数的名字是CChild1::~CChild1。函数CChild1::`scalar deleting
destructor‘在语句(18)上调用前面的析构函数,在语句(19)上调用delete函数释放对象空间。

在通过delete删除对象指针时,需要在析构后释放对象空间,所以编译器合成了第二个析构函数。通过VTable调用析构函数,肯定是delete对象指针引发的,所以VTable中放的是第二个析构函数。在析构堆栈上的对象时,只要调用第一个析构函数就可以了。

2.3 虚析构函数

千万不要将析构函数和虚函数混淆起来。不管析构函数是不是虚函数,编译器都会按照2.2节的介绍合成析构函数。将析构函数设为虚函数是希望在通过基类指针删除派生类对象时调用派生类的析构函数。如果析构函数不是虚函数,派生类对象没有Vptr,编译器会调用基类的析构函数(在编译时就确定了)。

这样,用户在派生类析构函数中填写的代码就不会被调用,派生类成员对象的析构函数也不会被调用。不过,派生类对象空间还是会被正确释放的,堆管理程序知道对象分配了多少空间。

3 不同的实现

本文的目的只是通过对编译器内部实现的适当了解,加深对C++基本概念的理解,我们的代码不应该依赖可能会改变的内部机制。其实各个编译器对相同机制的实现也会有较大差异。例如:Vptr的位置就可能有多种方案:

  1. VC的编译器把Vptr放在对象头部

  2. BCB的编译器将Vptr放在继承体系中第一个有Vptr的对象头部

  3. Dev-C++的编译器以前将Vptr放在继承体系中第一个有Vptr的对象尾部

Dev-C++的最新版本(4.9.9.2)也将Vptr放在对象头部。其实第1个方案有一个小问题:如果基类对象没有Vptr,而派生类对象有Vptr,让基类指针指向派生类对象时,编译器不得不调整基类指针的地址,让其指向Vptr后的基类非静态成员。以后如果通过基类指针delete派生类对象,由于delete的地址与分配地址不同,就会发生错误。读者可以在演示程序中找到研究这个问题的代码(其实是CSDN上一个网友的问题)。将Vptr放在其它两个位置,因为不用调整基类指针,就可以避免这个问题。

g++编译器(v3.4.2)产生的程序在打印虚函数地址时会输出:

CParent1::test地址 0x00000009
CChild1::test地址 0x00000009

在通过函数指针调用函数时,编译器会通过这个数字9在对象的虚函数表中找到虚函数test。

附录1 增量链接和ILT

为了简化表述,演示程序的VC6项目设置(Debug版)关闭了“Link
Incrementally”选项。如果打开这个选项,编译器会通过一个叫作ILT的数组间接调用函数。数组ILT的每个元素是一条5个字节的jmp指令,例如:

@ILT+170([email protected]@@QAEXXZ):
    004010AF:
E9 1C 10 00 00 jmp [email protected]@@QAEXXZ

编译器调用函数时:

call @ILT+170([email protected]@@QAEXXZ)

通过ILT跳转到函数的实际地址。这样,在函数地址变化时,编译器只需要修改ILT表,而不用修改每个引用函数的语句。ILT是编译器开发者起的变量名,据网友Cody2k3猜测,可能是Incremental
Linking Table的缩写。

附录2 C++的Name Mangling/Demangling

C++编译器会将程序中的变量名、函数名转换成内部名称,这个过程被称作Name Mangling,反过程被称作Name
Demangling。内部名称包含了变量或函数的更多信息,例如编译器看到[email protected]@3HA,就知道这是:

int g_var

"3H"表示int型的全局变量。编译器看到[email protected]@@QAEXXZ,知道这是:

public: void __thiscall CChild2::test(void)

编译器厂商一般不会公布Mangling的规则,因为这些规则可能会根据需求变化。不过,微软提供了一个Demangling的函数UnDecorateSymbolName。我用这个函数写了一个叫作“dem”的小工具,可以从内部名称得到变量或函数的声明信息。读者可以从我的个人主页下载这个工具(http://www.fmddlmyy.cn/dem.zip)。

关于“C++的Name Mangling/Demangling”的更多介绍,读者可以参见http://www.kegel.com/mangle.html

附录3 关于thunk

据说一个Algol-60程序员第一次使用“thunk”这个词汇,最初的语义源自"thought of (thunked)"
。这个单词的主要语义是“地址转换、替换程序”,一般是指通过一小段汇编代码,转调另一个函数。调用者在调用thunk代码时以为自己在调用一个函数,thunk代码会将控制转交给一个它选择的函数。例如:附录一介绍的ILT数组的每个元素都是一小段thunk代码。

附录4 在g++中生成mapfile

在通过gcc/g++间接调用链接程序ld时,所有的ld选项前必须加上“-Wl,”。所以,要让g++生成mapfile,需要增加编译参数“
-Wl,-Map,mapfile”。

from:http://blog.csdn.net/fmddlmyy/article/details/1510176

眼见为实(1):C++基本概念在编译器中的实现,布布扣,bubuko.com

时间: 2024-10-31 09:06:55

眼见为实(1):C++基本概念在编译器中的实现的相关文章

Xcode4.4(LLVM4.0编译器)中NSArray, NSDictionary, NSNumber优化写法

Xcode4.4(LLVM4.0编译器)中NSArray, NSDictionary, NSNumber优化写法 从xcode4.4开始,LLVM4.0编译器为Objective-C添加一些新的特性.创建数组NSArray,字典NSDictionary, 数值对象NSNumber时,可以像NSString的初始化一样简单方便.妈妈再也不担心程序写得手发酸了. A.   NSArray 首先是非常常用的NSArray,NSMutableArray.NSArray是一个初始化后就固定的静态数组.如果

vim编译器中多行注释方法(尤其对python代码注释)

------------------------------------------------------vim编译器中多行注释-------------------------------------------------------- 在vim命令下编写python程序时,有时候要进行多行注释,比较麻烦.因为python不像c语言那样可以用/*xxxx*/进行多行注释,只能每一行用#来注释,如果有几百行那得注释到什么时候.除了老老实实的一行一行注释外,这里再分享几种方法: 第一种:把要注

从开源编译器中移植代码的正确姿势

转眼间已经到了大三下学期了,马上就要实习了,最后一个学期我会好好珍惜的.为了让这个学期过的有格调,我打算每一件事情都做得有逼格一点. 开学第三周我们学校就开始陆陆续续有实验课了,做一个词法分析器? 好嘞,劳资要用Sourceinsight把开源的GCC里面的词法分析代码全部移植出来,就问你牛不牛批!!! 脑海中已经浮现出了助教对我异样的眼光,哈哈,不多说了,盘它!!!!!! 首先,进入gcc的官网,找到它最旧的一个版本(好像88年就有gcc了),但是我在官网上只能找到98年的.整个文件也才几百K

Python 学习之路 - 模块概念,模块中的特殊变量,os、sys模块

模块概念 1 #注意:自定义的模块名不与标准模块名相同,若相同只会导入自定义模块 2 #单模块并在同一目录下: 3 #直接用 import 模块名,调用时用 模块名.方法名() 4 #嵌套在其他文件夹下: 5 #from xxx import xxx as 别名 6 import example 7 import lib.commons 8 from lib import commons as lib_commons 9 from src import commons as src_common

4.事务提交过程,事务基本概念,Oracle中的事务生命周期,保存点savepoint,数据库的隔离级别

 事务提交过程 事务 基本概念 概念:一个或者多个DML语言组成 特点:要么都成功,要么都失败 事务的隔离性:多个客户端同时操作数据库的时候,要隔离它们的操作, 否则出现:脏读  不可重复读  幻读 Oracle默认情况下,事务是打开的 commit案例: SQL> create table t1(tid int,tname varchar2(20)); 表已创建. SQL> select * from tab; TNAME                          TABTYPE

栈变量被覆盖的问题在不同编译器中的表现,蛋疼的VC++

看到一道题说栈中连续定义一个指针和一个数组,类似于这样 { char *ptr = 指向一个字符数组; char buf[8]; gets(buf); strncpy(ptr,buf,8); } 当修改buf数组时发生越界会修改ptr指针的指向,这设计到程序健壮性的问题. 当我用VS2013尝试这段代码时,诡异的事情发生了,明明输入了多于8个的字符,但是ptr的指向并没有改变. 于是我尝试了这样的代码 #include <stdio.h> #include <stdlib.h> i

[ArcGIS API for JavaScript 4.8] Sample Code-Popups-1-popupTemplate的概念和popup中属性字段值的多种表现形式

[官方文档:https://developers.arcgis.com/javascript/latest/sample-code/intro-popuptemplate/index.html] 一.Intro to PopupTemplate(关于popup模板) popups(弹出框)提供了一种简便的方式去查看layers(图层)或者graphics(图形)的属性信息.它也可以展示鼠标在view(视图)中的点击位置的坐标等其他相关信息.在ArcMap中,查看要素图层(shapefile)的属

警惕Java编译器中那些“蜜糖”陷阱

一.前言 随着Java编译器不断地向前发展,它为程序员们提供了越来越多的“蜜糖”(compiler suger),极大地方便了程序的开发,例如,foreach的增强模式,自动拆箱与装箱以及字符串的连接操作...... 这些"蜜糖"带给我们很多的便利,但是也存在着一些陷阱. 二.自动拆装箱陷阱 首先我们来看看大家最为熟悉的自动拆装箱(boxing),boxing可以自动帮我们完成基本类型和基本类型包裹器之间的转换. 具体使用方法可以参考有名的Java Gossip(http://open

gfortran、g77等编译器中使用多个文件

gfortran aaaa.f90 bbbb.f90 -o cccc (生成cccc可执行文件,cccc名称可自由设定) 又可以分成两步,因为gfortran先把程序文件编译成*.o文件,再把*.o文件链接成执行文件.详细的步骤会分为下面3个程序: gfortran -c aaaa.f90(编译aaaa.f90,生成aaaa.o) gfortran -c bbbb.f90(编译bbbb.f90,生成bbbb.o) gfortran aaaa.o bbbb.o -o cccc(链接出执行文件) 在