首頁>技術>

使用bindgen的FFI實用指南(第1部分,共2部分)

今天我想深入探討一下我們在嘗試用Rust重寫IoT Python程式碼時遇到的一個困難:具體地說是FFI,或者“外文函式介面”(Foreign Function Interface)——允許Rust與其他語言互動的位。一年前,當我試圖編寫與C庫整合的Rust程式碼時,現有的文件和指南常常給出相互矛盾的建議,我不得不自己一個人跌跌撞撞地完成這個過程。本指南旨在幫助將來的rustacean完成將C庫移植到Rust的過程,並使讀者熟悉在進行同樣操作時遇到的最常見問題。

動機

為了證明這一點,我需要解釋一下為什麼我們德韋洛一開始就需要這麼做。

對於我們的重寫專案,我們希望與供應商提供的C庫整合,該庫負責透過標準供應商指定的協議透過串列埠與我們的Z-Wave晶片進行通訊。這種序列通訊協議很複雜,很難正確實現,而且還受到嚴格的時間限制——傳送到串列埠的位元組基本上是透過無線電直接傳輸的。在錯誤的時間傳送錯誤的位元組可能會完全掛起無線電晶片。有一個長達幾百頁的參考文件,包含傳輸和確認、重傳邏輯、錯誤處理、定時間隔等的規範。最初的Python程式碼從零開始(錯誤地)實現了這個協議,這個實現代表了遺留堆疊中相當大的一部分bug。除此之外,無線晶片組供應商正在推遲認證,除非我們能夠證明我們正確地實現了協議。巧合的是,提供的參考庫(用C實現)被保證符合規範。很明顯,供應商C程式碼似乎是商業成功的最短路徑。

Rust本機支援連結C庫並直接呼叫它們的函式。當然,因此匯入的任何函式都需要實際呼叫unsafe關鍵字(因為Rust不能保證其不變數或正確性),但這給我們帶來了不便,我們可以稍後再使用。

Rust Nomicon將告訴您,只要名稱和簽名完全對齊,就可以透過在extern塊中宣告來匯入函式定義或其他全域性符號。這在技術上是正確的,但不是很有幫助。手工輸入函式定義是完全愚蠢的,而且當我們有一組非常好的包含宣告的標頭檔案時就沒有意義了。相反,我們將使用一個工具從庫的C標頭檔案生成鏽跡簽名。然後我們將執行一些測試程式碼來驗證它是否正常工作,調整一些東西直到看起來正確為止,最後將整個東西烘焙到一個Rust的板條箱中。我們開始吧。

賓根(Bindgen)

最常用的工具是bindgen,它可以從C頭生成鏽跡簽名。我們的目標是創造一個繫結.rs表示庫的公共API(其公共函式、結構、列舉等)的檔案。我們將配置板條箱以包含該檔案。一旦構建了板條箱,我們就可以將該板條箱匯入到任何專案中,以呼叫C庫的函式。

您需要:

正常工作的貨物裝置。我假設如果你編譯的是Rust程式碼,你就有這個。一個正在執行的C編譯器和pkg配置,用於依賴項解析。與要使用的庫函式對應的標頭檔案。如果您有很好的原始碼,本例假設您是從原始碼構建庫。否則,如果連結到的靜態或動態庫不在系統路徑中,則需要該庫的路徑。與庫的API大小相對應的耐心程度。

安裝命令列bindgen工具非常簡單:

cargo install bindgen

在我的Debian膝上型電腦上,我還需要手動安裝clang,儘管您的里程數可能會有所不同。

設定你的板條箱(crate)

我們的新庫板條箱將包含骯髒的業務建設和出口本機C庫的不安全功能。同樣,將所有安全的包裝器留給另一個板條箱—這不僅加快了編譯速度,而且還使其他板條箱作者能夠最少地匯入和使用原始的c繫結。FFI板條箱的標準Rust命名約定為lib<XXXX>-sys。

我們要創造一個內部版本.rs將與cc板條箱一起使用的檔案,用於編譯和連結我們的bindgen匯出。讓我們將庫原始碼放在一個名為src的子目錄中,並將相關的include檔案放在一個名為include的子目錄中。接下來,讓我們確定貨物.toml已設定:

[package]name = "libfoo-sys"version = "0.1.0"links = "foo"build = "build.rs"edition = "2018"[dependencies]libc = "0.2"[build-dependencies]cc = { version = "1.0", features = ["parallel"] }pkg-config = "0.3"

接下來我們將填充內部版本.rs檔案。下面看起來有點奇怪-我們正在編寫一個Rust程式,它將輸出一個指令碼到stdout;cargo將直接使用這個指令碼來構建我們的板條箱。

如果你連結的是一個已經編譯過的庫,保證在系統路徑中,你的內部版本.rs可能就這麼簡單:

fn main() {println!("cargo:rustc-link-lib=foo");}

不過,大多數情況下,您至少需要使用某種包配置來確保庫已實際安裝並且連結器可以找到它。在許多情況下,您的庫足夠小,可以由cargo本身構建為靜態庫。pkg配置板條箱有助於庫和依賴項配置,cc處理從cargo內部構建C程式碼的髒活。兩個板條箱在輸出貨物所需的行之前都執行配置和構建步驟。在我們的示例中,我們的原始碼使用zlib,因此我們使用pkg config來查詢和匯入適當的版本。下面的示例程式碼還顯示瞭如何新增編譯器標誌和預處理器定義。

fn main() {    pkg_config::Config::new()        .atleast_version("1.2")        .probe("z")        .unwrap();    let src = [        "src/file1.c",        "src/otherfile.c",    ];    let mut builder = cc::Build::new();    let build = builder        .files(src.iter())        .include("include")        .flag("-Wno-unused-parameter")        .define("USE_ZLIB", None);    build.compile("foo");}

最後,你需要一個src/自由盧比檔案來編譯我們的繫結。在這裡,我們將禁用與Rust不一致的C命名約定的警告,然後只包含生成的檔案:

#![allow(non_upper_case_globals)]#![allow(non_camel_case_types)]#![allow(non_snake_case)]use libc::*;include!("./bindings.rs");
生成繫結

而bindgen使用者指南似乎指導您在其中動態生成繫結rs,實際上,您需要在將生成的輸出釋放到板條箱之前對其進行編輯。透過命令列生成一個或多個檔案,並將輸出提交到儲存庫,這將為您提供最大的控制。

最初的生成嘗試可能如下所示:

bindgen include/foo_api.h -o src/bindings.rs

對於一個包含多個API呼叫的真正頭,不幸的是,這將生成比我們想要或需要的更多的定義。生成部分繫結.rs因為我們在德韋洛的專案更接近於此:

bindgen include/foo_api.h -o src/bindings.rs '.*' --whitelist-function '^foo_.*' --whitelist-var '^FOO_.*' -- -DUSE_ZLIB

說服生成器只提供所需的內容,而不吐在未定義的符號上,這是一個反覆試驗的過程。考慮分階段生成並連線結果。

它很強大,但並不完美

當您將頭傳遞給bindgen時,它將呼叫Clang預處理器,然後貪婪地轉換它能看到的每個符號定義。您需要在命令列進行調整,並重構結果輸出。

原始Makefile/CMake extras

在bindgen命令列上的--之後,您可以新增在針對庫構建時通常新增到編譯器的任何標誌。有時,這些將是額外的include路徑,有時,當標頭具有#ifdef保護的定義時,它們將是必需的。對於我們的供應商庫,未能定義OS\u LINUX隱藏了一堆我們需要的符號。(什麼,你認為遺留程式碼會使用標準的編譯器定義,比如linux,而不是編造東西嗎?抱歉,喜劇時間在樓下和樓上。)如果您生成的輸出神秘地缺少函式,請檢查您的定義。

包含標準標頭的標頭

Bindgen非常積極地為預處理器輸出中的每個可用符號生成定義,甚至為不需要的可傳遞的系統特定依賴項生成定義。這意味著如果你的標頭檔案包含stddef.h或time.h(或者包含另一個包含stddef.h或time.h的標頭檔案),你將在生成的輸出中得到一堆額外的垃圾。編譯C++程式碼時更糟,因為C++編譯器顯然必須匯出STD中使用的每個符號,即使它不是必需的或需要的。

您的板條箱應該只公開庫API中的內容,而不是系統標頭檔案或生成程式碼的標準庫中的內容。這是一個痛苦,特別是如果您的庫的函式和常量不遵循任何型別的命名約定。唯一的解決方法是使用白名單regex和大量的嘗試和錯誤。

預處理器#defines
#define FOO_ANIMAL_UNDEFINED 0#define FOO_ANIMAL_WALRUS 1#define FOO_ANIMAL_DROP_BEAR 2/* Argument should be one of FOO_ANIMAL_XXX */void feed(uint8_t animal);

這看起來是人為的,但這是一個模糊版本的模式,在我們的供應商C庫中很普遍。

在C語言中,這樣做很好,因為當您將標頭檔案包含到原始碼中時,當函式呼叫它時,您可以直接使用FOO\u ANIMAL\u WALRUS之類的東西。C編譯器會隱式地將文字1轉換為uint8\t,程式碼就可以運行了。當然,為了清晰起見,最初的作者應該建立一個enum typedef並使用它,但是他們沒有,這仍然是我們必須處理的合法C程式碼。

pub const FOO_ANIMAL_UNDEFINED: u32 = 0;pub const FOO_ANIMAL_WALRUS: u32 = 1;pub const FOO_ANIMAL_DROP_BEAR: u32 = 2;extern "C" {    pub fn feed(animal: u8);}

儘管bindgen足夠聰明,可以將符號識別為常量,但仍然存在一些問題。首先,bindgen必須猜測每個FOO\u ANIMAL\u XXX的型別。在這種情況下,顯然是猜測了u32(它不僅與我們的函式引數不匹配,而且在技術上也是錯誤的)。這導致了另一個問題:在呼叫feed時,Rust將要求我們顯式地將FOO\u ANIMAL\u WALRUS轉換為u8。不是很符合人體工程學,是嗎?要解決這個問題,我們需要更改生成的常量的型別以匹配函式定義。稍後我們將在安全包裝中修復列舉問題。

有些結構應該是不透明的

我們的vendored庫為除初始化之外的幾乎所有函式傳遞一個指向上下文物件的指標。(現在我們稱之為foo\u ctx\t)這是一種廣泛使用的模式,非常合理。但是由於一個實現缺陷,我們的標頭檔案定義了foo\u ctx\u t而不是向前宣告它。不幸的是,這洩漏了foo\u ctx\t的內部結構,然後這種洩漏會間接地迫使我們知道並定義一堆我們不關心的其他依賴型別。

Rust實際上不允許對結構進行單獨的宣告和定義。與C不同,我們不能在Rust中宣告foo\u ctx\t而不為其提供定義,而且Rust編譯器必須識別foo\u ctx\t名稱,以便將指向它的指標用作函式arg。但是我們可以使用變通方法來避免完全定義它。兩者都不是完美的,但在撰寫本文時,有兩種選擇至少在實踐中起作用。

我們可以將結構定義替換為沒有變數的列舉型別,如果您不小心嘗試構造它或將它用作指標目標以外的任何物件,則很容易出現編譯錯誤。這讓型別純粹主義者感到不安,因為從技術上講,我們在對編譯器撒謊,但它確實有效:

pub enum foo_ctx_t {}

或者我們可以用一個私有的零大小型別欄位替換它的內部。這是bindgen預設的功能,只要不依賴mem::size\u of:

pub struct foo_ctx_t {_unused: [u8; 0],}

常量正確性

Bindgen將C常量指標轉換為Rust常量*,將未修飾的C指標轉換為mut*。如果原始程式碼是const correct,那麼這個結果就很好了。如果沒有,它可能會導致頭痛以後當試圖建立安全的包裝。如果可能,修復庫。

下面的例子可以很容易地用在一個Rust不安全的塊中,對時間的正常(不變)引用和對tm的可變引用:

// Generated from <time.h>extern "C" {pub fn gmtime_r(_t: *const time_t, _tp: *mut tm) -> *mut tm;}

從技術上講,您不必修改C庫來更改外部定義中指向const*的指標。事實上,C庫的符號表甚至沒有引數列表,所以Rust的連結器根本無法確認函式引數是否正確(這是C++符號的情況,謝天謝地)。如果您確實修改了Rust指標型別,那麼您將負責驗證const指標的不變數對於庫實際上是正確的。

寫一個自述檔案.md詳細說明如何呼叫bindgen的檔案,並將其提交到儲存庫。相信我,等你意識到有東西不見了你會想要這個的。

新增幾個單元測試來測試是否正常,然後嘗試執行cargo測試。Bindgen建立了一些自己的測試,以確保生成的結構對齊是正確的。您還可以執行cargo doc——在您的板條箱上開啟,以獲得您正在輸出的內容的高階檢視,並再次檢查您是否無意中暴露了錯誤的內容。

儘管如此,這些手動步驟是必要的,因為bindgen正在盡其所能地利用它所擁有的資訊。生成過程將暴露C庫中的每個小結構問題。

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

16
最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • mybatis原始碼詳解基礎功能包原始碼閱讀:parsing包