缘起
最近又遇到了一个程序功能不正常的问题,深入调查后发现与全局变量初始化顺序有非常大的关系,只不过这次更加隐蔽。
之前总结了两篇与全局变量初始化顺序有关的文章,感兴趣的小伙伴儿可以参考《调试实战 | dll 加载失败之全局变量初始化篇》 和 《调试实战 | 全局变量初始化顺序探究》。
在排查错误之前先简单介绍一下相关代码。
示例程序
示例程序一共包含 4 个工程:LoadDlls, dll1, dll2, dll3。
主程序
LoadDlls.exe会加载dll1.dlldll1.dll隐式依赖了dll2.dll,所以dll1.dll加载的时候会自动加载dll2.dlldll2.dll中的全局变量s_culprit的构造函数会加载dll3.dlldll3.dll加载的时候会自动调用dll2.dll的导出函数RegisterInitCallback()和RegisterCallback()
下面是每个工程的关键代码
src/common/autorunner.h该文件是公共头文件,实现了自动注册逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// autorunner.h
class AutoRunner
{
public:
AutoRunner(void (*func)())
{
func();
}
};
LoadDlls该工程只有一个源文件,用来模拟加载各种插件。对应的源码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60// LoadDlls.cpp
typedef void (*PFN_Init)();
std::map<std::string, HMODULE> LoadPlugins(const char* plugins[])
{
std::map<std::string, HMODULE> result;
for (int idx = 0; ; ++idx)
{
const char* plugin = plugins[idx];
if (plugin == nullptr)
{
break;
}
HMODULE module = LoadLibraryA(plugin);
if (module == nullptr)
{
DWORD lastError = GetLastError();
std::cout << "[+] load [" << plugin << "] failed with error " << lastError << std::endl;
}
else
{
result[plugin] = module;
}
}
return result;
}
void InitPlugins(const std::map<std::string, HMODULE>& loaded_plugins)
{
for (auto& it : loaded_plugins)
{
auto init_entry = (PFN_Init)GetProcAddress(it.second, "Init");
if (init_entry != nullptr)
{
init_entry();
}
}
}
int main()
{
std::cout << "[+] load plugin start." << std::endl;
const char* plugins[] = { "dll1.dll", /*"dll2.dll", "dll3.dll",*/ nullptr };
auto loaded_module_map = LoadPlugins(plugins);
std::cout << "[+] load plugin done, press any key to init plugins." << std::endl;
system("pause");
InitPlugins(loaded_module_map);
return 0;
}dll1该工程非常简单,什么有用的事情都没做,但是会依赖
dll2,加载dll1.dll的时候会自动加载dll2.dll。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// dllmain1.cpp
void PrintMajorVersion()
{
std::cout << "Major Version of dll2.dll is " << MajorVersion() << std::endl;
}
BEGIN_AUTO_RUN
std::cout << "I'm running in dll1.dll, which implicitly depends on dll2.dll." << std::endl;
END_AUTO_RUN
dll2该模块提供了注册回调函数的导出接口,并实现了回调逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// exports.h
DLL_EXPORT int MajorVersion();
DLL_EXPORT void RegisterInitCallback(const char* key, void(*callback)());
DLL_EXPORT void RegisterCallback(const char* key, void(*callback)());
DLL_EXPORT void Init();
1  | // dllmain2.cpp  | 
dll3该模块会自动调用
dll2.dll导出的接口进行注册1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27// dllmain3.cpp
///////////////////////////////////////////////////////////////////////////////
void Dll3InitCallback()
{
std::cout << "I'm callback from dll3.dll" << std::endl;
}
BEGIN_AUTO_RUN
RegisterInitCallback("dll3", Dll3InitCallback);
END_AUTO_RUN
///////////////////////////////////////////////////////////////////////////////
void Dll3Callback()
{
std::cout << "I'm callback from dll3.dll" << std::endl;
}
BEGIN_AUTO_RUN
RegisterCallback("dll3", Dll3Callback);
END_AUTO_RUN
直接运行程序,从表面上看一切正常,但是在调试器下运行程序的时候会遇到一个意想不到的异常。
调试运行
打开 windbg,选择需要执行的程序,确定后输入 g 命令,目标程序会发生异常,自动中断到 windbg 中。

在 windbg 中输入 kc 查看调用栈,输出结果摘录如下(为了方便查看,输出结果有所调整,注意 <---- 的部分):
1  | 0:000> kc  | 
在 windbg 中输入 .frame 0x27 切换到 0x27 栈帧,然后输入 dv 查看局部变量,可以发现确实是在加载 dll1.dll。
1  | 0:000> .frame 0x27  | 
结合代码可以整理整个执行流程,大概是这样的:
主程序
LoadDlls.exe会通过LoadPlugins()调用LoadLibrary()来加载dll1.dll,由于dll1.dll隐式依赖了dll2.dll,所以dll1.dll加载的时候会自动加载dll2.dll。dll2.dll中的全局变量s_culprit的构造函数(栈帧0x15)内部会调用LoadLibrary()加载dll3.dll(栈帧0x14)dll3.dll中的全局变量s_auto_runner_23的构造函数(栈帧0x6)内部会调用dll2.dll的导出函数RegisterCallback()(栈帧0x3)RegisterCallback()内部会调用s_callbacks.insert()把注册的回调函数保存起来,但是在保存过程中遇到了异常,中断到了windbg中。
查看异常
根据 windbg 给出的提示,可以发现是在读取地址 0x00000008 的时候发生了异常,此地址明显是不可访问的。
00007ffd 57684ff1 488b4008    mov rax,qword ptr [rax+8] ds:00000000 00000008=????????????????
看上去非常像是空指针异常。这段代码是在调用  s_callbacks.insert() 的时候执行的,大概率是 s_callbacks 出了问题,在 windbg 中使用 dx s_callbacks -r4 查看  s_callbacks 的值,如下图:

可以发现,s_callbacks 中的值很奇怪,都是空值。看上去很像还没有初始化的样子。
结合上面整理的调用流程,可以发现是在调用 dll2!s_culprit 的构造函数时接触发了对 dll2!RegisterCallback() 的调用,这时 dll2!s_callbacks 这个全局变量还没有初始化。
因为初始化完 dll2!s_culprit,才会初始化 dll2!s_callbacks。
至此,可以破案了。只需要调整一下这两个全局变量的顺序,问题就解决了。修改后的代码如下:
1  | //MyGlobalVariable s_culprit; // 移动到 s_callbacks 下面  | 
亲自动手
相关工程代码已经上传到 github 了,感兴趣的小伙伴儿可以下载验证。
总结
本次故障是因为在 dll2.dll 的全局变量 s_culprit 的构造函数中使用 LoadLibrary() 加载了 dll3.dll,而 dll3.dll 中的全局变量构造函数会调用 dll2!RegisterCallback(),这个函数内部会使用未初始化的全局变量 dll2!s_callbacks。因为此时正在初始化 dll2!s_culprit 的过程中,dll2!s_culprit 初始化完成后才会初始化 dll2!s_callbacks。
相较于之前的案例,这次的案例更复杂,涉及到了多个模块。单看每个模块,问题都不大,但是放到一起就触发了这个异常。
所以,尽量不要在全局变量的构造函数中做复杂的工作,尤其要避免类似 LoadLibrary 的操作。
参考资料
彩蛋
其实,这个问题背后还有一个更隐蔽的 bug,不知道你是否看出来了呢?stay tuned!