【问题标题】:c++ virtual table lookup - how does it search & replacec ++虚拟表查找 - 它如何搜索和替换
【发布时间】:2026-02-01 10:50:02
【问题描述】:

举个例子:

class Base{
    virtual string function1(){ return "Base - function1"; };
    virtual string function2(){ return "Base - function2"; };
};

class Derived : public Base {
    virtual string function2(){ return "Derived - function2"; };
    virtual string function1(){ return "Derived - function1"; };
    string function3() { return "Derived - function3"; };
};

所以vtable结构是这样的

Base-vTable
-----------------------
name_of_function address_of_function
function1   &function1
function2   &function2
-----------------------
-----------------------
Derived-vTable
-----------------------
name_of_function address_of_function
function1   &function1
function2   &function2

或者是这样的

    Base-vTable
-----------------------
    Offset function
    +0  function1
    +4  function2
-----------------------
-----------------------
    Derived-vTable
-----------------------
    Offset function
    +0  function1
    +4  function2

如果是后者呢?那这个偏移量是多少?用在什么地方?

还有函数名: 它是错位的函数名称吗?如果它被损坏,则基本名称和派生的损坏名称将不匹配,并且 vtable 查找将不起作用。 编译器确实修改了所有的虚函数名称,所以它必须是一个修改过的名称,这是否意味着如果它是虚函数,则基数和派生的修改过的名称是相同的。

【问题讨论】:

  • virtual mechanism 是实现定义的。它没有任何单一的方法。取决于系统。
  • @avakar:我读过一些关于这个主题的论文,特别是为了高效实现多方法,但我知道没有真正的编译器使用它们。通常有更有效的方法(存储方面),但它们也更复杂,而 v-table 足够简单,可以确保下注(编译器编写者也有更多经验)。
  • 这篇文章可能对以下主题有所帮助:“C++:幕后”,openrce.org/articles/files/jangrayhood.pdf
  • @David:这篇关于 Eiffel 的论文:Efficient Dynamic Dispatch without virtual Function Tables Olivier ZENDRA、Dominique COLNET 和 Suzanne COLLIN(更具体地说是 3.2 删除虚拟函数表),请注意他们的提议如果我没记错的话,只有“密封”类层次结构才有可能,当你有整个程序分析和模块时,它工作得很好。我不知道我是否见过 C++ 的东西。
  • @MatthieuM.:感谢指点,实际文章是here

标签: c++ virtual


【解决方案1】:

虚拟表只是函数指针的数组,就像您的第二个 sn-p 一样。编译器将对虚函数的调用转换为通过指针调用,例如

Base * b = /* ... */;
b->function2();

被翻译成

b->__vtable[1]();

我使用名称 __vtable 来指代虚拟表(但请注意,虚拟表通常不能直接访问)。

表中条目的顺序由函数在类中声明的顺序决定。请记住,类定义在调用点始终可用。

【讨论】:

  • 感谢您的回答。但我的问题是,编译器如何知道 [1] 用于 function2 而 [0] 用于 function1。此信息/映射存储在哪里?
  • @harish,看我的编辑,是由申报顺序决定的。
  • @harish:它在编译过程中作为临时信息存储,但不会在最终生成的二进制文件中编码。由于算法是确定性的(通常它只是按照声明的顺序获取虚函数),编译器不需要存储它,它可以在需要时从源文件中重新计算它,并且偏移量__vtable[1] 是硬编码的在编译时的二进制文件中。
  • @harish:考虑如何执行任何其他函数的分派:当编译器找到调用时,它标记对符号的依赖,然后链接器将其解析为指向要执行的函数的指针.它怎么知道 what 指针?因为它是首先生成功能代码的人。这里也是一样。编译器将创建vtable,并分配槽,因为它生成vtable,它知道它的布局以及如何从函数调用映射到vtable 槽。然后链接器会将指针注入插槽。
【解决方案2】:

我正在解释以下代码。我想它会让你清楚

  Base *p = new Derived;
  p->function2();

在编译时,创建了VTable,Base类的VTable与Derived类的VTable相同。我的意思是两者都有你在第一种情况下提到的两个功能。编译器插入代码来初始化正确对象的 vptr。

当编译器看到语句 p->function2(); 时,它不会对被调用的函数做任何绑定,因为 t 只知道 Base 对象。从 Base 类的 VTable 可以知道 function2 的位置(这里是 VTable 中的第 2 个位置)。

在运行时,Dervied 类的 VTable 被分配给 vptr。调用 VTable 的第二个位置的函数。

【讨论】:

    【解决方案3】:

    清除此问题的最简单方法是查找实际实现。

    考虑以下代码:

    struct Base { virtual void foo() = 0; };
    
    struct Derived { virtual void foo() { } };
    
    Base& base();
    
    void bar() {
      Base& b = base();
      b.foo();           // virtual call
    }
    

    现在,将其提供给 Clang 的 Try Out 页面以获取 LLVM IR:

    ; ModuleID = '/tmp/webcompile/_6336_0.bc'
    target datalayout = "e-p:64:64:64-i1:8:8-i8:8:8-i16:16:16-i32:32:32-i64:64:64-f32:32:32-f64:64:64-v64:64:64-v128:128:128-a0:0:64-s0:64:64-f80:128:128-n8:16:32:64"
    target triple = "x86_64-unknown-linux-gnu"
    
    %struct.Base = type { i32 (...)** }
    
    define void @_Z3barv() {
      %1 = tail call %struct.Base* @_Z4basev()
      %2 = bitcast %struct.Base* %1 to void (%struct.Base*)***
      %3 = load void (%struct.Base*)*** %2, align 8
      %4 = load void (%struct.Base*)** %3, align 8
      tail call void %4(%struct.Base* %1)
      ret void
    }
    
    declare %struct.Base* @_Z4basev()
    

    由于我想您可能还不了解 IR,所以让我们逐个回顾一下。

    先来一些你不应该担心的东西。它标识了为其编译的架构(处理器和系统)及其属性。

    ; ModuleID = '/tmp/webcompile/_6336_0.bc'
    target datalayout = "e-p:64:64:64-i1:8:8-i8:8:8-i16:16:16-i32:32:32-i64:64:64-f32:32:32-f64:64:64-v64:64:64-v128:128:128-a0:0:64-s0:64:64-f80:128:128-n8:16:32:64"
    target triple = "x86_64-unknown-linux-gnu"
    

    然后,LLVM 会学习类型:

    %struct.Base = type { i32 (...)** }
    

    它从结构上分析类型。所以在这里我们只得到Base 将由单个元素i32 (...)** 组成:这实际上是“臭名昭著”的v-table 指针。为什么是这种奇怪的类型?因为我们会在v-table中存储很多不同类型的函数指针。这意味着我们将有一个异构数组(这是不可能的),因此我们将其视为“通用”未知元素的数组(以标记我们确保那里有什么),这取决于应用程序进行转换在实际使用它之前指向适当的函数指针类型的指针(或者更确切地说,如果我们在 C 或 C++ 中,IR 的级别要低得多)。

    跳到最后:

    declare %struct.Base* @_Z4basev()
    

    这声明了一个函数(_Z4basev,名称被修改了),它返回一个指向Base的指针:在 IR 引用中,指针都由指针表示。

    好的,让我们看看bar 的定义(或_Z3barv,因为它被破坏了)。这就是有趣的地方:

      %1 = tail call %struct.Base* @_Z4basev()
    

    base 的调用,它返回一个指向Base 的指针(返回类型总是在调用处精确,更容易分析),它存储在一个名为%1 的常量中。

      %2 = bitcast %struct.Base* %1 to void (%struct.Base*)***
    

    一个奇怪的位广播,将我们的Base* 转换为一个指向奇怪事物的指针......本质上,我们正在获取 v-table 指针。它没有被“命名”,我们只是在类型的定义中确保它是第一个元素。

      %3 = load void (%struct.Base*)*** %2, align 8
      %4 = load void (%struct.Base*)** %3, align 8
    

    我们首先加载 v-table(%2 指向),然后加载函数指针(%3 指向)。此时,%4 因此是&Derived::foo

      tail call void %4(%struct.Base* %1)
    

    最后,我们调用该函数,并将this 元素传递给它,这里明确。

    【讨论】:

    • 非常感谢您花时间详细解释它。在 %3 和 %4 之间,哪个元素指定 - vtable 中函数的偏移量是多少?对齐8?在这种情况下,我们只有 1 个函数。如果我们有 2 个虚函数呢?
    • @harish: align 8 指定必要的内存对齐方式,在 64 位架构上,指针的对齐方式为 8%3 是函数指针的地址,%4 是函数指针本身。如果我们没有针对第一个函数,那么我们将有一个偏移量:%3.1 = getelementptr inbounds void (%struct.Base*)** %3, i64 1 这里我们将%3 中的指针增加 1 以到达数组中的下一个单元格,然后我们“加载”下一个单元格进入%4。 “偏移量”是硬编码的。
    【解决方案4】:

    第二种情况——假设指针占用 4 个字节(32 位机器)。

    函数名称永远不会存储在可执行文件中(调试除外)。 虚拟表只是一个函数指针向量,运行代码直接访问。

    【讨论】:

    • 在实践中有点复杂;共享库(DLL)由(函数)名称链接。
    【解决方案5】:

    这实际上取决于编译器,标准没有指定内存表示的工作方式。该标准规定多态性必须始终有效(即使在 inline 函数的情况下,就像你的一样)。您的函数可以内联,具体取决于编译器的上下文和智能性,因此有时calljmp 甚至可能不会出现。但是,在大多数编译器上,很可能会遇到第二种变体。

    对于您的情况:

    class Base{
        virtual string function1(){ return "Base - function1"; };
        virtual string function2(){ return "Base - function2"; };
    };
    
    class Derived : public Base {
        virtual string function2(){ return "Derived - function2"; };
        virtual string function1(){ return "Derived - function1"; };
    };
    

    假设你有:

    Base* base = new Base;
    Base* derived = new Derived;
    
    base->function1();
    derived->function2();
    

    对于第一次调用,编译器将为Base 获取vftable 的地址并调用第一个函数,即vftable。对于第二次调用,vftable 位于不同的位置,因为该对象实际上是 Derived 类型。它搜索第二个函数,从遇到函数的 vftable 开头跳转到偏移量(意思是 vftable + offset - 很可能是 4 个字节,但同样取决于平台)。

    【讨论】:

      【解决方案6】:

      当在类中添加虚函数时,编译器会创建一个隐藏指针(称为 v-ptr)作为该类的成员。[您可以通过 sizeof(class) 来检查它,它会增加 sizeof (pointer)] 编译器还在构造函数的开头添加了一些代码,以将 v-ptr 初始化为类的 v-table 的基本偏移量。现在,当这个类由某个其他类派生时,这个 v-ptr 也由 Derived 类派生。对于 Derived 类,这个 v-ptr 被初始化为 Derived 类的 v-table 的基本偏移量。而且我们已经知道,各个类的 v-tables 将存储它们的虚函数版本的地址。 [请注意,如果派生类中没有重写虚函数,则层次结构中函数的基版本或大多数派生版本(用于多级继承)的地址将存储在 v-table 中]。因此在运行时它只是通过这个 v-ptr 调用函数。因此,如果基类指针存储了一个基对象,那么 v-ptr 的基版本就会起作用。由于它指向 v-table 的基本版本,因此将自动调用函数的基本版本。 Derived 对象也是如此。

      【讨论】:

      • 你的答案正是我想要的,真的很抱歉,但它并没有说清楚。如果你能用例子解释一下,可能会帮助我更好地理解它。
      • 还有,如果vTable只是函数指针表,上面我提到的第二个表,那这个表叫什么?