在 Rust 中創建 C/C++ API
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-enum
和 union
類型)。
使用 cbindgen
綁定函數
函數具有類似的要求,由 cbindgen
作為類型獲取:
要綁定的函數需要: – pub
– extern "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
, cbindgen
和 cpp
,它們有助於減少樣板,並自動化容易出錯的類型和函數綁定。
當將 Rust 庫綁定到 C/C++ 時,核心邏輯層和 FFI 層之間應該存在明顯的分離。在做好的情況下,FFI 代碼應該位於一個單獨的 crate 中,因此設計 Rust API 不會受到 FFI 的太多影響,並且選擇可變性修飾符變得更加容易。
當向 FFI 公開數據類型是,使用複製 Copy
是最好的,對於 no-Copy
類型,擁有的數據應該優於借來的數據。
如果有任何其他我試圖解決相同問題的可用的工具,請告訴我!
原文翻譯自 Creating C/C++ APIs in Rust
原文轉載自 在 Rust 中創建 C/C++ API
推薦閱讀: