跳转到主要内容
Chinese, Simplified

一个实用的指南,以FFI不让你的指节流血(第2部分,共2)

Image for post

在第1部分中,我们探讨了如何获取一个C库并为它编写一箱不安全的Rust绑定。使用这个板条箱可以通过“不安全”直接访问库中的函数和其他符号,但必须在应用程序代码中的任何地方使用不安全的块并不是很符合人体工程学的。我们需要一些办法来阻止这种疯狂。

在本文中,我们将探讨如何包装这些函数,并使它们能够安全地正常使用。我们将讨论如何定义处理初始化和清理的包装器结构,并描述一些特性,这些特性描述了应用程序开发人员如何安全地将库与线程一起使用。我们还将讨论如何将函数的随机整数返回转换为符合人体工程学的类型检查结果,如何将字符串和数组与C世界进行转换,以及如何将从C返回的原始指针转换为具有继承生存期的作用域对象。

这一步的总体目标是深入研究C库的文档,并使每个函数的内部假设明确化。例如,文档可能会说函数返回指向内部只读数据结构的指针。该指针在程序生命周期内有效吗?它被限定在某个范围内还是被初始化了?最后,我们将有一套包装器,使这些假设对Rust可见。然后,我们将能够依靠Rust的借阅检查器和强制错误处理来确保每个人都正确地使用库,即使是在多个线程中。

添加枚举

在上一篇文章中,我们讨论了一个以枚举类型作为参数的示例函数。这是清理过的bindings.rs代码:

pub const FOO_ANIMAL_UNDEFINED: u8 = 0;
pub const FOO_ANIMAL_WALRUS: u8 = 1;
pub const FOO_ANIMAL_DROP_BEAR: u8 = 2;extern "C" {
    /// Argument should be one of FOO_ANIMAL_XXX
    pub fn feed(animal: u8);
}

等一下。函数签名说它需要一个u8,但是文档说参数应该是FOO\u ANIMAL\u XXX中的一个(为了我们自己的理智,假设它可以安全地处理未定义的情况)。如果我们让我们的安全代码以任意的u8作为输入运行,这不仅令人困惑,而且有潜在的危险。听起来我们的安全包装应该采取动物枚举和转换它。

我们可以手工写这个枚举。但是让我们使用enum_原语板条箱来给我们一些额外的灵活性。(为了简洁起见,我省略了rustdoc字符串,不过您应该在实际代码中包含它们):

use enum_primitive::*;enum_from_primitive! {
#[derive(Debug, Copy, Clone, PartialEq)]
#[repr(u8)]
pub enum Animal {
    Undefined = FOO_ANIMAL_UNDEFINED,
    Walrus = FOO_ANIMAL_WALRUS,
    DropBear = FOO_ANIMAL_DROP_BEAR,
}
}

因为我们将结构标记为“representable as a u8”,所以一个简单的cast就足以将动物转换成u8。现在我们可以这样写我们的安全包装:

pub fn feed(animal: Animal) {
unsafe { libfoo_sys::feed(animal as u8) }
}

enum_primitive板条箱还为我们提供了一些有用的样板函数,用于将u8转换为选项<Animal>。如果函数返回实际应视为枚举类型的u8值,则可能需要这样做。有一个问题:如果提供的数字与枚举值不匹配,则从数字类型的转换可能会失败。这取决于您的代码是否立即展开并惊慌失措,是否用默认值替换None(“Unknown”如果存在可以使用,如果不存在可以添加),或者只是返回选项并让调用者处理它。

返回指针的初始值设定项

在接下来的几个示例中,我将使用一个非常著名和非常粗糙的库作为示例:OpenSSL。(请不要自己为OpenSSL实现绑定;有人已经实现了,而且做得更好。这只是一个很熟悉的例子。)

在使用OpenSSL加密或解密数据之前,首先需要调用一个函数来分配和初始化一些上下文。在我们的示例中,这个初始化是通过调用SSL\u CTX\u new来执行的。执行任何操作的每个函数都会获取一个指向此上下文的指针。当我们使用完这个上下文后,需要使用SSL\u CTX\u free清理和销毁上下文数据。

我们将创建一个结构来包装此上下文的生存期。我们将添加一个名为new的函数,它为我们进行初始化并返回这个结构。所有需要上下文指针的C库函数都将包装为trust函数taking&self并在结构上实现。最后,当我们的结构超出范围时,我们希望锈迹能自动清除。希望这应该是一个熟悉的软件模式:它是RAII。

我们的示例可能如下所示:

use failure::{bail, Error};
use openssl_sys as ffi;pub struct OpenSSL {
    // This pointer must never be allowed to leave the struct
    ctx: *mut ffi::SSL_CTX,
}impl OpenSSL {
    pub fn new() -> Result<Self, Error> {
        let method = unsafe { ffi::TLS_method() };
        // Manually handle null pointer returns
        if method.is_null() {
            bail!("TLS_method() failed");
        }        let ctx = unsafe { ffi::SSL_CTX_new(method) };
        // Manually handle null pointer returns here
        if ctx.is_null() {
            bail!("SSL_CTX_new() failed");
        }        Ok(OpenSSL { ctx })
    }
}

我将ffi后面的C库调用命名为namespacking,以便更清楚地了解我们导入的内容与我们在包装器中定义的内容。我也作弊了一点,并使用保释失败板条箱-在真正的代码中,你会想定义一个错误类型,并使用它。是的,它看起来有点恶心,因为我们没有从我们的回报拆解选项类型的细节。我们必须手动检查一切。

请记住:包装不安全函数意味着您正在进行验证空指针和检查错误的艰苦工作。这正是包装器必须正确处理的类型。早期的恐慌远胜于默默地传递空指针或无效指针。我们也不能允许ctx指针从结构中复制出来,因为我们只能保证它在结构仍然存在时是有效的。

通过impl Drop的析构函数

另一端是清理。铁锈中的毁灭者是通过下降特性来处理的。我们可以为我们的结构实现Drop,这样铁锈就可以适当地破坏我们的句柄:

impl Drop for OpenSSL {
fn drop(&mut self) {
unsafe { ffi::SSL_CTX_free(self.ctx) }
}
}

Rust还可以防止drop被直接调用或两次调用,因此您不必玩一些技巧,比如在释放ctx之后手动将其置空。而且,与C++不同,析构函数不会因为隐形拷贝被创建和删除而被无形调用。

发送和同步

现在有了一个包含指针元素的结构。但是默认情况下,Rust会对如何在线程上下文中使用struct设置一些限制。为什么语言会这样做,为什么这很重要?

默认情况下,Rust假设原始指针不能在线程之间移动(!发送),并且不能在线程之间共享(!同步)。因为你的结构包含一个原始指针,所以它既不发送也不同步。这种保守的假设有助于防止外部C代码在crust提供的那些可爱的线程安全保证上到处乱跑。

如果你的对象没有被发送,那么你在线程程序中处理它的能力就会受到很大的限制——甚至无法将它包装在互斥锁中并在线程之间传递引用。但可能外部文档或对源代码的巧妙检查表明返回的上下文指针在线程之间移动是安全的。它还可以指示使用此上下文指针的函数在线程上下文中使用是否安全,即函数本身是线程安全的。Rust无法为您做出这些决定,因为它看不到库使用这些指针做了什么。

如果你能断言你的每一次使用(内部私有!)指针遵守这两条规则中的任何一条,你都可以直截了当地告诉我。正确地做出这种断言是困难的,如果不是危险的,并且灌输给你适当数量的恐惧Rust需要你使用不安全的关键字。

unsafe impl Send for MyStruct {}
unsafe impl Sync for MyStruct {}

假设您不允许以某种方式(通过访问器方法或通过标记结构成员pub)对指针进行外部访问,那么如果您可以做出以下断言,则可以安全地执行以下操作:

  • 如果取消引用指针的C代码从未使用线程本地存储或线程本地锁定,则可以标记struct Send。许多库都是这样。
  • 如果所有能够取消引用指针的C代码总是以线程安全的方式(即与safe-Rust一致)取消引用,则可以标记结构同步。大多数遵循此规则的库都会在文档中告诉您,并且它们在内部使用互斥锁来保护每个库调用。

返回指针的函数

假设我们已经用新的和drop实现建立了结构。我们很高兴地翻阅了接受这个上下文指针的函数列表,对于我们想要公开的每一个函数,我们正在针对我们的结构实现一个安全版本,它接受&self。然后我们遇到了这样的事情(为了简单起见是虚构的,但不远):

// Always returns valid data, never fails
SSL_CIPHER *SSL_CTX_get_cipher(const SSL_CTX *ctx);

我们显然不想从包装器中返回原始指针,这不太符合人体工程学。这样做的目的是确保库用户不必使用不安全的软件。

阅读文档后,我们发现SSL\u CIPHER是一个结构,只要SSL\u CTX没有被释放,返回的指针就有效。嘿,听起来像是一辈子的事。所以我们的第一种方法可能是这样的:

pub fn get_cipher(&self) -> &ffi::SSL_CIPHER {
unsafe {
let cipher = ffi::SSL_CTX_get_cipher(self.ctx);
// Dereference the pointer, then turn it into a reference.
// Remember: derefing a pointer is unsafe!
&*cipher
}
}

取消引用然后立即获取指针的地址会创建一个所谓的无限生存期。这不是我们想要的,所以我们立即通过返回类型来约束生存期。我们没有明确指定生存期,但是让我们回顾一下Rust手册中关于生存期省略的规则。在这种情况下,返回值的生存期将被默认约束为与&self的生存期相同。这是一个合理的界限,所以这个实现看起来是安全的。

但我们可以更进一步。SSL\u密码通常用作上下文指针,并具有自己的关联函数。实际上,让我们的安全代码返回对C结构的引用根本不符合人体工程学。我们要返回的是一个Rust结构,它自身的关联行为与C库匹配。但我们也应该保留生存期关联:“只有从中获取密码的OpenSSL对象仍然存在,这个密码对象才有效。”

因此,假设我们已经完成了创建一个密码结构来包装指针的工作,并且我们想告诉Rust这个结构有某种依赖于OpenSSL对象的生存期:

pub fn get_cipher<'a>(&'a self) -> Cipher<'a> {
    unsafe {
        let cipher = ffi::SSL_CTX_get_cipher(self.ctx);
        Cipher::from(&self, cipher)
    }
}// Something is missing here...
pub struct Cipher<'a> {
    cipher: *const ffi::SSL_CIPHER,
}fn from<'a>(_: &'a OpenSSL, cipher: *const ffi::SSL_CIPHER)
  -> Cipher<'a> {
    Cipher { cipher }
}

不幸的是,这不会编译,因为Rust说“嘿,你声明了一个与你的结构相关联的生存期,但是它没有在任何地方使用!“所以我们需要以某种方式声明,是的,内部依赖于一个我们不能立即看到的引用。

 

use std::marker::PhantomData;pub struct Cipher<'a> {
    cipher: *const ffi::SSL_CIPHER,
    phantom: PhantomData<&'a OpenSSL>,
}fn from<'a>(_: &'a OpenSSL, cipher: *const ffi::SSL_CIPHER)
  -> Cipher<'a> {
    Cipher { cipher, phantom: PhantomData }
}

您可以将其视为对编译器说:“将此结构视为包含对OpenSSL的引用,其生存期为'a'。这一生从何而来?当我们打电话给我们的发件人时,我们会提供它。

幻影数据实际上并不占用任何空间,它会在编译代码中消失。但它允许编译器对生存期的正确性进行推理。现在,我们的包装器用户不能在释放其父级OpenSSL后意外地持有密码。

可能返回错误的函数

考虑以下C函数:

int foo_get_widget(const foo_ctx_t*, widget_struct*);

我们需要传递一个指针,函数将填充它。如果此函数返回0,则一切正常,我们可以相信输出已正确填充。否则,我们需要返回一个错误。

更符合人体工程学的做法是返回一个拥有数据的结构,而不是要求调用方创建一个可变结构并传递一个mut引用(尽管如果这样做有意义的话,您可以同时提供这两个结构)。

在下面的示例中,我假设自定义错误类型是在其他地方定义的,并且允许从适当的类型进行转换。

use std::mem::MaybeUninit;pub fn get_widget(&self) -> Result<widget_struct, GetError> {
    let mut widget = MaybeUninit::uninit();
    unsafe {
        match foo_get_widget(self.context, widget.as_mut_ptr()) {
            0 => Ok(widget.assume_init()),
            x => Err(GetError::from(x)),
        }
    }
}

Ed:感谢reddit/u/Cocalus指出mem::uninitialized()已被弃用。希望我能修好!

上面,widget_struct不实现Default,因为“Default”和零化构造函数都没有意义。相反,我们告诉Rust不要初始化结构内存,因此我们断言外部函数负责正确初始化结构的每个字段。

有些函数不返回任何有用的信息,但仍然可能出错。

int foo_update(const foo_ctx_t*);

您可能会尝试将整数值转换为枚举类型,然后使用它。别那么做!正确编写的代码会在出现故障时返回结果,您也应该这样做。但是“成功”的价值观呢?为此,我们应该使用()告诉调用者没有返回数据。但是调用者仍然需要打开包装或者处理错误。

update(&self) -> Result<(), UpdateError> {
match unsafe { foo_update(self.context) } {
0 => Ok(()),
x => Err(UpdateError::from(x)),
}
}

FFI中的字符串

Rust不像C那样将字符串存储为以null结尾的char缓冲区;它在内部存储缓冲区和长度。因为类型并没有完全对齐,这意味着从Rust字符串的世界移到C char数组,再移回来需要一些技巧。谢天谢地,有一些内置类型可以帮助管理它,但是它们附带了很多字符串。有些转换分配(因为它们需要改变字符串表示或添加终止null),有些则不分配。有时不做拷贝就可以安全地逃脱,有时则不然。文档确实解释了哪个是哪个,但是有很多东西需要通读。这是执行摘要。

如果您以某种方式生成了一个Rust字符串,并且需要将它作为临时const*c\u char传递给c代码(这样它就可以制作一个字符串的副本供自己使用),那么您可以将它转换为CString,然后作为\u ptr调用。如果包装器签名借用了&str,则首先转换为&CStr,然后作为\u ptr调用。这两种情况下的指针只有在Rust引用正常有效时才有效。原始指针剥离了借用检查器的安全性,并要求我们自己维护这个不变量。如果你搞砸了,铁锈帮不了你。

对于从C函数中获取const char*并希望将其转换为Rust可以使用的内容的情况,需要确保它不为null,然后将其转换为具有适当生存期的&CStr。如果你不知道如何表达一个合适的生命周期,最安全的方法就是立即将它转换成一个拥有的字符串!

需要注意的其他事项:

  • CString::因为ptr有一把手枪,很难正确使用。阅读文档中标记为WARNING的部分,并确保CString在C代码返回之前一直在作用域中。
  • trust字符串可以合法地在中间有一个空的\0字节,但是C字符串不能(因为它会终止字符串)。因此,尝试将&str转换为&CStr或将字符串转换为CString可能会失败。你的代码需要处理这个问题。
  • 一旦将原始C指针转换为&CStr,在将其用作本机safe Rust中的&str之前,仍然需要执行(或不安全地跳过)一些验证。C使用任意字符串编码,而Rust字符串总是UTF-8。现在大多数C库都返回有效的UTF-8字符串(ASCII是一个子集,所以即使是传统的应用程序也可以)。允许分配的函数(如CStr::to \u str \u lossy)将在必要时用UTF“替换字符”替换无效字符,而其他函数如CStr::to \u str将只返回一个结果。阅读文档,并根据需要选择正确的函数。
  • 如果库返回路径,请在包装器中使用OsString和&OsStr,而不是String和&str。

数组和长度

如果库接受指向T的指针和长度,那么很容易实现一个包装器,该包装器接受一个切片并将其分解为指针和长度。但反过来呢?原来还有一个库函数。请记住检查null,确保大小是元素数,并再次检查返回生存期是否正确。同样,如果不能保证生存期的正确性,只需返回一个拥有的集合,比如Vec。

回调

回调签名通常作为bindgen中的选项<unsafe extern“C”fn…>生成。因此,当您使用Rust编写回调时,显然需要使用不安全的extern“C”来装饰它们(它们不需要是pub),然后当您将它们传递到C库时,只需将名称包装在一些文件中。很简单。

问题是,在C代码中释放恐慌是…好吧,我们就说它是坏的。理论上,恐慌几乎可以发生在Rust代码的任何地方。所以为了安全起见,我们需要把我们的身体包在里面。通常情况下,抓住恐慌是不明智或理智的,但这是例外。没有双关语。

unsafe extern "C" fn foo_fn_cb_wrapper() {
if let Err(e) = catch_unwind(|| {
// callback body goes here
}) {
// Code here must be panic-free.
// Sane things to do:
// log failure and/or kill the program
eprintln!("{:?}", e);
// Abort is safe because it doesn't unwind.
std::process::abort();
}
}

常见模式

在编写这些包装器时,您可能会遇到一些可以在函数或宏中轻松表达的模式。例如,库函数可能总是返回一个int,它总是表示同一组非零错误。编写一个调用不安全代码并将返回结果强制转换为Result<()、LibraryError>的私有宏可以节省大量样板文件。注意这些构造,通过一点重构,您可以节省自己几个小时的工作。

我不会撒谎的。正确地做这件事需要做很多工作。但是正确的操作会产生无bug的代码。如果您的C库是实心的,并且包装层是正确的,那么您将不会看到一个分段错误或缓冲区溢出。您将立即看到任何错误。当你在应用程序代码中犯了以前的指针错误时,它根本不会编译;当应用程序代码编译时,它只会工作。

原文:https://medium.com/dwelo-r-d/wrapping-unsafe-c-libraries-in-rust-d75aeb283c65

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

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

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