最近项目里老是遇到 dll
加载不上的问题,缘由各类各样。今天先总结一个虽然不是项目中实际遇到的问题,可是却很是经典的问题。其它几种问题,后续慢慢总结。html
示例代码包含一个 exe
工程,两个 dll
工程。 exe
会加载两个 dll
并调用它们的导出函数(GetCallCount
),结果只有一个 dll
的导出函数被成功调用。会是什么缘由呢?node
运行效果以下图:bash
经过 dumpbin
已经确认两个 dll
都有名为 GetCallCount
的函数。可是只有一个调用成功了,另一个却调用失败。函数
使用 process explorer
观察 dll
加载状况,发现只加载了一个 dll
,没发现另一个 dll
。测试
对于这个问题,若是咱们使用 process monitor
观察整个加载过程,看到的都是 Success
。以下图:ui
说明,加载正常,在本地找到了这个文件,并正确的映射到内存空间中了。但为何在进程中观察不到这个 dll
呢?是时候上调试器了。this
直接在 vs
中按 F5
启动,果真中断到 vs
中了。spa
从上图右侧部分,咱们能够看到完整的调用栈。.net
这里简单介绍下相关代码。在 GlobalVariableInitializeOrder.cpp
的第 15
行调用了 HMODULE hDll2 = LoadLibraryA("GlobalVariableInitializeOrderDll2.dll");
加载对应的模块。debug
Common\Test2.cpp
的第 10
行定义了全局变量 CTest2 g_t2;
(在 dll
中),问题就出在这个全局变量的初始化代码中。
从上图左侧部分,咱们能够得知错误代码是 0xc0000005
,内存访问异常。访问的地址是 0x00000004
,对应的指令位置是 0x001EA6DB
。
从上图中的反汇编看,确实是挂在了 001EA6DB mov eax,dword ptr [eax]
。由于 eax
的值是 4
,咱们须要查明 eax
为何的值是 4
。相信不少小伙伴都知道,eax
用来保存函数调用的返回值。咱们能够把注意力集中到 0x001EA6D6
处的 call
指令了,调用的是成员函数 _Root()
。
查看 vs
提供的源码,以下:
_Nodeptr& _Root() const{ // return root of nonmutable tree return (this->_Parent(this->_Myhead));}复制代码 |
咱们能够发现 _Root()
内部简单的调用了 _Parent()
函数,并把 this->_Myhead
看成参数传递过去了。再查看下 _Parent()
函数的源码,以下:
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;
。
因此 _Root()
函数至关于 &(this->_Myhead->_Parent)
。咱们来观察下 this
各个成员的值。
能够看到 _Myhead
的值是 0
,类型是 std::_Tree_node<...>
。
咱们再看下 _Tree_node
的定义:
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
)。
综上,地址 001EA6D6
处的 call
指令反回了 4
。接下来的两条指令是把返回值赋给局部变量 _Nodeptr _Pnode
。可是在执行第一条汇编指令 mov eax,dword ptr [eax]
时就挂了,由于 eax
的值是 4
,正常状况下访问 0x00000004
处的值固然会挂掉了。
至此,咱们知道了崩溃的直接缘由——访问非法地址。可是根本缘由是什么呢?为何 _Myhead
是 0
呢? 我猜想是由于 map
尚未初始化。可是该如何证明这个猜想呢?
CTest2
的构造函数里调用的是 CTest1::GetMap()
,GetMap()
内部会返回 CTest1
的静态变量 static std::map<std::string, std::string> s_manager;
的引用。
若是能证实在 CTest2::g_t2
初始化时,CTest1::s_manager
还没初始化,那么咱们就证明了咱们的猜想。
我想到两个办法:
map
的构造函数中输出一条日志。在调用 g_t2
的构造函数时,查看是否有咱们在 map
中新加的日志。第一种方法比较简单,直接修改 vs
提供的源码便可,注意修改只读属性。本文以第 2
种方法为例展开。
本小节根据上面的调用栈简单的介绍全局变量的初始化过程(只介绍咱们关心的部分)。
不知道各位小伙伴儿是否记得上面的调用栈。切换到 8
号栈帧,以下图:
能够发现,在 __DllMainCRTStartup()
函数中,当 dwReason == DLL_PROCESS_ATTACH
或者 dwReason == DLL_THREAD_ATTACH
的时候,会调用 _CRT_INIT()
函数。_CRT_INIT()
会执行运行时库的初始化相关功能,好比,初始化全局变量。而后才会调用用户提供的 DllMain()
函数。
继续切换到 7
号栈帧,以下图:
经过注释可知,_initterm()
是在调用 C++ constructors
。
咱们继续切换到 6
号栈帧,以下图:
根据注释猜想,应该是在依次调用每一个全局变量的初始化函数。pfbegin
指向了保存全局变量初始化函数的表格的起始位置,pfend
指向最后一个有效位置的下一个位置,跟标准库中的容器多么类似啊。若是 *pfbegin
的值不为 0
,说明表格对应的位置有有效的初始化函数,须要调用,不然就跳过。
在 vs
中,咱们想遍历出这个表格的内容有些费劲。是时候请 windbg
出场了。
在使用 windbg
以前必定要设置好符号路径,不然不少内容看不到。
使用 windbg
打开要运行的程序,在命令窗口输入 bm GlobalVariableInitializeOrderDll2!_CRT_INIT
,埋伏好断点后执行 g
命令继续运行。
很快,就中断到咱们设置好的断点处了。在调用 _initterm()
的地方设置好断点,执行 g
命令(也能够和 vs
同样按 F5
),断下来后,单步进入 _initterm()
函数,执行 dv
查看局部变量。
从输出结果可知,pfbegin = 0x001f6000
,pfend = 0x001f6250
。而后咱们就能够用强悍的 dps
来查看pfbegin
和 pfend
之间的内容了。在命令窗口执行,dps 0x001f6000 0x001f6250
。由于有不少空项,这里只截取中间部分。
咱们能够很明显的看到,g_t2
的构造函数在前,s_manager
的构造函数在后。
至此,已经证明了咱们以前的猜测。
由于工程 GlobalVariableInitializeOrderDll1
和工程 GlobalVariableInitializeOrderDll2
代码如出一辙,只有一点点的不一样,就是这一点不一样致使了一个 dll
能够正常使用,另一个却不能正常使用。
咱们能够用相同的手法观察 GlobalVariableInitializeOrderDll1.dll
的初始化过程。
在命令窗口输入 bm GlobalVariableInitializeOrderDll1!_CRT_INIT;g
,埋伏好断点后运行起来。再次中断后,使用相同的办法进入_initterm()
函数,经过 dv
命令获得 pfbegin = 0x10026000
和 pfend = 0x10026250
的值,而后执行 dps 0x10026000 0x10026250
,以下图(一样有不少空项,只截取了中间部分):
咱们发现,s_manager
的构造函数在前,g_t2
的构造函数在后。
咱们应该从根本上消除对全局变量的依赖,只须要把 s_manager
放到 GetMap()
中就能够了。
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
同样,再次编译运行,一切顺利。
强烈建议你也动手实战一番,毕竟纸上来的终觉浅。若是你也想动手实战,能够下载完整的工程文件,使用 vs2013
编译运行便可。若是没装 vs2013
,也能够手动改为其它版本的 vs
。
完整的测试工程下载连接:
百度云 连接: pan.baidu.com/s/1gW1dZsNY… 提取码: 7irh
CSDN 连接:download.csdn.net/download/xi…
永远不要让一个全局变量依赖另一个全局变量。
全局变量是在 DllMain
或者 main
函数执行前进行初始化的。
在 32
位程序中,通常使用 eax
保存函数的返回值。
dps
命令能够按地址遍历给定范围的内容。
dv
命令能够查看局部变量和参数。
若是有小伙伴儿对全局变量初始化感兴趣,能够参考如下几篇文档: