构建脚本示例

以下各节展示了一些编写构建脚本的示例。

一些常见的构建脚本功能可以在 crates.io 上的 crate 中找到。查阅 build-dependencies 关键字 以查看可用内容。以下是一些流行的 crate 示例1

  • bindgen — 自动为 C 库生成 Rust FFI 绑定。
  • cc — 编译 C/C++/汇编代码。
  • pkg-config — 使用 pkg-config 工具检测系统库。
  • cmake — 运行 cmake 构建工具来构建原生库。
  • autocfg, rustc_version, version_check — 这些 crate 提供了根据当前的 rustc(例如编译器版本)实现条件编译的方法。
1

此列表不构成认可。请评估您的依赖项,以确定哪个适合您的项目。

代码生成

出于各种原因,一些 Cargo 包需要在编译前生成代码。在这里,我们将通过一个简单的示例来演示如何将生成库调用作为构建脚本的一部分。

首先,让我们看看这个包的目录结构

.
├── 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 在打包时不允许此类脚本。
  • 这个脚本相对简单,它只是写出一个小的生成文件。可以想象,可能会发生其他更复杂的操作,例如从 C 头文件或其他语言定义生成 Rust 模块。
  • The rerun-if-changed 指令 告诉 Cargo,构建脚本只在脚本本身发生变化时才需要重新运行。如果没有这一行,如果包中的任何文件发生变化,Cargo 将自动运行构建脚本。如果您的代码生成使用了某些输入文件,您可以在此处打印出每个文件的列表。

接下来,让我们看看库本身

// src/main.rs

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

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

这就是奇迹发生的地方。该库结合使用 rustc 定义的 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();

    // 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 将 C 文件编译成目标文件,然后通过调用 ar 将此目标文件转换成静态库。最后一步是向 Cargo 本身反馈,说明我们的输出位于 out_dir 中,并且编译器应该通过 -l static=hello 标志将 crate 静态链接到 libhello.a

请注意,这种硬编码方法存在一些缺点

  • gcc 命令本身不跨平台可移植。例如,Windows 平台不太可能拥有 gcc,甚至并非所有 Unix 平台都有 gccar 命令也处于类似情况。
  • 这些命令不考虑交叉编译。如果我们要交叉编译到 Android 等平台,gcc 不太可能生成 ARM 可执行文件。

不过不用担心,这里就是 build-dependencies 条目派上用场的地方!Cargo 生态系统有许多包可以使这类任务变得更容易、更可移植、更标准化。让我们试试来自 crates.iocc 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");
}

The cc crate 抽象了 C 代码的一系列构建脚本需求

  • 它调用相应的编译器(Windows 使用 MSVC,MinGW 使用 gcc,Unix 平台使用 cc 等)。
  • 它通过向正在使用的编译器传递适当的标志来考虑 TARGET 变量。
  • 其他环境变量,例如 OPT_LEVELDEBUG 等,都会被自动处理。
  • 标准输出和 OUT_DIR 的位置也由 cc 库处理。

在这里,我们可以开始看到将尽可能多的功能分派给通用构建依赖项,而不是在所有构建脚本中重复逻辑的一些主要好处!

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

// src/hello.c

#include <stdio.h>

void hello() {
    printf("Hello, World!\n");
}
// 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(); }
}

好了!这应该完成了我们使用构建脚本从 Cargo 包构建 C 代码的示例。这也说明了为什么在许多情况下使用构建依赖项至关重要,甚至更简洁!

我们还看到了一个简短的示例,说明构建脚本如何仅将 crate 用作构建过程的依赖项,而不是 crate 本身在运行时使用。

链接到系统库

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

Rust crate 经常需要链接到系统上提供的原生库,以绑定其功能或仅将其用作实现细节的一部分。在以平台无关的方式执行此操作时,这是一个相当微妙的问题。如果可能的话,最好将尽可能多的工作分派出去,以便消费者尽可能轻松地使用。

对于本示例,我们将创建对系统 zlib 库的绑定。这是一个在大多数类 Unix 系统上常见的库,提供数据压缩功能。这已经被封装在 libz-sys crate 中,但为了本例,我们将做一个极其简化的版本。请参阅 源代码 以获取完整示例。

为了更容易找到库的位置,我们将使用 pkg-config crate。这个 crate 使用系统的 pkg-config 工具来发现有关库的信息。它会自动告诉 Cargo 链接库所需的内容。这可能只在安装了 pkg-config 的类 Unix 系统上有效。让我们从设置清单文件开始

# 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 之间传递信息的机制。在此示例中,我们将创建一个使用真实的 libz-sys crate 中的 zlib 的 C 库。

如果您的 C 库依赖于 zlib,您可以利用 libz-sys crate 自动查找或构建它。这对于跨平台支持非常有用,例如通常不安装 zlib 的 Windows。libz-sys 设置 include 元数据 以告诉其他包在哪里可以找到 zlib 的头文件。我们的构建脚本可以使用 DEP_Z_INCLUDE 环境变量读取该元数据。这是一个例子

# Cargo.toml

[package]
name = "zuser"
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/zuser.c");
    if let Some(include) = std::env::var_os("DEP_Z_INCLUDE") {
        cfg.include(include);
    }
    cfg.compile("zuser");
    println!("cargo::rerun-if-changed=src/zuser.c");
}

有了 libz-sys 完成所有繁重的工作,C 源代码现在可以包含 zlib 头文件了,即使在未安装 zlib 的系统上,它也应该能找到头文件。

// src/zuser.c

#include "zlib.h"

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

条件编译

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

The openssl-sys crate 实现了 OpenSSL 库的构建和链接。它支持多种不同的实现(如 LibreSSL)和多个版本。它利用 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

// (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 值可以与 cfg 属性cfg 一起使用,以条件性地包含代码。例如,SHA3 支持是在 OpenSSL 1.1.1 中添加的,因此对于旧版本,它被 有条件地排除

// (portion of openssl crate)

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

当然,使用此功能时应谨慎,因为它使生成的二进制文件更加依赖于构建环境。在此示例中,如果将二进制文件分发到另一个系统,它可能没有完全相同的共享库,这可能会导致问题。