构建脚本示例

以下部分将通过一些示例来说明如何编写构建脚本。

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

  • bindgen — 自动生成 Rust FFI 绑定到 C 库。
  • cc — 编译 C/C++/汇编代码。
  • pkg-config — 使用 pkg-config 工具检测系统库。
  • cmake — 运行 cmake 构建工具来构建原生库。
  • autocfgrustc_versionversion_check — 这些包提供了根据当前 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 = "2021"

让我们看看构建脚本里面是什么

// 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 中的源代码应该是不可变的。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) 包含到包的编译中。

使用这里显示的结构,包可以包含来自构建脚本本身的任意数量的生成文件。

构建原生库

有时,需要将一些原生 C 或 C++ 代码作为包的一部分进行构建。这是利用构建脚本在 Rust 包本身之前构建原生库的另一个极好的用例。例如,我们将创建一个 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 = "2021"

现在我们不打算使用任何构建依赖项,所以让我们看一下构建脚本

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

这个构建脚本首先将我们的 C 文件编译成一个目标文件(通过调用 gcc),然后将这个目标文件转换成一个静态库(通过调用 ar)。最后一步是向 Cargo 本身反馈,告诉它我们的输出在 out_dir 中,编译器应该通过 -l static=hello 标志将包静态链接到 libhello.a

请注意,这种硬编码的方法有一些缺点

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

不过,不要害怕,这就是 build-dependencies 条目可以提供帮助的地方!Cargo 生态系统中有许多包可以使这类任务变得更加容易、可移植和标准化。让我们试试 crates.io 上的 cc。首先,将它添加到 Cargo.toml 中的 build-dependencies

[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 代码的一系列构建脚本需求

  • 它调用适当的编译器(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.
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 = "2021"
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。

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

# Cargo.toml

[package]
name = "zuser"
version = "0.1.0"
edition = "2021"

[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 头文件,并且它应该可以找到头文件,即使在尚未安装它的系统上也是如此。

// 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::version_number={:x}", openssl_version);

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

提供更高级别接口的 openssl crate 将 openssl-sys 指定为依赖项。openssl 构建脚本可以使用 DEP_OPENSSL_VERSION_NUMBER 环境变量读取 openssl-sys 构建脚本生成的版本信息。它使用它来生成一些 cfg

// (portion of build.rs)

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

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