「一個月一次,只在深夜當掉」「做同樣的操作,在自己機器上再也弄不出來」「傾印是拿到了,可是看了當掉的位置也說不出『為什麼會變成那個值』」── 在長期運轉的 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 上偵錯 Managed 程式碼。1 載入方式和SOS 分析那篇文章的第 3 章相同(.loadby sos coreclr 或自動載入),在追蹤的各個位置都能用 !clrstack 與 !pe。基本動線是這樣的組合:用 TTD.Events 移到例外的位置,再用 !clrstack 讀 Managed 的堆疊。
flowchart TB
accTitle: 讀 .NET 應用程式的 TTD 追蹤的組成
accDescr: 用 WinDbg 開啟 TTD 追蹤,載入 64 位元的 SOS 擴充功能,移到例外事件的位置後用 !clrstack 或 !pe 讀 Managed 的狀態。TTD.Calls 用於原生邊界的呼叫
run["TTD 追蹤(.run)"] --> wd["WinDbg"]
wd --> sos["SOS 擴充功能(64 位元)"]
sos --> ev["用 TTD.Events 移到例外的位置"]
ev --> clr["用 !clrstack / !pe 讀"]
wd -.-> calls["TTD.Calls 用於原生邊界的呼叫"]
圖 24: Managed 的狀態交給 SOS,呼叫的搜尋交給原生邊界。角色分清楚就不會迷路。
有兩點要注意。第一,官方保證的是「64 位元模式的 SOS」。對以 x86 建置的 .NET 應用程式沒有明確說明,所以可以的話請把調查對象跑在 x64 上。第二,TTD.Calls 以 PDB 的符號資訊為前提。12 不要指望用名稱去搜尋 JIT 編譯的 Managed 方法,追 P/Invoke 目標的原生 DLL、COM、Win32 API 這類原生邊界的呼叫才是可靠的用法。「在 .NET 中區分 GC 延遲與記憶體洩漏」那種 Managed 堆積的問題,順序上是先用 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 擴充功能偵錯 Managed 程式碼。 ↩ ↩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 之下會消失哪些東西,並以判斷表梳理業務應用程式的報表、標籤列印中相依處的盤點...
DllMain 與載入器鎖定 ── 「DLL 初始化什麼都別做」真正的理由
為什麼不能從 DllMain 呼叫 LoadLibrary,也不能和其他執行緒同步。本文依一次資訊說明載入器鎖定如何序列化每個 DLL 通知、結構上必定成立的死結場景、延遲初始化的正確設計,以及無回應的調查方式。
解讀 Windows 錯誤碼 ── Win32、HRESULT、NTSTATUS 三層結構
出現 0x80004005 時,先分解再搜尋。本文整理 Win32 錯誤、HRESULT、NTSTATUS 的三層結構、0x8007xxxx 是包起來的 Win32 錯誤這個最重要模式,以及用 err.exe 與 PowerShell 查詢的方法。
MAX_PATH 與 Windows 路徑・檔案名稱的陷阱 ── 260 字元限制、保留名稱、結尾句點、大小寫
本文整理「找不到檔案」這類問題的常見原因──路徑與檔案名稱的限制。內容涵蓋 MAX_PATH=260 字元的組成、透過 LongPathsEnabled 啟用長路徑、CON 等保留名稱、結尾句點的正規化,直到 Path.Combine 的陷阱。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
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) 來偵錯 Managed 程式碼。基本組合是:在追蹤的各個位置執行 !clrstack、!pe 之類的 SOS 命令,先移到例外事件的位置,再讀 Managed 的堆疊。TTD.Calls 查詢以符號名稱搜尋呼叫的功能,是以 PDB 的符號資訊為前提,所以在 .NET 應用程式上,追 P/Invoke、COM、Win32 API 這類原生邊界的呼叫才是可靠的用法。
- 把追蹤檔(.run) 送給其他公司或公司外部沒問題嗎?
- 照原樣送很危險。官方文件明白寫著,TTD 的錄製包含處理程序的記憶體內容,可能含有檔案路徑、登錄檔、記憶體或檔案的內容等個人資訊與機密資訊。要送的話,請先掌握錄進去了什麼(連線字串、權杖、客戶資料等),再決定加密的傳輸途徑與保管位置。要分享只給 .run 就夠了,索引檔(.idx) 會在 WinDbg 開啟時自動產生。
- 用 TTD.Calls 搜尋函式卻什麼都沒傳回,為什麼?
- 原因主要有四個。第一是符號的問題:沒有 PDB 的模組,其函式名稱會變成「UnknownOrMissingSymbols」,模組名稱有時還是大寫,所以要用 x 命令確認實際的符號名稱。第二是目標 DLL 在那個時點還沒有被載入,這時先移到 DLL 載入之後的位置再重新查詢。第三是函式被內嵌展開了,查詢引擎追蹤不到。第四是萬用字元範圍太廣,符合的函式太多,這時把樣式縮小。