文章目录
概述
在 DLL 注入项目中,通常会通过 AllocConsole() 分配一个控制台窗口用于调试输出。当用户按下热键(如 Del)触发卸载流程时,理应调用 FreeConsole() 关闭该窗口。但实际测试中发现:FreeConsole() 调用了,控制台窗口却不关闭。
本文记录了从表面现象到根因的完整排查过程,涉及 spdlog 日志框架的 sink 机制和 C 标准流(freopen_s)对控制台句柄的持有问题。
问题背景
项目结构简述:
- DLL 注入到目标进程后,
DllMain创建线程MyThread MyThread中调用spdlog初始化日志系统、内部AllocConsole、stdout重定向MyThread循环中轮询热键,按键退出循环,执行清理后FreeLibraryAndExitThread
原始清理流程如下:
cpp
// 退出主循环后
FreeConsole();
FreeLibraryAndExitThread(hModule, 0);
按键后,控制台窗口始终不关闭。
排查过程
第一轮:怀疑清理流程阻塞
最初的假设是 FreeConsole() 被其它逻辑 卡住,FreeConsole() 就永远执行不到。
增加日志: 代码最后增加日志,确保FreeConsole和FreeLibraryAndExitThread均执行结束。
结果: 通过日志确认清理流程实际没有阻塞,全部在毫秒级完成。排除此假设。
第二轮:spdlog console sink 持有句柄
查看代码发现 spdlog 注册了 stdout_color_sink_mt:
stdout_color_sink_mt 内部持有 stdout 的文件句柄。直接调 FreeConsole() 时 sink 仍在引用该句柄,Windows 不会销毁控制台窗口。
修复尝试: 清理spdlog logger->sinks()。
cpp
auto& sinks = logger->sinks();
sinks.erase(
std::remove(sinks.begin(), sinks.end(), console_sink),
sinks.end());
结果: 控制台窗口仍然不关闭。
第三轮:freopen_s 打开的 C 标准流仍持有句柄
关键发现:Log::Init() 中的 freopen_s 将 C 标准流重定向到了控制台:
cpp
FILE* fp;
freopen_s(&fp, "CONOUT$", "w", stdout);
freopen_s(&fp, "CONOUT$", "w", stderr);
freopen_s(&fp, "CONIN$", "r", stdin);
这三个 freopen_s 调用使得 stdout、stderr、stdin 这三个 C 标准流各自持有一个到 CONOUT$ / CONIN$ 的文件句柄。即使 spdlog 的 sink 被移除了,即使 FreeConsole() 被调用了,这些 C 标准流的文件描述符仍然 open,Windows 内核认为仍有句柄引用该控制台对象,因此不会销毁控制台窗口。
最终修复
完整清理顺序:
- 移除 spdlog 的 console sink(停止向控制台写入)
FreeConsole()(解除进程与控制台的关联)fclose(stdout/stderr/stdin)(关闭freopen_s打开的底层文件句柄)FreeLibraryAndExitThread
原理总结
Windows 控制台窗口的生命周期由引用计数控制。以下对象都会增加控制台的引用:
| 引用来源 | 创建方式 | 释放方式 |
|---|---|---|
| 进程与控制台的关联 | AllocConsole() |
FreeConsole() |
| spdlog stdout_color_sink | std::make_shared<stdout_color_sink_mt>() |
从 logger sinks 中移除 |
| C 标准流 stdout/stderr | freopen_s(&fp, "CONOUT$", ...) |
fclose(stdout) |
| C 标准流 stdin | freopen_s(&fp, "CONIN$", ...) |
fclose(stdin) |
只有所有引用都释放后,Windows 才会真正销毁控制台窗口。遗漏任何一个都会导致窗口残留。
总结
这个问题的排查经历了三轮:
- 清理阻塞假设 --- 通过日志排除,清理流程正常完成
- spdlog sink 持有句柄 --- 使用
CloseConsoleIfOwned()正确移除 sink,但不够 - freopen_s 的 C 标准流 --- 最终根因,
fclose关闭残留句柄后窗口正常关闭
核心教训:AllocConsole() 容易配对 FreeConsole(),但中间如果有 freopen_s 重定向标准流,这些流会成为隐性的句柄持有者。清理控制台时必须同时处理所有层级的句柄引用------系统 API 层(FreeConsole)、框架层(spdlog sink)、C 运行时层(fclose)。