通过 dumpbin 已经确认两个 dll 都有名为 GetCallCount 的函数。但是只有一个调用成功了,另外一个却调用失败。
dumpbin
dll
GetCallCount
使用 process explorer 观察 dll 加载情况,发现只加载了一个 dll ,没发现另外一个 dll 。
process explorer
对于这个问题,如果我们使用 process monitor 观察整个加载过程,看到的都是 Success 。如下图:
process monitor
Success
说明,加载正常,在本地找到了这个文件,并正确的映射到内存空间中了。但为什么在进程中观察不到这个 dll 呢?是时候上调试器了。
直接在 vs 中按 F5 启动,果然中断到 vs 中了。
vs
F5
从上图右侧部分,我们可以看到完整的调用栈。
这里简单介绍下相关代码。在 GlobalVariableInitializeOrder.cpp 的第 15 行调用了 HMODULE hDll2 = LoadLibraryA("GlobalVariableInitializeOrderDll2.dll"); 加载对应的模块。
GlobalVariableInitializeOrder.cpp
15
HMODULE hDll2 = LoadLibraryA("GlobalVariableInitializeOrderDll2.dll");
Common\Test2.cpp 的第 10 行定义了全局变量 CTest2 g_t2; (在 dll 中),问题就出在这个全局变量的初始化代码中。
Common\Test2.cpp
10
CTest2 g_t2;
从上图左侧部分,我们可以得知错误代码是 0xc0000005 ,内存访问异常。访问的地址是 0x00000004 ,对应的指令位置是 0x001EA6DB 。
0xc0000005
0x00000004
0x001EA6DB
从上图中的反汇编看,确实是挂在了 001EA6DB mov eax,dword ptr [eax] 。因为 eax 的值是 4 ,我们需要查明 eax 为什么的值是 4 。相信很多小伙伴都知道, eax 用来保存函数调用的返回值。我们可以把注意力集中到 0x001EA6D6 处的 call 指令了,调用的是成员函数 _Root() 。
001EA6DB mov eax,dword ptr [eax]
eax
4
0x001EA6D6
call
_Root()
查看 vs 提供的源码,如下:
1234
_Nodeptr& _Root() const{ // return root of nonmutable tree return (this->_Parent(this->_Myhead));}
我们可以发现 _Root() 内部简单的调用了 _Parent() 函数,并把 this->_Myhead 当作参数传递过去了。再查看下 _Parent() 函数的源码,如下:
_Parent()
this->_Myhead
static _Nodepref _Parent(_Nodeptr _Pnode){ // return reference to parent pointer in node return ((_Nodepref)_Pnode->_Parent);}
务必注意: _Parent() 的返回值类型是 _Nodepref ,返回的是引用(最后三个字母 ref 已经说明了一切)!相当于返回的是 _Pnode->_Parent 的地址!我们可以查看 _Nodepref 的定义: typedef _Nodeptr& _Nodepref; 。
_Nodepref
ref
_Pnode->_Parent
typedef _Nodeptr& _Nodepref;
所以 _Root() 函数相当于 &(this->_Myhead->_Parent) 。我们来观察下 this 各个成员的值。
&(this->_Myhead->_Parent)
this
可以看到 _Myhead 的值是 0 ,类型是 std::_Tree_node<...> 。
_Myhead
0
std::_Tree_node<...>
我们再看下 _Tree_node 的定义:
_Tree_node
12345678910111213
template<class _Value_type, class _Voidptr>struct _Tree_node{ _Voidptr _Left; // offset: 0x0 _Voidptr _Parent; // offset: 0x4 _Voidptr _Right; // offset: 0x8 char _Color; // offset: 0xC char _Isnil; // offset: 0xD _Value_type _Myval; // offset: 0x10private: _Tree_node& operator=(const _Tree_node&);};
从 _Tree_node 的定义可知, _Parent 的偏移是 4 (因为是 32 位的程序,如果是 64 位,那么是 8 )。
_Parent
32
64
8
综上,地址 001EA6D6 处的 call 指令反回了 4 。接下来的两条指令是把返回值赋给局部变量 _Nodeptr _Pnode 。但是在执行第一条汇编指令 mov eax,dword ptr [eax] 时就挂了,因为 eax 的值是 4 ,正常情况下访问 0x00000004 处的值当然会挂掉了。
001EA6D6
_Nodeptr _Pnode
mov eax,dword ptr [eax]
至此,我们知道了崩溃的直接原因——访问非法地址。但是根本原因是什么呢?为什么 _Myhead 是 0 呢? 我猜测是因为 map 还没有初始化。但是该如何证实这个猜测呢?
map
CTest2 的构造函数里调用的是 CTest1::GetMap() , GetMap() 内部会返回 CTest1 的静态变量 static std::map<std::string, std::string> s_manager; 的引用。
CTest2
CTest1::GetMap()
GetMap()
CTest1
static std::map<std::string, std::string> s_manager;
如果能证明在 CTest2::g_t2 初始化时, CTest1::s_manager 还没初始化,那么我们就证实了我们的猜测。
CTest2::g_t2
CTest1::s_manager
我想到两个办法:
g_t2
第一种方法比较简单,直接修改 vs 提供的源码即可,注意修改只读属性。本文以第 2 种方法为例展开。
2
本小节根据上面的调用栈简单的介绍全局变量的初始化过程(只介绍我们关心的部分)。
不知道各位小伙伴儿是否记得上面的调用栈。切换到 8 号栈帧,如下图:
可以发现,在 __DllMainCRTStartup() 函数中,当 dwReason == DLL_PROCESS_ATTACH 或者 dwReason == DLL_THREAD_ATTACH 的时候,会调用 _CRT_INIT() 函数。 _CRT_INIT() 会执行运行时库的初始化相关功能,比如,初始化全局变量。然后才会调用用户提供的 DllMain() 函数。
__DllMainCRTStartup()
dwReason == DLL_PROCESS_ATTACH
dwReason == DLL_THREAD_ATTACH
_CRT_INIT()
DllMain()
继续切换到 7 号栈帧,如下图:
7
通过注释可知, _initterm() 是在调用 C++ constructors 。
_initterm()
C++ constructors
我们继续切换到 6 号栈帧,如下图:
6
根据注释猜测,应该是在依次调用每个全局变量的初始化函数。 pfbegin 指向了保存全局变量初始化函数的表格的起始位置, pfend 指向最后一个有效位置的下一个位置,跟标准库中的容器多么相似啊。如果 *pfbegin 的值不为 0 ,说明表格对应的位置有有效的初始化函数,需要调用,否则就跳过。
pfbegin
pfend
*pfbegin
在 vs 中,我们想遍历出这个表格的内容有些费劲。是时候请 windbg 出场了。
windbg
在使用 windbg 之前一定要设置好符号路径,否则很多内容看不到。
使用 windbg 打开要运行的程序,在命令窗口输入 bm GlobalVariableInitializeOrderDll2!_CRT_INIT ,埋伏好断点后执行 g 命令继续运行。
bm GlobalVariableInitializeOrderDll2!_CRT_INIT
g
很快,就中断到我们设置好的断点处了。在调用 _initterm() 的地方设置好断点,执行 g 命令(也可以和 vs 一样按 F5 ),断下来后,单步进入 _initterm() 函数,执行 dv 查看局部变量。
dv
从输出结果可知, pfbegin = 0x001f6000 , pfend = 0x001f6250 。然后我们就可以用强悍的 dps 来查看 pfbegin 和 pfend 之间的内容了。在命令窗口执行, dps 0x001f6000 0x001f6250 。因为有很多空项,这里只截取中间部分。
pfbegin = 0x001f6000
pfend = 0x001f6250
dps
dps 0x001f6000 0x001f6250
我们可以很明显的看到, g_t2 的构造函数在前, s_manager 的构造函数在后。
s_manager
至此,已经证实了我们之前的猜想。
因为工程 GlobalVariableInitializeOrderDll1 和工程 GlobalVariableInitializeOrderDll2 代码一模一样,只有一点点的不同,就是这一点不同导致了一个 dll 可以正常使用,另外一个却不能正常使用。
GlobalVariableInitializeOrderDll1
GlobalVariableInitializeOrderDll2
我们可以用相同的手法观察 GlobalVariableInitializeOrderDll1.dll 的初始化过程。
GlobalVariableInitializeOrderDll1.dll
在命令窗口输入 bm GlobalVariableInitializeOrderDll1!_CRT_INIT;g ,埋伏好断点后运行起来。再次中断后,使用相同的办法进入 _initterm() 函数,通过 dv 命令得到 pfbegin = 0x10026000 和 pfend = 0x10026250 的值,然后执行 dps 0x10026000 0x10026250 ,如下图(同样有很多空项,只截取了中间部分):
bm GlobalVariableInitializeOrderDll1!_CRT_INIT;g
pfbegin = 0x10026000
pfend = 0x10026250
dps 0x10026000 0x10026250
我们发现, s_manager 的构造函数在前, g_t2 的构造函数在后。
我们应该从根本上消除对全局变量的依赖,只需要把 s_manager 放到 GetMap() 中就可以了。
12345
static std::map<std::string, std::string>& GetMap(){ static std::map<std::string, std::string> s_manager; return s_manager;}
但有时候,由于各种各样的原因,我们不能消除这种依赖。我们还可以调整全局变量的初始化顺序。只要有办法让 g_t2 在 s_manager 之后再初始化就可以了。对比两个 dll 工程文件,我们发现有一处关键的不同点。
在能正常加载的 dll 对应的工程中, Test1.cpp, Test2.cpp 出现的顺序是 Test1.cpp, Test2.cpp ,在不能正常加载的 dll 对应的工程中,出现的顺序是 Test2.cpp, Test1.cpp 。调整 dll2.vcxproj 中的文件顺序和 dll1.vcxproj 一样,再次编译运行,一切顺利。
Test1.cpp, Test2.cpp
Test2.cpp, Test1.cpp
dll2.vcxproj
dll1.vcxproj
强烈建议你也动手实战一番,毕竟纸上来的终觉浅。如果你也想动手实战,可以下载完整的工程文件,使用 vs2013 编译运行即可。如果没装 vs2013 ,也可以手动改成其它版本的 vs 。
vs2013
完整的测试工程下载链接:
百度云 链接: https://pan.baidu.com/s/1gW1dZsNYZoo0s_rfaO2Jzg 提取码: 7irh
CSDN 链接: https://download.csdn.net/download/xiaoyanilw/12405380
永远不要让一个全局变量依赖另外一个全局变量。
全局变量是在 DllMain 或者 main 函数执行前进行初始化的。
DllMain
main
在 32 位程序中,一般使用 eax 保存函数的返回值。
dps 命令可以按地址遍历给定范围的内容。
dv 命令可以查看局部变量和参数。
如果有小伙伴儿对全局变量初始化感兴趣,可以参考以下几篇文档:
https://docs.microsoft.com/en-us/cpp/c-runtime-library/crt-initialization?redirectedfrom=MSDN&view=vs-2019
http://www.cppblog.com/xlshcn/archive/2007/12/07/37088.html
http://bytepointer.com/resources/pietrek_libctiny_2001.htm