Cargo 构建脚本实例:生成代码、编译 C 库与读取目标配置

构建脚本在 Rust 包编译之前完成准备工作。它可以生成源文件、构建本地库、探测系统依赖,再把链接和条件编译信息交给 Cargo。下面通过具体例子说明这些环节如何配合。

不少常见功能已有专门的构建依赖:bindgen 生成 C 库的 Rust FFI 绑定;cc 编译 C、C++ 和汇编;pkg-config 借助系统工具发现库;cmake 调用 CMake 构建本地库;autocfg、rustc_version 和 version_check 根据当前 Rust 编译器能力或版本选择编译条件。可在 crates.io 的 build-dependencies 分类查找更多工具。这份清单不是项目背书,选择前应评估自己的需求。

本页原例使用 Rust 2024 edition 和 cargo::KEY=VALUE 指令。Rust 2024 从 1.85.0 起提供,双冒号指令语法要求 Cargo/Rust 1.77 或更新版本。保留原例时,应使用兼容 Rust 2024 的工具链;不能只把双冒号改成单冒号就认为整个示例兼容旧工具链。Rust 2024 说明 构建脚本指令的版本要求

生成代码

有些包需要在编译前生成代码。先看这个最小包的结构:

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

1 directory, 3 files

build.rs 是构建脚本,src/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。写到外部目录看似方便,却可能在这个包作为依赖使用时破坏 .cargo/registry 内源码应当不变的约定;打包流程也不会接受这种做法。
  • 如果项目要把生成结果提交到版本库,并当作源码维护,应由测试或类似检查验证“已提交文件与重新生成结果完全一致”,不应让 build.rs 改写它。CI 可以生成临时文件进行比较,需要更新时再用临时结果替换源码。
  • 本例只生成一个小 Rust 文件。同样的机制可以用于从 C 头文件或其他语言定义生成 Rust 模块。
  • cargo::rerun-if-changed=build.rs 告诉 Cargo 仅在构建脚本变化时重新执行这个脚本。没有该指令时,包内任意文件变化都可能触发重跑。如果生成过程还读取其他输入文件,应为这些文件分别输出重跑指令。

程序通过宏纳入生成文件:

// src/main.rs

include!(concat!(env!("OUT_DIR"), "/hello.rs"));

fn main() {
    println!("{}", message());
}

include! 接收由 concat! 和 env! 拼出的路径,把 hello.rs 加入当前 crate 的编译。相同结构可以纳入任意多个构建脚本生成的文件。

构建本地库

构建脚本也可以在 Rust crate 编译之前构建 C 或 C++ 库。这个例子让 Rust 调用 C 函数输出一条消息:

.
├── 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();

    // Note that there are a number of downsides to this approach, the comments
    // below detail how to improve the portability of these commands.
    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");
}

它先调用 gcc 生成对象文件,再调用 ar 建立静态库。随后通过标准输出告诉 Cargo:库文件在 OUT_DIR 中,需要把 hello 作为静态库链接。

这种写法有明显局限。Windows 上通常没有这个 GCC 工具名;即便是 Unix 系统,也不一定安装了 gcc 或 ar。脚本也没有处理交叉编译:为 Android 等其他目标构建时,宿主机默认的 GCC 不一定会生成目标架构的产物。原例的 .status().unwrap() 只检查启动进程及等待结果有没有出错,没有进一步检查退出状态是否成功;实际工程还应处理非零退出状态。

构建依赖可以集中处理这些差异。把 cc 加到清单:

[build-dependencies]
cc = "1.0"

构建脚本可简化为:

// build.rs

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

cc 自动选择平台上的 C 编译器,例如 MSVC 的 cl、MinGW 的 gcc 或 Unix 的 cc;依据 TARGET 为编译器选择合适选项;处理 OPT_LEVEL、DEBUG 等环境信息;并管理给 Cargo 的输出和生成目录。让通用构建依赖承担这些规则,可以减少每个包重复维护的逻辑。

C 源文件:

// src/hello.c

#include <stdio.h>

void hello() {
    printf("Hello, World!\n");
}

Rust 调用端:

// src/main.rs

// Note the lack of the `#[link]` attribute. We’re delegating the responsibility
// of selecting what to link over to the build script rather than hard-coding
// it in the source file.
unsafe extern { fn hello(); }

fn main() {
    unsafe { hello(); }
}

这里没有写死 #[link] 属性,因为选用和链接哪个库由构建脚本负责。hello 来自外部 C 库,因此调用位于 unsafe 块中。构建依赖只用于构建阶段,不会因为出现在 [build-dependencies] 中就自动成为程序运行时的 Rust 依赖。

链接系统库

Rust crate 经常需要绑定系统已有的本地库。跨平台探测库的位置和链接参数并不简单,因此应尽可能复用已有工具。

下面对 zlib 建立极简绑定。zlib 在不少类 Unix 系统上提供压缩功能,实际项目通常应考虑已有的 libz-sys;这里的示例只用于展示机制。

pkg-config crate 调用系统 pkg-config 工具查询库信息,并自动向 Cargo 输出所需的链接设置。这份例子主要适用于已经安装 zlib 和 pkg-config 的类 Unix 系统。

# Cargo.toml

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

links = "z" 声明此包对应的本地库标识。这个标识还参与后续的依赖元数据传递。

// build.rs

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

调用 probe("zlib") 进行库探测;失败时本例通过 unwrap() 使构建失败。再添加 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

库发现和 Cargo 链接指令均由 pkg-config 完成。

有些包还随附本地库源码,在系统没有相应库时构建静态版本,或通过功能标志、环境变量要求静态构建。实际 libz-sys 可通过 LIBZ_SYS_STATIC 环境变量或 static 功能选择源码构建路径;完整条件以它自身的当前文档和 源码为准。

使用另一个 sys crate 的元数据

声明了 links 的 crate,可以向直接依赖它的包传递构建元数据。假设自己的 C 库依赖 zlib,可利用 libz-sys 负责查找或构建 zlib;在 Windows 等默认没有 zlib 的系统上,这也有助于提供统一构建途径。

libz-sys 输出 include 元数据说明头文件位置,依赖它的构建脚本通过 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"

这个依赖关系让 Cargo 对同一个 links 标识统一选择本地库包,并使构建脚本能够读取它提供的信息:

// 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");
}

当 DEP_Z_INCLUDE 有值时,把该路径加入 C 编译器的头文件搜索路径。于是 C 文件可以包含 zlib 的头文件:

// src/z_user.c

#include "zlib.h"

// … rest of code that makes use of zlib.

这一小节的 C 文件是原文片段,省略了实际使用 zlib 的业务函数;它不构成完整可执行应用。元数据只传给直接依赖者,不会自动穿过任意层的间接依赖。

读取目标配置

构建脚本编译并运行在宿主机上,而要构建的产物可能属于另一个目标平台。因此,在构建脚本里根据目标做判断,应读取 CARGO_CFG_* 环境变量,而不是用脚本自身的 cfg! 或 #[cfg] 判断。

// build.rs

fn main() {
    // reads the TARGET configuration
    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_OS 对应目标操作系统。部分配置变量有逗号分隔的多个值,例如 CARGO_CFG_TARGET_FAMILY 可能是 unix,wasm,检查时需要逐项处理。如果需要更便利的类型化接口,可以考虑 build-rs。

条件编译

构建脚本可以输出 rustc-cfg 指令,让源码在编译时选择不同实现。OpenSSL 是一个典型例子:openssl-sys 负责底层库的构建和链接,支持 OpenSSL、LibreSSL 等实现及多个版本,并通过 links 元数据传递探测结果。

构建脚本可以输出十六进制版本号:

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

直接依赖 openssl-sys 的包随后会获得 DEP_OPENSSL_VERSION_NUMBER。较高层的 openssl crate 读取它,登记自定义配置并根据版本启用相应条件:

// (portion of 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 值可以供属性或宏使用。例如,SHA3 支持在 OpenSSL 1.1.1 加入,因此对较旧版本排除相关函数:

// (portion of openssl crate)

#[cfg(ossl111)]
pub fn sha3_224() -> MessageDigest {
    unsafe { MessageDigest(ffi::EVP_sha3_224()) }
}

最后两块是原文明确标注的源码片段,依赖周围的 env 导入、MessageDigest 类型与 FFI 定义,不能单独当成完整文件编译。

按构建环境探测库版本,会让产物依赖当时可用的系统库。把二进制分发到另一台机器时,如果那里没有匹配的动态库,仍可能无法正常工作;条件编译没有消除运行环境的依赖。

来源与许可

作者为 Rust 项目及 Cargo 文档贡献者。原文为 The Cargo Book 的 Build Script Examples,源文件属于 Cargo 官方仓库。核对日期为 2026 年 10 月 3 日。

Cargo 采用 MIT / Apache 2.0 双重许可;本次再利用选择 MIT 许可。本稿将正文译为中文,保留原代码和示例版本,并补充工具链版本、进程退出状态、源码片段和未实测边界。未编译这些 Rust/C 示例,也未执行库探测或链接测试。原文输出是来源示例,不是本机结果。

MIT 许可全文
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the “Software”), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容