一個實用的指南,以FFI不讓你的指節流血(第2部分,共2)
在本文中,我們將探討如何包裝這些函式,並使它們能夠安全地正常使用。我們將討論如何定義處理初始化和清理的包裝器結構,並描述一些特性,這些特性描述了應用程式開發人員如何安全地將庫與執行緒一起使用。我們還將討論如何將函式的隨機整數返回轉換為符合人體工程學的型別檢查結果,如何將字串和陣列與C世界進行轉換,以及如何將從C返回的原始指標轉換為具有繼承生存期的作用域物件。
這一步的總體目標是深入研究C庫的文件,並使每個函式的內部假設明確化。例如,文件可能會說函式返回指向內部只讀資料結構的指標。該指標在程式生命週期內有效嗎?它被限定在某個範圍內還是被初始化了?最後,我們將有一套包裝器,使這些假設對Rust可見。然後,我們將能夠依靠Rust的借閱檢查器和強制錯誤處理來確保每個人都正確地使用庫,即使是在多個執行緒中。
pub const FOO_ANIMAL_UNDEFINED: u8 = 0;pub const FOO_ANIMAL_WALRUS: u8 = 1;pub const FOO_ANIMAL_DROP_BEAR: u8 = 2;extern "C" { /// Argument should be one of FOO_ANIMAL_XXX pub fn feed(animal: u8);}
等一下。函式簽名說它需要一個u8,但是文件說引數應該是FOO\u ANIMAL\u XXX中的一個(為了我們自己的理智,假設它可以安全地處理未定義的情況)。如果我們讓我們的安全程式碼以任意的u8作為輸入執行,這不僅令人困惑,而且有潛在的危險。聽起來我們的安全包裝應該採取動物列舉和轉換它。
我們可以手工寫這個列舉。但是讓我們使用enum_原語板條箱來給我們一些額外的靈活性。(為了簡潔起見,我省略了rustdoc字串,不過您應該在實際程式碼中包含它們):
use enum_primitive::*;enum_from_primitive! {#[derive(Debug, Copy, Clone, PartialEq)]#[repr(u8)]pub enum Animal { Undefined = FOO_ANIMAL_UNDEFINED, Walrus = FOO_ANIMAL_WALRUS, DropBear = FOO_ANIMAL_DROP_BEAR,}}
因為我們將結構標記為“representable as a u8”,所以一個簡單的cast就足以將動物轉換成u8。現在我們可以這樣寫我們的安全包裝:
pub fn feed(animal: Animal) {unsafe { libfoo_sys::feed(animal as u8) }}
enum_primitive板條箱還為我們提供了一些有用的樣板函式,用於將u8轉換為選項<Animal>。如果函式返回實際應視為列舉型別的u8值,則可能需要這樣做。有一個問題:如果提供的數字與列舉值不匹配,則從數字型別的轉換可能會失敗。這取決於您的程式碼是否立即展開並驚慌失措,是否用預設值替換None(“Unknown”如果存在可以使用,如果不存在可以新增),或者只是返回選項並讓呼叫者處理它。
返回指標的初始值設定項在接下來的幾個示例中,我將使用一個非常著名和非常粗糙的庫作為示例:OpenSSL。(請不要自己為OpenSSL實現繫結;有人已經實現了,而且做得更好。這只是一個很熟悉的例子。)
在使用OpenSSL加密或解密資料之前,首先需要呼叫一個函式來分配和初始化一些上下文。在我們的示例中,這個初始化是透過呼叫SSL\u CTX\u new來執行的。執行任何操作的每個函式都會獲取一個指向此上下文的指標。當我們使用完這個上下文後,需要使用SSL\u CTX\u free清理和銷燬上下文資料。
我們將建立一個結構來包裝此上下文的生存期。我們將新增一個名為new的函式,它為我們進行初始化並返回這個結構。所有需要上下文指標的C庫函式都將包裝為trust函式taking&self並在結構上實現。最後,當我們的結構超出範圍時,我們希望鏽跡能自動清除。希望這應該是一個熟悉的軟體模式:它是RAII。
我們的示例可能如下所示:
use failure::{bail, Error};use openssl_sys as ffi;pub struct OpenSSL { // This pointer must never be allowed to leave the struct ctx: *mut ffi::SSL_CTX,}impl OpenSSL { pub fn new() -> Result<Self, Error> { let method = unsafe { ffi::TLS_method() }; // Manually handle null pointer returns if method.is_null() { bail!("TLS_method() failed"); } let ctx = unsafe { ffi::SSL_CTX_new(method) }; // Manually handle null pointer returns here if ctx.is_null() { bail!("SSL_CTX_new() failed"); } Ok(OpenSSL { ctx }) }}
我將ffi後面的C庫呼叫命名為namespacking,以便更清楚地瞭解我們匯入的內容與我們在包裝器中定義的內容。我也作弊了一點,並使用保釋失敗板條箱-在真正的程式碼中,你會想定義一個錯誤型別,並使用它。是的,它看起來有點噁心,因為我們沒有從我們的回報拆解選項型別的細節。我們必須手動檢查一切。
請記住:包裝不安全函式意味著您正在進行驗證空指標和檢查錯誤的艱苦工作。這正是包裝器必須正確處理的型別。早期的恐慌遠勝於默默地傳遞空指標或無效指標。我們也不能允許ctx指標從結構中複製出來,因為我們只能保證它在結構仍然存在時是有效的。
透過impl Drop的解構函式另一端是清理。鐵鏽中的毀滅者是透過下降特性來處理的。我們可以為我們的結構實現Drop,這樣鐵鏽就可以適當地破壞我們的控制代碼:
impl Drop for OpenSSL {fn drop(&mut self) {unsafe { ffi::SSL_CTX_free(self.ctx) }}}
傳送和同步現在有了一個包含指標元素的結構。但是預設情況下,Rust會對如何線上程上下文中使用struct設定一些限制。為什麼語言會這樣做,為什麼這很重要?
預設情況下,Rust假設原始指標不能線上程之間移動(!傳送),並且不能線上程之間共享(!同步)。因為你的結構包含一個原始指標,所以它既不傳送也不同步。這種保守的假設有助於防止外部C程式碼在crust提供的那些可愛的執行緒安全保證上到處亂跑。
如果你的物件沒有被髮送,那麼你線上程程式中處理它的能力就會受到很大的限制——甚至無法將它包裝在互斥鎖中並在執行緒之間傳遞引用。但可能外部文件或對原始碼的巧妙檢查表明返回的上下文指標線上程之間移動是安全的。它還可以指示使用此上下文指標的函式線上程上下文中使用是否安全,即函式本身是執行緒安全的。Rust無法為您做出這些決定,因為它看不到庫使用這些指標做了什麼。
如果你能斷言你的每一次使用(內部私有!)指標遵守這兩條規則中的任何一條,你都可以直截了當地告訴我。正確地做出這種斷言是困難的,如果不是危險的,並且灌輸給你適當數量的恐懼Rust需要你使用不安全的關鍵字。
unsafe impl Send for MyStruct {}unsafe impl Sync for MyStruct {}
假設您不允許以某種方式(透過訪問器方法或透過標記結構成員pub)對指標進行外部訪問,那麼如果您可以做出以下斷言,則可以安全地執行以下操作:
如果取消引用指標的C程式碼從未使用執行緒本地儲存或執行緒本地鎖定,則可以標記struct Send。許多庫都是這樣。如果所有能夠取消引用指標的C程式碼總是以執行緒安全的方式(即與safe-Rust一致)取消引用,則可以標記結構同步。大多數遵循此規則的庫都會在文件中告訴您,並且它們在內部使用互斥鎖來保護每個庫呼叫。返回指標的函式假設我們已經用新的和drop實現建立了結構。我們很高興地翻閱了接受這個上下文指標的函式列表,對於我們想要公開的每一個函式,我們正在針對我們的結構實現一個安全版本,它接受&self。然後我們遇到了這樣的事情(為了簡單起見是虛構的,但不遠):
// Always returns valid data, never failsSSL_CIPHER *SSL_CTX_get_cipher(const SSL_CTX *ctx);
我們顯然不想從包裝器中返回原始指標,這不太符合人體工程學。這樣做的目的是確保庫使用者不必使用不安全的軟體。
閱讀文件後,我們發現SSL\u CIPHER是一個結構,只要SSL\u CTX沒有被釋放,返回的指標就有效。嘿,聽起來像是一輩子的事。所以我們的第一種方法可能是這樣的:
pub fn get_cipher(&self) -> &ffi::SSL_CIPHER {unsafe {let cipher = ffi::SSL_CTX_get_cipher(self.ctx);// Dereference the pointer, then turn it into a reference.// Remember: derefing a pointer is unsafe!&*cipher}}
取消引用然後立即獲取指標的地址會建立一個所謂的無限生存期。這不是我們想要的,所以我們立即透過返回型別來約束生存期。我們沒有明確指定生存期,但是讓我們回顧一下Rust手冊中關於生存期省略的規則。在這種情況下,返回值的生存期將被預設約束為與&self的生存期相同。這是一個合理的界限,所以這個實現看起來是安全的。
但我們可以更進一步。SSL\u密碼通常用作上下文指標,並具有自己的關聯函式。實際上,讓我們的安全程式碼返回對C結構的引用根本不符合人體工程學。我們要返回的是一個Rust結構,它自身的關聯行為與C庫匹配。但我們也應該保留生存期關聯:“只有從中獲取密碼的OpenSSL物件仍然存在,這個密碼物件才有效。”
因此,假設我們已經完成了建立一個密碼結構來包裝指標的工作,並且我們想告訴Rust這個結構有某種依賴於OpenSSL物件的生存期:
pub fn get_cipher<'a>(&'a self) -> Cipher<'a> { unsafe { let cipher = ffi::SSL_CTX_get_cipher(self.ctx); Cipher::from(&self, cipher) }}// Something is missing here...pub struct Cipher<'a> { cipher: *const ffi::SSL_CIPHER,}fn from<'a>(_: &'a OpenSSL, cipher: *const ffi::SSL_CIPHER) -> Cipher<'a> { Cipher { cipher }}
不幸的是,這不會編譯,因為Rust說“嘿,你聲明瞭一個與你的結構相關聯的生存期,但是它沒有在任何地方使用!“所以我們需要以某種方式宣告,是的,內部依賴於一個我們不能立即看到的引用。
use std::marker::PhantomData;pub struct Cipher<'a> { cipher: *const ffi::SSL_CIPHER, phantom: PhantomData<&'a OpenSSL>,}fn from<'a>(_: &'a OpenSSL, cipher: *const ffi::SSL_CIPHER) -> Cipher<'a> { Cipher { cipher, phantom: PhantomData }}
您可以將其視為對編譯器說:“將此結構視為包含對OpenSSL的引用,其生存期為'a'。這一生從何而來?當我們打電話給我們的發件人時,我們會提供它。
幻影資料實際上並不佔用任何空間,它會在編譯程式碼中消失。但它允許編譯器對生存期的正確性進行推理。現在,我們的包裝器使用者不能在釋放其父級OpenSSL後意外地持有密碼。
可能返回錯誤的函式考慮以下C函式:
int foo_get_widget(const foo_ctx_t*, widget_struct*);
我們需要傳遞一個指標,函式將填充它。如果此函式返回0,則一切正常,我們可以相信輸出已正確填充。否則,我們需要返回一個錯誤。
更符合人體工程學的做法是返回一個擁有資料的結構,而不是要求呼叫方建立一個可變結構並傳遞一個mut引用(儘管如果這樣做有意義的話,您可以同時提供這兩個結構)。
在下面的示例中,我假設自定義錯誤型別是在其他地方定義的,並且允許從適當的型別進行轉換。
use std::mem::MaybeUninit;pub fn get_widget(&self) -> Result<widget_struct, GetError> { let mut widget = MaybeUninit::uninit(); unsafe { match foo_get_widget(self.context, widget.as_mut_ptr()) { 0 => Ok(widget.assume_init()), x => Err(GetError::from(x)), } }}
Ed:感謝reddit/u/Cocalus指出mem::uninitialized()已被棄用。希望我能修好!
上面,widget_struct不實現Default,因為“Default”和零化建構函式都沒有意義。相反,我們告訴Rust不要初始化結構記憶體,因此我們斷言外部函式負責正確初始化結構的每個欄位。
有些函式不返回任何有用的資訊,但仍然可能出錯。
int foo_update(const foo_ctx_t*);
您可能會嘗試將整數值轉換為列舉型別,然後使用它。別那麼做!正確編寫的程式碼會在出現故障時返回結果,您也應該這樣做。但是“成功”的價值觀呢?為此,我們應該使用()告訴呼叫者沒有返回資料。但是呼叫者仍然需要開啟包裝或者處理錯誤。
update(&self) -> Result<(), UpdateError> {match unsafe { foo_update(self.context) } {0 => Ok(()),x => Err(UpdateError::from(x)),}}
FFI中的字串Rust不像C那樣將字串儲存為以null結尾的char緩衝區;它在內部儲存緩衝區和長度。因為型別並沒有完全對齊,這意味著從Rust字串的世界移到C char陣列,再移回來需要一些技巧。謝天謝地,有一些內建型別可以幫助管理它,但是它們附帶了很多字串。有些轉換分配(因為它們需要改變字串表示或新增終止null),有些則不分配。有時不做複製就可以安全地逃脫,有時則不然。文件確實解釋了哪個是哪個,但是有很多東西需要通讀。這是執行摘要。
如果您以某種方式生成了一個Rust字串,並且需要將它作為臨時const*c\u char傳遞給c程式碼(這樣它就可以製作一個字串的副本供自己使用),那麼您可以將它轉換為CString,然後作為\u ptr呼叫。如果包裝器簽名借用了&str,則首先轉換為&CStr,然後作為\u ptr呼叫。這兩種情況下的指標只有在Rust引用正常有效時才有效。原始指標剝離了借用檢查器的安全性,並要求我們自己維護這個不變數。如果你搞砸了,鐵鏽幫不了你。
對於從C函式中獲取const char*並希望將其轉換為Rust可以使用的內容的情況,需要確保它不為null,然後將其轉換為具有適當生存期的&CStr。如果你不知道如何表達一個合適的生命週期,最安全的方法就是立即將它轉換成一個擁有的字串!
需要注意的其他事項:
CString::因為ptr有一把手槍,很難正確使用。閱讀文件中標記為WARNING的部分,並確保CString在C程式碼返回之前一直在作用域中。trust字串可以合法地在中間有一個空的\0位元組,但是C字串不能(因為它會終止字串)。因此,嘗試將&str轉換為&CStr或將字串轉換為CString可能會失敗。你的程式碼需要處理這個問題。一旦將原始C指標轉換為&CStr,在將其用作本機safe Rust中的&str之前,仍然需要執行(或不安全地跳過)一些驗證。C使用任意字串編碼,而Rust字串總是UTF-8。現在大多數C庫都返回有效的UTF-8字串(ASCII是一個子集,所以即使是傳統的應用程式也可以)。允許分配的函式(如CStr::to \u str \u lossy)將在必要時用UTF“替換字元”替換無效字元,而其他函式如CStr::to \u str將只返回一個結果。閱讀文件,並根據需要選擇正確的函式。如果庫返回路徑,請在包裝器中使用OsString和&OsStr,而不是String和&str。陣列和長度如果庫接受指向T的指標和長度,那麼很容易實現一個包裝器,該包裝器接受一個切片並將其分解為指標和長度。但反過來呢?原來還有一個庫函式。請記住檢查null,確保大小是元素數,並再次檢查返回生存期是否正確。同樣,如果不能保證生存期的正確性,只需返回一個擁有的集合,比如Vec。
回撥回撥簽名通常作為bindgen中的選項<unsafe extern“C”fn…>生成。因此,當您使用Rust編寫回調時,顯然需要使用不安全的extern“C”來裝飾它們(它們不需要是pub),然後當您將它們傳遞到C庫時,只需將名稱包裝在一些檔案中。很簡單。
問題是,在C程式碼中釋放恐慌是…好吧,我們就說它是壞的。理論上,恐慌幾乎可以發生在Rust程式碼的任何地方。所以為了安全起見,我們需要把我們的身體包在裡面。通常情況下,抓住恐慌是不明智或理智的,但這是例外。沒有雙關語。
unsafe extern "C" fn foo_fn_cb_wrapper() {if let Err(e) = catch_unwind(|| {// callback body goes here}) {// Code here must be panic-free.// Sane things to do:// log failure and/or kill the programeprintln!("{:?}", e);// Abort is safe because it doesn't unwind.std::process::abort();}}
常見模式在編寫這些包裝器時,您可能會遇到一些可以在函式或宏中輕鬆表達的模式。例如,庫函式可能總是返回一個int,它總是表示同一組非零錯誤。編寫一個呼叫不安全程式碼並將返回結果強制轉換為Result<()、LibraryError>的私有宏可以節省大量樣板檔案。注意這些構造,透過一點重構,您可以節省自己幾個小時的工作。
我不會撒謊的。正確地做這件事需要做很多工作。但是正確的操作會產生無bug的程式碼。如果您的C庫是實心的,並且包裝層是正確的,那麼您將不會看到一個分段錯誤或緩衝區溢位。您將立即看到任何錯誤。當你在應用程式程式碼中犯了以前的指標錯誤時,它根本不會編譯;當應用程式程式碼編譯時,它只會工作。
全網同號,由【超級工程師】編譯
本文:http://jiagoushi.pro/node/1451