第十一章 外部函数接口

不是所有的代码都是用 Rust 编写的。这很令人震惊,我知道。每隔一段时间,你就需要与用其他语言编写的代码进行交互,要么从 Rust 中调用这些代码,要么让这些代码调用你的 Rust 代码。你可以通过外部函数接口(FFI)来实现这一点。

在这一章中,我们将首先看一下 Rust 为 FFI 提供的主要机制:extern 关键字。我们将看到如何使用 extern 将 Rust 的函数和静态变量暴露给其他语言,并让 Rust 访问来自 Rust 气泡之外的函数和静态变量。然后,我们将探讨如何将 Rust 类型与其他语言中定义的类型对齐,并探索允许数据跨越 FFI 边界的一些复杂情况。最后,我们将讨论一些你可能想要使用的工具,如果你正在做任何不重要的 FFI。

注意:虽然我经常提到 FFI 是关于跨越一种语言和另一种语言之间的边界,但 FFI 也可以完全发生在 Rust-land 内部。如果一个 Rust 程序与另一个 Rust 程序共享内存,但这两个程序并没有编译在一起--例如,如果你在你的 Rust 程序中使用一个动态链接的库,而这个库恰好是用 Rust 编写的,但你只有 C 语言兼容的 .so 文件--同样的复杂情况就会出现。

用 extern 跨越边界

归根结底,FFI 是关于访问来自你的应用程序的 Rust 代码之外的字节的。为此,Rust 提供了两个主要的构件:符号(symbols),它是分配给二进制文件中特定地址的名称,允许你在外部来源和 Rust 代码之间共享内存(无论是数据还是代码),以及调用约定(call conventions),提供了对如何调用存储在这种共享内存中的函数的共同理解。我们将依次看一下其中的每一项。

符号(symbols)

编译器从你的代码中产生的任何二进制工件都充满了符号--你定义的每个函数或静态变量都有一个符号,指向它在编译的二进制中的位置。泛型函数甚至可以有多个符号,编译器生成的每个函数的单态都有一个符号。

通常情况下,你不必考虑符号--它们被编译器内部用来传递二进制中的函数或静态变量的最终地址。这就是编译器在生成最终的机器代码时,知道每个函数调用应该针对内存中的什么位置,或者如果你的代码访问静态变量,应该从哪里读取。由于你在代码中通常不直接引用符号,编译器默认为它们选择半随机的名字--你可能在代码的不同部分有两个叫 foo 的函数,但编译器会从它们中生成不同的符号,这样就不会有混淆。

然而,当你想调用一个函数或访问一个没有同时编译的静态变量时,使用随机名称的符号是行不通的,例如用不同的语言编写的代码,因此由不同的编译器编译。如果在 C 语言中定义的静态变量的符号有一个不断变化的半随机名称,你就不能告诉 Rust 这个变量。反过来说,如果你不能为一个 Rust 函数产生一个稳定的名字,你就不能告诉 Python 的 FFI 接口关于它的情况。

为了使用一个具有外部来源的符号,我们还需要用某种方式告诉 Rust 关于一个变量或函数的信息,使编译器能够寻找在其他地方定义的相同的符号,而不是定义自己的符号(我们将在后面讨论如何进行搜索)。否则,我们就会为该函数或静态变量设置两个相同的符号,而且不会发生共享。 事实上,很有可能编译会失败,因为任何引用该符号的代码都不知道该用哪个定义(也就是哪个地址)来定义它。

注意:关于术语的简要说明:一个符号可以被多次声明,但只能定义一次。符号的每一个声明都将在链接时链接到该符号的同一个定义。如果没有找到一个声明的定义,或者有多个定义,链接器会抱怨。

关于汇编和链接的一个插曲

编译器速成课程时间!对将代码转化为可运行的二进制文件的复杂过程有一个大致的了解,将有助于你更好地理解 FFI。你看,编译器并不是一个单一的程序,而是(通常)被分解成几个较小的程序,每个程序都执行不同的任务,并一个接一个地运行。在高层次上,编译有三个不同的阶段--编译、代码生成和连接,由三个不同的组件处理。

第一阶段是由大多数人倾向于认为是 "编译器" 来执行的;它处理类型检查、借用检查、单态化和其他我们与特定编程语言相关的功能。这个阶段不生成机器代码,而是生成代码的低级表示,使用大量注释的抽象机器操作。然后,这个低级表示被传递给代码生成工具,该工具生成的机器代码可以在特定的 CPU 上实际运行。

这两种操作加在一起,不一定要在整个代码库上一次性运行一大遍。相反,代码库可以被切成更小的块,然后通过编译并发运行。例如,只要它们之间没有依赖关系,Rust 通常会独立和并行地编译不同的 crates。它还可以分别调用独立 crate 的代码生成工具来并行处理它们。Rust 甚至经常可以分别编译一个 crate 的多个小片断!

一旦应用程序的每一个部分的机器代码被生成,这些部分就可以被连接起来。这是在连接阶段完成的,不出所料,就是连接器。链接器的主要工作是将代码生成时产生的所有二进制工件(称为对象文件),将它们拼接成一个文件,然后用该符号的最终内存地址替换对该符号的每个引用。这就是为什么你可以在一个 crate 中定义一个函数,并从另一个 crate 中调用它,但仍要分别编译这两个 crate。

链接器是使 FFI 工作的原因。它并不关心每个输入对象文件是如何构建的;它只是尽职尽责地将所有对象文件连接在一起,然后解析任何共享符号。 一个对象文件可能最初是 Rust 代码,一个最初是 C 代码,还有一个可能是从互联网上下载的二进制 blob;只要它们都使用相同的符号名称,链接器将确保产生的机器代码对任何共享符号使用正确的交叉引用地址。

符号可以被静态或动态地链接。静态链接是最简单的,因为对一个符号的每个引用都被简单地替换成该符号定义的地址。另一方面,动态链接将每个对符号的引用与一段生成的代码联系起来,在程序运行时试图找到该符号的定义。稍后我们会更多地讨论这些链接模式。Rust 通常默认 Rust 代码采用静态链接,而 FFI 采用动态链接。

使用 extern

extern 关键字是一种机制,它允许我们声明一个符号驻留在一个外部接口中。具体来说,它声明了一个在其他地方定义的符号的存在。在清单 11-1 中,我们在 Rust 中定义了一个名为 RS_DEBUG 的静态变量,我们通过 FFI 让其他代码可以使用。我们还声明了一个名为 FOREIGN_DEBUG 的静态变量,它的定义是未指定的,但会在连接时被解决。

#![allow(unused)]
fn main() {
#[no_mangle]
pub static RS_DEBUG: bool = true;

extern {
    static FOREIGN_DEBUG: bool;
}

// 清单 11-1:通过 FFI 暴露 Rust 静态变量,并访问在其他地方声明的变量
}

#[no_mangle] 属性确保 RS_DEBUG 在编译过程中保留这个名字,而不是让编译器给它分配另一个符号名,例如,将它与程序中其他地方的(非 FFI)RS_DEBUG 静态变量区分开来。这个变量也被声明为 pub,因为它是 crate 的 public API 的一部分,尽管这个注解对于标记为 #[no_mangle] 的项目来说并不是严格必要的。注意,我们没有为 RS_DEBUG 使用 extern,因为它是在这里定义的。它仍然可以从其他语言中被访问以进行链接。

FOREIGN_DEBUG 静态变量周围的 extern 块表示这个声明指的是一个位置,Rust 会在链接时根据相同符号的定义位置来学习。因为它是在其他地方定义的,所以我们不给它一个初始化值,只给它一个类型,这个类型应该与定义地点使用的类型相匹配。由于 Rust 对定义静态变量的代码一无所知,因此无法检查你是否为该符号声明了正确的类型,所以 FOREIGN_DEBUG 只能在 unsafe 块中访问。

注意:Rust 中的静态变量默认是不可变的,无论它们是否在一个外部块中。这些变量在任何线程中都是可用的,所以可变的访问会带来数据竞争的风险。你可以将静态变量声明为可变,但如果你这样做,它的访问就变得不安全了。

声明 FFI 函数的过程非常相似。在清单 11-2 中,我们使 hello_rust 能够被非 Rust 代码访问,并拉入外部的 hello_foreign 函数。

#![allow(unused)]
fn main() {
#[no_mangle]
pub extern fn hello_rust(i: i32) { ... }

extern {
    fn hello_foreign(i: i32);
}

// 清单 11-2 :暴露一个 Rust 函数,并通过 FFI 访问一个在其他地方定义的函数
}

这些构件都与清单 11-1 中的相同,只是 Rust 函数是用 extern fn 声明的,我们将在下一节中探讨这个问题。

如果一个给定的外部符号有多个定义,如 FOREIGN_DEBUGhello_foreign,你可以使用 #[link] 属性明确指定该符号应该与哪个库链接。如果你不这样做,链接器会给你一个错误,说它发现了该符号的多个定义。例如,如果你用 #[link(name = "crypto")] 给外部块加前缀,你就告诉链接器要针对名为 "crypto" 的链接库解析任何符号(无论是静态还是函数)。你也可以在你的 Rust 代码中重命名一个外部静态或函数,方法是用 #[link_name = "<actual_symbol_name>"] 注释其声明,然后该项目链接到你希望的任何名称。同样,你可以用 #[export_name = "<export_symbol_name>"] 来重命名一个用于导出的 Rust 项目。

链接类型

#[link] 还接受参数 kind,它决定了块中的项目应该如何被链接。该参数默认为 dylib,表示与 C 语言兼容的动态链接。另一个种类值是 static,表示块中的项目应该在编译时被完全链接(也就是静态链接)。这基本上意味着外部代码被直接连接到由编译器产生的二进制文件中,因此不需要在运行时存在。还有一些其他类型,但它们不那么常见,也不在本书的讨论范围内。

在静态链接和动态链接之间有一些权衡,但主要考虑的是安全、二进制大小和分布。首先,动态链接倾向于更安全,因为它使独立升级库变得更容易。动态链接允许部署包含你的代码的二进制文件的人升级你的代码所链接的库,而不需要重新编译你的代码。比方说,如果 libcrypto 得到了安全更新,用户可以在主机上更新 crypto 库,然后重新启动二进制文件,更新后的库代码将被自动使用。在静态编译中,库的代码被硬塞进二进制文件中,所以用户必须针对库的升级版本重新编译你的代码以获得更新。

动态链接也倾向于产生更小的二进制文件。由于静态编译将任何链接的代码包括在最终的二进制输出中,以及该代码随后引入的任何代码,它产生的二进制文件更大。通过动态链接,每个外部项目只包括一小段包装代码,在运行时加载指定的库,然后转发访问。

到目前为止,静态链接可能看起来不是很有吸引力,但它比动态链接有一个很大的优势:易于分发。通过动态链接,任何想运行包括你的代码的二进制文件的人都必须拥有你的代码所链接的任何库。不仅如此,他们还必须确保他们拥有的每个库的版本与你的代码所期望的兼容。这对于像 glibc 或 OpenSSL 这样在大多数系统上可用的库来说,可能没有问题,但对于更多晦涩难懂的库来说,这就带来了问题。用户需要意识到他们应该安装这个库,并且必须寻找它以运行你的代码!这就是静态链接。通过静态链接,库的代码被直接嵌入到二进制输出中,所以用户不需要自己安装它。

最终,在静态链接和动态链接之间并没有一个正确的选择。动态链接通常是一个很好的默认值,但静态编译可能是一个更好的选择,适用于特别受限的部署环境或非常小的或小众的库依赖。请使用你的最佳判断!

调用约定(Calling Conventions)

符号决定了一个给定的函数或变量的定义位置,但这还不足以允许跨 FFI 边界的函数调用。要在任何语言中调用一个外部函数,编译器还需要知道它的调用约定,这决定了要使用汇编代码来调用该函数。我们不会在这里讨论每个调用约定的实际技术细节,但作为一个总体概述,该约定规定了:

  • 调用的堆栈框架是如何设置的。
  • 参数是如何传递的(是在堆栈中还是在寄存器中,是按顺序还是按反向传递)。
  • 当函数返回时,如何告诉它要跳回哪里?
  • 在函数完成后,CPU 的各种状态,如寄存器,如何在调用者中恢复。

Rust 有自己独特的调用约定,并没有标准化,允许编译器随着时间的推移而改变。只要所有的函数定义和调用是由同一个 Rust 编译器编译的,这就很好,但如果你想与外部代码互通有无,那就有问题了,因为外部代码不知道 Rust 的调用约定。

如果你没有声明其他东西,每个 Rust 函数都隐含地用 extern "Rust" 来声明。像清单 11-2 中那样单独使用 extern,是 extern "C" 的缩写,意思是 "使用标准的 C 调用约定"。之所以有这个速记,是因为在 FFI 的几乎所有情况下,C 的调用约定都是你想要的。

注意:解卷通常只对常规的 Rust 函数起作用。如果你在一个不是 extern "Rust" 的 Rust 函数的结尾处解卷,你的程序将会中止。绕过 FFI 边界进入外部代码是未定义的行为。在 RFC 2945 中,Rust 获得了一个新的 extern 声明,extern "C-unwind";这允许在特殊情况下跨 FFI 边界解卷,但如果你想使用它,你应该仔细阅读 RFC。

Rust 还支持一些其他的调用约定,你可以在 extern 关键字后面提供一个字符串(在 fnblock 上下文中)。例如,extern "system"表示使用操作系统标准库接口的调用约定,在撰写本文时,除了 Win32 使用 stdcall 调用约定外,其他地方都与 "C" 相同。一般来说,你很少需要明确地提供一个调用约定,除非你正在处理特别是平台特定的或高度优化的外部接口,所以只需要 extern(也就是 extern "C")就可以了。

注意:一个函数的调用约定是其类型的一部分。也就是说,extern "C" fn() 的类型与 fn() 不一样(或 extern "Rust" fn()),后者又与 extern "system" fn() 不同。

其他二进制构件(OTHER BINARY ARTIFACTS)

通常情况下,你编译 Rust 代码只是为了运行它的测试或构建一个二进制文件,然后你要分发或运行。与许多其他语言不同的是,你通常不会编译 Rust 库来向其他人分发它--如果你运行 cargo publish 这样的命令,它只是将你的 crate 的源代码包装起来并上传到 crates.io。这主要是因为除了源代码之外,很难将通用代码作为其他东西发布。由于编译器将每个泛型函数与所提供的类型参数进行单态化,而这些类型可能在调用者的 crate 中定义,编译器必须能够访问函数的泛型形式,这意味着没有优化的机器代码!

从技术上讲,Rust 确实编译了每个依赖的二进制库工件,称为 rlibs,它在最后结合在一起。这些 rlibs 包括解决泛型类型所需的信息,但它们是特定于所使用的具体编译器的,一般不能以任何有意义的方式分发。

那么,如果你想在 Rust 中编写一个库,然后想从其他编程语言中获得接口,你该怎么办?解决方案是以动态链接库(Unix 上的 .so 文件、macOS 上的 .dylib 文件和 Windows 上的 .dll 文件)和静态链接库(Unix/macOS 上的 .a 文件和 Windows 上的 .lib 文件)的形式产生兼容 C 的库文件。这些文件看起来像 C 代码产生的文件,所以它们也可以被其他知道如何与 C 语言互动的语言使用。

为了产生这些与 C 语言兼容的二进制工件,你要在 Cargo.toml 文件的 [lib] 部分设置 crate-type 字段。这个字段有一个数组的值,通常只有 "lib",表示一个标准的 Rust 库(rlib)。如果你的 crate 显然不是一个库(例如,如果它是一个过程性的宏),Cargo 会应用一些启发式方法来自动设置这个值,但最好的做法是,如果你生产的不是一个好的 Rust 库,就明确设置这个值。

有许多不同的 crate 类型,但这里相关的是 cdylibstaticlib,它们分别产生动态和静态链接的 C-兼容库文件。请记住,当你产生这些工件类型之一时,只有公开可用的符号是可用的--也就是说,公开和 #[no_mangle] 静态变量和函数。像类型和常量这样的东西是不可用的,即使它们被标记为 pub,因为它们在二进制库文件中没有意义的表示。

跨越语言界限的类型(Types Across Language Boundaries)

在 FFI 中,类型布局是至关重要的;如果一种语言以一种方式为某些共享数据铺设内存,而 FFI 边界另一侧的语言却希望以不同方式铺设,那么双方对数据的解释就会不一致。在这一节中,我们将看看如何使类型在 FFI 中匹配,以及当你跨越语言之间的边界时需要注意的类型的其他方面。

类型匹配

类型并不在 FFI 边界上共享。当你在 Rust 中声明一个类型时,该类型信息在编译时完全丢失。 所有传达给另一方的是构成该类型值的比特。因此你需要在边界的两边都声明这些比特的类型。当你声明类型的 Rust 版本时,你首先必须确保类型中包含的基元是匹配的。例如,如果在边界的另一边使用 C 语言,而 C 语言类型使用 int,那么 Rust 代码最好使用准确的 Rust 对应物:i32。为了消除这个过程中的一些猜测,对于使用类似 C 类型的接口,Rust 标准库在 std::os::raw 模块中为你提供了正确的 C 类型,它定义了 type c_int = i32, type c_char = i8/u8,取决于 char 是否是有符号的,type c_long = i32/i64 取决于目标指针宽度,等等。

注意:要特别注意 C 语言中古怪的整数类型,如 __be32。 这些类型通常不能直接转化为 Rust 类型,最好是留作类似 [u8; 4] 的类型。例如,__be32 总是被编码为 big-endian,而 Rust 的 i32 使用当前平台的 endianness

对于像向量和字符串这样更复杂的类型,你通常需要手动进行映射。例如,由于 C 语言倾向于将字符串表示为以 0 字节结束的字节序列,而不是单独存储长度的 UTF-8 编码的字符串,所以你一般不能通过 FFI 使用 Rust 的字符串类型。 相反,假设对方使用 C 语言风格的字符串表示,你应该使用 std::ffi::CStrstd::CString 类型,分别用于借用和拥有字符串。对于向量,你可能想使用一个指向第一个元素的原始指针,然后单独传递长度--Vec::into_raw_parts 方法可能在这方面很有用。

对于包含其他类型的类型,如结构体和联合体,你也需要处理布局和对齐问题。正如我们在第 2 章中所讨论的,Rust 默认以未定义的方式布局类型,所以至少你要使用 #[repr(C)] 来确保类型有一个确定的布局和对齐方式,以反映 FFI 边界上(可能也希望)使用的方式。如果接口还指定了该类型的其他配置,例如手动设置其对齐方式或移除填充,你就需要相应地调整你的 #[repr]

根据枚举是否包含数据,Rust 枚举有多种可能的 C 风格表示。考虑一个没有数据的枚举,像这样。

#![allow(unused)]
fn main() {
enum Foo { Bar, Baz }
}

使用 #[repr(C)]Foo类型只用一个整数进行编码,其大小与 C 语言编译器为具有相同数量变体的枚举选择的大小相同。第一个变体的值是 0,第二个变体的值是 1,以此类推。你也可以为每个变体手动赋值,如清单 11-3 所示。

#![allow(unused)]
fn main() {
#[repr(C)]
enum Foo {
    Bar = 1,
    Baz = 2,
}

// 清单 11-3: 为一个无数据的枚举定义显式变量值
}

注意:从技术上讲,规范说第一个变体的值是 0,每个后续变体的值都比前一个变体的值大 1。如果你手动设置了某些变量的值,但没有设置其他变量的值,这就有区别了--你没有设置的变量将从你设置的最后一个变量继续。

然而,你应该小心将 C 语言中的枚举类型映射到 Rust,因为只有定义的变体的值对枚举类型的实例有效。这往往会使你在处理 C 风格的枚举时遇到麻烦,这些枚举的功能通常更像比特集,其中变体可以被比特地或在一起,以产生一个同时封装了多个变体的值。例如,在清单 11-3 的例子中,通过取 Bar | Baz 产生的 3 的值在 Rust 中对 Foo 无效。如果你需要建立一个 C 语言的 API 模型,该模型使用一个枚举的比特标志集,可以单独设置和取消设置,可以考虑使用一个整数类型的新类型包装器,为每个变量提供相关的常量,并实现各种 Bit* 特性,以提高工效。 或者使用 bitflags crate。

注意:对于无字段枚举,你也可以给 #[repr] 传递一个数字类型,以使用与 isize 不同的类型作为判别器。 例如,#[repr(u8)] 将使用一个无符号字节对判别器进行编码。对于一个携带数据的枚举,你可以通过 #[repr(C, u8)] 来获得同样的效果。

在一个包含数据的枚举上,#[repr(C)] 属性会使该枚举用一个标记的联合来表示。也就是说,它在内存中由一个带有两个字段的 #[repr(C)] 结构表示,其中第一个字段是判别符,因为如果没有变体的字段,它将被编码,第二个字段是每个变体的数据结构的联合。对于一个具体的例子,请考虑清单 11-4 中的枚举和相关表示。

#![allow(unused)]
fn main() {
#[repr(C)]
enum Foo {
    Bar(i32),
    Baz { a: bool, b: f64 }
}

// is represented as
#[repr(C)]
enum FooTag { Bar, Baz }
#[repr(C)]
struct FooBar(i32);
#[repr(C)]
struct FooBaz{ a: bool, b: f64 }
#[repr(C)]
union FooData {
    bar: FooBar,
    baz: FooBaz,
}
#[repr(C)]
struct Foo {
    tag: FooTag,
    data: FooData
}

// 清单 11-4:带有#[repr(C)] 的 Rust 枚举被表示为标记的联合体。
}

FFI 中的利基(niche)优化

在第 9 章中,我们谈到了 niche 优化,即 Rust 编译器使用无效的比特模式来表示没有数据的枚举变体。这种优化是有保证的,这导致了与 FFI 的有趣互动。具体来说,这意味着在 FFI 类型中总是可以使用 Option 包装的指针类型来表示可空指针。 例如,可空函数指针可以表示为 Option<extern fn(..)>,而可空数据指针可以表示为 Option<*mut T>。如果提供了一个全零的比特模式值,这些将透明地做正确的事情,并在 Rust 中表示为无。

内存分配 (Allocations)

当你分配内存时,该分配属于其分配器,并且只能由同一分配器释放。如果你在 Rust 中使用多个分配器,或者你在 Rust 中和 FFI 边界另一端的某个分配器中分配内存,情况就是这样。你可以自由地跨越边界发送指针,并尽情地访问该内存,但当需要再次释放该内存时,需要将其返回给适当的分配器。

大多数 FFI 接口会有两种处理分配的配置之一:要么调用者提供指向大块内存的数据指针,要么接口暴露出专门的释放方法,当不再需要这些资源时,应将其返回。清单 11-5 显示了 OpenSSL 库中使用实现管理的内存的一些签名的 Rust 声明的例子。

#![allow(unused)]
fn main() {
// One function allocates memory for a new object.
extern fn ECDSA_SIG_new() -> *mut ECDSA_SIG;

// And another accepts a pointer created by new
// and deallocates it when the caller is done with it.
extern fn ECDSA_SIG_free(sig: *mut ECDSA_SIG);

// 清单 11-5:一个实施管理的内存接口
}

ECDSA_SIG_newECDSA_SIG_free 这两个函数形成了一对,调用者被期望调用 new 函数,根据需要使用返回的指针(可能是通过依次传递给其他函数),然后在完成对引用资源的处理后,将指针传递给 free 函数。据推测,实现者会在新函数中分配内存,并在 free 函数中取消分配。如果这些函数是在 Rust 中定义的,新函数可能会使用 Box::new,而 free 函数会调用 Box::from_raw,然后丢弃值来运行其析构器。

清单 11-6 显示了一个调用者管理的内存的例子。

#![allow(unused)]
fn main() {
// An example of caller-managed memory.
// The caller provides a pointer to a chunk of memory,
// which the implementation then uses to instantiate its own types.
// No free function is provided, as that happens in the caller.
extern fn BIO_new_mem_buf(buf: *const c_void, len: c_int) -> *mut BIO

// 清单 11-6:一个调用者管理的内存接口
}

在这里,BIO_new_mem_buf 函数反而让调用者提供支持内存。调用者可以选择在堆上分配内存,或者使用它认为合适的其他机制来获得所需的内存,然后将其传递给库。然后,调用者有责任确保该内存后来被释放,但只有在 FFI 实现不再需要它的时候。

你可以在你的 FFI API 中使用这两种方法,如果你愿意,甚至可以混合使用。作为一般的经验法则,在可行的情况下,允许调用者传递内存,因为这给了调用者更多的自由来管理它认为合适的内存。例如,调用者可能在某些定制的操作系统上使用高度专业化的分配器,并且可能不想被迫使用你的实现所使用的标准分配器。如果调用者可以传入内存,它甚至可以完全避免分配,如果它可以使用栈内存或重新使用已经分配的内存。然而,请记住,调用者管理的接口的人机工程学往往更加复杂,因为调用者现在必须做所有的工作来弄清楚要分配多少内存,然后在调用到你的库之前进行设置。

在某些情况下,调用者甚至不可能提前知道要分配多少内存--例如,如果你的库的类型是不透明的(因此不为调用者所知),或者会随时间变化,调用者将无法预测分配的大小。同样,如果你的代码在运行时需要分配更多的内存,比如你在运行中构建一个图形,那么需要的内存量可能在运行时动态变化。在这种情况下,你将不得不使用实现管理的内存。

当你不得不做出取舍时,对于任何大的或频繁的内存,都要选择调用者分配的内存。在这些情况下,调用者可能最关心控制分配本身。对于其他的东西,你的代码可能可以分配,然后为每个相关的类型提供解构函数。

回调

你可以跨越 FFI 边界传递函数指针,并通过这些指针调用被引用的函数,只要函数指针的类型有一个符合函数调用惯例的 extern 注释。也就是说,你可以在 Rust 中定义一个 extern "C" fn(c_int) -> c_int,然后将该函数的引用作为回调传递给 C 代码,C 代码最终会调用该函数。

在恐慌周围使用回调时,你确实需要小心,因为如果恐慌超过了除 extern "Rust" 以外的函数的结尾,就是未定义行为。Rust 编译器目前会在检测到这种恐慌时自动中止,但这不一定是你想要的行为。相反,你可能想使用 std::panic::catch_unwind 来检测任何标记为 extern 的函数中的恐慌,然后将恐慌转化为一个与 FFI 兼容的错误。

安全

当你写 Rust FFI 绑定时,大多数实际与 FFI 接口的代码都是不安全的,主要围绕着原始指针。然而,你的目标应该是最终在 FFI 之上呈现一个安全的 Rust 接口。要做到这一点,主要是要仔细阅读你所包装的不安全接口的不变性,然后确保你在安全接口中通过 Rust 类型系统维护它们。安全封装外部接口的三个最重要的因素是准确地捕获 &&mut,适当地实现 SendSync,并确保指针不会被意外地混淆。接下来我将介绍如何执行这些内容。

引用和生存期

如果外部代码有可能修改给定指针后面的数据,请确保安全的 Rust 接口有一个对相关数据的独占引用,即采取 &mut。否则,你的安全封装器的用户可能会意外地从内存中读取外部代码同时修改的数据,然后一切都会变得很糟糕!

你还想很好地利用 Rust 的生存期,以确保所有指针在 FFI 要求的时间内都有效。例如,设想一个外部接口,它让你创建一个 Context,然后让你从该 Context 中创建一个 Device,要求 ContextDevice 存在的时间内保持有效。在这种情况下,该接口的任何安全包装器都应该在类型系统中强制执行这一要求,让 Device 持有一个与 Context 的借用相关的寿命,该 Context 是由 Device 创建的。

Send 和 Sync

不要为外部库中的类型实现 SendSync,除非该库明确说明这些类型是线程安全的。安全的 Rust 封装器的工作是确保安全的 Rust 代码不能违反外部代码的不变性,从而引发未定义的行为。

有时,你甚至可能想引入假类型来强制执行外部不变性。例如,假设你有一个事件循环库,其接口在清单 11-7 中给出。

#![allow(unused)]
fn main() {
extern fn start_main_loop();
extern fn next_event() -> *mut Event;

// 清单 11-7:一个期望单线程使用的库
}

现在,假设外部库的文档指出,next_event 只能由调用 start_main_loop 的同一线程调用。然而,在这里我们没有可以避免实现 Send 的类型!相反,我们可以借鉴第三章的做法,引入额外的标记状态来强制执行该不变性,如清单 11-8 所示。

#![allow(unused)]
fn main() {
pub struct EventLoop(std::marker::PhantomData<*const ()>);
pub fn start() -> EventLoop {
    unsafe { ffi::start_main_loop() };
    EventLoop(std::marker::PhantomData)
}
impl EventLoop {
    pub fn next_event(&self) -> Option<Event> {
        let e = unsafe { ffi::next_event() };
        // ...
    }
}

// 清单 11-8:通过引入辅助类型来执行 FFI 的不变量
}

空类型的 EventLoop 实际上并不与底层外部接口中的任何东西相连接,而是强制执行合同,即只有在调用 start_main_loop 后才能调用 next_evesnt,而且只能在同一个线程上调用。你通过让 EventLoop 持有一个幽灵般的原始指针(它本身既不是 Send 也不是 Sync),来执行 "同一线程" 的部分。

使用 PhantomData<*const ()> 来 "撤销" SendSync 的自动特性,就像我们在这里做的那样,有点丑陋和间接。Rust 确实有一个不稳定的编译器特性,可以实现负属性的实现,比如 impl !Send for EventLoop {}, 但令人惊讶的是,它很难得到正确的实施,而且可能在一段时间内不会稳定下来。

你可能已经注意到,没有什么能阻止调用者多次调用 start_main_loop,无论是从同一个线程还是从另一个线程。你如何处理这个问题将取决于相关库的语义,所以我把它留给你作为一个练习。

指针的混乱(Pointer Confusion)

在许多 FFI API 中,你不一定想让调用者知道你给它的每一块内存指针的内部表示。该类型可能有内部状态,调用者不应该摆弄这些状态,或者这些状态可能很难以跨语言兼容的方式表达。对于这类情况,C 风格的 API 通常暴露出无效指针,写成 C 类型的 void*,相当于 Rust 中的*mut std::fi::c_void。像这样的类型消除的指针,实际上只是一个指针,并不传达任何关于它所指向的东西的信息。由于这个原因,这些类型的指针经常被称为不透明的。

不透明指针有效地起到了跨 FFI 边界的类型的可见性修改器的作用--因为方法签名没有说明被指向的是什么,调用者没有选择,只能将指针原封不动地传递给周围的人,并使用任何可用的 FFI 方法来提供被引用数据的可见性。不幸的是,由于一个*mut c_void 和另一个是无法区分的,所以没有什么可以阻止用户从一个 FFI 方法返回不透明的指针,并将其提供给一个期望指向不同不透明类型的指针的方法。

在 Rust 中我们可以做得更好。为了减少这种指针类型的混淆,我们可以避免在 FFI 中对不透明的指针直接使用 *mut c_void,即使实际的接口要求使用 void*,而是为每个不同的不透明类型构造不同的空类型。例如,在清单 11-9 中,我使用了两个不同的不透明指针类型,它们不会被混淆。

#![allow(unused)]
fn main() {
#[non_exhaustive] #[repr(transparent)] pub struct Foo(c_void);
#[non_exhaustive] #[repr(transparent)] pub struct Bar(c_void);
extern {
pub fn foo() -> *mut Foo;
    pub fn take_foo(arg: *mut Foo);
    pub fn take_bar(arg: *mut Bar);
}

// 清单 11-9:不能混淆的不透明指针类型
}

因为 Foo 和 Bar 都是零大小的类型,所以它们可以在 extern 方法签名中代替 ()。更棒的是,因为它们现在是不同的类型,Rust 不允许你在需要另一个的地方使用一个,所以现在不可能使用从 foo 得到的指针调用 take_bar。添加 #[non_] 注释可以确保 FooBar 类型不能在这个 crate 之外构造。

bindgen 和构建脚本

为一个较大的外部库绘制 Rust 类型和 externs 可能是一件相当麻烦的事情。大的库往往有足够多的类型和方法签名需要匹配,所以写出所有的 Rust 等价物是很耗时的。它们也有足够多的角落案例和 C 语言的怪异之处,因此一些模式的翻译必然需要更仔细的思考。

幸运的是,Rust 社区开发了一个叫做 bindgen 的工具,它大大简化了这一过程,只要你有 C 语言头文件,你想与之对接的库就可以。bindgen 本质上编码了我们在本章中讨论的所有规则和最佳实践,还有其他一些,并将它们包装在一个可配置的代码生成器中,它接收 C 语言头文件并吐出适当的 Rust 对应物。

bindgen 提供了一个独立的二进制文件,可以为 C 语言头文件生成一次 Rust 代码,这在你想检查绑定的时候很方便。这个过程允许你手动调整生成的绑定,如果有必要的话。另一方面,如果你想在每次构建时自动生成绑定,并在你的源代码中包含 C 头文件,bindgen 也作为一个库,你可以在软件包的自定义构建脚本中调用。

注意:如果你直接检查绑定,请记住它们只在为其生成的平台上是正确的。 在构建脚本中生成绑定将为当前的目标平台专门生成它们,这不太可能导致与平台有关的布局不一致。

你可以在 Cargo.toml 的 [package] 部分添加 build = "<some-file.rs>"来声明一个构建脚本。这告诉 Cargo,在编译你的 crate 之前,它应该把 <some-file.rs> 编译成一个独立的 Rust 程序并运行它;只有这样,它才能编译你的 crate 的源代码。构建脚本也有自己的依赖,你可以在 Cargo.toml 的 [build-dependencies] 部分声明这些依赖。

注意:如果你把你的构建脚本命名为 build.rs,你就不需要在 Cargo.toml 中声明它。

构建脚本在 FFI 中非常方便--它们可以从源代码中编译一个捆绑的 C 库,动态地发现和声明额外的构建标志以传递给编译器,声明 Cargo 应该为重新编译而检查的额外文件,而且,你猜对了,可以在运行中生成额外的源文件!这就是 FFI。

虽然构建脚本的用途非常广泛,但要小心让它们对运行的环境过于了解。虽然你可以用构建脚本来检测 Rust 编译器的版本是否是质数,或者明天伊斯坦布尔是否会下雨,但让你的编译依赖于这些条件可能会使其他开发者的构建意外失败,从而导致糟糕的开发体验。

构建脚本可以将文件写到一个通过 OUT_DIR 环境变量提供的特殊目录中。同样的目录和环境变量在编译时也可以在 Rust 源代码中访问,这样它就可以接收由构建脚本生成的文件了。为了从 C 头文件中生成和使用 Rust 类型,你首先让你的构建脚本使用 bindgen 的库版本来读入一个 .h 文件,并把它变成一个文件,比如说,在 OUT_DIR 里面的 bindings.rs。然后你在你的 crate 中的任何 Rust 文件中添加以下一行,以便在编译时包含 bindings.rs

#![allow(unused)]
fn main() {
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
}

由于 bindings.rs 中的代码是自动生成的,通常最好的做法是将绑定放在自己的 crate 中,并给 crate 起一个与绑定的库相同的名字,加上后缀 -sys(例如,openssl-sys)。如果你不遵循这种做法,发布你的库的新版本将更加痛苦,因为两个通过 Cargo.toml 中的链接键链接到同一个外部库的 crate 共存于一个特定的构建中是非法的。你将不得不一次性将整个生态系统升级到你的库的新主要版本。将绑定分离到自己的 crate 中,可以让你发布新的主要版本的封装 crate,从而可以逐步采用。这种分离还允许你在 Rust 绑定发生变化时,例如头文件本身升级或 bindgen 升级导致生成的 Rust 代码发生轻微变化时,削减带有这些绑定的 crate 的中断版本,而不必同时削减安全包裹 FFI 绑定的 crate 的中断版本。

注意:记住,如果你在你的主库的公共接口中包含了来自 -sys crate 的任何类型,将对 -sys crate 的依赖性改变到一个新的主要版本仍然构成对你的主库的破坏性改变。

如果你的 crate 产生了一个库文件,你想让别人通过 FFI 来使用,那么你也应该为它的接口发布一个 C 头文件,以便更容易从其他语言生成与你的库的本地绑定。然而,这个 C 头文件需要随着你的 crate 的变化而保持更新,这可能会随着你的库的规模的增长而变得很麻烦。幸运的是,Rust 社区也开发了一个工具来自动完成这项任务:cbindgen。 和 bindgen 一样,cbindgen 也是一个构建工具,而且它也是以二进制文件和库的形式出现,用于构建脚本。它不是接收一个 C 头文件并产生 Rust,而是接收 Rust 并产生一个 C 头文件。由于 C 头文件代表了你的 crate 的 FFI 的主要计算机可读描述,我建议手动查看它,以确保自动生成的 C 代码不会太笨重,尽管一般来说 cbindgen 倾向于产生相当合理的代码。如果不是这样,请提交一个 bug!

C++

在本章中,我主要关注的是 C 语言,因为它是最常用于描述跨语言接口的库,你可以与之链接。几乎每一种编程语言都提供了一些与 C 语言库交互的方式,因为它们是如此的普遍。虽然 C++感觉与 C 密切相关,而且许多高知名度的库是用 C++编写的,但在 FFI 方面,它是一个非常不同的野兽。生成类型和签名以匹配 C 头是相对简单的,但对于 C++来说,情况完全不是这样。在写这篇文章的时候,bindgen 对生成与 C++的绑定有很好的支持,但它们往往缺乏人体工程学。例如,你通常必须手动调用构造函数、析构函数、重载运算符等。一些 C++的特性,如模板专业化,也根本不被支持。如果你确实需要与 C++接口,我建议你试试 cxx crate。

总结

在这一章中,我们已经介绍了如何使用 extern 关键字从 Rust 中调用外部代码,以及如何使用它来使 Rust 代码能够被外部代码访问。我们还讨论了如何将 Rust 类型与 FFI 边界另一端的类型对齐,以及在试图让两种不同语言编写的代码很好地融合时的一些常见陷阱。最后,我们谈到了 bindgencbindgen 工具,它们使保持 FFI 绑定的经验更加愉快。在下一章中,我们将研究如何在更多的环境中使用 Rust,比如嵌入式设备,在这些环境中,标准库可能无法使用,甚至连分配内存这样的简单操作也无法实现。