跳到正文
Hacker News (official RSS)(tweedegolf.nl)·· 2 天前精選品質評分70

呼叫圖分析器協助尋找 Rust 程式中的 panic

Finding Panics with Call Graph Analyzer

重點導讀

Call Graph Analyzer (CGA) 是一個能從 Rust 編譯器的 monomorphized MIR 中抽取呼叫圖,並定位可能導致 panic 的程式碼路徑的工具。

推薦理由

本工具可透過 MIR 呼叫圖辨識 Rust 程式中的 panic 路徑,協助開發者提前修正潛在錯誤,提升程式可靠度,並提供視覺化輸出與白/黑名單配置,適合嵌入式與大型專案使用。

正文 · 中文翻譯

譯文尚未完整,完整內容請切換至原文。

我們經常被問到「如何在我的 Rust 程式碼中消除 panics?」已經有幾次嘗試解決這個問題。透過 Open Device Partnership 並與 Microsoft 合作,我們開發並發布了 Call Graph Analyzer (CGA),這是一個從 monomorphized MIR 提取 callgraph 的工具。這個 callgraph 可用來追蹤編譯後二進位檔中哪些函式可能會 panic。

我們經常被問到「如何在我的 Rust 程式碼中消除 panics?」已經有幾次嘗試解決這個問題。透過 Open Device Partnership 並與 Microsoft 合作,我們開發並發布了 Call Graph Analyzer (CGA),這是一個從 monomorphized MIR 提取 callgraph 的工具。這個 callgraph 可用來追蹤編譯後二進位檔中哪些函式可能會 panic。

例如,當你在程式碼上執行 cargo cga check 時,你會得到:

error: call to method `core::num::<impl u64>::strict_sub` which can panic
  --> src/main.rs:18:17
   |
18 |         let _ = 2u64.strict_sub(u64::MAX);
   |                 ^^^^^^^^^^^^^^^^^^^^^^^^^

在這篇部落格文章中,我們討論了這種新方法在尋找 panic 時相較於既有方法的優點與缺點。

那麼,為什麼要消除 panic 呢?

在討論 Call Graph Analyzer 之前,我們應先考慮 panic 為何有用,以及在哪些情況下最小化甚至完全消除任何可能的 panic 可能帶來好處。這個議題已被其他人廣泛探討 [1][2][3][4],但在此做一個總結還是有幫助。先從簡短說明開始:

Rust 提供了一種機制,讓函式無法正常回傳,而改為「panic」,這是對通常無法恢復的錯誤條件的回應 ...


Rust 參考手冊 - Panic

這些無法恢復的錯誤條件可能是由函式實作中的 bug 引起,或是違反函式的進入條件(即函式本身的 bug 或呼叫者的錯誤)。

一個例子是呼叫 1.strict_add(u32::MAX),如果呼叫者傳遞無效引數給該操作,會導致 panic。類似地,Rust 中的普通加法 1 + u32::MAX 在啟用 overflow-checks 時也會 panic。

不讓函式 panic,而是處理錯誤條件,可採用以下方式:

  1. making them recoverable by having the function return a Result<T, E> or Option<T> instead. For addition this is provided by u32::checked_add.
  2. making the function fail implicitly or silently and describing that behaviour in the function documentation. Arithmetic operations can for example wrap around with u32::wrapping_add.
  3. 讓函式永遠不回傳,例如使用無限 loop {}。有時在使用沒有超時或最大迭代次數的重試機制時,這種做法會不小心被採用。

然而追求零 panic 非常困難且相當愚蠢:程式碼很少能完全沒有假設。到處回傳 Result<T, AssumptionViolated> 只會將負擔推到呼叫堆疊上,最終還是會在根部中止。這實際上與直接 panic 的行為相同。

然而將 panic 明確化是有益的。瞭解你的 panic 路徑並進行推理,比隨意包含 … 更好;對 panic 路徑進行回歸分析也非常有用,能提醒你程式碼中新增的任何 panic 路徑。

說了這些,在以下情況下,消除部分 panic 可能是合理的:

Striving for no unrecoverable error conditions is extremely hard in practice. Generally it is better to implement top-level recovery mechanisms. In embedded this would entail using a Watchdog timer which resets the device if it is stuck, and a recovery image to restore the device to a working state. On a host machine this could mean catching unwinding panics using catch_unwind. Note that the latter is not fool-proof and comes with a few caveats. Externally accessible services should be redundant and fast to recover.

總結來說,找出非預期的 panic 是有用的。

先前技術

目前提取呼叫圖或偵測 panic 的方法包括:

你可以下載任何這些專案,除 Charon(並非明確針對此用途)之外,都很容易上手。

連結器級別的 panic 偵測方法是透過在連結器嘗試連結特殊 panic 處理程式時觸發錯誤來實現。此偵測方式僅在你的二進位檔中**沒有任何 panic**時有效。它亦僅適用於二進位檔,無法用於庫。

ELF 斷錯資訊分析會檢視包含於 ELF 工件(如二進位檔和編譯成物件檔的 Rust 庫)中的 DWARF 斷錯資訊。它在能提供的呼叫圖資訊上有限制:panic 機制可能在編譯器階段完全內聯或在連結階段消失。

最後,我們還有基於 HIR、MIR 以及 rust-analyzer 的方法,這些方法利用編譯器(以及 rust-analyzer)產生的各種中間表示(IR)來擷取呼叫圖。從某種意義上說,所有這些方法都會將你的原始 Rust 程式碼交給編譯器處理,並輸出它所認為正在發生的事情。理論上,這些方法適用於任何 Rust crate。

基於 HIR 的分析只能分析你特定 crate 的程式碼。這種分析可以檢查你是否在自己的 crate 中明確寫了 panic!。然而,它對依賴項中的任何程式碼(例如標準函式庫)都不瞭解。因此,它無法在依賴項內發生 panic 時觸發。編譯器也可能插入像邊界檢查這樣的斷言,這些斷言在根據你的編譯器旗標而在程式碼生成階段非常晚才會成為 panic 呼叫。

基於 MIR 的分析是理想的,因為它理論上可以看到所有來自依賴項的程式碼,在它們被最佳化掉之前,包括源自標準函式庫的 panic。因此呼叫圖分析器(Call Graph Analyzer)採用此方法。唯一的缺點是此分析在 LLVM 能夠移除不可達程式碼之前完成,因此會產生一些假陽性。

上述所有方法都利用編譯器在編譯你的 crate 時已經在做的工作,並中斷編譯過程,檢視編譯狀態快照以判斷你是否呼叫了 panic。

rust-analyzer 的做法稍有不同,因為它完全不依賴編譯器。rust-analyzer 複製了編譯器的 Rust-HIR-MIR 管道,並將其改造為自己的流程。這個為 rust-analyzer 單獨的管道相當快,能夠儲存在記憶體中,並支援在你主動編寫和修改 Rust 程式碼時即時更新狀態。然而,它並不總能分析 rustc 能順利編譯的程式碼。最突出的是它往往難以處理複雜的 proc_macro,就像我們在 Rust Embedded 中大量使用的那樣。作為終端使用者,你會在程式碼編輯器停止提供任何有用建議時感知到這一點。(rust-analyzer 背後的團隊正在努力防止這種情況!)rust-analyzer 的優點是它不依賴 Rust 編譯器。

CGA 的工作原理

針對我們的使用場景,我們需要一種方法:

我們決定採用基於 MIR 的呼叫圖匯出器,隨後演變為呼叫圖分析器(CGA)。接下來,我們將討論 CGA 的設計與功能。

rustc-driver

CGA 直接以 rustc-driver 的方式從 MIR 提取呼叫圖。Clippy 同樣是一個 rustc-driver。與 Clippy 一樣,CGA 因此依賴於特定版本的 Rust 編譯器,這就導致了 CGA 的第一個真正缺點:

請注意:CGA 用特定(可能不同)的 rustc 編譯器版本、標準庫以及可能不同的編譯器標誌來編譯你的 crate。因此,你得到的呼叫圖不會與二進位制檔案中包含的 exact 呼叫圖完全一致。

使用 rustc-private 將特定(nightly)版本的 rustc 作為依賴項,也意味著 CGA 不能作為 crate 從 crates.io 安裝。相反,必須在本地檢出倉庫,然後執行 cargo install --path .(它會自動使用特定版本的 Rust 工具鏈)。

此使用者體驗在 rustc_public 包含 CGA 所需的一切時可能會更佳。雖然這種情況實際上不太可能。相反地,如果 CGA 變得足夠流行,它可能會與 rustc 同時維護,就像 Clippy 目前擁有的特殊地位一樣。rustc 的任何破壞性變更也會套用到 Clippy,你可以使用 Rustup 為你的特定工具鏈安裝 Clippy。欲瞭解更多資訊,您可觀看 Alona Enraght-Moony 所講的精彩演說 "Out of tree access to the compiler state"。

作為 rustc-driver,CGA 二進位檔具有與 rustc 本身相同的 API。您可以使用 cargo 以 cargo cga check 執行 CGA。內部上 rustc 的執行方式相當於執行 cargo check。然而 CGA 會額外進行工作以收集呼叫圖,對於典型的 crate 而言,使用 CGA 編譯大約比執行 cargo check 慢 2 倍至 3 倍。

MIR 遍歷

當 CGA 完成執行所有通常屬於 cargo check 的編譯器階段後,它會強制編譯器產出所有所謂的 roots 或 entrypoints(根/入口點)之您的 crate。直觀上,這指的是編譯後從外部可存取的 crate 任意部分。這些包括:

  • fn main 以及其變體
  • 語言 _start 符號
  • 標記為 extern、並使用 no_mangle 或 export_name 具有穩定名稱的函式,包含 Embedded Rust 的中斷處理程式。

針對此分析,我們明確不關心所有在 const 區塊編譯的函式,包括 static 值初始化,因為只有這些計算結果會包含在最終二進位檔中。

從這些根向外,我們透過遍歷 MIR 樹進行可達性分析。每當呼叫一個函式時,該函式亦會被探索。因此未使用的函式不會被訪問。在 Rust(如同 C++)中,帶有泛型引數的函式在使用具體型別呼叫時會被 monomorphized(單態化)。

因此我們不僅收集被呼叫的函式,還收集每個函式的具體例項化,因為實作可能因傳遞的型別引數而大相徑庭。

考慮以下情況時,此過程會變得略為繁瑣:

  • dyn 物件,其實際呼叫的 trait 實作不明確。CGA 假設在發現該函式時,會呼叫 所有 vtable 條目。當為標準函式庫的 trait(如 Debug trait)構造 dyn 物件時,這種情況尤為 有趣 大。
  • function pointers 可由 Rust 函式建立。我們無法必然推斷這些指標會發生什麼。於是我們假設任何已建立的函式指標在稍後階段會被呼叫。
  • 每個 async 區塊隱含一條 panic 路徑。欲瞭解更多,請參閱 "Async Rust never left the MVP state" by Dion 的部落格文章。

在遍歷完您的 crate 及其依賴的 MIR 樹後,我們得到一組呼叫圖。接下來,我們可以選擇將它們 extract 為 JSON 或 GraphViz dot 檔,或將它們 check 以處理任何 panic 或被列入黑名單的函式。

在提取時,所有呼叫圖都放在您的 /target 資料夾中,檔名為 <crate>.callgraph.json。最後我們將它們合併並匯出具有獨立根的全域呼叫圖,使用:

cargo cga extract -Tdot -o callgraph.dot

為快速說明其外觀,請參考以下使用 dyn 物件的範例:

trait Test {
    fn test(&self) {
        panic!("Default impl")
    }
}

struct StrictAdd;
impl Test for StrictAdd {
    fn test(&self) {
        let _ = 2u64.strict_add(u64::MAX);
    }
}
struct StrictSub;
impl Test for StrictSub {
    fn test(&self) {
        let _ = 2u64.strict_sub(u64::MAX);
    }
}

struct DefaultImpl;
impl Test for DefaultImpl {}

fn main() {
    let object = StrictAdd;
    let dyn_object: &dyn Test = &object;
    dyn_object.test();
}

結果得到一個圖,對於 vtable 中的每個條目都有一個箭頭:

Graphviz Callgraph example

圖 1:Graphviz 呼叫圖範例(您可以放大以提高可讀性)

As you can imagine, this quickly becomes quite large and unreadable. If you have a suggestion for a GUI application that can meaningfully display callgraphs, please reach out as that would be very helpful!

Linting 與診斷

其次,像 Clippy 一樣,我們可以透過執行以下指令,請 CGA 檢查程式碼中任何可能的 panic 路徑:

cargo cga check

這將使用呼叫圖尋找 panic 路徑,並嘗試提供有用的洞見,說明此 panic 路徑與你的 crate 的關聯。上述程式碼所產生的其中一個 lint 如下:

error: call to method `core::num::<impl u64>::strict_sub` which can panic
  --> src/main.rs:18:17
   |
18 |         let _ = 2u64.strict_sub(u64::MAX);
   |                 ^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: 3 different panics from this location
   = note: backtrace (1 of 2):
           0: std::rt::panic_fmt (called)
           1: core::num::imp::overflow_panic::sub (called)
           2: core::num::<impl u64>::strict_sub (called) <--
           3: <totallysafe::StrictSub as totallysafe::Test>::test (possible vtable resolution)
           4: <dyn totallysafe::Test as totallysafe::Test>::test - virtual#3 (called)
           5: totallysafe::main (entrypoint)

請注意,它顯示了回溯、可能的 vtable 解決方式,以及從我們目前 crate 內部的最後一次呼叫。這些診斷是以 啟發式 方式產生的,意思是從所有 panic 路徑中,我們為本地 crate 的每個呼叫點選擇一個。我們嘗試使用評分機制挑選所有這些路徑中 最佳的。

白名單與黑名單

對於任何未以減少 panic 為目標開發的嚴肅 crate,CGA 產生的 lint 數量實在難以管理。為此,CGA 提供了幾個可設定的引數。

舉例來說,如果你信任某個函式不會 panic(即使它仍會產生 panic 路徑),可以將該函式加入白名單。任何包含該函式的 panic 路徑都會被忽略。這些規則可以匹配簡化的函式名稱,也可以匹配完整的 canonical Rust 路徑,包含所有泛型引數。

另外,函式也可以加入黑名單。除了 panic,若你希望某個 crate 及其相依套件 永遠不使用某個函式,亦可將其排除。例如,你可能想排除與檔案系統、網路或程序的互動。

blacklist = [
    { path = "^std::process::" },
    { path = "^std::fs::" },
    { path = "^std::sys::" }
]

cga.toml 配置檔範例

接著,如果我們將以下程式送給 CGA:

fn test() {
    let _ = std::process::Command::new("evil");
}

fn main() {
    test();
}

我們得到以下結果:

error: call to associated function `std::process::Command::new` which is blacklisted
 --> src/main.rs:7:13
  |
7 |     let _ = std::process::Command::new("evil");
  |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: 101 different blacklisted functions from this location
  = note: backtrace:
          0: std::process::Command::new::<&str> (called) <--
          1: fail_blacklist::test (called)
          2: fail_blacklist::main (entrypoint)
note: the lint level is defined here
 --> src/main.rs:3:11
  |
3 | #![forbid(cga::blacklisted)]
  |           ^^^^^^^^^^^^^^^^

error: call to drop of `std::process::Command` which can call blacklisted functions
 --> src/main.rs:7:47
  |
7 |     let _ = std::process::Command::new("evil");
  |                                               ^
  |
  = note: 101 different blacklisted functions from this location
  = note: backtrace (1 of 20283553):
          0: std::sys::fs::unix::debug_assert_fd_is_open (called)
          1: <std::os::fd::OwnedFd as std::ops::Drop>::drop (called)
          2: drop of `std::os::fd::OwnedFd` (called)
          3: drop of `std::sys::fd::unix::FileDesc` (called)
          4: drop of `std::sys::process::unix::common::Stdio` (called)
          5: drop of `std::option::Option<std::sys::process::unix::common::Stdio>` (called)
          6: drop of `std::sys::process::unix::common::Command` (called)
          7: drop of `std::process::Command` (called) <--
          8: fail_blacklist::test (called)
          9: fail_blacklist::main (entrypoint)

請注意,此功能並不真正阻止在任何你的 crate 或相依套件中產生程序:在 Rust 中,永遠可能鏈結到外部物件檔或直接使用組譯碼,而 CGA 無法分析。儘管如此,CGA 的黑名單功能仍是一項有趣的能力。

注意事項

在說明 CGA 能做與不能做的事之後,瞭解其方法存在的一些注意事項與缺點是有益的。

脆弱性

遍歷已多型化的 MIR 並非簡單。解析 vtable 相當棘手;編譯器有時會插入 shim,實際呼叫則在稍後插入;協程需拆解以尋找 async 路徑;還有隱含斷言最終也可能成為 panic。簡言之:分析本身艱鉅,且不可避免地會有部分不完全正確。當工具鏈更新時,產生的 MIR 可能會大幅變化,進而影響呼叫圖。未來若 rustc 行為改變,某些分析部份可能失效。

這是預期之內:Rust MIR 不穩定,且在可預見的未來仍將如此。因此 CGA 會略顯脆弱。

最佳化

根據你的編譯器旗標,尤其是你的 opt-level,CGA 收集的 MIR 可能包含不可達的 panic。

例如,表示式 1 + 1 包含了一個隱式的溢位邊界檢查。若你進行 const 評估,顯而易見該表示式永遠不會 panic。使用 opt-level=2(最佳化速度)時,該表示式在 CGA 取得 MIR 之前就已由較早的編譯器階段最佳化為 2。然而使用 opt-level="z"(最佳化大小)時,panic 路徑會被包含在 MIR 中。相反地,最佳化交由 LLVM 執行(它會執行)。

編譯器和 cargo 選項對於輸出的呼叫圖(callgraph)非常重要,與它們對 rustc 產生的最終二進位檔以及連結器所產生的影響相似。

我建議大家在 CGA 中使用 opt-level=2,即使釋出的二進位檔使用不同的設定,因為 通常 使用 opt-level=2 不會遺漏任何可達的 panic(因此偵測到的假陽性更少且沒有假陰性)。

工具預期 rustc

CGA 已在幾個實際二進位檔上進行測試,通常能即時運作。然而有時也會失敗。這可能是因為專案尚未以 build-std 編譯(CGA 需要),或是因為 build.rs 期望應用程式以標準 rustc 建置,而非像 CGA 那樣以 rustc-driver 建置(範例)。

若 CGA 在您的專案中不起作用,或呼叫圖看起來不正確,請 開啟 Issue!工具只能透過在實際軟體上測試才能進化。

試試看

使用 Call Graph Analyzer (CGA),您可以擷取呼叫圖並偵測 panic。若您想知道您的應用程式隱藏了多少 panic,即使您未使用 unwrap,請試用並 告訴我們 您的發現!

特別感謝 Jyn 的 這篇部落格文章 以及 Ferrocene 的工作,這些都深深啟發了我們在 Call Graph Analyzer 上的工作。

來源: Hacker News (official RSS) (tweedegolf.nl)
開啟原文