虚函数表是一个C++领域比较基础内容,同时也是面试中经常涉及到的一个内容,今天这个Session就通过一个实际例子,带你一步一步揭开虚表的面纱(注:本文讨论的仅针对 MSVC微软实现的方式,其他平台可能会有所不同,有机会再给大家分享其他平台的实现)。
好!废话不多,直接上代码。我们的测试工程包括一个简单的CMakeLists.txt文件,定义了两个工程,一个Base,只有一个基类,另一个Main,里面有子类和main方法。
cmake_minimum_required(VERSION
3.26)
project(main)
add_library(BASE SHARED base.cpp)
target_compile_definitions(BASE PRIVATE
MYLIBRARY_EXPORTS)
add_executable(main main.cpp
derived.cpp)
target_link_libraries(main PRIVATE
BASE)
BASE工程中,只有base.cpp一个源文件,它引用base.h
Base.cpp
#include "base.h"
#include
Base::Base() {
vfunfoo();
}
Base::~Base()
{
}
void Base::vfunfoo()
{
printf("vfunfoo
base is called!");
}
void Base::vfunbar()
{
printf("vfunbar
base is called!");
}
Base.h
#ifdef MYLIBRARY_EXPORTS
#define
MYLIBRARY_API __declspec(dllexport)
#else
#define
MYLIBRARY_API __declspec(dllimport)
#endif
class MYLIBRARY_API Base
{
public:
Base();
~Base();
virtual void vfunfoo();
virtual void vfunbar();
};
基类实现了两个虚方法,并且在基类构造中调用了其中一个虚方法,这时调用的将永远是基类方法。原因在后续揭密部分会有详细说明,各位看官请耐心。
接下来看一下Main工程,它有main.cpp
Main.cpp
#include "derived.h"
int main()
{
Derived* dv = new Derived();
dv->vfunbar();
delete dv;
return 0;
}
Derived.h代码如下:
#include
#include "base.h"
class Derived
: public Base
{
public:
Derived();
~Derived();
virtual void vfunbar();
};
Derived.cpp
#include
#include "derived.h"
Derived::Derived()
: Base()
{
vfunfoo();
vfunbar();
}
Derived::~Derived()
{
}
void Derived::vfunbar()
{
printf("vfunbar
derived is called!");
}
然后用CMake直接生成VisualStudio的工程。
我们跳过编译的环节,直接通过调试代码的方法,来确定一下main函数中,Derived类中调用的虚方法,以及基类中调用的虚方法各自是如何工作的。
直接把断点打在基类的构造函数上,运行代码,触发断点。
基类的构造函数
可以看到在调用vfunfoo()之前,会把基类的虚表指针放到this开头的8个字节,也就是说,在当前情况下类的第一个数据成员是虚表的指针,所以后续调用的是基类的vfunfoo。切换到CallStack窗口。
基类构造函数的调用堆栈
双击子类构造的frame,切换到子类的构造函数,如下图
子类构造函数的调用堆栈
发现此时基类还没完成(绿色小箭头所指的是调用完之后的返回地址),当基类完成以后,才会把虚表指针指向子类的虚表,再之后的调用才会调用子类的虚方法。
回到今天的主题,我们来查一下虚表的户口,它是如何产生的,在二进制文件的什么地方。
我们先看基类虚表。
基类虚表地址
基类虚表指针的值是0x00007FF85B0E9940
我们通过内存窗口来看一眼 0x00007FF85B0E9940这里的内容,它存了两个虚函数的地址。
基类虚函数表内容
我们再去0x00007ff85b0e11d1看看
基类虚表跳转指令
跳转去0x07FF85B0E1520,再确定一下这个地址的内容。
基类第一个虚函数代码
可以看到它是基类第一个虚函数的实现。
基于上面这些数据,我们换个角度,来看一下,虚表在文件层面到底在哪里。先通过模块窗口,看一下Base.dll的加载地址。
Base.dll的加载基地址信息
可以看到Base.dll被加载到了0x00007FF85B0E0000的基址上。
基类的虚函数表指针相对于模块基址的相对偏移地址RVA是0x9940
0x00007FF85B0E9940-0x00007FF85B0E0000 = 0x9940
打开CFF
Explorer工具,查看0x9940所在的Section
Base.dll的节表信息
可以看到RVA为0x9940的位置处在.rdata节。这个节通常包括只读数据 ,以及导入导出表数据。
再看一下0x9940具体存的数据。
0x9940的数据
这里只存了0x1800011D1,那这里的数据是怎么知道自己在运行的时候要变成
0x00007FF85B0E11D1的呢?
0x00007FF85B0E11D1和模块基址0x00007FF85B0E0000只差了0x11D1,那高位的0x180000000又是干吗用的呢?
查看Base.dll的可选文件头。
Base.dll的可选文件头
可以发现这个dll的_IMAGE_OPTIONAL_HEADER64 中的ImageBase 成员值为0x180000000,
它记录了二进制文件希望被加载的优选地址,如果能加载到这个地址,那是不需要进行重定位的(地址修复)。于是二进制文件中存的其他值,如0x1800011D1都是基于如果加载到这个优选地址时不需要做重定位时的值,现在显然不是,所以要基址0x00007FF85B0E0000+0x1800011D1-0x180000000=
0x00007FF85B0E11D1,就是加载后真实的地址。
那这里又有一个问题,系统怎么知道0x9940这个地址在加载后需要被重定位,参见重定位表可见0x9940这个地址是在重定位表中的。
Base.dll的重定位表
总结一下,基类的虚表指针是在Base.dll文件的.rdata节,对应的地址同样在重定位节中有指明。
所以当加载完成后,系统会进行重定位操作,以保证虚表指针的值永远指向正确的函数。
同样的,我们来看一下子类对象的虚表有什么特点。
让代码执行到子类的构造函数。
子类构造函数代码
我们通过内存窗口来看一眼子类虚表的位置0x00007FF7FA77AC50
子类虚函数表内容
通过模块窗口确定一下main.exe被加载的基地址。
主程序的加载基地址信息
Main被加载的基地址是0x00007FF7FA770000,所以虚表指针的相对偏移地址RVA是0xAC50
再通过CFF
Explorer查看一下0xAC50所在的节,同样也是在.rdata节。
主程序的节表信息
我们再去看一眼,0xAC50所存的具体内容。
0xAC50的数据
具体值为0x10400018EF,同样地查看重定位表以及可选文件头中的ImageBase字段。
主程序的重定位表
主程序的可选文件头
可知被加载后的真实地址应该是
基址0x00007FF7FA770000+ 0x10400018EF –
0x1040000000 = 0x00007FF7FA7718EF
这于上面内存窗口中看到的值一致。
通过反汇编窗口,我们再看一下0x00007FF7FA7718EF地址的代码,发现它和基类的情况已经不同了。
子类虚函数的间接跳转
对比一下基类的情况。
基类的直接跳转
可以清楚地看到,基类是直接跳转到了0x7FF85B0E1520,Base::vfunfoo的地址。而现在这次可以看到代码是
00007FF7FA7718EF FF 25 13 E7 00
00 jmp
qword ptr [__imp_Base::vfunfoo(07FF7FA780008h)]
它跳到了0x07FF7FA780008所在的内存地址
间接跳转表中的真实地址
清楚地看到它就是Base.dll中的地址,并且与基类的虚表指针的第一项具有一样的指针值,因为在子类中没有overwrite基类的行为。所以这个函数最终才调用到了Base.dll中的代码。
我们再来看一下这一切是如何发生的。
我们先来看一下0x18EF处的内容。
主程序中0x18EF的数据
这里的内容和上面的二进制指令值是一模一样的。
也就是00007FF7FA7718EF FF 25 13E7 00
00
jmp
qword ptr [__imp_Base::vfunfoo(07FF7FA780008h)]
它去取0x00007FF7FA780008中的内容,这个地址的RVA是0x10008
这个RVA对应在文件中.idata节,也就是导入表中。
主程序导入表在节表中的位置
查看0x10008这个RVA在文件中的内容
主程序中0x10008的数据
再切换到导入表视图,可以看到10008对应第二项。
主程序导入表结构
同时,对比Base.dll的导出表,可以看到序号和名称是对应的。
Base.dll的导出表结构
综上所述,当模块加载的时候,系统会根据main的导入表和Base.dll的导出表对应关系,把0x10008这个RVA中所记录的0x0000000000010A00,修正为Base.dll导出表最后一项所对应的RVA。也就是地址0x000011D1。
注意这里的导出表只有纯的RVA,并且是一个四字节数据 。导入导出表修正,它是一个跨模块的操作,它会将被导入模块Base.dll中的地址(基地址+四字节偏移得到8字节绝对地址)完全填进导入模块Main的对应导入表结构。
重定位则只会考虑本模块是否加载到了最佳位置,如果已经加载到最佳位置,重定位将不发生。
好了,今天的内容就到此为止,我们下次再见。
查户口之虚表的户籍
虚函数表是一个C++领域比较基础内容,同时也是面试中经常涉及到的一个内容,今天这个Session就通过一个实际例子,带你一步一步揭开虚表的面纱(注:本文讨论的仅针对 MSVC微软实现的方式,其他平台可能会有所不同,有机会再给大家分享其他平台的实现)。
好!废话不多,直接上代码。我们的测试工程包括一个简单的CMakeLists.txt文件,定义了两个工程,一个Base,只有一个基类,另一个Main,里面有子类和main方法。
cmake_minimum_required(VERSION 3.26)
project(main)
add_library(BASE SHARED base.cpp)
target_compile_definitions(BASE PRIVATE MYLIBRARY_EXPORTS)
add_executable(main main.cpp derived.cpp)
target_link_libraries(main PRIVATE BASE)
BASE工程中,只有base.cpp一个源文件,它引用base.h
Base.cpp
#include "base.h"
#include
Base::Base() {
vfunfoo();
}
Base::~Base()
{
}
void Base::vfunfoo()
{
printf("vfunfoo base is called!");
}
void Base::vfunbar()
{
printf("vfunbar base is called!");
}
Base.h
#ifdef MYLIBRARY_EXPORTS
#define MYLIBRARY_API __declspec(dllexport)
#else
#define MYLIBRARY_API __declspec(dllimport)
#endif
class MYLIBRARY_API Base {
public:
Base();
~Base();
virtual void vfunfoo();
virtual void vfunbar();
};
基类实现了两个虚方法,并且在基类构造中调用了其中一个虚方法,这时调用的将永远是基类方法。原因在后续揭密部分会有详细说明,各位看官请耐心。
接下来看一下Main工程,它有main.cpp
Main.cpp
#include "derived.h"
int main()
{
Derived* dv = new Derived();
dv->vfunbar();
delete dv;
return 0;
}
Derived.h代码如下:
#include
#include "base.h"
class Derived : public Base
{
public:
Derived();
~Derived();
virtual void vfunbar();
};
Derived.cpp
#include
#include "derived.h"
Derived::Derived() : Base()
{
vfunfoo();
vfunbar();
}
Derived::~Derived()
{
}
void Derived::vfunbar()
{
printf("vfunbar derived is called!");
}
然后用CMake直接生成VisualStudio的工程。
我们跳过编译的环节,直接通过调试代码的方法,来确定一下main函数中,Derived类中调用的虚方法,以及基类中调用的虚方法各自是如何工作的。
直接把断点打在基类的构造函数上,运行代码,触发断点。
可以看到在调用vfunfoo()之前,会把基类的虚表指针放到this开头的8个字节,也就是说,在当前情况下类的第一个数据成员是虚表的指针,所以后续调用的是基类的vfunfoo。切换到CallStack窗口。
双击子类构造的frame,切换到子类的构造函数,如下图
发现此时基类还没完成(绿色小箭头所指的是调用完之后的返回地址),当基类完成以后,才会把虚表指针指向子类的虚表,再之后的调用才会调用子类的虚方法。
回到今天的主题,我们来查一下虚表的户口,它是如何产生的,在二进制文件的什么地方。
我们先看基类虚表。
基类虚表指针的值是0x00007FF85B0E9940
我们通过内存窗口来看一眼 0x00007FF85B0E9940这里的内容,它存了两个虚函数的地址。
我们再去0x00007ff85b0e11d1看看
跳转去0x07FF85B0E1520,再确定一下这个地址的内容。
可以看到它是基类第一个虚函数的实现。
基于上面这些数据,我们换个角度,来看一下,虚表在文件层面到底在哪里。先通过模块窗口,看一下Base.dll的加载地址。
可以看到Base.dll被加载到了0x00007FF85B0E0000的基址上。
基类的虚函数表指针相对于模块基址的相对偏移地址RVA是0x9940
0x00007FF85B0E9940-0x00007FF85B0E0000 = 0x9940
打开CFF Explorer工具,查看0x9940所在的Section
可以看到RVA为0x9940的位置处在.rdata节。这个节通常包括只读数据 ,以及导入导出表数据。
再看一下0x9940具体存的数据。
这里只存了0x1800011D1,那这里的数据是怎么知道自己在运行的时候要变成 0x00007FF85B0E11D1的呢? 0x00007FF85B0E11D1和模块基址0x00007FF85B0E0000只差了0x11D1,那高位的0x180000000又是干吗用的呢?
查看Base.dll的可选文件头。
可以发现这个dll的_IMAGE_OPTIONAL_HEADER64 中的ImageBase 成员值为0x180000000, 它记录了二进制文件希望被加载的优选地址,如果能加载到这个地址,那是不需要进行重定位的(地址修复)。于是二进制文件中存的其他值,如0x1800011D1都是基于如果加载到这个优选地址时不需要做重定位时的值,现在显然不是,所以要基址0x00007FF85B0E0000+0x1800011D1-0x180000000= 0x00007FF85B0E11D1,就是加载后真实的地址。
那这里又有一个问题,系统怎么知道0x9940这个地址在加载后需要被重定位,参见重定位表可见0x9940这个地址是在重定位表中的。
总结一下,基类的虚表指针是在Base.dll文件的.rdata节,对应的地址同样在重定位节中有指明。
所以当加载完成后,系统会进行重定位操作,以保证虚表指针的值永远指向正确的函数。
同样的,我们来看一下子类对象的虚表有什么特点。
让代码执行到子类的构造函数。
我们通过内存窗口来看一眼子类虚表的位置0x00007FF7FA77AC50
通过模块窗口确定一下main.exe被加载的基地址。
Main被加载的基地址是0x00007FF7FA770000,所以虚表指针的相对偏移地址RVA是0xAC50
再通过CFF Explorer查看一下0xAC50所在的节,同样也是在.rdata节。
我们再去看一眼,0xAC50所存的具体内容。
具体值为0x10400018EF,同样地查看重定位表以及可选文件头中的ImageBase字段。
可知被加载后的真实地址应该是
基址0x00007FF7FA770000+ 0x10400018EF – 0x1040000000 = 0x00007FF7FA7718EF
这于上面内存窗口中看到的值一致。
通过反汇编窗口,我们再看一下0x00007FF7FA7718EF地址的代码,发现它和基类的情况已经不同了。
对比一下基类的情况。
可以清楚地看到,基类是直接跳转到了0x7FF85B0E1520,Base::vfunfoo的地址。而现在这次可以看到代码是
00007FF7FA7718EF FF 25 13 E7 00 00 jmp qword ptr [__imp_Base::vfunfoo(07FF7FA780008h)]
它跳到了0x07FF7FA780008所在的内存地址
清楚地看到它就是Base.dll中的地址,并且与基类的虚表指针的第一项具有一样的指针值,因为在子类中没有overwrite基类的行为。所以这个函数最终才调用到了Base.dll中的代码。
我们再来看一下这一切是如何发生的。
我们先来看一下0x18EF处的内容。
这里的内容和上面的二进制指令值是一模一样的。
也就是00007FF7FA7718EF FF 25 13E7 00 00 jmp qword ptr [__imp_Base::vfunfoo(07FF7FA780008h)]
它去取0x00007FF7FA780008中的内容,这个地址的RVA是0x10008
这个RVA对应在文件中.idata节,也就是导入表中。
查看0x10008这个RVA在文件中的内容
再切换到导入表视图,可以看到10008对应第二项。
同时,对比Base.dll的导出表,可以看到序号和名称是对应的。
综上所述,当模块加载的时候,系统会根据main的导入表和Base.dll的导出表对应关系,把0x10008这个RVA中所记录的0x0000000000010A00,修正为Base.dll导出表最后一项所对应的RVA。也就是地址0x000011D1。
注意这里的导出表只有纯的RVA,并且是一个四字节数据 。导入导出表修正,它是一个跨模块的操作,它会将被导入模块Base.dll中的地址(基地址+四字节偏移得到8字节绝对地址)完全填进导入模块Main的对应导入表结构。
重定位则只会考虑本模块是否加载到了最佳位置,如果已经加载到最佳位置,重定位将不发生。
好了,今天的内容就到此为止,我们下次再见。