“一个月一次,只在深夜崩溃”“做同样的操作,在自己机器上再也出不来”“转储是拿到了,可看了崩溃的位置也说不出‘为什么会变成那个值’”── 在长期运行的 Windows 应用的缺陷调查里,最烧时间的就是这类项目。之前的文章“用 WinDbg + SOS 解读崩溃转储”梳理了怎么读崩溃转储这一张照片。本文接着往下讲,介绍照片不够用时该拿出的工具 —— Time Travel Debugging(TTD)。
TTD 是 WinDbg 的功能:把进程的执行整个录下来,事后既能向前也能向后回放。不必反复尝试复现直到缺陷出现,而是可以把调试器的会话“倒回去”。1 目标读者是已经完整经历过读转储和日志的调查、却仍然够不到原因的 Windows / .NET / C++ 应用的开发与维护负责人。前提环境是 Windows 10/11 或 Windows Server 2016 以上以及当前版本的 WinDbg,录制需要管理员权限。12 难度为中级。
本文的前提
| 项目 | 内容 |
|---|---|
| 目标读者 | 靠转储和日志够不到原因、手上有长期运行且断断续续发生的缺陷的 Windows 应用开发与维护负责人 |
| 前提知识 | 有用 WinDbg 打开转储并敲过 !analyze -v、!clrstack 的经验。SOS 分析那篇文章的内容视为已知 |
| 前提环境 | Windows 10/11 或 Windows Server 2016/2019/2022/2025、WinDbg(当前版本)、TTD.exe、管理员权限2 |
| 不涉及的内容 | Visual Studio Enterprise 的 Snapshot Debugger 联动、内核模式(TTD 仅限用户模式3) |
1. 先说结论
- 转储留下的是“状态”,TTD 留下的是“路径”。官方文档也明确写道,转储容易漏掉导致失败的状态和执行路径。1 如果崩溃瞬间的照片说明不了原因,那么下一步该拿的不是更多照片,而是录像。
- 录制很重。录制中的目标进程会慢 5~20 倍以上,跟踪文件在进程活跃时每秒增长 5~50MB,而且没有上限。24 它不是可以无条件挂在长期运行的应用上的工具。
- 要用于长期运行,就要设计“录哪一段”。TTD.exe 的
-ring/-maxFile(只保留最后的 N MB)、-module(只在自家模块执行时录)、-recordmode Manual(由应用一侧指定录制区间)、-monitor(每次启动都录)这四个入口,在第 5 章整理。2 - 回放的主角有三个。位置(
!tt)、事件(dx @$curprocess.TTD.Events)、查询(TTD.Calls/TTD.Memory)。把ba(访问断点)和g-(反向执行)组合起来,就能让调试器直接回答“最后写这个值的是谁”。5 - 跟踪里会装进内存的内容。文件路径、注册表、内存或文件的内容等,可能包含个人信息和机密信息。1 共享和保管请按“机密文件”的规格来设计。
- TTD 做不到的事也先摆出来。内核模式录不了,无法注入受保护的进程(PPL),一旦附加就不会自己脱离,回放期间也不能改写内存。32
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 32 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
2. 转储不够用的场面
崩溃转储是进程崩溃(或被暂停)那一瞬间的内存与寄存器的副本。正如SOS 分析那篇文章所写,用 !clrstack 能读出在哪里崩溃,用 !dumpheap -stat 能读出什么在吃堆。读不出来的是,到达那个状态之前发生了什么。
flowchart TB
accTitle: 转储拍下的东西与 TTD 拍下的东西
accDescr: 崩溃转储只拍下崩溃瞬间的状态,在此之前的路径拍不到。TTD 把从录制开始到结束的指令执行整个留下来,所以除了状态之外还留下了路径
dump["崩溃转储:崩溃瞬间的状态"] --> q1["为什么变成那个值拍不到"]
ttd["TTD 跟踪:录制区间的指令执行"] --> q2["能回到值被改写的位置"]
q1 -.-> gap["这道缝隙让长期运行的调查越拖越久"]
图 1: 转储是状态,TTD 是路径。长期运行的缺陷里想要的,多半是后者。
典型的是下面这类项目。
- 堆上某个结构体的一个字段变成了不可能的值。转储里拍到了坏掉的值,但谁在什么时候写的没有留下来。
- 异常发生的位置知道了,可传进来的参数为什么不合法却不知道。再往调用方上溯,中途造出这个值的函数已经不在栈上了。
- 句柄或内存花一个月慢慢增长。单张转储只能说到“在增长”,让它增长的调用路径拍不到。
flowchart TB
accTitle: 长期运行的典型项目里转储拍不到的东西
accDescr: 坏掉的值拍得到但写它的主体拍不到,异常的位置拍得到但造出参数的函数已不在栈上,资源的增长拍得到但让它增长的路径拍不到,这三类项目的共同点
c1["坏掉的字段"] --> m1["没有谁在什么时候写的信息"]
c2["参数不合法导致的异常"] --> m2["造出这个值的函数已经不在栈上"]
c3["花一个月增长的资源"] --> m3["没有让它增长的调用路径"]
m1 --> same["共同点:有状态但没有路径"]
m2 --> same
m3 --> same
图 2: 三类项目都是“有状态但没有路径”。照片再多也补不上路径。
Microsoft 的文档把各种调查手段的长处和短处整理如下。1
| 手段 | 长处 | 短处 |
|---|---|---|
| 实时调试 | 可交互,能看到执行的流向,能改状态 | 会打断用户的操作。要反复复现很费事。生产环境往往用不了。从失败地点上溯到原因很难 |
| 转储 | 不需要事先改代码。侵入性低,可以用触发条件采集。不用的时候开销几乎为零 | 就算连续拍快照,“时间流逝”的呈现也很粗 |
| 遥测与日志 | 轻量。和业务场景挂得上钩 | 意料之外的代码路径上没有日志。数据的深度不够,而且是静态嵌在代码里的 |
| TTD | 对复杂的 Bug 有效。不需要事先改代码。可以离线反复回放,并且记录全部内容 | 录制时的开销很大。有时会采集到超出需要的数据。文件会变大 |
还有一点,是 TTD 的官方演练指出的重要性质:当调试器停在失败地点时,那里往往是从真正的原因往前走了几步之后的错误处理代码里。5 转储必然是在这个“几步之后”的位置拍下的。用 TTD 的话,可以从那里一条指令一条指令地往回走。
flowchart TB
accTitle: 失败地点与真正原因之间的偏差
accDescr: 采集转储的失败地点往往在真正的原因之后几步的错误处理代码里,而在 TTD 中可以从那个地点按指令倒回,上溯到原因
cause["真正的原因(写坏值的那条指令)"] --> steps["往前走几步"]
steps --> fail["失败地点(异常、错误处理)"]
fail -->|"转储"| photo["在这里被定格"]
fail -->|"TTD"| back["用 p- / t- / g- 往回走"]
back --> cause
图 3: 转储被定格在失败地点。TTD 能从失败地点一路走回原因。
3. TTD 的原理与代价
3.1 到底录了什么
TTD 会往目标进程里注入录制引擎,以指令为单位记录被执行的指令。用官方文档的表述,它把“完整的指令级跟踪平均编码到每条指令不到 1 字节”,实际落在每条指令 1 比特到 1 字节的范围内。执行的函数种类越少、处理的数据越少的程序越小,反之则越大。24
录制的产物有两个。1
| 文件 | 作用 | 大小的参考值 |
|---|---|---|
.run |
跟踪本体。保存录制期间的指令执行 | 活跃时每秒增长 5~50MB。空闲时不增长4 |
.idx |
索引。供 WinDbg 高效回放和查询内存的辅助数据。在停止录制时生成,WinDbg 打开 .run 时也会自动生成 |
跟踪的 1~2 倍4 |
flowchart TB
accTitle: TTD 从录制到回放的流程
accDescr: 录制引擎被注入目标进程,指令执行被记录到 .run 文件,WinDbg 打开 .run 后生成索引 .idx,再用位置、事件、查询向前向后回放
proc["目标进程"] --> inj["注入录制引擎(TTDRecordCPU)"]
inj --> run[".run(指令执行的记录)"]
run --> open["用 WinDbg 打开"]
open --> idx["生成 .idx(索引)"]
idx --> play["用位置、事件、查询向前向后回放"]
图 4: 录的是 TTD.exe 或 WinDbg,读的是 WinDbg。共享只给 .run 就够了。
.run 里的时刻用“位置(position)”表示。形如 12:0 或 1A0:12F,是两个十六进制数用冒号隔开,前半是排序编号(对应排序事件),后半是从该事件起的大致指令数。6 FFFFFFFFFFFFFFFE:0 表示跟踪的末尾。7 位置会在第 6 章成为主角。
flowchart TB
accTitle: 跟踪内位置的表示方法
accDescr: 位置由十六进制的排序编号和步数用冒号隔开构成,开头在 0 附近,末尾用 FFFFFFFFFFFFFFFE:0 表示,还可以用百分比指定移动到大致的位置
pos["位置 xx:yy(十六进制)"] --> seq["xx:排序编号"]
pos --> step["yy:从该事件起的指令数"]
seq --> tail["末尾是 FFFFFFFFFFFFFFFE:0"]
step --> pct["也可以像 !tt 50 这样按百分比指定"]
图 5: 位置是“事件编号:指令数”。它不是真实时刻,但如第 6 章所述可以换算成真实时刻。
3.2 代价
官方文档自己就写着,TTD 是“侵入性的技术”。2 把代价用数字摆出来。
| 项目 | 内容 |
|---|---|
| 速度 | 录制中的目标进程会慢 5~20 倍以上(取决于应用和录制选项)。有时在 UI 上察觉不到,但在打开文件对话框这类重活上能明显感觉到23 |
| 文件增长 | 活跃时每秒 5~50MB。录几分钟就可能达到几 GB。没有设上限4 |
| 磁盘耗尽 | 录制中磁盘用尽时,TTD 会写完最后一页,然后实质上一直等到能写为止。WinDbg 保持录制对话框不动,既不报错也不告警。做出来的是不完整的跟踪4 |
| 内存 | 录制会在目标进程的内存上加上虚拟 CPU 那一份开销(默认 x64/ARM64 是 55,x86 是 32)。只有内存不足时才用 -numVCpu 调小2 |
| 脱不掉 | 一旦附加,TTD 就不会自己脱离。录完之后要关闭应用或结束进程。如果是系统不可或缺的进程,就需要重启操作系统2 |
flowchart TB
accTitle: 录制的代价,以及它对长期运行的影响
accDescr: 录制中的速度下降、文件的增长、磁盘耗尽时的静默等待、附加后脱不掉,这四个代价构成了不能无条件挂在长期运行的应用上的理由
cost["TTD 的录制"] --> slow["5~20 倍的速度下降"]
cost --> grow["每秒 5~50MB 的增长,没有上限"]
cost --> disk["磁盘耗尽时静默等待"]
cost --> stuck["附加之后脱不掉"]
slow --> no["无条件长时间录制不可行"]
grow --> no
disk --> no
stuck --> no
图 6: 四个代价里任意一个,都让长时间常开录制无法成立。所以才需要第 5 章的“录制范围设计”。
3.3 做不到的事
- 仅限用户模式。能录的只有进程的用户模式执行,驱动程序等在内核模式下执行的代码无法调试。3
- 受保护的进程。对 Protected Process Light(PPL) 等 Windows 的受保护进程,TTD 无法把自己注入进去。3
- 回放是只读的。能回到过去,但改不了历史。读内存的命令能用,改写内存的命令不能用。3
- 与杀毒软件、内存监控类软件不兼容。由于 TTD 采用往进程里挂钩的方式,会和跟踪、影射系统内存调用的软件冲突。录制时如果出现类似权限不足的错误,就临时禁用它们来排查。Electron 框架也是已知的冲突例子,即使录得下来,目标进程也可能死锁或崩溃。3
- UWP 应用无法用启动方式录制(对已经在运行的 UWP 应用可以附加)。运行在别的会话、别的安全上下文里的“非常规进程”目前也不在对象之内。8
flowchart TB
accTitle: TTD 录不了的东西与做不到的事
accDescr: 内核模式的代码、受保护的进程、UWP 应用的启动录制、别的会话或别的安全上下文里的进程都录不了,回放期间不能改写内存,与杀毒软件和 Electron 可能冲突
no["TTD 的限制"] --> g1["录不了的东西"]
no --> g2["限制与冲突"]
g1 --> k["内核模式的代码(驱动程序等)"]
k --> ppl["受保护的进程(PPL)"]
ppl --> uwp["UWP 的启动录制(附加可以)"]
uwp --> sess["别的会话、别的上下文"]
g2 --> ro["回放是只读的"]
ro --> av["可能与杀毒软件、Electron 冲突"]
图 7: 限制分“录不了”和“会冲突”两类。后者需要按环境逐个排查。
最后一条对想录服务的人来说是个坎。TTD.exe 的文档把 -attach 说明为面向“服务或长时间运行的应用的调查”,把 -monitor 说明为“程序或服务每次启动都录制”的用途2,与疑难解答页面的说法没法简单对上。实务上的安全做法是按这样的顺序按环境逐个确认:先在与生产相同的配置上确认能录 ping.exe 或 cmd.exe,再拿目标进程试。8
4. 录制 ── WinDbg UI 与 TTD.exe
录制的入口有两个:从 WinDbg 的 UI 录,或者用命令行的 TTD.exe 录。
4.1 从 WinDbg UI 录
以管理员身份运行 WinDbg(TTD 必须提权1),用 File > Start debugging > Launch executable (advanced) 指定可执行文件,勾选 Record with Time Travel Debugging。选择 Configure and Record,可以设置跟踪文件的保存位置,以及 Record subset of execution(把要录制的模块用 notepad.exe,kernelbase.dll 这样的逗号分隔形式限定下来)。如果是已经在运行的进程,就用 File > Start debugging > Attach to process,同样勾选 Record Process with Time Travel Debugging。9
录制期间会弹出一个带 “Stop and Debug” 和 “Cancel” 两个按钮的小对话框。应用结束(或崩溃)后跟踪被关闭,WinDbg 会自动打开它并建立索引。5
flowchart TB
accTitle: 从 WinDbg UI 录制的流程
accDescr: 在以管理员身份启动的 WinDbg 中选择 Launch executable (advanced) 或 Attach to process,勾选 Record with Time Travel Debugging,用 Configure and Record 设置保存位置与模块限定,经过录制中的对话框,在应用结束时跟踪被关闭并自动建立索引
adm["以管理员身份启动 WinDbg"] --> pick["Launch executable(advanced)/ Attach to process"]
pick --> chk["勾选 Record with Time Travel Debugging"]
chk --> cfg["Configure and Record:保存位置、模块限定"]
cfg --> rec["录制中的对话框(Stop and Debug)"]
rec --> fin["应用结束时关闭跟踪,自动建立索引"]
图 8: 从 UI 录制是 5 个步骤。跳过“以管理员身份启动”,会卡在第一个对话框。
4.2 用 TTD.exe 录
在装不了 WinDbg 的电脑上录、要把录制自动化,这类场合就单独用 TTD.exe。从 https://aka.ms/ttd/download 经由 App Installer 安装,装完后用 ttd.exe -help 确认。面向离线环境,官方也准备了手动展开 MSIX 包只取出二进制文件的步骤(附带 PowerShell 脚本)。2 录制需要管理员权限,通常从管理员命令提示符运行。2
录制的模式有三种。2
flowchart TB
accTitle: TTD.exe 的三种录制模式
accDescr: launch 可以传参数并启动新进程来录制,但会以提权后的权限运行。attach 按 PID 附加到正在运行的进程。monitor 在指定程序每次启动时录制,启动是以正常权限进行的
m["TTD.exe 的录制模式"] --> l["-launch:启动并录制"]
m --> a["-attach:附加到运行中的 PID"]
m --> mo["-monitor:每次启动都录"]
l -.-> lp["以管理员权限启动"]
a -.-> ap["保持正常的权限"]
mo -.-> mp["正常的启动路径,适合自动化"]
图 9: 能传参数的只有 -launch,但它会以提权后的权限运行。想以接近生产的行为录制,就用 -attach 或 -monitor。
:: 启动并录制(默认模式。-launch 可以省略)
TTD.exe -out C:\traces MyApp.exe --config prod.json
:: 附加到正在运行的进程(输出目录要先建好)
TTD.exe -attach 21440 -out C:\traces\MyApp.run
:: 每次启动都录(用 Ctrl+C 结束监视。-out 必须是完整路径)
TTD.exe -out C:\traces\ -monitor MyApp.exe
-launch是唯一能传参数的模式,但程序会以和 TTD.exe 相同的(管理员)权限启动。对于权限不同行为就变的应用,用-attach或-monitor保持正常权限录制。2-attach要求输出目录已经存在。指定文件名时,该名字的文件不能已经存在。2-monitor会装入进程启动监视驱动程序,在指定的程序(可指定多个)每次启动时录制。它一直有效到重启为止,用 Ctrl+C 停止。优点是不用自己去组装启动过程、目标以正常权限运行、适合用脚本做自动化。加上-cmdLineFilter "字符串",就只录命令行里包含该字符串的启动。2-children加上之后连子进程也录,但每个进程会生成各自的.run,而 WinDbg 一次只能打开一个。2
录制期间会弹出一个带 “Tracing Off”(停止录制,应用继续运行)和 “Exit App”(关闭应用并结束录制)两个按钮的小界面。自动化时用 -noUI 去掉它,用 -accepteula 接受 EULA。2 录制的日志留在与 .run 同一位置的 .out 文件里,可以读到录制开始和结束的真实时刻、录制会话的长度(simulation time)、是启动还是附加,以及操作系统版本。录制不顺利时,有些错误消息只出现在 .out 里。2
5. 面向长期运行的录制设计
这里是本文的重头戏。以第 3 章的代价为前提,要“在跑好几天的进程上抓住不知何时发生的缺陷”,就需要设计录制范围。TTD.exe 准备的手段有四种,按症状的性质来选。
flowchart TB
accTitle: 按长期运行的症状选择录制范围
accDescr: 不知何时发生就用环形缓冲区只留最后的一段,已经知道可疑模块就只录那个模块,能改造应用就用手动录制 API 指定区间,只在启动时或特定启动出现就用监视模式在每次启动时录
q{"症状的性质是?"} -->|"不知何时发生"| ring["-ring / -maxFile:只留最后一段"]
q -->|"只在启动时、特定启动"| mon["-monitor:每次启动都录"]
ring -->|"可疑模块很明确"| mod["再叠加 -module"]
ring -->|"能改造应用"| man["用 -recordmode Manual 指定区间"]
图 10: 四者并不互斥。把 -ring 和 -module 叠起来,在长期运行场景里是最现实的组合。
5.1 环形缓冲区 ── 只留下最后的 N MB
加上 -ring 之后,跟踪会写进由 -maxFile 指定大小的环形缓冲区,文件不会超过这个上限继续增长。留下来的,只有能装进这个大小的录制的最后一段。2 -maxFile 的单位是 MB,环形缓冲区模式的默认值是 2,048MB,最小 1MB,最大 32,768MB(32 位进程的内存内环形缓冲区默认是 256MB)。2
:: 附加到正在运行的监视应用,只保留最近 4GB 的执行
TTD.exe -accepteula -noUI -attach 21440 -ring -maxFile 4096 -out C:\traces\MyApp.run
:: 检测到症状后停止录制(应用继续运行)
TTD.exe -stop 21440
-stop 接受进程名、PID、all,停止对应的录制。-wait <秒> 会一直等到系统上所有录制会话结束(-1 表示无限期),用于在自动化脚本里构造“先停止再回收文件”的顺序。2
flowchart TB
accTitle: 环形缓冲区录制的时间线
accDescr: 附加后开始录制,旧的部分被挤出环形缓冲区,在症状出现的时点执行 stop,就只留下最近 maxFile 那么多的跟踪
s["用 -attach -ring 开始录制"] --> old["旧的区间被挤出去"]
old --> sym["症状出现"]
sym --> stop["用 -stop 停止录制"]
stop --> keep["只留下最近 maxFile 那么多"]
keep -.-> note["让症状发生前的一段能装进缓冲区"]
图 11: 环形缓冲区是留下“症状发生前一段”的装置。-maxFile 要从“发现症状到停止录制的时间 × 每秒的增长量”反推。
设计上的要点有两个。
- 缓冲区要大到能吸收“从发现到停止”这段时间。活跃的进程每秒增长 5~50MB4,所以 4GB 的环形缓冲区在高负载时相当于 1~2 分钟,低负载时相当于十几分钟。需要有机制把从症状检测(日志的特定行、计数器的阈值、监控的异常通知)到
-stop的延迟控制在这个时间之内。 - 停了也脱不掉。
-stop能停止录制,但 TTD 不会自己从目标进程脱离。2 要彻底停止录制就必须结束进程,所以要把“回收跟踪之后,在下一个维护时间点重启”这一步也纳入运维。
sequenceDiagram
accTitle: 与监控联动的环形缓冲区录制的停止
accDescr: 监控一侧通过日志或计数器检测到症状后调用 TTD.exe 的 stop,回收定型的 .run 文件,之后在维护时间点重启进程以脱掉 TTD
participant W as 监控(日志、计数器)
participant T as TTD.exe
participant P as 目标进程
W->>W: 检测到症状
W->>T: -stop PID
T->>P: 停止录制(进程继续运行)
T-->>W: .run 定型
W->>W: 回收 .run 并加密保管
W->>P: 在下次维护时间点重启
图 12: 只有当检测与 -stop 之间的延迟能装进缓冲区,症状发生前的一段才会留在跟踪里。重启之前都属于运维的一部分。
5.2 限定模块 ── 只在自家代码运行时录
-module <模块名> 只录制指定的模块(可以是可执行文件本身,也可以是被加载的 DLL,可指定多个)以及该模块调用的代码。目标进程在指定模块的代码被执行之前以全速运行,进入模块后开始录制,离开模块后录制停止并重新回到全速。因为录制的开关成本很高,所以在指定模块调用进程内其他模块的期间,录制会保持打开。2
:: 只在自家的测量逻辑 DLL 运行时录制(与环形缓冲区并用)
TTD.exe -accepteula -noUI -attach 21440 -module MeasureCore.dll -ring -maxFile 2048 -out C:\traces\
这种方式的跟踪,只是把录制停止的区间当作“下一条指令是录制恢复后的第一条指令”跳过去,调试方法和录了全程的跟踪没有区别。2 在长期运行的应用里,UI 的空闲循环和框架的内部处理往往占了执行指令的大半,所以仅仅限定到自家模块,开销和文件大小就会大幅下降。
flowchart TB
accTitle: 限定模块录制的动作
accDescr: 目标进程在指定模块之外以全速运行,进入指定模块的代码后开始录制,该模块调用其他模块期间录制继续,离开模块后录制停止
out1["指定模块之外:全速"] --> in1["进入指定模块:开始录制"]
in1 --> callee["被调用的其他模块:录制继续"]
callee --> out2["离开指定模块:停止录制"]
out2 --> out1
图 13: 连自家 DLL 调用的 Win32 API 和运行时内部都能录到,所以要追“自家代码交给操作系统的是什么”已经够用。
5.3 手动录制 ── 由应用一侧指定区间
指定 -recordmode Manual 之后,即使 TTD 被注入,进程仍以全速运行,只有程序调用了 TTD 的进程内录制 API 时才录制(默认的 Automatic 是注入之后立刻开始录制)。2 API 的文档和示例在 GitHub 的 WinDbg-Samples 仓库里。10
如果能改造应用,这是最没有浪费的方式。可以做成“通信重试连续失败了 3 次”“队列积压超过阈值”这样,在应用自己知道异常前兆的场面开始录制,恢复正常后停止的内嵌形式。如果是Job Object 那篇文章里讲过的那种监视进程结构,也可以由监视一侧来决定这个区间。
5.4 监视模式 ── 每次启动都录
对只在启动直后或特定启动时出现的缺陷,-monitor 比较合适。官方的分工表里也把监视模式列为“抓住断断续续的问题或启动时的问题”的用途。2 同一个程序要录很多次时,默认的连号文件名(MyApp01.run、MyApp02.run……)会因为要扫描已有文件而效率低下,这时用 -timestampFilename 换成带时间戳的名字。同时进行的录制数量可以用 -maxConcurrentRecordings 压住。2
flowchart TB
accTitle: 监视模式的动作
accDescr: monitor 选项会装入进程启动监视驱动程序,在指定程序每次启动时用命令行过滤器筛选对象再录制,每次启动生成不同的跟踪文件,一直持续到 Ctrl+C 或重启
drv["装入启动监视驱动程序"] --> launch["检测到指定程序的启动"]
launch --> filt{"与 -cmdLineFilter 匹配?"}
filt -->|"是"| rec["录制这次启动(每次启动一个文件)"]
filt -->|"否"| skip["不录制"]
rec --> next["等待下一次启动(直到 Ctrl+C 或重启)"]
skip --> next
图 14: 监视模式是“守株待兔式地等启动”。适合每次启动都出现的缺陷,以及能用启动条件筛出来的缺陷。
5.5 磁盘的存放位置
长期运行的录制,要把跟踪的存放位置放在专用卷上,并把 .run 的增长纳入监控。正如第 3.2 节所述,磁盘用尽时录制只会静默地等待,不会报错。官方的规避办法也很原始:“在资源管理器里看剩余容量”“看 .run 是不是在定期增长”。4 如果不监控剩余容量,拿到手的就会是最想要的那个瞬间没有写进去的不完整跟踪。
flowchart TB
accTitle: 磁盘耗尽产生不完整跟踪的路径
accDescr: 录制中磁盘用尽时 TTD 会写完最后一页然后静默等待,既不报错也不告警,所以之后发生的症状不会被记录,形成打得开却缺了关键部分的不完整跟踪。用专用卷和 .run 的增长监控来预防
full["磁盘用尽"] --> wait["写完最后一页后静默等待"]
wait --> none["既不报错也不告警"]
none --> sym["之后症状出现"]
sym --> inc["没有症状的不完整跟踪"]
guard["专用卷+.run 增长的监控"] -.->|"预防"| full
图 15: “打得开却缺了关键部分”的跟踪,是磁盘监控缺位的产物。
6. 回放 ── 位置、事件与倒带
6.1 打开
用 WinDbg 打开 .run 时,如果没有索引就会自动跑 !index,一边数 keyframe(为建索引而在跟踪内自动生成的位置,跟踪越大越多)一边生成 .idx。跟踪越大耗时越长。5 索引的状态可以用 !index -status 确认,如果不是 “Index file loaded” 就用 !index -force 重建。还是不行的话,关闭调试器删掉 .idx,再重新打开 .run。重建索引不会改动 .run,所以数据不会丢失。8
要注意的是,在 TTD 1.11.611 改进大跟踪的索引生成时索引格式变了,已有的跟踪需要重新建立索引。11 如果一直带着旧的 .idx,就会在这里绊住。
flowchart TB
accTitle: 索引的确认与重建
accDescr: 打开跟踪后用 !index -status 确认状态,如果不是 Index file loaded 就用 !index -force 重建,还是不行就关闭调试器删掉 .idx 再重新打开 .run。重建不会改动 .run
open["打开跟踪"] --> st["!index -status"]
st -->|"Index file loaded"| ok["进入分析"]
st -->|"其他情况"| force["用 !index -force 重建"]
force -->|"失败"| del["关闭后删掉 .idx 再重新打开 .run"]
del --> ok
force -->|"成功"| ok
图 16: 重建索引不会碰 .run。拿不准就删掉重新打开。
6.2 按位置移动
给 !tt 传一个位置,就会移动到那个时点。6
!tt 0 ; 跟踪的开头
!tt 50 ; 大约 50% 的位置
!tt 100 ; 跟踪的末尾
!tt 1A0:12F ; 移动到位置 1A0:12F
位置是 排序编号:步数 这两个十六进制数。6 Position 对象有 Percent(在跟踪中的比例)、Sequence、Steps 这几个属性,还有移动到该位置的 SeekTo(),以及返回大致真实时刻(UTC) 的 ToSystemTime()。7 在长期运行的调查里,这个 ToSystemTime() 很管用,因为它能把应用日志里留下的时刻和跟踪内的位置对上。TTD.Calls 的结果里也会出现 SystemTimeStart / SystemTimeEnd。12
flowchart TB
accTitle: 位置与日志时刻的对照
accDescr: 从应用日志里的时刻出发,顺着 Position 对象的大致真实时刻找出跟踪内的位置,用 SeekTo 移动到那个位置,再读周边的执行
log["应用的日志:异常的时刻"] --> match["找 ToSystemTime 的真实时刻接近的位置"]
match --> seek["用 SeekTo 移动到那个位置"]
seek --> read["读周边的调用与值"]
图 17: “日志这一行的前一刻在干什么”,可以靠位置与真实时刻的对应关系查出来。
位置也能用于共享。把跟踪交给同事时附上 !tt x:y 的位置,对方就能从同一个时点开始看。在 Bug 单里写上位置区间的做法,官方也是推荐的。2
6.3 倒回去
在普通的单步执行命令末尾加上 -,时间就会反向推进。13
| 命令 | 含义 | 功能区的按钮 |
|---|---|---|
p- |
回退一条指令(或源代码一行)。函数调用算作一步 | Step Over Back |
t- |
回退一条指令(或源代码一行)。函数调用的内部也会走进去 | Step Into Back |
g- |
反向执行。直到命中断点、被事件停住,或者停在跟踪的开头 | Go Back |
停住 g- 的事件,和停住正向 g 的事件是一样的。13 也就是说,布下 ba(访问断点)或 bp 再敲 g-,就能一口气回到“该条件最后一次成立的位置”。这就是第 7 章的基本动作。
flowchart TB
accTitle: 反向命令的分工
accDescr: p- 跨过函数调用回退一步,t- 会走进函数内部按指令回退,g- 一口气回退到断点、事件或跟踪开头。停住正向 g 的条件同样会停住 g-
cur["当前位置"] -->|"p-"| over["跨过调用回退一步"]
cur -->|"t-"| into["走进函数内部回退一条指令"]
cur -->|"g-"| run["一口气回退到下一个停止条件"]
run -.-> stop["ba / bp / 事件 / 跟踪开头"]
图 18: 想仔细看近处就用 t-,想跳到远处的原因就用 ba + g-。
6.4 从事件切入
拿不准该从哪里开始读,就先看事件一览。@$curprocess.TTD.Events 里以事件的形式排列着线程的创建与结束、模块的加载与卸载,以及异常。14
dx -g @$curprocess.TTD.Events
dx @$curprocess.TTD.Events.Where(t => t.Type == "Exception").Select(e => e.Exception)
异常事件里装着位置、种类(Software / Hardware)、异常代码,以及发生时的程序计数器,按下输出里的 [Time Travel] 链接就会移动到那个位置。15 官方演练就是按这个流程,跳到访问冲突(0xc0000005)的位置,从栈指针和基址指针对不上怀疑栈被破坏,再用 t- 回退 3 条指令确认值。5 WinDbg 的 Timelines 窗口把异常、断点、内存访问、函数调用可视化成时间线,双击异常会发出同样的 SeekTo()。16
6.5 线程与位置
!positions 会显示当前位置上所有活跃的线程,以及各自在跟踪内的位置。17 这里有一个坑:用 ~<编号>s 切换线程,跟踪内的位置并不会动。调试器读内存时使用的位置不变,所以想看别的线程在“那个时点”的内存,就要用 !positions 输出里的位置链接或 !tt x:y 来移动。13
flowchart TB
accTitle: 回放的基本动线
accDescr: 打开跟踪并建立索引,从事件一览移动到异常的位置,用反向单步上溯到原因,必要时用 positions 转到其他线程的位置
open["打开跟踪、建立索引"] --> ev["用 TTD.Events 找异常"]
ev --> seek["用 [Time Travel] 移动到位置"]
seek --> back["用 t- / p- / g- 上溯"]
back --> th["用 !positions 确认其他线程的位置"]
th -.-> caution["~s 不会移动跟踪内的位置"]
图 19: “从事件切入,反向走”是回放的基本动线。切换线程不等于移动位置。
7. 用查询找“什么时候” ── TTD.Calls 与 TTD.Memory
回放真正的强项,是能对整个跟踪做查询。调试器的数据模型(dx 命令)上载入了 TTD 的对象,可以像 LINQ 一样筛选、排序、聚合。18
7.1 TTD.Calls ── 检索函数调用
@$cursession.TTD.Calls("module!symbol") 会从整个跟踪里收集指定函数的调用。可以用通配符,每次调用里都装着开始与结束位置(TimeStart / TimeEnd)、线程 ID(附带不会被复用的 UniqueThreadId)、参数(Parameters[])、返回值(ReturnValue)、返回地址(ReturnAddress)。12
官方文档的例子是 GetLastError。把返回值非 0 的调用按错误码聚合,就能列出跟踪期间出现了哪些错误、各出现了多少次。18
dx -g @$cursession.TTD.Calls("kernelbase!GetLastError").Where(x => x.ReturnValue != 0).GroupBy(x => x.ReturnValue).Select(x => new { ErrorNumber = x.First().ReturnValue, ErrorCount = x.Count() }).OrderByDescending(p => p.ErrorCount),d
想知道“最后弹出的 MessageBox 是从哪里调用的”,就用 OrderBy(c => c.TimeStart).Last() 取出最后一次调用,再用它的 TimeStart 的 [Time Travel] 链接移动过去。18
flowchart TB
accTitle: TTD.Calls 查询的组装
accDescr: 按函数名收集调用,用返回值或参数筛选,按错误码等聚合,按时刻排序,再用 Time Travel 链接移动到目标调用的位置
calls["TTD.Calls(函数名、通配符)"] --> where["Where:按返回值、参数筛选"]
where --> group["GroupBy:按错误码等聚合"]
group --> order["OrderBy:按时刻排序"]
order --> jump["用 TimeStart 的 [Time Travel] 移动"]
图 20: 查询的用法不是从“在哪里”切入,而是从“什么时候、几次、用什么参数”切入。
把和符号的关系交代清楚。TTD 从 PDB 的符号信息决定函数的参数个数与类型、返回值的类型、调用约定。有 private symbols 时会给出函数名和正确的参数。只有 public symbols 时是函数名加默认参数(4 个 64 位无符号整数)。完全没有符号的模块,函数名会变成 UnknownOrMissingSymbols。1218 PDB 的种类与保管请参阅“PDB(程序数据库)是什么”。
flowchart TB
accTitle: 符号的有无与 TTD.Calls 的结果
accDescr: 有 private symbols 就能得到函数名和正确的参数,只有 public symbols 就是函数名加默认的 4 个 64 位整数参数,没有符号则函数名变成 UnknownOrMissingSymbols
sym{"模块的符号是?"} -->|"private symbols"| full["函数名+正确的参数、返回值"]
sym -->|"public symbols"| pub["函数名+默认参数(64 位整数×4)"]
sym -->|"没有"| unk["UnknownOrMissingSymbols"]
图 21: 有没有把自家模块的 PDB 保管好,直接决定了查询的实用性。
Calls 伴随计算,所以跟踪越大耗时越长,CPU 使用率也会上升。结果会缓存在内存里,对同一个函数的第二次以后的查询会变快。12 查询什么都没返回时的原因有四个:调用的写法(模块名用 x 命令确认,返回的是大写就用大写)、目标 DLL 在那个位置还没有被加载(移动到加载之后的位置再执行)、函数被内联展开(无法追踪)、通配符太宽(收窄)。18
7.2 TTD.Memory ── 检索内存访问
@$cursession.TTD.Memory(起始地址, 结束地址, "访问类别") 会从整个跟踪里收集对指定范围内存的访问。类别有 r(读)、w(写)、rw、e(执行)、rwe、ec(执行/修改)。19 “最后写这个变量的是谁”,用 "w" 收集之后取 .Last(),再移动到那个位置,答案就出来了。5
dx -g @$cursession.TTD.Memory(0x00a4fca0, 0x00a4fca4, "w")
dx @$cursession.TTD.Memory(0x00a4fca0, 0x00a4fca4, "w").Last().TimeStart.SeekTo()
如果只是从当前位置向前后找,@$curprocess.TTD.PrevMemoryAccess("w", 地址, 大小) / NextMemoryAccess 更轻,而且可以一次指定多个范围。寄存器发生变化的位置可以用 @$curthread.TTD.PrevRegisterWrite("rcx") 查找。6
7.3 ba + g- ── 让调试器回答“是谁写坏的”
把官方演练给出的步骤压缩成能用于长期运行调查的形式,就是下面这样。5
- 用
TTD.Events移动到异常的位置 - 一边用
t-回退,一边假定持有坏值的变量 - 用
dx &变量取出该变量的地址 - 用
ba w4 <地址>设置写入断点 - 用
g-一口气回到该变量最后被写入的位置 - 看那里(或前面几条指令)是不是原因。如果写入的值来自另一个变量,就对那个变量再设
ba并g- - 重复到够到写坏的那条指令为止
flowchart TB
accTitle: 用 ba 和 g- 上溯值的出处
accDescr: 对坏值的地址设置写入断点并反向执行,停在最后写入的指令上,如果这个值来自另一个变量就重复同样的步骤,一直上溯到写坏的那条指令
bad["确定坏值的地址"] --> ba["用 ba w 设置写入断点"]
ba --> gb["用 g- 反向执行"]
gb --> writer["停在最后写入的指令上"]
writer --> q{"值来自另一个变量?"}
q -->|"是"| bad
q -->|"否"| found["写坏的指令=原因"]
图 22: 在转储上以“坏了”告终的调查,在 TTD 上能机械地一路推进到“是谁写坏的”。
TTD 1.11.553 以后还加入了返回栈帧局部变量取值历史的 @$curframe.TTD.VariableHistory()。可以用表格看到变量名的列表,以及各变量在哪些位置区间里持有过哪些值。11 在栈破坏这类想知道“从什么时候开始值不对”的场面,可以用它在设 ba 之前先估个大概。
8. 套用到长期运行的缺陷上
把到此为止的工具,套用到开头列出的三类项目上。
flowchart TB
accTitle: 长期运行缺陷的类型与 TTD 的切入点
accDescr: 断断续续的异常和数据损坏从事件出发用 ba 和 g- 上溯,资源的增长用 TTD.Calls 把获取与释放的调用对起来,无响应和等待的连锁用 positions 和等待 API 调用的真实时刻来追
t1["断断续续的异常、数据损坏"] --> a1["TTD.Events → ba + g-"]
t2["句柄、内存的增长"] --> a2["用 TTD.Calls 对照获取与释放"]
t3["无响应、等待的连锁"] --> a3["!positions 与等待 API 的真实时刻"]
a2 -.-> pre["用 -module 限定到自家 DLL 再录"]
图 23: 每种类型的切入点不同。共同点是先把录制范围收窄,再开始读。
类型 1:断断续续的异常与数据损坏。用第 5.1 节的环形缓冲区留下症状发生前的一段,从第 6.4 节的事件一览跳到异常的位置,再用第 7.3 节的 ba + g- 上溯值的出处。和转储调查的区别在于,找到“坏掉的变量”之后调查不会就此结束,而是能从那里机械地继续往前推。
类型 2:句柄与内存的增长。就是“句柄泄漏导致一个月后崩溃”里解剖过的那类项目。一个月的量是录不下来的,所以用第 5.2 节的 -module 限定到自家 DLL,再叠上 -ring,留下“正在增长的那几分钟”。在跟踪上收集 TTD.Calls("kernelbase!CreateFileW") 和 TTD.Calls("kernelbase!CloseHandle"),把 CreateFileW 的返回值(句柄值)和 CloseHandle 的第 1 个参数(Parameters[0]) 对起来。CloseHandle 的 ReturnValue 表示成败,是个布尔值,不能用来做对照。如果有没被关闭的句柄值留下来,看那次 CreateFileW 调用的 ReturnAddress(调用方),就能查出在泄漏的调用方。12 不过,观测泄漏的“有无”和“数量”,Application Verifier 或句柄计数更轻,TTD 用在确定“哪条路径在泄漏”的阶段,分工上才是对的。另外,开着 Application Verifier 录制的话,由于内存使用方式的关系回放时的性能会明显变差,所以录制时把它关掉。8
类型 3:“无响应”与等待的连锁。正如“「无响应」的真正含义”里写的,挂起是“谁在等谁”的问题。跟踪在空闲期间不增长4,所以在内核里等待的线程,其等待时间本身不会作为指令被拍下来。拍下来的是进入等待之前的调用,以及返回之后的指令。用 !positions 看各线程的位置17,再把 TTD.Calls("kernelbase!WaitForSingleObject") 等的 SystemTimeStart / SystemTimeEnd 排出来,得到“哪个线程、在什么时候、开始等什么”,就能和日志的时刻对照着把等待的连锁拼起来。12 DllMain 与加载器锁和条件变量的虚假唤醒这类“依赖顺序的缺陷”,正是顺序本身会留在录制里的 TTD 特别契合的领域。
9. .NET 应用中的 TTD
TTD 的官方文档说明,可以使用以 64 位模式运行的 SOS 扩展(sos.dll),在 WinDbg 的 TTD 上调试托管代码。1 加载方式和SOS 分析那篇文章的第 3 章相同(.loadby sos coreclr 或自动加载),在跟踪的各个位置都能用 !clrstack 和 !pe。基本动线是这样的组合:用 TTD.Events 移动到异常的位置,再用 !clrstack 读托管的调用栈。
flowchart TB
accTitle: 读 .NET 应用的 TTD 跟踪的构成
accDescr: 用 WinDbg 打开 TTD 跟踪,加载 64 位的 SOS 扩展,移动到异常事件的位置后用 !clrstack 或 !pe 读托管的状态。TTD.Calls 用于原生边界的调用
run["TTD 跟踪(.run)"] --> wd["WinDbg"]
wd --> sos["SOS 扩展(64 位)"]
sos --> ev["用 TTD.Events 移动到异常的位置"]
ev --> clr["用 !clrstack / !pe 读"]
wd -.-> calls["TTD.Calls 用于原生边界的调用"]
图 24: 托管的状态交给 SOS,调用的检索交给原生边界。分好角色就不会迷路。
有两点要注意。第一,官方保证的是“64 位模式的 SOS”。对以 x86 构建的 .NET 应用没有明确说明,所以可能的话请把调查对象跑在 x64 上。第二,TTD.Calls 以 PDB 的符号信息为前提。12 不要指望用名字去检索 JIT 编译的托管方法,追 P/Invoke 目标的原生 DLL、COM、Win32 API 这类原生边界的调用才是可靠的用法。“在 .NET 中区分 GC 等待与内存泄漏”那种托管堆的问题,顺序上是先用 dotnet-counters / dotnet-gcdump / 转储的 !gcroot 去追,等到牵扯原生边界的阶段再拿出 TTD。
10. 转储、日志、ETW 与 TTD 的分工
TTD 不是万能的,也不是用来取代现有工具的。把它和本公司文章里讲过的工具并排列出来。
| 想知道什么 | 先用的工具 | TTD 出场的时机 |
|---|---|---|
| 崩溃瞬间的状态 | 崩溃转储(WER LocalDumps / ProcDump) | 转储能看出“坏了”,但看不出是谁写坏的时候 |
| 在哪个文件、哪个注册表项上失败 | Process Monitor | 还需要知道传给失败 API 的参数是怎么造出来的时候 |
| 整台电脑、长时间的性能 | WPR/WPA、PerfView(ETW) | 问题不在性能而在正确性,且需要指令级顺序的时候 |
| 业务上的经过 | 应用的日志 | 发生在没有日志的代码路径上的时候(TTD 不需要事先改代码就能记录全部内容1) |
| 是谁写了那个值、调用的顺序 | TTD | ── |
flowchart TB
accTitle: 调查手段的选择方法
accDescr: 先用轻量的转储和日志掌握状态,失败的 API 用 ProcMon 查,性能用 ETW 查,在仍然需要「谁、什么时候、传了什么」时再进到 TTD
start["症状"] --> light["用转储、日志掌握状态(轻)"]
light --> api["失败的 API 用 ProcMon"]
light --> perf["性能用 WPR/WPA、PerfView"]
light --> need{"需要谁、什么时候、传了什么?"}
need -->|"是"| ttd["设计好录制范围再用 TTD 录"]
need -->|"否"| done["用轻量的工具收尾"]
图 25: 先用轻量的工具掌握“结果”,只对确认了需要“路径”的项目才拿出 TTD。
顺序是“从轻的开始”。转储和日志的采集成本几乎为零,所以常设,而 TTD 用在“读完转储之后确认了需要路径”的项目上,并且要先做完第 5 章的设计再拿出来。考虑到 TTD 的开销,以及跟踪包含机密信息的性质,没有理由把这个顺序颠倒过来。
11. 运维上的注意事项
- 把跟踪当作机密文件处理。录制包含内存内容,可能混入文件路径、注册表、内存或文件的内容等个人信息与安全相关信息。12 要在客户环境里录制,就事先掌握可能包含什么(连接字符串、令牌、客户数据),定好加密的传输通道、保管位置和保留期限。
- 共享只给
.run。.idx和.run差不多大,而且 WinDbg 打开时会自动生成。.run的压缩效果很好。报告 TTD 自身的缺陷时,把.out也附上。2 - 统一版本。TTD 一直和 WinDbg 一起更新,1.11.611 里加入了支持 AVX/AVX512 的程序的录制崩溃修复以及索引格式的变更。11 录制端和回放端混着旧版本,就要重新建索引或重新录制。
- 要在不同的 CPU 上回放就确认
-replayCpuSupport。默认是优先可移植性的设置,如果录制的 CPU 和回放的 CPU 不同(比如在 arm64 上回放 Intel 的跟踪),有MostConservative可用。反过来,如果确定会在同等或更高的 CPU 上回放,也可以选择更小更快的录制。2 - Windows Server 上也能录。TTD.exe 支持 Windows Server 2016/2019/2022/2025。2
- 录不了时的排查。先试试能不能录
ping.exe或cmd.exe,录不了就怀疑与杀毒软件、应用虚拟化等侵入性软件的冲突。8
flowchart TB
accTitle: 跟踪的处理步骤
accDescr: 录好的跟踪包含内存内容,所以先掌握可能包含的信息,只把 .run 压缩后经加密的通道交付,定好保管位置与保留期限,分析一侧用同版本的 WinDbg 打开并生成索引
rec["录制完成(.run / .idx / .out)"] --> know["掌握可能包含的信息"]
know --> share["只把 .run 压缩后加密交付"]
share --> keep["定好保管位置与保留期限"]
keep --> open["用同版本的 WinDbg 打开并生成 .idx"]
图 26: 跟踪的交接就是“机密文件的交接”。先定好步骤再开录。
12. 小结
- 转储是“状态”,TTD 是“路径”。在需要坏值出处或调用顺序的项目上,TTD 把调查变成机械的作业
- 录制会慢 5~20 倍,每秒增长 5~50MB,而且脱不掉。要用于长期运行,就先用
-ring/-maxFile、-module、-recordmode Manual、-monitor设计好录哪一段 - 回放是“从事件切入,反向走”。
TTD.Events→[Time Travel]→t-/g-,再用ba+g-让调试器回答“是谁写坏的” TTD.Calls和TTD.Memory是对整个跟踪的查询。用ToSystemTime()和SystemTimeStart与日志的时刻对照- .NET 用 64 位的 SOS 读状态,
TTD.Calls用在原生边界 - 跟踪是机密文件。共享只给
.run,并且先定好通道与保管
转储明明拿到了却够不到原因、因为不复现而停在原地,这类项目只要能设计好录制范围,用 TTD 收尾的情况并不少。我们也可以承接从录制设计到 .run 分析的整个过程,欢迎带上转储和日志来咨询。
相关文章
- 用 WinDbg + SOS 解读崩溃转储 ── 采集之后的实务分析入门
- Windows 崩溃转储收集入门 - WER/ProcDump/WinDbg
- PDB(程序数据库)是什么 ── 理解调试信息、符号与 Source Link
- 句柄泄漏导致一个月后崩溃 ── 工业相机应用长期运行故障的解剖(前篇)
- 「无响应」的真正含义 ── Windows 如何判定应用已挂起,以及如何设计不挂起的应用
- Process Monitor(ProcMon) 实战指南
- WPR/WPA 实务 ── 从系统整体调查「整台 PC 变慢」的性能调查入门
- 用 PerfView 与 dotnet-trace 定位“变慢”的原因
- 父进程崩溃之后还剩下什么 ── 用 Job Object 管住子进程
相关的咨询领域
小村软件有限公司承接长期运行之后或只是断断续续发生的 Windows 应用缺陷的原因调查,方式是组合崩溃转储、日志与 TTD 跟踪;也承接包含录制范围设计在内的调查体制建设,以及牵扯原生边界(COM、P/Invoke、设备 SDK) 的故障的排查。欢迎从“转储是拿到了但够不到原因”这个阶段开始咨询。
参考链接
-
Microsoft Learn, Time Travel Debugging - Overview. 关于 TTD 能录制进程的执行并向前向后回放、转储容易漏掉导致失败的状态和执行路径、录制需要管理员权限、录制可能包含个人信息与安全相关信息、调查手段的比较表、
.run/.idx的作用,以及能用 64 位模式的 SOS 扩展调试托管代码。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 -
Microsoft Learn, Time Travel Debugging - TTD.exe command line utility. 关于 5~20 倍以上的速度下降、附加之后不会自己脱离、对 Windows Server 2016~2025 的支持、安装与离线部署、
-launch/-attach/-monitor三种模式、-out/-noUI/-accepteula/-stop/-wait/-tracingOff/-children/-cmdLineFilter/-timestampFilename/-ring/-maxFile/-maxConcurrentRecordings/-numVCpu/-replayCpuSupport/-module/-recordmode各选项、.out文件的读法,以及共享跟踪的建议。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20 ↩21 ↩22 ↩23 ↩24 ↩25 ↩26 ↩27 ↩28 ↩29 ↩30 ↩31 ↩32 ↩33 ↩34 -
Microsoft Learn, Time Travel Debugging - Overview - Things to look out for. 关于与杀毒软件、内存监控软件和 Electron 的不兼容、仅限用户模式、回放是只读的、无法注入受保护的进程(PPL),以及录制时 10~20 倍左右的性能影响。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, Time Travel Debugging - Working with Trace Files. 关于跟踪大小的影响因素(每条指令 1 比特~1 字节)、活跃时每秒增长 5~50MB 而空闲时不增长、最大大小没有上限、索引是跟踪的 1~2 倍,以及磁盘耗尽时录制与索引生成的行为和规避办法。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9
-
Microsoft Learn, Time Travel Debugging - Sample App Walkthrough. 关于移动到异常事件的位置、用
ba和g-上溯到最后写入非法值的位置这一通用步骤、失败地点往往在真正原因之后几步的错误处理代码里、崩溃时跟踪会被关闭且 WinDbg 会自动建立索引,以及TTD.Memory与.Last()的用法。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 -
Microsoft Learn, !tt (time travel). 关于
!tt的位置指定(百分比、xx:yy)、位置这两个要素(排序编号与步数)的含义,以及TTD.PrevRegisterWrite/PrevMemoryAccess/NextMemoryAccess。 ↩ ↩2 ↩3 ↩4 -
Microsoft Learn, TTD Position Objects. 关于 Position 对象的
Percent/Sequence/Steps、SeekTo()、返回大致真实时刻(UTC) 的ToSystemTime(),以及FFFFFFFFFFFFFFFE:0表示跟踪末尾。 ↩ ↩2 -
Microsoft Learn, Time Travel Debugging - Troubleshooting. 关于需要提权、尚不支持 UWP 应用的启动录制、别的会话与别的安全上下文的“非常规进程”不在对象之内、用
ping.exe/cmd.exe排查、并用 Application Verifier 时回放会变慢,以及用!index -status/!index -force重建索引。 ↩ ↩2 ↩3 ↩4 ↩5 -
Microsoft Learn, Time Travel Debugging - Record a trace. 关于从 WinDbg UI 的 Launch executable (advanced) / Attach to process 录制、Record with Time Travel Debugging 复选框、用 Configure and Record 设置保存位置,以及用 Record subset of execution 限定模块。 ↩
-
Microsoft, WinDbg-Samples - TTD in-process recording API (GitHub). 关于与
-recordmode Manual组合、由程序一侧控制录制开始与停止的进程内录制 API 的文档。 ↩ -
Microsoft Learn, Time travel debugging release notes. 关于 1.11.611 中对支持 AVX/AVX512 的程序的录制修复与索引格式的变更(需要重新建立索引),以及 1.11.553 中新增的
@$curframe.TTD.VariableHistory()。 ↩ ↩2 ↩3 -
Microsoft Learn, TTD Calls Objects. 关于
TTD.Calls的参数,ThreadId/UniqueThreadId/Function/ReturnValue/ReturnAddress/Parameters[]/TimeStart/TimeEnd/SystemTimeStart/SystemTimeEnd各属性,没有 PDB 符号信息时的默认行为(4 个 64 位无符号整数参数、UnknownOrMissingSymbols),以及计算耗时且结果会被缓存。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 -
Microsoft Learn, Time Travel Debugging - Replay a trace. 关于用
p-/t-/g-反向执行、g-会在与正向相同的事件上停住、!positions,以及~s不会改变跟踪内的位置。 ↩ ↩2 ↩3 -
Microsoft Learn, TTD Event Objects. 关于事件的种类(ThreadCreated/ThreadTerminated/ModuleLoaded/ModuleUnloaded/Exception) 以及 Position、Module、Thread、Exception 这些子对象。 ↩
-
Microsoft Learn, TTD Exception Objects. 关于异常对象的
Type(Software/Hardware)、ProgramCounter、Code、Flags、Position。 ↩ -
Microsoft Learn, WinDbg: Timelines. 关于 Timelines 窗口把异常、断点、内存访问、函数调用可视化,以及双击异常会发出
Position.SeekTo()。 ↩ -
Microsoft Learn, !positions. 关于显示所有活跃线程以及各自在跟踪内的位置。 ↩ ↩2
-
Microsoft Learn, Introduction to Time Travel Debugging objects. 关于
@$curprocess.TTD/@$cursession.TTD这些对象、用OrderBy/Where/Select/GroupBy做查询、GetLastError的错误聚合与MessageBoxW最后一次调用的例子、UnknownOrMissingSymbols的含义,以及Calls什么都不返回的四个原因。 ↩ ↩2 ↩3 ↩4 ↩5 -
Microsoft Learn, TTD Memory Objects. 关于
TTD.Memory的访问类别(r/w/rw/e/rwe/ec),以及可以用结果里的[Time Travel]链接移动到位置。 ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
参数为什么会坏掉 ── Windows 命令行参数的规则
在 Windows 上并不存在参数的数组,传给 CreateProcess 的是一条字符串,切分由接收方完成。本文讲解 CommandLineToArgvW・CRT・.NET 的切分规则,以及 .NET 的 ArgumentList 与 C++ 中正确的组装方法。
Windows 打印驱动程序停止提供 ── 业务应用的报表与标签打印如何应对
Microsoft 正在分阶段推进 v3/v4 打印驱动程序的停止提供,从 2026 年 7 月起会优先选择 IPP 类驱动程序。本文梳理 Windows protected print mode 下会消失什么,并用判断表整理业务应用程序的报表、标签打印中依赖点的盘点方法与...
用 WinDbg + SOS 解读崩溃转储 ── 采集之后的实务分析入门
本文说明如何用 WinDbg 与 SOS 扩展实际读取已采集的 Windows 崩溃转储。内容涵盖符号路径设置、以 !clrstack、!pe、!dumpheap -stat、!gcroot 追踪异常与内存泄漏的方法、原生崩溃的 !analyze -v,以及与 dotnet...
防止 Windows 应用程序重复启动 ── 命名 Mutex 与重复执行时的窗口前置
整理业务型 Windows 应用程序的常见需求——「不让同一个应用程序启动两次」——如何用命名 Mutex 来实现。涵盖 Global\ 与 Local\ 命名空间差异在 RDP 环境中的陷阱、拥有线程限制与 AbandonedMutexException、在 SetFor...
父进程消失之后还剩下什么 —— 用 Job Object 圈养子进程
为什么强制结束 UI 之后,SDK 的辅助进程仍然残留,一直占着摄像头或 COM 端口?本文从测量应用的视角,讲解如何用 Job Object 把进程树变成一个单位,并借助 KillOnJobClose 与完成端口来设计子进程的寿命。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
故障调查 & 长期运行故障
整理间歇性故障、通信诊断、长期运行崩溃、失败路径测试基础的主题页面。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
Windows 应用程序开发
支持包含常驻处理、设备联动、运行日志与可维护结构的 Windows 桌面应用程序。
故障调查 & 根本原因分析
调查难以复现的故障、长时间运行后的问题、内存泄漏、通信停滞等棘手的生产环境问题。
常见问题
汇总了咨询这一主题时常见的问题。
- Time Travel Debugging(TTD) 和崩溃转储有什么不同?
- 崩溃转储是“崩溃那一瞬间的内存照片”,能看到那个时点的状态,但到达那里的路径拍不到。TTD 是把进程的指令执行整个录下来的东西,事后既能向前也能向后回放,可以倒回去直接确认“最后改写那个变量的是谁”“这个异常发生的前一刻在调用什么”。Microsoft 的官方文档也明确写道,转储容易漏掉导致失败的状态和执行路径。代价是录制期间会慢 5~20 倍,跟踪文件在进程活跃时每秒增长 5~50MB 左右。
- 能把 TTD 一直挂在连续跑好几天的生产应用上吗?
- 照原样是不行的。TTD 录制时的速度下降很大,在活跃的进程上跟踪每秒增长 5~50MB,而且文件大小没有上限。要用于长期运行,前提是设计好录制范围:用 TTD.exe 的 -ring(环形缓冲区)和 -maxFile“只保留最后的 N MB”,用 -module 只在自家模块运行时录,用 -recordmode Manual 由应用一侧指定录制区间。另外,一旦附加上去的 TTD 不会自己脱离,所以要停止录制,必须把“结束(重启)进程”这一步也一并纳入运维方案。
- 服务或别的会话里的进程也能录吗?
- TTD.exe 的文档把 -attach 说明为面向“服务或长时间运行的应用的调查”,把 -monitor 说明为“程序或服务每次启动都录制”的用途。另一方面,疑难解答页面也写着,运行在别的会话或别的安全上下文里的“非常规进程”目前还不在录制对象之内。实际能不能录取决于环境,所以请先在与生产相同的配置上录 ping.exe 或 cmd.exe 这种简单进程,再拿目标进程试一次,确认之后再纳入运维。
- .NET 应用的跟踪也能用 TTD 吗?
- 能用。官方文档说明,可以在 WinDbg 的 TTD 跟踪上使用以 64 位模式运行的 SOS 扩展(sos.dll) 来调试托管代码。基本组合是:在跟踪的各个位置执行 !clrstack、!pe 之类的 SOS 命令,先移动到异常事件的位置,再读托管的调用栈。TTD.Calls 查询按符号名检索调用的功能以 PDB 的符号信息为前提,所以在 .NET 应用上,追 P/Invoke、COM、Win32 API 这类原生边界的调用才是可靠的用法。
- 把跟踪文件(.run) 发给外部公司或社外可以吗?
- 照原样发很危险。官方文档明确写道,TTD 的录制包含进程的内存内容,可能混入文件路径、注册表、内存或文件的内容等个人信息和机密信息。要发的话,请先掌握录进去了什么(连接字符串、令牌、客户数据等),再定好加密的传输通道与保管位置。共享只发 .run 就够了,索引文件(.idx) 会在 WinDbg 打开时自动生成。
- 用 TTD.Calls 检索函数却什么都没返回,为什么?
- 原因主要有四个。第一是符号的问题:没有 PDB 的模块,其函数名会变成“UnknownOrMissingSymbols”,模块名有时还是大写,所以要用 x 命令确认实际的符号名。第二是目标 DLL 在那个时点还没有被加载,这时先移动到 DLL 加载之后的位置再重新查询。第三是函数被内联展开了,查询引擎追踪不到。第四是通配符太宽,匹配到的函数太多,这时把模式收窄。