今天,我想深入探讨我们在尝试用 Rust 重写 IoT Python 代码时遇到的一个困难:特别是 FFI,或“外部函数接口”——允许 Rust 与其他语言交互的位。一年前,当我尝试编写 Rust 代码以与 C 库集成时,现有的文档和指南经常给出相互矛盾的建议,我不得不自己磕磕绊绊地完成这个过程。本指南旨在帮助未来的 Rustaceans 完成将 C 库移植到 Rust 的过程,并使读者熟悉我们在执行相同操作时遇到的最常见问题。

在本指南中,我们将讨论如何使用 bindgen 将 C 库函数公开给 Rust。我们还将讨论此自动工具集的局限性,以及如何检查您的工作。公平警告:正确实施 FFI 是 Rust 困难模式。如果你是 Rust 的新手,请不要从这里开始。通读这本书,写一些练习代码,然后在你完全熟悉借阅检查器后再回来。

动机

首先需要解释为什么我们需要这样做。

对于我们的重写项目,我们希望与供应商提供的 C 库集成,该库负责通过串行端口通过标准供应商指定的协议与我们的 Z-Wave 芯片进行通信。这种串行通信协议很复杂,难以正确实现,并且还受到严格的时序限制 - 发送到串行端口的字节基本上直接通过无线电传输。在错误的时间发送错误的字节可能会完全挂起无线电芯片。有一份长达数百页的参考文档,其中包含传输和确认、重传逻辑、错误处理、时间间隔等规范。最初的 Python 代码从头开始(错误地)实现了这个协议,这个实现代表了我们遗留堆栈中相当大的错误。最重要的是,无线电芯片组供应商正在推迟认证,除非我们能够证明我们正确实施了协议。巧合的是,提供的参考库(用 C 语言实现)保证符合规范。很明显,供应商C代码似乎是通往业务成功的最短途径。

Rust 原生支持链接 C 库并直接调用它们的函数。当然,因此导入的任何函数都需要 [unsafe](https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html) 关键字才能实际调用(因为 Rust 不能保证其不变性或正确性),但这是一个不便,我们可以在以后再说。

Rust Nomicon 会告诉你,你可以通过在块中 extern 声明来导入函数定义或其他全局符号,只要名称和签名完全对齐。这在技术上是正确的,但并不是那么有用。手动输入函数定义是完全愚蠢的疯狂,当我们有一组非常好的头文件其中包含声明时,它是没有意义的。相反,我们将使用一个工具来从我们库的 C 头文件中生成 Rust 签名。然后我们将运行一些测试代码来验证它是否正常工作,调整内容直到它看起来正确,最后将整个东西烘焙到 Rust crate中。让我们开始吧。

Bindgen

从 C 标头生成 Rust 签名最常用的工具是 bindgen 。我们的目标是创建一个 bindings.rs 表示库的公共 API(其公共函数、结构、枚举等)的文件。我们将配置我们的crate以包含该文件。一旦 crate 构建完毕,我们就可以将该 crate 导入到任何项目中以调用 C 库的函数。

您需要什么:

  • 一个有效的 cargo 设置。我假设如果你正在编译 Rust 代码,你就有这个。
  • 一个有效的 C 编译器和 pkg-config 用于依赖项解析。
  • 与要使用的库函数对应的头文件。
  • 如果你有很棒的源代码;此示例假定您正在从源代码构建库。否则,您将需要要链接到的静态或动态库的路径(如果它不在系统路径中)。
  • 与库 API 的大小相对应的耐心程度。

安装命令行 bindgen 工具非常简单:

cargo install bindgen

在我的 Debian 笔记本电脑上,我也需要手动 apt install clang 操作,尽管您的里程可能会有所不同。

设置 crate

我们的新 crate 将包含构建和导出本机 C 库unsafe功能的肮脏业务。同样,将任何安全的包装器留给另一个crate — 这不仅加快了编译速度,而且还使其他crate作者可以最大限度地减少地导入和使用原始 C 绑定。FFI crate的标准 Rust 命名约定是 lib<XXXX>-sys

我们将创建一个 build.rs 文件,该文件将与 cc crate 一起使用,以编译和链接我们的绑定导出。让我们将库源代码放在名为 的子目录中,将关联的包含文件放在名为 includesrc 子目录中。接下来,让我们确保我们的 Cargo.toml 设置已设置:

[package]
name = "libfoo-sys"
version = "0.1.0"
links = "foo"
build = "build.rs"
edition = "2018"
[dependencies]
libc = "0.2"
[build-dependencies]
cc = { version = "1.0", features = ["parallel"] }
pkg-config = "0.3"

接下来,我们将填充 build.rs 该文件。下面看起来有点奇怪 — 我们正在编写一个 Rust 程序,它将输出一个脚本到 stdout;货物将直接使用此脚本来构建我们的crate。

如果要链接到保证位于系统路径中的已编译库,则 build.rs 可能如下所示:

fn main() {   println!("cargo:rustc-link-lib=foo");}

但是,大多数情况下,您至少需要使用某种包配置来确保实际安装了库并且链接器可以找到它。在许多情况下,您的库足够小,可以由货物本身构建为静态库。 pkg-config crate有助于库和依赖项配置,并 cc 处理从货物内部构建 C 代码的繁琐工作。两个crate在输出货物所需的生产线之前都会运行配置和构建步骤。在我们的示例中,我们的源代码使用 zlib,因此我们使用 pkg-config 查找并导入适当的版本。下面的示例代码还演示如何添加编译器标志和预处理器定义。

fn main() {
    pkg_config::Config::new()
        .atleast_version("1.2")
        .probe("z")
        .unwrap();
    let src = [
        "src/file1.c",
        "src/otherfile.c",
    ];
    let mut builder = cc::Build::new();
    let build = builder
        .files(src.iter())
        .include("include")
        .flag("-Wno-unused-parameter")
        .define("USE_ZLIB", None);
    build.compile("foo");
}

最后,您将需要一个 src/lib.rs 文件来实际编译我们的绑定。在这里,我们将禁用与 Rust 不一致的 C 命名约定的警告,然后只包含宏包含我们生成的文件:

#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
use libc::*;
include!("./bindings.rs");

生成绑定

虽然 bindgen 用户指南似乎指导您在 中 build.rs 动态生成绑定,但实际上您需要在将生成的输出发布到箱子之前对其进行编辑。通过命令行生成一个或多个文件并将输出提交到存储库将为您提供最大的控制权。

最初的生成尝试可能如下所示:

bindgen include/foo_api.h -o src/bindings.rs

对于具有多个 API 调用的真实标头,不幸的是,这将生成比我们想要或需要的更多的定义。为我们在 Dwelo 的项目生成部分的 bindings.rs 命令行最终更接近于此:

bindgen include/foo_api.h -o src/bindings.rs '.*' --whitelist-function '^foo_.*' --whitelist-var '^FOO_.*' -- -DUSE_ZLIB

说服生成器只给你必要的东西,而不是在未定义的符号上吠叫是一个反复试验的过程。考虑分阶段生成并连接结果。

它功能强大,但并不完美

当您将标头传递给 bindgen 时,它将调用 Clang 预处理器,然后贪婪地转换它可以看到的每个符号定义。您需要在命令行进行调整,并重构生成的输出。

原始 Makefile/CMake 附加功能

-- bindgen 命令行之后,您可以添加在针对库构建时通常添加到编译器的任何标志。有时这些将是额外的包含路径,有时当标头具有 #ifdef 受保护的定义时,它们是必需的。对于我们的供应商库,未能定义 OS_LINUX 会隐藏我们需要的一堆符号。(什么,你认为遗留代码会使用标准的编译器定义而不是 __linux__ 编造东西吗?对不起,喜剧时间在大厅和楼梯上。如果生成的输出神秘地缺少函数,请检查您的定义。

包含标准标头的头

Bindgen 非常积极地为预处理器输出中的每个可用符号生成定义,甚至为您不需要的传递系统特定依赖项生成定义。这意味着如果您的标头包含或(或 time.h 包含 stddef.h 另一个包含的标头),您将在生成的输出中产生一堆额外的废话。在编译C++代码时情况更糟,因为C++编译器显然必须导出使用的每个符号, std 即使它不是必需的或不需要的。

您的 crate 应该只公开库 API 中的内容,而不是系统头文件或您生成时的标准库中的内容。这是一个痛苦,特别是如果你的库的函数和常量不遵循任何类型的命名约定。解决此问题的唯一方法是使用白名单正则表达式和大量的试验和错误。

预处理器#defines

#define FOO_ANIMAL_UNDEFINED 0
#define FOO_ANIMAL_WALRUS 1
#define FOO_ANIMAL_DROP_BEAR 2
/* Argument should be one of FOO_ANIMAL_XXX */
void feed(uint8_t animal);

这看起来很做作,但这是我们供应商的 C 库中普遍存在的模式的混淆版本。

在 C 中,这工作正常,因为当您将标头包含在源代码中时,您可以在函数调用它时直接使用类似 FOO_ANIMAL_WALRUS 的东西。C 编译器将隐式地将文字 1 转换为代码, uint8_t 并且代码可以工作。当然,为了清楚起见,原作者应该创建一个枚举 typedef 并使用它,但他们没有,这仍然是我们必须处理的合法 C 代码。

pub const FOO_ANIMAL_UNDEFINED: u32 = 0;
pub const FOO_ANIMAL_WALRUS: u32 = 1;
pub const FOO_ANIMAL_DROP_BEAR: u32 = 2;
extern "C" {
    pub fn feed(animal: u8);
}

尽管 bindgen 足够聪明,可以将符号识别为常量,但仍存在一些问题。首先是绑定必须猜测每个 FOO_ANIMAL_XXX .在这种情况下,它显然是猜测的 u32 (这不仅与我们的函数参数不匹配,而且在技术上也是错误的)。这导致了另一个问题:Rust 将要求我们在 FOO_ANIMAL_WALRUS 调用 u8 feed .不太符合人体工程学,是吗?为了解决这个问题,我们需要更改生成的 consts 上的类型以匹配函数定义。稍后我们将在安全包装器中修复枚举问题。

有些结构应该是不透明的

我们供应商的库为除初始化之外的几乎所有函数传递指向上下文对象的指针。(现在我们来称呼它 foo_ctx_t 。这是一种广泛使用的模式,非常合理。但是由于实现缺陷,我们的头文件定义 foo_ctx_t 而不是正向声明它。不幸的是,这泄露了 的内部 foo_ctx_t 结构。然后,这种泄漏会暂时迫使我们知道并定义一堆我们不关心的其他依赖类型。

Rust 实际上不允许对结构进行单独的声明和定义。与 C 不同,我们不能只在 Rust 中声明 foo_ctx_t 而不提供它的定义,并且 Rust 编译器必须识别名称 foo_ctx_t 才能使用指向它的指针作为函数参数。但是我们可以使用解决方法来避免必须完全定义它。它们都不是完美的,但在撰写本文时,有两种选择至少在实践中是有效的。

我们可以将结构定义替换为没有变体的枚举类型,如果您不小心尝试构造它或将其用作指针目标以外的任何内容,这将方便地为您提供编译错误。这让类型纯粹主义者感到不安,因为我们在技术上对编译器撒谎,但它确实有效:

pub enum foo_ctx_t {}

或者我们可以用一个私有的零大小类型字段替换它的内部。这是 bindgen 默认所做的,只要你不依赖 mem::size_of

pub struct foo_ctx_t {
    _unused: [u8; 0],
}

常量正确性

Bindgen 会将 C 常量指针转换为 Rust const * ,将未修饰的 C 指针转换为 mut * 。如果原始代码是常量正确的,这就可以了。如果没有,以后在尝试创建安全包装器时可能会引起头痛。如果可能,请修复库。

下面的示例可以很容易地在 Rust unsafe块中使用,其中包含对以下的 time_t tm 正常(不可变)引用和可变引用:

// Generated from <time.h>
extern "C" {
    pub fn gmtime_r(_t: *const time_t, _tp: *mut tm) -> *mut tm;
}

从技术上讲,您不必修改 C 库即可在 extern Rust 定义 const * 中更改指向的指针。事实上,C 库的符号表甚至没有参数列表,所以 Rust 的链接器根本无法确认你的函数参数是否正确(谢天谢地,C++符号并非如此)。如果您确实修改了 Rust 指针类型,则负责验证 const 指针的不变量对于库来说实际上是正确的。

锋利的边缘

如果您的函数具有错误的返回值,请立即帮自己一个忙,并确保 #[must_use] 注释附加到每个值。如果调用者忘记检查返回值是否存在错误,这至少会给出一些指示,并且稍后当我们将所有内容包装在安全层中时,这将有所帮助。

编写一个 README.md 文件,详细说明您如何调用 bindgen,并将其提交到存储库。相信我,当你意识到缺少一些东西时,你会想要这个。

添加几个单元测试以确保健全,然后尝试运行 cargo test 。Bindgen 有助于创建自己的一些测试,以确保生成的结构对齐正常。您还可以 cargo doc --open 在crate上运行,以获得要导出的内容的高级视图,并仔细检查您是否不小心暴露了错误的内容。

综上所述,这些手动步骤是必要的,因为 bindgen 正在尽其所能地利用它所拥有的信息。生成过程将公开 C 库中的每个小结构问题。

当你完成所有工作时,希望你会留下一个不太可恶的 Rust 包,它通过unsafe的 Rust 公开你的原始库 API。你已经成功了一半!接下来,我们将讨论如何采用这些绑定并将它们保护在符合人体工程学和安全的包装器后面,以便我们的应用程序代码不会错误地使用它们。