为什么使用存储在虚方法 table 中的地址调用虚函数会返回垃圾?

Why is the function call to the virtual function using the address stored in the virtual method table returning garbage?

我正在从虚拟 table 中的地址调用虚拟函数作为练习来测试我对这个概念的理解。然而,就在我以为自己对虚方法的理解有了突破table的时候,我运行又陷入了另一个我就是不明白的问题。

在下面的代码中,我创建了一个名为 Car 的 class,它包含一个成员变量 x 和两个虚函数,first 和 second。现在,我通过虚拟 table 来调用这两个虚拟方法。第一个函数 returns 正确答案,但第二个 returns 一些随机值或垃圾而不是它被初始化的值。

#include <cstdio>

class Car
{
private:
    int x;

    virtual int first()
    {
        printf("IT WORKS!!\n");
        int num = 5;
        return num;
    }
    virtual int second()
    {
        printf("IT WORKS 2!!\n");
        //int num  = 5;
        return x;
    }


public:

    Car(){
        x = 2;
    }
};

int main()
{
    Car car;
    void* carPtr = &car;
    long **mVtable =(long **)(carPtr);

    printf("VTable: %p\n", *mVtable);
    printf("First Entry of VTable: %p\n", (void*) mVtable[0][0]);
    printf("Second Entry of VTable: %p\n", (void*) mVtable[0][1]);

    if(sizeof(void*) == 8){
        printf("64 bit\n");
    }

    int (*firstfunc)() = (int (*)()) mVtable[0][0];
    int x = firstfunc();    

    int (*secondfunc)() = (int (*)()) mVtable[0][1];
    int x2 = secondfunc();

    printf("first: %d\nsecond: %d", x, x2);
    return 0;
}

如果有人能指出我做错了什么,我们将不胜感激。此外,由于这在编译器之间的工作方式不同,我正在使用 c++14 在 http://cpp.sh/ 上对其进行测试。

该代码输出,其中 "garbage" 第二个输出可能会发生变化:

VTable: 0x400890
First Entry of VTable: 0x400740
Second Entry of VTable: 0x400720
64 bit
IT WORKS!!
IT WORKS 2!!
first: 5
second: -888586240 

当您将函数指针直接调用到 vtable 中时,设置 x = 2 的构造函数不会 运行。您正在从 second 返回未初始化的内存,它可以是任何东西。

方法确实通常作为常规函数实现,但它们需要接收 this 指针才能访问特定实例的数据 - 事实上,当您在实例上调用方法时,指向实例作为隐藏参数传递。

在您的代码中,您没有传入它,因此该方法只是 returns 垃圾——它可能正在使用寄存器或堆栈中的任何内容,就好像它是实例指针一样;你很幸运,它没有明显崩溃。

您可以尝试更改您的原型以接受 Car* 参数并将 &car 传递给它,但它可能会或可能不会工作,具体取决于您的 [=31= 使用的调用约定]:

  • 在 Win32/x86/VC++ 上,例如,方法使用 stdcall 调用约定(或 cdecl 用于可变参数),但在 ecx,你无法通过常规函数调用模拟的东西;
  • 另一方面,x86 gcc 只是将它们作为 cdecl 函数处理,隐式传递 this 就好像它是最后一个参数一样。

方法是函数,但方法指针一般不是函数指针。

调用方法的调用约定并不总是与调用函数的调用约定一致。

我们可以解决这个问题。还有更多未定义的行为,但至少有时会起作用。

MSVC clang g++

代码:

template<class Sig>
struct fake_it;

template<class R, class...Args>
struct fake_it<R(Args...)>{
    R method(Args...);

    using mptr = decltype(&fake_it::method);
};
template<class R, class...Args>
struct fake_it<R(Args...) const> {
    R method(Args...) const;

    using mptr = decltype(&fake_it::method);
};

template<class Sig>
using method_ptr = typename fake_it<Sig>::mptr;

template<class Sig>
struct this_helper {
    using type=fake_it<Sig>*;
};
template<class Sig>
struct this_helper<Sig const>{
    using type=fake_it<Sig> const*;
};

template<class Sig>
using this_ptr = typename this_helper<Sig>::type;

现在这个测试代码:

Car car;
void* carPtr = &car;
auto **mVtable = (uintptr_t **)(carPtr);
printf("VTable: %p\n", *mVtable);
printf("First Entry of VTable: %p\n", (void*)mVtable[0][0]);
printf("Second Entry of VTable: %p\n", (void*)mVtable[0][1]);

if(sizeof(void*) == 8){
    printf("64 bit\n");
}

auto firstfunc = to_method_ptr<int()>(mVtable[0][0]);
int x = (this_ptr<int()>(carPtr)->*firstfunc)();    

auto secondfunc = to_method_ptr<int()>(mVtable[0][1]);
int x2 = (this_ptr<int()>(carPtr)->*secondfunc)();

printf("first: %d\nsecond: %d", x, x2);

上面的代码依赖于方法指针是一对函数指针和第二部分,如果所有 0 都是非虚拟分派,并且 vtable 只包含函数指针组件。

因此我们可以通过用 0 填充缓冲区,然后将内存解释为方法指针,从 vtable 中的数据重建方法指针。

为了让调用生效,我们创建了一个伪类型,其方法与我们的签名相匹配,然后将我们的指针转换为该类型,并使用从原始类型的 vtable 重建的成员函数指针调用它。

我们希望,这模仿了编译器用于其他方法调用的调用约定。


在clang/g++中,非虚方法指针是两个指针,第二个被忽略。我相信虚方法指针使用第二个指针大小的数据。

在MSVC中,非虚方法指针是一个指针的大小。具有虚拟继承树的虚拟方法指针不是一个指针的大小。我认为这违反了标准(要求成员指针可以相互转换)。

在这两种情况下,vtable 似乎都存储了每个非虚拟方法指针的前半部分。