最近测试时发现代码中一处关于 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 命令输出当前线程的异常处理链。

1static 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 
30extern "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 
4typedef void (*FN)();
5 
6class Class1
7{
8public:
9  ~Class1() {
10    ((FN)1000)();
11  }
12};
13 
14Class1 c;
15 
16BOOL 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 
25void SigillHandler(int) {
26  printf("SigillHandler\n");
27}
28void SigsegvHandler(int) {
29  printf("SigsegvHandler\n");
30}
31 
32int 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 时就会触发到全局变量析构函数中的内存访问异常。
调试器收到异常后查看当前的异常链:

10:000> !exchain
2007ef438: Dll1!_filter_x86_sse2_floating_point_exception+12f0 (7bd37510)
3007ef554: ucrtbased!_aulldvrm+138 (78b03008)
4007ef588: 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)
6007ef600: 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)
10007ef740: 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)
14007ef910: 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)
18007ef9a4: ntdll!FinalExceptionHandlerPad56+0 (772988d8)
19Invalid exception stack at ffffffff

异常发生时的堆栈:

100 0x3e8
201 Dll1!Class1::~Class1+0x55
302 Dll1!`dynamic atexit destructor for 'c''+0x28
403 ucrtbased!<lambda_d121dba8a4adeaf3a9819e48611155df>::operator()+0x102
504 ucrtbased!__crt_seh_guarded_call<int>::operator()<<lambda_d74cd35c3df28f16c733e6755eb03555>,<lambda_d121dba8a4adeaf3a9819e48611155df> &,<lambda_ade73ae6aee997c6ba47ce5901b340f9> >+0x53
605 ucrtbased!__acrt_lock_and_call<<lambda_d121dba8a4adeaf3a9819e48611155df> >+0x2e
706 ucrtbased!_execute_onexit_table+0x1e
807 Dll1!__scrt_dllmain_uninitialize_c+0x16
908 Dll1!dllmain_crt_process_detach+0x7f
1009 Dll1!dllmain_crt_dispatch+0x48
110a Dll1!dllmain_dispatch+0x10b
120b Dll1!_DllMainCRTStartup+0x1f
130c ntdll!LdrxCallInitRoutine+0x16
140d ntdll!LdrpCallInitRoutine+0x51
150e ntdll!LdrpProcessDetachNode+0xb7
160f ntdll!LdrpUnloadNode+0xa2
1710 ntdll!LdrpDecrementModuleLoadCountEx+0x4a
1811 ntdll!LdrUnloadDll+0x103
1912 KERNELBASE!FreeLibrary+0x16
2013 Tests!main+0x64
2114 Tests!invoke_main+0x33
2215 Tests!__scrt_common_main_seh+0x157
2316 Tests!__scrt_common_main+0xd
2417 Tests!mainCRTStartup+0x8
2518 KERNEL32!BaseThreadInitThunk+0x19
2619 ntdll!__RtlUserThreadStart+0x2f
271a ntdll!_RtlUserThreadStart+0x1b

在 ntdll!ExecuteHandler2 中下断点观察,可以发现前面的过滤器都跳过了处理,最终是由

1007ef740: 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)

处理了异常,处理异常时的堆栈

10:000> k
200 ntdll!SendMessageToWERService
301 ntdll!ReportExceptionInternal+0xde
402 ntdll!RtlReportExceptionHelper+0x202
503 ntdll!RtlReportException+0x61
604 ntdll!LdrpCalloutExceptionFilter+0x35
705 ntdll!LdrpProcessDetachNode+0x438a9     <----- 它添加的处理函数
806 ntdll!LdrpUnloadNode+0xa2
907 ntdll!LdrpDecrementModuleLoadCountEx+0x4a
1008 ntdll!LdrUnloadDll+0x103
1109 KERNELBASE!FreeLibrary+0x16
120a Tests!main+0x64
130b Tests!invoke_main+0x33
140c Tests!__scrt_common_main_seh+0x157
150d Tests!__scrt_common_main+0xd
160e Tests!mainCRTStartup+0x8
170f KERNEL32!BaseThreadInitThunk+0x19
1810 ntdll!__RtlUserThreadStart+0x2f
1911 ntdll!_RtlUserThreadStart+0x1b

可以看到这里将异常报告发送给了 WER 服务,该操作完成后也就结束了本次的异常处理。在做完扫尾的栈展开后线程便会继续执行后续的代码。