Rust 编译器中的 panic 实现机制:从 `panic!` 宏到栈展开与中止的完整调用链

原创2026-09-12 22:43:08459 阅读
文章标签:编程语言编译器语言运行时标准库

Rust 编译器中的 panic 实现机制:从 panic! 宏到栈展开与中止的完整调用链

本篇技术指南以 rustc 仓库的 Rust 开发指南(src/doc/rustc-dev-guide/src/panic-implementation.md)为核心脉络,系统梳理 Rust 中一次 panic 从宏调用、lang item 符号解析、std 侧 panic hook 分发,到进入 panic_abort / panic_unwind 运行时、最终执行栈展开或进程中止的完整流程。读完本文,你将掌握 core 与 std 两套 panic! 入口如何通过弱 lang item 在链接期汇合,理解 __rust_start_panic 为何以 usize 传递胖指针,并能对照源码精确定位每一步实现。

一、总体流程概览

一次 Rust panic 的生命周期可以划分为两个阶段:

  1. Step 1:panic! 宏的调用。从宏展开的入口函数出发,最终调用名为 rust_begin_unwind(源码中的 panic_impl lang item)的符号,将控制权从 core 移交到 std。
  2. Step 2:panic 运行时(panic runtime)。std 执行全局 panic hook、检测双重 panic 后,调用由 panic 运行时提供的 __rust_start_panic,由 panic_abort(直接中止进程)或 panic_unwind(执行平台相关的栈展开)完成收尾。

下面沿着这两步深入源码。

二、Step 1:panic! 宏的调用

Rust 实际存在两个 panic 宏:一个定义在 core,一个定义在 std。原因在于 core 中的代码本身也可能 panic,而 core 先于 std 被构建;为了让无论源自 core 还是 std 的 panic 在运行时都走同一套机制(panic hook、栈展开等),两者最终必须在链接期汇聚到同一个符号上。

2.1 core 中的 panic! 定义

core 的 panic! 宏最终会调用 library/core/src/panicking.rs 中的 panic_fmt(其 lang item 名为 panic_fmt):

// NOTE This function never crosses the FFI boundary; it's a Rust-to-Rust call
unsafe extern "Rust" {
    #[lang = "panic_impl"]
    fn panic_impl(pi: &PanicInfo<'_>) -> !;
}

let pi = PanicInfo::new(
    &fmt,
    Location::caller(),
    /* can_unwind */ true,
    /* force_no_backtrace */ false,
);

unsafe { panic_impl(&pi) }

这段代码实际解决的是一个多层间接的符号解析问题,链路如下:

  1. 在 core 中,panic_impl 被声明为 weak lang item,其关联符号为 rust_begin_unwind。当前仓库中该声明位于 compiler/rustc_attr_ir/src/weak_lang_items.rs(对应 PanicImpl, rust_begin_unwind; 这一条目),并在 rustc_hir_analysis 的 collect.rs 中把实际符号名设置为 rust_begin_unwind。
  2. 注意 panic_impl 声明在 extern "Rust" 块中,这意味着 core 会在链接期尝试解析一个名为 rust_begin_unwind 的外部符号——这是一个 Rust 到 Rust 的调用,并不跨越 FFI 边界。

2.2 std 中的 panic_handler 与符号汇合

在 library/std/src/panicking.rs 中,存在如下定义:

/// Entry point of panics from the core crate (`panic_impl` lang item).
#[cfg(not(any(test, doctest)))]
#[panic_handler]
pub fn panic_handler(info: &core::panic::PanicInfo<'_>) -> ! {
    ...
}

这里的特殊属性 #<a href="https://link.gitcode.com/i/0d99226e1db22ee6aa5fdedf0866bde6" target="_blank">panic_handler] 由 [compiler/rustc_passes/src/lang_items.rs 解析:extract_ast 函数会把 panic_handler 属性转换为 panic_impl lang item。

于是 std 中有了与 core 中 extern { fn panic_impl } 声明相匹配的 panic_impl lang item。std 的这个函数经历与 core 相同的过程,最终也得到符号名 rust_begin_unwind。在链接期:

  • core 中对 rust_begin_unwind 的符号引用被解析到 std 中 panic_handler 函数的定义;
  • 运行时控制流从 core 传递到 std。

这就是为什么 core 产生的 panic 也能享受 std 提供的全套基础设施(panic hooks、unwinding 等)。

2.3 std 中 panic! 的实现:真正的 panic 逻辑起点

在 library/std/src/panicking.rs 中,panic_handler 根据 panic 消息的形态选择 payload 包装方式:

  • 若消息是 'static 字符串,直接使用 StaticStrPayload(take_box 时零拷贝装箱);
  • 否则使用 FormatStringPayload,在 take_box 时惰性执行格式化并装箱为 String。

随后控制权进入 panic_with_hook。该函数(位于同文件的 panic_with_hook)负责:

  1. 通过 panic_count::increase(true) 增加当前线程的 panic 计数;
  2. 检查是否必须立即中止(MustAbort::PanicInHook 表示 panic hook 内部再次 panic,MustAbort::AlwaysAbort 表示已调用过 panic::always_abort(),例如在 libc::fork 之后的子进程中,此时访问线程局部状态是非法的);
  3. 调用全局 panic hook(默认 hook 或用户通过 set_hook / update_hook 注册的自定义 hook);
  4. 检查 can_unwind:若为 false(例如在 extern "C" 等 nounwind 函数中),直接打印 "thread caused non-unwinding panic. aborting." 并 process::abort();
  5. 最终调用 rust_panic(payload),后者再调用 __rust_start_panic,进入 panic 运行时。

关于 __rust_start_panic 的奇怪类型

对 __rust_start_panic 的调用非常特别:它接收的是一个 *mut &mut dyn PanicPayload 转换而来的 usize。分解这个类型:

  1. PanicPayload 是定义在 library/alloc/src/panicking.rs 中的内部 trait(pub trait PanicPayload: Display),由 std 侧的 payload 包装类型实现,其关键方法是 fn take_box(&mut self) -> Box<dyn Any + Send>。它把用户提供的 payload(T: Any + Send)装箱,再转成原始指针。使用 trait 对象接口而不是直接传值,是为了惰性分配:采用 abort 运行时(panic_abort)时,take_box 根本不会被调用,从而避免不必要的堆分配。
  2. 调用 __rust_start_panic 时,我们持有 &mut dyn PanicPayload——这是一个胖指针(大小是 usize 的两倍,包含数据指针和 vtable 指针),无法直接跨 FFI 传递。解决方案是:先取这个可变引用的可变引用(&mut &mut dyn PanicPayload),再转成原始指针(*mut &mut dyn PanicPayload)。外层原始指针是瘦指针,因为它指向的是 Sized 类型(一个引用),因此可以安全地转为 usize,从而适合跨越 FFI 边界。

至此我们正式进入 panic 运行时。

三、Step 2:panic 运行时

Rust 提供两个 panic 运行时:panic_abort 和 panic_unwind。用户在构建期通过 Cargo.toml 选择:

[profile.release]
panic = "unwind"   # 默认值,栈展开
# panic = "abort" # 直接中止进程

std 本身带有 #![needs_panic_runtime] 标记(引入自 RFC 1513),表明它要求某个带有 #![panic_runtime] 标记的 crate 存在,并实现 __rust_start_panic 等约定的符号(见 library/std/src/panicking.rs 中对外部符号的声明)。

3.1 panic_abort:极简的中止实现

library/panic_abort/src/lib.rs 的实现正如其名,极其简单:

#[rustc_std_internal_symbol]
pub fn __rust_start_panic(_payload: &mut dyn PanicPayload) -> u32 {
    // Android / zkvm 平台可以附加 abort 消息
    unsafe extern "Rust" {
        #[rustc_std_internal_symbol]
        safe fn __rust_abort() -> !;   // 定义在 std::rt
    }
    __rust_abort()
}

它直接调用定义在 std::rt 中的 __rust_abort 终止进程。附带的效果是:PanicPayload::take_box 永远不会被调用,panic 消息的格式化与装箱全部被跳过,因此这种模式下 panic 路径几乎零分配。另外,同文件的 __rust_panic_cleanup 直接 unreachable!(),因为不会有任何展开需要清理。

3.2 panic_unwind:平台相关的栈展开

library/panic_unwind/src/lib.rs 是更有趣的情形。其 __rust_start_panic 只是委托给平台相关模块:

#[rustc_std_internal_symbol]
pub fn __rust_start_panic(payload: &mut dyn PanicPayload) -> u32 {
    imp::panic(payload)
}

在 imp::panic 中,我们取回那个 usize,转换回 *mut &mut dyn PanicPayload,解引用后调用 take_box。此时我们得到指向 payload 本身的原始指针(*mut (dyn Send + Any)),即用户调用 panic! 时提供的实际值的指针。

这一步之后,平台无关的代码到此结束,控制权转入平台相关的展开逻辑。imp 模块通过 cfg_select! 按平台选择:

  • MSVC 目标(非 32 位 ARM)使用 SEH(结构化异常处理),实现在 seh.rs;
  • Miri 环境使用 Miri 内建展开支持(miri.rs),因为 Miri 不支持基于 funclet 的展开,只支持 landingpad;
  • 大多数 Unix / wasm / SGX 等使用 libunwind/libgcc,实现在 gcc.rs;
  • 不支持展开的目标(如 os = "none" 的裸机、UEFI、ESP-IDF、Hermit、NVPTX、AVR)使用 dummy.rs,其实现实际上退化为中止。

这段平台代码负责:展开栈、执行与每个栈帧关联的 "landing pad"(当前语境下即运行析构函数),并将控制权转移到 catch_unwind 所在的帧。

3.3 所有 panic 的最终归宿:中止或被 catch_unwind 捕获

值得强调的结论是:所有 panic 要么中止进程,要么被某次 catch_unwind 调用捕获。特别的,在 std 的 runtime 服务中,用户提供的 main 函数调用被包裹在 catch_unwind 中——见 library/std/src/rt.rs 中 lang_start_internal 的实现:

panic::catch_unwind(move || {
    let ret_code = panic::catch_unwind(main).unwrap_or_else(move |payload| {
        // 默认 panic hook 已打印过 panic 消息,这里处理返回码
        ...
    });
    ...
})

std 侧的 catch_unwind 实现位于 library/std/src/panicking.rs:它通过 intrinsics::catch_unwind(do_call, &raw mut data, do_catch) 调用编译器内建能力,使用一个 union Data<F, R> 在闭包正常返回(写入 r 字段)与 panic(do_catch 中经 __rust_panic_cleanup 恢复 payload 后写入 p 字段)两条路径之间安全地传递所有权。同时 panic_count::decrease() 会在清理时递减 panic 计数,确保 thread::panicking() 与双重 panic 检测保持正确。

四、补充:更多 panic 入口与细节

4.1 core 中的其他 panic 入口

library/core/src/panicking.rs 不只是 panic_fmt,它还定义了多个带 lang item 的入口,供编译器在特定场景直接生成跳转(避免每个调用点都展开字符串参数,减少二进制体积):

  • panic(lang item panic):panic!("...") 的底层实现,使用 fmt::Arguments::from_str 而非 format_args!,可让编译器省略 Formatter::pad 等代码,节省数 KB;
  • panic_nounwind_fmt / panic_nounwind(lang item panic_nounwind,带 #[rustc_nounwind]):用于不能展开的场景;若 panic handler 无视 can_unwind = false 仍然展开,会触发 "panic in a function that cannot unwind" 防护;
  • panic_bounds_check:数组/切片越界访问(OOB)的 panic;
  • panic_misaligned_pointer_dereference、panic_null_pointer_dereference、panic_invalid_enum_construction 等:由对应 MIR 检查 pass 生成的 panic;
  • panic_const_* 系列:溢出检查等编译器生成断言使用的常量消息(如 "attempt to add with overflow");
  • const_panic_fmt:供 const-eval 机器在编译期求值 panic 时替换 panic_fmt 调用。

4.2 std 中的其他入口

  • begin_panic(lang item begin_panic):支持任意 payload(M: Any + Send)的 panic 入口,也是 panic_any! 的基础;
  • resume_unwind:catch_unwind 捕获后再次抛出 payload 的入口,直接转发给 panic 运行时;
  • panic::set_hook / take_hook / update_hook:操作全局 panic hook(静态 HOOK: RwLock<Hook>),分别对应替换、取回、链式更新 hook。

五、总结:一条完整的调用链

将上述内容串联起来,一次 panic!("boom") 的完整路径是:

panic! 宏展开
  └─> core::panicking::panic_fmt        (lang item "panic_fmt")
        └─> panic_impl lang item 调用    (weak lang item, 符号 rust_begin_unwind)
              └─> std::panicking::panic_handler   (#[panic_handler] 属性解析为 panic_impl)
                    └─> panic_with_hook           (全局 hook + 双重 panic 检查)
                          └─> rust_panic
                                └─> __rust_start_panic   (由 panic 运行时提供)
                                      ├─ panic_abort  → __rust_abort → 进程中止
                                      └─ panic_unwind → imp::panic → take_box 装箱 payload
                                                          └─> 平台展开(SEH / libunwind / Miri / dummy)
                                                                └─> 运行析构函数 → 转移至 catch_unwind 帧
                                                                      └─> 或被 std::rt 中的 main 包装器捕获

理解这条链路的关键在于两个"汇合点":其一是 lang item 符号解析——core 的弱引用 rust_begin_unwind 与 std 的 #[panic_handler] 在链接期绑定;其二是 __rust_start_panic 的胖指针瘦化——以 usize 形式把 &mut dyn PanicPayload 传入运行时。这两处机制共同保证了:无论 panic 起源于 core 还是 std、无论选择展开还是中止策略,Rust 都能以统一、可控的方式处理错误路径。

延伸阅读:与本文直接相关的源码文件均在当前仓库中,可按需深入:library/core/src/panicking.rs、library/std/src/panicking.rs、library/alloc/src/panicking.rs(PanicPayload trait 定义)、library/panic_unwind/src/lib.rs、library/panic_abort/src/lib.rs、library/std/src/rt.rs(main 的 catch_unwind 包装)、compiler/rustc_attr_ir/src/weak_lang_items.rs(panic_impl 弱 lang item 声明)以及 compiler/rustc_passes/src/lang_items.rs(#[panic_handler] 属性解析)。

登录后查看全文
rust