在 Rust 中创建 C/C++ API

图片来自pexels.com

Rust 是一种神奇的语言,有着更好的生态系统。许多 Rust 的设计决策都非常适合向现有的C/C++系统添加新功能,或者逐步替换这些系统的部分!

当我尝试为 Rust 创建 C++ API 时,我发现,从 C/C++ 到 Rust 的绑定被更好地记录下来,并且比从 Rust 到 C/C++ 的结合有更平滑的体验。

正如我所发现的,事实并非如此!有一些很棒的工具可以帮助你创建C/C++ API。 这篇文章介绍了我在使用这些工具方面的一点经验,希望能帮助有同样追求的人 🙂

在 Rust 中使用 C/C++ API

这是最常见的情况,也是最广泛使用的Rust的FFI系统。

最简单的入门方法是使用 bindgen 工具。

bindgen 将创建绑定到给定 C 或 C++ API 的 Rust 代码。

这对于 C API 非常有效,但是为 C++ API 生成绑定是有限的。 最值得注意的是,继承(inheritance)伴随着各种各样的问题,因为许多 C++ 编译器以不同的方式实现它,而Rust在如何模仿它方面也受到了限制。

因为这是关于使用 Rust 来将功能暴露给C/C++(而不是其他方式),这里不会深入研究bindgen

用 Rust 编写 C API 的工具

不太常见的情况是,你希望在 C 或 C++ 代码库中使用 Rust 提供的一些功能。

最精雕细琢(但单调乏味)的工作流是手动为Rust代码创建一个C API。

选择的工具应该是 cbindgen,它扫描 Rust 的 crate (以及可选的依赖项),查找可以通过 C 语言访问的项。

使用 cbindgen 绑定类型

为了让 cbindgen 为 Rust 生成 C 绑定,Rust 类型需要 pub#[repr(C)]#[repr(transparent)] (或 #[repr(NUM_TYPE)] 对于 enum).

例如:

#[repr(C)]
#[derive(Copy, Clone)]
pub struct NumPair {
    pub first: u64,
    pub second: usize,
}

这将生成类似于这样的 C 绑定:

typedef struct NumPair {
    uint64_t first;
    uintptr_t second;
} NumPair;

甚至 Rust enum 也可以绑定到 C! 如果将 #[repr(C)] 应用于 enum ,则使用一个稳定的布局,该布局可以清晰的映射到 C (使用 Tag-enumunion 类型)。

使用 cbindgen 绑定函数

函数具有类似的要求,由 cbindgen作为类型获取:

要绑定的函数需要: – pubextern "C"#[no_mangle]

如果满足所有这些条件,那么将发出一个C函数声明。

例如:

#[no_mangle]
pub extern "C" fn process_pair(pair: NumPair) -> f64 {
    (pair.first as f64 * pair.second as f64) + 4.2
}

将生成类似这样的C函数声明:

double process_pair(NumPair pair);

导出的函数不需要 unsafe,但是只要涉及到任何类型的 C 指针 / Rust 引用传递,函数就应该被标记为 unsafe

暴露多个 Rust 函数时的个人经验

我个人试图在开始时使用cbindgen 制作 C++ API (通过创建 C++ class 并在内部使用 C 函数) 并发现它工作的很好,但是需要手动编写绑定代码非常麻烦,而且非常耗时。也就是说,这是创建 C API 时的最佳选择。

对于只将类型暴露给 C,cbindgen 非常简单,只需注释类型即可。 ?

在 Rust 中编写 C++ API 的工具

对于编程语言来说,支持某种类型的 C++ FFI 是非常有挑战性的,因为在不同的编译器之间 C++ 名称混淆,调用约定,类的布局,vtables 和其他一些东西常常是不同的。

正因为如此,Rust 没有向 C++ 公开功能的本地方法,但是 C++ 和 Rust 都支持 C!

实际上, cbindgen 还支持以 C++ 风格的数据类型输出,包括模板等等。这非常方便, 因为具有泛型参数的类型最终不会有像Transform2D_ObjectSpace_WorldSpace 这样的长名称,而是使用模板。

另一个在Rust中制作 C ++ API的便利工具是 cpp crate。

`cpp` crate 允许你使用´cpp!´ 宏在 Rust 代码中嵌入 C++ 代码。它通过获取所有的内联 C++ 代码并将其写入一个单独的 cpp 文件来实现这一点, 该文件将被编译为 Rust crate 的最终目标代码。

让这个 crate 更有用的是,它还允许使用 “pseudo-macro” rust!()将 Rust 代码嵌入到 C++ 代码中。rust!() 中的任何 Rust 代码都将被放入 extern "C" 中,并在 C++ 代码中添加对这个新函数的调用。这意味着从 C++ 中调用 Rust 代码和从 Rust 中调用 C++ 代码看起来就像是 闭包或者块的高级版本。

cpp!{{
#include <stdio.h>
}}

fn add(a: i32, b: i32) -> i32 {
    cpp!([a as "int32_t", b as "int32_t"] -> i32 as "int32_t" {
        printf("adding %d and %d\n", a, b);
        return a + b;
    })
}

cpp!{{
void call_rust() {
    rust!(cpp_call_rust [] {
        println!("This is in Rust!");
    });
}
}}

我认为 cpp 主要是为了在 Rust 中使用 C++,但是 rust!非常适合于 low-boilerplate 的跨语言绑定! 惟一的“开销”是必须用两种语言声明参数和返回类型,并且 Rust 宏需要为生成的 extern "C" 函数提供一个唯一的名称。

使用cbindgen 制作 C++ API,或者使用 cpp制作 C++ API,必须做出选择时,我会选择 cpp,因为代码主要停留在一个地方并且更容易更新。

在 Rust 中创建 C++ API 的指南

在为 Rust 库创建 C/C++ API 时,我发现了一些模式。

项目设置和规划

通常,FFI 表面应尽可能小。例如,虽然在创建 C++ 类时可以在 rust!() 中编写所有代码,但这并不可取,因为内存安全问题可能会慢慢出现,而且编译器也不喜欢做这么多复杂的宏扩展 (Hello there, #![recursion_limit = "4096"]!)。

相反,应该创建一个主要的惯用 Rust API。FFI 代码应尽可能分开。有一些事情仍然可以泄漏到 Rust API中,比如尽量使用 Copy 类型(实现这一点的一种方法是创建一个“存储”系统,在该系统中,资源可以被索引引用,而不是传递复杂的数据)。

在我的大多数暴露 Rust 功能的项目中出现的一个模式是拥有一个 ffi 目录(或者一个单独的 crate),其中包含 C/C++ 头文件和“Rust FFI surface” 代码。任何头文件够可以通过构件脚本复制到 target/include/projectname/ 中,这样 “receiving” 就可以很轻松包含它们。

这个 “Rust FFI surface code” 接收来自 C/C++ 的数据,并从中创建惯用的 Rust 数据,然后将控制传递给 “内部” Rust 实现。

src/lib.rs

use log::info;

mod ffi;

#[derive(Default)]
pub struct Adder {
    count: i64,
}

impl Adder {
    pub fn add(&mut self, value: i64) {
        info!("Adder::add()");
        self.count += value;
    }
    
    pub fn tell(&self) -> i64 {
        info!("Adder::tell()");
        self.count
    }
}

src/ffi/adder.hpp

#ifndef ADDER_HPP
#define ADDER_HPP

class Adder {
	void *internal;
public:
	Adder();
	~Adder();
	void add(int64_t value);
	int64_t tell() const;
};

#endif

src/ffi/mod.rs

// src/ffi/mod.rs
use cpp::cpp;

use crate::Adder;

cpp!{{

Adder::Adder() {
    this->internal =
        rust!(Adder_constructor [] -> *mut Adder as "void *" {
            let b = Box::new(Adder::default());
            Box::into_raw(b)
        });
}

Adder::~Adder() {
    rust!(Adder_destructor [internal: *mut Adder as "void *"] {
        let _b = unsafe {
            Box::from_raw(internal)
        };
    });
}

void Adder::add(int64_t value) {
    rust!(Adder_add [
        internal: &mut Adder as "void *",
        value: i64 as "int64_t
    ] {
        internal.add(value);
    });
}

int64_t Adder::tell() const {
    return rust!(Adder_tell [
        internal: &mut Adder as "void *"
    ] -> i64 as "int64_t" {
        internal.tell()
    });
}

}}

挑战

FFI “surface” 一般由两部分组成:

  • 数据接口 (类型)
  • 行为接口 (函数)

通过控制调用者和被调用者之间传递控制是一个 “solved problem” ™️。调用函数是通常不会出现很多问题,大多数语言的 C FFI 都非常成熟,可以正确处理 ABI和错误。

唯一的例外是 Rust 中的 panic。如果代码在 FFI 边界发生 panic 和展开,你的裤子可能会被吃掉,解决这个问题最简单的方法是将 panic 行为更改为"abort",在 Cargo.toml 文件中。

更关键的部分是正确是数据接口,并确保概念从一种语言正确映射到另一种语言,并且对数据的任何 “重新解释” 都是安全的。

试图将来自 FFI 的指针转换为引用可能会导致未定义的行为,并可能在释放后使用(use-after-free),因此除非你 100% 确保生命周期匹配,否则应该避免使用它。虽然并不总能带来最佳性能,但使用自有数据(或者 Copy 类型)可大大简化内存安全性。

我的代码中出现的一种模式是拥有两种版本的复杂数据类型,Rust 内部版本和*Ffi 版本。

use std::os::raw::c_char;
use std::ffi::CStr;
use std::str::Utf8Error;

pub struct Person {
    pub name: String,
    pub favorite_birds: Vec<String>,
}

#[repr(C)]
#[derive(Copy, Clone)]
pub struct PersonFfi {
    pub name: *const c_char,
    pub favorite_birds_data: *const *const c_char,
    pub favorite_birds_len: usize,
}

impl Person {
    pub unsafe fn from_ffi(p: PersonFfi) -> Result<Self, Utf8Error> {
        use std::slice::from_raw_parts;
    
        unsafe fn ptr_to_string(
            ptr: *const c_char,
        ) -> Result<String, std::str::Utf8Error> {
            let cstr = CStr::from_ptr(ptr);
            
            Ok(cstr.to_str()?.to_string())
        }
        
        let name = ptr_to_string(p.name)?;
        
        let favorite_birds = {
            let slice = from_raw_parts(
                p.favorite_birds_data,
                p.favorite_birds_len,
            );
            let mut res = Vec::with_capacity(p.favorite_birds_len);
            for bird in slice {
                let name = ptr_to_string(*bird)?;
                res.push(name);
            }
            res
        };

        Ok(Person {
            name,
            favorite_birds,
        })
    }
}

这些 *Ffi 类型应该驻留在 src/ffi 目录 (或 FFI crate)。

我个人发现,如果 FFI 代码与“主逻辑” 存在于同一个 crate 中,那么另一个挑战可能是知道哪些项应该是 pub, pub(crate) 或者 hidden。如果对代码的所有访问都是通过位于用一个 crate 中的 FFI 进行的,那么技术上一些都可以是 pub(crate) ,因为没有外部 crate 可以访问任何项。

然后,使用 pub 可以为你的 Rust 代码获得一个良好的 rustdoc 文档,但这也意味着不再报告诸如 “unused function” 之类的警告。

总结

Rust 非常适合创建 C 或 C++ API,该语言不需要特殊的运行时,函数可以通过 FFI 轻松调用,user-created types (and primitives) 可以轻松转换为与 C 兼容的类型。

有一些不错的工具,比如 bindgen, cbindgencpp ,它们有助于减少样板,并自动化容易出错的类型和函数绑定。

当将 Rust 库绑定到 C/C++ 时,核心逻辑层和 FFI 层之间应该存在明显的分离。在做好的情况下,FFI 代码应该位于一个单独的 crate 中,因此设计 Rust API 不会受到 FFI 的太多影响,并且选择可变性修饰符变得更加容易。

当向 FFI 公开数据类型是,使用复制 Copy 是最好的,对于 no-Copy 类型,拥有的数据应该优于借来的数据。

如果有任何其他我试图解决相同问题的可用的工具,请告诉我!

原文翻译自 Creating C/C++ APIs in Rust

发表评论

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

97 − 87 =