感谢博主 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; |
最直接的方法就是查看编译器生成的汇编代码分析。有兴趣的话先自己调试一下,看看跟我的分析是否一样。
我的分析
我分析的平台是,VC6,release mode下编译:(因为当时做这个case的时候,客户用的VC6。现在VC6已经退出历史舞台,微软不再提供支持)。
int hgt=4; |
这段代码涉及到的优化有:
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 #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。 |
跟踪汇编指令来分析
拿到这个问题后,首先在本地重现。根据下面一些测试和分析,认为很有可能是编译器的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 |
ESI寄存器中保存的是需要传入_Func的参数*_First。可以看到,std::transform把这个参数通过push指令传入stack给_Func调用。
对于for loop中的*_Dest = func_templatefloatstd::string>(*_First);编译器是这样处理的:
EAX = 003712B0 EBX = 00371338 ECX = 003712BC EDX = 00000000 |
可以看到,使用for loop的时候,参数通过mov指令保存到ebx寄存器中传入func_template调用。
最后,看一下func_template函数是如何来获取传入的参数的。
004021A0 push esi |
这里直接把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 |
2.2.2 题外话和相关讨论
Release比 Debug快吗
分别在debug/release模式下运行下面的代码比较效率,会发现debug比release更快。你能找到原因吗?
long nSize = 200; |
如果让你自己实现一个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; |
这里应该会打印出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 |
也就是说,通过设定注册表中的AeDebug项,可以在程序崩溃后,选择调试器进行调试。选择Dr. Watson就可以直接生成dump文件。
问题分析
回到这个问题,客户并没有获取到dump文件,可能性有两个:
1. Dr. Watson工作不正常。
2. 客户的程序根本没有崩溃,不过是正常退出而已。
为了测试第1点,提供了如下的代码给客户测试:
int *p=0; |
测试上面的代码,Dr. Watson成功地获取了dump文件。也就是说,Dr. Watson工作是正常的。那看来客户声称的崩溃可能并不是unhandled exception导致的。说不定在非预料情况下调用了ExitProcess,被客户误认为是崩溃。所以,抓取信息不应该局限于unhandled exception,而应该检查进程退出的原因。
当程序在Windbg调试器中退出的时候,系统会触发调试器的进程退出消息,可以在这个时候抓取dump来分析进程退出的原因。
如果让客户每次都先启动Windbg,然后用Windbg启动程序,操作起来很复杂。最好有一个自动的方法。Windows提供了让指定程序随调试器启动的选项。设定注册表后,当设定的进程启动的时候,系统先启动指定的调试器,然后把目标进程的地址和命令行作为参数传递给调试器,调试器再启动目标进程调试。这个选项在无法手动从调试器中启动程序的时候特别有用,比如调试先于用户登录而启动Windows Service程序,就必须使用这个方法:
How to debug Windows services |
有趣的是,好多恶意程序也通过这个方法来达到加载进程的目的。很多人把这个方法叫做IFEO 劫持(Image File Execution Option Hacking)。
在Windbg目录下,有一个叫做adplus.vbs的脚本可以方便地调用Windbg来获取dump文件。所以这里可以借用这个脚本:
How to use ADPlus to troubleshoot "hangs" and "crashes" |
脚本的详细说明可以参考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 |
注意看第二个的名字,这个名字表示发生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[]) |
果然,对于第一个程序,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 |
第二种情况CRT交给系统处理。Callstack如下:
ntdll!KiFastSystemCallRet |
详细的信息可以参考:
上面观察到的信息能解释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 ntdll!KiFastSystemCallRet |
通过检查LoadLibraryExW的参数,可以看到:
0:000> du 0013ea40 |
从上面的信息可以看到:
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 |
拿案例2中的第2个例子做一个实验。直接运行,崩溃后看到弹出的框框。这个时候不要点击确定,而是启动Windbg,attach到这个进程,然后用kb命令打印出call stack,找到UnhandledExceptionFilter的参数:
0:000> kb |
第一个参数0012fa08保存的就是异常信息和异常上下文的地址:
0:000> dd 0x0012fa08 |
接下来用.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 |
上下文切换完成后,可以用kb命令重新打印出该上下文上的call stack,就可以看到异常发生时候的状态:
0:000> kb |
这里可以直接看到问题发生在release_crash.cpp文件的第51行。
Adplus,天天都用的工具
如果要捕获崩溃时候的详细信息,通常可以在调试器下运行程序,或者使用更方便的adplus来自动获取异常产生时候的dump文件。可以参考:
How to use ADPlus to troubleshoot "hangs" and "crashes" |
未处理异常发生后的主动退出
在某些特殊情况下,程序员为了需要,会在发生未处理异常后主动退出,而不是等到崩溃被动发生。使用这种技术的有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 |
根据MSDN的描述,UnhandledExceptionFilter在没有debugger attach的时候才会被调用。所以,SetUnhandledExceptionFilter函数还有一个妙用,就是让某些敏感代码避开debugger的追踪。比如你想把一些代码保护起来,避免调试器的追踪,可以采用的方法:
1. 在代码执行前调用IsDebuggerPresent来检查当前是否有调试器加载上来。如果有,就退出。
2. 把代码放到SetUnhandledExceptionFilter设定的函数里面。通过人为触发一个unhandled exception来执行。由于设定的UnhandledExceptionFilter函数只有在调试器没有加载的时候才会被系统调用,这里巧妙地使用了系统的这个功能来保护代码。
第一钟方法很容易被绕过。看看IsDebuggerPresent的实现:
0:000> uf kernel32!IsDebuggerPresent |
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 |
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 |
2.4.2 PageHeap,调试Heap问题的工具
幸运的是,Heap Manager的确提供了主动检查错误的功能。只需要在注册表里面做对应的修改,操作系统就会根据设置来改变Heap Manager的行为。Pageheap是用来配置该注册表的工具。关于heap的详细信息和原理请参考:
How to use Pageheap.exe in Windows XP and Windows 2000 |
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 |
简单例子的多种情况
看几个简单的但是却很有意义的例子:
用release模式编译运行下面的代码:
char *p=(char*)malloc(1024); |
这里往分配的空间多写一个字节。但是在release模式下运行,程序不会崩溃。
假设上面的代码编译成mytest.exe,用下面的方法可以对mytest.exe激活pageheap:
C:\Debuggers\pageheap>pageheap /enable mytest.exe /full |
直接运行pageheap可以查看当前pageheap的激活状态:
C:\Debuggers\pageheap>pageheap |
当激活pageheap后,重新运行一次上面的代码,程序就崩溃了。
(直接双击运行程序和在Windbg中用调试模式运行程序,观察到的崩溃有差别。在Windbg中运行,pageheap会首先触发break point异常,同时pageheap还会在调试器中输出额外的调试信息方便调试。)
上面的例子说明了pageheap能够让错误尽快暴露出来。接下来我们稍微修改一下代码:
char *p=(char*)malloc(1023); |
试试看,修改后的代码还会导致程序崩溃吗?
根据我的测试,分配1023字节的情况下,哪怕激活pageheap,也不会崩溃。你能说明原因吗?如果看不出来,可以检查一下每次malloc返回的地址的数值,注意对这个数值在二进制上敏感一点,然后结合Heap Manager和pageheap的原理思考一下,看看有没有发现。
对于上面两种代码,如果用debug模式编译,激活pageheap,程序会崩溃吗?根据我的测试,无论是否激活pageheap,debug模式都不会崩溃的。你能想到原因吗?
再来看下面一段代码:
char *p=(char*)malloc(1023); |
这里显然有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 |
为了更好地理解上面的解释,考虑这样的情况。假设开发人员设计了一个数据结构来描述一首歌曲,数据结构分成两部分,第一部分是歌曲的名字、作者和其他相关的描述性信息,第二部分是歌曲的二进制内容。显然第一部分比第二部分小得多。假设第一部分长度1KB,第二部分399KB。每处理一首歌需要调用两次内存分配函数,分别分配数据结构第一部分和第二部分需要的空间。
假设每次处理完成后,只释放了数据结构的第二部分,忘记释放第一部分,这样每处理一次,就会留下1个1KB的数据块没有释放。程序长时间运行后,留下的1KB数据块就会很多,虽然HeapManager的薄计信息中可能记录了有很多399KB的数据块可以分配,但是如果要申请500KB的内存,就会因为找不到连续的内存块而失败。对于内存碎片的调试,可以参考最后的案例讨论。在Windows 2000上,可以用下面的方法来缓解问题:
The Windows XP Low Fragmentation Heap Algorithm |
关于 CLR上内存碎片的讨论和图文详解,请参考:
.NET Memory usage - A restaurant analogy |
2.4.3 Stack overrun/corruption
另外一种内存问题是Stack overrun和Stack corruption。Stack overrun很简单,一般是递归函数缺少结束条件导致,函数调用过深从而把stack地址用光,比如下面的代码:
Void 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() int main(int, char*) |
在VS2005中用下面的参数,在debug模式下编译:
/Od /D "WIN32" /D "_DEBUG" /D "_CONSOLE" /D "_UNICODE" /D |
在调试器中运行,看到的结果是:
0:000> g |
在Windbg里面看到EIP,EBP都指向非法地址,callstack的信息已经被冲毁,根本找不到任何线索进行调试。对于Stack corruption,行之有效的方法是首先对问题作大致定位,然后检查相关函数,在可疑函数中添加代码写log文件。当问题发生后从log文件中找到线索。
2.4.4 题外话和相关讨论
PageHeap的/unaligned参数
char *p=(char*)malloc(1023); |
前面提到了分配1023个字节的问题。在激活pageheap后,同时使用/unaligned参数,才可以检测到这类问题。详细情况请参考KB816542中关于/unaligned的介绍。
同理,下面这段代码默认情况下使用pageheap也不会崩溃:
char *p=new char[1023]; |
解决方法是使用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 |
发生崩溃的Free函数调用(后面一次Free调用)的返回地址是00401016,所以后面一次Free是在00401016的前一行被调用的。接下来分析第一次Free调用发生的地方。发生问题的Heap地址是Free函数的参数0x3f5858,在Windbg中使用!heap命令加上–p –a参数打印出保存下来的callstack:
0:000> !heap -p -a 0x3f5858 |
上面的callstack就是Heap Manager保存的,这是Heap地址的历史操作。从保存的callstack看到,在0x401010地址是MSVCR80!free调用的返回地址,所以00401016和00401010两个地址,就是对同一个Heap地址两次调用Free后的两个返回地址。检查这两个地址的前一条汇编语句,就能找到对应的Free调用:
0:000> uf 00401010 |
这里可以看到,对应的问题的确是前后调用delete和delete []导致的。对应的源代码地址大约在win32.cpp的74行。(源代码中,delete和delete[]是在两个自定义函数中被调用的。这里看不到free1和free2两个函数的原因在于release模式下编译器做了inline优化。)
同时可以检查一下Heap 指针0x3f5858前后的内容:
0:000> dd 0x3f5848 |
这里的红色dcba其实是一个标志位,标志位前面的地址保存的其实就是这个Heap地址的历史操作记录。通过Windbg的dds命令,可以直接检查保存下来的callstack:
0:000> dds 00412920 |
了解这个标志位的好处是,可以利用这个特点来解决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 |
根据搜索结果,使用下面的命令来随机打印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文件后,把偏移跟源代码对应起来,很快就找到了申请这些内存的源代码。客户检查源代码后发现这就是问题根源。添加对应的内存释放代码后,问题解决。