深入浅出:Rust 编译与库类型详解(给从未接触过 Rust 编译的你)

写在前面

如果你是第一次接触 Rust,大概率会觉得"编译"这件事很简单——不就是把代码变成能跑的程序吗?

但 Rust 的编译器(rustc,通常由 Cargo 在背后调用)其实能产出好几种完全不同形态的"产物":有的只能自己运行,有的能被别的程序调用,有的甚至能被 Python、Java、C++ 这些完全不同的语言调用。理解这些形态的区别,是你从"能写 Rust"迈向"能驾驭 Rust 工程"的重要一步。

本文会从最基础的概念讲起,尽量少用行话,帮你把这套体系一次性理清楚。

一、编译产物的两大类

写代码时,.rs 文件只是给人看的文本。编译器要做的事,是把这些文本变成机器能直接执行、或者能被其他程序调用的二进制文件。

最终产物主要分两类:

  • 可执行文件:一个完整独立的程序,双击或命令行直接运行。
  • 库文件:封装好的功能模块,不能自己运行,得由别的程序"调用"它。

而库文件,根据打包方式的不同,又分为静态库动态库。理解这两者的区别,是后面所有内容的基础。


二、静态编译 vs 动态编译

静态编译

编译时,把所有依赖的代码直接"塞进"最终产物里。生成的文件自己带全了运行所需的一切,运行时不用再找别的库文件。

优点:

  • 部署简单,一个文件扔过去就能跑。
  • 不会出现"缺库"或者"库版本对不上"的问题。
  • 运行时少一步加载动态库的开销,性能通常更好。

缺点:

  • 文件体积更大。
  • 如果电脑上同时跑好几个用同一份库的程序,每个程序都各自带了一份,没法共享,内存占用相对更高。

动态编译

依赖的库以独立文件的形式存在(Linux 上是 .so,macOS 上是 .dylib,Windows 上是 .dll)。程序运行的那一刻,才去加载这些外部库文件。

优点:

  • 主程序体积更小。
  • 多个程序可以共享同一份库文件,省磁盘、省内存。
  • 支持热更新、插件机制(不用重新编译主程序,换个库文件就能升级功能)。

缺点:

  • 部署时必须保证目标机器上有对应的库文件,而且版本要兼容。
  • 版本不兼容是动态库最常见的坑(业内俗称"DLL 地狱")。

Rust 的默认取向:标准库倾向静态链接

很多资料会说"Rust 默认走静态链接",这个说法需要拆开来看,否则容易产生误解:

  • Rust 标准库本身,默认是静态编译进你的最终产物里的——这也是 Rust 程序"扔一个文件就能跑"的重要原因。

  • 但如果你在 Linux 上用的是默认的 glibc 工具链(比如 x86_64-unknown-linux-gnu),最底层的系统 C 库(glibc)依然是动态链接的。也就是说,你的 Rust 二进制文件运行时,仍然依赖目标机器上有对应版本的 glibc。

  • 如果你真的想要一个"不依赖任何外部动态库、扔到任何同架构 Linux 机器上都能跑"的完全静态二进制文件,需要换用 musl 目标平台,比如:

    rustup target add x86_64-unknown-linux-musl
    cargo build --release --target x86_64-unknown-linux-musl
    

    这在制作 Docker 镜像(比如基于 scratchalpine 的极简镜像)时非常常用。


三、Rust 里的库类型(crate-type)

Cargo.toml 里,可以通过 [lib] 部分指定库的类型:

[lib]
crate-type = ["rlib", "staticlib", "dylib", "cdylib"]

这里有个初学者容易忽略的点:这是一个数组,意味着同一份源码,可以一次性编译出好几种不同形态的产物,互不冲突。下面逐一说明每种类型。

1. rlib(默认类型)

Rust 原生的静态库格式。只能被其他 Rust 代码使用,因为它里面包含了完整的 Rust 元数据和类型信息(泛型、trait 这些 Rust 特有的高级特性,都要靠这些元数据才能在编译期被正确处理和优化)。

你写的绝大多数普通 Rust 库(比如发布到 crates.io 上的包),用的都是这个类型,通常也不需要你手动指定。

2. staticlib

面向 C 语言及其他语言的静态库。生成 .a(Unix 系)或 .lib(Windows)文件。别的语言的编译器可以在编译时把这个库直接"焊"进自己的程序里。

适合场景:你想把一段 Rust 代码完全嵌入到一个非 Rust 项目中,编译后两者变成一个不可分割的整体。

3. dylib

面向其他 Rust 程序的动态库。它保留了 Rust 自己的 ABI(应用二进制接口),所以只能被 Rust 代码动态加载。

这里有个关键点原文容易被忽略:Rust 的 ABI 不光是"版本大了会变",连同一个编译器版本下、不同的优化级别或 codegen 参数编译出来的 dylib 之间,也不保证能互相兼容。这比很多人以为的"换个大版本才会出问题"要严格得多。实践中要用 dylib,几乎必须保证编译端和调用端使用完全一致的工具链、完全一致的编译参数,这也是 dylib 在实际项目里很少被应用层直接采用的根本原因。

不过它并不是"没人用"——Rust 编译器自己内部(比如 librustc_driver)、以及一些和编译器紧密耦合的工具,就用到了这种机制。只是普通业务项目基本用不上。

4. cdylib

面向任意语言的动态库,强制使用稳定的 C ABI。对外暴露接口时需要配合 extern "C"#[no_mangle],比如:

#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a + b
}

这是实现跨语言调用(FFI)的首选方式,广泛用于:

  • 插件系统、语言扩展。
  • 与 Python(比如 PyO3)、Java(JNI)、Go、C++ 等语言互相调用。
  • 编译到 WebAssembly。这一点原本容易被忽略:用 wasm-bindgen / wasm-pack 把 Rust 代码编译成能在浏览器里跑的 .wasm 文件时,目标平台是 wasm32-unknown-unknown,而 crate-type 同样要设置成 cdylib。也就是说,cdylib 目前除了传统 FFI 之外,另一个非常活跃的战场就是前端/WebAssembly 生态。

cdylib 有一个容易被忽略的隐藏成本:每一个 cdylib 都会独立打包一份完整的 Rust 运行时(panic 处理逻辑、内存分配器等)。如果同一个进程里同时加载了多个用 Rust 写的 cdylib(比如你用了两个不同的 Rust 编写的 Python 扩展包),会带来一定的体积和内存重复;更麻烦的是,如果这些 cdylib 各自初始化了全局状态(比如日志库、tracing 之类的全局订阅者),可能会出现重复初始化甚至冲突的坑。这是实践中真实会踩到的问题,值得提前知道。

5. 另外两种容易被漏掉的类型

除了上面四种最常讨论的类型,还有两种也属于 crate-type 的范畴,只是通常不需要你手动指定:

  • bin:可执行文件本身其实也是一种 crate-type,只是 Cargo 会自动帮你处理,几乎不需要在 [lib] 里手动写。
  • proc-macro:过程宏专用的类型。它的编译产物是一个在编译期被编译器自身加载并执行的特殊动态库,用来生成代码(比如 #[derive(Debug)] 背后的机制)。它的运作方式和 dylib、cdylib 都不一样,是完全独立的一条线,写自定义宏(比如 serde 的 derive 宏)时会用到。

四、核心对比一览

类型 目标使用者 链接方式 主要用途 使用频率
rlib 仅 Rust 静态 普通 Rust 库(默认) 极高
staticlib C 及其他语言 静态 将 Rust 代码静态嵌入其他语言项目 较高
dylib 仅 Rust 动态 Rust 程序间动态共享代码(工具链要求高度一致) 较低
cdylib 任意语言 / WebAssembly 动态 跨语言插件、FFI、编译到 wasm
proc-macro 编译器自身(编译期) 特殊 生成代码的过程宏 视项目而定

五、额外需要掌握的要点

  1. 默认行为
    编写库 crate 时默认产出 rlib;编写二进制 crate 时默认产出可执行文件,标准库部分默认走静态链接。

  2. ABI 稳定性
    Rust 自身 ABI 不稳定(且不仅限于跨大版本,同版本不同编译参数也可能不兼容),这是 dylib 使用受限的根本原因。C ABI 经过数十年验证,高度稳定,因此跨语言场景几乎总是选 cdylib 或 staticlib。

  3. 常见实践选择

    • 纯 Rust 项目内部使用:rlib 即可,通常你不用操心这件事。
    • 需要被其他语言调用,或需要编译到 WebAssembly:优先 cdylib(动态)或 staticlib(静态)。
    • 真正需要 Rust 程序间动态链接的场景很少,多数情况下静态方式更可靠,除非你能严格控制编译环境一致性。
  4. 一份代码,多种产物
    前面提到 crate-typeCargo.toml 里是个数组,这意味着你完全可以让同一份源码同时产出 rlib(供 Rust 内部使用)+ cdylib(供 Python 调用)+ staticlib(供 C++ 静态嵌入),三份产物互不干扰。如果只想临时试一下某种类型,也可以直接用 rustc 命令行的 --crate-type 参数,不需要改 Cargo.toml

  5. 编译控制选项

    • --release:开启优化,生成更小更快的产物。
    • LTO(链接时优化):进一步减小体积并提升性能。
    • 目标三元组(如 x86_64-unknown-linux-gnu / x86_64-unknown-linux-musl):决定了交叉编译目标平台,也决定了你能不能拿到"完全静态"的二进制。
  6. 设计哲学
    Rust 默认偏向静态链接,是为了降低部署复杂度和运行时出错的风险。动态库作为补充手段,服务于插件、跨语言调用、共享代码等特定需求,而不是常规默认选择。


结语

理解 Rust 编译与库类型,本质上是理解"代码如何被组织、如何被链接、如何被跨语言使用"。

  • 静态与动态的选择,决定了程序的独立性和资源共享能力;
  • rlib、staticlib、dylib、cdylib、proc-macro 的选择,决定了代码能被哪些语言、在什么阶段、以何种方式调用。

搞清楚这些概念之后,你就能更精准地控制自己代码的产出形态:日常开发用最简单的 rlib 就够了;一旦涉及跨语言集成、插件系统、或者要编译到浏览器里跑,就知道该往 cdylib 和 staticlib 上想;而 dylib,大概率你这辈子都不需要主动选它。编译不再是一个黑盒,而是一套可以被清晰驾驭的工具。