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
", 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


原文轉載自 在 Rust 中創建 C/C++ API


推薦閱讀:
相關文章