使用 Call Graph Analyzer 查找 panic

Lobsters Hottest 工具

摘要

Tweede golf 与 Microsoft 在 Open Device Partnership 框架下合作发布了 Call Graph Analyzer(CGA),这是一款 Rust 工具,可从单态化的 MIR 中提取调用图,用于追踪已编译二进制文件中可能发生 panic 的函数,可通过 `cargo cga check` 运行。

<p><a href="https://lobste.rs/s/opqtiv/finding_panics_with_call_graph_analyzer">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/10/28 01:53

# 使用 Call Graph Analyzer 查找 panic — Tweede golf 来源:https://tweedegolf.nl/en/blog/242/finding-panics-with-call-graph-analyzer/ **我们经常被问到:"如何在我的 Rust 代码中消除 [panic](https://doc.rust-lang.org/reference/panic.html)?"已经有许多尝试来解决这个问题。在 [Open Device Partnership](https://opendevicepartnership.org/) 的框架下,与 Microsoft 合作,我们开发并发布了 [Call Graph Analyzer(CGA)](https://github.com/opendevicepartnership/cga),这是一个用于从[单态化](https://rustc-dev-guide.rust-lang.org/backend/monomorph.html)的 [MIR](https://rustc-dev-guide.rust-lang.org/mir/index.html) 中提取[调用图](https://en.wikipedia.org/wiki/Call_graph)的工具。这个调用图可以用来追踪编译后的二进制文件中哪些函数可能会 panic。**我们经常被问到:"如何在我的 Rust 代码中消除 [panic](https://doc.rust-lang.org/reference/panic.html)?"已经有许多尝试来解决这个问题。在 [Open Device Partnership](https://opendevicepartnership.org/) 的框架下,与 Microsoft 合作,我们开发并发布了 [Call Graph Analyzer(CGA)](https://github.com/opendevicepartnership/cga),这是一个用于从[单态化](https://rustc-dev-guide.rust-lang.org/backend/monomorph.html)的 [MIR](https://rustc-dev-guide.rust-lang.org/mir/index.html) 中提取[调用图](https://en.wikipedia.org/wiki/Call_graph)的工具。这个调用图可以用来追踪编译后的二进制文件中哪些函数可能会 panic。例如,当你在代码上运行 `cargo cga check` 时,你会得到: ``` error: call to method `core::num::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\](https://blog.reverberate.org/2025/02/03/no-panic-rust.html)\[2\](https://blog.cloudflare.com/making-rust-workers-reliable/)\[3\](https://blog.logrocket.com/panic-vs-error-rust/)\[4\](https://www.ncameron.org/blog/to-panic-or-not-to-panic/),但在这里总结一下仍然很有用。让我们先从一个简短的描述开始: > Rust 提供了一种机制来阻止函数正常返回,而是"panic",即对一种错误条件的响应,这种错误条件通常被认为不可恢复…… > > **The Rust Reference - Panic**(https://doc.rust-lang.org/reference/panic.html) 这些不可恢复的错误条件可能是由函数实现中的 bug 引起的,也可能是因为违反了函数的前置条件(即函数自身的 bug 或调用方的错误)。例如,调用 `1.strict_add(u32::MAX)` 会因为调用方传递了无效参数而触发 panic。同样,Rust 中的普通加法 `1 + u32::MAX` 在启用 [`overflow_checks`](https://doc.rust-lang.org/beta/std/intrinsics/fn.overflow_checks.html) 时也会 panic。 除了 panic 之外,错误条件可以通过以下方式处理: 1. 通过让函数返回 [`Result`](https://doc.rust-lang.org/std/result/enum.Result.html) 或 [`Option`](https://doc.rust-lang.org/std/option/enum.Option.html) 来使其可恢复。对于加法运算,这由 [`u32::checked_add`](https://doc.rust-lang.org/std/primitive.u32.html#method.checked_add) 提供。 2. 让函数隐式或静默地失败,并在函数文档中描述该行为。例如,算术运算可以用 [`u32::wrapping_add`](https://doc.rust-lang.org/std/primitive.u32.html#method.wrapping_add) 来进行回绕运算。 3. 让函数永不返回,例如使用无限的 `loop {}`。有时在实现没有超时或最大迭代次数的重试方案时,会意外使用这种方式。 然而,追求零 panic 是非常困难且相当愚蠢的:代码很少能完全不依赖任何假设。到处返回 `Result` 只是将负担向上推移到调用栈,直到你在根部不得不停止。这本质上与 panic 产生了相同的行为。 让 panic 变得*显式*是有意义的。了解你的 panic 路径并对其加以推理,比不小心引入 panic 却忽略它们的存在要好得多。对 panic 路径进行回归分析也非常有用,可以提醒你代码中引入的任何新 panic 路径。 话虽如此,在以下情况下消除某些 panic 可能是合理的: - 在代码体积真正重要的场景中,比如嵌入式设备。panic 会因为所需的字符串以及格式化这些字符串的相关代码而产生相对大量的代码。我们可以: - 使用 [`defmt::panic`](https://docs.rs/defmt/latest/defmt/macro.panic.html) 来减少这种字符串膨胀。这需要将所有 panic 包裹在该宏中。对于库依赖项,通常只有在库专门设计用于嵌入式平台时才会这样做(https://crates.io/crates/defmt/reverse_dependencies)。Defmt 不用于其他库,包括 Rust 标准库。 - 使用 [`-C panic=immediate-abort`](https://github.com/rust-lang/rust/issues/147286) 来完全阻止 panic 的生成,而是直接崩溃应用。但这不会留下太多可用于调试的信息。 - 在绝对不能失败的关键代码路径中,例如: - 对外可访问的服务,需要防止导致服务崩溃的拒绝服务(DoS)攻击。无效输入应该导致面向用户的错误。 - 嵌入式设备上的固件更新路径,包括引导加载程序。 - 在 panic 处理代码本身中。在 Rust 中,当[展开(unwinding)](https://doc.rust-lang.org/nomicon/unwinding.html) panic 时,`Drop` [的实现](https://doc.rust-lang.org/std/ops/trait.Drop.html#panics)中的 panic 会导致所谓的"双重析构(double-drop)",并可能使程序中止。其次,自定义 `#[panic_handler]` 中的 panic 是不被允许的,但此时应该发生什么尚不清楚(https://github.com/rust-lang/rust/issues/97146)。 在实践中,追求完全没有不可恢复的错误条件是极其困难的。通常更好的做法是实现顶层恢复机制。在嵌入式场景中,这意味着使用看门狗定时器在设备卡住时将其重置,以及使用恢复镜像将设备恢复到工作状态。在宿主机上,这可能意味着使用 [`catch_unwind`](https://doc.rust-lang.org/std/panic/fn.catch_unwind.html) 来捕获展开中的 panic。请注意,后者并非万无一失,还存在一些注意事项。对外可访问的服务应该是冗余的并且能够快速恢复。 总之,查找非预期的 panic 是有价值的。 ## 先前的工作 目前,提取调用图或检测 panic 的方法包括: - [rustig](https://github.com/Technolution/rustig)(ELF 调试信息分析) - [findpanics](https://github.com/philipc/findpanics)(ELF 调试信息分析) - [cargo-callgraph](https://github.com/robinmoussu/cargo-callgraph)(Rust HIR 分析器) - [cargo-callgraph](https://crates.io/crates/cargo-callgraph)(基于 rust-analyzer 的导出器) - [charon](https://github.com/AeneasVerif/charon)(Rust MIR 导出器) - [no-panic](https://crates.io/crates/no-panic)(基于链接器的 panic 检测) - [dont_panic](https://github.com/Kixunil/dont_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 的工作原理 对于我们的用例,我们需要一种方法来: - 检测所有和任意的 panic - 允许二进制文件的特定部分可以 panic - 与 async Rust 兼容(因为所有 async 块都有一个隐式的 panic 路径(https://tweedegolf.nl/en/blog/237/async-rust-never-left-the-mvp-state/)) - 与 Embedded Rust 配合良好 我们选择了一个基于 MIR 的调用图导出器,它成为了 Call Graph Analyzer(CGA)。接下来,我们将讨论 CGA 的设计和功能。 ### rustc-driver CGA 作为 [rustc-driver](https://rustc-dev-guide.rust-lang.org/rustc-driver/intro.html) 直接从 MIR 中提取调用图。Clippy 同样也是一个 rustc-driver。因此,与 Clippy 类似,CGA 依赖于特定版本的 Rust 编译器,这引出了 CGA 的第一个真正的缺点: 注意事项:CGA 使用特定(可能不同的)版本的 rustc 编译器、标准库,以及可能不同的编译器标志来编译你的 crate。因此,你不会得到与你的二进制文件中包含的*完全相同*的调用图。 通过 `rustc-private`(https://doc.rust-lang.org/unstable-book/language-features/rustc-private.html)依赖特定(nightly)版本的 rustc,也意味着 CGA 不能作为 crate 从 [crates.io](https://crates.io/) 安装。相反,需要先将仓库检出到本地,然后运行 `cargo install --path .`(这会自动使用特定版本的 Rust 工具链)。当 `rustc_public`(https://doc.rust-lang.org/nightly/nightly-rustc/rustc_public/index.html)包含 CGA 所需的一切时,这种用户体验可能会得到改善。不过,这种场景不太可能发生。更可能的情况是,如果 CGA 变得足够流行,它可能会与 rustc 一起维护,就像 Clippy 目前享有的特殊地位一样。rustc 中的任何破坏性变更也会应用到 Clippy,你可以使用 Rustup 为你的特定工具链安装 Clippy。有关更多信息,你可以观看 Alona Enraght-Moony 关于"对编译器状态的树外访问(Out of tree access to the compiler state)"的精彩演讲(https://www.youtube.com/watch?v=ExxxtADP-t8)。 作为 rustc-driver,CGA 二进制文件拥有与 rustc 本身相同的 API。你可以使用 `cargo` 通过 `cargo cga check` 运行 CGA。在内部,rustc 的运行方式与你运行 `cargo check` 时相同。然而,CGA 会做一些额外的工作来收集调用图,对于一个典型的 crate 来说,这意味着使用 CGA 进行编译大约比运行 `cargo check` 慢 2-3 倍。 ### MIR 遍历 当 CGA 完成运行所有通常属于 `cargo check` 的编译器阶段后,它会强制编译器输出你的 crate 的所有所谓的*根(roots)*或*入口点(entrypoints)*。直观地说,这是你的 crate 编译后从外部可以访问的任何部分。这些包括: - `fn main` 及其变体 - 语言的 `_start` 符号 - 标记为 [`extern`](https://doc.rust-lang.org/reference/items/functions.html#extern-function-qualifier) 且通过 [`no_mangle`](https://doc.rust-lang.org/reference/abi.html#the-no_mangle-attribute) 或 [`export_name`](https://doc.rust-lang.org/reference/abi.html#the-export_name-attribute) 使用稳定名称的函数,包括 Embedded Rust 的中断处理程序。 对于这次分析,我们明确不关心作为 `const` 块一部分编译的所有函数,包括 `static` 值初始化,因为只有这些计算的结果被包含在最终的二进制文件中。 从这些根开始向外,我们通过遍历 MIR 树来进行可达性分析。每当调用一个函数时,该函数也会被探索。因此所有未使用的函数都不会被访问。 在 Rust 中,像 C++ 一样,带有泛型参数的函数在使用具体类型调用时会被[单态化](https://rustc-dev-guide.rust-lang.org/backend/monomorph.html)。因此我们不仅收集被调用的函数,还收集每个函数的具体实例化,因为实现可能因传递的类型参数而大不相同。当考虑到以下情况时,这个过程会变得有点繁琐: - [`dyn`](https://doc.rust-lang.org/std/keyword.dyn.html) 对象,不清楚实际上调用了哪个 trait 实现。CGA 在发现一个 dyn 对象时假设*所有* vtable 条目都被调用。当为标准库 trait(如 `Debug` trait)构造 dyn 对象时,这会变得特别有趣。 - [函数指针](https://doc.rust-lang.org/std/primitive.fn.html#creating-function-points)可以从 Rust 函数创建。我们不总能推理出这些指针会发生什么。因此我们假设任何创建的函数指针都会在稍后的某个阶段被调用。 - 每个 `async` 块都隐式地有一个 panic 路径。有关更多信息,请参阅 Dion 的博客文章"Async Rust never left the MVP state"(https://tweedegolf.nl/en/blog/237/async-rust-never-left-the-mvp-state/)。 在遍历你的 crate 及其依赖项的 MIR 树之后,我们得到了一组调用图。现在我们可以选择将它们 `extract` 为 JSON 或 GraphViz dot 文件,或者用 `check` 来检查其中的 panic 或黑名单函数。 在提取时,所有调用图

相似文章

Rust:不要 panic

Lobsters Hottest

一段视频转录文本,讨论如何通过使用组合器和 Result 类型而不是 unwrap 来避免 Rust 中的 panic,解释了三种典型的崩溃原因,并强调编写健壮的代码。

Cargo-Geiger

Hacker News Top

cargo-geiger 是一个 Rust cargo 插件,用于列出 crate 及其依赖中不安全代码使用的统计信息,为审计提供输入。

那一次我用Go panic做流程控制

Lobsters Hottest

一位Go工程师讲述了一次事件:内存数据存储因排序缓慢而超载,他们在排序函数内部实现了上下文取消,使用panic和recover进行非局部流程控制,类似于encoding/json处理错误的方式。