C++如何获取虚函数表(vtbl)的内容及虚成员函数指针存放原理

一、前言

因为不同的运行环境的运行结果是不同的,特别是不同的编译器对c++类对象模型的实现是很可能存在差异,所以有时不同的编译平台的代码不能兼容也是部分原因于此。本文的运行环境是:

  • ubuntu16.04;
  • 编译器g++ (Ubuntu 5.4.0-6ubuntu1~16.04.12) 5.4.0 20160609

二、一些关于指针的知识

这篇文章事引用于这篇博客内容其中的虚函数表(vtbl)的内容及函数指针存放顺序,这里针对这部分进行了进一步的分析,为了方便理解本篇文章的内容,对于没基础的人,有必要看下前面这篇文章学习一些比较概念和基础知识。不过,这里补充一点关于指针的知识。

指针的类型

在64位机器上,无论任何指针都是占8个字节,8字节=64位,也就是一个机器码。假设有以下代码:

#include <iostream>
#include <typeinfo>
#include <array>
#include <string>

using namespace std;

class Person
{
    public:
        Person():mId(0), mAge(20){ ++sCount; }
        static int personCount()
        {
            return sCount;
        }
 
        virtual void print()
        {
            cout << "id: " << mId
                 << ", age: " << mAge << endl;
        }
        virtual void job()
        {
            cout << "Person" << endl;
        }
        virtual ~Person()
        {
            --sCount;
            cout << "~Person" << endl;
        }
 
    protected:
        static int sCount;
        int mId;
        int mAge;
};

int main () {
	Person* person;
	int* pint;
	std::array<std::string, 5>* pta;

    std::cout << sizeof(person) << std::endl;
    std::cout << sizeof(pint) << std::endl;
    std::cout << sizeof(pta) << std::endl;

    std::cout << typeid(person).name() << std::endl;
    std::cout << typeid(pint).name() << std::endl;
    std::cout << typeid(pta).name() << std::endl; 
}

输出结果:
在这里插入图片描述

如上代码所示,一个指向Person的指针,或是指向简单类型——整形——的指针int,还是指向template Aarry,以内存需求的观点来说,三种指针式没什么不同的!它们三个都需要足够的内存来放置一个机器地址(一般是8个字节)。换另一种说法,指针也是变量,它们都是一种占8个字节的变量,与普通变量无差别,只是这指针变量存放的内容是指针(其实就是机器地址)。
那它们不同类型的指针带来的影响是什么?”指向不同类型之各类指针”间的差异,既不在于其指针表示法(指针变量的命名)不同,也不在于其内容(代表一个地址)不同,而是在于其所寻址出来的object类型不同。也就是说,“指针类型”会教导编译器如何解释某个特定地址中的内存内容及其大小。
那么,一个指针的类型为void时,将涵盖怎样的地址空间呢?是的,我们不知道!这就是为什么一个类型为void的指针只能持有一个地址,而不能通过它操作它所指之object的缘故。(因为编译无法知道该给这个object寻址多大范围)。
所以,C++的四种cast操作:static_cast、dynamic_cast、const_cast和reinterpret_cast(具体含义和用法可以查看这篇文章),其实只是一种编译器指令。大部分情况下这些操作并不改变一个指针所含的真正地址,它只影响“被指出之内存的大小和其内容”的解释方式(理解这句话很重要,后面会用到)

元素类型为指针的一维数组

数组元素类型为指针情况下,元素地址与元素内容所指向地址的区别:

#include <iostream>
using namespace std;

int main() {
    int* a[3];
    for (int i = 0; i < 3; ++i) {
        a[i] = new int{i};
    }
    for (int i = 0; i < 3; ++i) {
        cout << "数组第" << i << "元素的地址(从栈中分配的每个元素的地址):" << (a + i) << endl;
    }

    for (int i = 0; i < 3; ++i) {
       cout << "数组第" << i << "元素的内容(元素指向一个在堆中的指针):" << a[i] << endl;
    }

    cout << "数组名 a 是第一个元素的地址:" << a << endl;
    cout << "数组名的解引用( *a )是第一个元素的内容:" << *a << endl;
}

输出:
在这里插入图片描述
可见,我们有以下结论:

  • 虽然每个元素没有名称,但每个元素都有由系统从栈中分配的地址;
  • 每个元素的内容是我们从堆中申请的地址;
  • 数组名的第一个元素的地址;
  • 数组名的的解引用是第一个元素的内容;
  • 数组可以作加法运算,每次加运算的结果偏置一个元素类型的大小。

三、虚函数表(vtbl)的内容及虚成员函数指针存放原理

带有虚函数的c++类的内存模型

这里将带有虚函数的c++类的内存模型,是虚指针位于对象地址开头的模型,有的编译器实现不同于此,可能位于第一个类内存分布的尾部。假如有如下代码:

在这里插入图片描述
把虚指针放在开头的内存分布方式如下:
在这里插入图片描述
把虚指针放在末尾的内存分布方式:
在这里插入图片描述

这篇文章所用的编译器的内存分布实现把虚指针放在开头。
至于更详细的内容,这不做介绍,可以看开头的那篇文章或者找图片所在的这本书《深度探索c++对象模型》看看。

元素类型为普通函数指针的一维数组

#include <iostream>

using namespace std;

void fun1() { cout << "This is fun1" << endl; }
void fun2() { cout << "This is fun2" << endl; }
void fun3() { cout << "This is fun3" << endl; }

// fun与FuncPtr等效
typedef void (*FuncPtr)();
using fun = void (*)();

int main() {
  fun a[3];
  int** pa = reinterpret_cast<int**>(a);

  a[0] = &fun1;

  a[1] = &fun2;
  a[2] = &fun3;

  for (int i = 0; i < 3; ++i) {
    cout << "数组第" << i
         << "元素的地址(从栈中分配的每个元素的地址):" << (a + i) << endl;
    fun func = (fun) * (a + i);
    func();
  }

  cout << endl;
  for (int i = 0; i < 3; ++i) {
    cout << "数组第" << i << "元素的内容(元素指向一个普通函数指针):"
         << reinterpret_cast<int*>(a[i]) << endl;
    FuncPtr func = (FuncPtr)*a[i];
    func();
  }

  cout << endl;
  for (int i = 0; i < 3; ++i) {
    cout << "数组第" << i << "元素的地址(从栈中分配的每个元素的地址):"
         << reinterpret_cast<int*>(pa) << endl;
    FuncPtr func = (FuncPtr)*pa;
    func();
    ++pa;
  }
}

输出:
sas在这里插入图片描述

上面的例子实现了一个一维数组存放了三个普通函数指针,并通过获取元素的内容获得函数指针,并运行获得函数功能。

从这个例子我们知道:

  • 函数指针同样可以强转换为一个普通的指针地址;
  • 函数指针通过转换为一个函数类型,可以正确执行函数体的内容;
  • 一维数组的的数组名可以按照指针大小进行偏置,从过在只知道数组首指针的情况下遍历所有元素内容,进而可以执行函数。
int** pa = reinterpret_cast<int**>(a);
  • 重点解释下这句语句的意思:
    • (1)对于一个数组而言,数组名是一个指向首地址的常量指针,所以不能对这里的数组名a进行++a操作,这样会改变a的值,编译肯定不通过,但采用a+i会产生一个临时变量,所以可行。
    • (2)我们也可以把这个数组名代表的地址复制给一个自由变量,代码里的pa就是这个自由变量,因此可以++pa
  • 上面这两点分别对应第三节的两种解释,由于虚函数表的其实是不存在数组名的,所以可以进行(1)种里操作。

int** pa = (int**)a;int**即指针的指针,应该这么理解,因为数组名指向数组的首地址,所以数组名是一个指针类型,而数组的元素内容是指针,所以这里的指针的指针可以翻译为,一个变量为指针类型,这个变量指向一个指针类型:(int*)(*)

虚函数表是一个元素类型为虚函数指针的一维数组

如标题所示,虚函数表可以理解为一个元素类型为虚函数指针的一维数组,所以这个一维数组的遍历应该和上一小节的元素类型为普通函数指针的一维数组遍历方式思路一致。
其次,虚指针和虚函数表的概念看开头文章的介绍,这里只是简单贴出带有虚指针和虚函数表的简略模型,但是这个模型并不完全正确,但在本文的代码实验下是正确的(或者说这个模型的正确性仅在本文简单代码例子下条件性成立),如下:
在这里插入图片描述

这里提供两种虚函数表的每个虚函数指针(元素)的遍历方法和解释。

第一种解释,虚函数指针vptr的指向的地址就是数组名,直接使用数组名操作

#include <iostream>
#include <typeinfo>
using namespace std;

class Person {
 public:
  Person() : mId(0), mAge(20) { ++sCount; }
  static int personCount() { return sCount; }

  virtual void print() { cout << "id: " << mId << ", age: " << mAge << endl; }
  virtual void job() { cout << "Person" << endl; }
  virtual ~Person() {
    --sCount;
    cout << "~Person" << endl;
  }

 protected:
  static int sCount;
  int mId;
  int mAge;
};
int Person::sCount = 0;

typedef void (*FuncPtr)();

int main() {
  Person person;
  int64_t** vptr = reinterpret_cast<int64_t**>(&person);
  int64_t* vtbl = *vptr;jiesh

  for (int i = 0; i < 3; ++i) {
    FuncPtr func = (FuncPtr) * (vtbl + i);
    func();
  }
  
  // 由于虚函数表没有数组名之说,所以是可以用首地址自增的,运行结果与上面的用法一样
  // for (int i = 0; i < 3; ++i) {
  //   FuncPtr func = (FuncPtr) * (vtbl);
  //   func();
  //   ++vtbl;
  // }

  // 以数组的形式调用
  // for (int i = 0; i < 3; ++i) {
  //   FuncPtr func = (FuncPtr)(vtbl[i]);
  //   func();
  // }

  cout << "!!!!!!!!!!!!!!" << endl;
  return 0;
}

输出:
在这里插入图片描述
这一种用法相当于直接操作数组名,由于虚函数表没有数组名之说,所以是可以用首地址自增的, 同时也能用(vtbl + i)进行偏置操作
代码分析:

long** vptr = reinterpret_cast<long**>(&person);
  • 根据上面所说的,对象的首地址是虚指针vptr的地址,所以我们取的对象地址就是虚指针的地址;(不要混淆了,vptr只是一个指针类型的变量,它的地址是person的首地址,而vptr的内容才是虚函数表的地址)
  • 根据前面指针的类型的解释,我们已经知道&person在编译器的解释为一个Person对象,再根据前面一维数组对int** 的分析,不难理解为什么我们需要调用reinterpret_cast让编译器重新解释vptr这段地址为int64_t** 类型;
long* vtbl = *vptr;
  • 根据一维数组的首地址是第一个元素的地址,因此vptr是第一个元素的地址,而vptr的指向地址才是虚函数表的地址。因此,*vptr就是虚函数表的第一个元素的地址,同时这个首指针指向的是一个虚函数指针。
for (int i = 0; i < 3; ++i)
{
    FuncPtr func = (FuncPtr)*vtbl;
    func();
    ++vtbl;
}
  • 这里解释下,为什么要把指针类型定义为int64_t,而不是int,不然程序编译时会有:
 warning: cast to pointer from integer of different size [-Wint-to-pointer-cast]
     FuncPtr func = (FuncPtr) * (vtbl + i);
  • 运行时程序崩溃:
Segmentation fault (core dumped)
  • 因为int64_t是八个字节,而int是4个字节,在这一行语法(FuncPtr)*vtbl;里,funcptr是一个八个字节的指针,而*vtbl是一个整数,这个整数是函数地址的整数转换值,如果用int,根据我们前面说的指针的类型会教导编译器解释某个特定地址中的内存内容及其大小。用int的话,编译器将转换4个字节大小的地址,然而,指针的地址是8个字节,这样的转换是不完整的,将引发内存出错!!!所以这里必须是一个8字节的int64_t。
  • 对*vptr解引用就是第一个元素的内容,内容是一个虚函数地址,经由FuncPtr func = (FuncPtr)*vtbl;我们将这个虚函数地址转化为一个可调用对象。
  • 这里的++vtbl就是数组元素地址偏置一个元素类型大小(这里偏置一个指针类型的大小,8个字节),每进行一次偏置后进行解引用(*vtbl;)就能获得元素的内容(函数地址),进而调用func();执行函数。

第二种,把虚函数指针vptr的指向的地址,再转换为一个指针的指针后再操作

#include <iostream>
#include <typeinfo>
using namespace std;

class Person {
 public:
  Person() : mId(0), mAge(20) { ++sCount; }
  static int personCount() { return sCount; }

  virtual void print() { cout << "id: " << mId << ", age: " << mAge << endl; }
  virtual void job() { cout << "Person" << endl; }
  virtual ~Person() {
    --sCount;
    cout << "~Person" << endl;
  }

 protected:
  static int sCount;
  int mId;
  int mAge;
};
int Person::sCount = 0;

typedef void (*FuncPtr)();

int main() {
  Person person;
  int** vptr = reinterpret_cast<int**>(&person);
  int** vtbl = reinterpret_cast<int**>(*vptr);

  for (int i = 0; i < 3; ++i) {
    FuncPtr func = (FuncPtr)*vtbl;
    func();
    ++vtbl;
  }

  // 以数组的形式调用
  // for (int i = 0; i < 3; ++i) {
  //   FuncPtr func = (FuncPtr)(vtbl[i]);
  //   func();
  // }

  cout << "!!!!!!!!!!!!!!" << endl;
  return 0;
}

这种用法相当于把数组名转换为一个自由变量的用法。
输出:
在这里插入图片描述
代码分析:

int** vptr = reinterpret_cast<int**>(&person);

这行含义与第一种的一样。

int** vtbl = reinterpret_cast<int**>(*vptr);

这里可以用int的原因是,*vptr的结果是指针,是8个字节。
这一行把虚函数表的首地址转化为一个,指向指针的指针变量,相当于把前面的把数组名转化为一个自由变量的操作。

    for (int i = 0; i < 3; ++i)
    {
        FuncPtr func = (FuncPtr)*vtbl;
        func();
        ++vtbl;
    }

对自由变量进行自增操作,并执行函数。

四、本文潜在问题阐述

至此,完整的说明了虚函数表是一个一位数组了。对比两种解释,第二种是正确的用法,第一种是利用数组自增时,偏置元素类型的大小。不过,对于在一片连续的地址里,对于编译器,这两种操作应当是一致的?

但是,就像前面说的,这里虚函数表的这个模型不是完全正确的:

(1)虚函数是类的成员函数,所以我们调用虚函数需要传递一个对象的指针给它,但这里我们没有传递也能成功调用,这是为什么?
(2)虚函数表并不是只有虚函数的地址,还有对象类型信息,以及其他的一些信息,这些在虚函数表的位置和用法其实在本文并没有涉及。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
要在 C# 中调用 C++ 导出的 API 类和 SPI 类,需要使用 Platform Invocation Services (P/Invoke)。以下是一些基本步骤: 1. 在 C++ 中将需要导出的类声明为 `extern "C"`,并使用 `__declspec(dllexport)` 指定导出接口。 例如: ```c++ #ifdef __cplusplus extern "C" { #endif __declspec(dllexport) int __stdcall MyFunc(int arg); #ifdef __cplusplus } #endif ``` 2. 在 C# 中使用 `[DllImport]` 特性引入 C++ DLL 中的函数或类。 例如: ```c# using System.Runtime.InteropServices; [DllImport("MyDLL.dll", CallingConvention = CallingConvention.StdCall)] public static extern int MyFunc(int arg); ``` 3. 如果需要使用 C++ SPI 类,可以在 C# 中定义一个与 C++ 类相同的接口,并使用 `[DllImport]` 引入 C++ DLL 中的类实例化函数成员函数。 例如: ```c++ class MySPIClass { public: virtual void OnEvent(int eventCode) = 0; }; extern "C" { __declspec(dllexport) MySPIClass* __stdcall CreateMySPIClassInstance(); __declspec(dllexport) void __stdcall MySPIClass_OnEvent(MySPIClass* instance, int eventCode); } ``` ```c# [UnmanagedFunctionPointer(CallingConvention.StdCall)] public delegate void MySPIClass_OnEvent_delegate(int eventCode); [StructLayout(LayoutKind.Sequential)] public struct MySPIClass { public IntPtr vtbl; } [DllImport("MyDLL.dll", CallingConvention = CallingConvention.StdCall)] public static extern IntPtr CreateMySPIClassInstance(); [DllImport("MyDLL.dll", CallingConvention = CallingConvention.StdCall)] public static extern void MySPIClass_OnEvent(IntPtr instance, int eventCode); public static void MySPIClass_OnEvent_wrapper(IntPtr instance, int eventCode) { Marshal.GetDelegateForFunctionPointer<MySPIClass_OnEvent_delegate>(Marshal.ReadIntPtr(instance, 0))(eventCode); } public static MySPIClass CreateMySPIClass() { var instance = new MySPIClass(); instance.vtbl = CreateMySPIClassInstance(); Marshal.WriteIntPtr(instance.vtbl, Marshal.GetFunctionPointerForDelegate(new MySPIClass_OnEvent_delegate(MySPIClass_OnEvent_wrapper))); return instance; } ``` 这样,就可以在 C# 中调用 C++ 导出的 API 类和 SPI 类了。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值