入门 The Cargo Team 2026-09-13 15:48:21 · 0 阅读

第21章 Cargo 构建脚本详解

有些包需要编译第三方的非 Rust 代码,例如 C 语言库。另一些包需要链接 C 语言库,这些库可能已存在于系统中,也可能需要从源码构建。还有一些包则依赖诸如构建前代码生成(如解析器生成器)等特定功能。

Cargo 并不旨在取代那些在这些任务上已经高度优化的其他工具,但它允许通过自定义构建脚本与之集成。如果将名为 build.rs 的文件放置在包根目录下,Cargo 就会编译并在构建该包之前执行此脚本。

// 自定义构建脚本示例。
fn main() {
    // 告知 Cargo 如果指定文件发生变化,则重新运行此构建脚本。
    println!("cargo::rerun-if-changed=src/hello.c");
    // 使用 `cc` crate 构建 C 文件并将其静态链接。
    cc::Build::new()
        .file("src/hello.c")
        .compile("hello");}

构建脚本的一些典型应用场景包括:

  • 构建打包内的 C 语言库。
  • 在宿主系统上查找 C 语言库。
  • 根据规范生成 Rust 模块。
  • 执行 crate 所需的任何平台特定配置。

下文详细阐述了构建脚本的工作机制,而示例章节则展示了编写脚本的各种具体示例。

注意:可以使用 package.build manifest 键来更改构建脚本的名称,或者完全禁用它。

构建脚本的生命周期

在构建包之前,Cargo 会将构建脚本编译为可执行文件(如果尚未构建过)。随后它会运行该脚本,脚本可以执行任意数量的任务。脚本可以通过向 stdout 输出带有 cargo:: 前缀的特定格式命令,与 Cargo 进行通信。

如果构建脚本的任何源文件或依赖项发生变更,该脚本都会被重新构建。

默认情况下,如果包内的任何文件发生变化,Cargo 都会重新运行构建脚本。通常,最好使用下文变更检测章节中描述的 rerun-if 命令,以精确限定触发构建脚本重新运行的条件。

构建脚本成功执行完毕后,包的其余部分才会开始编译。如果发生错误,脚本应以非零退出码退出以中止构建,此时构建脚本的输出会显示在终端上。

构建脚本的输入

构建脚本运行时,会接收到一系列输入,全部以环境变量的形式传入。

除了环境变量之外,构建脚本的当前目录是其所在包的根目录。

注意:在构建脚本中检查 target_ostarget_arch配置选项时,不要使用 cfg! 宏或 #[cfg] 属性,因为它们检查的是宿主机(构建脚本运行的平台),而不是你要编译的目标平台。在交叉编译时,这个区别尤为重要。

正确做法是读取对应的 CARGO_CFG_* 环境变量,它们如实反映目标平台的配置。如果需要类型化的 API,可以考虑使用 build-rs crate。更多细节参见构建脚本示例

构建脚本的输出

构建脚本可以把所有输出文件或中间产物保存在 OUT_DIR 环境变量指定的目录中。脚本不应修改该目录之外的任何文件。

注意:Cargo 不会在每次构建之间清理或重置 OUT_DIR。即使构建脚本重新运行,该目录的内容也可能在多次重建之间保留。这是有意为之,以支持增量构建,例如原生代码的编译。

构建脚本不应依赖 OUT_DIR 为空,因为其内容可能在重新构建期间持续存在。如果脚本需要干净的目录,目前它需要负责管理或清理其创建的文件和子目录。该领域的改进正在讨论中(参见 #16427#9661)。

构建脚本通过向标准输出(stdout)打印内容来与 Cargo 通信。Cargo 会解读每一行以 cargo:: 开头的输出,将其视为影响包编译的指令。其他所有行均被忽略。

构建脚本打印 cargo:: 指令的顺序可能会影响 cargo 传递给 rustc 的参数顺序。进而,传递给 rustc 的参数顺序又可能影响传递给链接器的参数顺序。因此,你需要留意构建脚本中指令的顺序。例如,如果目标文件 foo 需要链接库 bar,你可能需要确保库 barcargo::rustc-link-lib 指令出现在链接目标文件 foo 的指令之后

在正常编译过程中,脚本的输出对终端是隐藏的。如果你希望在终端直接看到输出,请使用 -vv 标志以“非常详细”模式调用 Cargo。这仅发生在构建脚本运行时。如果 Cargo 确定没有任何变更,它将不会重新运行脚本,更多细节见下文变更检测

构建脚本打印到标准输出的所有行都会写入类似 target/debug/build/<pkg>/output 的文件中(具体位置可能取决于你的配置)。标准错误(stderr)输出也会保存在同一目录中。

以下是 Cargo 识别的指令摘要,每个指令的详细说明如下。

MSRV: cargo::KEY=VALUE 语法需要 1.77 版本。若要支持旧版本,请使用 cargo:KEY=VALUE 语法。

rustc-link-arg 指令告诉 Cargo 在编译时传递 -C link-arg=FLAG 选项,但只在构建受支持的目标(benchmarks、binaries、cdylib crate、examples 和 tests)时生效。它的用法高度依赖平台,常用于设置共享库版本或链接脚本。

rustc-link-arg-cdylib 指令告诉 Cargo 在编译时传递 -C link-arg=FLAG 选项,但只在构建 cdylib 库目标时生效。它的用法高度依赖平台,常用于设置共享库版本或运行时路径(runtime-path)。

出于历史原因,cargo::rustc-cdylib-link-argcargo::rustc-link-arg-cdylib 的别名,含义完全相同。

rustc-link-arg-bin 指令告诉 Cargo 在编译时传递 -C link-arg=FLAG 选项,但只在构建名为 BIN 的二进制目标时生效。它的用法高度依赖平台,常用于设置链接脚本或其他链接器选项。

rustc-link-arg-bins 指令告诉 Cargo 在编译时传递 -C link-arg=FLAG 选项,但只在构建二进制目标时生效。它的用法高度依赖平台,常用于设置链接脚本或其他链接器选项。

rustc-link-arg-tests 指令告诉 Cargo 在编译时传递 -C link-arg=FLAG 选项,但只在构建测试目标时生效。

rustc-link-arg-examples 指示 Cargo 在构建 example(示例)目标时,向编译器传递 -C link-arg=FLAG 选项

rustc-link-arg-benches 指示 Cargo 在构建 benchmark(基准测试)目标时,向编译器传递 -C link-arg=FLAG 选项

rustc-link-lib 指示 Cargo 使用编译器的 -l 标志链接指定的库。这通常用于通过 FFI 链接原生库。

LIB 字符串会直接传递给 rustc,因此支持 -l 的所有语法。
目前 LIB 完全支持的语法是 [KIND[:MODIFIERS]=]NAME[:RENAME]

-l 标志仅传递给包的 library(库)目标;若包中没有 library 目标,则传递给所有目标。之所以这样做,是因为其他目标都隐式依赖于 library 目标,而要链接的库应当只被包含一次。这意味着,如果一个包同时拥有 library 和 binary(二进制)目标,*library* 可以访问该库的符号,而 binary 应通过 library 目标的公共 API 来访问这些符号。

可选的 KIND 可以是 dylibstaticframework。更多细节请参阅 rustc 手册

rustc-link-search 指示 Cargo 向编译器传递 -L 标志,以在库搜索路径中添加一个目录。

可选的 KIND 参数可以取 dependencycratenativeframeworkall 之一。更多详细信息请参阅 rustc 手册

如果这些路径位于 OUT_DIR 内部,它们也会被添加到动态库搜索路径环境变量中。由于这种行为会增加使用最终生成的二进制的难度,因此不推荐依赖此机制。通常,最好避免在构建脚本中创建动态库(使用现有的系统库则没有问题)。

cargo::rustc-flags=FLAGS

rustc-flags 指令告知 Cargo 将给定的以空格分隔的标志传递给编译器。此指令仅允许使用 -l-L 标志,效果等同于使用rustc-link-librustc-link-search

cargo::rustc-cfg=KEY[="VALUE"]

rustc-cfg 指令告知 Cargo 将给定的值传递给编译器的--cfg 标志。这可用于在编译时检测功能以启用条件编译。自定义 cfg 要么使用cargo::rustc-check-cfg 指令进行预期声明,要么需要允许unexpected_cfgs lint,以避免出现意外 cfg 的警告。

请注意,这不会影响 Cargo 的依赖项解析。不能使用此功能来启用可选依赖项或激活其他 Cargo 功能。

注意 Cargo features 使用的是 feature="foo" 这种形式,但通过这个参数传递的 cfg 值并不限于这种形式——可以只写一个标识符,也可以是任意的键值对。比如输出 cargo::rustc-cfg=abc 后,代码里就可以用 #[cfg(abc)](注意没有 feature= 前缀)。也可以用 = 符号传递任意键值对,比如 cargo::rustc-cfg=my_component="foo"。键必须是 Rust 标识符,值必须是字符串。

cargo::rustc-check-cfg=CHECK_CFG

向预期配置名和值的列表中添加条目,该列表会在 unexpected_cfgs lint 检查可达的 cfg 表达式时使用。

CHECK_CFG 的语法与 rustc--check-cfg 参数相同,详情参见Checking conditional configurations

用法示例:

#![allow(unused)]
fn main() {
// build.rs
println!("cargo::rustc-check-cfg=cfg(foo, values(\"bar\"))");
if foo_bar_condition {
    println!("cargo::rustc-cfg=foo=\"bar\"");
}
}

注意应该定义所有可能的 cfg,而不是只定义当前启用的那些,包括某个 cfg 名下所有可能的取值。

建议把 cargo::rustc-check-cfgcargo::rustc-cfg 指令尽量写在一起,避免出现拼写错误、漏写 check-cfg、cfg 过期等问题。

另请参阅条件编译示例。

MSRV:自 1.80 起支持

cargo::rustc-env=VAR=VALUE

rustc-env 指令告诉 Cargo 在编译包时设置指定的环境变量。编译后的 crate 可通过 env! 读取该值。这对于在 crate 代码中嵌入额外的元数据很有用,例如 Git HEAD 的哈希值或持续集成服务器的唯一标识符。

有关 Cargo 自动设置的环境变量,请参阅下文。

注意:使用 cargo runcargo test 运行可执行文件时,这些环境变量也会被设置。但由于这样会将可执行文件绑定到 Cargo 的执行环境,因此不推荐这种做法。通常,这些环境变量应仅在编译时通过 env! 宏检查。

cargo::error=MESSAGE

error 指令告诉 Cargo 在构建脚本执行完毕后显示错误,并使构建失败。

注意:构建脚本库应仔细考虑是使用 cargo::error 还是返回 Result。返回 Result 可能更好,让调用方决定错误是否致命。调用方随后可以决定是否使用 cargo::error 显示 Err 变体。

MSRV: 自 1.84 版本起受支持

cargo::warning=MESSAGE

warning 指令告诉 Cargo 在构建脚本执行完毕后显示警告。警告仅针对 path 依赖项(即本地正在开发的依赖项)显示,因此例如在 crates.io 上的 crate 中打印的警告默认不会显示,除非构建失败。可以使用 -vv “非常详细”标志让 Cargo 为所有 crate 显示警告。

构建依赖项

构建脚本也可以依赖其他基于 Cargo 的 crate。依赖项通过清单文件中的 build-dependencies 部分声明。

[build-dependencies]
cc = "1.0.46"

构建脚本无法访问 dependenciesdev-dependencies 部分中列出的依赖项(因为它们尚未构建!)。此外,除非显式添加到 [dependencies] 表中,否则构建依赖项对包本身也不可用。

建议仔细权衡添加每个依赖所带来的影响,如编译时间、许可证、维护成本等。如果构建依赖与普通依赖共享同一个依赖项,Cargo 会尝试复用该依赖。但这种情况并非总是可行,例如在交叉编译时就不行,因此在考量编译时间影响时需注意这一点。

变更检测

重新构建包时,Cargo 不一定知道是否需要再次运行构建脚本。默认情况下,它采取保守策略:只要包内任何文件发生变更(或被 excludeinclude 字段 控制的文件列表发生变更),就重新运行构建脚本。对于大多数场景,这并非最佳选择,因此建议每个构建脚本至少输出一条 rerun-if 指令(详见下文)。如果输出了这些指令,Cargo 仅在指定值发生变化时才会重新运行脚本。如果你不确定 Cargo 为何重新运行你的 crate 或其依赖的构建脚本,请参阅常见问题解答中的“为什么 Cargo 在重新构建我的代码?”

cargo::rerun-if-changed=PATH

rerun-if-changed 指令告知 Cargo,如果指定路径下的文件发生变化,则重新运行构建脚本。目前,Cargo 仅使用文件系统最后修改时间(mtime)时间戳来判断文件是否变更。它会与内部缓存的构建脚本上次运行时的时间戳进行比较。

如果路径指向一个目录,它会扫描整个目录以检测任何修改。

如果构建脚本在任何情况下都不需要重新运行,那么输出 cargo::rerun-if-changed=build.rs 是一种简单的阻止方式(否则,在没有输出任何 rerun-if 指令的情况下,默认行为是扫描整个 package 目录来检测变更)。Cargo 会自动处理脚本本身是否需要重新编译,当然脚本在重新编译之后也会重新运行。除此之外,指定 build.rs 是多余的、没有必要的。

cargo::rerun-if-env-changed=NAME

rerun-if-env-changed 指令告诉 Cargo:当指定名称的环境变量的值发生变化时,重新运行构建脚本。

注意,这里的环境变量指的是像 CC 这样的全局环境变量,不能用于 Cargo 为构建脚本设置的环境变量(如 TARGET)。这里说的是 cargo 调用时接收到的环境变量,而不是构建脚本可执行文件接收到的环境变量。

从 1.46 版本开始,在源码中使用 env!option_env! 会自动检测变化并触发重新构建。对于这些宏已引用的变量,就不再需要 rerun-if-env-changed 了。

可以在 Cargo.toml 中设置 package.links 键,声明该 package 链接了给定的原生库。这个键的目的是让 Cargo 了解 package 的原生依赖情况,并提供一套在 package 构建脚本之间传递元数据的规范机制。

[package]
# ...
links = "foo"

这个配置表示该 package 链接了名为 libfoo 的原生库。使用 links 键时,package 必须有构建脚本,并且构建脚本应使用 rustc-link-lib 指令 来链接该库。

Cargo 要求一个 links 值最多对应一个包。换言之,禁止两个包链接到同一个原生库。这有助于防止 crate 之间出现符号重复。不过,目前有相关约定可缓解这一问题。

构建脚本可以生成任意一组键值对形式的元数据。这些元数据通过 cargo::metadata=KEY=VALUE 指令进行设置。

元数据会被传递给直接依赖方的构建脚本。例如,如果包 foo 依赖于 bar,而 bar 链接了 baz,当 bar 在其构建脚本元数据中生成 key=value 时,foo 的构建脚本中将包含环境变量 DEP_BAZ_KEY=value(注意这里使用了 links 键的值,且 key 转换为大写)。关于这一机制的具体用法,请参考“使用其他 sys crate”中的示例。

请注意,元数据仅传递给直接依赖方,不会传递给间接依赖方。

MSRV(最低支持 Rust 版本): cargo::metadata=KEY=VALUE 需要 Rust 1.77。若需兼容旧版本,请使用 cargo:KEY=VALUE(未被支持的指令会被视为元数据键)。

*-sys

某些链接系统库的 Cargo 包遵循 -sys 后缀的命名约定。任何名为 foo-sys 的包都应提供以下两项主要功能:

  • 库 crate 应链接到原生库 libfoo。通常,它会先探测当前系统中是否已存在 libfoo,若不存在再尝试从源码构建。
  • 库 crate 应提供 libfoo 中类型和函数的声明,但不提供更高层的抽象。

*-sys 包集合为链接原生库提供了一组通用依赖。这种原生库相关包的命名约定带来了诸多好处:

  • foo-sys 的通用依赖缓解了“一个 links 值仅对应一个包”的限制。
  • 其他 -sys 包可以利用 DEP_LINKS_KEY=value 环境变量,从而更好地与其他包集成。参见“使用其他 sys crate”示例。
  • 公共依赖允许将发现 libfoo 本身的逻辑(或从源码构建)集中处理。
  • 这些依赖项可以轻松覆盖

通常会有一个不带 -sys 后缀的伴随包,它在 sys 包之上提供安全的高级抽象。例如,git2 crate 提供了对 libgit2-sys crate 的高级接口。

覆盖构建脚本

如果清单中包含 links 键,Cargo 支持通过自定义库覆盖指定的构建脚本。此功能旨在完全防止运行相关的构建脚本,而是预先提供元数据。

若要覆盖构建脚本,请在任意接受的 config.toml 文件中添加以下配置。

[target.x86_64-unknown-linux-gnu.foo]
rustc-link-lib = ["foo"]
rustc-link-search = ["/path/to/foo"]
rustc-flags = "-L /some/path"
rustc-cfg = ['key="value"']
rustc-env = {key = "value"}
rustc-cdylib-link-arg = ["…"]
metadata_key1 = "value"
metadata_key2 = "value"

在此配置下,如果某个包声明它链接到 foo,则构建脚本不会被编译或运行,而是使用指定的元数据。

不应使用 warningrerun-if-changedrerun-if-env-changed 键,它们将被忽略。

Jobserver

Cargo 和 rustc 使用为 GNU make 开发的jobserver 协议来协调跨进程的并发。它本质上是一个信号量,用于控制并发运行的任务数量。并发数可以通过 --jobs 标志设置,默认值为逻辑 CPU 数量。

每个 build script 都会从 Cargo 获得一个作业槽位,运行时应尽量只占用一个 CPU。如果脚本需要并行使用更多 CPU,应通过 jobserver crate 与 Cargo 协调。

例如,cc crate 可以启用可选的 parallel feature,利用 jobserver 协议同时编译多个 C 文件。

Build Script 示例

下面几节将介绍一些编写 build script 的示例。

一些常见的 build script 功能可以在 crates.io 上找到现成的 crate,可以浏览 build-dependencies 关键字查看有哪些可用的。以下是一些常用的 crate1

  • bindgen —— 自动为 C 库生成 Rust FFI 绑定。
  • cc —— 编译 C/C++/汇编代码。
  • pkg-config —— 使用 pkg-config 工具检测系统库。
  • cmake —— 运行 cmake 构建工具来构建本地库。
  • autocfgrustc_versionversion_check —— 这些 crate 可以根据当前 rustc 的信息(比如编译器版本)实现条件编译。

代码生成

出于各种原因,有些 Cargo 包需要在编译前生成代码。下面我们通过一个简单的示例,演示如何在 build script 中生成一个库调用。

先看一下这个包的目录结构:

.
├── Cargo.toml
├── build.rs
└── src
    └── main.rs

1 directory, 3 files

在这里我们可以看到,项目包含一个 build.rs 构建脚本,以及位于 main.rs 中的二进制目标。该包有一个基本的清单文件:

# Cargo.toml

[package]
name = "hello-from-generated-code"
version = "0.1.0"
edition = "2024"

让我们看看构建脚本内部的内容:

// build.rs

use std::env;
use std::fs;
use std::path::Path;

fn main() {
    let out_dir = env::var_os("OUT_DIR").unwrap();
    let dest_path = Path::new(&out_dir).join("hello.rs");
    fs::write(
        &dest_path,
        "pub fn message() -> &'static str {
            \"Hello, World!\"
        }
        "
    ).unwrap();
    println!("cargo::rerun-if-changed=build.rs");
}

这里有几个值得注意的点:

  • 脚本通过 OUT_DIR 环境变量来确定输出文件应存放的位置。虽然可以使用进程当前的工作目录来定位输入文件,但本例中并没有输入文件。
  • 一般来说,构建脚本不应该修改 OUT_DIR 之外的任何文件。乍一看这似乎没什么问题,但当该 crate 被用作依赖项时,确实会引发问题,因为 隐含 的约定是 .cargo/registry 中的源代码必须是不可变的。cargo 在打包时不允许此类脚本。
    • 有时,项目希望将生成的文件提交到版本控制中,并将其视为源代码。然而在这种情况下,该文件不应由 build.rs 生成。相反,应编写一个测试(或类似的检查)来验证该文件与生成版本完全一致,如果结果不匹配则测试失败,并将该测试纳入 CI 流程。(测试可以生成一个临时文件用于比对;如果想更新已提交的生成文件,可以用该临时文件替换已提交的版本。)
  • 这个脚本比较简单,因为它只是写入了一个小文件的生成内容。可以想象更复杂的操作,例如从 C 头文件或另一种语言的定义生成 Rust 模块等。
  • rerun-if-changed 指令 告知 Cargo 仅当构建脚本自身发生变化时才需重新执行。缺少这行指令时,包内任何文件变动都会触发 Cargo 自动重新运行构建脚本。若代码生成依赖某些输入文件,应在此处打印每个文件的路径列表。
  • 下面,让我们查看库本身的代码:

    // src/main.rs
    
    include!(concat!(env!("OUT_DIR"), "/hello.rs"));
    
    fn main() {
        println!("{}", message());
    }

    这里发生了真正的“魔法”。库将 Rust 编译器定义的 include!concat!env! 宏结合使用,将 生成的文件(hello.rs)纳入 crate 的编译过程。

    采用此处展示的结构,crate 可以引入构建脚本生成的任意数量文件。

    构建原生库

    有时需要在包中包含并编译一些原生 C 或 C++ 代码。这是利用构建脚本在 Rust crate 自身编译前构建原生库的另一个绝佳场景。举例来说,我们将创建一个调用 C 函数打印“Hello, World!”的 Rust 库。

    与上文类似,先来看包的结构:

    .
    ├── Cargo.toml
    ├── build.rs
    └── src
        ├── hello.c
        └── main.rs
    
    1 directory, 4 files
    

    结构大致相同!接下来查看清单文件:

    # Cargo.toml
    
    [package]
    name = "hello-world-from-c"
    version = "0.1.0"
    edition = "2024"
    

    暂时不使用任何构建依赖,因此先看构建脚本:

    // build.rs
    
    use std::process::Command;
    use std::env;
    use std::path::Path;
    
    fn main() {
        let out_dir = env::var("OUT_DIR").unwrap();
    
        // 注意,这种做法有不少缺点,下面的注释说明了如何提升这些命令的可移植性。
        Command::new("gcc").args(&["src/hello.c", "-c", "-fPIC", "-o"])
                           .arg(&format!("{}/hello.o", out_dir))
                           .status().unwrap();
        Command::new("ar").args(&["crus", "libhello.a", "hello.o"])
                          .current_dir(&Path::new(&out_dir))
                          .status().unwrap();
    
        println!("cargo::rustc-link-search=native={}", out_dir);
        println!("cargo::rustc-link-lib=static=hello");
        println!("cargo::rerun-if-changed=src/hello.c");
    }

    这个构建脚本先把 C 文件编译成目标文件(调用 gcc),再把目标文件转换成静态库(调用 ar)。最后一步是向 Cargo 反馈:输出位于 out_dir 中,编译器应该通过 -l static=hello 标志将 crate 静态链接到 libhello.a

    不过要注意,这种硬编码的方式有几个明显的缺点:

    • gcc 命令本身不具备跨平台可移植性。比如 Windows 平台一般没有 gcc,而且也不是所有 Unix 平台都装了它。ar 命令的情况也类似。
    • 这些命令没有考虑交叉编译。比如我们要为 Android 这样的平台做交叉编译时,gcc 很可能生成不了 ARM 可执行文件。

    不过别担心,这正是 build-dependencies 能派上用场的地方!Cargo 生态中有很多包可以把这类任务变得更容易、更可移植、更标准化。我们来试试 crates.io 上的 cc crate。首先,把它加到 Cargo.tomlbuild-dependencies 中:

    [build-dependencies]
    cc = "1.0"
    

    然后重写构建脚本,改用这个 crate:

    // build.rs
    
    fn main() {
        cc::Build::new()
            .file("src/hello.c")
            .compile("hello");
        println!("cargo::rerun-if-changed=src/hello.c");
    }

    cc crate 抽象化了 C 代码构建脚本中的多种需求:

    • 调用合适的编译器(Windows 上用 MSVC,MinGW 上用 gcc,Unix 平台上用 cc 等)。
    • 通过向所用编译器传递合适的标志,将 TARGET 变量纳入考虑。
    • OPT_LEVELDEBUG 等其他环境变量均自动处理。
    • stdout 输出和 OUT_DIR 位置也由 cc 库处理。

    由此可以初步看出,将尽可能多的功能委托给通用的构建依赖库,而非在所有构建脚本中重复逻辑,带来了显著的好处!

    回到案例研究,我们快速查看 src 目录的内容:

    // src/hello.c
    
    #include <stdio.h>
    
    void hello() {
        printf("Hello, World!\n");
    }
    
    // src/main.rs
    
    // 注意这里没有使用 #[link] 特性。我们把选择链接什么的责任交给了构建脚本,
    // 而不是硬编码在源文件中。
    unsafe extern { fn hello(); }
    
    fn main() {
        unsafe { hello(); }
    }

    就是这样!至此,我们完成了使用 Cargo 包和构建脚本本身构建 C 代码的示例。这也说明了为何在许多情况下,使用构建依赖库至关重要,甚至更加简洁!

    我们还简要看到,构建脚本可以将某个 crate 作为依赖项仅用于构建过程,而不用于运行时。

    链接系统库

    本示例演示如何链接系统库,以及构建脚本如何支持此用例。

    Rust 程序经常需要链接系统提供的原生库,以便绑定其功能,或将其作为实现细节的一部分。若要以平台无关的方式完成这一操作,其中的细节颇为复杂。最佳实践是尽可能将这些工作外包出去,让下游使用者用起来更轻松。

    在本例中,我们将创建一个指向系统 zlib 库的绑定。zlib 是一个提供数据压缩功能的库,在大多数 Unix 类系统中都很常见。虽然 libz-sys crate 已经封装了这些功能,但为了教学目的,这里演示一个极简版本。完整示例的源代码见 这里

    为了方便定位库的位置,我们将使用 pkg-config crate。该 crate 利用系统的 pkg-config 工具来发现库的相关信息,并自动告知 Cargo 链接该库所需的配置。这通常仅适用于安装了 pkg-config 的 Unix 类系统。首先,我们来配置 manifest:

    # Cargo.toml
    
    [package]
    name = "libz-sys"
    version = "0.1.0"
    edition = "2024"
    links = "z"
    
    [build-dependencies]
    pkg-config = "0.3.16"
    

    注意我们在 package 表中添加了 links 字段。这告诉 Cargo 我们正在链接 libz 库。关于如何利用这一特性的示例,请参见 “使用其他 sys crate”

    构建脚本非常简单:

    // build.rs
    
    fn main() {
        pkg_config::Config::new().probe("zlib").unwrap();
        println!("cargo::rerun-if-changed=build.rs");
    }

    最后,我们添加一个基础的 FFI 绑定来完善示例:

    // src/lib.rs
    
    use std::os::raw::{c_uint, c_ulong};
    
    unsafe extern "C" {
        pub fn crc32(crc: c_ulong, buf: *const u8, len: c_uint) -> c_ulong;
    }
    
    #[test]
    fn test_crc32() {
        let s = "hello";
        unsafe {
            assert_eq!(crc32(0, s.as_ptr(), s.len() as c_uint), 0x3610a686);
        }
    }

    运行 cargo build -vv 可以看到构建脚本的输出。在已安装 libz 的系统上,输出大致如下:

    [libz-sys 0.1.0] cargo::rustc-link-search=native=/usr/lib
    [libz-sys 0.1.0] cargo::rustc-link-lib=z
    [libz-sys 0.1.0] cargo::rerun-if-changed=build.rs
    

    很省事!pkg-config 找到了库的位置,并告诉了 Cargo。

    很多包还会把库的源码一并打包进来:如果系统上找不到这个库,或者设置了某个 feature 或环境变量,就改为静态编译。比如真正的 libz-sys crate 就会检查环境变量 LIBZ_SYS_STATICstatic feature,决定从源码编译还是使用系统库。想看更完整的示例,可以查阅它的源码

    使用其他 sys crate

    声明了 links 键的 crate 可以设置元数据,供依赖它的其他 crate 读取,从而在 crate 之间传递信息。本节我们将创建一个使用 zlib 的 C 库,zlib 由真正的 libz-sys crate 提供。

    如果你的 C 库依赖 zlib,可以借助 libz-sys crate 自动查找或构建 zlib,这对跨平台支持很有用——比如 Windows 上通常没装 zlib。libz-sys设置 include 元数据,告诉其他包去哪里找 zlib 的头文件。我们的构建脚本可以通过 DEP_Z_INCLUDE 环境变量读取这个元数据,示例如下:

    # Cargo.toml
    
    [package]
    name = "z_user"
    version = "0.1.0"
    edition = "2024"
    
    [dependencies]
    libz-sys = "1.0.25"
    
    [build-dependencies]
    cc = "1.0.46"
    

    这里引入 libz-sys 是为了确保最终产物中只用一份 libz,同时让构建脚本能访问到它:

    // build.rs
    
    fn main() {
        let mut cfg = cc::Build::new();
        cfg.file("src/z_user.c");
        if let Some(include) = std::env::var_os("DEP_Z_INCLUDE") {
            cfg.include(include);
        }
        cfg.compile("z_user");
        println!("cargo::rerun-if-changed=src/z_user.c");
    }

    既然 libz-sys 承担了繁重的构建工作,C 源代码现在可以直接包含 zlib 头文件。即使系统中未预装 zlib,也能找到该头文件。

    // src/z_user.c
    
    #include "zlib.h"
    
    // … 其余使用 zlib 的代码。
    

    读取目标平台配置

    当构建脚本需要根据目标平台做出决策时,应读取 CARGO_CFG_* 环境变量,而非使用 cfg!#[cfg] 属性。这是因为构建脚本是在并运行于宿主机器上,而 CARGO_CFG_* 变量反映的是目标平台的情况。在进行交叉编译时,这一区别至关重要。

    // build.rs
    
    fn main() {
        // 读取 TARGET 配置
        let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
    
        if target_os == "windows" {
            println!("cargo::rustc-link-lib=userenv");
        } else if target_os == "linux" {
            println!("cargo::rustc-link-lib=pthread");
        }
    }

    注意,某些配置值可能包含多个由逗号分隔的值(例如,CARGO_CFG_TARGET_FAMILY 可能是 unix,wasm)。检查这些值时,务必妥善处理这种多值情况。

    为了获得更便捷的类型化 API,可以考虑使用 build-rs crate,它能帮你处理这些细节。

    条件编译

    构建脚本可以发出 rustc-cfg 指令,以启用可在编译时检查的条件。在本例中,我们将看看 openssl crate 如何利用这一机制来支持多个版本的 OpenSSL 库。

    openssl-sys crate 负责构建并链接 OpenSSL 库。它支持多种不同实现(如 LibreSSL)以及多个版本。该 crate 利用 links 键向其他构建脚本传递信息。其中一个传递的内容是 version_number 键,即检测到的 OpenSSL 版本。构建脚本中的代码如下所示:此处链接

    println!("cargo::metadata=version_number={openssl_version:x}");

    此指令会在任何直接依赖 openssl-sys 的 crate 中设置 DEP_OPENSSL_VERSION_NUMBER 环境变量。

    提供高级接口的 openssl crate 将 openssl-sys 指定为依赖项。openssl 的构建脚本可通过 DEP_OPENSSL_VERSION_NUMBER 环境变量读取由 openssl-sys 构建脚本生成的版本信息,并据此生成一些 cfg

    // (build.rs 的部分代码)
    
    println!("cargo::rustc-check-cfg=cfg(ossl101,ossl102)");
    println!("cargo::rustc-check-cfg=cfg(ossl110,ossl110g,ossl111)");
    
    if let Ok(version) = env::var("DEP_OPENSSL_VERSION_NUMBER") {
        let version = u64::from_str_radix(&version, 16).unwrap();
    
        if version >= 0x1_00_01_00_0 {
            println!("cargo::rustc-cfg=ossl101");
        }
        if version >= 0x1_00_02_00_0 {
            println!("cargo::rustc-cfg=ossl102");
        }
        if version >= 0x1_01_00_00_0 {
            println!("cargo::rustc-cfg=ossl110");
        }
        if version >= 0x1_01_00_07_0 {
            println!("cargo::rustc-cfg=ossl110g");
        }
        if version >= 0x1_01_01_00_0 {
            println!("cargo::rustc-cfg=ossl111");
        }
    }

    这些 cfg 值随后可以配合 cfg 属性cfg来有条件地编译代码。例如,SHA3 支持是在 OpenSSL 1.1.1 中才加入的,因此对于旧版本会有条件地排除这部分代码:

    // (openssl crate 的一部分)
    
    #[cfg(ossl111)]
    pub fn sha3_224() -> MessageDigest {
        unsafe { MessageDigest(ffi::EVP_sha3_224()) }
    }

    当然,使用这种方式要谨慎,因为它会让最终生成的二进制文件更加依赖构建环境。在上面这个例子里,如果把二进制文件分发到另一台系统上,那边的共享库版本可能不完全一致,从而引发问题。


    1. 这份列表并非推荐背书。请自行评估你的依赖,选出适合你项目的那个。

    评论 (0)