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