用gdb看C++虚函数表及虚继承
一. 虚函数表在哪?
在.cpp文件中定义一个如下的类
// vtable_test.cpp
class Com_class {
public:
int mem1;
static int mem2;
const static int mem3 = 3;
virtual void func_v() {cout << "call func_virtual" << endl;}
void func_c() {cout << "call func_common " << endl;}
static void func_s() {cout << "call func_static " << endl;}
};
int Com_class::mem2 = 2;
int main(){...}
那么对该类来说,它的虚函数存放在何处?普通成员函数和静态成员函数又存放在哪?
首先,按照一般思路,考虑成员函数应该是类对象公有的,即存放在代码区(.text)。
先从符号表验证一下函数存放的位置,使用g++对文件进行编译,再使用readelf -s读取可执行文件中符号表的信息:
# g++ vtable_test.cpp -o vt
readelf -s vt | egrep "func_|Ndx"
得到如下的输出,其中Ndx表示符号所在段(section)编号:
Num: Value Size Type Bind Vis Ndx Name
55: 00000000000013aa 59 FUNC WEAK DEFAULT 16 _ZN9Com_class6func_cEv
71: 00000000000013e5 51 FUNC WEAK DEFAULT 16 _ZN9Com_class6func_sEv
73: 000000000000136e 59 FUNC WEAK DEFAULT 16 _ZN9Com_class6func_vEv
如果在符号表中找不到这三个函数,可以创建一个实例并对这三个成员函数进行调用,防止编译器优化
由于C++的符号表中函数名携带有命名空间、类、参数信息以支持重载等,因此会与C语言编译后的符号表中比较直观的函数名称有差异。可以使用以下工具对符号表中名称进行解析:
# c++filt _ZN9Com_class6func_vEv
Com_class::func_v()
可以看到,解析后的函数名,确实是我们在类中声明并定义的。
找到符号所在的段编号之后,我们再通过以下指令查找一下段头(section header)中编号为16的段:
# readelf -S vt | egrep -A 1 "\[16\]"
[16] .text PROGBITS 0000000000001120 00001120
0000000000000395 0000000000000000 AX 0 0 16
不出所料,编号为16的段确实是.text段,即代码段,与我们的推测相符。
那么虚函数在内存中,到底与普通成员函数有什么不同呢?
为了解决这个问题,我们首先创建一个类的实例对象:
int main() {
Com_class *cc = new Com_class{};
cc->func_v();
cc->func_c();
cc->func_s();
delete cc;
}
随后,重新进行编译,这次g++使用-g参数,这样我们可以用gdb工具进行分析:
# g++ -g vtable_test.cpp -o vt_g
# gdb vt_g
进入调试界面,查看实例对象的内存结构:
(gdb) set print pretty on
(gdb) p *cc
(gdb) p *cc
$2 = {
_vptr.Com_class = 0x55ebbeee9d58 <vtable for Com_class+16>,
mem1 = 0,
static mem2 = 2,
static mem3 = 3
}
在该实例对象的内存结构中,存储的第一个元素是一个名为_vptr.Com_class的指针变量。该指针变量的值即为虚函数表的地址,并且可以看到这个地址的值,那么只需要知道这个地址的值所处的段,即可知道虚函数表存储的位置。
还有另外一种方法,可以在创建对象后附上以下代码,直接打印出虚函数表及虚函数的地址:
// cout << sizeof(long long) << endl;
long long vt_addr = *(long long *) cc;
cout << "vtable address: 0x" << hex << vt_addr << endl;
long long vf_addr = **(long long **) cc;
cout << "vfunction address: 0x" << hex << vf_addr << endl;
使用long long 类型是因为64位系统下,指针占8位地址,如32位系统一般可使用int
**(long long **) cc 的含义是,取cc指示的前8位数据cc_p8,再取cc_p8的前8位数据
另外,可以在gdb中使用print (*(void ***)cc)
打印vtable地址,print (**(void ***)cc)
打印虚函数地址
此时打印结果为
vtable address: 0x55ebbeee9d58
vfunction address: 0x55ebbeee74e6
该结果与上述用gdb查看虚函数表地址相同。
到此,在gdb中查看sections的地址值:
(gdb) maintenance info sections
...
[15] 0x55ebbeee7140->0x55ebbeee7625 at 0x00001140: .text ALLOC LOAD READONLY CODE HAS_CONTENTS
[16] 0x55ebbeee7628->0x55ebbeee7635 at 0x00001628: .fini ALLOC LOAD READONLY CODE HAS_CONTENTS
[17] 0x55ebbeee8000->0x55ebbeee8073 at 0x00002000: .rodata ALLOC LOAD READONLY DATA HAS_CONTENTS
[18] 0x55ebbeee8074->0x55ebbeee8120 at 0x00002074: .eh_frame_hdr ALLOC LOAD READONLY DATA HAS_CONTENTS
[19] 0x55ebbeee8120->0x55ebbeee83d0 at 0x00002120: .eh_frame ALLOC LOAD READONLY DATA HAS_CONTENTS
[20] 0x55ebbeee9d30->0x55ebbeee9d40 at 0x00002d30: .init_array ALLOC LOAD DATA HAS_CONTENTS
[21] 0x55ebbeee9d40->0x55ebbeee9d48 at 0x00002d40: .fini_array ALLOC LOAD DATA HAS_CONTENTS
[22] 0x55ebbeee9d48->0x55ebbeee9d70 at 0x00002d48: .data.rel.ro ALLOC LOAD DATA
...
可以看到,虚函数表所处的地址位于.data.rel.ro地址范围内,而虚函数则是位于.text范围内。
为了验证是否可以直接通过该地址的值调用虚函数,我们再加入如下代码:
((void(*)(void)) (**(long long **) cc))();
(void(*)(void))表示强制类型转换为一个返回值为void,参数为void的函数指针
也可以直接在gdb中执行(gdb) print ((void(*)(void)) (*(void ***) cc)[1]) ()
验证能否调用虚函数,关于gdb如何调用可执行文件中函数,准备专门用另一篇文章讲解,目前可先参考此文
重新编译后,执行代码可以正确打印代码,证明此处确实是虚函数的地址。
以上的内存结构图示:
————未完————
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?