Windows 應用程式當機時留下日誌與傾印的設計

· 更新日期: · · Windows 開發, 例外處理, 日誌, WER, 當機傾印, 缺陷調查

更新紀錄(2 筆,最後更新 2026年09月04日)

本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。

已將繁體中文版改寫為日文原文的完整翻譯。先前的繁體中文版只譯出日文原文的一部分,遺漏了章節、表格、Mermaid 圖、圖說與 FAQ。本次依日文原文將這些內容全部補回,並新增本文的知識地圖章節。技術主張與日文版一致。 查看更新前的版本 (DOI: 10.5281/zenodo.22279462)
補上了日文原文中已有的諮詢引導(consultation_services)。內文沒有改動。 查看更新前的版本 (DOI: 10.5281/zenodo.21616331)
初次發布
引用本文(DOI: 10.5281/zenodo.21616330)

本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。

Go Komura(2026)。〈Windows 應用程式當機時留下日誌與傾印的設計〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616330 https://comcomponent.com/zh-TW/blog/2026/03/19/000-windows-app-crash-logging-best-practices/

DOI(最新版本)
10.5281/zenodo.21616330
DOI(此版本)
10.5281/zenodo.22297168

在 Windows 應用程式的缺陷調查裡,最難受的就是 只知道它當掉了,卻沒留下任何說明為什麼當掉的資料 這種狀態。

在下面這幾種專案裡,這個問題會特別嚴重。

  • 只在客戶環境才會當掉
  • 只有長時間運轉之後才會當掉
  • WPF / WinForms / Windows 服務 / 常駐應用程式,而且重現率很低
  • 牽涉到 COM、P/Invoke、native DLL、vendor SDK
  • 只拿得到「例外訊息」,前後的脈絡完全沒有

不過話說在前面,光靠當掉那一側的處理程序,沒辦法「一定」留下日誌。 把堆疊毀損、記憶體毀損、fast fail、強制終止、斷電都算進來之後,in-process 的最後一筆日誌本質上就是 best effort。

in-process 的最後一筆日誌是 best effort說明把堆疊毀損、記憶體毀損、fast fail、強制終止、斷電都算進來之後,光靠當掉那一側的處理程序無法一定留下日誌,in-process 的最後一筆日誌本質上就是 best effort 的圖。堆疊毀損、記憶體毀損最後一筆 in-process 日誌fast fail、強制終止斷電本質上是 best effort做不到「一定留下」

圖 1: 只靠當掉那一側的處理程序,無法「一定」留下最後一筆日誌。

實務上該追求的,是 不把希望全押在當掉的處理程序內部 的架構。 也就是說,

  1. 平時的時序日誌
  2. 當掉瞬間的最終當機標記
  3. 由 OS 或另一個處理程序留下的當機佐證資料

用這三層來思考。

本文以 Windows 桌面應用程式、常駐應用程式、Windows 服務、設備介接工具為前提,梳理 即使因為程式錯誤引發的例外而當掉,也不會失去調查能力的最佳實踐。

1. 先講結論

先把結論列出來。

  • 不要把「最後一筆日誌」全押在單一個 in-process 處理常式上,這一點最重要。
  • 實務上最穩當的,是 一般日誌 + 最終當機標記 + WER LocalDumps 的組合。
  • 長時間運轉、設備介接、外掛程式、混用 native SDK 的情況,再加上一個監控處理程序(watchdog / launcher / service) 會強上不少。
  • 在當機處理常式裡 不做耗時的處理 是鐵則。壓縮、HTTP 傳送、DI 解析、UI 對話方塊、產生複雜的 JSON 都要移出去。
  • 當機時只在本機短短留下一筆,壓縮、上傳、通知則挪到 下次啟動後或另一個處理程序。
  • 用 WinForms 的 ThreadException 或 WPF 的 DispatcherUnhandledException 在表面上硬撐下去的設計,面對程式錯誤時很危險。
  • 不論 .NET 還是 native,懷疑狀態已經毀損的例外,都以「記錄後結束」而不是「復原」為原則 比較安全。
  • 要取得傾印的話,必須同時保管 PDB 與散發出去的二進位檔,否則之後讀不出來。

簡單說,最佳實踐就是 「不要想在當掉的瞬間做完全部。把當掉前、當掉瞬間、當掉後的職責分開」。

當掉前、當掉瞬間、當掉後的職責分工說明不要想在當掉的瞬間做完全部,而是把當掉前的一般日誌、當掉瞬間只在本機短短寫入、當掉後的壓縮與上傳與通知分開負責的最佳實踐的圖。當掉前:留下一般日誌當掉瞬間:只在本機短短寫入當掉後:壓縮、上傳、通知在下次啟動後或另一個處理程序進行

圖 2: 不要在當掉的瞬間做完全部,而是分成前、瞬間、後三個時點分擔職責。

1.1 本文使用的術語

先把後面會直接使用、不再另外說明的詞列出來。

術語 全稱、讀法 意義
WER Windows Error Reporting,Windows 錯誤報告 由 OS 端攔下應用程式的異常終止並加以記錄的 Windows 機制。把傾印留在本機的設定就是 LocalDumps
傾印 / minidump crash dump 把當掉那一瞬間處理程序的記憶體內容存下來的檔案。事後可以查看執行緒、堆疊與模組
PDB Program Database 建置時產生的符號檔。少了它,就算打開傾印也不會出現函式名稱與行號
in-process 處理程序內 在當掉的那個處理程序自己內部處理。相對的說法是另一個處理程序
best effort 盡力而為 「順利的話會留下來,但不保證」的性質。當掉瞬間的 in-process 日誌就是這樣
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 session ID(工作階段識別碼) 用來辨識「講的是哪一次啟動的執行個體」的值。是把日誌、傾印、watchdog 記錄兜在一起的鑰匙

圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 24 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle

2. 為什麼只靠 in-process 做不到「確實」

這一點含糊帶過,整個設計就會搖擺。

2.1 當掉的那條執行緒,執行內容本身就可能已經壞了

未處理例外的掛勾(hook)與頂層例外篩選器,有可能 在已經壞掉那一側的執行緒內容上執行。 到了這個時間點,

  • 堆疊已經不安全
  • 堆積毀損讓後續的記憶體配置也不安全
  • 例外發生時持有的鎖,會讓後續的等待直接卡死
  • logger 本身相依的物件已經壞掉

這些狀況都很常見。

所以把 最後那個處理常式看成「能做的事情相當少的地方」,而不是「什麼都能做的地方」 會比較安全。

最後那個處理常式能做的事情很少說明未處理例外的掛勾有可能在已經壞掉那一側的執行緒內容上執行,堆疊與堆積都不安全,等待鎖會卡住,logger 的相依物件也可能壞掉,因此應視為能做的事情相當少的地方的圖。在壞掉的執行緒內容上執行堆疊與堆積都不安全等待鎖可能卡死logger 的相依物件也可能壞掉能做的事情相當少的地方

圖 3: 最後那個處理常式不是「什麼都能做的地方」,而是限制重重的地方。

2.2 fast fail 與毀損狀態例外,前提就是「in-process 只做最低限度的動作」

在記憶體毀損或致命狀態下,最好不要指望一般的例外處理。 特別是 native 端的 __fastfail 這一類,以及懷疑狀態已毀損的異常情境,設計方向本來就是 「用盡可能少的額外負擔立刻結束」。

也就是說,最後那筆 in-process 日誌寫得出來算賺到,主要佐證資料交給 OS 或另一個處理程序,才是自然的想法。

2.3 .NET 的未處理例外事件同樣不是做「耗時復原處理」的地方

.NET 的 AppDomain.UnhandledException 很方便, 但最好把這裡能做的事情限縮到 簡短的記錄 為止。

  • 可能受到例外發生時仍持有的鎖影響
  • 並不是連毀損狀態例外都能安全攔下
  • 硬要在這裡訂出繼續執行的方針,很容易變成半壞的狀態還硬撐著

把它理解成 「未處理例外事件 = 最後的通知」,而 不是「安全的復原點」,才符合實際。

未處理例外事件是最後的通知說明 AppDomain.UnhandledException 裡能做的只到簡短的記錄,並不是連毀損狀態例外都能安全攔下,未處理例外事件是最後的通知而不是安全的復原點的圖。未處理例外事件能做的只到簡短的記錄當成最後的通知使用不是安全的復原點硬訂繼續方針就會半壞著硬撐

圖 4: 未處理例外事件是記錄的入口,不是復原的地方。

3. 建議的架構 - 把 crash-time 與 after-restart 分開

最容易梳理清楚的做法,是把 當機時要做的事 和 重新啟動後要做的事 分開。

先用一張圖把這三層各自 由哪個處理程序負責、資料最後落在哪裡 畫出來。

下次啟動的健全處理程序watchdog 處理程序Windows 端應用程式處理程序例外發生處理程序結束處理程序結束壓縮 / 上傳 / 通知偵測上次的異常終止記錄 exit code 與結束時間判斷是否重新啟動WER LocalDumps在處理程序外儲存傾印一般日誌append-only 的時序記錄最終當機標記只寫一行就結束本機的固定資料夾

圖 5: 三層佐證資料的全貌。當掉的處理程序自己寫的只有兩份,主要佐證資料在外面。

重點有三個。

  • 當掉的處理程序自己寫的,只有「應用程式處理程序」框裡的那兩份。而且最終當機標記只做到「寫一行就結束」。
  • 主要佐證資料在處理程序之外。WER 的傾印與 watchdog 的記錄,就算應用程式壞掉也會留下來。
  • 比對的鑰匙是共用的 session ID 與 PID。這裡沒有對齊,三份佐證資料看起來就會像三件不相干的事。
階段 目的 在哪裡執行 做什麼
平時 留下時序記錄 應用程式內 結構化日誌、heartbeat、界線事件
當機時 落下最低限度的佐證資料 應用程式內 + OS 最終當機標記、WER 傾印
結束的當下 偵測 unexpected exit 另一個處理程序 記錄 exit code、判斷是否重新啟動、通知
下次啟動後 做耗時的後續處理 新的健全處理程序 壓縮、上傳、通知使用者、清理舊日誌

照這樣切開之後,設計會穩定很多。

3.1 最小構成

如果是規模較小的業務工具,或公司內部用的 WPF / WinForms,一開始做到這個程度通常就夠了。

  • 一般日誌:本機的 append-only 檔案
  • 最終當機標記:專用的短檔案
  • 傾印:WER LocalDumps
  • 下次啟動時:顯示「上次異常終止,有診斷資訊」

3.2 加強版的構成

遇到下面這些要求時,最好再往上加一級。

  • 24/7 運轉
  • 裝置控制、監控、常駐
  • 大量使用 COM / P/Invoke / native SDK
  • 有子處理程序、外掛程式、指令碼執行
  • 在客戶環境不允許「停著不動」

這種情況下,分成

  • worker 處理程序:本體處理
  • launcher / watchdog / service:監控啟動、記錄 exit、重新啟動
  • WER LocalDumps:設在 worker 端
  • 下次啟動或 watchdog:回收診斷資訊

這樣切開,會相當貼近實務。

加強版構成的分工說明在 24 小時運轉或裝置控制的情境下,把本體處理交給 worker 處理程序,把監控啟動、記錄 exit、重新啟動交給 launcher 或 watchdog,WER LocalDumps 設在 worker 端,再由下次啟動或 watchdog 回收診斷資訊的構成的圖。較嚴格的要求(24/7、裝置控制等)worker:本體處理watchdog:監控啟動、記錄 exit、重新啟動WER LocalDumps 設在 worker 端下次啟動或 watchdog 回收診斷資訊

圖 6: 加強版構成把本體與監控拆成不同的處理程序,職責固定下來。

4. 一般日誌的最佳實踐

只想靠當機時的最後一行來打這場仗,大多會輸。 真正管用的是 當掉前那一段的一般日誌。

4.1 日誌要的是「之後能互相比對的資訊」,而不是「給人讀的文章」

列出一般日誌裡至少要放進去的欄位。

  • UTC 時間戳記
  • 從處理程序啟動起算的經過時間
  • PID / TID
  • 應用程式名稱、版本、建置編號、commit 識別碼
  • session 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

比起留下給人讀的長篇文字, 「之後能把三個檔案兜在一起比對」 這件事更重要。

從一行日誌串起三份佐證資料說明一般日誌的一行靠 pid 與 session 連到傾印那邊,靠 ver 與 commit 連到建置那邊,之後能把三個檔案兜起來比對,比留下給人讀的長篇文字更重要的圖。一般日誌的一行靠 pid / session 連到傾印那邊靠 ver / commit 連到建置那邊三個檔案能兜起來比對

圖 7: 日誌的一行,靠 pid、session、ver、commit 連上其他佐證資料。

4.2 關鍵事件要同步寫下來

把一般日誌全部改成同步寫入會變慢。 但全部交給非同步緩衝區的話,當掉的瞬間會整批消失。

所以實務上依照層級改變處理方式才實際。

  • Information 這種細節事件:可以先進緩衝區
  • Warning 以上:儘早 flush
  • 重要的界線事件:同步寫下來
    • ProcessStart
    • ConfigLoaded
    • WorkerStarted
    • ExternalCommandSent
    • TransactionCommitted
    • RecoveryStarted
    • FatalPathEntered

重點是,至少業務上的界線一定要確實落到地上。

依層級區分寫入方式說明 Information 這種細節事件可以先進緩衝區,Warning 以上要儘早 flush,業務上的界線事件則同步寫下來,依層級區分處理方式的圖。日誌的寫入Information:可以緩衝Warning 以上:儘早 flush界線事件:同步寫下至少業務上的界線要落到地上

圖 8: 不是全部同步也不是全部緩衝,而是依層級區分寫入方式。

4.3 把「正在寫的一般日誌」和「最後的當機標記」分開

這一點相當重要。

想把全部東西塞進單一個 rolling log 的話,

  • 剛好正在輪替
  • 還留在非同步佇列裡
  • 例外發生之後 logger 自己就死了
  • 日誌寫到一半就斷了

這些事情都會發生。

所以建議至少拆成兩份。

  • app-<session>.jsonl 平時的時序日誌
  • fatal-last.log 或 fatal-<session>.log 最終當機標記專用

光是 「最後那一行要留在哪裡」很明確,實務上就幫上很大的忙。

把一般日誌與 fatal 標記分開說明全部塞進單一個 rolling log 時,輪替中、非同步佇列殘留、logger 自己死掉都會讓最後一行消失,因此要拆成平時的時序日誌與最終當機標記專用檔案兩份的圖。改為全部塞進單一個 rolling log輪替中、佇列殘留、logger 死掉都會消失拆成一般日誌與 fatal 標記兩份最後一行的存放位置變明確

圖 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
  • session ID
  • 版本 / 建置編號
  • 來自哪一個掛勾
    • AppDomain.UnhandledException
    • Application.ThreadException
    • DispatcherUnhandledException
    • SetUnhandledExceptionFilter
    • _set_invalid_parameter_handler
    • set_terminate
  • 例外型別或例外碼
  • 可能的話加上簡短訊息
  • 前一個操作 ID
  • 一般日誌的檔名
  • 預定的 dump 資料夾

這樣就夠了。

標記的目的是固定住入口說明最終當機標記不是要寫原因的細節,而是把來自哪一個掛勾、哪一種例外,以及一般日誌的檔名與預定的 dump 資料夾這些調查入口固定下來,因此資訊要收窄的圖。最終當機標記哪一個掛勾、例外型別、session一般日誌檔名與 dump 資料夾調查的入口被固定下來原因的細節交給傾印與一般日誌

圖 10: 標記的職責不是原因的細節,而是固定住調查的入口。

5.2 當機處理常式裡不能做的事

下面每一項出事的機率都相當高。

  • 從 DI 容器解析 logger
  • 使用 async / await
  • 丟出 Task
  • 等待鎖
  • 組出複雜的 JSON
  • 碰 COM 物件
  • 彈出 UI 對話方塊
  • 壓縮
  • HTTP / SMTP / Slack / Teams 傳送
  • 解析 dump 並做出摘要
  • 把例外吞掉繼續執行

當機處理常式 不是一般處理流程的延續。 要把它收攏成「只做最低限度的本機寫入然後結束」。

5.3 當機處理常式裡要做的事

反過來說,要做的事相當單純。

  1. 防止重複進入
  2. 只寫一行
  3. flush
  4. 結束處理程序

順序就是這樣。

可以的話,用的是

  • 事先建好的專用資料夾
  • 事先確認過存在的路徑
  • 已經查看過 ACL 的儲存位置

這幾種位置。

一般日誌 flush 過頭會變慢,但 fatal 標記的筆數極少,只有這裡可以 flush 得重一點。 .NET 用 FileStream.Flush(true),native 用 FlushFileBuffers,把它當成 「只有這一行現在就要落到地上」 來處理,設計起來比較順。

當機處理常式的四個步驟說明當機處理常式要做的只有防止重複進入、只寫一行、flush、結束這四件事,而且 fatal 標記筆數極少,只有這裡可以用較重的 flush 直接寫到磁碟的圖。1. 防止重複進入2. 只寫一行3. flush4. 結束處理程序只有這一行現在就要落到地上

圖 11: 處理常式只做這四個步驟,順序不要打亂。

5.4 不要想著讓它繼續跑

如果是程式錯誤引發的 unexpected 例外,把最後那個處理常式看成 記錄裝置而不是復原裝置 比較安全。

特別想把「不繼續」訂成原則的,是下面這幾種。

  • 就算只是 NullReferenceException 或 InvalidOperationException,也可能剛好停在共用狀態更新到一半
  • UI 執行緒上的 unexpected 例外
  • 從監控迴圈或父迴圈漏出來的 unexpected 例外
  • AccessViolationException
  • StackOverflowException
  • native 界線上的異常
  • CRT 的 invalid parameter / purecall / terminate

「不想讓它當掉」的心情可以理解,但 半壞著活下去,對診斷和維運通常更難受。

要結束時,.NET 可以考慮 Environment.FailFast,native 可以考慮 RaiseFailFastException 或 __fastfail 這類 立即終止的 API,設計上不要指望 finally 或一般的善後處理,比較安全。

是記錄裝置而不是復原裝置說明面對程式錯誤引發的非預期例外時,應把最後的處理常式看成記錄裝置而不是復原裝置,比起半壞著活下去,記錄後用立即終止的 API 結束更安全的圖。程式錯誤引發的例外半壞著活下去記錄後結束診斷與維運都難受考慮 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 會寫到磁碟,而不是只寫進 OS 的快取
                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 列出的那四件事。

  1. 用 Interlocked.Exchange 防止重複進入
  2. 只用字串串接 寫一行
  3. 用 Flush(true) 寫到磁碟
  4. 從 UnhandledException 不讓它繼續執行

Environment.FailFast 會先把訊息寫進 Windows 的應用程式記錄檔,再立刻結束處理程序,並把內容一併放進錯誤報告。也就是說 FailFast 本身也多留下一份佐證資料。不過如同前面所說,在未處理例外的路徑上呼叫它,傾印看起來的樣子會變,所以呼叫的位置要挑過。

挑選呼叫 FailFast 的位置說明 Environment.FailFast 會先寫進應用程式記錄檔再立即結束而多留下一份佐證資料,但在未處理例外的路徑上呼叫,WER 留下的傾印的原因會從原本的例外被換成 FailFast,因此要挑選呼叫位置的圖。Environment.FailFast寫進事件記錄檔後立即結束佐證資料多一份在未處理例外的路徑上呼叫傾印的原因被換成 FailFast

圖 13: FailFast 會多留一份佐證資料,但在未處理例外裡呼叫會讓原因被換掉。

6. 各框架的注意事項

6.1 .NET 共通:AppDomain.CurrentDomain.UnhandledException

它拿來當 最後的通知 很有用。 但要避免在這裡做耗時的復原處理。

用法的基本原則很簡單。

  • 寫下最終當機標記
  • 需要的話在 Windows 事件記錄檔留下最小限度的訊息
  • 不繼續執行
  • 不要在這裡等待或重試

UnhandledException 很方便,但 不要把「能在這裡把應用程式救回健康狀態」當成前提,比較安全。

6.2 WinForms:Application.ThreadException

它麻煩的地方在於,能攔下 UI 執行緒的未處理例外,讓程式表面上繼續跑下去。

拿來把業務輸入這種預期之內的錯誤變成對話方塊還說得過去, 但 不適合用在程式錯誤引發的 unexpected 例外還要繼續執行的場合。

如果真的要優先做原因調查,

  • 在 ThreadException 裡只做最小限度的記錄
  • 或是改用 UnhandledExceptionMode.ThrowException
  • 然後結束處理程序,留下傾印與日誌

這樣比較安全。

6.3 WPF:Application.DispatcherUnhandledException

WPF 的情況很像。

  • 主要對象只有 UI 執行緒上的例外
  • 設成 Handled = true 就能表面上繼續跑
  • 但面對程式錯誤這樣做,畫面狀態和內部狀態很容易對不上

所以在 WPF 也一樣,不要拿它當成硬撐下去的手段,而是當成記錄的入口 比較穩當。

不要用 UI 事件硬撐下去說明 WinForms 的 ThreadException 與 WPF 的 DispatcherUnhandledException 雖然能讓程式表面上繼續跑,但面對程式錯誤時畫面狀態與內部狀態會對不上,應當成記錄的入口,結束後留下傾印與日誌比較安全的圖。UI 執行緒的未處理例外表面上還能繼續跑畫面與內部狀態對不上當成記錄的入口結束後留下傾印與日誌

圖 14: WinForms / WPF 的 UI 事件,要當成記錄的入口而不是硬撐的手段。

6.4 不要把 TaskScheduler.UnobservedTaskException 當成主要路徑

它並不是 「當掉前的最後一道防線」。

拿來輔助偵測 Task 漏接的例外可以, 但 當成當機時可靠的記錄路徑就太弱了。

所以,

  • 及早發現漏看的例外
  • 在開發階段把 Task 設計上的疏漏揪出來

這些用途可以用,但 不要讓它當最終當機處理常式的主角。

6.5 native Win32 / C++:不要過度信任 SetUnhandledExceptionFilter

在 native 這一側,很容易就想把希望寄託在 SetUnhandledExceptionFilter 上。

不過它 在 faulting thread 的內容上執行,所以會受到

  • 無效的堆疊
  • 很深的遞迴
  • 已經壞掉的堆積
  • 例外發生時仍持有的鎖

這些狀況影響。

因此把 SetUnhandledExceptionFilter 想成 接收最後通知的 best effort 入口,剛剛好。

6.6 native C++ 也要接住 CRT 的終止路徑

在 native C++ 只盯著未處理的 SEH 會漏掉東西。

具體來說,要一併看下面這幾個。

  • _set_invalid_parameter_handler
  • _set_purecall_handler
  • set_terminate

這一系列是用來接住 由 C 執行階段或 C++ 執行階段發起的「終止路徑」。

實務上,

  • 在這些處理常式裡也寫下最終當機標記
  • 但不做耗時的復原處理
  • 確實結束處理程序
  • 主要佐證資料交給 WER / dump

這樣比較穩當。

只有 SEH 會漏掉說明在 native C++ 只盯著 SetUnhandledExceptionFilter,會漏掉由 CRT 或 C++ 執行階段發起的終止路徑,因此也要在 invalid parameter、purecall、terminate 的處理常式留下最小限度的記錄,主要佐證資料交給 WER 與 dump 的圖。只有 SetUnhandledExceptionFilter漏掉 CRT 的終止路徑連 invalid parameter / purecall / terminate 也接住只寫最小限度的記錄並確實結束主要佐證資料交給 WER / dump

圖 15: native C++ 要在 SEH 之外再接住 CRT 端的終止路徑,才不會有漏。

7. 以 WER LocalDumps 為基礎

這一塊在實務上相當強。

7.1 首選是 WER LocalDumps

以 「當掉之後盡量確實地留下最低限度的佐證資料」 這個目的來說, WER LocalDumps 是最好上手的。

理由很單純。

  • 可以由 OS 端留下傾印
  • 不用額外工具就能導入
  • 可以用應用程式為單位設定
  • 能把當機時的主要佐證資料移到 in-process 之外

只看日誌看不出來的

  • 是哪一條執行緒當掉
  • 在哪個堆疊上當掉
  • 是哪個模組界線
  • managed / native / COM / SDK 哪一邊可疑

這些事後都能查看,這一點很強。

WER LocalDumps 強在哪裡說明 WER LocalDumps 可以由 OS 端留下傾印、以應用程式為單位設定,把主要佐證資料移到 in-process 之外,而且事後還能查看是哪一條執行緒在哪個堆疊當掉、是哪個模組界線的圖。WER LocalDumps由 OS 端留下傾印可以用應用程式為單位設定把主要佐證資料移到 in-process 之外看得到執行緒、堆疊、模組界線

圖 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 一定要查看 dump 儲存位置的 ACL

日誌和傾印都一樣,設到寫不進去的資料夾就沒有意義。

特別是在

  • Windows 服務
  • 做了權限分離的子處理程序
  • 現場電腦上的受限帳戶
  • 牽涉到 UAC

這些場合,儲存位置的 ACL 是撲空的主因。

儲存位置要確認到

  • 事先建立
  • 寫入測試
  • 限制保留數量
  • 維運人員是否進得去查看

這幾點。

避免儲存位置的 ACL 讓傾印撲空說明 Windows 服務或受限帳戶被指到寫不進去的資料夾而撲空是典型的失敗,因此儲存位置要事先建立,並確認寫入測試、保留數量限制,以及維運人員是否進得去查看的圖。要避免的話服務、受限帳戶設到寫不進去的資料夾傾印撲空事先建立並做寫入測試也要確認保留數量與是否進得去查看

圖 17: 傾印撲空的主因是儲存位置的 ACL,用事前的寫入測試來避免。

7.4 想把目前的日誌附加到 WER 報告時

如果要用送往 Microsoft 的 WER 報告,或自行維運的 WER 流程,也可以用 WerRegisterFile 做 把目前的日誌檔納入錯誤報告的註冊。

不過把這裡看成 本機儲存之外額外多出來的一條路徑,而不是替代品,比較安全。 因為當機時真正想要的,首先是 在手邊那台電腦上盡量確實地留下來。

順序上,

  1. 本機的一般日誌
  2. 本機的 fatal 標記
  3. 本機的 dump
  4. 需要的話再用 WER 傳送路徑註冊相關檔案

這樣比較貼近實務。

7.5 不只留傾印,也要留下版本管理的資訊

就算取到傾印,事後如果

  • 沒有當時的 EXE / DLL
  • 沒有 PDB
  • 不知道是哪個 commit 建置的

那就會弱掉很多。

至少下面這些要留著。

  • 散發出去的二進位檔
  • 對應的 PDB
  • 版本
  • 建置的日期時間
  • commit 識別碼
  • 安裝程式的版本

傾印的收集與 PDB 的保管是 一組的。

傾印收集與 PDB 保管是一組的說明就算取到傾印,若沒有留下當時的 EXE 與 DLL、PDB,以及是哪個 commit 建置的,事後就會讀不出來,因此散發的二進位檔、PDB、版本、commit 識別碼的保管要和傾印收集一起做的圖。所以只留下傾印沒有 PDB 與當時的二進位檔事後讀不出來保管散發的二進位檔與 PDB版本與 commit 識別碼也要留

圖 18: 傾印要有同一次建置的 PDB 與二進位檔留著,才讀得出來。

7.6 從取得傾印到第一次打開它

「傾印堆了一堆,卻沒有人打開過」真的很常見。深入的部分留給專門的文章,這裡只放 最初的十分鐘份。

準備工作有兩項。

  1. 把和那個 dump 同一次建置的 PDB 與散發二進位檔 收到同一個資料夾
  2. 準備好 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 的版本管理。

打開傾印的最初十分鐘說明先收集同一次建置的 PDB 與散發二進位檔,用 WinDbg 打開傾印,移動到例外發生時的內容再讀分析結果,若不出現函式名稱就表示 PDB 來自不同次的建置,要回到版本管理的最初流程的圖。沒出現收集同一次建置的 PDB 與二進位檔用 WinDbg 打開傾印移動到例外發生時的內容再分析忘了 .ecxr 就會看到 WER 端的等待堆疊是否出現函式名稱與行號PDB 來自不同次建置,回到版本管理

圖 19: 傾印分析從準備、開啟、移動到例外內容這個順序開始。

更深入的收集與分析,整理在文末的相關文章裡。

8. 使用 MiniDumpWriteDump 或自製當機回報程式時的思路

有些場合確實需要自己實作。

  • 想在 UI 上放一個「儲存診斷資訊」按鈕
  • 想把日誌與設定檔一起打包
  • 想把一群子處理程序一起處理
  • 想在自動上傳前加上自訂的遮蔽處理

不過這裡最重要的是,不要讓取得 dump 的處理也全背在當掉那一側。

8.1 比起 self-dump,交給另一個處理程序更好

MiniDumpWriteDump 很強大, 但 與其從當掉的那個處理程序內部呼叫,從另一個處理程序呼叫更安全。

典型的構成長這樣。

  • worker 本體偵測到異常
  • 可以的話用事件或具名管道通知 helper
  • helper 取得 worker 的 dump
  • helper 把 tail 日誌與設定檔打包起來
  • helper 在結束後放進上傳佇列

這樣一來,就算 worker 壞掉,helper 那邊還是健全的。

由另一個處理程序 helper 取得傾印說明 worker 本體偵測到異常後用事件或具名管道通知 helper,健全的 helper 取得 worker 的傾印,把日誌與設定檔打包,並在結束後放進上傳佇列的流程的圖。helperworker 本體helperworker 本體worker 壞掉時 helper 仍然健全用事件或管道通知異常取得 worker 的 dump打包 tail 日誌與設定檔結束後放進上傳佇列

圖 20: dump 的取得交給健全的 helper 處理程序,而不是當掉的那一側。

8.2 真的只能 in-process 的話,就收攏到專用執行緒

就算沒辦法拆成另一個處理程序, 留一條專用執行緒只做 dump 也會好一些。

但即使如此,本質上仍然是 best effort。 不會因為「加了自製的 dump 實作」就 100% 安心。

8.3 耗時的事情挪到下次啟動後

自製的回報程式上,很容易就想做這些事。

  • zip 壓縮
  • 和 symbol 資訊互相比對
  • 上傳到伺服器
  • 螢幕擷取
  • 從資料庫取得額外資訊

這些要挪到 重新啟動之後或 helper 那一側,而不是當機的當下。

9. 加入監控處理程序之後會有什麼不同

長時間運轉的系統,監控處理程序相當管用。

9.1 監控處理程序會留下什麼

在 watchdog / launcher / 父服務那一側,可以留下這些資訊。

  • 子處理程序的啟動時間
  • 啟動引數
  • PID
  • 監控對象的版本
  • 最後一次收到 heartbeat 的時間
  • 結束時間
  • exit code
  • restart 次數
  • 有沒有 dump
  • 有沒有重新啟動

光是有這些,

  • 是不是真的當掉了
  • 是不是 OS 關機
  • 是不是使用者自己關掉
  • 是不是停止回應之後被 kill
  • 重新啟動迴圈跑了幾次

就都看得相當清楚。

watchdog 的記錄能分辨出什麼說明監控處理程序留下 exit code 與結束時間、最後一次收到 heartbeat 的時間、restart 次數之後,就能從外部分辨是真的當掉、OS 關機、使用者關掉、停止回應後被 kill,還是重新啟動迴圈的圖。exit code 與結束時間能從外部分辨最後一次收到 heartbeatrestart 次數是當掉、關機,還是使用者關掉

圖 21: 有 watchdog 的記錄,就能從外面分辨結束的種類。

9.2 特別適合的情況

可以積極考慮做拆分的,是下面這些情況。

  • 內含 vendor SDK 的 worker
  • 影像處理 / 影片處理 / device I/O
  • 監控或輪詢的父迴圈
  • 執行指令碼或外掛程式
  • 承載既有的 COM / ActiveX 資產
  • 64 位元 / 32 位元橋接或互通

把危險的處理關進單一個 worker,日誌設計和復原設計都會輕鬆很多。

10. 常見的錯誤做法

這一節收集的是 設計層級的陷阱。「當機處理常式裡不能做什麼」這種操作步驟層級的說明整理在 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
  • proxy
  • 驗證
  • 逾時
  • 等待重送

全部都會壓在已經當掉的內容上。

要傳出去就放到 重新啟動之後。

10.4 有 dump,卻和一般日誌連不起來

這種情況很多。

  • 傾印檔名裡沒有 session
  • 日誌那邊沒有 PID / session
  • watchdog 那邊沒有 PID
  • build 編號對不上

結果就是 三份佐證資料看起來像三件不相干的事。

佐證資料連不起來的失敗說明傾印檔名裡沒有 session、日誌那邊沒有 PID 或 session、build 編號對不上時,傾印、日誌、watchdog 記錄這三份佐證資料就會看起來像三件不相干的事的圖。傾印檔名裡沒有 session三份佐證資料看起來像三件事日誌裡沒有 PID / sessionbuild 編號對不上

圖 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 已經以應用程式為單位設定好
  • dump 儲存位置的 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 in-process 做不到時,watchdog 那邊能不能記下 unexpected exit
重新啟動 下次啟動後的通知、回收、上傳會不會動

重要的是 不要停在「有例外飛出來應該就會有日誌」,而要確認「在這個條件下會留下這個檔案」。

13. 總結

想讓 Windows 應用程式因程式錯誤引發的例外而當掉之後,仍留下調查所需的資訊,思考的主軸其實相當單純。

  • 不要只指望當掉那一側的處理程序
  • 拆成一般日誌、最終當機標記,以及 OS 或另一個處理程序留下的佐證資料
  • 當機時只在本機短短留下一筆
  • 耗時的處理挪到重新啟動後或另一個處理程序
  • 以 WER LocalDumps 為基礎
  • 比起繼續執行,以記錄後結束為原則

說到底, 比起「拚最後那一行」,「打造一套就算沒有最後那一行也追得下去的架構」 更強。

不依賴最後一行的架構說明比起拚最後那一行,打造一套沒有最後一行也追得下去的架構更強,而即使如此仍要把最終當機標記短短留在另一個檔案,主要佐證資料交給 WER 的傾印與當掉前的一般日誌的圖。要追求的形狀沒有最後一行也追得下去的架構標記短短留在另一個檔案主要佐證資料是傾印與一般日誌

圖 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

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

當掉的應用程式靠自己能確實留下日誌嗎?
不能。把堆疊毀損、記憶體毀損、fast fail、強制終止、斷電都算進來之後,in-process 的最後一筆日誌本質上就是 best effort。實務上會分成三層:平時的時序日誌、當掉瞬間的最終當機標記,以及由 OS 或另一個處理程序留下的當機佐證資料,讓整套設計不只指望當掉的處理程序內部。最穩當的組合是一般日誌 + 最終當機標記 + WER LocalDumps。
當機處理常式裡不能做哪些事?
所有耗時的處理。從 DI 容器解析 logger、async/await、等待鎖、組出複雜的 JSON、操作 COM 物件、彈出 UI 對話方塊、壓縮、HTTP/SMTP/Slack 傳送,都有很高的機率會出事。要做的只收攏成四件:防止重複進入、只寫一行、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 確實能讓程式表面上繼續跑,但很容易做出殭屍狀態:畫面還在而 worker 已經死了,按鈕還能按卻不知道到底存檔成功沒有。未處理例外事件應該當成記錄的入口而不是復原裝置,寫完最終當機標記就不要繼續,直接結束處理程序,主要的佐證資料交給 WER 的傾印與當掉前的一般日誌,這樣比較安全。結束時可以考慮 Environment.FailFast 這類立即終止的 API。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽