从 Rust 库中公开 FFI

Photo by Dominika Roseclay from Pexels

Wikipedia 将 FFI 定义为一种机制,通过这种机制,用一种编程语言编写的程序可以调用或使用用另一种编程语言编写的服务。

FFI 可用于加快程序执行(这在 Python 或 Ruby 这类动态语言中很常见),或者只是因为你想使用一些其他语言编写的库(例如 TensorFlow 的核心库是用 C++ 写的,并暴露了 C API,允许其他语言使用)。

为 Rust 库编写 FFI 并不难,但是却有一些挑战和可怕的部分,主要是你要使用指针和 unsafe1。这可能会脱离 Rust 的内存安全模型,换句话说,编译器无法检查一切是否正常,因此内存管理和安全保障取决于开发人员。

在这篇文章中,我将讲述我对 Rust 和 FFI 的经验,基于 battery-ffi ,它将 FFI 暴露给我的另一个 crate — battery。我想做的是提供一个 C 接口来创建特定于 Rust 的结构,并能够从它们获取数据。

首先要做的事

你需要将 libc 添加到 crate 的 dependencies 中,并将 crate-type 设置为cdylib2,这样将会构建出动态库 (.so, .dylib.dll 文件,取决你的操作系统类型)。

[dependencies]
libc = "*"

[lib]
crate-type = ["cdylib"]

将 FFI 层与 “主” 库分离出来,并将不安全的代码转移到一个新的 crate,这可能是一个好主意,类似于社区 *-sys3 的约定,但是这次相反。另外,默认情况下 Rust 库使用 crate-type = ["rlib"],而 FFI 因该是 cdylib。对于如何命名没有统一的约定,但是这些 crate 通常有 -ffi or -capi 后缀。

FFI 语法

下面是函数示例,它从Battery 结构返回一个电池百分比(0.0…100.0%范围):

#[no_mangle]
pub unsafe extern fn battery_get_percentage(ptr: *const Battery) -> libc::c_float {
    unimplemented!()  // Example below will contain the full function
}

它的声明以 #[no_mangle] 开始,该属性禁用 name mangling。简而言之,它允许其他编程语言,以预期的名称(在我们的例子中是 battery_get_percentage)在编译后的库中查找已声明的函数,而不是编译器生成的名称, 就像 _ZN7battery_get_percentage17h5179a29d7b114f74E。谁愿意使用这样的名称?

然后,我们在函数定义时,包含了两个额外的关键字 unsafeextern

extern 关键字使函数遵守 C 调用约定,你可以查看 Wikipedia 了解为什么要这样做。并且可以在 Rust Nomicon 找到所有可用的调用约定。

你之前可能看到unsafe关键字被用于标记不安全的块 (就像 unsafe { .. 做一些可怕的事情 .. }),但是在这里,整个函数被标记为 unsafe ,因为不正确的使用会导致未定义行为,比如传递 NULL悬空指针。以此告诉调用者应该正确使用它并意识到可能造成的后果。

返回参数

在我的例子中,我想向外部公开一些 Rust 的结构,但是由于实现的原因,它们可能包含一些复杂的结构,而强迫最终用户处理这些东西是一个坏主意。例如,如果我的 Manager 结构中包含 Mutex,它应该如何用 C 或 Python 4

这就是我为什么把结构体的实现隐藏在 不透明指针 背后的原因。我将返回一个指向堆上某个内存块的指针,并提供从该指针获取所需数据的函数。堆分配是强制性的,否则,如果你将数据分配到栈上(Rust 默认将数据分配到栈上,除了 Vec,HashMap 等),这样数据会在函数结束时被释放,你将无法正确返回它,因此 Box 是你最好的朋友。

在大多数情况下,你不如要将诸如 u8 or i32 封装到 Box,除非你想在堆上分配他们,按原样返回它们是完全可以的。The Rust FFI OmnibusRust FFI Guide 都提供了如何做到这一点的多个示例。

现在让我们看一下这个函数:

#[no_mangle]
pub extern fn battery_manager_new() -> *mut Manager {
    let manager: Manager = Manager::new();
    let boxed: Box<Manager> = Box::new(manager);

    Box::into_raw(boxed)
}

如你所见,它创建了一个 ManagerBox::new,将其移动到中,然后返回原始指针,指向堆中存储它的位置。不过这个函数不需要用unsafe 标记,因为这里不可能创建一些未定义行为。

传递参数

这个函数接受前面创建的 Manager 结构的指针,并调用 Manager::iter 方法,创建Batteries 结构:

#[no_mangle]
pub unsafe extern fn battery_manager_iter(ptr: *mut Manager) -> *mut Batteries {
    assert!(!ptr.is_null());
    let manager = &*ptr;

    Box::into_raw(Box::new(manager.iter()))
}

我们要做的第一件事是确保传递的 指针 不为 NULL:

assert!(!ptr.is_null());

你确实应该为每个传递的指针执行次操作,因为你的输入并不安全,而且你不应该总是期望得到有效的数据。所以说提前 panic 总比执行一个未定义的性外要好。

之后,我们从这个指针创建对结构的引用:

let manager = &*ptr;

这一行推断所有类型。如果 &* 对你来说看起来有点奇怪的话,这里有一个长版本(这个版本不会编译通过5,但更容易理解发生了什么):

let manager_struct: Manager = *ptr;
let manager: &Manager = &manager_struct;

这里我们解引用 ptr ,并立即重新引用,就得到了我们结构体的引用。

在我的示例中, Manager::iter 方法返回Batteries 迭代器,我也想公开它,因此我执行与 battery_manager_new 函数相同的操作:

Box::into_raw(Box::new(manager.iter()))

释放它

Box::into_raw 调用之后,Rust 会忘记这个变量,因此我们有责任手动释放内存或处理内存泄漏。幸运的是这很简单:

#[no_mangle]
pub unsafe extern fn battery_manager_free(ptr: *mut Manager) {
    if ptr.is_null() {
        return;
    }

    Box::from_raw(ptr);
}

作为一个漂亮的附加,我们悄悄忽略空指针,因为我们不能对它做任何事情。而且在同一个指针上调用两次 Box::from_raw 是一个坏主意,这可能会导致 double-free 行为。

在前面的例子中,我们使用 Box::into_raw 将结构体转换为一个原始指针,现在,我们又将它转换回结构体。接下来发生的是一个常见的 Rust “魔法” — 现在的指针属于 Box 并由 safe Rust 控制,它将在函数结束时自动删除,正确的调用析构函数释放内存。

也应该对*mut Batteries指针完成上面几个例子中的同样的事。

创建 getter

我的 battery crate 的主要目标是提供各种电池(如你笔记本)的信息。因此我们需要创建多个 “getter” 函数,从之前创建的 *const Battery 指针获取数据(没有关于它的例子,但是这个结构体与上面代码片段中的另一个结构体非常类似)。

下面的例子对你来说应该很容易理解,我们正在接收原始指正,验证它,并引用 Battery 结构体:

#[no_mangle]
pub unsafe extern fn battery_get_energy(ptr: *const Battery) -> libc::uint32_t {
    assert!(!ptr.is_null());
    let battery = &*ptr;

    battery.energy()
}

在引用之后,我只是简单地从 Battery::energy 方法中返回一个 u32 ,因为它与 libc::unit32 类型相同,所以它按原样传递给调用者。

你可能注意到了与前面示例的细微差别:这个函数没有接收 *mut 而是接收 *const 指针。

这里 or 这里的文章将帮助你理解其中的区别,以下是 matklad 的简短总结:

如果你为 FFI 使用原始指针 (作为 extern “C” 函数的参数和返回类型),那么 *const 和 *mut 纯粹是一个记录意图的问题,根本不影响生成的代码。然而,记录意图是很重要的,因为 C 和 C++ 有一个规则,你不能修改常量对象。

因为这里我不打算改变电池状态,所有我喜欢用 *const 符号,用这个参数精确地描述我的意图。

处理可选结果

一些Battery 结构体的 方法 返回 Option<T> 类型,他们不能按照原样映射到 C ABI,而且它们的 T 值不能返回 NULL ,因为他们不是指针,而是基本类型,比如 f32

有三种广泛采用的方法来解决这一问题:

  1. 返回一些不可能的值 (例如 C 中常用的 -1)
  2. 创建一个线程本地变量 (通常称为 errno) ,并在每次收到一个“可选”的参数后检查它
  3. 或者类似于下面的代码结构,返回并检查 present == true
 #[repr(C)]
 struct COption<T> {
    value: T,
    present: bool
}

Rust FFI Guide 对第二种方法进行了全面的描述。我现在选择了第一个,更喜欢返回现实生活中不可能的值(你笔记本电池不可能是 340282350000000000000000000000000000000 °C,对吧?)。

处理字符串结果

C 字符串和 Rust 字符串是两种完全不同的类型, 你不能只是将它们转换为另一种类型,官方文档提供了它们之间的大量差异。幸运的是,在我的例子中,我不需要接收传入的字符串,但我要输出它们。 非常类似于前面我们在其中使用了
Box 值的例子。

由于 C 字符串基本上是指向以 nul 字节结尾的堆内存块的指针 (在 char* 类型的情况下),我们需要在堆上分配一些内存,并将 UTF-8 字符串6 放在那里。Rust 提供了 CString 类型,它正是我们需要的,它表示在堆内存上分配的与 C 兼容的字符串。

在下面的例子中, battery.serial_number() 返回 Option<&str>,我们稍后将其转换为 CString,与之前的示例相同,我们将原始指针返回给调用者。如果 battery.serial_number() 返回 None,我们返回一个 NULL 指针,将结果标记为不存在。

由于我们再次在堆上分配了内存,我们需要手动管理它,并在使用后释放内存,这与之前几乎相同:

#[no_mangle]
pub unsafe extern fn battery_get_serial_number(ptr: *const Battery) -> *mut libc::c_char {
    assert!(!ptr.is_null());
    let battery = &*ptr;

    match battery.serial_number() {
        Some(sn) => {
            let c_str = CString::new(*sn).unwrap();
            c_str.into_raw()
        },
        None => ptr::null_mut(),
    }
}

#[no_mangle]
pub unsafe extern fn battery_str_free(ptr: *mut libc::c_char) {
    if ptr.is_null() {
        return;
    }

    CString::from_raw(ptr);
}

相反的情况下,当你需要从 C 接收字符串,记住这一点是至关重要的,C 字符串不仅可以是 UTF-8 以外的编码,可能具有不同的字符发小,因此这确实是个很大的问题,本文中将会跳过。

绑定生成

构建完成后,你将得到库文件,你可以将其发布或发送给客户端程序员,使他们更快乐。除非他们需要用他们的语言再次重写你导出的定义,就像 Python 的 ctypes 需要的那样:

import ctypes

class Manager(ctypes.Structure):
    pass

lib = ctypes.cdll.LoadLibrary('libmy_lib_ffi.so'))

lib.battery_manager_new.argtypes = None
lib.battery_manager_new.restype = ctypes.POINTER(Manager)
lib.battery_manager_free.argtypes = (ctypes.POINTER(Manager), )
lib.battery_manager_free.restype = None

幸运的事,绑定生成器已经为我们准备好了,这些工具可以解析 C 头文件并以所需语言输出生成的代码。 cbindgen crate 的帮助下,我们可以用 FFI 接口信息自动生成 .h 文件,然后将其放入绑定生成器。

嵌入 cbindgen 非常简单,首先我们需要把它作为一个构建依赖项添加到 Cargo.toml 文件:

[build-dependencies]
cbindgen = "0.8.0"

我们还需要 cbindgen.toml 文件, 在Cargo.toml 文件旁边:

include_guard = "my_lib_ffi_h"
autogen_warning = "/* Warning, this file is autogenerated by cbindgen. Don't modify this manually. */"
language = "C"

添加构建脚本:

use std::env;
use std::path::PathBuf;

fn main() {
    let crate_dir = env::var("CARGO_MANIFEST_DIR")
        .expect("CARGO_MANIFEST_DIR env var is not defined");
    let out_dir = PathBuf::from(env::var("OUT_DIR")
        .expect("OUT_DIR env var is not defined"));

    let config = cbindgen::Config::from_file("cbindgen.toml")
        .expect("Unable to find cbindgen.toml configuration file");

    cbindgen::generate_with_config(&crate_dir, config)
        .unwrap()
        .write_to_file(out_dir.join("my_lib_ffi.h"));
}

现在,在 cargo build 命令之后,Rust 的 OUT_DIR 将会包含my_lib_ffi.h 以及所有需要的信息。

附加说明:我发现这个构建脚本在 docs.rs 中构建文档时出现了一些神秘错误,导致构建失败失败。因此,我使用了特性门控制的 cbindgen,将其添加为默认特性,然后在Cargo.toml 中禁用了 docs.rs 构建的默认特性:

[package.metadata.docs.rs]
no-default-features = true

可能是因为构建脚本无法访问 OUT_DIR ,所以你可以尝试将其输出写入另一个文件夹。

后记

这应该足以让你开始为你的 crate 编写 FFI 绑定。你可以查看以下链接获取更多信息:

感谢来自于 #rust IRC 频道的 Moongoodboy{K}sebk,他们提供了校对和宝贵的帮助。


  1. https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html#calling-an-unsafe-function-or-method
  2. https://doc.rust-lang.org/reference/linkage.html
  3. https://www.matt-harrison.com/building-and-using-a-sys-crate-with-rust-lets-make-a-node-clone-well-kind-of/
  4. it can’t unless explicitly defined with #[repr(C)]
  5. With &* you are dereferencing the raw pointer and taking a reference in one operation, but in a non-working example you can’t move out from raw pointer
  6. of course C strings can be in any desired encoding, but usually it is assumed to be an UTF-8, otherwise it quickly become a mess

本文翻译自Exposing FFI from the Rust library

发表评论

电子邮件地址不会被公开。 必填项已用*标注

42 − = 37