cuda-oxide:rustc backend dylib 是怎么编出来的

聚焦一个 hello-constant 拆解正篇略过的问题:librustc_codegen_cuda.so 到底是怎么编出来的?Cargo.toml 里 dylib、空 [workspace]、#![feature(rustc_private)] 三个不寻常的设计各自解决了什么问题。

📚 系列 cuda-oxide · 第 8 篇

0. 问题

普通 Rust 库的 Cargo.toml 是这样的:

[package]
name = "my_lib"
version = "0.1.0"

[dependencies]
serde = "1.0"

crates/rustc-codegen-cuda/Cargo.toml 长这样:

[package]
name = "rustc_codegen_cuda"
version = "0.1.0"
edition = "2024"

# IMPORTANT: Must be dylib for rustc to load as codegen backend
[lib]
crate-type = ["dylib"]

# Keep out of workspace - requires special nightly build
[workspace]

[dependencies]
# ...
# Note: rustc internal crates are NOT listed here.
# They're accessed via `extern crate` with #![feature(rustc_private)]
# rustc will link them when loading this dylib as a codegen backend.

三处 unusual:

  1. crate-type = ["dylib"](不是 cdylib,也不是默认的 rlib)
  2. 空的 [workspace] 区段
  3. 注释说 rustc 内部 crate 不在 dependencies 里,靠 #![feature(rustc_private)] 解决

每一处都对应一个独立的设计问题。下面逐个拆。

1. crate-type = ["dylib"]cdylib

Rust 有五种 crate-type,跟动态库相关的两种长得很像但完全不同:

dylibcdylib
ABIRust ABI(不稳定)C ABI(稳定)
符号保留 Rust 类型信息(BoxVecResult)只暴露 extern "C" 函数
给谁用别的 Rust 程序 dlopen给 C / C++ / Python / Node 等外部语言 dlopen
stdlib多个 dylib 共享同一份 stdlib每个 cdylib 自带一份 stdlib(更大)
典型用法rustc plugin、proc-macro 宏(早期)FFI 出去给非 Rust 调用方

rustc 加载我们的后端时,要调一个返回 Box<dyn CodegenBackend> 的函数:

#[unsafe(no_mangle)]
pub fn __rustc_codegen_backend() -> Box<dyn CodegenBackend> {
    // ...
}

Box<dyn CodegenBackend> 是 Rust trait object——胖指针 + vtable,C ABI 根本无法表达。所以必须 dylib,让 rustc 进程和我们的 .so 共享同一份 Rust ABI 和同一份 stdlib。

这意味着我们的 backend .so 跟 rustc 二进制是严格 ABI 绑定的——换一个 nightly 版本的 rustc,我们的 .so 多半就 load 不进去了。所以仓库里有 rust-toolchain.toml 把版本钉死。

2. 空的 [workspace] 区段

# Keep out of workspace - requires special nightly build
[workspace]

写一个完全空[workspace] 表是一个鲜为人知的 cargo 用法,意思:

“我这个 crate 自己就是一个独立 workspace 根,不要被外层 workspace 收编。”

为什么要这样?因为这个 crate 有特殊编译需求,跟 cuda-oxide 主 workspace 的其它 crate 不兼容:

  • 它必须用 nightly toolchain(普通 crate 可能能用 stable)
  • 它要开 #![feature(rustc_private)](普通 crate 不该碰)
  • 它的依赖里有几个 git pin 死了特定 commit(避免 ABI 漂移)

如果不写空 [workspace],cargo 会沿着目录树往上找,自动把它纳入外层 workspace。被纳入之后:

  • cargo build 在外层根执行时会连带编它,但环境变量(LIBRARY_PATH 等)没准备好,炸
  • target 目录共享,版本冲突,build cache 互相污染
  • rust-analyzer 跨 workspace 的 IDE 体验会乱

用一行空的 [workspace] 把它隔离出去,代价小,效果干净。这个技巧除了 rustc plugin 之外还常出现在:proc-macro 单独发布、build.rs 子项目、bootstrap 脚本等场景。

3. rustc 内部 crate 不在 dependencies 里

Cargo.toml 第 40-42 行的注释:

# Note: rustc internal crates are NOT listed here.
# They're accessed via `extern crate` with #![feature(rustc_private)]
# rustc will link them when loading this dylib as a codegen backend.

我们的 backend 代码顶部一定有这两件事:

#![feature(rustc_private)]              // 解锁内部 API

extern crate rustc_driver;
extern crate rustc_middle;
extern crate rustc_codegen_llvm;
extern crate rustc_codegen_ssa;
extern crate rustc_session;
extern crate rustc_hir;
// ... 等等

三个关键点:

3.1 rustc_private 是什么

rustc 团队把 rustc 内部的 crate(rustc_middlerustc_codegen_llvm 等)标成 #![unstable(feature = "rustc_private")]。意思:

“这些是我们的内部实现,API 随时变。我们留个 nightly 后门让你能用,但别指望它稳定。”

打开 #![feature(rustc_private)] 就是按下这个后门开关。

3.2 为什么 extern crate 而不是 [dependencies]

如果在 [dependencies] 里写 rustc_middle = "...",cargo 会去 crates.io 找——找不到,这些 crate 从来没发布过。

正确做法是 extern crate rustc_middle;——告诉 rustc”这个 crate 我用,你帮我连”。然后:

  • 编译时:rustc 看到 extern crate rustc_middle,从自己的 sysroot(~/.rustup/toolchains/<nightly>/lib/rustlib/)找
  • 链接时:链接器需要找到 librustc_middle-<hash>.so,这就是为什么 cargo-oxide 编译时要注入 LIBRARY_PATH 指向 sysroot 的 lib 目录

3.3 运行时怎么找到这些库

backend .so 编完之后,rustc dlopen 它时,rustc 自己进程里已经加载了 librustc_middle-<hash>.so。我们的 .so 通过同进程符号引用直接用。

这就是为什么我们的 .so 只能被 rustc 加载,不能被普通 Rust 程序加载——它依赖的符号只存在于 rustc 进程地址空间。

4. cargo-oxide 编译它时干了什么

crates/cargo-oxide/src/backend.rs:

pub fn build_backend_from_source(codegen_crate: &Path) {
    println!("Building rustc-codegen-cuda backend...");

    let rustc_sysroot = get_rustc_sysroot();
    let lib_path = rustc_sysroot.as_ref().map(|s| format!("{}/lib", s));

    let mut cmd = Command::new("cargo");
    cmd.args(["build"]).current_dir(codegen_crate);     // ← 就一句 cargo build

    if let Some(ref path) = lib_path {
        cmd.env("LIBRARY_PATH", path);                   // ← 关键 ①
        cmd.env("LD_LIBRARY_PATH", build_ld_library_path(path));  // ← 关键 ②
    }

    cmd.status();
}

整段构建逻辑就是个 cargo build,多了两件事:

注入项作用
current_dir(codegen_crate)切到 crates/rustc-codegen-cuda/,让 cargo 找到那个特殊的 Cargo.toml
LIBRARY_PATH=<sysroot>/lib链接时找到 librustc_driver.so
LD_LIBRARY_PATH=<sysroot>/lib编译完跑测试或后续步骤时还能找到

<sysroot> 通常长这样:

~/.rustup/toolchains/nightly-2026-04-03-x86_64-unknown-linux-gnu/

里面 lib/ 目录下放着十几个 librustc_*.so

5. 整条链路串起来

cargo oxide run hello_constant


cargo-oxide find_or_build_backend

        ├── 命中本地 repo


build_backend_from_source(codegen_crate)

        ├── Command::new("cargo")
        │     .args(["build"])
        │     .current_dir("crates/rustc-codegen-cuda/")
        │     .env("LIBRARY_PATH", "<sysroot>/lib")
        │     .env("LD_LIBRARY_PATH", ...)
        │     .status()


cargo build 看 Cargo.toml

        ├── [lib] crate-type = ["dylib"]   ← 输出 .so 而不是 .rlib
        ├── [workspace] (空)                ← 不被外层 workspace 收编


rustc 编译源码

        ├── #![feature(rustc_private)]      ← 解锁内部 API
        ├── extern crate rustc_middle 等    ← 从 sysroot 引入
        ├── 链接器找 librustc_*.so          ← 靠 LIBRARY_PATH


target/debug/librustc_codegen_cuda.so       ← 出炉


回到 cargo-oxide


RUSTFLAGS="-Z codegen-backend=...so"


spawn 第二个 cargo 进程


rustc dlopen 这个 .so

        ├── 同进程里已经有 librustc_middle 等
        ├── 我们的 .so 的符号引用直接被 resolve


dlsym("__rustc_codegen_backend") → 接管 codegen

6. 三件套的关系

这三个”不寻常设计”其实共同解决一个问题:

怎么让一个 Rust crate 既能直接用 rustc 内部 API、又能被 rustc 在运行时动态加载、又不污染外层 workspace 的常规构建?

对应关系:

设计解决的子问题
crate-type = ["dylib"]rustc 要 dlopen + 调返回 trait object 的函数 → Rust ABI 动态库
[workspace]外层 workspace 没准备好这个 crate 的特殊环境 → 隔离它
#![feature(rustc_private)] + extern crate要用 rustc 内部 crate 但它们不发布 crates.io → 走 sysroot 链接

少任何一个都不能工作。

一句话总结

librustc_codegen_cuda.so 不是普通 Rust 库,而是一个为了被 rustc 在运行时 dlopen 而生的”特殊 dylib”——Rust ABI 而非 C ABI,跟外层 workspace 物理隔离,链接 rustc 内部 crate 而不是 crates.io 的依赖。Cargo.toml 里那三行不寻常的设计各自解决一个子问题,合起来才让这件事成立。


相关阅读: cuda-oxide:hello-constant 拆解 02——cargo-oxide driver 进程做了什么

评论区
评论功能即将上线, 敬请期待。