•
Rust FFI 入門:與 C 語言跨界溝通的實戰指南
13 分鐘閱讀 •

Rust 的 Foreign Function Interface(FFI,外部函式介面)讓 Rust 程式碼能夠呼叫 C 函式庫,也能讓 C 程式碼呼叫 Rust 函式。這篇文章整理了 Rustonomicon 的 FFI 章節中的重點概念,加上我自己的理解和分析,幫助剛接觸 Rust 的開發者建立對 FFI 的整體認知。
什麼是 FFI,為什麼需要它
Rust 無法直接呼叫 C++ 函式庫,但許多 C++ 專案會提供 C 語言介面。FFI 的角色是讓 Rust 透過這層 C 介面,與外部函式庫溝通。
現實中有大量成熟的 C 函式庫(壓縮、加密、系統 API 等)已經過長期驗證。重新用 Rust 實作固然是一種選擇,但在許多情境下,直接綁定(binding)既有的 C 函式庫更務實。FFI 就是實現這件事的機制。
extern 區塊與 #[link] 屬性
呼叫外部 C 函式的第一步,是用 extern 區塊宣告函式簽章。以 snappy 壓縮函式庫為例,Rustonomicon 示範了最基本的綁定方式:
use libc::size_t;
#[link(name = "snappy")]
unsafe extern "C" {
fn snappy_max_compressed_length(source_length: size_t) -> size_t;
}
fn main() {
let x = unsafe { snappy_max_compressed_length(100) };
println!("max compressed length of a 100 byte buffer: {}", x);
}這段程式碼做了三件事:
extern "C"告訴編譯器,這些函式遵循 C 的呼叫慣例(calling convention)。#[link(name = "snappy")]指示連結器去連結名為snappy的函式庫。- 呼叫端必須用
unsafe {}包裹,因為編譯器無法驗證外部函式的行為是否安全。
使用 FFI 時,通常需要 libc crate 來提供 C 語言的型別定義。在 Cargo.toml 中加入相依套件即可:
[dependencies]
libc = "0.2.0" 為什麼 FFI 呼叫是 unsafe
C 函式庫不受 Rust 的所有權和借用規則約束。一個接受裸指標(raw pointer)的 C 函式,可能收到懸空指標(dangling pointer)、空指標,或任何不合法的記憶體位址。C 函式庫也經常有非執行緒安全(non-thread-safe)的介面。
Rust 將這些函式標記為 unsafe,代表呼叫者必須自行承擔驗證安全性的責任。這個設計很合理,Rust 把「信任邊界」(trust boundary)畫在 FFI 的交界處。
建立安全的封裝層
直接把 unsafe 的 C 函式暴露給使用者並不理想。Rustonomicon 推薦的做法是寫一層安全的 Rust 封裝(safe wrapper),把 unsafe 的細節隱藏在內部。
以驗證壓縮資料為例:
pub fn validate_compressed_buffer(src: &[u8]) -> bool {
unsafe {
snappy_validate_compressed_buffer(src.as_ptr(), src.len() as size_t) == 0
}
}這個函式的簽章沒有 unsafe,代表對所有輸入都是安全的。函式內部用 unsafe 呼叫 C 函式,但透過 Rust 的 slice 型別保證了指標有效、長度正確。
壓縮函式的封裝稍微複雜一些,需要先配置足夠大的 Vec 作為輸出緩衝區:
pub fn compress(src: &[u8]) -> Vec<u8> {
unsafe {
let srclen = src.len() as size_t;
let psrc = src.as_ptr();
let mut dstlen = snappy_max_compressed_length(srclen);
let mut dst = Vec::with_capacity(dstlen as usize);
let pdst = dst.as_mut_ptr();
snappy_compress(psrc, srclen, pdst, &mut dstlen);
dst.set_len(dstlen as usize);
dst
}
}這段程式碼利用 snappy_max_compressed_length 預估最大輸出大小,配置 Vec 容量後,再透過 set_len 設定實際寫入長度。Vec 的記憶體佈局保證是連續的,所以可以安全地將 as_mut_ptr() 傳給 C 函式。
從 C 呼叫 Rust
FFI 是雙向的。Rust 也能匯出函式給 C 呼叫。做法是加上 extern "C" 和 #[unsafe(no_mangle)] 屬性:
#[unsafe(no_mangle)]
pub extern "C" fn hello_from_rust() {
println!("Hello from Rust!");
}extern "C" 讓函式遵循 C 的呼叫慣例,no_mangle 阻止 Rust 編譯器對函式名稱進行修飾(name mangling),讓 C 端能透過名稱找到這個符號。
在 Cargo.toml 中設定 crate 類型為 cdylib(動態函式庫)或 staticlib(靜態函式庫):
[lib]
crate-type = ["cdylib"]C 端只需宣告外部函式原型,然後在編譯時連結 Rust 產生的函式庫即可。
回呼函式(Callback)
有些 C 函式庫允許註冊回呼函式,讓 C 在特定事件發生時呼叫 Rust 程式碼。基本模式是將帶有 extern 標記的 Rust 函式指標傳入 C:
extern fn callback(a: i32) {
println!("I'm called from C with value {0}", a);
}
#[link(name = "extlib")]
unsafe extern {
fn register_callback(cb: extern fn(i32)) -> i32;
fn trigger_callback();
}如果需要在回呼中存取 Rust 物件的狀態,可以把裸指標一起傳入 C,讓 C 在觸發回呼時把指標帶回來:
struct RustObject {
a: i32,
}
unsafe extern "C" fn callback(target: *mut RustObject, a: i32) {
unsafe {
(*target).a = a;
}
}非同步回呼的額外風險
當 C 函式庫從自己產生的執行緒呼叫回呼時,問題變得更複雜。此時回呼中對 Rust 資料結構的存取必須加上同步機制(例如 Mutex),否則會產生資料競爭。一個替代方案是透過 std::sync::mpsc channel 把資料從 C 執行緒轉發到 Rust 執行緒,避免在回呼中直接修改共享狀態。
還有一點要注意,如果 Rust 物件被銷毀後,C 端仍然持有其指標並嘗試觸發回呼,結果是未定義行為。正確的做法是在 Rust 物件的 Drop 實作中取消註冊回呼。
連結方式與型別
#[link] 屬性支援三種連結方式:
- 動態連結:
#[link(name = "readline")],執行期載入共享函式庫。 - 靜態連結:
#[link(name = "my_lib", kind = "static")],編譯期直接嵌入。 - Framework:
#[link(name = "CoreFoundation", kind = "framework")],僅限 macOS。
靜態函式庫會被整合進最終產物中,不需要額外發布。動態函式庫的相依性則會傳播到最終的二進位檔或動態函式庫。
#[repr(C)] 與結構體互通
Rust 的結構體記憶體佈局預設不保證與 C 相容。要在 FFI 中傳遞結構體,必須加上 #[repr(C)] 屬性,讓編譯器使用 C 的佈局規則。也可以搭配 #[repr(C, packed)] 移除欄位間的填充位元組(padding)。
處理不透明型別(opaque type)時,C 端通常只提供指標而不暴露結構體內部。Rust 的做法是建立一個帶有私有欄位的結構體:
#[repr(C)]
pub struct Foo {
_data: (),
_marker:
core::marker::PhantomData<(*mut u8, core::marker::PhantomPinned)>,
}PhantomData 的作用是阻止編譯器自動標記這個型別為 Send、Sync 或 Unpin,避免在多執行緒環境中被不安全地使用。
可變參數函式與呼叫慣例
C 的可變參數函式(variadic function)可以在 extern 區塊中用 ... 宣告:
unsafe extern {
fn foo(x: i32, ...);
}但 Rust 本身的函式不支援可變參數語法。
在呼叫慣例方面,除了最常見的 extern "C",Rust 還支援 stdcall、fastcall、system 等。其中 system 比較特別,它會根據目標平台自動選擇適當的慣例——在 32 位元 Windows 上是 stdcall,在 64 位元 Windows 上是 C。
Nullable Pointer 最佳化
Rust 有一個巧妙的最佳化,如果 enum 剛好有兩個變體,其中一個無資料、另一個包含非空型別(如 &T、Box<T> 或函式指標),那麼 enum 可以用 null 來表示無資料的變體,不需要額外的辨別欄位。
Option<extern "C" fn(c_int) -> c_int> 就是這個最佳化的典型應用。它在 FFI 中對應 C 的 nullable 函式指標 int (*)(int),不需要用 transmute 轉換。
Unwinding 與 Panic 的邊界行為
FFI 邊界的 panic 處理是容易被忽略但後果嚴重的議題。
預設的 extern "C" 不允許 unwinding 穿越邊界。如果 Rust 函式在 extern "C" 中 panic,程式會安全地中止(abort)。如果你預期 panic 或 C++ 例外可能跨越 FFI 邊界,應該使用 extern "C-unwind" ABI。
extern "C-unwind" 允許 Rust 的 panic 穿越 C++ 的堆疊框架(stack frame),也允許 C++ 的例外穿越 Rust 的堆疊框架。在後者的情況下,Rust 物件的解構函式(destructor)會正常執行。
如果 C++ 例外進入非 `-unwind` 的 ABI 邊界,結果是未定義行為。而 Rust 的 panic 進入非 -unwind 邊界時,程式會安全中止。兩者的處理方式不對稱,C++ 例外的風險更高。
如果你不想讓 panic 傳播,可以在 FFI 邊界處用 catch_unwind 攔截:
use std::panic::catch_unwind;
#[unsafe(no_mangle)]
pub extern "C" fn oh_no() -> i32 {
let result = catch_unwind(|| {
panic!("Oops!");
});
match result {
Ok(_) => 0,
Err(_) => 1,
}
}我的觀察
寫 FFI 綁定的過程,本質上是在兩個語言的安全假設之間搭建橋樑。Rust 端用 unsafe 標記出風險區域,再用安全的封裝函式把風險收攏起來。這個模式在實務中經受了大量驗證,是目前 Rust 生態系統中與 C 互通的標準做法。
對於初階 Rust 開發者,我的建議是先理解 extern 區塊和 unsafe 的語義,再學習如何寫安全的封裝。回呼函式和不透明型別的處理可以等到實際需要時再深入。在大多數專案中,社群已經有現成的 -sys crate 提供底層綁定,你要做的通常是理解它的安全封裝層在做什麼。