JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

不安全 Rust、健全性契約,與外部函式介面(FFI)

安全 Rust 一直把借用檢查器擋在你和深淵之間。現在我們刻意打開那道暗門:unsafe 究竟關掉了什麼、你必須遵守哪一份契約才能讓程式其餘部分保持健全,以及如何越過 FFI 邊界呼叫 C 而不悄悄地把一切弄壞。

unsafe 究竟關掉了什麼

在這一級的前四篇導引裡,你看著借用檢查器駁回看起來明明正確的程式碼、學了生存期,也見識到別名與可變互斥(aliasing XOR mutability)如何讓共享參照與可變參照永不重疊。人們很容易以為 unsafe 把這一切都丟掉了。並沒有。關於不安全 Rust,第一件該誠實知道的事,就是它實際上解除的東西有多少——非常少。借用檢查、生存期檢查、型別檢查、移動檢查——每一項在 unsafe 區塊裡都依然完全開著。編譯器並不會就此鬆懈成 C。

unsafe 所做的,是恰好解鎖五項額外能力,再無其他:你可以對裸指標(`*const T` 或 `*mut T`)解參照、呼叫標記為 unsafe 的函式(包含任何 FFI 函式)、存取或修改可變的 static、實作 unsafe 的 trait,以及存取 union 的欄位。整份清單就這些。注意它的形狀——這些正是編譯器無法獨力證明其正確性的操作。一個普通的 `&T` 由型別系統保證指向一個存活且有效的 `T`;而 `*const T` 不帶這種承諾,所以透過它讀取是只有你能擔保的事。unsafe 這個關鍵字,就是你舉起手說「我檢查過了,相信我」。

健全性契約

接下來這個觀念,是其餘一切賴以成立的根基。Rust 的安全承諾並不是「安全程式碼受檢查、不安全程式碼自生自滅」。它是兩者之間的一筆交易:安全程式碼無論被怎麼使用,都絕不可造成未定義行為 若一個函式不存在任何可能的安全輸入——沒有任何引數、沒有任何呼叫順序——能把它逼進未定義行為,我們就稱它是健全的(sound)。當你寫下 unsafe,你的工作就是替安全程式碼守住那份承諾。unsafe 區塊是你做編譯器做不到的工作之處;健全性契約則是那份工作必須達到的標準。

尖銳的後果是:單單一個寫錯的 unsafe 區塊,就能讓遠處、完全清白的安全程式碼破壞記憶體。假設你寫了一個快速的 `split_at_mut`,用裸指標把一個陣列切成兩個 `&mut` 切片發出去。如果你的索引算術差了一格,使兩個切片重疊,你就剛剛鑄造出兩個指向同一個位元組的可變參照——違反了別名與可變互斥。你的 unsafe 區塊照樣編譯,今天甚至可能跑得好好的,但你破壞了最佳化器所倚賴的不變式,而某處、某個安全呼叫者終將撞上最佳化器獲准假設不會發生的那個未定義行為。這就是為什麼我們說 unsafe 程式碼不是臭蟲發生之處——它是臭蟲被造成之處,而安全程式碼是它們現身之處。

因為波及範圍會延伸進安全程式碼,真正有用的紀律是封裝。你把那些 unsafe 的行為包進一個模組或型別裡,只對外暴露安全的 API,然後親手證明——在腦中、在註解裡——這個 API 的任何安全用法都到不了未定義行為。標準函式庫正是這樣造出來的:`Vec`、`RefCell`、`Mutex`、`Arc` 全都是包在 unsafe 內臟外的一層薄薄的安全外殼,每一個都是一份你正在信任的小小健全性證明。目標從來不是消滅 unsafe;而是把它縮到少數幾行受過審查的程式碼,並讓那道邊界是健全的。

你必須守住的不變式,以及 Miri

遵守契約意味著要知道裸操作絕不可違反的那些具體不變式。有幾條是長相左右的伙伴。一個參照在其存活期間,必須始終指向一個正確對齊、非空、且已初始化的、屬於其型別的值;一個 `bool` 只能裝 0 或 1,別無其他;一個 enum 必須裝著有效的判別子(discriminant)。對一個懸空或未對齊的指標解參照,即使程式看起來能跑,也是當場的未定義行為。而最深的一條,承接自上一篇導引:當一個 `&mut T` 存在時,別的任何人都不可讀寫那段記憶體;而當一個 `&T` 存在時,沒有人可以寫入它——即使是透過裸指標、即使是在 unsafe 裡。

最後那條規則微妙到語言社群為它建了一套操作模型來精確釘死它:Stacked Borrows(以及它的後繼者 Tree Borrows),也就是 Miri 所強制執行的別名紀律。你可以把它想成附在每個位置上的一疊權限:建立一個參照會推入一個標記,使用它時那個標記必須仍在最上面,而不按次序使用某個指標會彈出它上面的一切並使那些借用失效。細節屬於研究等級,但實務上的回報極為可觀:Miri 是一個直譯器,它跑你的測試,並在 unsafe 區塊一碰到它無權碰的記憶體時就放聲尖叫——正好抓住那種編譯乾淨、在 `-O0` 下通過、卻在最佳化下引爆的沉默損壞。

跨進 C:FFI 邊界

寫 unsafe 最常見的理由,是要和那個龐大的既有 C 世界對話:libc、OpenSSL、SQLite,乃至作業系統本身。這就是 FFI,外部函式介面,而它之所以不安全有個精確的理由——那個 C 函式不做任何 Rust 編譯器讀得懂的承諾,所以呼叫它正是 unsafe 解鎖的五項能力之一。要呼叫 C,你在一個 `extern "C"` 區塊裡宣告它的簽章。那個 `"C"` 選定了 C ABI:你早在組合語言那一級遇過的平台呼叫慣例,它釘死了引數如何落入 rdi、rsi 之類的暫存器、回傳值如何由 rax 帶回、以及堆疊如何排列。名稱必須和 C 符號分毫不差地相符,因為連結器看到的只有一個符號——跨越這道邊界完全沒有任何型別檢查。

FFI 的另一半,是讓你的資料長成 C 所期待的樣子。一個普通的 Rust 結構沒有保證的欄位順序——編譯器可能重排欄位以塞得更緊。一旦你要把結構交給 C,你就必須為它標註 `#[repr(C)]`,它會以 C 的對齊與填補規則、依宣告順序釘住欄位,好讓兩種語言對位元組佈局取得一致。字串是經典陷阱:Rust 的 `&str` 是一個指標加一個長度,而且不是以空字元結尾的,而 C 期待一個以 0 位元組結尾的 `char *s`。你用 `CString` 來送、用 `CStr` 來收,以此搭橋。下面的草圖展示了一次安全地呼叫進 C 的完整形狀。

// declare the C function: size_t strlen(const char *s);
extern "C" {
    fn strlen(s: *const c_char) -> usize;
}

// a SAFE wrapper that upholds the contract for every caller
pub fn c_strlen(text: &str) -> Result<usize, NulError> {
    let owned = CString::new(text)?;     // copy + append a trailing 0 byte
    let p: *const c_char = owned.as_ptr();
    // SAFETY: p points to a valid, NUL-terminated buffer that
    // `owned` keeps alive for the whole duration of this call.
    let n = unsafe { strlen(p) };
    Ok(n)                                 // `owned` dropped here, after the call
}
不安全的呼叫只有兩行;安全工作則是它周圍的一切。SAFETY 註解精確記下了契約為何成立——以及為何 `owned` 必須活得比這次呼叫久。

跨邊界的所有權,以及 Drop

最棘手的 FFI 臭蟲與型別無關——而是關於所有權跨越一道借用檢查器跟不過去的邊界。C 沒有「誰擁有一個指標」的概念,所以每個 C API 都把自己的規則寫成散文:這個函式回傳的是一個你必須 free() 的緩衝區,還是一個它仍持有的?如果你呼叫某 C 函式庫的 open 而它遞回一個控制代碼,你通常就欠它一次相對應的 c_close,正是你在基礎那一級學過的 malloc/free 配對——只是這回編譯器看不見這份義務。忘了那次 close,你就洩漏了一個 Rust 所有權系統從不知其存在的 C 資源。

這正是這一級首尾相接之處。解法是把那份外來的義務重新交回 Rust 的 RAII 手中:給那個控制代碼一個 Rust 擁有者,並為它實作 Drop trait。你把裸的 C 控制代碼包進一個結構、只對外發出安全方法,並寫一個 `drop`,讓它恰好呼叫一次 C 的清理函式。如今這個外來資源會在右大括號處確定性地被釋放,在每一條路徑上——包括 panic 展開——都是如此;這正是你在 C++ 裡見過 RAII 所給的同一份保證,在這裡以 Drop 表達。對 C 解構函式的那個不安全呼叫住在唯一一處受審查的地方,而你那個包裝的每一個安全使用者,都白白得到了正確的清理。

關於邊界上的 Drop,有兩點誠實的提醒。第一,絕不可讓 panic 跨越 FFI 邊界展開進 C——那是未定義行為,所以任何你以 `extern "C"` 暴露給 C 的 Rust 函式,都應該攔下 panic 或就此中止,而不是任由它逃逸。第二,Drop 在生命正常結束時才執行;若一個值被洩漏(用 std::mem::forget,或藉由 Rc 的參照環),它的 drop 永不觸發,所以 Drop 是清理的正確工具,卻不是對付一個鐵了心的洩漏的保證。把它的來歷與年份說清楚:這整套 FFI 機制自 2015 年的 1.0 版起就是 Rust 的一部分,而這正是 Rust 賺得「系統語言」這個稱號的方式——它能流利地說 C 的 ABI,同時把安全契約完好無損地維持到最邊緣。