最近测试时发现代码中一处关于 std::function 函数对象的问题。在卸载某个 Dll(记为 A)的时候会触发该模块中一个单例对象的析构,在它析构内部的 std::function 成员时产生了内存访问异常(0xc0000005)。而触发这个异常的 std::function 其实是复制于另一个 Dll(记为 B),卸载 Dll A 时,Dll B 已经卸载掉了。
此时调用它关联的函数会抛异常容易理解,但这时并没有进行调用操作。原因在于函数对象析构时会调用关联的 _Delete_this 方法释放资源,而这个函数一般定义在 std 的 _Func_impl / _Func_impl_no_alloc 模板类中。所以当实例化 function 时对应删除函数的代码也是属于当前模块里的(也就是 Dll B)。现在 Dll B 已经卸载,那么只要有任何触发相关代码执行的操作都会引发异常。
所以自己的屁股还是要自己擦干净,清理工作要记得做好。
未能觉察的异常
看提交记录这个异常也存在有一段时间了,但还是在挂着调试器下时才被发现。软件中实际上包含了基于 signal 的异常捕获方式,显然这个场景下 SIGILL 的 handler 没有被触发、同时全局的 UnhandledExceptionFilter 也没有被调用,所以使用时注意不到这个异常。
在代码中 signal 的捕获早在每个线程创建后就会注册完毕,而且显然也是正常在发挥作用的。此外最上层的 UnhandledExceptionFilter 也没有得到处理机会,这样考虑的话应该是系统在加载/卸载动态库时附加了额外的异常处理器并处理掉了异常,所以我们自己的异常报告模块才感知不到有异常发生。
异常分发
异常分发的核心函数是 KiDispatchException,它会首先尝试将异常发送给调试器。如果发送失败或者调试器没有处理,再调用异常处理块处理异常。用户态下异常处理由 ntdll 中的 KiUserExceptionDispatcher 函数进行。KiUserExceptionDispatcher 就会从 fs:[0] 开始遍历异常过滤器,直到有处理器处理异常或是执行 UnhandledExceptionFilter 函数。通过在代码里添加 __try / __except 就可以添加自定义的 seh 异常处理器。
因为异常注册信息存放在每个线程 TIB 的第一个字段 ExceptionList 中,而 x86 系统中的段寄存器 fs 总是指向线程的 TIB 结构。也就是说 fs:[0] 总是指向异常处理链表的表头,通过它就可以查看到线程上所有的异常处理器。在 windbg 中可以使用 !exchain 命令输出当前线程的异常处理链。
1 | static BOOL __cdecl dllmain_dispatch( |
2 | HINSTANCE const instance, |
3 | DWORD const reason, |
4 | LPVOID const reserved |
5 | ) |
6 | { |
7 | __try { |
8 | // process thread attach 处理代码 |
9 |
10 | result = DllMain(instance, reason, reserved); |
11 |
12 | // process attach failed 处理代码 |
13 |
14 | // process thread detach 处理代码 |
15 | } |
16 | __except(__scrt_dllmain_exception_filter( |
17 | instance, |
18 | reason, |
19 | reserved, |
20 | dllmain_crt_dispatch, |
21 | GetExceptionCode(), |
22 | GetExceptionInformation())) |
23 | { |
24 | result = FALSE; |
25 | } |
26 | return result; |
27 | } |
28 |
29 |
30 | extern "C" int __cdecl _seh_filter_dll( |
31 | unsigned long const xcptnum, |
32 | PEXCEPTION_POINTERS const pxcptinfoptrs |
33 | ) |
34 | { |
35 | if (xcptnum != ( 'msc' | 0xE0000000)) |
36 | return EXCEPTION_CONTINUE_SEARCH; |
37 | return _seh_filter_exe(xcptnum,pxcptinfoptrs); |
38 | } |
在 crt\src\vcruntime\dll_dllmain.cpp 的 dllmain_dispatch 函数中我们可以看到,crt 的初始化相关代码以及 DllMain 都被包含在运行库创建的 __try / __except 块里,负责过滤的是 __scrt_dllmain_exception_filter 函数。而 __scrt_dllmain_exception_filter 函数会调用 _seh_filter_dll,后者又会调用 _seh_filter_exe 进行实际处理。关键的 signal handler 触发是在 _seh_filter_exe 函数中。然而区别在于 _seh_filter_dll 中会判断当前的异常是不是一个 c++ 异常(异常代码为 0xe06d7363),是就直接跳过不处理。执行不可访问内存实际触发的是 0xc0000005,所以 _seh_filter_dll 就会直接返回 EXCEPTION_CONTINUE_SEARCH。
调试
我写了个简单的测试程序来模拟异常发生的情况
1 | //-------------------- dll -------------------- |
2 | #include <Windows.h> |
3 |
4 | typedef void (*FN)(); |
5 |
6 | class Class1 |
7 | { |
8 | public : |
9 | ~Class1() { |
10 | ((FN)1000)(); |
11 | } |
12 | }; |
13 |
14 | Class1 c; |
15 |
16 | BOOL APIENTRY DllMain( HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) { |
17 | return TRUE; |
18 | } |
19 |
20 | //-------------------- exe -------------------- |
21 | #include <csignal> |
22 | #include <cstdio> |
23 | #include <Windows.h> |
24 |
25 | void SigillHandler( int ) { |
26 | printf ( "SigillHandler\n" ); |
27 | } |
28 | void SigsegvHandler( int ) { |
29 | printf ( "SigsegvHandler\n" ); |
30 | } |
31 |
32 | int main( int argc, char ** argv) { |
33 | signal (SIGILL, SigillHandler); |
34 | signal (SIGSEGV, SigsegvHandler); |
35 | auto hd = LoadLibrary(L "Dll1.dll" ); |
36 | FreeLibrary(hd); |
37 | printf ( "exit main\n" ); |
38 | return 0; |
39 | } |
在调用 FreeLibrary 卸载 dll 时就会触发到全局变量析构函数中的内存访问异常。
调试器收到异常后查看当前的异常链:
1 | 0:000> !exchain |
2 | 007ef438: Dll1!_filter_x86_sse2_floating_point_exception+12f0 (7bd37510) |
3 | 007ef554: ucrtbased!_aulldvrm+138 (78b03008) |
4 | 007ef588: ucrtbased!_except_handler4+0 (78b02410) |
5 | CRT scope 0, func: ucrtbased!__crt_seh_guarded_call<int>::operator()<<lambda_d74cd35c3df28f16c733e6755eb03555>,<lambda_d121dba8a4adeaf3a9819e48611155df> &,<lambda_ade73ae6aee997c6ba47ce5901b340f9> >+64 (78a56534) |
6 | 007ef600: Dll1!_except_handler4+0 (7bd34330) |
7 | CRT scope 1, 007ef658: Dll1!_except_handler4+0 (7bd34330) |
8 | CRT scope 0, 007ef6d8: ntdll!_except_handler4+0 (7728af00) |
9 | CRT scope 0, func: ntdll!LdrpCallInitRoutine+4b72c (772a92ad) |
10 | 007ef740: ntdll!_except_handler4+0 (7728af00) |
11 | CRT scope 1, filter: ntdll!LdrpProcessDetachNode+438a1 (772ade74) |
12 | func: ntdll!LdrpProcessDetachNode+438aa (772ade7d) |
13 | CRT scope 0, func: ntdll!LdrpProcessDetachNode+438b4 (772ade87) |
14 | 007ef910: Tests!_except_handler4+0 (00143b80) |
15 | CRT scope 0, 007ef98c: ntdll!_except_handler4+0 (7728af00) |
16 | CRT scope 0, filter: ntdll!__RtlUserThreadStart+3ca48 (772b4697) |
17 | func: ntdll!__RtlUserThreadStart+3cae1 (772b4730) |
18 | 007ef9a4: ntdll!FinalExceptionHandlerPad56+0 (772988d8) |
19 | Invalid exception stack at ffffffff |
异常发生时的堆栈:
1 | 00 0x3e8 |
2 | 01 Dll1!Class1::~Class1+0x55 |
3 | 02 Dll1!`dynamic atexit destructor for 'c''+0x28 |
4 | 03 ucrtbased!<lambda_d121dba8a4adeaf3a9819e48611155df>::operator()+0x102 |
5 | 04 ucrtbased!__crt_seh_guarded_call<int>::operator()<<lambda_d74cd35c3df28f16c733e6755eb03555>,<lambda_d121dba8a4adeaf3a9819e48611155df> &,<lambda_ade73ae6aee997c6ba47ce5901b340f9> >+0x53 |
6 | 05 ucrtbased!__acrt_lock_and_call<<lambda_d121dba8a4adeaf3a9819e48611155df> >+0x2e |
7 | 06 ucrtbased!_execute_onexit_table+0x1e |
8 | 07 Dll1!__scrt_dllmain_uninitialize_c+0x16 |
9 | 08 Dll1!dllmain_crt_process_detach+0x7f |
10 | 09 Dll1!dllmain_crt_dispatch+0x48 |
11 | 0a Dll1!dllmain_dispatch+0x10b |
12 | 0b Dll1!_DllMainCRTStartup+0x1f |
13 | 0c ntdll!LdrxCallInitRoutine+0x16 |
14 | 0d ntdll!LdrpCallInitRoutine+0x51 |
15 | 0e ntdll!LdrpProcessDetachNode+0xb7 |
16 | 0f ntdll!LdrpUnloadNode+0xa2 |
17 | 10 ntdll!LdrpDecrementModuleLoadCountEx+0x4a |
18 | 11 ntdll!LdrUnloadDll+0x103 |
19 | 12 KERNELBASE!FreeLibrary+0x16 |
20 | 13 Tests!main+0x64 |
21 | 14 Tests!invoke_main+0x33 |
22 | 15 Tests!__scrt_common_main_seh+0x157 |
23 | 16 Tests!__scrt_common_main+0xd |
24 | 17 Tests!mainCRTStartup+0x8 |
25 | 18 KERNEL32!BaseThreadInitThunk+0x19 |
26 | 19 ntdll!__RtlUserThreadStart+0x2f |
27 | 1a ntdll!_RtlUserThreadStart+0x1b |
在 ntdll!ExecuteHandler2 中下断点观察,可以发现前面的过滤器都跳过了处理,最终是由
1 | 007ef740: ntdll!_except_handler4+0 (7728af00) |
2 | CRT scope 1, filter: ntdll!LdrpProcessDetachNode+438a1 (772ade74) |
3 | func: ntdll!LdrpProcessDetachNode+438aa (772ade7d) |
4 | CRT scope 0, func: ntdll!LdrpProcessDetachNode+438b4 (772ade87) |
处理了异常,处理异常时的堆栈
1 | 0:000> k |
2 | 00 ntdll!SendMessageToWERService |
3 | 01 ntdll!ReportExceptionInternal+0xde |
4 | 02 ntdll!RtlReportExceptionHelper+0x202 |
5 | 03 ntdll!RtlReportException+0x61 |
6 | 04 ntdll!LdrpCalloutExceptionFilter+0x35 |
7 | 05 ntdll!LdrpProcessDetachNode+0x438a9 <----- 它添加的处理函数 |
8 | 06 ntdll!LdrpUnloadNode+0xa2 |
9 | 07 ntdll!LdrpDecrementModuleLoadCountEx+0x4a |
10 | 08 ntdll!LdrUnloadDll+0x103 |
11 | 09 KERNELBASE!FreeLibrary+0x16 |
12 | 0a Tests!main+0x64 |
13 | 0b Tests!invoke_main+0x33 |
14 | 0c Tests!__scrt_common_main_seh+0x157 |
15 | 0d Tests!__scrt_common_main+0xd |
16 | 0e Tests!mainCRTStartup+0x8 |
17 | 0f KERNEL32!BaseThreadInitThunk+0x19 |
18 | 10 ntdll!__RtlUserThreadStart+0x2f |
19 | 11 ntdll!_RtlUserThreadStart+0x1b |
可以看到这里将异常报告发送给了 WER 服务,该操作完成后也就结束了本次的异常处理。在做完扫尾的栈展开后线程便会继续执行后续的代码。