日志、崩溃转储与运行时诊断
本项目(SpinningMomo,Win32 原生 C++ 应用)的运行时诊断能力由两个独立但共享存储布局的子系统构成:基于 spdlog 的日志系统(utils::logging / Logger)与基于 DbgHelp MiniDumpWriteDump 的崩溃转储系统(utils::crash_dump)。二者在进程启动最早期完成装配,为整个应用提供"事后可追溯"的故障现场记录能力。
目的与范围
本页覆盖运行时诊断能力的完整端到端机制:
- 进程入口处的诊断装配顺序(崩溃转储先于日志初始化)
- SEH 未捕获异常与
std::terminate两条崩溃捕获路径 - MiniDump 转储文件的生成、命名规则、落盘目录与写入算法
- spdlog 日志的 sink 拓扑、格式模式、级别解析与运行时调级
Logger类基于std::source_location的免宏调用点捕获设计- 重入保护、幂等安装、级别回退等失败模式与并发语义
%APPDATA%下的诊断产物存储布局(logs/app.log与logs/dumps/*.dmp)
有意留给兄弟页面的主题:日志/转储产物的设置界面呈现(如何把日志目录暴露给用户)属于设置子系统;utils::path 的 AppData 目录解析细节属于路径工具子系统;utils::system 等其他系统查询工具亦不在本页范围。本页仅在诊断机制引用这些能力时说明其契约。
概述
设计目标
桌面应用崩溃现场往往转瞬即逝:进程终止后无法附加调试器,"用户说不清、复现不了"是排障常态。本项目通过两层机制解决该问题:
- 崩溃转储(crash dump):在进程即将死亡的瞬间,用 Windows DbgHelp API 把进程的线程状态、模块列表与关键内存快照写入
.dmp文件,供事后用 Visual Studio / WinDbg 打开还原完整调用栈。 - 结构化日志(logging):全程以带源文件位置、级别、毫秒级时间戳的格式记录运行轨迹,配合崩溃转储还原"崩溃前发生了什么"。
关键概念
| 概念 | 含义 |
|---|---|
| SEH | Windows 结构化异常处理;SetUnhandledExceptionFilter 注册的过滤器在无任何处理器的异常到达时被调用 |
std::terminate | C++ 运行时终止路径(未捕获 C++ 异常、noexcept 违约等),通过 std::set_terminate 接管 |
| MiniDump | Windows 缩减版进程快照格式;dump type 标志决定快照包含哪些信息 |
std::source_location | C++20 编译期调用点信息(文件/行号/函数名),Logger 借此免去日志宏 |
std::expected<T, E> | 错误码式返回,本项目诊断 API 全部以它替代异常跨边界传播 |
| 双 sink | 同一条日志同时写入调试器输出(msvc_sink_mt)与轮转文件(rotating_file_sink_mt) |
架构
组件职责解读
src/main.cpp:唯一装配点。按"崩溃转储 → 日志"的顺序初始化——转储处理器不依赖任何运行时状态,必须赶在一切之前安装,确保连日志初始化自身的失败也能留下现场。utils::crash_dump:注册两条崩溃捕获路径(SEH 过滤器 + terminate 处理器),共用同一核心写入例程write_dump_internal()。不依赖 spdlog(崩溃时日志系统可能已不可用),失败时以OutputDebugStringA作为最后兜底输出通道。utils::logging/Logger:Logger是轻量值类型,构造时捕获调用点std::source_location,所有方法直接路由到spdlog::default_logger();命名 logger"spinning_momo"同时注册并设为默认。utils::path:两个子系统共同的存储地基,把诊断产物统一收敛到%APPDATA%下的logs子目录,日志与转储因此天然同处一处,便于用户打包上报。
核心实现机制
1. 启动装配顺序(入口控制流)
#include "utils/crash_dump/crash_dump.hpp"
#include "utils/logger/logger.hpp"在 src/main.cpp 中,诊断机制的装配遵循固定顺序:
// 尽早安装崩溃转储处理器
utils::crash_dump::install();Source: main.cpp
设计意图(WHY):崩溃转储必须最早安装。若日志初始化本身触发崩溃(例如 spdlog 抛异常、磁盘 IO 失败),此时唯一的"黑匣子"就是转储处理器;反之若先初始化日志,安装转储处理器之前的任何异常都会无迹可寻。转储路径刻意不依赖 spdlog,正是为了在"日志系统可能已死"的场景下依然能工作。
2. 崩溃转储:双路捕获与写入算法
install() 的幂等实现与两条捕获路径的注册:
1auto install() -> void {
2 if (detail::g_installed.exchange(true, std::memory_order_acq_rel)) {
3 return;
4 }
5
6 SetUnhandledExceptionFilter(detail::unhandled_exception_filter);
7 std::set_terminate(detail::terminate_handler);
8}Source: crash_dump.cpp
g_installed为std::atomic_bool,exchange保证多线程/重复调用下处理器至多注册一次,避免过滤器链被重复覆盖。- 路径一(SEH):
unhandled_exception_filter捕获所有未被应用层处理的结构化异常,调用write_dump_internal(exception_pointers, "seh")后返回EXCEPTION_EXECUTE_HANDLER,让系统按"已处理"终止进程而不是弹 WER 对话框。 - 路径二(terminate):
terminate_handler处理未捕获 C++ 异常等 C++ 运行时终止场景,因无EXCEPTION_POINTERS可用,传nullptr并以"terminate"为理由;写完后调用std::abort()主动终结。
核心写入例程 write_dump_internal(重入保护 + 落盘):
1auto write_dump_internal(void* exception_pointers, std::string_view reason)
2 -> std::expected<std::filesystem::path, std::string> {
3 if (g_dump_writing.test_and_set(std::memory_order_acquire)) {
4 return std::unexpected("Dump writing is already in progress");
5 }
6 DumpWriteScope scope_guard{};Source: crash_dump.cpp
并发语义(关键):g_dump_writing 是 std::atomic_flag,test_and_set(acquire) 构成"只有第一个进入者写 dump"的门槛。设想场景:线程 A 崩溃触发 SEH 过滤器开始写 dump → dump 写入过程本身又触发异常 → SEH 过滤器重入 → 若无此保护将造成递归写盘直至栈溢出。DumpWriteScope 析构函数(RAII)以 release 序清除标志,保证异常路径也能正确复位。
随后构造文件名、创建文件并调用 DbgHelp:
const auto filename =
std::format("crash_{}_pid{}_tid{}_{}_0x{:08X}.dmp", current_timestamp(),
GetCurrentProcessId(), GetCurrentThreadId(), sanitize_reason(reason), code);Source: crash_dump.cpp
文件名编码了完整诊断上下文:本地时间戳(yyyyMMdd_HHmmss)、进程 ID、线程 ID、净化后的原因标签(非字母数字一律替换为 _,防止注入路径分隔符)、异常代码(十六进制,取自 ExceptionRecord->ExceptionCode)。这使得多线程同时崩溃、同秒重启复崩都能产生互不覆盖的唯一文件名。
实际写入 DbgHelp 的调用与异常信息透传:
BOOL ok =
MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), dump_file, kDefaultDumpType,
exception ? &exception_info : nullptr, nullptr, nullptr);Source: crash_dump.cpp
Dump 类型选择(设计权衡):kDefaultDumpType 组合了三个标志:
constexpr MINIDUMP_TYPE kDefaultDumpType = static_cast<MINIDUMP_TYPE>(
MiniDumpWithThreadInfo | MiniDumpWithUnloadedModules | MiniDumpWithIndirectlyReferencedMemory);Source: crash_dump.cpp
MiniDumpWithThreadInfo:记录线程时序信息,还原死锁/竞争现场必需。MiniDumpWithUnloadedModules:卸载模块列表,DLL 动态加载问题的线索。MiniDumpWithIndirectlyReferencedMemory:间接引用的内存页,能显著提高调用栈符号还原率。- 刻意未包含
MiniDumpWithFullMemory:完整内存快照体积可达 GB 级,对桌面应用不现实;该组合是"体积可控 + 可诊断性足够"的折中。
MINIDUMP_EXCEPTION_INFORMATION.ClientPointers = FALSE 表示异常指针位于本进程地址空间内(崩溃发生在本进程时为常态);exception ? &exception_info : nullptr 使 terminate 路径(无异常记录)降级为不带异常上下文的 dump。
3. 日志系统:初始化、sink 拓扑与级别治理
initialize() 一次性构建双 sink、命名 logger 并设为默认:
1 std::vector<spdlog::sink_ptr> sinks;
2 sinks.push_back(std::make_shared<spdlog::sinks::msvc_sink_mt>());
3 sinks.push_back(std::make_shared<spdlog::sinks::rotating_file_sink_mt>(log_file_path.string(),
4 5 * 1024 * 1024, 3));
5
6 auto logger = std::make_shared<spdlog::logger>("spinning_momo", sinks.begin(), sinks.end());
7
8 logger->set_pattern(core::build_config::is_debug_build()
9 ? "%Y-%m-%d %H:%M:%S.%e [%^%l%$] [%g:%#] %v"
10 : "%Y-%m-%d %H:%M:%S.%e [%^%l%$] [%s:%#] %v");Source: logger.cpp
设计意图:
msvc_sink_mt把日志镜像到调试器输出窗口(OutputDebugString),开发期"边跑边看"零成本;mt后缀表示多线程安全 sink。rotating_file_sink_mt以app.log为基础名,5 MB 单文件上限、保留 3 个轮转副本(即app.log/app.1.log/app.2.log等形态),上限约 20 MB,防止长期运行吃满用户磁盘。- 发布版与调试版使用不同的源码位置格式符:调试版
%g:%#输出完整源文件路径与行号,发布版%s:%#输出精简 basename——发布版日志更易读,调试版信息更全。 - 默认级别由构建类型决定:调试构建
trace、发布构建info(见detail::default_level(),logger.cpp L12-L14)。
级别解析采用"先规范化再匹配"的两段式,并带内置别名:
if (normalized == "WARN" || normalized == "WARNING") {
return spdlog::level::warn;
}Source: logger.cpp
normalize_level_string 去除全部空白并统一大写,因此 "warn"、" Warning " 均合法。失败模式:非法级别字符串在初始化时不会令初始化失败,而是记录一条警告并回退默认级别:
1 auto resolved_level = detail::default_level();
2 std::optional<std::string> level_warning;
3 if (configured_level.has_value() && !configured_level->empty()) {
4 auto parse_result = detail::parse_level(configured_level.value());
5 if (parse_result) {
6 resolved_level = parse_result.value();
7 } else {
8 level_warning = parse_result.error() + ", fallback to default level";
9 }
10 }Source: logger.cpp
这一"宽容解析 + 可见警告"策略面向用户配置场景:一个写错的日志级别不应让应用无法启动,但必须留痕提醒。
最后,logger->flush_on(spdlog::level::trace) 使任何级别的日志都立即落盘——牺牲吞吐换取崩溃前的最后几条日志不丢失,与崩溃转储机制在"可靠性优先"上形成呼应。
4. Logger 类:免宏的调用点捕获
1class Logger {
2 public:
3 Logger(std::source_location loc = std::source_location::current());
4
5 template <typename... Args>
6 inline auto warn(spdlog::format_string_t<Args...> fmt, Args&&... args) const -> void {
7 spdlog::default_logger()->log(
8 spdlog::source_loc{loc_.file_name(), static_cast<int>(loc_.line()), loc_.function_name()},
9 spdlog::level::warn, fmt, std::forward<Args>(args)...);
10 }Source: logger.hpp
设计意图(WHY):传统日志库靠 __FILE__/__LINE__ 宏注入调用点,污染调用处、无法按值传递。这里利用 std::source_location::current() 作为构造函数默认实参的技巧——默认实参在调用点求值,因此 Logger() 临时对象构造时即冻结了调用者位置;六个级别方法(trace/debug/info/warn/error/critical)随后把 loc_ 转为 spdlog 的 source_loc 一并输出。调用侧因而极其干净:
Logger().warn("Gallery startup initialization crashed: {}", e.what());Source: gallery.cpp
Logger 是无状态值类型,每次 Logger() 构造即用即弃,无生命周期管理负担;spdlog::format_string_t<Args...> 保证格式串在编译期检查,{} 参数错配在编译期报错而非运行期崩溃。
端到端核心流程
崩溃时序(SEH 路径)
正常诊断链路(日志 + 转储协同)
存储布局(诊断产物数据模型)
两类产物同根于 logs 目录:app.log 由 spdlog 轮转管理;dumps/ 每次崩溃时按需创建(EnsureDirectoryExists),dmp 文件不可变、按次累积,需运维/用户定期清理。同处一目录的收益:用户上报问题时只需打包一个 logs 文件夹,即同时携带"崩溃前轨迹(日志)"与"崩溃现场(dump)"。
使用示例
示例 1:业务代码中的日常日志(格式化 + 调用点自动捕获)
} catch (const std::exception& e) {
Logger().warn("Gallery startup initialization crashed: {}", e.what());
} catch (...) {Source: gallery.cpp
不需要任何宏或额外参数——Logger() 构造时已冻结调用者文件/行号/函数名,{} 占位符由 spdlog 在编译期校验。
示例 2:初始化日志并应用用户配置的级别
1auto initialize(const std::optional<std::string>& configured_level)
2 -> std::expected<void, std::string> {
3 try {
4 auto logs_dir_result = utils::path::GetAppDataSubdirectory("logs");
5 if (!logs_dir_result) {
6 return std::unexpected("Failed to get log directory: " + logs_dir_result.error());
7 }
8
9 auto log_file_path = logs_dir_result.value() / "app.log";Source: logger.cpp
返回 std::expected<void, std::string>:目录解析失败时不抛异常,把错误字符串上交调用方决策(重试、降级运行或提示用户)。configured_level 来自设置子系统(用户可配置项),故以 std::optional + 空串双态表示"未配置"。
示例 3:手动写转储(不依赖崩溃触发)
// 手动写入转储(exception_pointers 可传 nullptr)
auto write_dump(void* exception_pointers, std::string_view reason)
-> std::expected<std::filesystem::path, std::string>;Source: crash_dump.hpp
write_dump 是公开的底层入口,可用于"怀疑即将出错"的现场主动快照;成功时返回落盘的 .dmp 路径,失败时返回错误串(重入中 / 目录创建失败 / CreateFileW 失败 / MiniDumpWriteDump 失败四类)。
示例 4:运行时动态调整日志级别
1auto set_level(std::string_view level) -> std::expected<void, std::string> {
2 auto logger = spdlog::default_logger();
3 if (!logger) {
4 return std::unexpected("Logger is not initialized");
5 }
6
7 auto parse_result = detail::parse_level(level);
8 if (!parse_result) {
9 return std::unexpected(parse_result.error());
10 }
11
12 logger->set_level(parse_result.value());
13 logger->flush();
14 return {};
15}Source: logger.cpp
与初始化的宽容策略不同:运行时调级失败必须返回错误(例如设置界面提交了非法值),调用方可据此向用户回显;成功后立即 flush() 保证后续日志立即按新级别落盘。
配置选项
崩溃转储(无运行时配置,全部编译期常量)
| 项 | 类型 | 值 | 说明 |
|---|---|---|---|
| dump 目录 | std::filesystem::path | %APPDATA% .../logs/dumps | 每次 write_dump 前 EnsureDirectoryExists 按需创建 |
kDefaultDumpType | MINIDUMP_TYPE | ThreadInfo | UnloadedModules | IndirectlyReferencedMemory | 编译期 constexpr,见 crash_dump.cpp L19-L20 |
| 文件名模式 | — | crash_{ts}_pid{pid}_tid{tid}_{reason}_0x{code}.dmp | 见 crash_dump.cpp L68-L70 |
| 原因净化规则 | — | 非字母数字 → _ | sanitize_reason,空串回退为 unknown |
| 转储失败输出通道 | — | OutputDebugStringA | 崩溃兜底,不依赖 spdlog |
日志
| 选项 | 类型 | 默认 | 说明 |
|---|---|---|---|
configured_level(initialize 参数) | std::optional<std::string> | nullopt | 合法值:TRACE/DEBUG/INFO/WARN(WARNING)/ERROR(ERR)/CRITICAL/OFF,大小写与空白不敏感;非法值回退默认并 warn 留痕 |
| 默认级别 | spdlog::level::level_enum | debug 构建 trace / release 构建 info | detail::default_level() |
| 日志文件 | path | logs/app.log | 固定,不可配置 |
| 轮转策略 | size×count | 5 MB × 3 个副本 | 编译于 sink 构造参数 |
| sink 集合 | — | msvc_sink_mt + rotating_file_sink_mt | 调试器输出 + 文件 |
| logger 名 | string | "spinning_momo" | 同时 register_logger + set_default_logger |
| flush 策略 | — | flush_on(trace)(每条即刷)+ shutdown()/flush() 显式冲刷 | 可靠性优先 |
| 格式模式 | string | %Y-%m-%d %H:%M:%S.%e [%l] [%g:%#] %v(debug)/ %s:%#(release) | 见 logger.cpp L123-L125 |
API 参考
utils::crash_dump::install(): void
安装全局崩溃处理器(SEH 过滤器 + terminate 处理器)。幂等:首次调用生效,后续调用直接返回(atomic_bool exchange 保护)。进程生命期内应尽早调用且无需卸载。
utils::crash_dump::write_dump(void* exception_pointers, std::string_view reason): std::expected<std::filesystem::path, std::string>
写入一份 MiniDump 快照。
参数:
exception_pointers(void*,实际为EXCEPTION_POINTERS*):异常上下文;传nullptr则 dump 不含异常记录信息。reason(std::string_view):写进文件名的原因标签,会被sanitize_reason净化。
返回: 成功返回生成的 .dmp 绝对路径;失败返回错误描述(可能值:"Dump writing is already in progress"、"Failed to get logs directory: ..."、"Failed to create dump directory: ..."、"CreateFileW failed with error: N"、"MiniDumpWriteDump failed with error: N")。
并发语义: 以 std::atomic_flag 实现单飞(single-flight);并发第二次调用立即失败返回 "Dump writing is already in progress",不阻塞等待。
utils::logging::initialize(const std::optional<std::string>& configured_level = std::nullopt): std::expected<void, std::string>
构建双 sink、命名 logger "spinning_momo"、解析应用配置级别并注册为 spdlog 默认 logger。
参数: configured_level — 用户配置的级别串;nullopt 或空串表示使用构建类型默认级别。
返回: 成功为空 expected;失败返回 "Failed to get log directory: ..." 或 "Log initialization failed: ..."(捕获 spdlog::spdlog_ex)。
副作用: 非法级别不失败,仅以 warn 级别写一条 "..., fallback to default level" 警告。
utils::logging::set_level(std::string_view level): std::expected<void, std::string>
运行时调整默认 logger 级别并立即冲刷。失败返回 "Logger is not initialized" 或 "Unsupported logger level: ...";无宽容回退(与初始化语义相反,见上文示例 4 说明)。
utils::logging::flush(): void / utils::logging::shutdown(): void
flush() 冲刷当前默认 logger;shutdown() 冲刷后调用 spdlog::shutdown(),应在进程退出前调用一次,之后不可再记日志。
class Logger(utils::logging 命名空间外,头文件同处)
构造时以默认实参 std::source_location::current() 冻结调用点;提供 trace/debug/info/warn/error/critical 六个级别方法,各有格式化模板版(头文件内联)与 std::string_view 简单串版(logger.cpp L64-L101 实现于 Logger:: 成员),全部路由至 spdlog::default_logger()->log(...) 并附带源位置。
失败模式、边界与并发
崩溃转储的失败模式
| 场景 | 行为 | 兜底 |
|---|---|---|
| dump 写入中途再次异常(重入) | atomic_flag 拦截,直接返回 unexpected("Dump writing is already in progress") | 原始流程继续 |
| logs/dumps 目录无法创建 | make_dump_dir 返回 unexpected("Failed to create dump directory: ...") | OutputDebugStringA 留痕 |
CreateFileW 失败(磁盘满/权限) | 返回 unexpected("CreateFileW failed with error: N"),GetLastError 编码入串 | 同上 |
MiniDumpWriteDump 失败 | 先取 last_error 再 CloseHandle(顺序正确,避免被覆盖)再返回错误 | 同上 |
| terminate 路径无异常上下文 | exception 判空,MiniDumpWriteDump 第 5 参数传 nullptr | dump 仍生成,仅缺异常记录 |
| SEH 过滤器与 terminate 先后触发 | 由 g_installed 与 g_dump_writing 双重原子状态守护 | 最多产生两份独立 dump 文件 |
日志的失败模式与边界
- 初始化失败即失败:目录解析或 spdlog 抛异常(
spdlog_ex)时返回错误串,由调用方决定是否继续启动——与非法级别"宽容回退"形成对照,因为日志文件不可写意味着诊断能力整体缺失。 app.log并发写入:rotating_file_sink_mt与msvc_sink_mt均为多线程安全(_mt后缀),spdlog 内部互斥保证。- flush 语义:
flush_on(trace)让每条日志立即落盘;即便如此,进程被强杀(如电源断电)仍可能丢失极少量尾部日志——这是与"必须有 dump 兜底"的互补关系。 Logger值语义边界:Logger持有std::source_location副本(std::move入成员),可拷贝/传递,但其记录的位置始终是最初构造点,转发Logger对象不会重新定位。
运维要点
- dmp 累积无清理机制:源码中未见转储保留上限,
dumps/会随崩溃次数无界增长,运维需关注磁盘占用。 - 日志体积上界确定:轮转 5 MB × 3 副本 ≈ 20 MB,可预估。
- 上报打包:
logs/目录整体打包即覆盖两类诊断产物。
扩展点
- 新增 sink(如网络上报、Windows 事件日志):在
initialize()的sinks向量追加spdlog::sink_ptr即可,其余逻辑(级别、格式、默认 logger 装配)零改动。 - 调级入口:
utils::logging::set_level已是现成的运行时钩子,设置界面或命令行均可直接调用,见上文示例 4。 - dump 类型升级:
kDefaultDumpType是单一constexpr常量,如需"完整内存模式"可将其改为可配置项,但需评估文件体积影响。 - 主动快照:
utils::crash_dump::write_dump是公开 API,任何"低置信状态"下的现场保存场景均可复用,无需等待真实崩溃。
测试
本页成文时未在仓库中检索到针对 utils::crash_dump 或 utils::logging 的独立单元测试文件(源工具预算内未发现 tests/ 下相关用例)。相关行为保障主要依赖:类型系统(std::expected 强制调用方处理错误)、原子原语(重入/幂等的正确性由标准语义保证)以及构建配置(core::build_config::is_debug_build())驱动的双模式默认值。若后续补充测试,建议覆盖:非法级别回退、并发 write_dump 单飞、terminate 路径 nullptr 降级三类场景。
相关链接
- crash_dump.cpp / crash_dump.hpp — 崩溃转储完整实现与契约
- logger.cpp / logger.hpp — 日志系统实现与
Logger类 - main.cpp — 诊断装配入口(崩溃转储最早安装)
- 设置子系统(日志级别用户配置的呈现层)与
utils::path(AppData 目录解析)属于兄弟页面主题,本页仅引用其契约。