构建脚本示例

以下章节阐述了一些编写构建脚本的示例。

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

  • bindgen — 自动生成 Rust FFI 绑定到 C 库。
  • 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 模块。
  • 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 本身之前构建原生库的另一个极好的用例。作为一个示例,我们将创建一个 Rust 库,该库调用 C 以打印“Hello, World!”。

与上面类似,让我们首先看一下包布局

.
├── 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.io 中的 cc crate。首先,将其添加到 Cargo.toml 中的 build-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

// 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.
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};

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 其位置的所有工作。

包通常会包含库的源代码,并在系统上找不到该库,或者如果设置了功能或环境变量,则静态构建它。例如,真正的 libz-sys crate 检查环境变量 LIBZ_SYS_STATICstatic 功能以从源代码构建它,而不是使用系统库。查看 源代码 以获取更完整的示例。

使用另一个 sys crate

使用 links 键时,crate 可以设置元数据,依赖于它的其他 crate 可以读取这些元数据。这提供了一种在 crate 之间传递信息的机制。在此示例中,我们将创建一个 C 库,该库利用来自真正的 libz-sys crate 的 zlib。

如果您的 C 库依赖于 zlib,则可以利用 libz-sys crate 自动查找或构建它。这对于跨平台支持非常有用,例如 Windows,其中通常未安装 zlib。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 库。

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()) }
}

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