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

本文深入探讨了使用调试器Windbg进行程序排错的技巧,包括理解汇编语言在排错中的作用,分析汇编与CPU、编译器和操作系统之间的交互。通过实例分析了如何利用汇编知识解决VC编译器的bug、异常处理机制以及堆栈内存问题。此外,文章还介绍了PageHeap工具在检测Heap问题上的应用,以及如何通过异常和堆栈信息来诊断和修复程序错误。
摘要由CSDN通过智能技术生成
               

感谢博主 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,ebp0040107C   lea         edi,[esp+10h]25:        for (j=0; j<wid; j++)26:            A[i*wid+j] = exp(-(i*i+j*j));00401080   mov         ebx,ebp00401082   xor         esi,esi// The result of i*i is saved in ebx00401084   imul        ebx,ebp00401087   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        eax00401091   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 edi00401099   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,40040109F   cmp         esi,7004010A2   jl          main+17h (00401087)004010A4   inc         ebp004010A5   cmp         ebp,4004010A8   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::cstringtemplate <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 esi00402229 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 = 0000029737: *_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 esi004021A1 push edi 16:  std::cout<<u<<std::endl;004021A2 push ebx004021A3 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 Rebootinghttp://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,也可以跳到另外的地址开始执行

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值