调试实战 —— dll 加载失败之 Debug Release 争锋篇
调试实战 —— dll 加载失败之 Debug Release 争锋篇
缘起
最近,项目里遇到一个 dll
加载不上的问题。实际项目比较复杂,但是解决后,又是这么的简单,合情合理。本文是我使用示例工程模拟的,实际项目中另有玄机,但问题的本质是一样的。本文从行文上与 《调试实战 —— dll 加载失败之全局变量初始化篇》 非常相似,示例代码也非常相似(原谅我比较懒),感兴趣的小伙伴儿可以对比来读。
背景介绍
示例代码中一共有四个工程,一个 exe
,三个 dll
。其中,Base.vcxproj
是封装了公共接口的工程,会生成 Base.dll
。Extension1.vcxproj
和 Extension2.vcxproj
非常相似,会分别生成 Extension1.dll
和 Extension2.dll
。MixConfiguration.vcxproj
会生成 MixConfiguration.exe
,该 exe
会加载 Extension1.dll
和 Extension2.dll
,并调用它们的导出函数(象征性的调用)。程序运行起来后,发现只有一个 dll
的功能正常,另外一个 dll
的功能执行不正常。如下图:
已经通过 dumpbin
确认两个 dll
都有名为 GetCallCount
的函数。但是只有一个调用成功了,另外一个却调用失败。
使用 process explorer
观察 dll
加载情况,发现只加载了一个 dll
,没发现另外一个 dll
。
与上一个问题一样,如果用 procmon
观察整个加载过程,看到的都是 Success
。这里不截图了。直接上调试器。
上调试器
直接在 vs
中按 F5
启动,果然中断到 vs
中了。
从上图右侧部分,可以看到完整的调用栈。
简单介绍下相关代码。在 MixConfiguration\Entry.cpp
的第 15
行调用了auto hDll2 = LoadLibraryA("Extension2.dll");
加载对应的模块。在 Extension2\Extension2.cpp
的第 22
行定义了全局变量 CTest2 g_t2
,问题就出在这个全局变量的初始化代码中。
从上图左侧部分可知,错误代码是 0xc0000005
,内存访问异常。访问的地址是 0x0000000D
,对应的指令地址是 008B7F34
。
从上图可以看出,确实是挂在了 008B7F34 movsx ecx,byte ptr [eax]
。因为 eax
的值是 0xD
,我们需要查明 eax
的值为什么是 0xD
。相信很多小伙伴都知道,eax
用来保存函数调用的返回值。我们可以把注意力集中到 0x008B7F2c
处的 Call
指令了,调用的是 _Isnil()
成员函数。
查看 vs
提供的源码,如下:
static char& _Isnil(_Nodeptr _Pnode)
{// return reference to nil flag in node
return ((char&)_Pnode->_Isnil);
}
发现 _Isnil
内部简单的返回了 _Pnode
的 _Isnil
成员。
务必注意: 这里返回的是 char&
,返回的是引用!相当于返回的是 _Pnode->_Isnil
的地址!
可以在 Watch
窗口查看传递给 _Isnil()
的参数 _Pnode
,如下:
可以看到 _Pnode
的值是 0
,类型是 std::_Tree_node<...>
。
std::_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: 0x10
private:
_Tree_node& operator=(const _Tree_node&);
};
从 _Tree_node
的定义可知, _Isnil
的偏移是 0xD
(一般,32
位的程序指针占 4
字节,如果是 64
位,那么占 8
字节)。
综上,地址 008B7F2C
处的 call
指令反回 0xD
合情合理。008B7F34
处的指令 movsx ecx,byte ptr [eax]
把返回值保存到 ecx
处,但是因为 eax
的值是 0xD
,正常情况下访问 0x0000000D
处的值当然会挂掉了。
至此,我们知道了崩溃的直接原因——访问非法地址。但是根本原因是什么呢?为什么 _Pnode
是 0
呢?
_Pnode
的值来自 _Nodeptr _Pnode = _Root();
。根据《调试实战 —— dll 加载失败之全局变量初始化篇》 分析的结果, _Root()
函数相当于 &(this->_Myhead->_Parent)
。赋值给 _Pnode
后,_Pnode
的值等于 this->_Myhead->_Parent
的值。我们需要观察下 this
的值。
我们发现 _Parent
的值确实是 0
。难道也像上次一样,是没初始化导致的?但是其它成员明明有值,跟上次的情况有些不同。我们需要进一步分析 this
值的来源。
继续深入
查看调用栈,我们发现,this
来自 CTest2
的构造函数里调用的 CObjectManager::GetMap()
,这个函数是 Base.dll
的导出函数,返回了一个 GetMap()
中定义的静态变量 s_manager
,应该不是初始化顺序的问题了,因为当我们第一次调用 GetMap()
的时候,其内部定义的静态变量会被初始化。那还会是什么问题呢?
想在 vs
中观察下 s_manager
的值,试了几种方式,都不行。
无奈,继续请 windbg
出场。
windbg 出场
打开 windbg
,附加到进程,注意一定要勾选 Noninvasive
选项,因为目标进程正在被 vs
调试。
如果没勾选 Noninvasive
选项,会报下图中的错误。
成功附加后,我们先通过 x Base!*GetMap*
查找到 GetMap
的地址,然后使用 u 004B5830 L20
查看对应的反汇编并查找 s_manager
的地址,发现对应的地址是 004c431c
。
我们不能直接 dt s_manager
,但是可以 dt 004c431c
。
观察出问题的 map
对象。对比看下两者有什么不同,如下图:
注意看上图红色高亮部分,在 Base.dll
中的定义是带 _Myproxy
的,_Myhead
的偏移是 4
,而在 Extension2.dll
中,并没有 _Myproxy
,自然而然的,_Myhead
的偏移是 0
。这是两个不同的 map
类型!
至此,问题已经明确了,s_manager
在两个模块眼中不一样,注意观察上图中地址(黄色高亮部分)都是 0x004c431c
。接下来的工作就是找出为什么 s_manager
在 Base.dll
和 Extension2.dll
中不一样。
追本溯源
在 vs
中观察继承关系,如下图:
从上图可知:_Tree
继承自 _Tree_comp
,Tree_comp
继承自 _Tree_buy
, _Tree_buy
继承自 _Tree_alloc
,_Tree_alloc
又继承自 _Tree_val
, _Tree_val
又继承自 _Container_base
。而 map
继承自 _Tree
。
这里我们只需要关注 _Tree_val
和 _Container_base
。
_Tree_val
定义如下(删除了无关信息):
template<class _Val_types>
class _Tree_val : public _Container_base
{
public:
typedef typename _Val_types::_Nodeptr _Nodeptr;
// remove unrelated typedefs and member functions
_Nodeptr _Myhead; // pointer to head node
size_type _Mysize; // number of elements
};
_Container_base
的定义如下(删除了无关信息):
#if _ITERATOR_DEBUG_LEVEL == 0
typedef _Container_base0 _Container_base;
#else
typedef _Container_base12 _Container_base;
#endif
可以发现,如果 _ITERATOR_DEBUG_LEVEL
是 0
,_Container_base
就等价于 _Container_base0
。否则 _Container_base
等价于 _Container_base12
。
继续观察_Container_base0
和 _Container_base12
的定义。
_Container_base0
的定义如下:
struct _CRTIMP2_PURE _Container_base0
{
void _Orphan_all() {}
void _Swap_all(_Container_base0&) {}
};
_Container_base12
的定义如下(删除了无关的成员函数):
struct _CRTIMP2_PURE _Container_base12
{
public:
// remove unrelated member functions
_Container_proxy *_Myproxy;
};
也就是说,_ITERATOR_DEBUG_LEVEL
不同的时候,map
占用的内存是不一样的。我在项目中遇到的正是这个问题。
水落石出
知道 _ITERATOR_DEBUG_LEVEL
会导致 map
的内存结构不一样,我们还需要进一步查找是哪里导致了 _ITERATOR_DEBUG_LEVEL
的值不一样。在整个解决方案搜索 _ITERATOR_DEBUG_LEVEL
。
发现,Extension2.vcxproj
中的 stdafx.h
中定义了 #define _ITERATOR_DEBUG_LEVEL 0
。如果没有显式定义,该宏的值受 _HAS_ITERATOR_DEBUGGING
影响。一般在 Debug
下,_ITERATOR_DEBUG_LEVEL
的值是 2
。可以参考yvals.h
中的定义,截图如下:
至此,我们搞清了整个事情的来龙去脉。总结一下:
由于两个工程的 _ITERATOR_DEBUG_LEVEL
不一样,导致 map
的根基类( _Container_base
)不一样,从而导致了两个工程眼中的 map
不一样,尤其是 _Myhead
的偏移不一样。间接导致了全局变量 g_t2
在初始化时崩溃,进而导致了对应的 dll
加载失败。
动手实战
强烈建议你也动手实战一番,毕竟纸上来的终觉浅。如果你也想动手实战,可以直接下载我保存好的转储文件和对应的调试符号,直接使用 windbg
分析。
dump
文件和对应的符号文件下载链接:
百度云链接: https://pan.baidu.com/s/1EkOVoevZWTHCQOBxZxmJ4w 提取码: xui4
CSDN:https://download.csdn.net/download/xiaoyanilw/12502717
也可以下载完整的工程文件,使用 vs2013
编译运行即可。如果没装 vs2013
,也可以手动改成其它版本的 vs
。
完整的测试工程下载链接:
百度云链接: https://pan.baidu.com/s/1swaTU-7GiVHzdeWroWma6g 提取码: iwkj
CSDN:https://download.csdn.net/download/xiaoyanilw/12502953
总结
-
不要混用
Debug
和Release
生成的Dll
。 -
map
的基类会根据_HAS_ITERATOR_DEBUGGING
的不同而不同。 -
如果一个进程已经被调试了,我们可以通过
Noninvasive
的方式附加到被调试的进程中,执行一些观察操作。