C++11标准库新增的std::thread类可以方便地开启子线程。然而有个奇怪的现象是,如果在这些子线程中抛出了未处理的C++异常而导致程序崩溃,那么在生成的dump文件中将还原不出异常发生时的调用栈。可以通过下面的方法来展示这个现象。
首先使用以下代码生成一个控制台程序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| #include <thread> #include <vector>
std::thread* g_thread;
void ThreadEntry() {
std::vector<int> v; v.at(0); }
int main() {
g_thread = new std::thread(ThreadEntry); g_thread->join(); }
|
这段代码很简单,就是通过std::thread创建一个子线程,并且在这个子线程中访问一个空的std::vector中的元素,让它抛出C++异常。务必要使用Release配置来生成程序,不能使用Debug配置。
接下来,在资源管理器中直接运行该程序,注意不要通过调试器来运行。一般会在第二次运行的时候,出现下面的Windows错误报告窗口:
在详细信息中的C:\Users\Zplutor\AppData\Local\Temp\WERBF98.tmp.mdmp文件即是Windows错误报告为崩溃的程序生成的dump文件,里面包含了程序崩溃时的一些信息,例如函数调用栈。该文件在关闭了错误报告窗口时即被删除,所以要先把这个文件复制出来。
最后,用WinDbg打开这个dump文件,先用.ecxr命令切换到异常环境,再用k命令显示调用栈,结果显示如下:
1 2 3 4 5 6 7 8 9 10 11
| 0:002> k *** Stack trace for last set context - .thread/.cxr resets it ChildEBP RetAddr 0095f07c 6ba8dc5f msvcr120!abort+0x38 [f:\dd\vctools\crt\crtw32\misc\abort.c @ 90] 0095f0ac 6b99f353 msvcr120!terminate+0x33 [f:\dd\vctools\crt\crtw32\eh\hooks.cpp @ 96] 0095f8fc 6ba1c01d msvcp120!_Call_func+0x2e [f:\dd\vctools\crt\crtw32\stdcpp\thr\threadcall.cpp @ 35] 0095f934 6ba1c001 msvcr120!_callthreadstartex+0x1b [f:\dd\vctools\crt\crtw32\startup\threadex.c @ 376] 0095f940 7685ee6c msvcr120!_threadstartex+0x7c [f:\dd\vctools\crt\crtw32\startup\threadex.c @ 354] 0095f94c 77053ab3 kernel32!BaseThreadInitThunk+0xe 0095f98c 77053a86 ntdll!__RtlUserThreadStart+0x70 0095f9a4 00000000 ntdll!_RtlUserThreadStart+0x1b
|
可以看到,显示出来的调用栈几乎没有用处,只能看出子线程在开始之后就调用了terminate函数来终止程序,完全看不出来是什么原因导致的。
在调试的时候,如果遇到难以逾越的问题,不妨大胆地进行推测,并根据这些推测进行尝试。推测不一定是正确的,但是在尝试的过程中很可能会发现新的解法。在当前这个例子中,即使不了解std::thread子线程的具体实现,也不难根据上面的调用栈推测出来。下面是一种可能的实现方式:
1 2 3 4 5 6 7 8 9
| void _Call_func() {
try { ThreadEntry(); } catch (...) { terminate(); } }
|
_Call_func函数用来调用在std::thread的构造函数中传进来的入口函数,在本例中即是ThreadEntry。该入口函数通过一对try/catch包裹起来,凡是在它里面抛出来的未处理异常都会被捕获,继而调用terminate函数来终止程序。由于terminate是在_Call_func函数中调用的,所以从调用栈上来看,terminate的上一个栈帧必然是_Call_func,ThreadEntry内部的所有栈帧都被跳过了。
根据以上的推测可知,异常发生时的调用栈还原不出来,只是因为它的栈帧被跳过了而已,假如这些栈帧还保留着,那肯定还是能还原出来的。继续观察上述的调用栈,发现_Call_func和terminate的栈帧之间尚有大约2KB(根据ChildEBP计算得来,0095f8fc - 0095f0ac)的内容,因此可以有八九分把握确定被跳过的栈帧就在这里面。
接下来要通过手工方式寻找那些被跳过的栈帧。从terminate栈帧的ChildEBP开始,使用dps命令逐步向上寻找。如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| 0:002> dps 0095f0ac l32 0095f0ac 0095f8fc 0095f0b0 6b99f353 msvcp120!_Call_func+0x2e [f:\dd\vctools\crt\crtw32\stdcpp\thr\threadcall.cpp @ 35] 0095f0b4 6ba097f2 msvcr120!_NLG_Return [f:\dd\vctools\crt\crtw32\eh\i386\lowhelpr.asm @ 64] 0095f0b8 0095f8f0 0095f0bc 0095f37c 0095f0c0 0095f0d0 0095f0c4 0095f8f0 0095f0c8 00000000 0095f0cc 0095f8fc 0095f0d0 0095f0fc 0095f0d4 6ba09861 msvcr120!_CallCatchBlock2+0x4f [f:\dd\vctools\crt\crtw32\eh\i386\trnsctrl.cpp @ 502] 0095f0d8 6b99f34d msvcp120!_Call_func+0x28 [f:\dd\vctools\crt\crtw32\stdcpp\thr\threadcall.cpp @ 35] 0095f0dc 0095f8f0 0095f0e0 00000100 0095f0e4 0095f158 0095f0e8 6ba09ffc msvcr120!CatchGuardHandler [f:\dd\vctools\crt\crtw32\eh\i386\trnsctrl.cpp @ 535] 0095f0ec c29105eb 0095f0f0 6b9b4bc0 msvcp120!_CTA4?AVsystem_errorstd+0x7c 0095f0f4 0095f8f0 0095f0f8 00000001 0095f0fc 0095f168 0095f100 6ba0999c msvcr120!CallCatchBlock+0x87 [f:\dd\vctools\crt\crtw32\eh\frame.cpp @ 1400] 0095f104 0095f8f0 0095f108 6b9b4bc0 msvcp120!_CTA4?AVsystem_errorstd+0x7c 0095f10c 6b99f34d msvcp120!_Call_func+0x28 [f:\dd\vctools\crt\crtw32\stdcpp\thr\threadcall.cpp @ 35] 0095f110 00000000 0095f114 00000100 0095f118 c2910467 0095f11c 0095f8f0 0095f120 6b9b4bac msvcp120!_CTA4?AVsystem_errorstd+0x68 0095f124 0095f8f0 0095f128 0095f898 0095f12c 00000000 0095f130 00000000 0095f134 00000000 0095f138 00000000 0095f13c 0095f128 0095f140 0095f8dc 0095f144 6b9ff756 msvcr120!_getptd+0x6 [f:\dd\vctools\crt\crtw32\startup\tidtable.c @ 337] 0095f148 00000000 0095f14c 6b99f34d msvcp120!_Call_func+0x28 [f:\dd\vctools\crt\crtw32\stdcpp\thr\threadcall.cpp @ 35] 0095f150 0095f118 0095f154 c291048b 0095f158 0095f2a8 0095f15c 6ba0a0d5 msvcr120!_except_handler4 [f:\dd\vctools\crt\crtw32\misc\i386\chandler4gs.c @ 84] 0095f160 a9a46ccf 0095f164 00000001 0095f168 0095f1a4 0095f16c 6ba09a50 msvcr120!CatchIt+0x69 [f:\dd\vctools\crt\crtw32\eh\frame.cpp @ 1211] 0095f170 0095f37c
|
这个寻找过程需要耐心和运气,因为dps命令输出的结果非常多,而且有些并不是真正的栈帧。在不了解C++异常处理的情况下只能逐个排查,排查的方法是,使用k命令从该栈帧开始回溯调用栈,看看最终能不能到达KiUserExceptionDispatcher的栈帧。例如,可以从上面结果的最后一个栈帧CatchIt开始回溯。要注意,根据栈帧的结构,0095f16c存放的是返回地址,上一个0095f168存放的才是ebp,所以应使用0095f168来回溯。结果如下所示:
1 2 3 4 5 6 7 8 9 10 11 12
| 0:002> k = 0095f168 ChildEBP RetAddr 0095f07c 6ba8dc5f msvcr120!abort+0x38 [f:\dd\vctools\crt\crtw32\misc\abort.c @ 90] 0095f168 6ba09a50 msvcr120!terminate+0x33 [f:\dd\vctools\crt\crtw32\eh\hooks.cpp @ 96] 0095f1a4 6ba095ab msvcr120!CatchIt+0x69 [f:\dd\vctools\crt\crtw32\eh\frame.cpp @ 1211] 0095f220 6ba09638 msvcr120!FindHandler+0x27b [f:\dd\vctools\crt\crtw32\eh\frame.cpp @ 689] 0095f254 6ba096ba msvcr120!__InternalCxxFrameHandler+0xd6 [f:\dd\vctools\crt\crtw32\eh\frame.cpp @ 439] 0095f290 770372b9 msvcr120!__CxxFrameHandler3+0x26 [f:\dd\vctools\crt\crtw32\eh\i386\trnsctrl.cpp @ 301] 0095f2b4 7703728b ntdll!ExecuteHandler2+0x26 0095f2d8 7700f9d7 ntdll!ExecuteHandler+0x24 0095f364 77037117 ntdll!RtlDispatchException+0x127 0095f364 00000000 ntdll!KiUserExceptionDispatcher+0xf
|
这个调用栈最终到达了KiUserExceptionDispatcher,也就是我们要找的栈帧。为什么要找KiUserExceptionDispatcher的栈帧呢?这是因为在Windows下所有异常都是通过KiUserExceptionDispatcher这个函数抛出来的,这个函数具有两个类型分别为PEXCEPTION_RECORD和PCONTEXT的参数,分别指向异常信息以及异常发生时的线程环境信息。
既然找到了KiUserExceptionDispatcher的栈帧,那么可以换用kb命令,显示出它的参数列表:
1 2 3 4
| 0:002> kb = 0095f168 ChildEBP RetAddr Args to Child (略) 0095f364 00000000 0095f37c 0095f39c 0095f37c ntdll!KiUserExceptionDispatcher+0xf
|
可知PEXCEPTION_RECORD的值是0095f37c,PCONTEXT的值是0095f39c。
WinDbg提供了.exr命令来显示PEXCEPTION_RECORD的内容,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13
| 0:002> .exr 0095f37c ExceptionAddress: 74c4812f (KERNELBASE!RaiseException+0x00000058) ExceptionCode: e06d7363 (C++ EH exception) ExceptionFlags: 00000001 NumberParameters: 3 Parameter[0]: 19930520 Parameter[1]: 0095f898 Parameter[2]: 6b9b5734 pExceptionObject: 0095f898 _s_ThrowInfo : 6b9b5734 Type : class std::out_of_range Type : class std::logic_error Type : class std::exception
|
可以看到这是一个C++异常,并且类型是std::out_of_range。
WinDbg亦提供了.cxr命令,可以切换到指定PCONTEXT的线程环境,如下所示:
1 2 3 4 5 6
| 0:002> .cxr 0095f39c eax=0095f7f8 ebx=00431ee8 ecx=00000003 edx=00000000 esi=6b9b5734 edi=0095f898 eip=74c4812f esp=0095f7f8 ebp=0095f848 iopl=0 nv up ei pl nz ac po nc cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00000212 KERNELBASE!RaiseException+0x58: 74c4812f c9 leave
|
至此,异常发生时的各种寄存器信息一览无余。此时使用k命令即可得到异常发生时的调用栈:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 0:002> k *** Stack trace for last set context - .thread/.cxr resets it ChildEBP RetAddr 0095f848 6ba09339 KERNELBASE!RaiseException+0x58 0095f888 6b983a3a msvcr120!_CxxThrowException+0x5b [f:\dd\vctools\crt\crtw32\eh\throw.cpp @ 152] 0095f8a4 00c2104c msvcp120!std::_Xout_of_range+0x2e [f:\dd\vctools\crt\crtw32\stdcpp\xthrow.cpp @ 24] 0095f8cc 00c211ac ConsoleApplication!ThreadEntry+0x4c [c:\users\zplutor\documents\projects\tests\consoleapplication\main.cpp @ 9] 0095f8d4 6b99f33c ConsoleApplication!std::_LaunchPad<std::_Bind<1,void,void (__cdecl*const)(void)> >::_Go+0xc [c:\program files\microsoft visual studio 12.0\vc\include\thr\xthread @ 187] 0095f8fc 6ba1c01d msvcp120!_Call_func+0x17 [f:\dd\vctools\crt\crtw32\stdcpp\thr\threadcall.cpp @ 28] 0095f934 6ba1c001 msvcr120!_callthreadstartex+0x1b [f:\dd\vctools\crt\crtw32\startup\threadex.c @ 376] 0095f940 7685ee6c msvcr120!_threadstartex+0x7c [f:\dd\vctools\crt\crtw32\startup\threadex.c @ 354] 0095f94c 77053ab3 kernel32!BaseThreadInitThunk+0xe 0095f98c 77053a86 ntdll!__RtlUserThreadStart+0x70 0095f9a4 00000000 ntdll!_RtlUserThreadStart+0x1b
|
本文开头提到的现象并不是std::thread特有的。事实上,如果在捕获了一个异常之后的处理过程中又抛出了一个新的异常,那么旧异常的调用栈会被新异常的调用栈覆盖。在本文的例子中,_Call_func捕获了第一个异常之后,调用了terminate函数,terminate继而又调用了abort函数来抛出新的异常。使用本文提到的方法,就可以应对这种情况,还原出旧异常的调用栈。