用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如何调用可执行文件中函数,准备专门用另一篇文章讲解,目前可先参考此文

重新编译后,执行代码可以正确打印代码,证明此处确实是虚函数的地址。

以上的内存结构图示:

————未完————

posted @   Luke老黑  阅读(461)  评论(1编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
点击右上角即可分享
微信分享提示