Time Travel Debugging ── 把长期运行中不复现的缺陷“录下来”再倒回去

· 更新日期: · · WinDbg, Time Travel Debugging, 调试, 缺陷调查, Windows, Windows 开发, .NET, C++, 技术咨询

“一个月一次,只在深夜崩溃”“做同样的操作,在自己机器上再也出不来”“转储是拿到了,可看了崩溃的位置也说不出‘为什么会变成那个值’”── 在长期运行的 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 能读出什么在吃堆。读不出来的是,到达那个状态之前发生了什么

转储拍下的东西与 TTD 拍下的东西崩溃转储只拍下崩溃瞬间的状态,在此之前的路径拍不到。TTD 把从录制开始到结束的指令执行整个留下来,所以除了状态之外还留下了路径崩溃转储:崩溃瞬间的状态为什么变成那个值拍不到TTD 跟踪:录制区间的指令执行能回到值被改写的位置这道缝隙让长期运行的调查越拖越久

图 1: 转储是状态,TTD 是路径。长期运行的缺陷里想要的,多半是后者。

典型的是下面这类项目。

  • 堆上某个结构体的一个字段变成了不可能的值。转储里拍到了坏掉的值,但谁在什么时候写的没有留下来。
  • 异常发生的位置知道了,可传进来的参数为什么不合法却不知道。再往调用方上溯,中途造出这个值的函数已经不在栈上了。
  • 句柄或内存花一个月慢慢增长。单张转储只能说到“在增长”,让它增长的调用路径拍不到。
长期运行的典型项目里转储拍不到的东西坏掉的值拍得到但写它的主体拍不到,异常的位置拍得到但造出参数的函数已不在栈上,资源的增长拍得到但让它增长的路径拍不到,这三类项目的共同点坏掉的字段没有谁在什么时候写的信息参数不合法导致的异常造出这个值的函数已经不在栈上花一个月增长的资源没有让它增长的调用路径共同点:有状态但没有路径

图 2: 三类项目都是“有状态但没有路径”。照片再多也补不上路径。

Microsoft 的文档把各种调查手段的长处和短处整理如下。1

手段 长处 短处
实时调试 可交互,能看到执行的流向,能改状态 会打断用户的操作。要反复复现很费事。生产环境往往用不了。从失败地点上溯到原因很难
转储 不需要事先改代码。侵入性低,可以用触发条件采集。不用的时候开销几乎为零 就算连续拍快照,“时间流逝”的呈现也很粗
遥测与日志 轻量。和业务场景挂得上钩 意料之外的代码路径上没有日志。数据的深度不够,而且是静态嵌在代码里的
TTD 对复杂的 Bug 有效。不需要事先改代码。可以离线反复回放,并且记录全部内容 录制时的开销很大。有时会采集到超出需要的数据。文件会变大

还有一点,是 TTD 的官方演练指出的重要性质:当调试器停在失败地点时,那里往往是从真正的原因往前走了几步之后的错误处理代码里。5 转储必然是在这个“几步之后”的位置拍下的。用 TTD 的话,可以从那里一条指令一条指令地往回走。

失败地点与真正原因之间的偏差采集转储的失败地点往往在真正的原因之后几步的错误处理代码里,而在 TTD 中可以从那个地点按指令倒回,上溯到原因转储TTD真正的原因(写坏值的那条指令)往前走几步失败地点(异常、错误处理)在这里被定格用 p- / t- / g- 往回走

图 3: 转储被定格在失败地点。TTD 能从失败地点一路走回原因。

3. TTD 的原理与代价

3.1 到底录了什么

TTD 会往目标进程里注入录制引擎,以指令为单位记录被执行的指令。用官方文档的表述,它把“完整的指令级跟踪平均编码到每条指令不到 1 字节”,实际落在每条指令 1 比特到 1 字节的范围内。执行的函数种类越少、处理的数据越少的程序越小,反之则越大。24

录制的产物有两个。1

文件 作用 大小的参考值
.run 跟踪本体。保存录制期间的指令执行 活跃时每秒增长 5~50MB。空闲时不增长4
.idx 索引。供 WinDbg 高效回放和查询内存的辅助数据。在停止录制时生成,WinDbg 打开 .run 时也会自动生成 跟踪的 1~2 倍4
TTD 从录制到回放的流程录制引擎被注入目标进程,指令执行被记录到 .run 文件,WinDbg 打开 .run 后生成索引 .idx,再用位置、事件、查询向前向后回放目标进程注入录制引擎(TTDRecordCPU).run(指令执行的记录)用 WinDbg 打开生成 .idx(索引)用位置、事件、查询向前向后回放

图 4: 录的是 TTD.exe 或 WinDbg,读的是 WinDbg。共享只给 .run 就够了。

.run 里的时刻用“位置(position)”表示。形如 12:01A0:12F,是两个十六进制数用冒号隔开,前半是排序编号(对应排序事件),后半是从该事件起的大致指令数。6 FFFFFFFFFFFFFFFE:0 表示跟踪的末尾。7 位置会在第 6 章成为主角。

跟踪内位置的表示方法位置由十六进制的排序编号和步数用冒号隔开构成,开头在 0 附近,末尾用 FFFFFFFFFFFFFFFE:0 表示,还可以用百分比指定移动到大致的位置位置 xx:yy(十六进制)xx:排序编号yy:从该事件起的指令数末尾是 FFFFFFFFFFFFFFFE:0也可以像 !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
录制的代价,以及它对长期运行的影响录制中的速度下降、文件的增长、磁盘耗尽时的静默等待、附加后脱不掉,这四个代价构成了不能无条件挂在长期运行的应用上的理由TTD 的录制5~20 倍的速度下降每秒 5~50MB 的增长,没有上限磁盘耗尽时静默等待附加之后脱不掉无条件长时间录制不可行

图 6: 四个代价里任意一个,都让长时间常开录制无法成立。所以才需要第 5 章的“录制范围设计”。

3.3 做不到的事

  • 仅限用户模式。能录的只有进程的用户模式执行,驱动程序等在内核模式下执行的代码无法调试。3
  • 受保护的进程。对 Protected Process Light(PPL) 等 Windows 的受保护进程,TTD 无法把自己注入进去。3
  • 回放是只读的。能回到过去,但改不了历史。读内存的命令能用,改写内存的命令不能用。3
  • 与杀毒软件、内存监控类软件不兼容。由于 TTD 采用往进程里挂钩的方式,会和跟踪、影射系统内存调用的软件冲突。录制时如果出现类似权限不足的错误,就临时禁用它们来排查。Electron 框架也是已知的冲突例子,即使录得下来,目标进程也可能死锁或崩溃。3
  • UWP 应用无法用启动方式录制(对已经在运行的 UWP 应用可以附加)。运行在别的会话、别的安全上下文里的“非常规进程”目前也不在对象之内。8
TTD 录不了的东西与做不到的事内核模式的代码、受保护的进程、UWP 应用的启动录制、别的会话或别的安全上下文里的进程都录不了,回放期间不能改写内存,与杀毒软件和 Electron 可能冲突TTD 的限制录不了的东西限制与冲突内核模式的代码(驱动程序等)受保护的进程(PPL)UWP 的启动录制(附加可以)别的会话、别的上下文回放是只读的可能与杀毒软件、Electron 冲突

图 7: 限制分“录不了”和“会冲突”两类。后者需要按环境逐个排查。

最后一条对想录服务的人来说是个坎。TTD.exe 的文档把 -attach 说明为面向“服务或长时间运行的应用的调查”,把 -monitor 说明为“程序或服务每次启动都录制”的用途2,与疑难解答页面的说法没法简单对上。实务上的安全做法是按这样的顺序按环境逐个确认:先在与生产相同的配置上确认能录 ping.execmd.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 Debugging9

录制期间会弹出一个带 “Stop and Debug” 和 “Cancel” 两个按钮的小对话框。应用结束(或崩溃)后跟踪被关闭,WinDbg 会自动打开它并建立索引。5

从 WinDbg UI 录制的流程在以管理员身份启动的 WinDbg 中选择 Launch executable (advanced) 或 Attach to process,勾选 Record with Time Travel Debugging,用 Configure and Record 设置保存位置与模块限定,经过录制中的对话框,在应用结束时跟踪被关闭并自动建立索引以管理员身份启动 WinDbgLaunch executable(advanced)/ Attach to process勾选 Record with Time Travel DebuggingConfigure and Record:保存位置、模块限定录制中的对话框(Stop and Debug)应用结束时关闭跟踪,自动建立索引

图 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

TTD.exe 的三种录制模式launch 可以传参数并启动新进程来录制,但会以提权后的权限运行。attach 按 PID 附加到正在运行的进程。monitor 在指定程序每次启动时录制,启动是以正常权限进行的TTD.exe 的录制模式-launch:启动并录制-attach:附加到运行中的 PID-monitor:每次启动都录以管理员权限启动保持正常的权限正常的启动路径,适合自动化

图 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 准备的手段有四种,按症状的性质来选。

按长期运行的症状选择录制范围不知何时发生就用环形缓冲区只留最后的一段,已经知道可疑模块就只录那个模块,能改造应用就用手动录制 API 指定区间,只在启动时或特定启动出现就用监视模式在每次启动时录不知何时发生只在启动时、特定启动可疑模块很明确能改造应用症状的性质是?-ring / -maxFile:只留最后一段-monitor:每次启动都录再叠加 -module用 -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

环形缓冲区录制的时间线附加后开始录制,旧的部分被挤出环形缓冲区,在症状出现的时点执行 stop,就只留下最近 maxFile 那么多的跟踪用 -attach -ring 开始录制旧的区间被挤出去症状出现用 -stop 停止录制只留下最近 maxFile 那么多让症状发生前的一段能装进缓冲区

图 11: 环形缓冲区是留下“症状发生前一段”的装置。-maxFile 要从“发现症状到停止录制的时间 × 每秒的增长量”反推。

设计上的要点有两个。

  1. 缓冲区要大到能吸收“从发现到停止”这段时间。活跃的进程每秒增长 5~50MB4,所以 4GB 的环形缓冲区在高负载时相当于 1~2 分钟,低负载时相当于十几分钟。需要有机制把从症状检测(日志的特定行、计数器的阈值、监控的异常通知)到 -stop 的延迟控制在这个时间之内。
  2. 停了也脱不掉。-stop 能停止录制,但 TTD 不会自己从目标进程脱离。2 要彻底停止录制就必须结束进程,所以要把“回收跟踪之后,在下一个维护时间点重启”这一步也纳入运维。
与监控联动的环形缓冲区录制的停止监控一侧通过日志或计数器检测到症状后调用 TTD.exe 的 stop,回收定型的 .run 文件,之后在维护时间点重启进程以脱掉 TTD目标进程TTD.exe监控(日志、计数器)目标进程TTD.exe监控(日志、计数器)检测到症状-stop PID停止录制(进程继续运行).run 定型回收 .run 并加密保管在下次维护时间点重启

图 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 的空闲循环和框架的内部处理往往占了执行指令的大半,所以仅仅限定到自家模块,开销和文件大小就会大幅下降。

限定模块录制的动作目标进程在指定模块之外以全速运行,进入指定模块的代码后开始录制,该模块调用其他模块期间录制继续,离开模块后录制停止指定模块之外:全速进入指定模块:开始录制被调用的其他模块:录制继续离开指定模块:停止录制

图 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.runMyApp02.run……)会因为要扫描已有文件而效率低下,这时用 -timestampFilename 换成带时间戳的名字。同时进行的录制数量可以用 -maxConcurrentRecordings 压住。2

监视模式的动作monitor 选项会装入进程启动监视驱动程序,在指定程序每次启动时用命令行过滤器筛选对象再录制,每次启动生成不同的跟踪文件,一直持续到 Ctrl+C 或重启装入启动监视驱动程序检测到指定程序的启动与 -cmdLineFilter 匹配?录制这次启动(每次启动一个文件)不录制等待下一次启动(直到 Ctrl+C 或重启)

图 14: 监视模式是“守株待兔式地等启动”。适合每次启动都出现的缺陷,以及能用启动条件筛出来的缺陷。

5.5 磁盘的存放位置

长期运行的录制,要把跟踪的存放位置放在专用卷上,并把 .run 的增长纳入监控。正如第 3.2 节所述,磁盘用尽时录制只会静默地等待,不会报错。官方的规避办法也很原始:“在资源管理器里看剩余容量”“看 .run 是不是在定期增长”。4 如果不监控剩余容量,拿到手的就会是最想要的那个瞬间没有写进去的不完整跟踪。

磁盘耗尽产生不完整跟踪的路径录制中磁盘用尽时 TTD 会写完最后一页然后静默等待,既不报错也不告警,所以之后发生的症状不会被记录,形成打得开却缺了关键部分的不完整跟踪。用专用卷和 .run 的增长监控来预防预防磁盘用尽写完最后一页后静默等待既不报错也不告警之后症状出现没有症状的不完整跟踪专用卷+.run 增长的监控

图 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,就会在这里绊住。

索引的确认与重建打开跟踪后用 !index -status 确认状态,如果不是 Index file loaded 就用 !index -force 重建,还是不行就关闭调试器删掉 .idx 再重新打开 .run。重建不会改动 .runIndex file loaded其他情况失败成功打开跟踪!index -status进入分析用 !index -force 重建关闭后删掉 .idx 再重新打开 .run

图 16: 重建索引不会碰 .run。拿不准就删掉重新打开。

6.2 按位置移动

!tt 传一个位置,就会移动到那个时点。6

!tt 0          ; 跟踪的开头
!tt 50         ; 大约 50% 的位置
!tt 100        ; 跟踪的末尾
!tt 1A0:12F    ; 移动到位置 1A0:12F

位置是 排序编号:步数 这两个十六进制数。6 Position 对象有 Percent(在跟踪中的比例)、SequenceSteps 这几个属性,还有移动到该位置的 SeekTo(),以及返回大致真实时刻(UTC) 的 ToSystemTime()7 在长期运行的调查里,这个 ToSystemTime() 很管用,因为它能把应用日志里留下的时刻和跟踪内的位置对上。TTD.Calls 的结果里也会出现 SystemTimeStart / SystemTimeEnd12

位置与日志时刻的对照从应用日志里的时刻出发,顺着 Position 对象的大致真实时刻找出跟踪内的位置,用 SeekTo 移动到那个位置,再读周边的执行应用的日志:异常的时刻找 ToSystemTime 的真实时刻接近的位置用 SeekTo 移动到那个位置读周边的调用与值

图 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 章的基本动作。

反向命令的分工p- 跨过函数调用回退一步,t- 会走进函数内部按指令回退,g- 一口气回退到断点、事件或跟踪开头。停住正向 g 的条件同样会停住 g-p-t-g-当前位置跨过调用回退一步走进函数内部回退一条指令一口气回退到下一个停止条件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

回放的基本动线打开跟踪并建立索引,从事件一览移动到异常的位置,用反向单步上溯到原因,必要时用 positions 转到其他线程的位置打开跟踪、建立索引用 TTD.Events 找异常用 [Time Travel] 移动到位置用 t- / p- / g- 上溯用 !positions 确认其他线程的位置~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

TTD.Calls 查询的组装按函数名收集调用,用返回值或参数筛选,按错误码等聚合,按时刻排序,再用 Time Travel 链接移动到目标调用的位置TTD.Calls(函数名、通配符)Where:按返回值、参数筛选GroupBy:按错误码等聚合OrderBy:按时刻排序用 TimeStart 的 [Time Travel] 移动

图 20: 查询的用法不是从“在哪里”切入,而是从“什么时候、几次、用什么参数”切入。

把和符号的关系交代清楚。TTD 从 PDB 的符号信息决定函数的参数个数与类型、返回值的类型、调用约定。有 private symbols 时会给出函数名和正确的参数。只有 public symbols 时是函数名加默认参数(4 个 64 位无符号整数)。完全没有符号的模块,函数名会变成 UnknownOrMissingSymbols1218 PDB 的种类与保管请参阅“PDB(程序数据库)是什么”。

符号的有无与 TTD.Calls 的结果有 private symbols 就能得到函数名和正确的参数,只有 public symbols 就是函数名加默认的 4 个 64 位整数参数,没有符号则函数名变成 UnknownOrMissingSymbolsprivate symbolspublic symbols没有模块的符号是?函数名+正确的参数、返回值函数名+默认参数(64 位整数×4)UnknownOrMissingSymbols

图 21: 有没有把自家模块的 PDB 保管好,直接决定了查询的实用性。

Calls 伴随计算,所以跟踪越大耗时越长,CPU 使用率也会上升。结果会缓存在内存里,对同一个函数的第二次以后的查询会变快。12 查询什么都没返回时的原因有四个:调用的写法(模块名用 x 命令确认,返回的是大写就用大写)、目标 DLL 在那个位置还没有被加载(移动到加载之后的位置再执行)、函数被内联展开(无法追踪)、通配符太宽(收窄)。18

7.2 TTD.Memory ── 检索内存访问

@$cursession.TTD.Memory(起始地址, 结束地址, "访问类别") 会从整个跟踪里收集对指定范围内存的访问。类别有 r(读)、w(写)、rwe(执行)、rweec(执行/修改)。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

  1. TTD.Events 移动到异常的位置
  2. 一边用 t- 回退,一边假定持有坏值的变量
  3. dx &变量 取出该变量的地址
  4. ba w4 <地址> 设置写入断点
  5. g- 一口气回到该变量最后被写入的位置
  6. 看那里(或前面几条指令)是不是原因。如果写入的值来自另一个变量,就对那个变量再设 bag-
  7. 重复到够到写坏的那条指令为止
用 ba 和 g- 上溯值的出处对坏值的地址设置写入断点并反向执行,停在最后写入的指令上,如果这个值来自另一个变量就重复同样的步骤,一直上溯到写坏的那条指令确定坏值的地址用 ba w 设置写入断点用 g- 反向执行停在最后写入的指令上值来自另一个变量?写坏的指令=原因

图 22: 在转储上以“坏了”告终的调查,在 TTD 上能机械地一路推进到“是谁写坏的”。

TTD 1.11.553 以后还加入了返回栈帧局部变量取值历史的 @$curframe.TTD.VariableHistory()。可以用表格看到变量名的列表,以及各变量在哪些位置区间里持有过哪些值。11 在栈破坏这类想知道“从什么时候开始值不对”的场面,可以用它在设 ba 之前先估个大概。

8. 套用到长期运行的缺陷上

把到此为止的工具,套用到开头列出的三类项目上。

长期运行缺陷的类型与 TTD 的切入点断断续续的异常和数据损坏从事件出发用 ba 和 g- 上溯,资源的增长用 TTD.Calls 把获取与释放的调用对起来,无响应和等待的连锁用 positions 和等待 API 调用的真实时刻来追断断续续的异常、数据损坏TTD.Events → ba + g-句柄、内存的增长用 TTD.Calls 对照获取与释放无响应、等待的连锁!positions 与等待 API 的真实时刻用 -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]) 对起来CloseHandleReturnValue 表示成败,是个布尔值,不能用来做对照。如果有没被关闭的句柄值留下来,看那次 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 读托管的调用栈。

读 .NET 应用的 TTD 跟踪的构成用 WinDbg 打开 TTD 跟踪,加载 64 位的 SOS 扩展,移动到异常事件的位置后用 !clrstack 或 !pe 读托管的状态。TTD.Calls 用于原生边界的调用TTD 跟踪(.run)WinDbgSOS 扩展(64 位)用 TTD.Events 移动到异常的位置用 !clrstack / !pe 读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/WPAPerfView(ETW) 问题不在性能而在正确性,且需要指令级顺序的时候
业务上的经过 应用的日志 发生在没有日志的代码路径上的时候(TTD 不需要事先改代码就能记录全部内容1
是谁写了那个值、调用的顺序 TTD ──
调查手段的选择方法先用轻量的转储和日志掌握状态,失败的 API 用 ProcMon 查,性能用 ETW 查,在仍然需要「谁、什么时候、传了什么」时再进到 TTD症状用转储、日志掌握状态(轻)失败的 API 用 ProcMon性能用 WPR/WPA、PerfView需要谁、什么时候、传了什么?设计好录制范围再用 TTD 录用轻量的工具收尾

图 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.execmd.exe,录不了就怀疑与杀毒软件、应用虚拟化等侵入性软件的冲突。8
跟踪的处理步骤录好的跟踪包含内存内容,所以先掌握可能包含的信息,只把 .run 压缩后经加密的通道交付,定好保管位置与保留期限,分析一侧用同版本的 WinDbg 打开并生成索引录制完成(.run / .idx / .out)掌握可能包含的信息只把 .run 压缩后加密交付定好保管位置与保留期限用同版本的 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.CallsTTD.Memory 是对整个跟踪的查询。用 ToSystemTime()SystemTimeStart 与日志的时刻对照
  • .NET 用 64 位的 SOS 读状态,TTD.Calls 用在原生边界
  • 跟踪是机密文件。共享只给 .run,并且先定好通道与保管

转储明明拿到了却够不到原因、因为不复现而停在原地,这类项目只要能设计好录制范围,用 TTD 收尾的情况并不少。我们也可以承接从录制设计到 .run 分析的整个过程,欢迎带上转储和日志来咨询。

相关文章

相关的咨询领域

小村软件有限公司承接长期运行之后或只是断断续续发生的 Windows 应用缺陷的原因调查,方式是组合崩溃转储、日志与 TTD 跟踪;也承接包含录制范围设计在内的调查体制建设,以及牵扯原生边界(COM、P/Invoke、设备 SDK) 的故障的排查。欢迎从“转储是拿到了但够不到原因”这个阶段开始咨询。

参考链接

  1. Microsoft Learn, Time Travel Debugging - Overview. 关于 TTD 能录制进程的执行并向前向后回放、转储容易漏掉导致失败的状态和执行路径、录制需要管理员权限、录制可能包含个人信息与安全相关信息、调查手段的比较表、.run/.idx 的作用,以及能用 64 位模式的 SOS 扩展调试托管代码。  2 3 4 5 6 7 8 9 10

  2. 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

  3. Microsoft Learn, Time Travel Debugging - Overview - Things to look out for. 关于与杀毒软件、内存监控软件和 Electron 的不兼容、仅限用户模式、回放是只读的、无法注入受保护的进程(PPL),以及录制时 10~20 倍左右的性能影响。  2 3 4 5 6 7

  4. Microsoft Learn, Time Travel Debugging - Working with Trace Files. 关于跟踪大小的影响因素(每条指令 1 比特~1 字节)、活跃时每秒增长 5~50MB 而空闲时不增长、最大大小没有上限、索引是跟踪的 1~2 倍,以及磁盘耗尽时录制与索引生成的行为和规避办法。  2 3 4 5 6 7 8 9

  5. Microsoft Learn, Time Travel Debugging - Sample App Walkthrough. 关于移动到异常事件的位置、用 bag- 上溯到最后写入非法值的位置这一通用步骤、失败地点往往在真正原因之后几步的错误处理代码里、崩溃时跟踪会被关闭且 WinDbg 会自动建立索引,以及 TTD.Memory.Last() 的用法。  2 3 4 5 6 7

  6. Microsoft Learn, !tt (time travel). 关于 !tt 的位置指定(百分比、xx:yy)、位置这两个要素(排序编号与步数)的含义,以及 TTD.PrevRegisterWrite/PrevMemoryAccess/NextMemoryAccess。  2 3 4

  7. Microsoft Learn, TTD Position Objects. 关于 Position 对象的 Percent/Sequence/StepsSeekTo()、返回大致真实时刻(UTC) 的 ToSystemTime(),以及 FFFFFFFFFFFFFFFE:0 表示跟踪末尾。  2

  8. Microsoft Learn, Time Travel Debugging - Troubleshooting. 关于需要提权、尚不支持 UWP 应用的启动录制、别的会话与别的安全上下文的“非常规进程”不在对象之内、用 ping.exe/cmd.exe 排查、并用 Application Verifier 时回放会变慢,以及用 !index -status/!index -force 重建索引。  2 3 4 5

  9. 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 限定模块。 

  10. Microsoft, WinDbg-Samples - TTD in-process recording API (GitHub). 关于与 -recordmode Manual 组合、由程序一侧控制录制开始与停止的进程内录制 API 的文档。 

  11. Microsoft Learn, Time travel debugging release notes. 关于 1.11.611 中对支持 AVX/AVX512 的程序的录制修复与索引格式的变更(需要重新建立索引),以及 1.11.553 中新增的 @$curframe.TTD.VariableHistory()。  2 3

  12. 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

  13. Microsoft Learn, Time Travel Debugging - Replay a trace. 关于用 p-/t-/g- 反向执行、g- 会在与正向相同的事件上停住、!positions,以及 ~s 不会改变跟踪内的位置。  2 3

  14. Microsoft Learn, TTD Event Objects. 关于事件的种类(ThreadCreated/ThreadTerminated/ModuleLoaded/ModuleUnloaded/Exception) 以及 Position、Module、Thread、Exception 这些子对象。 

  15. Microsoft Learn, TTD Exception Objects. 关于异常对象的 Type(Software/Hardware)、ProgramCounterCodeFlagsPosition。 

  16. Microsoft Learn, WinDbg: Timelines. 关于 Timelines 窗口把异常、断点、内存访问、函数调用可视化,以及双击异常会发出 Position.SeekTo()。 

  17. Microsoft Learn, !positions. 关于显示所有活跃线程以及各自在跟踪内的位置。  2

  18. Microsoft Learn, Introduction to Time Travel Debugging objects. 关于 @$curprocess.TTD / @$cursession.TTD 这些对象、用 OrderBy/Where/Select/GroupBy 做查询、GetLastError 的错误聚合与 MessageBoxW 最后一次调用的例子、UnknownOrMissingSymbols 的含义,以及 Calls 什么都不返回的四个原因。  2 3 4 5

  19. Microsoft Learn, TTD Memory Objects. 关于 TTD.Memory 的访问类别(r/w/rw/e/rwe/ec),以及可以用结果里的 [Time Travel] 链接移动到位置。 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

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 加载之后的位置再重新查询。第三是函数被内联展开了,查询引擎追踪不到。第四是通配符太宽,匹配到的函数太多,这时把模式收窄。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表