如何调试在std::thread子线程中抛出的C++异常

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函数来抛出新的异常。使用本文提到的方法,就可以应对这种情况,还原出旧异常的调用栈。