最近测试时发现代码中一处关于 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 命令输出当前线程的异常处理链。
static BOOL __cdecl dllmain_dispatch( HINSTANCE const instance, DWORD const reason, LPVOID const reserved ) { __try { // process thread attach 处理代码 result = DllMain(instance, reason, reserved); // process attach failed 处理代码 // process thread detach 处理代码 } __except(__scrt_dllmain_exception_filter( instance, reason, reserved, dllmain_crt_dispatch, GetExceptionCode(), GetExceptionInformation())) { result = FALSE; } return result; } extern "C" int __cdecl _seh_filter_dll( unsigned long const xcptnum, PEXCEPTION_POINTERS const pxcptinfoptrs ) { if (xcptnum != ('msc' | 0xE0000000)) return EXCEPTION_CONTINUE_SEARCH; return _seh_filter_exe(xcptnum,pxcptinfoptrs); }
在 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。
调试
我写了个简单的测试程序来模拟异常发生的情况
//-------------------- dll -------------------- #include <Windows.h> typedef void (*FN)(); class Class1 { public: ~Class1() { ((FN)1000)(); } }; Class1 c; BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) { return TRUE; } //-------------------- exe -------------------- #include <csignal> #include <cstdio> #include <Windows.h> void SigillHandler(int) { printf("SigillHandler\n"); } void SigsegvHandler(int) { printf("SigsegvHandler\n"); } int main(int argc, char** argv) { signal(SIGILL, SigillHandler); signal(SIGSEGV, SigsegvHandler); auto hd = LoadLibrary(L"Dll1.dll"); FreeLibrary(hd); printf("exit main\n"); return 0; }
在调用 FreeLibrary 卸载 dll 时就会触发到全局变量析构函数中的内存访问异常。
调试器收到异常后查看当前的异常链:
0:000> !exchain 007ef438: Dll1!_filter_x86_sse2_floating_point_exception+12f0 (7bd37510) 007ef554: ucrtbased!_aulldvrm+138 (78b03008) 007ef588: ucrtbased!_except_handler4+0 (78b02410) CRT scope 0, func: ucrtbased!__crt_seh_guarded_call<int>::operator()<<lambda_d74cd35c3df28f16c733e6755eb03555>,<lambda_d121dba8a4adeaf3a9819e48611155df> &,<lambda_ade73ae6aee997c6ba47ce5901b340f9> >+64 (78a56534) 007ef600: Dll1!_except_handler4+0 (7bd34330) CRT scope 1, 007ef658: Dll1!_except_handler4+0 (7bd34330) CRT scope 0, 007ef6d8: ntdll!_except_handler4+0 (7728af00) CRT scope 0, func: ntdll!LdrpCallInitRoutine+4b72c (772a92ad) 007ef740: ntdll!_except_handler4+0 (7728af00) CRT scope 1, filter: ntdll!LdrpProcessDetachNode+438a1 (772ade74) func: ntdll!LdrpProcessDetachNode+438aa (772ade7d) CRT scope 0, func: ntdll!LdrpProcessDetachNode+438b4 (772ade87) 007ef910: Tests!_except_handler4+0 (00143b80) CRT scope 0, 007ef98c: ntdll!_except_handler4+0 (7728af00) CRT scope 0, filter: ntdll!__RtlUserThreadStart+3ca48 (772b4697) func: ntdll!__RtlUserThreadStart+3cae1 (772b4730) 007ef9a4: ntdll!FinalExceptionHandlerPad56+0 (772988d8) Invalid exception stack at ffffffff
异常发生时的堆栈:
00 0x3e8 01 Dll1!Class1::~Class1+0x55 02 Dll1!`dynamic atexit destructor for 'c''+0x28 03 ucrtbased!<lambda_d121dba8a4adeaf3a9819e48611155df>::operator()+0x102 04 ucrtbased!__crt_seh_guarded_call<int>::operator()<<lambda_d74cd35c3df28f16c733e6755eb03555>,<lambda_d121dba8a4adeaf3a9819e48611155df> &,<lambda_ade73ae6aee997c6ba47ce5901b340f9> >+0x53 05 ucrtbased!__acrt_lock_and_call<<lambda_d121dba8a4adeaf3a9819e48611155df> >+0x2e 06 ucrtbased!_execute_onexit_table+0x1e 07 Dll1!__scrt_dllmain_uninitialize_c+0x16 08 Dll1!dllmain_crt_process_detach+0x7f 09 Dll1!dllmain_crt_dispatch+0x48 0a Dll1!dllmain_dispatch+0x10b 0b Dll1!_DllMainCRTStartup+0x1f 0c ntdll!LdrxCallInitRoutine+0x16 0d ntdll!LdrpCallInitRoutine+0x51 0e ntdll!LdrpProcessDetachNode+0xb7 0f ntdll!LdrpUnloadNode+0xa2 10 ntdll!LdrpDecrementModuleLoadCountEx+0x4a 11 ntdll!LdrUnloadDll+0x103 12 KERNELBASE!FreeLibrary+0x16 13 Tests!main+0x64 14 Tests!invoke_main+0x33 15 Tests!__scrt_common_main_seh+0x157 16 Tests!__scrt_common_main+0xd 17 Tests!mainCRTStartup+0x8 18 KERNEL32!BaseThreadInitThunk+0x19 19 ntdll!__RtlUserThreadStart+0x2f 1a ntdll!_RtlUserThreadStart+0x1b
在 ntdll!ExecuteHandler2 中下断点观察,可以发现前面的过滤器都跳过了处理,最终是由
007ef740: ntdll!_except_handler4+0 (7728af00) CRT scope 1, filter: ntdll!LdrpProcessDetachNode+438a1 (772ade74) func: ntdll!LdrpProcessDetachNode+438aa (772ade7d) CRT scope 0, func: ntdll!LdrpProcessDetachNode+438b4 (772ade87)
处理了异常,处理异常时的堆栈
0:000> k 00 ntdll!SendMessageToWERService 01 ntdll!ReportExceptionInternal+0xde 02 ntdll!RtlReportExceptionHelper+0x202 03 ntdll!RtlReportException+0x61 04 ntdll!LdrpCalloutExceptionFilter+0x35 05 ntdll!LdrpProcessDetachNode+0x438a9 <----- 它添加的处理函数 06 ntdll!LdrpUnloadNode+0xa2 07 ntdll!LdrpDecrementModuleLoadCountEx+0x4a 08 ntdll!LdrUnloadDll+0x103 09 KERNELBASE!FreeLibrary+0x16 0a Tests!main+0x64 0b Tests!invoke_main+0x33 0c Tests!__scrt_common_main_seh+0x157 0d Tests!__scrt_common_main+0xd 0e Tests!mainCRTStartup+0x8 0f KERNEL32!BaseThreadInitThunk+0x19 10 ntdll!__RtlUserThreadStart+0x2f 11 ntdll!_RtlUserThreadStart+0x1b
可以看到这里将异常报告发送给了 WER 服务,该操作完成后也就结束了本次的异常处理。在做完扫尾的栈展开后线程便会继续执行后续的代码。