引用本文(DOI: 10.5281/zenodo.21615508)
本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。
小村 豪(2026)。《Windows 应用崩溃时保留日志与转储的设计》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615508 https://comcomponent.com/zh-CN/blog/2026/03/19/000-windows-app-crash-logging-best-practices/
- DOI(最新版本)
- 10.5281/zenodo.21615508
- DOI(此版本)
- 10.5281/zenodo.22282034
Windows 应用问题排查中最痛苦的,就是明明知道程序崩溃了,却没有留下崩溃原因的状态。
尤其是以下这类项目,这个问题会格外严重:
- 只在客户环境中崩溃
- 只在长时间运行之后才崩溃
- WPF / WinForms / Windows 服务 / 常驻应用中,复现率很低
- 涉及 COM、P/Invoke、native DLL、第三方 SDK
- 只拿到了“异常消息”,却没有崩溃前的上下文
不过,首先要诚实地说一句:仅靠崩溃侧的进程,不可能“百分之百”留下日志。 考虑到栈损坏、内存破坏、fast fail、强制终止乃至断电,进程内最后一条日志本质上只是 best effort(尽力而为)。
flowchart TB
accTitle: 进程内最后一条日志只能是 best effort
accDescr: 考虑到栈损坏、内存破坏、fast fail、强制终止乃至断电,仅靠崩溃侧的进程无法确保留下日志,进程内最后一条日志本质上只能是 best effort 的示意图。
a1["栈损坏 / 内存破坏"] --> a4["进程内最后一条日志"]
a2["fast fail / 强制终止"] --> a4
a3["断电"] --> a4
a4 --> a5["本质上只是 best effort"]
a5 -.-> a6["做不到“一定留下”"]
图1:仅靠崩溃侧的进程,无法“百分之百”留下最后一条日志。
实务中真正应该追求的,是打造一套不完全依赖崩溃进程本身的结构。 也就是分成以下三层来考虑:
- 正常运行时的时间序列日志
- 崩溃瞬间的最终崩溃标记
- 由操作系统或另一个进程保留的崩溃证据
本文以 Windows 桌面应用、常驻应用、Windows 服务、设备联动工具为前提,整理即使因程序缺陷引发异常而崩溃,也不丢失可调查性的最佳实践。
1. 先说结论
先把结论整理出来。
- 最重要的是不要把“最后一条日志”押在单一的进程内处理程序上。
- 实务中最稳妥的组合是常规日志 + 最终崩溃标记 + WER LocalDumps。
- 如果是长时间运行、设备联动、插件、混用 native SDK 的场景,加入监控进程(watchdog / launcher / service) 会大幅增强可靠性。
- 崩溃处理程序的铁律是不要做繁重的处理。压缩、HTTP 发送、DI 解析、UI 对话框、复杂 JSON 生成都应排除在外。
- 崩溃时只在本地简短地留下记录,压缩、上传、通知放到下次启动后或另一个进程中处理。
- 使用 WinForms 的
ThreadException或 WPF 的DispatcherUnhandledException让程序表面上继续运行的设计,在面对程序缺陷时是危险的。 - 无论是 .NET 还是 native,怀疑状态已损坏的异常,都应以“记录后结束”为基本方针,而不是“恢复”。
- 要采集转储的话,PDB 和发布二进制文件的保存必须同时进行,否则事后无法解读。
归纳来说,最佳实践就是 “不要试图在崩溃的那一瞬间把所有事都做完,而是在崩溃之前、崩溃瞬间、崩溃之后分别承担各自的职责”。
flowchart TB
accTitle: 崩溃之前、瞬间、之后的职责分担
accDescr: 不要试图在崩溃瞬间把所有事都做完,而是由崩溃之前的常规日志、崩溃瞬间的简短本地写入、崩溃之后的压缩上传通知来分担职责的最佳实践示意图。
b1["崩溃之前:保留常规日志"] --> b2["崩溃瞬间:只在本地简短写入"]
b2 --> b3["崩溃之后:压缩、上传、通知"]
b3 -.-> b4["放到下次启动后或另一个进程中做"]
图2:不要在崩溃瞬间把所有事都做完,而是在之前、瞬间、之后这三个时点分担职责。
1.1 本文使用的术语
先把后文中不再解释就直接使用的词汇列出来。
| 术语 | 全称 / 读法 | 含义 |
|---|---|---|
| WER | Windows Error Reporting,Windows 错误报告 | 由操作系统侧捕获并记录应用异常终止的 Windows 机制。把转储保留在本机的配置就是 LocalDumps |
| 转储 / minidump | crash dump | 保存崩溃瞬间进程内存内容的文件。事后可以查看线程、调用栈和模块 |
| PDB | Program Database | 构建时生成的符号文件。没有它,即使打开转储也看不到函数名和行号 |
| in-process | 进程内 | 在崩溃的那个进程自身内部进行处理。反义词是另一个进程 |
| best effort | 尽力而为 | “顺利的话能留下,但不做保证”的性质。崩溃瞬间的进程内日志就是这种性质 |
fast fail / __fastfail |
快速失败终止 | 判断状态已损坏时,不做收尾、以最少步骤立即终止的机制。native 侧对应 __fastfail,.NET 侧对应 Environment.FailFast |
| watchdog | 监控进程 | 从外部监视本体进程的启动、结束与存活状态的另一个进程。也可以做成 launcher 或父服务 |
| heartbeat | 存活信号 | 定期告知 watchdog“我还在运行”的信号 |
| UNC 路径 | Universal Naming Convention | \\server\share\... 这种形式的网络共享路径。崩溃时使用它,会因瞬断或凭据问题而卡住 |
| ACL | Access Control List,访问控制列表 | 规定谁能对该文件夹读写的配置。是转储和日志“空转”的常见原因 |
| SEH | Structured Exception Handling,结构化异常处理 | Windows 的原生异常机制。SetUnhandledExceptionFilter 就建立在它之上 |
| CRT | C Runtime,C 运行时 | C / C++ 标准库的实现。它有独立于 SEH 的终止路径 |
| session | 会话 ID | 用来识别“说的是哪一次启动实例”的值。是把日志、转储、watchdog 记录相互对照的钥匙 |
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 24 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
2. 为什么仅靠进程内处理无法做到“确保”
如果这一点含糊不清,整体设计就会摇摆不定。
2.1 崩溃线程的上下文本身可能已经损坏
未处理异常的钩子或顶层异常过滤器,有可能运行在已经损坏的线程上下文中。 这个时候,可能出现以下情况:
- 栈已经很危险
- 堆已损坏,再分配内存也很危险
- 因为异常发生时持有的锁而导致等待,进而卡死
- logger 自身依赖的对象已经损坏
这些情况相当常见。
因此,最后的处理程序不应被当作“什么都能做的地方”,而应视为“能做的事情非常少的地方”,这样更安全。
flowchart TB
accTitle: 最后的处理程序能做的事情很少
accDescr: 未处理异常的钩子可能运行在已损坏的线程上下文中,栈和堆都很危险,可能因等待锁而卡死,logger 的依赖也可能已损坏,因此应把它视为能做的事情非常少的地方的示意图。
c1["运行在已损坏的线程上下文中"] --> c2["栈和堆都很危险"]
c1 --> c3["可能因等待锁而卡死"]
c1 --> c4["logger 的依赖也可能已损坏"]
c2 --> c5["能做的事情非常少的地方"]
c3 --> c5
c4 --> c5
图3:最后的处理程序不是“什么都能做的地方”,而是处处受限的地方。
2.2 fast fail 和状态损坏类异常,前提是“最小限度的进程内动作”
在内存损坏或致命状态下,最好不要指望常规的异常处理能正常工作。
尤其是 native 侧的 __fastfail 系列,以及怀疑状态已损坏的异常,其设计方向是尽可能以最小开销立即终止。
也就是说,比较自然的想法是:能写下最后一条进程内日志算是幸运,主要证据应交给 OS / 另一个进程。
2.3 .NET 的未处理异常事件也不是“繁重恢复处理”的场所
.NET 的 AppDomain.UnhandledException 很方便,但
这里能做的最好也只限于简短的记录。
- 会受到异常发生时所持有的锁的影响
- 并非所有状态损坏类异常都能安全地被捕获
- 如果在这里强行构建继续运行的方案,很容易在半损坏状态下“延命”
比较现实的理解是:“未处理异常事件 = 最后的通知”,而不是“安全的恢复地点”。
flowchart TB
accTitle: 未处理异常事件是最后的通知
accDescr: 在 AppDomain.UnhandledException 中能做的只限于简短记录,并非所有状态损坏类异常都能安全捕获,未处理异常事件是最后的通知而不是安全的恢复地点的示意图。
d1["未处理异常事件"] --> d2["能做的只限于简短记录"]
d2 --> d3["当作最后的通知来使用"]
d1 -.-> d4["不是安全的恢复地点"]
d4 -.-> d5["强行构建继续方案会在半损坏状态下延命"]
图4:未处理异常事件是记录的入口,而不是恢复的场所。
3. 推荐架构 - 区分崩溃时(crash-time)与重启后(after-restart)
最容易梳理清楚的方法,是把崩溃时要做的事和重启后要做的事分开。
先用一张图说明三层证据各自由哪个进程负责、最终落到哪里。
flowchart TD
subgraph APP["应用进程"]
L1["常规日志<br/>append-only 的时间序列"]
L2["最终崩溃标记<br/>只写一行就结束"]
end
subgraph WIN["Windows 侧"]
WER["WER LocalDumps<br/>在进程外保存转储"]
end
subgraph WD["watchdog 进程"]
EX["记录 exit code 与结束时刻<br/>判断是否重启"]
end
subgraph NEXT["下次启动的健康进程"]
POST["压缩 / 上传 / 通知<br/>检测上次异常终止"]
end
DISK[("本地的固定文件夹")]
L1 --> DISK
L2 --> DISK
WER --> DISK
EX --> DISK
DISK --> POST
L1 -. 发生异常 .-> L2
L2 -. 进程结束 .-> WER
L2 -. 进程结束 .-> EX
图5:三层证据的全貌。崩溃进程自己写的只有两项,主要证据在进程之外。
要点有 3 个。
- 崩溃进程自己写的,只有“应用进程”框内的那两项。而且最终崩溃标记也只写到“写一行就结束”为止。
- 主要证据在进程之外。WER 的转储和 watchdog 的记录,即使应用已经损坏也能留下来。
- 相互对照的钥匙,是共用的 session ID 与 PID。这里对不上,三份证据就会看起来像三件互不相关的事。
| 阶段 | 目的 | 在哪里运行 | 要做的事 |
|---|---|---|---|
| 正常运行时 | 保留时间序列 | 应用内 | 结构化日志、心跳、边界事件 |
| 崩溃时 | 落下最低限度的证据 | 应用内 + OS | 最终崩溃标记、WER 转储 |
| 结束后瞬间 | 检测异常退出 | 另一个进程 | 记录 exit code、判断是否重启、通知 |
| 下次启动后 | 处理繁重的后续工作 | 全新的健康进程 | 压缩、上传、用户通知、清理旧日志 |
按这种方式划分,整体设计会相当稳定。
3.1 最小配置
小型业务工具或内部使用的 WPF / WinForms 应用,很多情况下这种程度就够了:
- 常规日志:本地的 append-only 文件
- 最终崩溃标记:专用的简短文件
- 转储:WER LocalDumps
- 下次启动时:显示“上次异常终止,已保存诊断信息”
3.2 加强版配置
在以下场景中,建议再加强一个层级:
- 24/7 运行
- 设备控制、监控、常驻
- 大量使用 COM / P/Invoke / native SDK
- 存在子进程、插件、脚本执行
- 客户环境不允许“一直停摆”
这种情况下,可以拆分为:
- worker 进程:本体处理
- launcher / watchdog / service:启动监控、记录退出、重启
- WER LocalDumps:应用于 worker 侧
- 下次启动或 watchdog:回收诊断信息
这样划分会相当贴合实务需求。
flowchart TB
accTitle: 加强版配置的职责分担
accDescr: 在 24 小时运行或设备控制场景下,把本体处理交给 worker 进程,把启动监控、记录退出和重启交给 launcher 或 watchdog,WER LocalDumps 配置在 worker 侧,由下次启动或 watchdog 回收诊断信息的配置示意图。
e0["加强版要求(24/7、设备控制等)"] --> e1["worker:本体处理"]
e0 --> e2["watchdog:启动监控、记录退出、重启"]
e1 -.-> e3["WER LocalDumps 配置在 worker 侧"]
e2 -.-> e4["下次启动或 watchdog 回收诊断信息"]
图6:加强版配置把本体与监控拆成不同进程,固定各自的职责。
4. 常规日志的最佳实践
如果只靠崩溃时的最后一行来“战斗”,通常都会输。 真正起作用的是崩溃之前的常规日志。
4.1 日志应重视“事后可关联的信息”,而不是“给人看的文章”
常规日志中至少应该包含以下项目:
- UTC 时间戳
- 自进程启动以来经过的时间
- PID / TID
- 应用名、版本、构建号、commit 标识
- 会话 ID
- 操作 ID / 任务 ID / 关联 ID
- 模块名 / 界面名 / worker 名
- 之前的外部影响
- 文件写入
- 数据库更新
- 设备命令发送
- 通信请求
- 异常类型、HRESULT / Win32 错误码 / 异常代码
- 主要输入参数的摘要
- 不含敏感信息范围内的对象 ID
推荐使用每行一个事件的 JSON Lines 或 key=value 格式。
如果用 JSON Lines,一个事件大致是这样的粒度:
{"ts":"2026-03-18T01:15:33.412Z","up_ms":184213,"pid":1234,"tid":9,"app":"MyApp","ver":"3.2.1.884","commit":"9f1c2ab","session":"4f1c","level":"Warning","module":"DeviceWorker","op":"JOB-20260318-0042","event":"ExternalCommandSent","target":"COM3","cmd":"SEQ_START","timeout_ms":3000}
虽然很长,但只看这一行就能知道“什么时候、哪一次启动实例、哪个版本、在哪个操作的中途、做了什么”。它通过 pid 和 session 与转储侧相连,通过 ver 和 commit 与构建侧相连。
如果改成 key=value 格式,同样的内容是这样:
ts=2026-03-18T01:15:33.412Z up_ms=184213 pid=1234 tid=9 app=MyApp ver=3.2.1.884 commit=9f1c2ab session=4f1c level=Warning module=DeviceWorker op=JOB-20260318-0042 event=ExternalCommandSent target=COM3 cmd=SEQ_START timeout_ms=3000
与写给人看的长文相比, “事后能对照多个文件”这一点更重要。
flowchart TB
accTitle: 一行日志把三份证据串起来
accDescr: 常规日志的一行通过 pid 与 session 连到转储侧,通过 ver 与 commit 连到构建侧,事后能对照三个文件这一点比写给人看的长文更重要的示意图。
f1["常规日志的一行"] --> f2["通过 pid / session 连到转储侧"]
f1 --> f3["通过 ver / commit 连到构建侧"]
f2 --> f4["事后能对照三个文件"]
f3 --> f4
图7:一行日志通过 pid、session、ver、commit 与其他证据相连。
4.2 关键事件要同步落地
如果把常规日志全部改成同步写入,会变重。 但如果全部依赖异步缓冲,一旦崩溃,缓冲内容会一并消失。
因此实务中比较现实的做法,是按级别区分处理方式。
Information级别的琐碎事件:可以缓冲Warning及以上:尽早 flush- 重要的边界事件:同步落地
- ProcessStart
- ConfigLoaded
- WorkerStarted
- ExternalCommandSent
- TransactionCommitted
- RecoveryStarted
- FatalPathEntered
简单说,就是至少把业务上的边界事件,扎扎实实地落到磁盘上。
flowchart TB
accTitle: 按级别区分写入方式
accDescr: Information 级别的琐碎事件可以缓冲,Warning 及以上要尽早 flush,业务上的边界事件要同步落地,按级别区分处理方式的示意图。
g0["日志写入"] --> g1["Information:可以缓冲"]
g0 --> g2["Warning 及以上:尽早 flush"]
g0 --> g3["边界事件:同步落地"]
g3 -.-> g4["至少把业务边界扎实落到磁盘"]
图8:既不是全部同步也不是全部缓冲,而是按级别区分写入方式。
4.3 把“当前正在写的常规日志”和“最后的崩溃标记”分开
这一点相当重要。
如果试图把所有内容都塞进一个 rolling log,就会遇到:
- 正在轮转(rotation)
- 还堆在异步队列里
- 异常发生后 logger 自身立刻挂了
- 日志行写到一半就被截断
这些情况都会发生。
因此,建议至少拆成两个文件:
app-<session>.jsonl常规时间序列日志fatal-last.log或fatal-<session>.log专用于最终崩溃标记
明确“最后一行应该写在哪里”,光是这一点,现场排查就能轻松很多。
flowchart TB
accTitle: 把常规日志与 fatal 标记分开
accDescr: 如果把所有内容塞进一个 rolling log,会因为正在轮转、异步队列残留、logger 自身挂掉而丢失最后一行,因此要拆成常规时间序列日志和最终崩溃标记专用文件两个文件的示意图。
h1["把所有内容塞进一个 rolling log"] --> h2["轮转中、队列残留、logger 挂掉都会丢失"]
h2 -.->|"改为"| h3["拆成常规日志和 fatal 标记两个文件"]
h3 --> h4["最后一行的落点变得明确"]
图9:最后一行的落点,要固定在与常规日志不同的文件里。
4.4 日志保存路径固定在本地,不使用网络路径
崩溃时依赖 UNC 路径、NAS、HTTP、云 API 是很危险的。
原因包括:
- 网络瞬断
- DNS 延迟
- 凭据失效
- 在 UI 线程上等待
- 服务账户权限不足
崩溃时首先落到本地固定路径。 发送则放到下次启动后或另一个进程中处理。
4.5 文件名中要包含 session
只有日期是不够的。 因为同一天可能会多次重启。
例如可以是这样的形式:
Logs\
MyApp_20260318_101530_pid1234_session-4f1c.jsonl
MyApp_fatal_20260318_101533_pid1234_session-4f1c.log
MyApp_watchdog_20260318.jsonl
只要“这是哪一次启动实例的记录”足够明确,分析速度就会有很大差别。
5. 最终崩溃标记的最佳实践
这里不是打造功能齐全的 logger 的地方。 而是只写一次、写得简短、尽量确保能落地的地方。
5.1 目的是“固定入口”,而不是“原因的细节”
最终崩溃标记中应该包含的信息,越精简越有力。
- 发生时刻(UTC)
- PID / TID
- 会话 ID
- 版本 / 构建号
- 来自哪个钩子
AppDomain.UnhandledExceptionApplication.ThreadExceptionDispatcherUnhandledExceptionSetUnhandledExceptionFilter_set_invalid_parameter_handlerset_terminate
- 异常类型或异常代码
- 可能的话,简单的消息
- 之前的操作 ID
- 常规日志的文件名
- 预期的转储文件夹
有这些就足够了。
flowchart TB
accTitle: 标记的目的是固定入口
accDescr: 最终崩溃标记不是为了记录原因的细节,而是为了固定调查入口,把来自哪个钩子、哪个异常,以及常规日志文件名和预期转储文件夹这些信息精简保留的示意图。
i1["最终崩溃标记"] --> i2["哪个钩子 / 异常类型 / session"]
i1 --> i3["常规日志名与 dump 文件夹"]
i2 --> i4["调查入口被固定下来"]
i3 --> i4
i4 -.-> i5["原因的细节交给转储和常规日志"]
图10:标记的职责不是记录原因细节,而是固定调查入口。
5.2 崩溃处理程序中不应该做的事
以下每一项都有相当高的概率成为地雷。
- 从 DI 容器解析 logger
- 使用 async / await
- 抛出 Task
- 等待锁
- 组装复杂的 JSON
- 操作 COM 对象
- 弹出 UI 对话框
- 压缩
- 发送 HTTP / SMTP / Slack / Teams 消息
- 解析并汇总 dump
- 吞掉异常继续运行
崩溃处理程序不是普通处理流程的延续。 应该定位为“只做最少限度的本地写入然后结束”。
5.3 崩溃处理程序应该做的事
反过来,应该做的事情非常简单。
- 防止重入
- 只写一行
- flush
- 结束
按这个顺序执行。
如果可能的话,最好使用:
- 提前创建好的专用文件夹
- 提前确认存在的路径
- 已确认 ACL 的保存位置
常规日志如果 flush 太频繁会变重,但fatal 标记的写入次数极少,所以这里可以强力 flush。
.NET 可以用 FileStream.Flush(true),native 可以用 FlushFileBuffers,把这一行当作“必须立刻落地”来处理,会更容易设计。
flowchart TB
accTitle: 崩溃处理程序的 4 个步骤
accDescr: 崩溃处理程序要做的只有防止重入、只写一行、flush、结束这 4 件事,而且 fatal 标记写入次数极少,所以可以强力 flush 直到落到磁盘的示意图。
j1["1. 防止重入"] --> j2["2. 只写一行"]
j2 --> j3["3. flush"]
j3 --> j4["4. 结束"]
j3 -.-> j5["这一行必须立刻落到磁盘"]
图11:处理程序只做这 4 个步骤,而且顺序不能打乱。
5.4 不要试图继续运行
对于由程序缺陷引起的意外异常,最好把最终处理程序视为记录装置,而不是恢复装置。
尤其应该以“不继续运行”为基本方针的情况,包括:
NullReferenceException或InvalidOperationException,但发生在共享状态更新过程中- UI 线程上的意外异常
- 从监控循环或父循环中漏出的意外异常
AccessViolationExceptionStackOverflowException- native 边界的异常
- CRT 的 invalid parameter / purecall / terminate
“不想让程序崩掉”的心情可以理解,但在半损坏状态下硬撑,往往会让排查和运维更痛苦。
结束进程时,.NET 可以考虑 Environment.FailFast,native 可以考虑 RaiseFailFastException 或 __fastfail 这类立即终止的 API,不要指望 finally 或常规的收尾处理,这样设计更安全。
flowchart TB
accTitle: 是记录装置而不是恢复装置
accDescr: 面对由程序缺陷引发的意外异常,应把最终处理程序视为记录装置而不是恢复装置,与其在半损坏状态下硬撑,不如记录后用立即终止的 API 结束进程更安全的示意图。
k1["程序缺陷引发的异常"] --> k2["在半损坏状态下硬撑"]
k1 --> k3["记录后结束"]
k2 -.-> k4["排查和运维都更痛苦"]
k3 --> k5["考虑 FailFast 等立即终止的 API"]
图12:最终处理程序不是恢复装置,而是记录后结束进程的装置。
5.5 C# 中的最小实现
把上面这些方针直接落到 .NET 6 及以上的 C# 里,大致就是这样的篇幅。既不用 DI,也不用 logger。
using System;
using System.IO;
using System.Text;
using System.Threading;
internal static class FatalMarker
{
// 使用提前创建好、并且已经做过写入测试的本地固定路径
private static readonly string LogDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MyApp", "Logs");
// 用于与常规日志、转储、watchdog 记录相互对照的钥匙
private static readonly string SessionId = Guid.NewGuid().ToString("N").Substring(0, 8);
// 防止重入的标志。为 0 表示尚未写入
private static int _written;
/// <summary>在应用启动后立即调用一次。</summary>
public static void Install()
{
// 崩溃时不希望再调用 CreateDirectory,所以在这里先创建好
Directory.CreateDirectory(LogDirectory);
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
}
/// <summary>在判断“不能再继续下去”的地方调用。</summary>
public static void FailNow(string reason)
{
Write("FailNow", null, reason);
Environment.FailFast(reason);
}
private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
Write("AppDomain.UnhandledException", e.ExceptionObject as Exception, null);
// 这里不结束进程。因为是未处理异常,之后 CLR 会进入默认的终止流程。
// 如果在这里调用 FailFast,WER 中留下的转储的原因
// 就会从原本的异常被替换成 FailFast。
}
private static void Write(string hook, Exception ex, string note)
{
// 第二次以后什么都不做
if (Interlocked.Exchange(ref _written, 1) != 0)
{
return;
}
try
{
string fileName = string.Format(
"MyApp_fatal_{0:yyyyMMdd_HHmmss}_pid{1}_session-{2}.log",
DateTime.UtcNow, Environment.ProcessId, SessionId);
string path = Path.Combine(LogDirectory, fileName);
// 不调用序列化器,只用字符串拼接组装出一行
string line = string.Join("\t",
"ts=" + DateTime.UtcNow.ToString("O"),
"pid=" + Environment.ProcessId,
"tid=" + Environment.CurrentManagedThreadId,
"session=" + SessionId,
"hook=" + hook,
"type=" + (ex != null ? ex.GetType().FullName : "none"),
"message=" + Flatten(ex != null ? ex.Message : note),
"log=" + LogDirectory);
using (var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
{
byte[] bytes = new UTF8Encoding(false).GetBytes(line + Environment.NewLine);
stream.Write(bytes, 0, bytes.Length);
// 传入 true,就会绕过操作系统缓存直接落到磁盘
stream.Flush(true);
}
}
catch
{
// 到这一步还失败,就没有别的办法了。吞掉并结束
}
}
private static string Flatten(string s)
{
return string.IsNullOrEmpty(s) ? string.Empty : s.Replace("\r", " ").Replace("\n", " ");
}
}
调用方只需要在启动后立即调用 Install()。忘了这一步,上面的代码一个字节都不会生效。
internal static class Program
{
private static void Main(string[] args)
{
FatalMarker.Install();
// 之后是常规的应用启动处理
RunApplication(args);
}
private static void RunApplication(string[] args)
{
// 例:判断共享状态已损坏时,不继续运行而是直接结束
// FatalMarker.FailNow("device state inconsistent");
}
}
这 60 行代码守住的,就是 5.3 中列出的那 4 件事。
- 用
Interlocked.Exchange防止重入 - 只用字符串拼接写一行
- 用
Flush(true)落到磁盘 - 不在
UnhandledException中继续运行
Environment.FailFast 会先把消息写入 Windows 的应用程序事件日志,然后立即终止进程,并把这些内容也包含到错误报告中。也就是说,FailFast 本身也多留下了一份证据。不过如前所述,在未处理异常的路径上调用它会改变转储的呈现方式,所以要选好调用的位置。
flowchart TB
accTitle: 选好调用 FailFast 的位置
accDescr: Environment.FailFast 会先写入应用程序事件日志再立即终止,从而多留下一份证据,但在未处理异常的路径上调用它会把 WER 中转储的原因从原本的异常替换成 FailFast,因此要选好调用位置的示意图。
l1["Environment.FailFast"] --> l2["写入事件日志后立即终止"]
l2 --> l3["证据多了一份"]
l1 -.-> l4["若在未处理异常的路径上调用"]
l4 -.-> l5["转储的原因会被替换成 FailFast"]
图13:FailFast 会增加证据,但在未处理异常中调用会导致原因被替换。
6. 各框架的注意事项
6.1 .NET 通用:AppDomain.CurrentDomain.UnhandledException
这是很有用的最后通知。 但这里应该避免繁重的恢复处理。
使用方式的基本原则很简单:
- 写最终崩溃标记
- 如有需要,向 Windows 事件日志写入最简消息
- 不继续运行
- 不在这里等待或重试
UnhandledException 很方便,但不应假设可以在这里把应用恢复到健康状态,这样更安全。
6.2 WinForms:Application.ThreadException
这个的难点在于,捕获 UI 线程的未处理异常后,可以表面上继续运行。
如果用来把业务录入的可预期错误做成对话框倒还说得过去,但 不适合用于由程序缺陷引发的意外异常场景下的“继续运行”。
如果真的要优先保障原因排查,建议:
- 在
ThreadException中只做最小限度的记录 - 或者转为
UnhandledExceptionMode.ThrowException - 之后结束进程,保留转储和日志
这样会更安全。
6.3 WPF:Application.DispatcherUnhandledException
WPF 也类似。
- 主要针对 UI 线程上的异常
- 把
Handled设为true可以表面上继续运行 - 但面对程序缺陷这么做,界面状态和内部状态很容易出现偏差
因此在 WPF 中,最好不要把它当作用于延命的继续运行装置,而是当作记录的入口。
flowchart TB
accTitle: 不要用 UI 事件来延命
accDescr: WinForms 的 ThreadException 和 WPF 的 DispatcherUnhandledException 可以让程序表面上继续运行,但面对程序缺陷会导致界面状态与内部状态偏差,因此应把它们当作记录的入口,然后结束进程保留转储和日志的示意图。
m1["UI 线程的未处理异常"] --> m2["表面上可以继续运行"]
m2 -.-> m3["界面与内部状态出现偏差"]
m2 --> m4["当作记录的入口来使用"]
m4 --> m5["结束进程并保留转储和日志"]
图14:WinForms / WPF 的 UI 事件不是延命装置,而是记录的入口。
6.4 不要把 TaskScheduler.UnobservedTaskException 当作主路径
这不是“崩溃前的最后一道防线”。
它可以用来辅助检测 Task 异常的遗漏,但
作为崩溃时确保记录的路径来说,力度不足。
因此,它适合用于:
- 早期发现异常观测遗漏
- 开发过程中揭示
Task设计上的疏漏
这类用途,但不应把它当作最终崩溃处理程序的主角。
6.5 native Win32 / C++:不要过度信任 SetUnhandledExceptionFilter
在 native 侧,很容易想依赖 SetUnhandledExceptionFilter。
但这运行在发生故障的线程上下文中,会受到:
- 无效的栈
- 深层递归
- 已损坏的堆
- 异常发生时持有的锁
这些因素的影响。
因此,SetUnhandledExceptionFilter 最好理解为
接收最后通知的、best effort 的入口。
6.6 native C++ 也要捕获 CRT 的终止路径
在 native C++ 中,如果只关注未处理的 SEH,就会有遗漏。
具体来说,应该关注:
_set_invalid_parameter_handler_set_purecall_handlerset_terminate
这一系列,是用来捕获C 运行时或 C++ 运行时引发的“终止路径”的。
实务中比较稳妥的做法是:
- 在这些处理程序中也写最终崩溃标记
- 但不做繁重的恢复处理
- 确保结束进程
- 主要证据交给 WER / dump
flowchart TB
accTitle: 只看 SEH 会有遗漏
accDescr: 在 native C++ 中只关注 SetUnhandledExceptionFilter 会漏掉 CRT 与 C++ 运行时引发的终止路径,因此也要在 invalid parameter、purecall、terminate 的处理程序中留下最小记录,主要证据交给 WER 与 dump 的示意图。
n1["只用 SetUnhandledExceptionFilter"] --> n2["漏掉 CRT 的终止路径"]
n2 --> n3["也捕获 invalid parameter / purecall / terminate"]
n3 --> n4["只写最小记录并确保结束进程"]
n4 -.-> n5["主要证据交给 WER / dump"]
图15:native C++ 只有在 SEH 之外也捕获 CRT 侧的终止路径,才算没有遗漏。
7. 以 WER LocalDumps 为基础
这一点在实务中相当有效。
7.1 首先推荐 WER LocalDumps
从“崩溃后尽量确保留下最低限度证据”这个角度来说, 首选还是最容易上手的 WER LocalDumps。
理由很简单:
- 可以由 OS 侧保留转储
- 无需额外工具就能接入
- 可按应用单独设置
- 能把崩溃时的主要证据保存到进程之外
仅靠日志无法看到:
- 哪个线程崩溃了
- 在哪个调用栈崩溃的
- 是哪个模块边界
- managed / native / COM / SDK 哪一部分可疑
事后能看到这些信息是它的优势所在。
flowchart TB
accTitle: WER LocalDumps 强在哪里
accDescr: WER LocalDumps 可以由 OS 侧保留转储并按应用单独配置,能把主要证据保存到进程之外,还能让人事后看到是哪个线程在哪个调用栈崩溃、处于哪个模块边界的示意图。
p1["WER LocalDumps"] --> p2["由 OS 侧保留转储"]
p1 --> p3["可按应用单独设置"]
p2 --> p4["把主要证据移到进程之外"]
p3 --> p4
p4 -.-> p5["能看到线程、调用栈、模块边界"]
图16:WER LocalDumps 是把主要证据移到崩溃进程之外的基础。
7.2 典型配置
例如,对于 MyApp.exe,如果想把转储保存到 C:\CrashDumps\MyApp,可以这样设置:
reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\MyApp.exe" /f
reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\MyApp.exe" /v DumpFolder /t REG_EXPAND_SZ /d "C:\CrashDumps\MyApp" /f
reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\MyApp.exe" /v DumpCount /t REG_DWORD /d 10 /f
reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\MyApp.exe" /v DumpType /t REG_DWORD /d 2 /f
一开始按这个程度取舍就足够了。
| 值 | 首选建议 |
|---|---|
DumpFolder |
专用文件夹 |
DumpCount |
5~10 |
DumpType |
开发机用 2,现场根据容量和保密要求选择 1 或 2 |
7.3 一定要确认转储保存路径的 ACL
日志和转储都是同样的道理,如果设置了写不进去的文件夹,就毫无意义。
尤其是:
- Windows 服务
- 权限隔离的子进程
- 现场机的受限账户
- 涉及 UAC 的情况
在这些场景中,保存路径的 ACL 是空转的主要原因。
保存路径最好确认到:
- 是否已提前创建
- 是否做过写入测试
- 是否有保留数量限制
- 运维人员能否访问到
flowchart TB
accTitle: 防止保存路径 ACL 导致空转
accDescr: 在 Windows 服务或受限账户下,把转储保存路径设置成写不进去的文件夹从而空转是典型失败,因此保存路径要提前创建,并做到写入测试、保留数量限制以及运维人员能否访问的确认的示意图。
q1["服务 / 受限账户"] --> q2["设置成了写不进去的文件夹"]
q2 --> q3["转储空转"]
q3 -.->|"要防止就得"| q4["提前创建并做写入测试"]
q4 --> q5["同时确认保留数量和能否访问"]
图17:转储空转的主要原因是保存路径 ACL,靠事前的写入测试来防止。
7.4 想在 WER 报告中附带当前日志时
如果使用面向 Microsoft 的 WER 报告或自建的 WER 机制,也可以用 WerRegisterFile 来注册当前日志文件,使其包含在错误报告中。
但这里最好理解为本地保存的补充手段,而不是替代方案。 因为崩溃时真正需要的,首先是尽量确保留在本机上。
顺序上,比较贴合实务的做法是:
- 本地常规日志
- 本地 fatal 标记
- 本地 dump
- 如有需要,再通过 WER 发送路径注册相关文件
7.5 不只保留转储,也要保留版本管理
即使采集了转储,如果事后发现:
- 找不到当时的 EXE / DLL
- 没有 PDB
- 不知道对应哪个 commit 构建
那这份转储的价值会大幅下降。
至少应该保留:
- 发布的二进制文件
- 对应的 PDB
- 版本号
- 构建时间
- commit 标识
- 安装包版本
转储采集和 PDB 保存是一套的。
flowchart TB
accTitle: 转储采集与 PDB 保存是一套的
accDescr: 即使采集了转储,如果没有留下当时的 EXE、DLL、PDB 以及对应哪个 commit 构建,事后也无法解读,因此要把发布二进制文件、PDB、版本号和 commit 标识的保存与转储采集配套进行的示意图。
r1["只保留转储"] --> r2["没有 PDB 和当时的二进制文件"]
r2 --> r3["事后无法解读"]
r3 -.->|"所以"| r4["保存发布二进制文件与 PDB"]
r4 --> r5["也要留下版本号与 commit 标识"]
图18:只有同一次构建的 PDB 和二进制文件还在,转储才读得懂。
7.6 从采集到的转储一直讲到第一次打开它
“转储攒了一堆,但谁都没打开过”的情况真的很常见。深入分析留给专门的文章,这里只放最初 10 分钟的内容。
准备工作有 2 项:
- 把与那份转储同一次构建的 PDB 和发布二进制文件收集到同一个文件夹
- 准备好 Windows SDK 的 Debugging Tools for Windows 中附带的 WinDbg
之后,在 WinDbg 中打开 .dmp,依次输入:
.sympath srv*C:\symbols*https://msdl.microsoft.com/download/symbols;C:\myapp\pdb
.reload /f
.ecxr
!analyze -v
~*k
lm v
它们各自的作用如下:
| 命令 | 作用是什么 |
|---|---|
.sympath |
设置符号的搜索位置。同时指定 Microsoft 的符号服务器和自己的 PDB 存放处 |
.reload /f |
强制重新加载符号 |
.ecxr |
切换到异常发生时的寄存器上下文。忘了这一步,看到的就不是崩溃位置,而是 WER 侧的等待栈 |
!analyze -v |
自动分析当前异常并详细显示。先从这里开始读 |
~*k |
输出所有线程的调用栈。可以看到崩溃线程以外的线程当时在做什么 |
lm v |
输出已加载的模块及其版本。用于与发布物和构建号相互对照 |
如果做到这一步还是“不显示函数名”“不显示行号”,那原因基本上是 PDB 来自不同的构建。请回到 7.5 的版本管理。
flowchart TB
accTitle: 打开转储的最初 10 分钟
accDescr: 先收集同一次构建的 PDB 和发布二进制文件,用 WinDbg 打开转储,切换到异常发生时的上下文再读分析结果,如果不显示函数名就说明 PDB 来自不同构建,需要回到版本管理的最初流程示意图。
s1["收集同一次构建的 PDB 和二进制文件"] --> s2["用 WinDbg 打开转储"]
s2 --> s3["切换到异常发生时的上下文再分析"]
s3 -.-> s4["忘了 .ecxr 就会看到 WER 侧的等待栈"]
s3 --> s5{"是否显示函数名和行号"}
s5 -->|"不显示"| s6["PDB 来自不同构建,回到版本管理"]
图19:转储分析按准备、打开、切换到异常上下文的顺序开始。
更深入的采集与分析内容,整理在文末的相关文章中。
8. 使用 MiniDumpWriteDump 或自建崩溃报告工具时的思路
有些场景需要自己实现相关功能。
- 想在 UI 上提供“保存诊断信息”按钮
- 想把日志和配置文件一起打包
- 想统一处理一组子进程
- 想在自动上传前加入自定义的信息脱敏
不过,这里最重要的是,不要把采集 dump 这项工作也全部压在崩溃侧的进程上。
8.1 self-dump 不如另一个进程
MiniDumpWriteDump 很强大,但
从另一个进程调用,比从崩溃的那个进程内部调用更安全。
典型的结构大致如下:
- worker 本体检测到异常
- 尽可能通过事件或命名管道通知 helper
- helper 采集 worker 的 dump
- helper 汇总
tail日志或配置文件 - helper 结束后将其放入上传队列
这样即使 worker 已经损坏,helper 侧依然是健康的。
sequenceDiagram
accTitle: 由另一个进程 helper 采集转储
accDescr: worker 本体检测到异常后通过事件或命名管道通知 helper,健康的 helper 采集 worker 的转储,汇总日志和配置文件,并在结束后放入上传队列的流程示意图。
participant W as worker 本体
participant H as helper
W->>H: 通过事件或管道通知异常
H->>W: 采集 worker 的 dump
H->>H: 汇总 tail 日志和配置文件
H->>H: 结束后放入上传队列
Note over H: 即使 worker 已损坏,helper 依然健康
图20:dump 的采集不交给崩溃侧,而是交给健康的 helper 进程。
8.2 如果无论如何都要放在进程内,就集中到专用线程
即使无法拆分到另一个进程, 把 dump 处理集中到专用线程也会稍微好一些。
但即便如此,本质上仍是 best effort。 “加入了自定义 dump 实现”并不代表可以百分之百放心。
8.3 繁重的工作放到下次启动之后
在自建报告工具中,容易想加入以下功能:
- zip 压缩
- 与 symbol 信息比对
- 上传到服务器
- 截屏
- 从数据库获取补充信息
这些都应该放到重启之后或 helper 侧,而不是崩溃当时。
9. 引入监控进程会带来什么变化
在长时间运行的系统中,监控进程会相当有效。
9.1 监控进程能保留的信息
在 watchdog / launcher / 父服务侧,可以保留以下信息:
- 子进程启动时间
- 启动参数
- PID
- 被监控对象的版本
- 心跳最后接收时间
- 结束时间
- exit code
- 重启次数
- 是否存在 dump
- 是否发生了重启
有了这些信息,就能大致看清:
- 是否真的崩溃了
- 是否是操作系统关机造成的
- 是否是用户主动关闭的
- 是否因为挂起而被 kill
- 重启循环发生了几次
flowchart TB
accTitle: watchdog 的记录能区分出什么
accDescr: 监控进程保留 exit code、结束时刻、心跳最后接收时间和重启次数之后,就能从外部区分出到底是真的崩溃、操作系统关机、用户主动关闭、挂起被 kill 还是重启循环的示意图。
t1["exit code 与结束时刻"] --> t4["可以从外部区分"]
t2["心跳最后接收时间"] --> t4
t3["重启次数"] --> t4
t4 -.-> t5["是崩溃、关机还是用户主动关闭"]
图21:有了 watchdog 的记录,就能从外部分辨出结束的类型。
9.2 特别适合的场景
值得考虑进行进程拆分的场景包括:
- 承载 vendor SDK 的 worker
- 图像处理 / 视频处理 / 设备 I/O
- 监控或轮询的父循环
- 脚本或插件执行
- 承载 COM / ActiveX 遗留资产的宿主
- 64 位 / 32 位桥接或互操作
把危险的处理封闭到单个 worker 里,日志设计和恢复设计都会轻松很多。
10. 常见的 NG 做法
这一节收集的是设计层面的陷阱。“在崩溃处理程序内不能做什么”这类流程层面的内容,已经整理在 5.2 中,正在编码的读者请看那一节。看起来重复的条目(例如 10.3 的 HTTP 发送),5.2 讲的是“为什么在处理程序内做会出问题”,而这里讲的是“结果在运维上会发生什么”。
10.1 用 catch (Exception) 只记录日志然后继续运行
这是最常见、也是最危险的做法。
- 中途的修改残留下来
- 共享状态被破坏
- 后续故障增多
- 真正的原因位置变得模糊
结果往往是:日志多了一条,但故障反而拖得更久。
10.2 只依赖 async logger 的队列
异步日志本身并不坏。 问题在于,fatal path 也走同一个队列然后就结束了。
一旦崩溃瞬间 worker 停止,那个队列也会一并消失。
只让 fatal path 直接写入,保留这样一条退路会更安全。
10.3 在崩溃处理程序中发送 HTTP 请求
这是很容易想去实现的功能,但相当危险。
- DNS
- TLS
- 代理
- 认证
- 超时
- 等待重发
这些全部都会叠加在已经崩溃的上下文里。
发送操作应该放到重启之后。
10.4 有转储,但无法与常规日志关联
这种情况很常见。
- 转储文件名里没有 session
- 日志侧没有 PID / session
- watchdog 侧没有 PID
- build 号不一致
结果就是,三份证据看起来像是三件互不相关的事。
flowchart TB
accTitle: 证据串不起来的失败
accDescr: 转储文件名里没有 session、日志侧没有 PID 或 session、build 号不一致时,转储、日志和 watchdog 记录这三份证据就会看起来像三件互不相关的事的示意图。
u1["转储名里没有 session"] --> u4["三份证据看起来像三件事"]
u2["日志里没有 PID / session"] --> u4
u3["build 号不一致"] --> u4
图22:作为钥匙的 ID 一旦缺失,好不容易留下的证据之间就串不起来。
10.5 用 WinForms / WPF 的未处理异常事件来延命
表面上“不再崩溃”,一开始会让人觉得挺好。 但实际上往往会出现:
- 只剩界面
- worker 已经死了
- 按钮还能点
- 不知道是否真的保存成功了
这种僵尸状态。
10.6 没有关注 native 侧的终止路径
如果只依赖 SetUnhandledExceptionFilter 就掉以轻心,会漏掉:
- invalid parameter
- purecall
- terminate
- fast fail
这些路径。
在 native C++ 中,不仅要关注 SEH,也要关注 CRT / C++ 运行时侧的终止路径,这样更稳妥。
11. 最低限度的落地检查清单
如果满足以下条件,说明已经相当实战化了。
- 常规日志以“一行一事件”的方式保留
- 所有日志都包含 UTC、PID、TID、version、session
ProcessStart和ProcessExit都有记录- 重要边界事件会同步 flush
- 存在专用的最终崩溃标记文件
- fatal path 不经过 async logger
- WER LocalDumps 已按应用单独配置
- 已验证转储保存路径的 ACL
- 保存了 PDB 和发布二进制文件
- 下次启动时能检测到上次异常终止
- 压缩 / 上传 / 通知在重启后或另一个进程中进行
- native C++ 中也整理了 invalid parameter / purecall / terminate
- 在验证机上故意让程序崩溃,确认是否真的留下了记录
最后一行尤其重要。 光是设计出来没有意义,必须做一次“确保能采集到”的实测。
12. 应该测试到什么程度
把需要确认的项目整理成表格。
| 测试 | 确认什么 |
|---|---|
| managed 的未处理异常 | 常规日志、fatal 标记、dump 是否全部齐全 |
| UI 线程异常 | WinForms / WPF 的事件路径是否符合预期 |
| worker 线程异常 | 是否能到达 AppDomain.UnhandledException,watchdog 能否检测到 |
| native 异常 | WER dump 是否真的能采集到 |
| invalid parameter / terminate | CRT / C++ 运行时路径是否也留下了最小记录 |
| 强制 kill | 即使进程内无法记录,watchdog 侧能否记录到意外退出 |
| 重启 | 下次启动后的通知、回收、上传是否正常运行 |
不能只满足于“异常抛出后应该会有日志”,而要确认“在这种条件下,这个文件确实会留下来”,这才是关键。
13. 总结
即便 Windows 应用因程序缺陷引发的异常而崩溃,如果想保留调查所需的信息,思路的主轴其实相当简单。
- 不要只依赖崩溃侧的那个进程
- 拆分为常规日志、最终崩溃标记、OS / 另一个进程侧的证据
- 崩溃时只在本地简短地保留记录
- 繁重的处理推迟到重启之后或另一个进程
- 以 WER LocalDumps 为基础
- 以“记录后结束”为基本方针,而不是“继续运行”
归根结底, 与“努力写好最后一行日志”相比,“打造一套即使没有最后一行也能追溯的结构”更强大。
flowchart TB
accTitle: 不依赖最后一行的结构
accDescr: 与其努力写好最后一行日志,不如打造一套即使没有最后一行也能追溯的结构,同时仍然把最终崩溃标记简短地保存在单独文件中,主要证据交给 WER 的转储和之前的常规日志的示意图。
v0["要追求的形态"] --> v2["即使没有最后一行也能追溯的结构"]
v2 --> v3["标记简短地保存在单独文件中"]
v2 --> v4["主要证据是转储和常规日志"]
图23:与其努力写好最后一行,不如打造一套即使没有它也能追溯的结构。
尽管如此,最后一行仍然值得争取, 最终崩溃标记应该以简短的形式保存在单独的文件中。 真正的主要证据,则交给 WER 的 dump 和之前的常规日志来承担。 这是 Windows 应用实务中相当稳定的做法。
相关文章
参考资料
- Microsoft Learn: Collecting User-Mode Dumps https://learn.microsoft.com/en-us/windows/win32/wer/collecting-user-mode-dumps
- Microsoft Learn: Using WER https://learn.microsoft.com/en-us/windows/win32/wer/using-wer
- Microsoft Learn: MiniDumpWriteDump function https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/nf-minidumpapiset-minidumpwritedump
- Microsoft Learn: SetUnhandledExceptionFilter function https://learn.microsoft.com/en-us/windows/win32/api/errhandlingapi/nf-errhandlingapi-setunhandledexceptionfilter
- Microsoft Learn: System.AppDomain.UnhandledException event https://learn.microsoft.com/en-us/dotnet/fundamentals/runtime-libraries/system-appdomain-unhandledexception
- Microsoft Learn: Application.ThreadException Event https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.application.threadexception
- Microsoft Learn: Application.DispatcherUnhandledException Event https://learn.microsoft.com/en-us/dotnet/api/system.windows.application.dispatcherunhandledexception
- Microsoft Learn: TaskScheduler.UnobservedTaskException Event https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskscheduler.unobservedtaskexception
- Microsoft Learn: Environment.FailFast https://learn.microsoft.com/en-us/dotnet/api/system.environment.failfast
- Microsoft Learn: Registering for Application Recovery https://learn.microsoft.com/en-us/windows/win32/recovery/registering-for-application-recovery
- Microsoft Learn: RegisterApplicationRecoveryCallback https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-registerapplicationrecoverycallback
- Microsoft Learn: WerRegisterFile https://learn.microsoft.com/en-us/windows/win32/api/werapi/nf-werapi-werregisterfile
- Microsoft Learn: _set_invalid_parameter_handler https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/set-invalid-parameter-handler-set-thread-local-invalid-parameter-handler
- Microsoft Learn: _set_purecall_handler https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/get-purecall-handler-set-purecall-handler
- Microsoft Learn: set_terminate https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/set-terminate-crt
- Microsoft Learn: __fastfail https://learn.microsoft.com/en-us/cpp/intrinsics/fastfail
- Microsoft Learn: FileStream.Flush(Boolean) https://learn.microsoft.com/en-us/dotnet/api/system.io.filestream.flush
- Microsoft Learn: !analyze (WinDbg) https://learn.microsoft.com/en-us/windows-hardware/drivers/debuggercmds/-analyze
- Microsoft Learn: .ecxr (Display Exception Context Record) https://learn.microsoft.com/en-us/windows-hardware/drivers/debuggercmds/-ecxr–display-exception-context-record-
- Microsoft Learn: Symbol path for Windows debuggers https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/symbol-path
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
Windows 崩溃转储收集入门 - WER/ProcDump/WinDbg
为了追查难以复现的 Windows 应用崩溃,本文梳理 WER LocalDumps、ProcDump、MiniDumpWriteDump、WinDbg 的分工,以及运维上的注意事项。
Windows的时刻同步(w32time)与业务系统 ── 从原理出发解决「日志时间戳对不上」的问题
从 Windows 时间服务(w32time)的原理出发,讲解设备与 PC 之间日志时刻偏差的成因。整理域层级结构与工作组的默认行为、w32tm 命令诊断、高精度时刻与虚拟机的注意事项,直至 Stopwatch 与 UTC 并用的日志设计。
故障处理不止于恢复 ── 写给小型开发团队的 Postmortem(再发防止)实践模板
把故障处理停留在「修复、道歉,就此结束」,同样的故障就会反复发生。本文把 blameless postmortem 翻译给小型团队使用,整理出可在 1 小时内写完的模板、再发防止策略的强度判断表,以及实施的分级(triage)标准。
睡眠、休眠、Modern Standby 与长时间运行应用 ── 用设计防止「夜里意外停止」
本文从 S3 睡眠、休眠、Modern Standby 的区别入手,整理长时间运行的 Windows 应用为何会出现「早上一看已经停止」的原因,并讲解睡眠期间计时器与 TCP 连接的行为,以及如何用 SetThreadExecutionState 进行抑制。
MAX_PATH 与 Windows 路径・文件名的陷阱 ── 260 字符限制、保留名称、末尾句点、大小写
梳理导致「找不到文件」的经典原因 ── 路径与文件名的各种限制。本文解析 MAX_PATH=260 的构成、通过 LongPathsEnabled 启用长路径、CON 等保留名称、末尾句点的规范化处理,以及 Path.Combine 的陷阱。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
故障调查 & 根本原因分析
只在客户环境才出现的崩溃、复现率极低的异常终止,以及通过转储与日志相互对照来定位原因,都与故障排查、原因分析这一主题高度契合。
Windows 应用程序开发
在 WPF、WinForms、常驻应用和 Windows 服务中如何设计常规日志、WER 与 watchdog,直接关系到 Windows 应用开发本身。
常见问题
汇总了咨询这一主题时常见的问题。
- 崩溃的应用自身能否确保留下日志?
- 不能。考虑到栈损坏、内存破坏、fast fail、强制终止直至断电等情况,进程内最后一条日志本质上只能是尽力而为(best effort)。实务中的做法是分成三层:正常运行时的时间序列日志、崩溃瞬间的最终崩溃标记,以及由操作系统或另一个进程保留的崩溃证据,从而形成一套不完全依赖崩溃进程本身的架构。最稳妥的组合是常规日志 + 最终崩溃标记 + WER LocalDumps。
- 在崩溃处理程序中不应该做哪些事?
- 所有偏重的处理都不应该做。从 DI 容器解析 logger、使用 async/await、等待锁、生成复杂 JSON、操作 COM 对象、弹出 UI 对话框、压缩、发送 HTTP/SMTP/Slack 消息等,都有很高概率变成地雷。应该做的只有 4 件事:防止重入、只写一行、flush,然后结束。压缩、上传、通知这类繁重的后续处理,应推迟到下次启动之后或交给另一个进程处理。
- WER LocalDumps 应该如何配置?
- 在注册表 HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\应用名.exe 下,设置 DumpFolder(专用文件夹)、DumpCount(大约 5~10 个)、DumpType(开发机用 2,现场则根据容量和保密要求选择 1 或 2)。关键是要确认保存路径的 ACL:把路径设置成 Windows 服务或受限账户无法写入的文件夹,是最典型的失败案例。此外,转储收集要与 PDB、发布二进制文件的保存配套进行,缺了任何一项,事后都无法解读。
- 在 WPF 的 DispatcherUnhandledException 中吞掉异常继续运行可以吗?
- 面对由程序缺陷引发的意外异常,这样做很危险。虽然把 Handled 设为 true 可以让程序表面上继续运行,但很容易造成“界面还在,但工作线程已经死了”“按钮还能点,却不知道是否真的保存成功了”这类僵尸状态。未处理异常事件应当被当作记录的入口,而不是恢复装置:写完最终崩溃标记后就应该结束进程,不要继续运行,主要证据交给 WER 的转储和之前的常规日志会更安全。结束进程时可以考虑使用 Environment.FailFast 这类立即终止的 API。