跳转到主要内容
Chinese, Simplified

使用bindgen的FFI实用指南(第1部分,共2部分)

Cargo containers loaded on ships in front of a dock

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

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

动机

为了证明这一点,我需要解释一下为什么我们德韦洛一开始就需要这么做。

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

Rust本机支持链接C库并直接调用它们的函数。当然,因此导入的任何函数都需要实际调用unsafe关键字(因为Rust不能保证其不变量或正确性),但这给我们带来了不便,我们可以稍后再使用。

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

宾根(Bindgen)

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

您需要:

  • 正常工作的货物装置。我假设如果你编译的是Rust代码,你就有这个。
  • 一个正在运行的C编译器和pkg配置,用于依赖项解析。
  • 与要使用的库函数对应的头文件。
  • 如果您有很好的源代码,本例假设您是从源代码构建库。否则,如果链接到的静态或动态库不在系统路径中,则需要该库的路径。
  • 与库的API大小相对应的耐心程度。

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

cargo install bindgen

在我的Debian笔记本电脑上,我还需要手动安装clang,尽管您的里程数可能会有所不同。

设置你的板条箱(crate)

我们的新库板条箱将包含肮脏的业务建设和出口本机C库的不安全功能。同样,将所有安全的包装器留给另一个板条箱—这不仅加快了编译速度,而且还使其他板条箱作者能够最少地导入和使用原始的c绑定。FFI板条箱的标准Rust命名约定为lib<XXXX>-sys。

我们要创造一个内部版本.rs将与cc板条箱一起使用的文件,用于编译和链接我们的bindgen导出。让我们将库源代码放在一个名为src的子目录中,并将相关的include文件放在一个名为include的子目录中。接下来,让我们确定货物.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"

接下来我们将填充内部版本.rs文件。下面看起来有点奇怪-我们正在编写一个Rust程序,它将输出一个脚本到stdout;cargo将直接使用这个脚本来构建我们的板条箱。

如果你链接的是一个已经编译过的库,保证在系统路径中,你的内部版本.rs可能就这么简单:

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

不过,大多数情况下,您至少需要使用某种包配置来确保库已实际安装并且链接器可以找到它。在许多情况下,您的库足够小,可以由cargo本身构建为静态库。pkg配置板条箱有助于库和依赖项配置,cc处理从cargo内部构建C代码的脏活。两个板条箱在输出货物所需的行之前都运行配置和构建步骤。在我们的示例中,我们的源代码使用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/自由卢比文件来编译我们的绑定。在这里,我们将禁用与Rust不一致的C命名约定的警告,然后只包含生成的文件:

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

生成绑定

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

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

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

对于一个包含多个API调用的真正头,不幸的是,这将生成比我们想要或需要的更多的定义。生成部分绑定.rs因为我们在德韦洛的项目更接近于此:

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

说服生成器只提供所需的内容,而不吐在未定义的符号上,这是一个反复试验的过程。考虑分阶段生成并连接结果。

它很强大,但并不完美

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

原始Makefile/CMake extras

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

包含标准标头的标头

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

您的板条箱应该只公开库API中的内容,而不是系统头文件或生成代码的标准库中的内容。这是一个痛苦,特别是如果您的库的函数和常量不遵循任何类型的命名约定。唯一的解决方法是使用白名单regex和大量的尝试和错误。

预处理器#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\u ANIMAL\u WALRUS之类的东西。C编译器会隐式地将文字1转换为uint8\t,代码就可以运行了。当然,为了清晰起见,最初的作者应该创建一个enum 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足够聪明,可以将符号识别为常量,但仍然存在一些问题。首先,bindgen必须猜测每个FOO\u ANIMAL\u XXX的类型。在这种情况下,显然是猜测了u32(它不仅与我们的函数参数不匹配,而且在技术上也是错误的)。这导致了另一个问题:在调用feed时,Rust将要求我们显式地将FOO\u ANIMAL\u WALRUS转换为u8。不是很符合人体工程学,是吗?要解决这个问题,我们需要更改生成的常量的类型以匹配函数定义。稍后我们将在安全包装中修复枚举问题。

有些结构应该是不透明的

我们的vendored库为除初始化之外的几乎所有函数传递一个指向上下文对象的指针。(现在我们称之为foo\u ctx\t)这是一种广泛使用的模式,非常合理。但是由于一个实现缺陷,我们的头文件定义了foo\u ctx\u t而不是向前声明它。不幸的是,这泄漏了foo\u ctx\t的内部结构,然后这种泄漏会间接地迫使我们知道并定义一堆我们不关心的其他依赖类型。

Rust实际上不允许对结构进行单独的声明和定义。与C不同,我们不能在Rust中声明foo\u ctx\t而不为其提供定义,而且Rust编译器必须识别foo\u ctx\t名称,以便将指向它的指针用作函数arg。但是我们可以使用变通方法来避免完全定义它。两者都不是完美的,但在撰写本文时,有两种选择至少在实践中起作用。

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

pub enum foo_ctx_t {}

或者我们可以用一个私有的零大小类型字段替换它的内部。这是bindgen默认的功能,只要不依赖mem::size\u of:

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

常量正确性

Bindgen将C常量指针转换为Rust常量*,将未修饰的C指针转换为mut*。如果原始代码是const correct,那么这个结果就很好了。如果没有,它可能会导致头痛以后当试图创建安全的包装。如果可能,修复库。

下面的例子可以很容易地用在一个Rust不安全的块中,对时间的正常(不变)引用和对tm的可变引用:

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

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

锋利的边缘

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

写一个自述文件.md详细说明如何调用bindgen的文件,并将其提交到存储库。相信我,等你意识到有东西不见了你会想要这个的。

添加几个单元测试来测试是否正常,然后尝试运行cargo测试。Bindgen创建了一些自己的测试,以确保生成的结构对齐是正确的。您还可以运行cargo doc——在您的板条箱上打开,以获得您正在输出的内容的高级视图,并再次检查您是否无意中暴露了错误的内容。

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

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

原文:https://medium.com/dwelo-r-d/using-c-libraries-in-rust-13961948c72a

本文:http://jiagoushi.pro/node/1450

讨论:请加入知识星球【全栈和低代码开发】或者微信【it_training】或者QQ群【11107767】

Tags
 
Article
知识星球
 
微信公众号
 
视频号