第二章排错的工具:调试器Windbg(下)

时间:2022-04-15 23:00:04

感谢博主 http://book.51cto.com/art/200711/59874.htm

2.2  读懂机器的语言:汇编,CPU执行指令的最小单元
2.2.1  需要用汇编来排错的常见情况

汇编是CPU执行指令的最小单元。下面一些情况下,汇编级别的分析通常是必要的:
1. 阅读代码看不出问题,但是跑出来的结果就是不对,怀疑编译器甚至CPU有毛病。
2. 没有源代码可以阅读。比如,调用某一个API的时候出问题,没有Windows的源代码,那就看汇编。
3. 当程序崩溃,访问违例的时候,调试器里看到的直接信息就是汇编。

调试中涉及的汇编知识分为两部分:

1. 寄存器的运算,对内存地址的寻址和读写。这部分是跟CPU本身相关的。
2. 函数调用时候堆栈的变化,局部变量全局变量的定位,虚函数的调用。这部分是跟编译器相关的。
汇编的知识可以在大学计算机教程里面找到。建议先熟悉简单的8086/80286的汇编,再结合IA32芯片结构和32位Windows汇编知识深入。建议的资源:

AoGo汇编小站
http://www.aogosoft.com/
Intel Architecture Manual volume 1,2,3
http://www.intel.com/design/pentium4/manuals/index_new.htm

案例分析:用汇编读懂VC编译器的优化
问题描述
客户在开发一个性能敏感的程序,想知道VC编译器对下面这段代码的优化做得怎么样:

 int hgt=4;
int wid=7;
for (i=0; i<hgt; i++)
for (j=0; j<wid; j++)
A[i*wid+j] = exp(-(i*i+j*j));

最直接的方法就是查看编译器生成的汇编代码分析。有兴趣的话先自己调试一下,看看跟我的分析是否一样。

我的分析

我分析的平台是,VC6,release mode下编译:(因为当时做这个case的时候,客户用的VC6。现在VC6已经退出历史舞台,微软不再提供支持)。

int hgt=4;
int wid=7;
24: for (i=0; i<hgt; i++)
0040107A xor ebp,ebp
0040107C lea edi,[esp+10h]
25: for (j=0; j<wid; j++)
26: A[i*wid+j] = exp(-(i*i+j*j));
00401080 mov ebx,ebp
00401082 xor esi,esi
// The result of i*i is saved in ebx
00401084 imul ebx,ebp
00401087 mov eax,esi
// Only one imul occurs in every inner loop (j*j)
00401089 imul eax,esi
// Use the saved i*i in ebx directly. !!Optimized!!
0040108C add eax,ebx
0040108E neg eax
00401090 push eax
00401091 call @ILT+0(exp) (00401005)
00401096 add esp,4
// Save the result back to A[]. The addr of current offset in A[] is saved in edi
00401099 mov dword ptr [edi],eax
0040109B inc esi
// Simply add edi by 4. Does not calculate with i*wid. Imul is never used. !!Optimized!!
0040109C add edi,4
0040109F cmp esi,7
004010A2 jl main+17h (00401087)
004010A4 inc ebp
004010A5 cmp ebp,4
004010A8 jl main+10h (00401080)

这段代码涉及到的优化有:

1. i*i在每次内循环中是不变化的,所以只需要在外循环里面重新计算。编译器把外循环计算好的i*i放到ebx寄存器中,内循环直接使用。
2. 对A[i*wid+j]寻址的时候,在内循环里面,变化的只有j,而且每次j都是增加1,由于A是整型数组,所以每次寻址的变化就是增加1*sizeof(int),就是4。编译器把i*wid+j的结果放到了EDI中,在内循环中每次add edi,4来实现了这个优化。
3. 对于中间变量,编译器都是保存在寄存器中,并没有读写内存。
如果这段汇编让你手动来写,你能做得比编译器更好一点吗?

案例分析:VC2003 编译器的bug、debug模式正常,release模式会崩溃

不要迷信编译器没有bug。如果你在VS2003中测试下面的代码,会发现在release mode下面,程序会崩溃或者异常,但是在debug mode下工作正常。

例子程序

// The following code crashes/abnormal in release build
when "whole program optimizations /GL"
// is set. The bug is fixed in VS2005

#include <string> #pragma warning( push ) #pragma warning( disable : 4702 ) // unreachable code in <vector> #include <vector> #pragma warning( pop ) #include <algorithm> #include <iostream>

//vcsig // T = float, U = std::cstring template <typename T, typename U> T func_template( const U & u ) { std::cout<<u<<std::endl; const char* str=u.c_str(); printf(str); return static_cast<T>(0); }

void crash_in_release() { std::vector<std::string> vStr;

vStr.push_back("1.0"); vStr.push_back("0.0"); vStr.push_back("4.4");

std::vector<float> vDest( vStr.size(), 0.0 );

std::vector<std::string>::iterator _First=vStr.begin(); std::vector<std::string>::iterator _Last=vStr.end(); std::vector<float>::iterator _Dest=vDest.begin();

std::transform( _First,_Last,_Dest, func_template<float,std::string> );

_First=vStr.begin(); _Last=vStr.end(); _Dest=vDest.begin();

for (; _First != _Last; ++_First, ++_Dest) *_Dest = func_template<float,std::string>(*_First); }

int main(int, char*) { getchar(); crash_in_release(); return 0; }

编译设定如下:

1. 取消precompiled header。
2. 编译选项是: /O2 /GL /D "WIN32" /D "NDEBUG" /D "_CONSOLE"
/D "_MBCS" /FD /EHsc /ML /GS /Fo"Release/" /Fd"Release/vc70.pdb"
/W4 /nologo /c /Wp64 /Zi /TP。

跟踪汇编指令来分析

拿到这个问题后,首先在本地重现。根据下面一些测试和分析,认为很有可能是编译器的bug:

1. 程序中除了cout和printf外,没有牵涉到系统相关的API,所有的操作都是寄存器和内存上的操作。所以不会是环境或者系统因素导致的,可能性是代码错误(比如边界问题)或者编译器有问题。
2. 检查代码后没有发现异常。同时,如果调整一下std::transform的位置,在for loop后面调用的话,问题就不会发生。
3. 问题发生的情况跟编译模式相关。

代码中的std::transform和for loop的作用都是对整个vector调用func_template作转换。可以比较transform和for loop的执行情况进行比较分析,看看func_template的执行过程有什么区别。在VS2003里面利用main函数设定断点,停下来后用ctrl+alt+D进入汇编模式单步跟踪。下面的分析证明了这是编译器的bug:

在VisualStudio附带的STL源代码中,发现 std::transform的实现中用这样的代码来调用传入的转换函数:

*_Dest = _Func(*_First);

编译器对于该代码的处理是:

EAX = 0012FEA8 EBX = 0037138C ECX = 003712BC EDX = 00371338 ESI
= 00371338 EDI = 003712B0 EIP = 00402228 ESP = 0012FE70 EBP = 0012FEA8 EFL = 00000297
388: *_Dest = _Func(*_First);
00402228 push esi
00402229 call dword ptr [esp+28h]
0040222D fstp dword ptr [edi]

ESI寄存器中保存的是需要传入_Func的参数*_First。可以看到,std::transform把这个参数通过push指令传入stack给_Func调用。
对于for loop中的*_Dest =  func_templatefloatstd::string>(*_First);编译器是这样处理的:

EAX = 003712B0 EBX = 00371338 ECX = 003712BC EDX = 00000000
ESI = 00371338 EDI = 0037138C EIP = 00401242 ESP = 0012FE98 EBP = 003712B0 EFL = 00000297
37: *_Dest = func_template<float,std::string>(*_First);
00401240 mov ebx,esi
00401242 call func_template <float,std::basic_string<char,
std::char_traits<char>,std::allocator<char> > > (4021A0h)
00401247 fstp dword ptr [ebp]

可以看到,使用for loop的时候,参数通过mov指令保存到ebx寄存器中传入func_template调用。
最后,看一下func_template函数是如何来获取传入的参数的。

004021A0 push esi
004021A1 push edi
16: std::cout<<u<<std::endl;
004021A2 push ebx
004021A3 push offset std::cout (414170h)
004021A8 call std::operator<<<char,std::char_traits<char>,std::allocator<char> > (402280h)

这里直接把ebx推入stack,然后调用std::cout,并没有读取stack中的资料,说明func_template(callee)认为参数应该是从寄存器中传入的。然而transform函数(caller)却把参数通过stack传递。于是使用transform调用func_template的时候,func_template无法拿到正确的参数,因而导致崩溃。通过for loop调用的时候,由于参数通过寄存器传递,所以func_template就可以正常工作。

结论是编译器对参数的传入、读取、处理不统一,导致了这个问题。

为何问题在debug模式下不发生,或者调换函数次序后也不发生,留作你的练习吧 :-P

案例分析:臭名昭著的DLL Hell如何导致ASP.NET出现Server Unavailable

客户的ASP.NET程序,访问任何页面都报告Server Unavailable。观察发现,ASP.NET的宿主w3wp.exe进程每次刚启动就崩溃。通过调试器观察,崩溃的原因是访问了一个空指针。但是从call stack看,这里所有的代码都是w3wp.exe和.net framework的代码,还没有开始执行客户的页面,所以跟客户的代码无关。通过代码检查,发现该空指针是作为函数参数从调用者(caller)传到被调用者(callee)的,当callee使用这个指针的时候问题发生。接下来应该检查caller为什么没有把正确的指针传入callee。

奇怪的时候,caller中这个指针已经正常初始化了,是一个合法的指针,调用call语句执行callee的以前,这个指针已经被正确地push到stack上了。为什么caller从stack上拿的时候,却拿到一个空指针呢?再次单步跟踪,发现问题在于caller把参数放到了callee的[ebp+8],但是callee在使用这个参数的时候,却访问[ebp+c]。是不是跟前一个案例很像?但是这次的凶手不是编译器,而是文件版本。Caller和callee的代码位于两个不同的DLL,其中caller是.NET Framework 1.1带的,而callee是.NET Framework 1.1 SP1带的。在.NET Framework 1.1中,callee函数接受4个参数,但是新版本SP1对callee这个函数作了修改,增加了1个参数。由于caller还使用SP1以前的版本,所以caller还是按照4个参数在传递,而callee按照5个参数在访问,所以拿到了错误的参数,典型的DLL Hell问题。在重新安装.NET Framework 1.1 SP1让两个DLL保持版本一致,重新启动后,问题解决。

导致DLL Hell的原因有很多。根据经验猜测版本不一致的原因可能是:

1. 安装了.NET Framework 1.1 SP1后没有重新启动,导致某些正在使用的DLL必须要等到重新启动后才能够完成更新。
2. 由于使用了Application Center做Load Balance,集群中的服务器没有做好正确的设置,导致系统自动把老版本的文件还原回去了:

PRB: Application Center Cluster Members Are Automatically Synchronized After Rebooting
http://support.microsoft.com/kb/282278/en-us

2.2.2  题外话和相关讨论

Release比 Debug快吗

分别在debug/release模式下运行下面的代码比较效率,会发现debug比release更快。你能找到原因吗?

 long nSize = 200;
char* pSource = (char *)malloc(nSize+1);
char* pDest = (char *)malloc(nSize+1);
memset(pSource, 'a', nSize);
pSource[nSize] = '\0';
DWORD dwStart = GetTickCount();
for(int i=0; i<5000000; i++)
{
strcpy(pDest, pSource);
}
DWORD dwEnd = GetTickCount();
printf("%d", dwEnd-dwStart);

如果让你自己实现一个strcpy函数,应该考虑什么?你能做到比系统的strcpy函数快吗?

一些讨论可以参考:

http://eparg.spaces.live.com/blog/cns!59BFC22C0E7E1A76!1498.entry

从效率上说,起决定性作用的至少有下面两点:

1. 在32位芯片上,应该尽量每次mov一个DWORD,而不是4个byte来提高效率。注意到mov DWORD的时候要4字节对齐。
2. 这里对strcpy的调用高达5000000次。由于call指令的开销,使用内联 (inline) 版本的strcpy函数可以极大提高效率。

所以,汇编、CPU习性、操作系统和编译器,是分析细节的最直接武器。

上面例子中的strcpy是否内联,取决于编译设定。由于strcpy是CRT(CRuntime C运行库)函数,函数的实现位于MSVCRT.DLL或者MSVCRTD.DLL。如果编译设定使得函数调用要跨越DLL,这个函数是无法内联的。
关于性能的另外一些讨论:
http://eparg.spaces.msn.com/blog/cns!59BFC22C0E7E1A76!875.entry

2.3  理解操作系统对程序的反馈:异常(Exception)和通知(Debug Event)

本小结首先介绍异常的原理和相关资料,再举例说明异常跟崩溃和调试是如何紧密联系在一起的。最后说明如何利用工具来监视异常,获取准确的信息。

2.3.1  异常(Exception)的方方面面和一篇字字珠玑的文章

异常是CPU,操作系统和应用程序控制代码流程的一种机制。正常情况下,代码是顺序执行的,比如下面两行:

 *p=11;
printf(“%d”,*p);

这里应该会打印出11。 但若p指向的地址是无效地址呢?那么这里对*p赋值的时候,也就是CPU向对应地址做写操作的时候,CPU就会触发无效地址访问的异常,接下来的printf很可能就不会执行了。

从这个简单的例子可以看到,当程序行为跟预期相左的时候,很可能就是异常的发生改变了程序的执行逻辑。在很多案例中,抓准异常的原因,其实就解决了问题。

异常发生的时候,由于操作系统在内核挂接了对应的CPU异常处理函数,CPU就会跳转去执行操作系统提供的处理函数,所以printf就不一定会被执行了。在操作系统的处理函数里面,如果检测到发生在用户态的程序的异常,操作系统会再把异常信息发送给用户态进程对应的处理函数,让用户态程序有处理异常的机会。

用户态程序处理完了异常,代码会继续执行,不过执行的次序可以是紧接着的下一个指令,比如printf,也可以跳到另外的地址开始执行,比如catch block,或者重新执行一次出错的指令。这些都是用户态的异常处理函数可以控制的。

如果用户态程序没有处理这个异常,那操作系统的默认行为就是中止程序的执行,然后用户可以看到给Microsoft发送错误报告的界面,或者干脆就是一个红色的框框,说某某地址上的指令在访问某某地址的时候遭遇了访问违例的错误。

除了上面的非预期异常,也可以手动触发异常来控制执行顺序,C++/C# 中的throw关键字就可以触发异常。手动触发异常需要依赖于编译器和操作系统API来实现。

异常的类型,是通过异常代码来标识的。比如访问无效地址的号码是0xc0000005,而C++异常的号码是0xe06d7363。其他很多看似跟异常无关的东西,其实都是跟异常联系在一起的,比如调试的时候设置断点,或者单步执行,都有通过break point exception来实现的。越权指令,堆栈溢出的处理也依靠异常。在Windbg帮助文件的Controlling Exceptions and Events主题里面,有一张常用异常代码表。

程序的行为跟预期的不一样,直接原因是代码执行次序跟预期的不一样。异常改变了代码执行次序,比如代码中从来都没有什么函数跳一个红框框出来,说某某地址上的指令在访问某某地址的时候遭遇了访问违例。弄清楚异常发生的时间、地址、导致异常的指令和异常导致的结果对排错是至关重要的。

异常如此重要,所以操作系统提供了对应的调试功能,可以使用调试器来检视异常。异常发生后,操作系统在调用用户态程序的异常处理函数前,会检查当前用户态程序是否有调试器加载。如果有,那么操作系统会首先把异常信息发送给调试器,让调试器有观察异常的第一次机会,所以也叫做first chance exception,调试器处理完毕后,操作系统才让用户态程序来处理。

如果用户态程序处理了这个异常,就没调试器什么事了。否则,程序在unhandled exception崩溃前,操作系统会给调试器第二次观察异常的机会,所以也叫做second chance exception。

请注意,这里的1st chance, 2nd chance是针对调试器来说的。虽然C++异常处理的时候也会有first phrase find exception handler, second phrase unwind stack这样的概念,但是两者是不一样的。

操作系统提供的异常处理功能叫做 Structrued Exception Handle(SEH),C++和其他高级语言的异常处理机制都是建立在SEH上的。如果要直接使用SEH,可以在C/C++中使用__try,__except关键字。

关于异常处理的详细信息,所有的来龙去脉,操作系统做了些什么事情,C++编译器做了些什么事情,SEH和C++异常处理的关系,以及调试器是如何参与的,下面几篇文章有非常详细的介绍。

A Crash Course on the Depths of Win32™ Structured Exception Handling
http://www.microsoft.com/msj/0197/Exception/Exception.aspx

这篇文章出来后,没见人写第二篇了。深入浅出,字字珠玑。

RaiseException
http://msdn.microsoft.com/library/default.asp?url=/library/en-us/debug/base/raiseexception.asp

注意,上面链接中,remark section详细介绍了异常处理函数是如何被分发的。

案例分析:如何让C++像C#一样打印出函数调用栈(callstack)

如果用C#或者Java,在异常发生后,可以获取异常发生时刻的call stack。但是对于C++,除非使用调试器,否则是看不到的。现在用户想尽可能少地修改代码,让C++程序在异常崩溃后,能够打印出call stack,有什么方法呢?
我的解法是直接使用SEH,加上局部变量析构函数在异常发生时候会被执行的特点来完成。这个例子当时使用VC6在Windows 2003上调试通过。当重新整理这个例子的时候,发现这段代码在VC2005+Windows 2003 SP1上有奇怪的现象发生。如果用debug模式编译,运行正常。如果用release模式编译,程序会在没有任何异常报告的情况下悄然退出。关于整个源代码和对应的分析,请参考:

SEH,DEP, Compiler,FS:[0] and PE format
http://eparg.spaces.msn.com/blog/cns!59BFC22C0E7E1A76!712.entry

2.3.2  Adplus,抓取dump的方便工具

前面提到了dump文件能保存进程状态,方便分析。由于dump文件记录的是进程某一时刻的具体信息,所以保存dump的时机非常重要。比如程序崩溃,dump应该选在引发崩溃的指令执行时(也就是1st chance exception发生的时候)获取,这样分析dump的时候就能够看到问题的直接原因。

Adplus是跟Windbg在同一个目录的VBS脚本。Adplus主要是用来抓取dump文件。 详细的信息,可以参考Windbg帮助文件中关于adplus的帮助。有下面一些常见用法:

假设我们的目标程序是test.exe:

假设test.exe运行一段时间崩溃,在test.exe启动后崩溃前的这个时间段,运行下面的命令监视:

 Adplus –crash –pn test.exe –o C:\dumps

当test.exe发生2nd chance exception崩溃的时候,adplus在C:\dumps生成full dump文件。当发生1st chance AV exception, 或者1st chance breakpoint exception的时候,adplus在C:\dumps生成mini dump文件。

也可以用:

 Adplus –crash –pn test.exe –fullonfirst –o C:\dumps

差别在于,加上-fullonfirst参数后,无论是1st chance exception还是2nd chance exception,都会生成full dump文件。
假如test.exe发生deadlock,或者memory leak,并不是crash,需要获取任意时刻的一个dump,可以用下面的命令:

 Adplus –hang –pn test.exe –o C:\dumps

该命令立刻把test.exe的full dump 抓到C:\dumps下。

Adplus更灵活的方法就是用-c参数带配置文件。在配置文件里面,可以选择exception发生的时间,生成的dump是mini dump还是full dump,还可以设定断点等等。对于adplus各项参数的选用原则,在最后一章还会作进一步介绍。

案例分析:华生医生(Dr. Watson)在什么情况下不能记录Dump文件

问题描述

客户声称用VC开发的程序偶尔会崩溃。为了获取详细信息,客户激活了Dr. Watson,以便程序崩溃的时候可以自动获取dump文件。但是问题再次发生后,Dr. Watson并没有记录dump文件。

背景知识

dump文件包含的是内存镜像信息。在Windows系统上,dump文件分为内核dump和用户态dump两种。前者一般用来分析内核相关的问题,比如驱动程序;后者一般用来分析用户态程序的问题。如果不作说明,本书后面所指的dump都表示用户态dump。用户态的dump又分成mini dump和full dump。前者尺寸小,只记录一些常用信息;后者则是把目标进程用户态的所有内容都记录下来。Windows提供了MiniDumpWriteDump API可供程序调用来生成mini dump。通过调试器和相关工具,可以抓取目标程序的full dump。拿到dump后,可以通过调试器检查dump中的内容,比如call stack,memory,exception等等。关于dump和调试器的更详细信息,后面会有更多介绍。跟Dr. Watson相关的文档是:

 Description of the Dr. Watson for Windows (Drwtsn32.exe) Tool
http://support.microsoft.com/?id=308538
Specifying the Debugger for Unhandled User Mode Exceptions
http://support.microsoft.com/?id=121434
INFO: Choosing the Debugger That the System Will Spawn
http://support.microsoft.com/?id=103861

也就是说,通过设定注册表中的AeDebug项,可以在程序崩溃后,选择调试器进行调试。选择Dr. Watson就可以直接生成dump文件。

问题分析

回到这个问题,客户并没有获取到dump文件,可能性有两个:

1. Dr. Watson工作不正常。
2. 客户的程序根本没有崩溃,不过是正常退出而已。

为了测试第1点,提供了如下的代码给客户测试:

int *p=0;
*p=0;

测试上面的代码,Dr. Watson成功地获取了dump文件。也就是说,Dr. Watson工作是正常的。那看来客户声称的崩溃可能并不是unhandled exception导致的。说不定在非预料情况下调用了ExitProcess,被客户误认为是崩溃。所以,抓取信息不应该局限于unhandled exception,而应该检查进程退出的原因。

当程序在Windbg调试器中退出的时候,系统会触发调试器的进程退出消息,可以在这个时候抓取dump来分析进程退出的原因。

如果让客户每次都先启动Windbg,然后用Windbg启动程序,操作起来很复杂。最好有一个自动的方法。Windows提供了让指定程序随调试器启动的选项。设定注册表后,当设定的进程启动的时候,系统先启动指定的调试器,然后把目标进程的地址和命令行作为参数传递给调试器,调试器再启动目标进程调试。这个选项在无法手动从调试器中启动程序的时候特别有用,比如调试先于用户登录而启动Windows Service程序,就必须使用这个方法:

How to debug Windows services
http://support.microsoft.com/?kbid=824344

有趣的是,好多恶意程序也通过这个方法来达到加载进程的目的。很多人把这个方法叫做IFEO 劫持(Image File Execution Option Hacking)。

在Windbg目录下,有一个叫做adplus.vbs的脚本可以方便地调用Windbg来获取dump文件。所以这里可以借用这个脚本:

How to use ADPlus to troubleshoot "hangs" and "crashes"
http://support.microsoft.com/kb/286350/EN-US/

脚本的详细说明可以参考adplus /?的帮助。

新的做法

结合上面的信息,具体做法是:

1. 在客户机器的Image File Execution Options注册表下面创建跟问题程序同名的键。
2. 在这个键的下面创建Debugger字符串类型子键。
3. 设定Debugger= C:\Debuggers\autodump.bat。
4. 编辑C:\Debuggers\autodump.bat文件的内容为如下:

cscript.exe C:\Debuggers\adplus.vbs -crash -o C:\dumps -quiet -sc %1

通过上面的设置,当程序启动的时候,系统自动运行cscript.exe来执行adplus.vbs脚本。Adplus.vbs脚本的-sc参数指定需要启动的目标进程路径(路径作为参数又系统传入,bat文件中的%1代表这个参数),-crash参数表示监视进程退出,-o参数指定dump文件路径,-quiet参数取消额外的提示。可以用notepad.exe作为小白鼠做一个实验,看看关闭notepad.exe的时候,是否有dump产生。

根据上面的设定,问题再次发生后,C:\dumps目录生成了两个dump文件。文件名分别是:

PID-0__Spawned0__1st_chance_Process_Shut_Down__full_178C_DateTime_0928.dmp
PID-0__Spawned0__2nd_chance_CPlusPlusEH__full_178C_2006-06-21_DateTime_0928.dmp

注意看第二个的名字,这个名字表示发生2nd chance的C++ exception!打开这个dump后找到了对应的call stack,发现的确是客户忘记了catch潜在的C++异常。修改代码添加对应的catch后,问题解决。

问题解决了,可是为什么华生医生(Dr. Watson)抓不到dump呢

当然疑问并没有随着问题的解决而结束。既然是unhandled exception导致的crash,为什么Dr. Watson抓不到呢?首先创建两个不同的程序来测试Dr. Watson的行为:

int _tmain(int argc, _TCHAR* argv[])
{
throw 1;
return 0;
}
int _tmain(int argc, _TCHAR* argv[])
{
int *p=0;
*p=0;
return 0;
}

果然,对于第一个程序,Dr. Watson并没有保存dump文件。对于第二个,Dr. Watson工作正常。看来的确跟异常类型相关。

仔细回忆一下。当AeDebug下的Auto设定为0的时候,系统会弹出前面提到的红色框框。对于上面这两个程序,框框的内容是不一样的。

在我这里,看到的对话框分别是(对话框出现的时候用Ctrl+C保存的信息):

--------------------------- Microsoft Visual C++ Debug Library --------------------------- Debug Error! Program: d:\xiongli\today\exceptioninject\debug\exceptioninject.exe This application has requested the Runtime to terminate it in an unusual way. Please contact the application's support team for more information. (Press Retry to debug the application) --------------------------- Abort Retry Ignore ---------------------------

--------------------------- exceptioninject.exe - Application Error --------------------------- The instruction at "0x00411908" referenced memory at "0x00000000". The memory could not be "written".

Click on OK to terminate the program Click on CANCEL to debug the program --------------------------- OK Cancel ---------------------------

两者行为完全不一样!如果做更多的测试,会发现对话框的细节还跟编译模式release/debug 相关。

程序可以通过SetUnhandledExceptionFilter函数来修改unhanded exception的默认处理函数。这里,C++运行库在初始化CRT(C Runtime)的时候,传入了CRT的处理函数 (msvcrt!CxxUnhandledExceptionFilter)。如果发生unhandled exception,该函数会判断异常的号码,如果是C++异常,就会弹出第一个对话框,否则就交给系统默认的处理函数(kernel32!UnhandledExceptionFilter)处理。第一种情况的call stack 如下:

USER32!MessageBoxA
MSVCR80D!__crtMessageBoxA
MSVCR80D!__crtMessageWindowA
MSVCR80D!_VCrtDbgReportA
MSVCR80D!_CrtDbgReportV
MSVCR80D!_CrtDbgReport
MSVCR80D!_NMSG_WRITE
MSVCR80D!abort
MSVCR80D!terminate
MSVCR80D!__CxxUnhandledExceptionFilter
kernel32!UnhandledExceptionFilter
MSVCR80D!_XcptFilter

第二种情况CRT交给系统处理。Callstack如下:

ntdll!KiFastSystemCallRet
ntdll!ZwRaiseHardError+0xc
kernel32!UnhandledExceptionFilter+0x4b4
release_crash!_XcptFilter+0x2e
release_crash!mainCRTStartup+0x1aa
release_crash!_except_handler3+0x61
ntdll!ExecuteHandler2+0x26
ntdll!ExecuteHandler+0x24
ntdll!KiUserExceptionDispatcher+0xe
release_crash!main+0x28
release_crash!mainCRTStartup+0x170
kernel32!BaseProcessStart+0x23

详细的信息可以参考:

SetUnhandledExceptionFilter
http://msdn.microsoft.com/library/default.asp?
url=/library/en-us/debug/base/setunhandledexceptionfilter.asp

UnhandledExceptionFilter
http://msdn.microsoft.com/library/default.asp?
url=/library/en-us/debug/base/unhandledexceptionfilter.asp

上面观察到的信息能解释Dr. Watson的行为吗?看起来似乎有关系。为了进一步确认这个问题,可以通过下面的测试,使用Windbg代替Dr. Watson,看看是否可以获取dump。如果仅仅换一个调试器就可以获取dump,那说明问题是跟调试器相关,跟程序抛出的异常无关。具体做法是:
1. 运行drwtsn32.exe –i注册Dr. Watson。
2. 打开AeDebug注册表,找到Debugger项,里面应该是drwtsn32 -p %ld -e %ld -g。
3. 修改Debugger为: C:\debuggers\windbg.exe -p %ld -e %ld -c ".dump /mfh C:\myfile.dmp ;q"。
当unhanded exception发生后,系统会启动windbg.exe作为调试器加载到目标进程。但是windbg.exe不会自动获取dump,所以需要用-c参数来指定初始命令。命令之间可以用分开分割。这里的.dump /mfh C:\myfile.dmp命令就是用来生成dump文件的。接下来的q命令是让windbg.exe在dump生成完毕后自动退出。用这个方法,对于unhandled C++ exception,windbg.exe是可以获取dump文件的。所以我认为Dr. Watson这个工具在获取dump的时候是有缺陷的。研究的发现在:

http://eparg.spaces.msn.com/blog/cns!59BFC22C0E7E1A76!1213.entry

2.3.3  通知(Debug Event)是操作系统跟调试器交流的一种方法

通知,也叫做调试信息(Debug Events),是操作系统在某些事件发生的时候,通知调试器的一个手段。跟异常处理相似,操作系统在某些事件发生的时候,会检查当前进程是否有调试器加载。如果有,就会给调试器发送对应的消息,以便使用调试器进行观察。跟异常不一样的地方就是,只有调试器才会得到通知,应用程序本身是得不到的。同时调试器得到通知后不需要做什么处理,没有1st /2nd chance的差别。在Windbg帮助文件的Controlling Exceptions and Events主题里面,可以看到关于通知的所有代号。常见的通知有:DLL的加载、卸载,线程的创建、退出等。

案例分析:VB6的版本问题

客户用VB6开发的程序,在VB6 IDE调试的时候无法访问Access 2003创建的数据库,访问Access 97的数据库却是好的。如果换一台开发机,测试就一切正常。

这个问题的思路非常简单,既然只有一台机器有问题,说明是环境的原因。既然访问Access 97没问题,或许跟Access客户端文件,也就是DAO的版本有关。通过工具Windbg目录下的tlist工具检查进程中加载的DLL,发现有问题的机器加载的是dao350.dll,没有问题的机器加载的是dao360.dll。下一步就需要知道为什么加载的是dao350.dll?

DAO是一个COM对象,很有可能是通过COM对象加载的方法完成的。那么,可以采取1.2节中ShellExecute同时打开两个文件的处理方法,从创建COM的API: CoCreateInstanceEx开始,用wt命令跟踪整个函数的执行,保存下来后比较两种不同情况的异同。通过这个方法肯定是可以找出原因的,不过要想用wt命令一直跟踪到LoadLibrary函数加载这个DLL,可能需要执行一整天。所以,应该找一个可操作性更强一点的方法来检查。既然最后要追踪到LoadLibrary为止,那何不在这个函数上设置断点,观察检查DAO350.DLL加载起来的情况?

在LoadLibrary上设定断点并不是一个很好的方法。因为:

1. 加载DLL不一定要调用LoadLibrary的。可以直接调用Native API,比如ntdll!LdrLoadDll。

2. 假设有几十个DLL要加载,如果每次LoadLibrary都断下来,操作起来也是很麻烦的事情。虽然可以通过条件断点判断LoadLibrary的参数来决定是否断下来,但是设定条件断点也是很麻烦的。

最好的方法,就是使用通知,在moudle load的时候,系统给调试器发送通知。由于Windbg在收到moudle load通知的时候,可以使用通配符来判断 DLL的名字,操作起来就简单多了。首先,在Windbg中用sxe ld:dao*.dll设置截获Moudle Load的通知,当文件名是dao*.dll的时候,Windbg就会停下来。(关于Windbg的详细信息,以及这里使用到的命令,后面都有章节详细介绍)。看到的结果就是:

0:008> sxe ld:dao*.dll

ModLoad: 1b740000 1b7c8000 C:\Program Files\Common Files\Microsoft Shared\DAO\DAO360.DLL
eax=00000001 ebx=00000000 ecx=0013e301 edx=00000000 esi=7ffdf000 edi=20000000
eip=7c82ed54 esp=0013e300 ebp=0013e344 iopl=0 nv up ei pl zr na po nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00000246
ntdll!KiFastSystemCallRet:
7c82ed54 c3 ret

ntdll!KiFastSystemCallRet
ntdll!NtMapViewOfSection
ntdll!LdrpMapViewOfDllSection
ntdll!LdrpMapDll
ntdll!LdrpLoadDll
ntdll!LdrLoadDll
0013e9c4 776ab4d0 0013ea40 00000000 00000008 kernel32!LoadLibraryExW
ole32!CClassCache::CDllPathEntry::LoadDll
ole32!CClassCache::CDllPathEntry::Create_rl
ole32!CClassCache::CClassEntry::CreateDllClassEntry_rl
ole32!CClassCache::GetClassObjectActivator
ole32!CClassCache::GetClassObject
ole32!CServerContextActivator::GetClassObject
ole32!ActivationPropertiesIn::DelegateGetClassObject
ole32!CApartmentActivator::GetClassObject
ole32!CProcessActivator::GCOCallback
ole32!CProcessActivator::AttemptActivation
ole32!CProcessActivator::ActivateByContext
ole32!CProcessActivator::GetClassObject
ole32!ActivationPropertiesIn::DelegateGetClassObject
ole32!CClientContextActivator::GetClassObject
ole32!ActivationPropertiesIn::DelegateGetClassObject
ole32!ICoGetClassObject
ole32!CComActivator::DoGetClassObject
ole32!CoGetClassObject
VB6!VBCoGetClassObject
VB6!_DBErrCreateDao36DBEngine

通过检查LoadLibraryExW的参数,可以看到:

 0:000> du 0013ea40
0013ea40 "C:\Program Files\Common Files\Mi"
0013ea80 "crosoft Shared\DAO\DAO360.DLL"

从上面的信息可以看到:

1. DAO360不是通过CoCreateInstanceEx加载进来的,而是另外一个COM API: CoGetClassObject。所以如果对CoCreateInstanceEx做想当然的跟踪,就浪费时间了。
2. COM调用的发起者是VB6!_DBErrCreateDao36DBEngine这个函数。应该仔细检查这个函数。

有了前面DLL HELL 案例的教训,在检查这个函数前,首先检查VB6.EXE的版本。发现正常情况下的版本是6.00.9782,有问题的机器上的版本是6.00.8176。 在有问题的机器上安装Visual Studio 6,SP6升级VB6版本后,问题解决。

2.3.4  题外话和相关讨论

错过第一现场后还从dump中分析出线索吗

前面介绍了用Windbg截取1st chance exception进行分析的方法。

但是好多情况下,程序并没有运行在调试器下。崩溃发生后留在桌面上的是红色的框框,这时候已经错过了第一现场,但还是有机会找到对应exception的信息。

前面介绍过,红色的框框是通过UnhandledExceptionFilter函数显示出来的,而UnhandledExceptionFilter的参数就包含了异常信息。这个时候检查UnhandledExceptionFilter的参数,就可以找到异常信息和异常上下文的地址,然后通过.exr和.cxr就可以在Windbg中把对应信息打印出来。

(注意:在Vista和Windows 2008中,系统改良了Error Reporting功能。程序崩溃后,系统会在Error Reporting的时候从内核直接挂起出错的进程。这个时候如果用调试器检查,会看到出错进程就停在发生问题的指令上,不再需要在调试器中手动恢复exception context。

详细信息可以参考:

Inside the Windows Vista Kernel: Part 3
http://www.microsoft.com/technet/technetmag
/issues/2007/04/vistakernel/default.aspx?loc=en

拿案例2中的第2个例子做一个实验。直接运行,崩溃后看到弹出的框框。这个时候不要点击确定,而是启动Windbg,attach到这个进程,然后用kb命令打印出call stack,找到UnhandledExceptionFilter的参数:

0:000> kb
ChildEBP RetAddr Args to Child
0012f74c 7c821b74 77e999ea d0000144 00000004 ntdll!KiFastSystemCallRet
0012f750 77e999ea d0000144 00000004 00000000 ntdll!ZwRaiseHardError+0xc
0012f9bc 004339be 0012fa08 7ffdd000 0044c4d8 kernel32!UnhandledExceptionFilter+0x4b4

第一个参数0012fa08保存的就是异常信息和异常上下文的地址:

0:000> dd 0x0012fa08
0012fa08 0012faf4 0012fb10 0012fa34 7c82eeb2

接下来用.exr加上异常信息地址打印出异常的信息:

0:000> .exr 0012faf4 ExceptionAddress: 0041a5a8 (release_crash!main+0x00000028) ExceptionCode: c0000005 (Access violation) ExceptionFlags: 00000000 NumberParameters: 2 Parameter[0]: 00000001 Parameter[1]: 00000000 Attempt to write to address 00000000

然后可以用.cxr加上异常上下文地址来切换上下文:

0:000> .cxr 0012fb10
eax=00000000 ebx=7ffde000 ecx=00000000 edx=00000001 esi=00000000 edi=0012fedc
eip=0041a5a8 esp=0012fddc ebp=0012fedc iopl=0 nv up ei pl nz na po nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00010206 release_crash!main+0x28:
0041a5a8 c60000 mov byte ptr [eax],0x0 ds:0023:00000000=??

上下文切换完成后,可以用kb命令重新打印出该上下文上的call stack,就可以看到异常发生时候的状态:

0:000> kb
*** Stack trace for last set context - .thread/.cxr resets it
ChildEBP RetAddr Args to Child
0012fedc 00427c90 00000001 00361748 003617d0 release_crash!main+0x28
[c:\documents and settings\lixiong\desktop\amobrowser\release_crash.cpp @ 51]
0012ffc0 77e523cd 00000000 00000000 7ffde000 release_crash!mainCRTStartup+0x170
0012fff0 00000000 00418b18 00000000 78746341 kernel32!BaseProcessStart+0x23

这里可以直接看到问题发生在release_crash.cpp文件的第51行。

Adplus,天天都用的工具

如果要捕获崩溃时候的详细信息,通常可以在调试器下运行程序,或者使用更方便的adplus来自动获取异常产生时候的dump文件。可以参考:

How to use ADPlus to troubleshoot "hangs" and "crashes"
http://support.microsoft.com/kb/286350/

未处理异常发生后的主动退出

在某些特殊情况下,程序员为了需要,会在发生未处理异常后主动退出,而不是等到崩溃被动发生。使用这种技术的有COM+,ASP.NET,还有淘宝旺旺客户端。

这样做的好处是:
1. 可以自定义接口。
2. 可以把发生异常时候的详细信息保存下来以便后继分析。
3. 可以防止调试器带来的不必要干扰,保证发生崩溃的程序能立刻被系统回收,同时可以进行必要的挽救工作,比如重新启动发生错误的进程继续服务。

实现方法非常简单。一种方法是在程序的main函数,或者关键函数中,使用SEH的__try和__except语句捕获所有的异常。在__except语句中做相应的操作后(比如显示UI,保存信息)直接退出程序。

另外一种方法是使用SetUnhandledExceptionFilter。有很多程序有崩溃后发送异常报告的功能。淘宝旺旺客户端就是这样的一个例子,可以参考:
http://eparg.spaces.msn.com/blog/cns!59BFC22C0E7E1A76!817.entry

根据我的分析,淘宝旺旺客户端这里用了SetUnhandledExceptionFilter这个函数来定义自己的异常处理函数,在异常处理函数中通过MiniDumpWriteDump API实现dump的捕获。

使用这个技术的缺点就是调试器无法接收到2nd chance exception了,给调试增加了难度。比如要获取COM+程序上crash的信息,颇费一番周折,还需要使用上面提到的.exr/.cxr命令:

How To Obtain a Userdump When COM+ Failfasts
http://support.microsoft.com/?id=287643
How to find the faulting stack in a process dump file that COM+ obtains
http://support.microsoft.com/?id=317317
如何调试UnhandledExceptionFilter

根据MSDN的描述,UnhandledExceptionFilter在没有debugger attach的时候才会被调用。所以,SetUnhandledExceptionFilter函数还有一个妙用,就是让某些敏感代码避开debugger的追踪。比如你想把一些代码保护起来,避免调试器的追踪,可以采用的方法:

1. 在代码执行前调用IsDebuggerPresent来检查当前是否有调试器加载上来。如果有,就退出。
2. 把代码放到SetUnhandledExceptionFilter设定的函数里面。通过人为触发一个unhandled exception来执行。由于设定的UnhandledExceptionFilter函数只有在调试器没有加载的时候才会被系统调用,这里巧妙地使用了系统的这个功能来保护代码。

第一钟方法很容易被绕过。看看IsDebuggerPresent的实现:

0:000> uf kernel32!IsDebuggerPresent
kernel32!IsDebuggerPresent:
281 77e64860 64a118000000 mov eax,fs:[00000018]
282 77e64866 8b4030 mov eax,[eax+0x30]
282 77e64869 0fb64002 movzx eax,byte ptr [eax+0x2]
283 77e6486d c3 ret

IsDebuggerPresent是通过返回FS寄存器上记录的地址的一些偏移量来实现的。([FS: [18]]:30保存的其实是当前进程的PEB地址)。在debugger中可以任意操作当前进程内存地址上的值,所以只需要用调试器把[[FS:[18]]:30]:2的值修改成0,IsDebuggerPresent就会返回false,导致方法1失效。

对于第二种方法,使用[[FS:[18]]:30]:2的欺骗方法就没用了。因为UnhandledExceptionFilter是否调用取决于系统内核的判断。用户态的调试器要想改变这个行为,要破费一番脑筋了。

Kwan Hyun Kim提供了一种欺骗系统的方法:

How to debug UnhandleExceptionHandler
http://eparg.spaces.msn.com/blog/cns!59BFC22C0E7E1A76!1208.entry

2.4  平坦内存空间中的层次结构:Heap和Stack

本小结主要介绍Heap相关的崩溃和内存泄漏,和如何使用pageheap来排错。首先介绍heap的原理,不同层面的内存分配,接下来通过例子代码举例演示heap问题的严重性和欺骗性。最后介绍如何使用pageheap工具高效地对heap问题排错。

2.4.1  Heap是对平坦空间的高效管理和利用

内存是容纳代码和资料的空间。无论是stack,heap还是DLL,都是“生长”在内存上的。代码的执行效果其实是对内存上的资料进行转化。内存是导致问题最多的地方,比如内存不足、内存访问违例、内存泄漏等,都是常见的问题。

关于内存的详细信息,Programming Applications for Microsoft Windows书中有详细介绍。这里针对排错作一些补充。
Windows API中有两类内存分配函数。分别是:VirtualAlloc和HeapAlloc。前一种是向操作系统申请4KB为边界的整块内存,后者是分配任意大小的内存块。区别在于,后者的实现依赖于前者。换句话说,操作系统管理内存的最小单位是4KB,这个粒度是固定的(其实根据芯片是可以做调整的。这里只讨论最普遍的情况)。但用户的资料不可能恰好都是4KB大小。用4KB作单位,难免会产生很多浪费。解决办法是依靠用户态代码的薄计工作,实现比4KB单位更小的分配粒度。换句话说,用户态的程序需要实现一个Memory Manager,通过自身的管理,在4KB为粒度的基础上,提供以字节为粒度的内存分配,释放功能,并且能够平衡好时间利用率和空间利用率。

Windows提供了Heap Manager完成上述功能。HeapAlloc函数是Heap Manager的分配函数。Heap Manager的工作方式大概是这样:首先分配足够的大的4 KB倍数的连续内存空间;然后在这块内存上开辟一小块区域用来做薄计;接下来把这连续的大块内存分割成很多尺寸不等的小块,把每一小块的信息记录到薄计里面。薄计记录了每一小块的起始地址和长度,以及是否已经分配。

当内存请求发生的时候,HeapManager根据请求的长度,在薄计信息里面找到大小最合适的一块空间,把这块空间标记成已经分配,然后把这块空间的地址返回,这样就完成了一次内存分配。如果找不到长度足够大的空闲小块,Heap Manager继续以4 KB为粒度向系统申请更多的内存。

当用户需要释放内存的时候,调用HeapFree,同时传入起始地址。HeapManager在薄计信息中找到这块地址,把这块地址的信息由已经分配改回没有分配。当Heap Manager发现有大量的连续空闲空间的时候,也会调用VirtualFree来把这些内存归还给操作系统。在实现上面这些基本功能的情况下,HeapManager还需要考虑到:

1. 分配的内存最好是在4字节边界上,这样可以提高内存访问效率。
2. 做好线程同步,保证多个线程同时分配内存的时候不会出现错误。
3. 尽可能节省维护薄计的开销,提高性能,避免不必要的计算和检查。所以HeapManager假设用户的代码没有bug,比如用户代码永远不会越界对内存块进行存取,这样就可以省去检查核对的开销。
4. 优化内部的内存块管理。比如灵活地合并连续的内存小块以便满足长尺寸的内存申请,或者拆分连续内存块提高小尺寸的内存使用率。

有了上面的理解后,看下面一些情况:

1. 如果首先用HeapAlloc分配了一块空间,然后用HeapFree释放了这块空间。但是在释放后,继续对这块空间做操作,程序会发生访问违例错误吗?答案是不会,除非HeapManager恰好把那块地址用VirtualFree返还给操作系统了。但是带来的结果是什么?是“非预期结果”。也就是说,谁都无法保证最后会产生什么情况。程序可能不会有什么问题,也可能会格式化整个硬盘。出现得最多的情况是,这块内存后来被Heap Manager重新分配出去。导致两个本应指向不同地址的指针,指向同一个地址。伴随而来的是资料损坏或者访问违例等等。

2. 如果用HeapAlloc分配了100KB的空间,但是访问的长度超过了100KB,会怎么样?如果100KB恰好在4KB内存边界上,而且恰好后面的内存地址并没有被映像上来,程序不会崩溃的。这时,越界的写操作,要么写到别的内存块上,要么就写入薄计信息中,破坏了薄计。导致的结果是HeapManager维护的数据损坏,导致“非预期结果”。

3. 其他错误的代码,比如对同一个地址HeapFree了两次,多线程访问的时候忘记在调用HeapAllocate的第二个参数中传入SERIALIZE bit等等,都会导致“非预期结果”。

总的来说,上面这些情况都会导致非预期结果。如果问题发生后程序立刻崩溃,或者抛出异常,则可以在第一时间截获这个错误。但是,现实的情况是,这些错误不会有及时的效果,错误带来的后果会暂时隐藏起来,在程序继续执行几个小时后,突然在一些看起来绝对不可能出现错误的地方崩溃。比如在调用HeapAllocate/HeapFree的时候崩溃。比如访问一个刚刚分配好的地址的时候崩溃。这个时候哪怕抓到了崩溃的详细信息也无济于事,因为问题根源潜伏在很久以前。这种根源在前现象在后的情况会给调试带来极大的困难。

仔细考虑这种难于调试的情况,错误之所以没有在第一时间暴露,在于下面两点:

1. Heap每一块内存的界限是Heap Manager定义的,而内存访问无效的界限,是操作系统定义的。哪怕访问越界,如果越界的地方已经有映像上来的4KB为粒度的内存页,程序就不会立刻崩溃。
2. 为了提高效率,Heap Manager不会主动检查自身的数据结构是否被破坏。

所以,为了方便检查Heap上的错误,让现象尽早表现出来,Heap Manager应该这样管理内存:

1. 把所有的Heap内存都分配到4KB页的结尾,然后把下一个4KB页面标记为不可访问。越界访问发生时候,就会访问到无效地址,程序就立刻崩溃。
2. 每次调用Heap相关函数的时候,Heap Manager主动去检查自身的数据结构是否被破坏。如果检查到这样的情况,就主动报告出来。

接下来我们会分析如何使用Pageheap帮忙做到上面两点。如果想了解更多Windows上Heap的知识,以及如何用Windbg中的!heap命令检查Heap,请参考:

Debug Tutorial Part 3: The Heap
http://www.codeproject.com/debug/cdbntsd3.asp

2.4.2  PageHeap,调试Heap问题的工具

幸运的是,Heap Manager的确提供了主动检查错误的功能。只需要在注册表里面做对应的修改,操作系统就会根据设置来改变Heap Manager的行为。Pageheap是用来配置该注册表的工具。关于heap的详细信息和原理请参考:

How to use Pageheap.exe in Windows XP and Windows 2000
http://support.microsoft.com/kb/286470/en-us

Pageheap,Gflag和后面介绍的Application Verifier工具一样,都是方便修改对应注册表的工具。如果不使用这两个工具,直接修改注册表也可以达到一样的效果。3个工具里面Application Verifier是目前的主流,Gflag是老牌。除了heap问题外,这两个工具还可以修改其他的调试选项,后面都有说明。Pageheap.exe工具主要针对heap问题,使用起来简单方便。目前gflag.exe包含在调试器的安装包中,Application Verifier可以单独下载安装。如果调试安装包中没有包含pageheap.exe,可以从这里下载:

http://www.heijoy.com/debugdoc/pageheap.zip
http://blogs.msdn.com/lixiong/attachment/2792912.ashx

简单例子的多种情况

看几个简单的但是却很有意义的例子:

用release模式编译运行下面的代码:

 char *p=(char*)malloc(1024);
p[1024]=1;

这里往分配的空间多写一个字节。但是在release模式下运行,程序不会崩溃。

假设上面的代码编译成mytest.exe,用下面的方法可以对mytest.exe激活pageheap:

C:\Debuggers\pageheap>pageheap /enable mytest.exe /full    

直接运行pageheap可以查看当前pageheap的激活状态:

C:\Debuggers\pageheap>pageheap
mytest.exe: page heap enabled with flags (full traces )

当激活pageheap后,重新运行一次上面的代码,程序就崩溃了。

(直接双击运行程序和在Windbg中用调试模式运行程序,观察到的崩溃有差别。在Windbg中运行,pageheap会首先触发break point异常,同时pageheap还会在调试器中输出额外的调试信息方便调试。)

上面的例子说明了pageheap能够让错误尽快暴露出来。接下来我们稍微修改一下代码:

 char *p=(char*)malloc(1023);
p[1023]=1;

试试看,修改后的代码还会导致程序崩溃吗?

根据我的测试,分配1023字节的情况下,哪怕激活pageheap,也不会崩溃。你能说明原因吗?如果看不出来,可以检查一下每次malloc返回的地址的数值,注意对这个数值在二进制上敏感一点,然后结合Heap Manager和pageheap的原理思考一下,看看有没有发现。

对于上面两种代码,如果用debug模式编译,激活pageheap,程序会崩溃吗?根据我的测试,无论是否激活pageheap,debug模式都不会崩溃的。你能想到原因吗?

再来看下面一段代码:

 char *p=(char*)malloc(1023);
free(p);
free(p);

这里显然有double free的问题。

如果没有激活pageheap,分别在debug和release模式下运行,根据我的测试,debug模式下会崩溃,release模式下运行正常。

如果激活pageheap,同样在debug/release模式下运行。根据我的测试,在两种模式下都会崩溃。如果细心观察,会发现两种模式下,崩溃后弹出的提示各自不同。你能想到原因吗?

如果有兴趣,你还可以测试一下heap误用的其他几种情况,看看pageheap是不是都有帮助。

Heap上的内存泄漏和内存碎片

从上面的例子,可以很清楚地看到pageheap对于检查这类问题的帮助。同时也可以看到,pageheap无法保证检查出所有潜在问题,比如分配1023个字节,但是写1024个字节这种情况。只有理解pageheap的工作原理,同时对问题作认真的思考和测试后,才会理解其中的差别。

除了Heap使用不当导致崩溃外,还有一类问题是内存泄漏。内存泄漏是指随着程序的运行,内存消耗越来越多,最后发生内存不足,或者整体性能下降。从代码上看,这类问题是由于内存使用后没有及时释放导致的。这里的内存,可以是VirtualAlloc分配的,也有可能是HeapAllocate分配的。

这里只讨论Heap相关的内存泄漏。检查内存泄漏是一个比较大的题目,第4章会作详细讨论。

举个例子,客户开发一个cd刻录程序。每次把盘片中所有内容写入内存,然后开始刻录。如果每次刻录完成后都忘记去释放分配的空间,那么最多能够刻3张CD。因为3张CD,每一张600MB,加在一起就是1.8GB,濒临2GB的上限。

另外还有一种跟内存泄漏相关的问题,是内存碎片(Fragmentation)。内存碎片是指内存被分割成很多的小块,以至于很难找到连续的内存来满足比较大的内存申请。导致内存碎片常见原因有两种,一种是加载了过多DLL,还有一种是小块Heap的频繁使用。

DLL分割内存空间最常见的情况是ASP.NET中的batch compilation没有打开,导致每一个ASP.NET页面都会被编译成一个单独的DLL文件。运行一段时间后,就可以看到几千个DLL文件加载到进程中。一个极端的例子是5000个DLL把2GB内存平均分成5000份,导致每一份的大小在400KB左右(假设DLL本身只占用1个字节),于是无法申请大于400KB的内存,哪怕总的内存还是接近2GB。对于这种情况的检查很简单,列一下当前进程中所有加载起来的DLL就可以看出问题来。

对于小块Heap的频繁使用导致的内存分片,可以参考下面的解释:

Heap fragmentation is often caused by one of the following two reasons
1. Small heap memory blocks that are leaked (allocated but never freed) over time
2. Mixing long lived small allocations with short lived long allocations
Both of these reasons can prevent the NT heap manager from using free
memory efficiently since they are spread as small fragments that cannot
be used as a single large allocation

为了更好地理解上面的解释,考虑这样的情况。假设开发人员设计了一个数据结构来描述一首歌曲,数据结构分成两部分,第一部分是歌曲的名字、作者和其他相关的描述性信息,第二部分是歌曲的二进制内容。显然第一部分比第二部分小得多。假设第一部分长度1KB,第二部分399KB。每处理一首歌需要调用两次内存分配函数,分别分配数据结构第一部分和第二部分需要的空间。

假设每次处理完成后,只释放了数据结构的第二部分,忘记释放第一部分,这样每处理一次,就会留下1个1KB的数据块没有释放。程序长时间运行后,留下的1KB数据块就会很多,虽然HeapManager的薄计信息中可能记录了有很多399KB的数据块可以分配,但是如果要申请500KB的内存,就会因为找不到连续的内存块而失败。对于内存碎片的调试,可以参考最后的案例讨论。在Windows 2000上,可以用下面的方法来缓解问题:

The Windows XP Low Fragmentation Heap Algorithm
Feature Is Available for Windows 2000
http://support.microsoft.com/?id=816542

关于 CLR上内存碎片的讨论和图文详解,请参考:

 .NET Memory usage - A restaurant analogy
http://blogs.msdn.com/tess/archive/2006/09/06/742568.aspx

2.4.3  Stack overrun/corruption

另外一种内存问题是Stack overrun和Stack corruption。Stack overrun很简单,一般是递归函数缺少结束条件导致,函数调用过深从而把stack地址用光,比如下面的代码:

Void foo()
{
foo();
}

只要在调试器里重现问题,调试器立刻就会收到Stack overflow Exception。检查callstack就可以立刻看出问题所在:

0:001> g (cd0.4b0): Stack overflow - code c00000fd (first chance) First chance exceptions are reported before any exception handling. This exception may be expected and handled. eax=cccccccc ebx=7ffdd000 ecx=00000000 edx=10312d18 esi=0012fe9c edi=00033130 eip=004116f9 esp=00032f9c ebp=0003305c iopl=0 nv up ei pl nz na po nc cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00010206 *** WARNING: Unable to verify checksum for c:\Documents and Settings\Li Xiong\My Documents\My code\MyTest\debug\MyTest.exe MyTest!foo+0x9: 004116f9 53 push ebx 0:000> k ChildEBP RetAddr 0003305c 00411713 MyTest!foo+0x9 00033130 00411713 MyTest!foo+0x23 00033204 00411713 MyTest!foo+0x23 000332d8 00411713 MyTest!foo+0x23 000333ac 00411713 MyTest!foo+0x23 00033480 00411713 MyTest!foo+0x23 00033554 00411713 MyTest!foo+0x23 00033628 00411713 MyTest!foo+0x23 000336fc 00411713 MyTest!foo+0x23 000337d0 00411713 MyTest!foo+0x23 000338a4 00411713 MyTest!foo+0x23 00033978 00411713 MyTest!foo+0x23 00033a4c 00411713 MyTest!foo+0x23

第二种情况,Stack corruption往往是Stack buffer overflow导致的。这样的bug不单单会造成程序崩溃,还会严重威胁到系统安全性。在网上搜索Stack buffer overflow,可以看到无数用Stack buffer进行攻击的例子。
在当前的计算机架构上,Stack是保存运行信息的地方。当Stack损坏后,当前执行情况的所有信息都丢失了,所以调试器在这种情况下没有用武之地。比如下面的代码:

void killstack()
{
char c;
char *p=&c;
for(int i=10;i<=100;i++)
*(p+i)=0;
}

int main(int, char*)
{
killstack();
return 0;
}

在VS2005中用下面的参数,在debug模式下编译:

/Od /D "WIN32" /D "_DEBUG" /D "_CONSOLE" /D "_UNICODE" /D
"UNICODE" /Gm /EHsc /RTC1 /MDd /Gy /Fo"Debug\\" /Fd"Debug\vc80.pdb"
/W3 /nologo /c /Wp64 /Zi /TP /errorReport:prompt

在调试器中运行,看到的结果是:

0:000> g
ModLoad: 76290000 762ad000 C:\WINDOWS\system32\IMM32.DLL
ModLoad: 62d80000 62d89000 C:\WINDOWS\system32\LPK.DLL
ModLoad: 75490000 754f1000 C:\WINDOWS\system32\USP10.dll
(1d0.1504): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
eax=0012ff5b ebx=7ffda000 ecx=fffffffb edx=0012ffbf esi=00000000 edi=00000000
eip=000000f8 esp=0012ff68 ebp=0012ff68 iopl=0 nv up ei pl zr na po nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00010246
000000f8 ?? ???
0:000> kb
ChildEBP RetAddr Args to Child
WARNING: Frame IP not in any known module. Following frames may be wrong.
0012ff64 00000000 00000000 00000000 00000000 0xf8
0:000> dds esp
0012ff68 00000000
0012ff6c 00000000
0012ff70 00000000
0012ff74 00000000
0012ff78 00000000
0012ff7c 00000000
0012ff80 00000000
0012ff84 00000000
0012ff88 00000000
0012ff8c 00000000
0012ff90 00000000
0012ff94 00000000
0012ff98 00000000
0012ff9c 00000000
0012ffa0 00000000
0012ffa4 00000000
0012ffa8 00000000
0012ffac 00000000
0012ffb0 00000000
0012ffb4 00000000
0012ffb8 00000000
0012ffbc 00000000
0012ffc0 0012fff0
0012ffc4 77e523cd kernel32!BaseProcessStart+0x23

在Windbg里面看到EIP,EBP都指向非法地址,callstack的信息已经被冲毁,根本找不到任何线索进行调试。对于Stack corruption,行之有效的方法是首先对问题作大致定位,然后检查相关函数,在可疑函数中添加代码写log文件。当问题发生后从log文件中找到线索。

2.4.4  题外话和相关讨论

PageHeap的/unaligned参数

char *p=(char*)malloc(1023);
p[1023]=1;

前面提到了分配1023个字节的问题。在激活pageheap后,同时使用/unaligned参数,才可以检测到这类问题。详细情况请参考KB816542中关于/unaligned的介绍。

同理,下面这段代码默认情况下使用pageheap也不会崩溃:

char *p=new char[1023];
p[-1]='c';

解决方法是使用pageheap的/backwards参数。

上面两个例子说明由于4KB的粒度限制,哪怕使用pageheap,也需要根据pageheap的原理来调整参数,以便覆盖多种情况。

Heap trace,系统帮你记录下每次Heap的操作

Pageheap的另外一个功能是trace,作用是记录Heap的历史操作。激活pageheap的trace功能后,Heap Manager会在内存中开辟一块专门的空间来记录每次Heap的操作,比如Heap的分配和释放,把操作Heap的callstack记录下来。当问题发生后,在Windbg中可以检查Heap操作的历史记录,方便调试。参考下面一个例子:

char * getmem() { return new char[100]; }

void free1(char *p) { delete p; }

void free2(char *p) { delete [] p; }

int main(int, char*) { char *c=getmem(); free1(c); free2(c); return 0; }

该程序在release模式下,不激活pageheap是不会崩溃的。激活pageheap后,在Windbg中运行会看到:

0:000> g

=========================================================== VERIFIER STOP 00000007: pid 0x1324: block already freed

015B1000 : Heap handle 003F5858 : Heap block 00000064 : Block size 00000000 : ===========================================================

(1324.538): Break instruction exception - code 80000003 (first chance) eax=00000000 ebx=015b1001 ecx=7c81b863 edx=0012fa7f esi=00000064 edi=00000000 eip=7c822583 esp=0012fbe8 ebp=0012fbf4 iopl=0 nv up ei pl nz na pe nc cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00000202 ntdll!DbgBreakPoint: 7c822583 cc int 3

激活pageheap后,当Heap Manager检测到错误,就会激发一个break point exception,使debugger停下来,同时pageheap会在debugger中打印出block already freed信息,表示这是一个double free问题。
通过kb命令可以打印出当前的callstack。注意,由于崩溃发生在第二次调用Free函数的时候,所以这里看到的是第二次调用Free函数时的callstack:

0:000> kb
ChildEBP RetAddr Args to Child
0012fbe4 7c85079b 015b1000 0012fc94 0012fc70 ntdll!DbgBreakPoint
0012fbf4 7c87204b 00000007 7c8722f8 015b1000 ntdll!RtlpPageHeapStop+0x72
0012fc70 7c873305 015b1000 00000004 003f5858 ntdll!RtlpDphReportCorruptedBlock+0x11e
0012fca0 7c8734c3 015b1000 003f0000 01001002 ntdll!RtlpDphNormalHeapFree+0x32
0012fcf8 7c8766b9 015b0000 01001002 003f5858 ntdll!RtlpDebugPageHeapFree+0x146
0012fd60 7c860386 015b0000 01001002 003f5858 ntdll!RtlDebugFreeHeap+0x1ed
0012fe38 7c81d77d 015b0000 01001002 003f5858 ntdll!RtlFreeHeapSlowly+0x37
0012ff1c 78134c3b 015b0000 01001002 003f5858 ntdll!RtlFreeHeap+0x11a
0012ff68 00401016 003f5858 003f5858 00000064 MSVCR80!free+0xcd
0012ff7c 00401198 00000001 003f57e8 003f3628 win32!main+0x16
[d:\xiongli\today\win32\win32\win32.cpp @ 77]
0012ffc0 77e523cd 00000000 00000000 7ffde000 win32!__tmainCRTStartup+0x10f
0012fff0 00000000 004012e1 00000000 78746341 kernel32!BaseProcessStart+0x23

发生崩溃的Free函数调用(后面一次Free调用)的返回地址是00401016,所以后面一次Free是在00401016的前一行被调用的。接下来分析第一次Free调用发生的地方。发生问题的Heap地址是Free函数的参数0x3f5858,在Windbg中使用!heap命令加上–p –a参数打印出保存下来的callstack:

0:000> !heap -p -a 0x3f5858
address 003f5858 found in
_HEAP @ 3f0000
in HEAP_ENTRY: Size : Prev Flags - UserPtr UserSize - state
3f5830: 0014 : N/A [N/A] - 3f5858 (70) - (free DelayedFree)
Trace: 004f
7c860386 ntdll!RtlFreeHeapSlowly+0x00000037
7c81d77d ntdll!RtlFreeHeap+0x0000011a
78134c3b MSVCR80!free+0x000000cd
401010 win32!main+0x00000010
77e523cd kernel32!BaseProcessStart+0x00000023

上面的callstack就是Heap Manager保存的,这是Heap地址的历史操作。从保存的callstack看到,在0x401010地址是MSVCR80!free调用的返回地址,所以00401016和00401010两个地址,就是对同一个Heap地址两次调用Free后的两个返回地址。检查这两个地址的前一条汇编语句,就能找到对应的Free调用:

0:000> uf 00401010
win32!main [d:\xiongli\today\win32\win32\win32.cpp @ 74]:
74 00401000 56 push esi
75 00401001 6a64 push 0x64
75 00401003 e824000000 call win32!operator new[] (0040102c)
75 00401008 8bf0 mov esi,eax
76 0040100a 56 push esi
76 0040100b e828000000 call win32!operator delete (00401038)
77 00401010 56 push esi
77 00401011 e81c000000 call win32!operator delete[] (00401032)
77 00401016 83c40c add esp,0xc
78 00401019 33c0 xor eax,eax
78 0040101b 5e pop esi
79 0040101c c3 ret

这里可以看到,对应的问题的确是前后调用delete和delete []导致的。对应的源代码地址大约在win32.cpp的74行。(源代码中,delete和delete[]是在两个自定义函数中被调用的。这里看不到free1和free2两个函数的原因在于release模式下编译器做了inline优化。)

同时可以检查一下Heap 指针0x3f5858前后的内容:

0:000> dd 0x3f5848
003f5848 7c88c580 0025a5f0 00412920 dcbaaaa9
003f5858 f0f0f0f0 f0f0f0f0 f0f0f0f0 f0f0f0f0
003f5868 f0f0f0f0 f0f0f0f0 f0f0f0f0 f0f0f0f0
003f5878 f0f0f0f0 f0f0f0f0 f0f0f0f0 f0f0f0f0
003f5888 f0f0f0f0 f0f0f0f0 f0f0f0f0 f0f0f0f0
003f5898 f0f0f0f0 f0f0f0f0 f0f0f0f0 f0f0f0f0
003f58a8 f0f0f0f0 f0f0f0f0 f0f0f0f0 f0f0f0f0
003f58b8 f0f0f0f0 a0a0a0a0 a0a0a0a0 00000000

这里的红色dcba其实是一个标志位,标志位前面的地址保存的其实就是这个Heap地址的历史操作记录。通过Windbg的dds命令,可以直接检查保存下来的callstack:

0:000> dds 00412920
00412920 00000000
00412924 00000001
00412928 0005004f
0041292c 7c860386 ntdll!RtlFreeHeapSlowly+0x37
00412930 7c81d77d ntdll!RtlFreeHeap+0x11a
00412934 78134c3b MSVCR80!free+0xcd
00412938 00401010 win32!main+0x10
0041293c 77e523cd kernel32!BaseProcessStart+0x23

了解这个标志位的好处是,可以利用这个特点来解决memory leak和fragmentation。由于发生泄漏的内存往往是相同callstack分配的,所以泄漏比较严重的程度时,程序中残留的大多数的Heap指针都是泄漏掉的内存地址。通过在程序中搜索每一个Heap 指针的标志位,就可以找到这些指针分别对应的callstack。如果某些callstack出现得非常频繁,这些callstack往往就跟memory leak相关。下面就是一个使用这个方法解决memory leak的案例。

为何才分配了300MB内存,就报告Out of memory

客户程序在内存占用只有300MB左右的时候,对malloc的调用就会失败。通过检查问题发生时候的dump文件,发现问题是由heap fragmentation导致的。客户的程序有大量的小块内存没有及时释放,导致分片严重。

激活pageheap后,再次抓取问题发生时的dump,然后使用下面命令在内存空间搜索dcba标志位:

0:044> s -w 0 L?60030000      0xdcba
00115e9e dcba 0000 0000 ef98 0012 893d 0047 efc8 ..........=.G...

19b90fe6 dcba cfe8 02d8 afe8 2ca3 cfe8 02d8 b22a .........,....*.
19b92fe6 dcba cfe8 1a52 8fe8 1dff cfe8 1af6 f44f ....R.........O.
19b9cfce dcba efd0 23d8 cfd0 1c58 8fd0 15ac c0c0 .....#..X.......

2b06efe6 dcba cfe8 02d8 8fe8 258b cfe8 02d8 a6d2 .........%......
2b074fce dcba 2fd0 1c0f afd0 1c4d dfd0 0e69 c0c0 .../....M...i...

2e860fe6 dcba afe8 02d8 2fe8 2ef3 afe8 02d8 0a0b ......./........
2e868fce dcba afd0 0881 2fd0 2e92 afd0 0881 c0c0 ......./........

根据搜索结果,使用下面的命令来随机打印callstack,看到:

0:044> dds poi(19b92fe6 -6) 005bba0c 005cbe90 005bba10 00031c49 005bba14 00122ddb 005bba18 77fa8468 ntdll!RtlpDebugPageHeapAllocate+0x2f7 005bba1c 77faa27a ntdll!RtlDebugAllocateHeap+0x2d 005bba20 77f60e22 ntdll!RtlAllocateHeapSlowly+0x41 005bba24 77f46f5c ntdll!RtlAllocateHeap+0xe3a 005bba28 0046b404 Customer_App+0x6b404 005bba2c 0046b426 Customer_App+0x6b426 005bba30 00427612 Customer_App+0x27612

0:044> dds poi(19b9cfce -6) 005bba0c 005cbe90 005bba10 00031c49 005bba14 00122ddb 005bba18 77fa8468 ntdll!RtlpDebugPageHeapAllocate+0x2f7 005bba1c 77faa27a ntdll!RtlDebugAllocateHeap+0x2d 005bba20 77f60e22 ntdll!RtlAllocateHeapSlowly+0x41 005bba24 77f46f5c ntdll!RtlAllocateHeap+0xe3a 005b8024 0046b404 Customer_App+0x6b404 005b8028 0046b426 Customer_App+0x6b426 005b802c 00427a82 Customer_App+0x27a82

0:044> dds poi(2b06efe6 -6) 005bba0c 005cbe90 005bba10 00031c49 005bba14 00122ddb 005bba18 77fa8468 ntdll!RtlpDebugPageHeapAllocate+0x2f7 005bba1c 77faa27a ntdll!RtlDebugAllocateHeap+0x2d 005bba20 77f60e22 ntdll!RtlAllocateHeapSlowly+0x41 005bba24 77f46f5c ntdll!RtlAllocateHeap+0xe3a 005bd5d4 0046b404 Customer_App+0x6b404 005bd5d8 0046b426 Customer_App+0x6b426 005bd5dc 00427612 Customer_App+0x27612

正常情况下,内存指针分配的callstack是随机的。但是上面的却看到大多数内存指针都由固定的callstack分配,该callstack很有可能就是泄漏的根源。拿到客户的PDB文件后,把偏移跟源代码对应起来,很快就找到了申请这些内存的源代码。客户检查源代码后发现这就是问题根源。添加对应的内存释放代码后,问题解决。