C ++虚拟表查找 - 如何搜索和替换

c++ virtual table lookup - how does it search & replace

本文关键字:搜索 替换 何搜索 虚拟 查找      更新时间:2023-10-16

让我们看看下面的例子:

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"; };
};

虚值表结构就像

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

如果是后者?那么偏移量是多少呢?它在哪里使用?

函数名:它是混乱的函数名吗?如果它被篡改了,那么基名和派生名将不匹配,虚参表查找将无法工作。编译器对所有虚函数名进行了修改,所以它必须是一个修改过的名称,这是否意味着base &如果是虚函数,派生函数也是一样的。

虚表只是函数指针的数组,就像第二个代码片段一样。编译器将对虚函数的调用转换为通过指针的调用,例如

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

被翻译成

b->__vtable[1]();

,其中我使用名称__vtable来引用虚拟表(注意,虚拟表通常不能直接访问)。

表中表项的顺序由类中函数声明的顺序决定。记住,类定义在调用点总是可用的。

我正在解释下面的代码。我想这会让你明白

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

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

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

在运行时,类派生的VTable被赋值给vtr。调用VTable中第二个位置的函数。

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

考虑以下代码:

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表指针。为什么是这种奇怪的类型?因为我们将在v表中存储许多不同类型的函数指针。这意味着我们将拥有一个异构数组(这是不可能的),因此我们将其视为"泛型"未知元素的数组(以标记我们确保存在什么),并且在实际使用它之前,由应用程序将指针转换为适当的函数指针类型(或者更确切地说,如果我们在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表指针。它没有被"命名",我们只是在类型的定义中确保它是第一个元素。

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

首先加载v表(由%2指向),然后加载函数指针(由%3指向)。此时,%4因此为&Derived::foo

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

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

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

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

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

这实际上取决于编译器,标准没有指定内存表示如何工作。标准规定多态性必须始终有效(即使在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();

对于第一次调用,编译器将获得Basevftable的地址,并调用vftable中的第一个函数。对于第二次调用,vftable驻留在不同的位置,因为对象实际上是Derived类型。它搜索第二个函数,从遇到函数的vftable开始跳转到偏移量(即vftable + offset—最有可能是4字节,但同样取决于平台)。