虚函数技术分析

多态

Posted by Jiayang Hu on September 3, 2025

多态场景

多态这个词能看到这个博客的肯定都很熟了,这边来回顾下。

class Base {
public:
    virtual void func1() { /* Base's func1 */ }
    virtual void func2() { /* Base's func2 */ }
    int base_data;
};

class Derived : public Base {
public:
    void func1() override { /* Derived's func1 */ } // 重写 func1
    virtual void func3() { /* Derived's func3 */ }  // 新的虚函数
    int derived_data;
};

​虚函数表 (vtable):​​

• ​​位置:​​ ​​只读数据段 (.rodata)​​ 或类似的只读内存区域。

• ​​性质:​​ vtable是​​类级别​​的元数据。同一个类的所有对象共享同一个 vtable。

• ​​内容:​​ 包含该类所有虚函数的函数指针(地址)。这些地址在编译时或程序加载时就已经确定。

• ​​可写性:​​ 通常是​​只读​​的。程序运行时不应该修改 vtable的内容,否则会导致未定义行为。编译器将其放在 .rodata段就是为了保证这一点。

​​虚函数表指针 (vptr):​​

• ​​位置:​​ ​​对象实例内部​​。具体位置取决于对象实例本身存储在内存的哪个区域。

• 如果对象是​​全局/静态对象​​,vptr就在​​全局/静态数据区​​。

• 如果对象是​​局部自动对象 (栈上)​​,vptr就在​​栈 (stack)​​ 上。

• 如果对象是​​动态分配的对象 (堆上)​​,vptr就在​​堆 (heap)​​ 上。

• ​​性质:​​ vptr是​​对象实例级别​​的数据。每个具有虚函数的对象实例都拥有自己的 vptr。

• ​​内容:​​ 存储一个指针,指向该对象所属类的 vtable。

• ​​可写性:​​ ​​可写​​。在对象的构造和析构过程中,vptr会被修改(例如,在构造子类对象时,先指向父类 vtable,再指向子类 vtable)。这就是为什么它不能放在只读段。

​​代码段 (.text):​​

• ​​位置:​​ 存放程序执行的​​机器指令​​(即函数体的实际代码)。

• ​​与虚函数的关系:​​ vtable中存储的函数指针,最终指向的就是位于 ​​.text 段​​ 中的这些虚函数的具体实现代码。

关于父类和子类的虚表:​

• ​​父类:​​ 如果父类声明了虚函数(或继承了虚函数),编译器会为父类生成一个​​独立的 vtable​​。这个 vtable存储在 .rodata段。

• ​​子类:​​ 编译器也会为子类生成一个​​独立的 vtable​​,同样存储在 .rodata段。

• 子类的 vtable通常是在父类 vtable的基础上​​扩展​​而来:

• 前 N 个条目(N 是父类虚函数的数量)对应继承自父类的虚函数。

• 如果子类重写了某个父类虚函数,则子类 vtable中对应的条目指向子类的实现。

• 如果子类没有重写,则子类 vtable中对应的条目指向父类的实现。

• 之后的条目对应子类自己新声明的虚函数。

• ​​指针指向:​​

• 所有​​父类对象​​的 vptr都指向​​父类的 vtable​​(位于 .rodata)。

• 所有​​子类对象​​的 vptr都指向​​子类的 vtable​​(位于 .rodata)。

• 当父类指针指向子类对象时,它访问到的 vptr是子类对象内部的 vptr,而这个 vptr指向的是​​子类的 vtable​​。这就是多态调用的关键!

​​总结内存布局图

例子 Base* ptr = new Derived();​

内存区域         | 内容
----------------|-------------------------------------------------------
.text (代码段)   | &Base::func1 (代码)   &Base::func2 (代码)
                | &Derived::func1 (代码) &Derived::func3 (代码)
----------------|-------------------------------------------------------
.rodata (只读数据段)| Base's vtable: [ &Base::func1, &Base::func2 ]
                | Derived's vtable: [ &Derived::func1, &Base::func2, &Derived::func3 ]
----------------|-------------------------------------------------------
Heap (堆)       | Derived 对象实例:
                |   +---------------------+
                |   | vptr -------------->|----> (指向 .rodata 中的 Derived's vtable)
                |   +---------------------+
                |   | base_data (int)     | <-- Base 子对象部分 (ptr 指向这里)
                |   +---------------------+
                |   | derived_data (int)  | <-- Derived 特有部分
                |   +---------------------+
----------------|-------------------------------------------------------
Stack (栈)      | Base* ptr = address_of_above_Derived_object

调用过程 ptr->func1();的底层步骤 (细化内存访问):​​

  1. ​​ptr的值:​​ 存储在栈上的指针变量 ptr保存着堆上 Derived对象中 Base子对象的起始地址。

  2. ​​获取 vptr:​​

• 通过 ptr的值找到堆上的对象内存。

• 编译器知道 vptr在对象布局中的偏移量(通常是对象起始位置)。​​读取​​这个内存位置的值。这个值就是 vptr,它指向 .rodata段中的 Derived’s vtable。

  1. ​​定位 vtable条目:​​

• 编译器知道 func1在 vtable中的索引(比如索引 0,由编译器在编译类定义时确定)。

• 计算目标条目的地址:vptr + (index * sizeof(function_pointer))。

• ​​读取​​ .rodata段中这个地址处的值。这个值就是 &Derived::func1(一个指向 .text段中代码的指针)。

  1. ​​调用函数:​​

• 处理器跳转到上一步取到的函数指针 (&Derived::func1) 所指向的地址,该地址位于 .text段。

• 执行 Derived::func1()的机器指令。

整个过程的核心在于:通过对象内部的 vptr(存储在堆/栈/全局区)找到类级别的 vtable(存储在只读段),再通过 vtable找到函数代码(存储在代码段),最终实现运行时动态绑定。