Repository Wiki
ChanIok/SpinningMomo

日志、崩溃转储与运行时诊断

本项目(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 等其他系统查询工具亦不在本页范围。本页仅在诊断机制引用这些能力时说明其契约。

概述

设计目标

桌面应用崩溃现场往往转瞬即逝:进程终止后无法附加调试器,"用户说不清、复现不了"是排障常态。本项目通过两层机制解决该问题:

  1. 崩溃转储(crash dump):在进程即将死亡的瞬间,用 Windows DbgHelp API 把进程的线程状态、模块列表与关键内存快照写入 .dmp 文件,供事后用 Visual Studio / WinDbg 打开还原完整调用栈。
  2. 结构化日志(logging):全程以带源文件位置、级别、毫秒级时间戳的格式记录运行轨迹,配合崩溃转储还原"崩溃前发生了什么"。

关键概念

概念含义
SEHWindows 结构化异常处理;SetUnhandledExceptionFilter 注册的过滤器在无任何处理器的异常到达时被调用
std::terminateC++ 运行时终止路径(未捕获 C++ 异常、noexcept 违约等),通过 std::set_terminate 接管
MiniDumpWindows 缩减版进程快照格式;dump type 标志决定快照包含哪些信息
std::source_locationC++20 编译期调用点信息(文件/行号/函数名),Logger 借此免去日志宏
std::expected<T, E>错误码式返回,本项目诊断 API 全部以它替代异常跨边界传播
双 sink同一条日志同时写入调试器输出(msvc_sink_mt)与轮转文件(rotating_file_sink_mt)

架构

Loading diagram...

组件职责解读

  • 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. 启动装配顺序(入口控制流)

cpp
#include "utils/crash_dump/crash_dump.hpp" #include "utils/logger/logger.hpp"

在 src/main.cpp 中,诊断机制的装配遵循固定顺序:

cpp
// 尽早安装崩溃转储处理器 utils::crash_dump::install();

Source: main.cpp

设计意图(WHY):崩溃转储必须最早安装。若日志初始化本身触发崩溃(例如 spdlog 抛异常、磁盘 IO 失败),此时唯一的"黑匣子"就是转储处理器;反之若先初始化日志,安装转储处理器之前的任何异常都会无迹可寻。转储路径刻意不依赖 spdlog,正是为了在"日志系统可能已死"的场景下依然能工作。

2. 崩溃转储:双路捕获与写入算法

install() 的幂等实现与两条捕获路径的注册:

cpp
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(重入保护 + 落盘):

cpp
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:

cpp
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 的调用与异常信息透传:

cpp
BOOL ok = MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), dump_file, kDefaultDumpType, exception ? &exception_info : nullptr, nullptr, nullptr);

Source: crash_dump.cpp

Dump 类型选择(设计权衡):kDefaultDumpType 组合了三个标志:

cpp
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 并设为默认:

cpp
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)。

级别解析采用"先规范化再匹配"的两段式,并带内置别名:

cpp
if (normalized == "WARN" || normalized == "WARNING") { return spdlog::level::warn; }

Source: logger.cpp

normalize_level_string 去除全部空白并统一大写,因此 "warn"、" Warning " 均合法。失败模式:非法级别字符串在初始化时不会令初始化失败,而是记录一条警告并回退默认级别:

cpp
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 类:免宏的调用点捕获

cpp
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 一并输出。调用侧因而极其干净:

cpp
Logger().warn("Gallery startup initialization crashed: {}", e.what());

Source: gallery.cpp

Logger 是无状态值类型,每次 Logger() 构造即用即弃,无生命周期管理负担;spdlog::format_string_t<Args...> 保证格式串在编译期检查,{} 参数错配在编译期报错而非运行期崩溃。

端到端核心流程

崩溃时序(SEH 路径)

Loading diagram...

正常诊断链路(日志 + 转储协同)

Loading diagram...

存储布局(诊断产物数据模型)

Loading diagram...

两类产物同根于 logs 目录:app.log 由 spdlog 轮转管理;dumps/ 每次崩溃时按需创建(EnsureDirectoryExists),dmp 文件不可变、按次累积,需运维/用户定期清理。同处一目录的收益:用户上报问题时只需打包一个 logs 文件夹,即同时携带"崩溃前轨迹(日志)"与"崩溃现场(dump)"。

使用示例

示例 1:业务代码中的日常日志(格式化 + 调用点自动捕获)

cpp
} catch (const std::exception& e) { Logger().warn("Gallery startup initialization crashed: {}", e.what()); } catch (...) {

Source: gallery.cpp

不需要任何宏或额外参数——Logger() 构造时已冻结调用者文件/行号/函数名,{} 占位符由 spdlog 在编译期校验。

示例 2:初始化日志并应用用户配置的级别

cpp
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:手动写转储(不依赖崩溃触发)

cpp
// 手动写入转储(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:运行时动态调整日志级别

cpp
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 按需创建
kDefaultDumpTypeMINIDUMP_TYPEThreadInfo | 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_enumdebug 构建 trace / release 构建 infodetail::default_level()
日志文件pathlogs/app.log固定,不可配置
轮转策略size×count5 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 参数传 nullptrdump 仍生成,仅缺异常记录
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 目录解析)属于兄弟页面主题,本页仅引用其契约。

Sources

(4 files)
src/utils/crash_dump
src/utils/logger