调试实战 | dll 加载失败之 Debug Release 争锋篇

缘起

最近,项目里遇到一个 dll 加载不上的问题。实际项目比较复杂,但是解决后,又是这么的简单,合情合理。本文是我使用示例工程模拟的,实际项目中另有玄机,但问题的本质是一样的。本文从行文上与 《调试实战 | dll 加载失败之全局变量初始化篇》 非常相似,示例代码也非常相似(原谅我比较懒),感兴趣的小伙伴儿可以对比来读。

背景介绍

示例代码中一共有四个工程,一个 exe,三个 dll。其中,Base.vcxproj 是封装了公共接口的工程,会生成 Base.dllExtension1.vcxprojExtension2.vcxproj 非常相似,会分别生成 Extension1.dllExtension2.dllMixConfiguration.vcxproj 会生成 MixConfiguration.exe ,该 exe 会加载 Extension1.dllExtension2.dll ,并调用它们的导出函数(象征性的调用)。程序运行起来后,发现只有一个 dll 的功能正常,另外一个 dll 的功能执行不正常。如下图:

run-result

已经通过 dumpbin 确认两个 dll 都有名为 GetCallCount 的函数。但是只有一个调用成功了,另外一个却调用失败。

exports-info

使用 process explorer 观察 dll 加载情况,发现只加载了一个 dll,没发现另外一个 dll

dll-load-info

与上一个问题一样,如果用 procmon 观察整个加载过程,看到的都是 Success。这里不截图了。直接上调试器。

上调试器

直接在 vs 中按 F5 启动,果然中断到 vs 中了。

callstack-and-exception-info

从上图右侧部分,可以看到完整的调用栈。

简单介绍下相关代码。在 MixConfiguration\Entry.cpp 的第 15 行调用了auto hDll2 = LoadLibraryA("Extension2.dll"); 加载对应的模块。在 Extension2\Extension2.cpp 的第 22 行定义了全局变量 CTest2 g_t2,问题就出在这个全局变量的初始化代码中。

从上图左侧部分可知,错误代码是 0xc0000005,内存访问异常。访问的地址是 0x0000000D,对应的指令地址是 008B7F34

exception-address

从上图可以看出,确实是挂在了 008B7F34 movsx ecx,byte ptr [eax]。因为 eax 的值是 0xD,我们需要查明 eax 的值为什么是 0xD。相信很多小伙伴都知道,eax 用来保存函数调用的返回值。我们可以把注意力集中到 0x008B7F2c 处的 Call 指令了,调用的是 _Isnil() 成员函数。

查看 vs 提供的源码,如下:

1
2
3
4
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 ,如下:

watch-pnode

可以看到 _Pnode 的值是 0,类型是 std::_Tree_node<...>

std::_Tree_node 的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 处的值当然会挂掉了。

至此,我们知道了崩溃的直接原因——访问非法地址。但是根本原因是什么呢?为什么 _Pnode0 呢?

_Pnode 的值来自 _Nodeptr _Pnode = _Root();。根据《调试实战 —— dll 加载失败之全局变量初始化篇》 分析的结果, _Root() 函数相当于 &(this->_Myhead->_Parent)。赋值给 _Pnode 后,_Pnode 的值等于 this->_Myhead->_Parent 的值。我们需要观察下 this 的值。

watch-this-content

我们发现 _Parent 的值确实是 0。难道也像上次一样,是没初始化导致的?但是其它成员明明有值,跟上次的情况有些不同。我们需要进一步分析 this 值的来源。

继续深入

查看调用栈,我们发现,this 来自 CTest2 的构造函数里调用的 CObjectManager::GetMap(),这个函数是 Base.dll 的导出函数,返回了一个 GetMap() 中定义的静态变量 s_manager,应该不是初始化顺序的问题了,因为当我们第一次调用 GetMap() 的时候,其内部定义的静态变量会被初始化。那还会是什么问题呢?

想在 vs 中观察下 s_manager 的值,试了几种方式,都不行。

watch-s_manager_type_in_vs

无奈,继续请 windbg 出场。

windbg 出场

打开 windbg,附加到进程,注意一定要勾选 Noninvasive 选项,因为目标进程正在被 vs 调试。

attach-noninvasive

如果没勾选 Noninvasive 选项,会报下图中的错误。

attach-already-being-debugged-process-failed-tip

成功附加后,我们先通过 x Base!*GetMap* 查找到 GetMap 的地址,然后使用 u 004B5830 L20 查看对应的反汇编并查找 s_manager 的地址,发现对应的地址是 004c431c

find_s_manager_address_by_windbg

我们不能直接 dt s_manager,但是可以 dt 004c431c

watch-s_manager_in_windbg

观察出问题的 map 对象。对比看下两者有什么不同,如下图:

compare-two-map

注意看上图红色高亮部分,在 Base.dll 中的定义是带 _Myproxy 的,_Myhead 的偏移是 4,而在 Extension2.dll 中,并没有 _Myproxy,自然而然的,_Myhead 的偏移是 0。这是两个不同的 map 类型!

至此,问题已经明确了,s_manager 在两个模块眼中不一样,注意观察上图中地址(黄色高亮部分)都是 0x004c431c。接下来的工作就是找出为什么 s_managerBase.dllExtension2.dll 中不一样。

追本溯源

vs 中观察继承关系,如下图:

watch-inherit

从上图可知:_Tree 继承自 _Tree_compTree_comp 继承自 _Tree_buy_Tree_buy 继承自 _Tree_alloc_Tree_alloc 又继承自 _Tree_val_Tree_val 又继承自 _Container_base。而 map 继承自 _Tree

这里我们只需要关注 _Tree_val_Container_base

_Tree_val 定义如下(删除了无关信息):

1
2
3
4
5
6
7
8
9
10
11
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 的定义如下(删除了无关信息):

1
2
3
4
5
#if _ITERATOR_DEBUG_LEVEL == 0
typedef _Container_base0 _Container_base;
#else
typedef _Container_base12 _Container_base;
#endif

可以发现,如果 _ITERATOR_DEBUG_LEVEL0_Container_base 就等价于 _Container_base0。否则 _Container_base 等价于 _Container_base12

继续观察_Container_base0_Container_base12 的定义。

_Container_base0 的定义如下:

1
2
3
4
5
struct _CRTIMP2_PURE _Container_base0
{
void _Orphan_all() {}
void _Swap_all(_Container_base0&) {}
};

_Container_base12 的定义如下(删除了无关的成员函数):

1
2
3
4
5
6
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

find-the-culprit

发现,Extension2.vcxproj 中的 stdafx.h 中定义了 #define _ITERATOR_DEBUG_LEVEL 0。如果没有显式定义,该宏的值受 _HAS_ITERATOR_DEBUGGING 影响。一般在 Debug 下,_ITERATOR_DEBUG_LEVEL 的值是 2。可以参考yvals.h 中的定义,截图如下:

iterator_debug_level

至此,我们搞清了整个事情的来龙去脉。总结一下:

由于两个工程的 _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

总结

  • 不要混用 DebugRelease 生成的 Dll

  • map 的基类会根据 _HAS_ITERATOR_DEBUGGING 的不同而不同。

  • 如果一个进程已经被调试了,我们可以通过 Noninvasive 的方式附加到被调试的进程中,执行一些观察操作。

参考资料

BianChengNan wechat
扫描左侧二维码关注公众号,扫描右侧二维码加我个人微信:)
0%