Wikipedia 將 FFI 定義為一種機制,通過這種機制,用一種編程語言編寫的程序可以調用或使用用另一種編程語言編寫的服務。

FFI 可用於加快程序執行(這在 Python 或 Ruby 這類動態語言中很常見),或者只是因為你想使用一些其他語言編寫的庫(例如 TensorFlow 的核心庫是用 C++ 寫的,並暴露了 C API,允許其他語言使用)。

為 Rust 庫編寫 FFI 並不難,但是卻有一些挑戰和可怕的部分,主要是你要使用指針和 unsafe 塊1。這可能會脫離 Rust 的內從安全模型,換句話說,編譯器無法檢查一切是否正常,因次內存管理和安全保障取決於開發人員。

在這篇文章中,我將講述我對 Rust 和 FFI 的經驗,基於 battery-ffi ,它將 FFI 暴露給我的另一個 crate — battery。我想做的是提供一個 C 介面來創建特定於 Rust 的結構,並能夠從它們獲取數據。

首先要做的事

你需要將 libc 添加到 crate 的 dependencies 中,並將 crate-type 設置為cdylib2,這樣將會構建出動態庫 (.so, .dylib.dll 文件,取決你的操作系統類型)。

[dependencies]
libc = "*"
[lib]
crate-type = ["cdylib"]

將 FFI 層與 「主」 庫分離出來,並將不安全的代碼轉移到一個新的 crate,這可能是一個好主意,類似於社區 *-sys3 的約定,但是這次相反。另外,默認情況下 Rust 庫使用 crate-type = ["rlib"],而 FFI 因該是 cdylib。對於如何命名沒有統一的約定,但是這些 crate 通常有 -ffi or -capi 後綴。

FFI 語法

下面是函數示例,它從Battery 結構返回一個電池百分比(0.0…100.0%範圍):

#[no_mangle]
pub unsafe extern fn battery_get_percentage(ptr: *const Battery) -> libc::c_float {
unimplemented!() // Example below will contain the full function
}

它的聲明以 #[no_mangle] 開始,該屬性禁用 name mangling。簡而言之,它允許其他編程語言,以預期的名稱(在我們的例子中是 battery_get_percentage)在編譯後的庫中查找已聲明的函數,而不是編譯器生成的名稱, 就像 _ZN7battery_get_percentage17h5179a29d7b114f74E。誰願意使用這樣的名稱?

然後,我們在函數定義時,包含了兩個額外的關鍵字 unsafeextern

extern 關鍵字使函數遵守 C 調用約定,你可以查看 Wikipedia 瞭解為什麼要這樣做。並且可以在 Rust Nomicon 找到所有可用的調用約定。

你之前可能看到unsafe關鍵字被用於標記不安全的塊 (就像 unsafe { .. 做一些可怕的事情 .. }),但是在這裡,整個函數被標記為 unsafe ,因為不正確的使用會導致未定義行為,比如傳遞 NULL 或 懸空指針。以此告訴調用者應該正確使用它並意識到可能造成的後果。

返回參數

在我的例子中,我想向外部公開一些 Rust 的結構,但是由於實現的原因,它們可能包含一些複雜的結構,而強迫最終用戶處理這些東西是一個壞主意。例如,如果我的 Manager 結構中包含 Mutex,它應該如何用 C 或 Python 4。

這就是我為什麼把結構體的實現隱藏在 不透明指針 背後的原因。我將返回一個指向堆上某個內存塊的指針,並提供從該指針獲取所需數據的函數。堆分配是強制性的,否則,如果你將數據分配到棧上(Rust 默認將數據分配到棧上,除了 Vec,HashMap 等),這樣數據會在函數結束時被釋放,你將無法正確返回它,因此 Box 是你最好的朋友。

在大多數情況下,你不如要將諸如 u8 or i32 封裝到 Box,除非你想在堆上分配他們,按原樣返回它們是完全可以的。The Rust FFI Omnibus 和 Rust FFI Guide 都提供瞭如何做到這一點的多個示例。

現在讓我們看一下這個函數:

#[no_mangle]
pub extern fn battery_manager_new() -> *mut Manager {
let manager: Manager = Manager::new();
let boxed: Box<Manager> = Box::new(manager);
Box::into_raw(boxed)
}

如你所見,它創建了一個 ManagerBox::new,將其移動到堆中,然後返回原始指針,指向堆中存儲它的位置。不過這個函數不需要用unsafe 標記,因為這裡不可能創建一些未定義行為。

傳遞參數

這個函數接受前面創建的 Manager 結構的指針,並調用 Manager::iter 方法,創建Batteries 結構:

#[no_mangle]
pub unsafe extern fn battery_manager_iter(ptr: *mut Manager) -> *mut Batteries {
assert!(!ptr.is_null());
let manager = &*ptr;
Box::into_raw(Box::new(manager.iter()))
}

我們要做的第一件事是確保傳遞的 指針 不為 NULL:

assert!(!ptr.is_null());

你確實應該為每個傳遞的指針執行次操作,因為你的輸入並不安全,而且你不應該總是期望得到有效的數據。所以說提前 panic 總比執行一個未定義的性外要好。

之後,我們從這個指針創建對結構的引用:

let manager = &*ptr;

這一行推斷所有類型。如果 &* 對你來說看起來有點奇怪的話,這裡有一個長版本(這個版本不會編譯通過5,但更容易理解發生了什麼):

let manager_struct: Manager = *ptr;
let manager: &Manager = &manager_struct;

這裡我們解引用 ptr ,並立即重新引用,就得到了我們結構體的引用。

在我的示例中, Manager::iter 方法返回Batteries 迭代器,我也想公開它,因此我執行與 battery_manager_new 函數相同的操作:

Box::into_raw(Box::new(manager.iter()))

釋放它

Box::into_raw 調用之後,Rust 會忘記這個變數,因此我們有責任手動釋放內存或處理內存泄漏。幸運的是這很簡單:

#[no_mangle]
pub unsafe extern fn battery_manager_free(ptr: *mut Manager) {
if ptr.is_null() {
return;
}
Box::from_raw(ptr);
}

作為一個漂亮的附加,我們悄悄忽略空指針,因為我們不能對它做任何事情。而且在同一個指針上調用兩次 Box::from_raw 是一個壞主意,這可能會導致 double-free 行為。

在前面的例子中,我們使用 Box::into_raw 將結構體轉換為一個原始指針,現在,我們又將它轉換回結構體。接下來發生的是一個常見的 Rust 「魔法」 — 現在的指針屬於 Box 並由 safe Rust 控制,它將在函數結束時自動刪除,正確的調用析構函數釋放內存。

也應該對*mut Batteries指針完成上面幾個例子中的同樣的事。

創建 getter

我的 battery crate 的主要目標是提供各種電池(如你筆記本)的信息。因此我們需要創建多個 「getter」 函數,從之前創建的 *const Battery 指針獲取數據(沒有關於它的例子,但是這個結構體與上面代碼片段中的另一個結構體非常類似)。

下面的例子對你來說應該很容易理解,我們正在接收原始指正,驗證它,並引用 Battery 結構體:

#[no_mangle]
pub unsafe extern fn battery_get_energy(ptr: *const Battery) -> libc::uint32_t {
assert!(!ptr.is_null());
let battery = &*ptr;
battery.energy()
}

在引用之後,我只是簡單地從 Battery::energy 方法中返回一個 u32 ,因為它與 libc::unit32 類型相同,所以它按原樣傳遞給調用者。

你可能注意到了與前面示例的細微差別:這個函數沒有接收 *mut 而是接收 *const 指針。

這裡 or 這裡的文章將幫助你理解其中的區別,以下是 matklad 的簡短總結:

如果你為 FFI 使用原始指針 (作為 extern 「C」 函數的參數和返回類型),那麼 *const 和 *mut 純粹是一個記錄意圖的問題,根本不影響生成的代碼。然而,記錄意圖是很重要的,因為 C 和 C++ 有一個規則,你不能修改常量對象。

因為這裡我不打算改變電池狀態,所有我喜歡用 *const 符號,用這個參數精確地描述我的意圖。

處理可選結果

一些Battery 結構體的 方法 返回 Option<T> 類型,他們不能按照原樣映射到 C ABI,而且它們的 T 值不能返回 NULL ,因為他們不是指針,而是基本類型,比如 f32

有三種廣泛採用的方法來解決這一問題:

  1. 返回一些不可能的值 (例如 C 中常用的 -1)
  2. 創建一個線程本地變數 (通常稱為 errno) ,並在每次收到一個「可選」的參數後檢查它
  3. 或者類似於下面的代碼結構,返回並檢查 present == true

#[repr(C)]
struct COption<T> {
value: T,
present: bool
}

Rust FFI Guide 對第二種方法進行了全面的描述。我現在選擇了第一個,更喜歡返回現實生活中不可能的值(你筆記本電池不可能是 340282350000000000000000000000000000000 °C,對吧?)。

處理字元串結果

C 字元串和 Rust 字元串是兩種完全不同的類型, 你不能只是將它們轉換為另一種類型,官方文檔提供了它們之間的大量差異。幸運的是,在我的例子中,我不需要接收傳入的字元串,但我要輸出它們。 非常類似於前面我們在其中使用了

Box 值的例子。

由於 C 字元串基本上是指向以 nul 位元組結尾的堆內存塊的指針 (在 char* 類型的情況下),我們需要在堆上分配一些內存,並將 UTF-8 字元串6 放在那裡。Rust 提供了 CString 類型,它正是我們需要的,它表示在堆內存上分配的與 C 兼容的字元串。

在下面的例子中, battery.serial_number() 返回 Option<&str>,我們稍後將其轉換為 CString,與之前的示例相同,我們將原始指針返回給調用者。如果 battery.serial_number() 返回 None,我們返回一個 NULL 指針,將結果標記為不存在。

由於我們再次在堆上分配了內存,我們需要手動管理它,並在使用後釋放內存,這與之前幾乎相同:

#[no_mangle]
pub unsafe extern fn battery_get_serial_number(ptr: *const Battery) -> *mut libc::c_char {
assert!(!ptr.is_null());
let battery = &*ptr;
match battery.serial_number() {
Some(sn) => {
let c_str = CString::new(*sn).unwrap();
c_str.into_raw()
},
None => ptr::null_mut(),
}
}
#[no_mangle]
pub unsafe extern fn battery_str_free(ptr: *mut libc::c_char) {
if ptr.is_null() {
return;
}
CString::from_raw(ptr);
}

相反的情況下,當你需要從 C 接收字元串,記住這一點是至關重要的,C 字元串不僅可以是 UTF-8 以外的編碼,可能具有不同的字元發小,因此這確實是個很大的問題,本文中將會跳過。

綁定生成

構建完成後,你將得到庫文件,你可以將其發布或發送給客戶端程序員,使他們更快樂。除非他們需要用他們的語言再次重寫你導出的定義,就像 Python 的 ctypes 需要的那樣:

import ctypes
class Manager(ctypes.Structure):
pass
lib = ctypes.cdll.LoadLibrary(libmy_lib_ffi.so))
lib.battery_manager_new.argtypes = None
lib.battery_manager_new.restype = ctypes.POINTER(Manager)
lib.battery_manager_free.argtypes = (ctypes.POINTER(Manager), )
lib.battery_manager_free.restype = None

幸運的事,綁定生成器已經為我們準備好了,這些工具可以解析 C 頭文件並以所需語言輸出生成的代碼。 cbindgen crate 的幫助下,我們可以用 FFI 介面信息自動生成 .h 文件,然後將其放入綁定生成器。

嵌入 cbindgen 非常簡單,首先我們需要把它作為一個構建依賴項添加到 Cargo.toml 文件:

[build-dependencies]
cbindgen = "0.8.0"

我們還需要 cbindgen.toml 文件, 在Cargo.toml 文件旁邊:

include_guard = "my_lib_ffi_h"
autogen_warning = "/* Warning, this file is autogenerated by cbindgen. Dont modify this manually. */"
language = "C"

添加構建腳本:

use std::env;
use std::path::PathBuf;
fn main() {
let crate_dir = env::var("CARGO_MANIFEST_DIR")
.expect("CARGO_MANIFEST_DIR env var is not defined");
let out_dir = PathBuf::from(env::var("OUT_DIR")
.expect("OUT_DIR env var is not defined"));
let config = cbindgen::Config::from_file("cbindgen.toml")
.expect("Unable to find cbindgen.toml configuration file");
cbindgen::generate_with_config(&crate_dir, config)
.unwrap()
.write_to_file(out_dir.join("my_lib_ffi.h"));
}

現在,在 cargo build 命令之後,Rust 的 OUT_DIR 將會包含my_lib_ffi.h 以及所有需要的信息。

附加說明:我發現這個構建腳本在 docs.rs 中構建文檔時出現了一些神祕錯誤,導致構建失敗失敗。因此,我使用了特性門控制的 cbindgen,將其添加為默認特性,然後在Cargo.toml 中禁用了 docs.rs 構建的默認特性:

[package.metadata.docs.rs]
no-default-features = true

可能是因為構建腳本無法訪問 OUT_DIR ,所以你可以嘗試將其輸出寫入另一個文件夾。

後記

這應該足以讓你開始為你的 crate 編寫 FFI 綁定。你可以查看以下鏈接獲取更多信息:

  • jakegoulding.com/rust-f
  • michael-f-bryan.github.io
  • crates.io/crates/ffi_he
  • github.com/dushistov/ru

感謝來自於 #rust IRC 頻道的 Moongoodboy{K} 和 sebk,他們提供了校對和寶貴的幫助。

鏈接

  • Reddit discussion

  1. doc.rust-lang.org/book/
  2. doc.rust-lang.org/refer
  3. matt-harrison.com/build
  4. it can』t unless explicitly defined with #[repr(C)]
  5. With &* you are dereferencing the raw pointer and taking a reference in one operation, but in a non-working example you can』t move out from raw pointer
  6. of course C strings can be in any desired encoding, but usually it is assumed to be an UTF-8, otherwise it quickly become a mess

本文翻譯自Exposing FFI from the Rust library


推薦閱讀:
相關文章