JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

消毒器三劍客:ASan、UBSan、TSan

Valgrind 靠著把你的程式放進一顆軟體模擬的 CPU 裡執行來抓記憶體錯誤;消毒器(sanitizer)做的事更聰明——編譯器把檢查直接織進你的程式碼,於是錯誤一發生,就會又快又大聲地自我舉發。本篇帶你認識 ASan、UBSan、TSan 這三個旗標,它們把無聲的記憶體損毀,變成一份精確到行的報告。

一個與 Valgrind 不同的點子

上一篇示範了 Valgrind 的 memcheck 如何獵捕記憶體錯誤。它的招數是「根本不執行你真正的程式」——而是把你的機器碼放進一顆軟體模擬的 CPU 裡跑,從外部盯著每一次記憶體存取。這讓它對「自己從未重編過」的程式碼擁有透視眼,連你毫無原始碼的函式庫也看得穿。代價很高:在 Valgrind 之下,程式通常會慢上 10 到 50 倍,因為每一次載入與儲存都是被「直譯」、而非直接執行。本篇的消毒器則用一條完全不同的路,抵達同一個目標——把無聲的損毀,變成一份大聲而精確的報告。

這個點子叫做編譯器插樁(compiler instrumentation):當你帶著消毒器旗標建置時,編譯器本身會改寫你的程式,在那些「可能出錯」的操作周圍插入細小的額外檢查。產出的仍是你真正的原生程式碼,直接跑在真正的 CPU 上——只是內建了護欄。因為檢查是被編譯進去、而非被模擬出來,拖慢幅度溫和得多,常落在 2 倍上下,這意味著你可以在開發與測試的整段期間就一直開著它。但它的弱點,恰是 Valgrind 強項的鏡像:消毒器只能盯住「你」用旗標重編過的程式碼。你沒有重建的東西——一個預先編譯好的函式庫——會以未插樁、看不見的狀態執行。

ASan:替每一個位元組架上護欄

AddressSanitizer,幾乎總是被叫做 ASan,是你最常會伸手去拿的那一個。它抓的,正是動態記憶體那一階警告過你的那些記憶體錯誤:一次堆積緩衝區溢位、一次寫過區域陣列尾端的堆疊溢位、一次釋放後使用、一次重複釋放,以及程式結束時的洩漏。你只需在編譯與連結兩處都加上一個旗標就能開啟它:`gcc -fsanitize=address -g -O1 main.c`。其中 `-g` 帶進除錯資訊,好讓報告能指名檔案與行號;`-O1` 讓它不至於慢得難受,又不會把錯誤藏起來。

它怎麼知道某次寫入越界了?靠兩套機制協同運作。第一,ASan 用自己的版本替換掉 malloc() 與 free(),在每一塊配置的兩側都放上一圈被「下毒」的紅區(redzone)位元組。第二,它維護一張精簡的旁側對照表,叫做影子記憶體(shadow memory)——每一個影子位元組描述「對應的那 8 個真實位元組此刻是否可定址」。你程式做的每一次記憶體存取,前面都被編譯器插入了一道檢查:查一下這個位址的影子,若它說「已下毒」,就停下並報告。紅區是被下毒的,所以你一旦踏出陣列尾端一個位元組,緊接著的下一次存取就會觸動警報——不是晚一點、不是「也許」,而是當場。

這份「即時性」是 ASan 的招牌。跑一支單純有錯的程式,你拿到的,要嘛是一個距離真正錯誤很遠的區段錯誤、要嘛根本沒有崩潰,只因為那個脫韁的位元組碰巧落在某個無害之處。有了 ASan,你會拿到一次「停在出錯指令上」的暫停,外加一份可讀的報告:錯誤的種類(heap-buffer-overflow)、闖禍的確切行號,還有——漂亮的是——第二段回溯,標出那塊記憶體是在哪裡配置的;若是釋放後使用,則標出它在哪裡被釋放。對一次釋放後使用,它甚至把三件事都告訴你:你在哪裡配置、在哪裡釋放、又在哪裡剛剛非法地碰了它。

UBSan:抓住那些「語言律師」的陷阱

ASan 盯的是你「在哪裡」碰記憶體。UndefinedBehaviorSanitizer,即 UBSan,盯的則是你的程式有沒有做出某件 C 語言根本「不准它發生」的事。回想未定義行為那一階的硬道理:未定義行為不是「視平台而定」,也不是「編譯器高興怎樣就怎樣」。它是一份被你毀掉的合約,而最佳化器「被允許假設你永遠不會毀約」。這正是為什麼一次有號整數溢位、或一次越界索引,可以在 `-O0` 跑得好好的,卻在 `-O2` 無聲地損毀——編譯器是在「這個 UB 不可能發生」的假設下做最佳化的。

UBSan 把那個幽靈變得實體化。用 `gcc -fsanitize=undefined -g main.c` 建置,編譯器就會在每一個「藏著 UB 暗門」的操作之前插入一道檢查。在執行期,某個操作真要觸發 UB 的那一刻,UBSan 會印出一份精確的診斷——檔案、行號、以及哪裡出了錯——而不是放任最佳化器悄悄地在一個錯誤假設上層層堆疊。它涵蓋的菜單很廣:有號溢位、移位量超出寬度(對一個 32 位元 int 做 `x << 40`)、除以零、解參考一個空指標、未對齊的存取,以及失去意義的型別轉換。每一項都是一個獨立的子檢查,你可以個別開啟或關閉。

int sum(int a, int b) {
    return a + b;          // a + b can overflow INT_MAX -> UB
}

// built with: gcc -fsanitize=undefined -g add.c
// at runtime, calling sum(2147483647, 1) prints:
//
//   add.c:2:14: runtime error: signed integer overflow:
//   2147483647 + 1 cannot be represented in type 'int'
UBSan 指名出確切的檔案、行號、欄位與原因——把最佳化器無聲的假設攤在陽光下。

TSan:唯一看得見競爭的那一個

第三個消毒器解決一個 Valgrind 的 memcheck 與另外兩者都辦不到的問題:資料競爭(data race)。回想並行那一階:一次資料競爭是兩條執行緒在同一時間碰同一塊記憶體、卻沒有任何互斥鎖替它們排序,而其中至少有一方在寫入。競爭最令人抓狂之處,在於它依賴時序——它是一隻海森堡蟲,你一加上 print 或掛上除錯器,它就消失,因為這個動作本身就改變了時序。你無法可靠地重現它,於是也無法靠尋常手段可靠地抓到它。

ThreadSanitizer,即 TSan,徹底繞開了「重現」這件事。用 `gcc -fsanitize=thread -g main.c` 建置,它會為每一次記憶體存取「以及」每一次上鎖呼叫——pthread_mutex_lock()、執行緒建立、join——都插樁。它從這些資訊建立一個happens-before(發生在前)關係的模型:哪些事件可被鎖證明是有序的、哪些則不是。只要它看到兩條執行緒存取了同一個位置、彼此間沒有鎖所施加的次序、且至少一方是寫入,它就報告一次競爭——即便在這一次特定的執行裡,時序碰巧運作正常、什麼都沒有明顯壞掉。這才是它真正深刻的勝利:TSan 舉發的是那個「潛伏的」競爭,而不只是今天咬了你一口的那個。

一份 TSan 報告讀起來像一個有兩位敘事者的故事:它呈現出那兩次相衝突的存取、每條執行緒在碰到那個共享位置那一刻的堆疊回溯,以及每條執行緒當時握著(與沒握著)哪些鎖。最後這個細節是黃金,因為一次競爭的修法幾乎總是「在這次存取周圍握住正確的鎖」,而 TSan 剛剛已精確地指給你看:是哪一次存取沒被保護。誠實的代價:TSan 用掉多得多的記憶體(它為每個位置儲存存取歷史的影子),而且和所有消毒器一樣,它只看得見「你」插樁過的程式碼裡的執行緒之間的競爭。

把它們用好

幾個習慣能讓消毒器帶來回報、而非帶來挫折。第一條是你跳不過的規則:ASan 與 TSan 互斥——你不能在同一次建置裡同時用 `-fsanitize=address` 與 `-fsanitize=thread`,因為兩者都想獨占同一套影子記憶體機制。相對地,UBSan 很好相處,能與其中任一個搭著跑。所以慣常的配方是兩次建置:一次用 `-fsanitize=address,undefined` 抓記憶體與 UB 錯誤,另一次獨立地用 `-fsanitize=thread` 來獵捕競爭。

  1. 用消毒器旗標——在編譯與連結兩道指令都加——再加上 -g 以取得行號,建出一支測試或除錯用的執行檔;把你要出貨的正式版建置另外分開、不帶消毒器。
  2. 在它底下跑你的測試套件。消毒器只在「真的被執行到的程式碼路徑」上報告錯誤,所以你的測試操演越多輸入,它就找到越多——這正是為什麼 CI 流水線常會把整套測試以一個帶消毒器的建置跑一遍。
  3. 由上而下讀那份報告:第一段回溯的第一個框,通常就是該修的那一行,而配置、釋放、或第二條執行緒的回溯,會把故事剩下的部分告訴你。
  4. 修掉根本原因,再重跑到乾淨為止——並且把 CI 裡任何一則消毒器報告當成「硬性失敗」、而非一個可以延後處理的警告,因為在這裡被找到的真實錯誤,正是一個客戶會在正式環境裡踩到的錯誤。

最後,請保持整個除錯這一階所一再堅持的那份誠實視角。消毒器在「把一次模糊的崩潰變成一個指名道姓、指到行的錯誤」這件事上非凡出色,但它們是偵測器、不是證明:它們找到你的輸入所抵達的那些錯誤,而非每一個可能存在的錯誤。而這——越界寫入、釋放後使用、資料競爭、有號溢位——正是 Rust 語言被設計來「在安全程式碼中讓它們根本無法被表達出來」的那一類錯誤,在編譯期、而非執行期就把它們攔下。那是一個真實而不同的取捨,不是魔法:Rust 的借用檢查器有著貨真價實的學習曲線,而 `unsafe` 依然存在。消毒器,是你讓 C 與 C++ 保持誠實的方式;而搞懂「它們為什麼非存在不可」,正是搞懂「它們在抓什麼」的一半功夫。