缘起
前一阵子,同事遇到了一个崩溃问题,解决后发现这个崩溃是由于在公共类中加了一个虚函数接口,但是并没有编译相关模块导致的。这种崩溃问题是老朋友了。在此之前,我已经写了几篇关于虚函数的总结,感兴趣的小伙伴儿可以查看这几篇文章:
《基础知识 | c++ 有趣的动态转换之 delete 崩溃探究兼谈基类虚析构的重要性》
《基础知识 | 函数基础 1 —— 基本概念 & 如何调用外部模块的函数》
《基础知识 | 函数基础 2 —— 如何不依赖外部模块却能调用它的函数?》
《基础知识 | 函数基础 3 —— 跨模块调用未导出虚函数的各种姿势 》
本文主要关注以下两个问题,如果你已经有了很明确的答案,可以跳过本文:
- 如果在编译
A模块的时候,Test类的虚函数声明的顺序是Test1, Test2, Test3,但是在B模块编译的时候,Test类头文件中虚函数顺序变成了Test2, Test1, Test3。在B模块中调用test->Test1(),调用的是哪个函数呢?
- 假设
A模块代码不变,但是在编译B模块的时候,Test类的头文件中又多了一个虚函数Test4(),在B模块中调用test->Test4(),代码可以正常编译吗?会有链接问题吗?如果可以正常编译链接,运行的时候会有问题吗?
在开始验证前先回顾一下之前的结论
一些结论
虚表及虚表中的函数地址会与构造函数存储在同一个模块中。如果构造函数存在于多个模块中,虚表及虚表中的函数地址也会保存在多个模块中
每个包含虚函数的类对象都有一个虚表指针,该指针指向了虚表,会在类对象的构造函数中初始化
虚表中按顺序存放了虚函数地址
调用虚函数的时候会先获取虚表指针,然后根据虚函数的索引从虚表中得到最终的函数地址进行调用
更多结论请参考 上一篇文章。
回顾完结论后,让我们用测试程序探寻以上两个问题的答案,先来看看示例程序的代码
示例程序
示例程序由两个工程组成:主模块和接口模块。
接口模块
对应的工程是
Interface.vcxproj。代码很简单,实现了接口,并暴露了一个导出接口供主模块使用1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24// interface.h
class InterfaceBase
{
public:
virtual ~InterfaceBase() {}
};
class Interface1 : public InterfaceBase
{
public:
DLL_EXPORT_INTERFACE virtual void Test1(int);
DLL_EXPORT_INTERFACE virtual void Test2(int);
DLL_EXPORT_INTERFACE virtual void Test3(int);
};
DLL_EXPORT_INTERFACE InterfaceBase* GetInterface();
1 | // Interface.cpp |
主模块
对应的工程是
InterfaceExe.vcxproj。主模块通过导出接口和直接实例化的方式获取Interface1的对象指针,然后调用Test2()。1
2
3
4
5
6
7
8
9
10// InterfaceExe.cpp
int main()
{
Interface1* if1 = dynamic_cast<Interface1*>(GetInterface());
Interface1* if2 = new Interface1();
if1->Test2(0);
if2->Test2(1);
return 0;
}
以上示例程序编译运行一切正常。接下来保持接口模块不变,修改代码后只重新编译主模块。
验证1
交换 Interface.h 中的 Test2() 和 Test3() 的顺序,修改后的代码如下:
1 | class Interface1 : public InterfaceBase |
只重新编译主模块,查看运行结果。结果如下:

虽然代码里调用的都是 Test2(),但是从输出结果可知:if1 调用的是 Test3(),if2 调用的是 Test2()。
在 windbg 中分别查看这两个调用对应的反汇编,如下图:

可以发现,调用的都是虚表中索引为 3 (0x18 / 0x8 = 3)的函数。分别查看一下虚表

可以发现,
if1 对应的虚表存储在 Interface 模块,第 3 项是 Interface!Interface1::Test3;
if2 对应的虚表存储在 InterfaceExe 模块,第 3 项是 InterfaceExe!Interface1::Test2。
在编译 Interface 模块时,由于 GetInterface() 内部会调用 new Interface1,因此会在 Interface 模块中生成虚表,此时 Test3() 是最后一项。
在编译 InterfaceExe 模块时,由于会直接调用 new Interface1(),因此会在 InterfaceExe 模块中生成虚表,此时 Test2() 是最后一项。
在 windbg 中查看 InterfaceExe!Interface1::Test2 对应的反汇编,可以发现其最终会调用 Interface!Interface1::Test2()。

思考: 如果
Test2和Test3的参数个数或者参数类型不一致,是不是会有更严重的问题?根据上面的分析可知,从
if1调用的话会有问题,从if2调用的话没问题。
验证 2
在 Interface.h 中增加一个名为 Test4 的接口,并在 main() 函数中调用之。修改后的代码如下:
1 | class Interface1 : public InterfaceBase |
1 | // InterfaceExe.cpp |
同样,只重新编译主模块。可以正常编译链接,但是执行的时候遇到了一个异常。

从上图可知,在 main() 函数调用 Test4() 时,rip 指向了地址 00000003 19930522。因为在重新编译主模块时,Test4() 是虚表中的第 4 项(从 0 开始),而虚表中第 4 项的值是 00000003 19930522。
因为在编译 Interface.dll 的时候,一共只有 4 项,最大索引是 3。而主模块中的调用代码却尝试访问虚表中的第 4 项,而第 4 项的内容是随机的,所以在执行的时候发生了异常。
根据以上验证结果可知,编译器会根据头文件中的顺序生成虚函数调用代码,如果与虚函数所属模块生成时的顺序不一致,很可能调用的是不同的函数,行为是未定义的。
亲自动手
示例工程已经上传到这里了,感兴趣的小伙伴儿可以自行下载,根据 验证 1 和 验证 2 中的修改方式手动修改代码,进行验证。
务必注意: 修改完代码后不要重新编译
Interface工程,只重新编译InterfaceExe工程!
总结
- 编译器会根据头文件中的顺序生成虚函数调用代码,如果与虚函数所属模块生成时的顺序不一致,很可能调用的是不同的函数,行为是未定义的。