基础知识 | 函数基础 4 —— 又崩溃了,原来是虚函数声明顺序不一致捣的鬼

缘起

前一阵子,同事遇到了一个崩溃问题,解决后发现这个崩溃是由于在公共类中加了一个虚函数接口,但是并没有编译相关模块导致的。这种崩溃问题是老朋友了。在此之前,我已经写了几篇关于虚函数的总结,感兴趣的小伙伴儿可以查看这几篇文章:

《基础知识 | 有趣的动态转换》

《基础知识 | C++ 虚函数简介》

《基础知识 | 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
    #pragma once

    #ifdef DLL_EXPORT_INTERFACE
    #define DLL_EXPORT_INTERFACE __declspec(dllexport)
    #else
    #define DLL_EXPORT_INTERFACE __declspec(dllimport)
    #endif

    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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Interface.cpp
#include <iostream>

#define DLL_EXPORT__INTERFACE

#include "Interface.h"

void Interface1::Test1(int a)
{
std::cout << __FUNCTION__ << " : " << a << std::endl;
}
void Interface1::Test2(int a)
{
std::cout << __FUNCTION__ << " : " << a << std::endl;
}
void Interface1::Test3(int a)
{
std::cout << __FUNCTION__ << " : " << a << std::endl;
}

InterfaceBase* GetInterface()
{
return new Interface1();
}
  • 主模块

    对应的工程是 InterfaceExe.vcxproj。主模块通过导出接口直接实例化的方式获取 Interface1 的对象指针,然后调用 Test2()

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // InterfaceExe.cpp
    #include "../Interface/Interface.h"
    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
2
3
4
5
6
7
class Interface1 : public InterfaceBase
{
public:
virtual void Test1(int);
virtual void Test3(int);
virtual void Test2(int);
};

只重新编译主模块,查看运行结果。结果如下:

swap-test2-test3

虽然代码里调用的都是 Test2(),但是从输出结果可知:if1 调用的是 Test3()if2 调用的是 Test2()

windbg 中分别查看这两个调用对应的反汇编,如下图:

view-disassembly-of-virtual-function-call

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

view-vtable-in-windbg

可以发现,

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()

view-disassembly-of-interfaceexe-interface1-test2

思考: 如果 Test2Test3 的参数个数或者参数类型不一致,是不是会有更严重的问题?

根据上面的分析可知,从 if1 调用的话会有问题,从 if2 调用的话没问题。

验证 2

Interface.h 中增加一个名为 Test4 的接口,并在 main() 函数中调用之。修改后的代码如下:

1
2
3
4
5
6
7
8
class Interface1 : public InterfaceBase
{
public:
DLL_EXPORT_INTERFACE virtual void Test1(int);
DLL_EXPORT_INTERFACE virtual void Test3(int);
DLL_EXPORT_INTERFACE virtual void Test2(int);
DLL_EXPORT_INTERFACE virtual void Test4(int);
};
1
2
3
4
5
6
7
8
9
// InterfaceExe.cpp
#include "../Interface/Interface.h"

int main()
{
Interface1* if1 = dynamic_cast<Interface1*>(GetInterface());
if1->Test4(0);
return 0;
}

同样,只重新编译主模块。可以正常编译链接,但是执行的时候遇到了一个异常。

view-4th-function-in-vtable

从上图可知,在 main() 函数调用 Test4() 时,rip 指向了地址 00000003 19930522。因为在重新编译主模块时,Test4() 是虚表中的第 4 项(从 0 开始),而虚表中第 4 项的值是 00000003 19930522

因为在编译 Interface.dll 的时候,一共只有 4 项,最大索引是 3。而主模块中的调用代码却尝试访问虚表中的第 4 项,而第 4 项的内容是随机的,所以在执行的时候发生了异常。

根据以上验证结果可知,编译器会根据头文件中的顺序生成虚函数调用代码,如果与虚函数所属模块生成时的顺序不一致,很可能调用的是不同的函数,行为是未定义的。

亲自动手

示例工程已经上传到这里了,感兴趣的小伙伴儿可以自行下载,根据 验证 1验证 2 中的修改方式手动修改代码,进行验证。

务必注意: 修改完代码后不要重新编译 Interface 工程,只重新编译 InterfaceExe 工程!

总结

  • 编译器会根据头文件中的顺序生成虚函数调用代码,如果与虚函数所属模块生成时的顺序不一致,很可能调用的是不同的函数,行为是未定义的。
BianChengNan wechat
扫描左侧二维码关注公众号,扫描右侧二维码加我个人微信:)
0%