最近测试时发现代码中一处关于 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 服务,该操作完成后也就结束了本次的异常处理。在做完扫尾的栈展开后线程便会继续执行后续的代码。