周末闲在家里无聊,拜读了一遍Lippman的《深度探索C++对象模型》。简单刷完前三章,有种似懂非懂的感觉,毕竟,纸上得来终觉浅,只是简单的灌输知识无法理解深刻。于是决定打开电脑,躬行此事,一探C++对象背后的秘密。
使用GDB探索对象内存布局
以该demo为例,使用GDB工具来探索C++对象在内存中的布局:
#include<iostream>
class father{
public:
father();
virtual void vfunc1();
virtual void vfunc2();
public:
long _f;
};
class child: public father{
public:
child();
virtual void vfunc1();
public:
long _c;
};
father::father() {
_f = 0x888;
std::cout << "Call father()" << std::endl;
}
void father::vfunc1() {
std::cout << "Call father::vfunc1()" << std::endl;
}
void father::vfunc2() {
std::cout << "Call father::vfunc2()" << std::endl;
}
child::child() {
_c = 0x666;
std::cout << "Call child()" << std::endl;
}
void child::vfunc1() {
std::cout << "Call child::vfunc1()" << std::endl;
}
int main(){
father* pc = new child();
father* pf = new father();
std::cout << "sizeof father is " << sizeof(father) << std::endl;
std::cout << "sizeof child is " << sizeof(child) << std::endl;
return 0;
}
编译指令如下:
g++ ./object_model.cpp -o object -g -std=c++11
使用GDB开始调试可执行文件object:
(gdb) b main
Breakpoint 1 at 0x400c08: file ./object_model.cpp, line 43.
(gdb) r
Starting program: /home/workspace/practice/C++/object_model/object
Breakpoint 1, main () at ./object_model.cpp:43
43 father* pc = new child();
(gdb) b 47
Breakpoint 2 at 0x400c8c: file ./object_model.cpp, line 47.
(gdb) c
Continuing.
Call father()
Call child()
Call father()
sizeof father is 16
sizeof child is 24
Breakpoint 2, main () at ./object_model.cpp:47
47 return 0;
(gdb) p pc
$1 = (father *) 0x614c20
(gdb) x/10xg pc
0x614c20: 0x0000000000400e38 0x0000000000000888
0x614c30: 0x0000000000000666 0x0000000000000411
0x614c40: 0x6320666f657a6973 0x20736920646c6968
0x614c50: 0x000000000a0a3432 0x0000000000000000
0x614c60: 0x0000000000000000 0x0000000000000000
(gdb) x/10xg pf
0x615050: 0x0000000000400e58 0x0000000000000888
0x615060: 0x0000000000000000 0x000000000001ffa1
0x615070: 0x0000000000000000 0x0000000000000000
0x615080: 0x0000000000000000 0x0000000000000000
0x615090: 0x0000000000000000 0x0000000000000000
既然要研究对象的内存布局,最简单直接的莫过于直接把内存布局打印出来。不过,内存中都是0和1的排序,用不同格式解读会带来不同结果,在本例中father和child的成员都定义为long类型,这是因为在64位环境下,指针(地址)是64bits,因此将成员也定义成64bits,方便在查看内存时统一用64bits为单位去解释内存。
查看指定内存的内容使用GDB的x命令,"x/10xg pf"的意思是**”以pf为起始地址,往后打印10个单位的内容,每个单位占64bits,内容以16进制格式输出“**,x命令具体用法请见https://visualgdb.com/gdbreference/commands/x 。
前面通过sizeof知道了child和father对象的大小分别为24和16Bytes,再结合gdb中打印的地址内容,可以知道,该例中child对象*pc在内存中内容排布为:0x0000000000400e38,0x0000000000000888和0x0000000000000666。后两个数据很明显,0x888和0x666明显是father和child的数据成员,第一个0x400e38又是什么呢?
首先猜一下,0x400e38看着大概率是个地址。可以使用GDB的info symbol address命令查看某个地址对应的符号名:
(gdb) info symbol 0x0000000000400e38
vtable for child + 16 in section .rodata of /home/workspace/practice/C++/object_model/object
GDB告诉我们0x400e38是child虚表往后偏移16个字节的地址,可以使用info vtbl object命令(查看对象的虚表)验证一下:
(gdb) info vtbl *pc
vtable for 'father' @ 0x400e38 (subobject @ 0x614c20):
[0]: 0x400bd2 <child::vfunc1()>
[1]: 0x400b56 <father::vfunc2()>
(gdb) info vtbl *pf
vtable for 'father' @ 0x400e58 (subobject @ 0x615050):
[0]: 0x400b2a <father::vfunc1()>
[1]: 0x400b56 <father::vfunc2()>
可以看到,0x400e38确实指向child虚表(为什么GDB中显示的是vtable for ‘father’ @ 0x400e38? 应该是vtable for ‘child’ @ 0x400e38才对,若有知情者还请不离赐教!!!)。
根据以上信息,可以画出child对象内存布局:
从GDB中”info symbol 0x0000000000400e38“的结果来看,0x400e38只是child vtable的+16bytes偏移地址,那么child vtable的前16bytes是什么内容呢? 下面在GDB中继续探索:
(gdb) x/10xg 0x400e28
0x400e28 <_ZTV5child>: 0x0000000000000000 0x0000000000400e68
0x400e38 <_ZTV5child+16>: 0x0000000000400bd2 0x0000000000400b56
0x400e48 <_ZTV6father>: 0x0000000000000000 0x0000000000400e88
0x400e58 <_ZTV6father+16>: 0x0000000000400b2a 0x0000000000400b56
0x400e68 <_ZTI5child>: 0x0000000000602220 0x0000000000400e80
(gdb) info symbol 0x0000000000400e68
typeinfo for child in section .rodata of /home/workspace/practice/C++/object_model/object
可以看到,child vtable的前16bytes分别为8 Bytes的0和child的typeinfo地址,于是上图可进一步完善:
child对象的内存布局基本上清晰了。我们还可以再深入一些,前面GDB调试过程中可以看到,类的虚表也是个符号,既然是符号,我们就可以在ELF文件中看到:
harry@HP:~/workspace/practice/C++/object_model$ nm --demangle ./object
... ... ... ...
0000000000400bd2 T child::vfunc1()
0000000000400b82 T child::child()
0000000000400b82 T child::child()
0000000000400b2a T father::vfunc1()
0000000000400b56 T father::vfunc2()
0000000000400ae6 T father::father()
0000000000400ae6 T father::father()
... ... ... ...
0000000000400e68 V typeinfo for child
0000000000400e88 V typeinfo for father
0000000000400e80 V typeinfo name for child
0000000000400e98 V typeinfo name for father
0000000000400e28 V vtable for child
0000000000400e48 V vtable for father
使用nm命令查看object文件的符号信息,可以看到确实有child和father类的虚表符号,且地址完全能对的上(需要注意的是,如果是在shared library中,需要将偏移加上shared library的base address才能与真实的虚拟地址对上),亦能佐证前面通过GDB推断出来的内存布局。
在代码中获得虚表
前面已经通过GDB了解了C++简单对象的内存布局,下面尝试在代码中通过对象内存布局间接拿到其虚函数地址并访问,以进一步验证前面的结论。
#include<iostream>
class father{
public:
father();
virtual void vfunc1();
virtual void vfunc2();
public:
long _f;
};
class child: public father{
public:
child();
virtual void vfunc1();
public:
long _c;
};
father::father() {
_f = 0x888;
std::cout << "Call father()" << std::endl;
}
void father::vfunc1() {
std::cout << "Call father::vfunc1()" << std::endl;
}
void father::vfunc2() {
std::cout << "Call father::vfunc2()" << std::endl;
}
child::child() {
_c = 0x666;
std::cout << "Call child()" << std::endl;
}
void child::vfunc1() {
std::cout << "Call child::vfunc1()" << std::endl;
}
int main(){
typedef void(*FuncType)(void);
father* pc = new child();
FuncType** vtbl_ptr = (FuncType**)pc;
std::cout << "Will call child object's virtual function ... ..." << std::endl;
// 通过虚表地址间接访问虚表中的虚函数
for(int i=0; vtbl_ptr[0][i] != nullptr; i++) {
FuncType vfunc= vtbl_ptr[0][i];
vfunc();
}
// 通过对象首地址获得其内部成员
std::cout << "child._c = " << std::hex << ((long*)vtbl_ptr)[2] << std::endl;
std::cout << "father._f = " << std::hex << ((long*)vtbl_ptr)[1] << std::endl;
return 0;
}
结果如下,基本可验证内存布局的正确性:
harry@HP:~/workspace/practice/C++/object_model$ ./object
Call father()
Call child()
Will call child object's virtual function ... ...
Call child::vfunc1()
Call father::vfunc2()
child._c = 666
father._f = 888
总结
读过《深度探索C++对象模型》一书会发现,本文中通过GDB探究的child对象内存布局与书中的描述略有差异———虚表开头是否有8Bytes的0填充。这一细节其实无所谓对错,因为未在语言层面规定,具体细节视编译器的实现而定。
本文想表达的观点在于:书中描述的结论并不重要,没必要死记硬背。重要的是结论背后的逻辑和依据,以及独立探究并验证结论的能力。