缘起
前一阵子,同事遇到了一个崩溃问题,解决后发现这个崩溃是由于在公共类中加了一个虚函数接口,但是并没有编译相关模块导致的。这种崩溃问题是老朋友了。在此之前,我已经写了几篇关于虚函数的总结,感兴趣的小伙伴儿可以查看这几篇文章:
《基础知识 | 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
工程!
总结
- 编译器会根据头文件中的顺序生成虚函数调用代码,如果与虚函数所属模块生成时的顺序不一致,很可能调用的是不同的函数,行为是未定义的。