Rust FFI 入門:與 C 語言跨界溝通的實戰指南

13 分鐘閱讀

Rust FFI 入門:與 C 語言跨界溝通的實戰指南
Made with Nano Banana 2 by Gemini 3.1 Pro

Rust 的 Foreign Function Interface(FFI,外部函式介面)讓 Rust 程式碼能夠呼叫 C 函式庫,也能讓 C 程式碼呼叫 Rust 函式。這篇文章整理了 Rustonomicon 的 FFI 章節中的重點概念,加上我自己的理解和分析,幫助剛接觸 Rust 的開發者建立對 FFI 的整體認知。

悠奈 avatar
悠奈

FFI 這個主題在 Rustonomicon 裡面篇幅不小
但核心觀念其實可以拆成幾個階段來理解
我會試著用「你第一次碰到 C 函式庫時會遇到什麼」的角度來講

什麼是 FFI,為什麼需要它

Rust 無法直接呼叫 C++ 函式庫,但許多 C++ 專案會提供 C 語言介面。FFI 的角色是讓 Rust 透過這層 C 介面,與外部函式庫溝通。

現實中有大量成熟的 C 函式庫(壓縮、加密、系統 API 等)已經過長期驗證。重新用 Rust 實作固然是一種選擇,但在許多情境下,直接綁定(binding)既有的 C 函式庫更務實。FFI 就是實現這件事的機制。

呼叫外部 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);
}

這段程式碼做了三件事:

  1. extern "C" 告訴編譯器,這些函式遵循 C 的呼叫慣例(calling convention)。
  2. #[link(name = "snappy")] 指示連結器去連結名為 snappy 的函式庫。
  3. 呼叫端必須用 unsafe {} 包裹,因為編譯器無法驗證外部函式的行為是否安全。
悠奈 avatar
悠奈

這裡有個容易忽略的細節
Rust 編譯器沒辦法檢查你宣告的函式簽章是否正確
如果型別或參數數量寫錯了,程式會在執行期出問題
所以寫 FFI 綁定的時候,對照 C 標頭檔是基本功

使用 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 型別保證了指標有效、長度正確。

悠奈 avatar
悠奈

封裝的藝術在於,你要想清楚哪些不變式(invariant)是由 Rust 端維護的
slice 保證了連續記憶體和有效長度
這兩個條件剛好是 C 端需要的
所以這個封裝是成立的

壓縮函式的封裝稍微複雜一些,需要先配置足夠大的 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;
    }
}
悠奈 avatar
悠奈

回呼函式跨 FFI 邊界時要特別注意生命週期
Rust 的 BoxVec 如果被 drop 了,C 端持有的指標就變成懸空指標
這種 bug 很難追蹤

非同步回呼的額外風險

當 C 函式庫從自己產生的執行緒呼叫回呼時,問題變得更複雜。此時回呼中對 Rust 資料結構的存取必須加上同步機制(例如 Mutex),否則會產生資料競爭。一個替代方案是透過 std::sync::mpsc channel 把資料從 C 執行緒轉發到 Rust 執行緒,避免在回呼中直接修改共享狀態。

還有一點要注意,如果 Rust 物件被銷毀後,C 端仍然持有其指標並嘗試觸發回呼,結果是未定義行為。正確的做法是在 Rust 物件的 Drop 實作中取消註冊回呼。

連結方式與型別

#[link] 屬性支援三種連結方式:

靜態函式庫會被整合進最終產物中,不需要額外發布。動態函式庫的相依性則會傳播到最終的二進位檔或動態函式庫。

#[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 的作用是阻止編譯器自動標記這個型別為 SendSyncUnpin,避免在多執行緒環境中被不安全地使用。

悠奈 avatar
悠奈

Rustonomicon 特別提醒,空的 enum 絕對不能拿來當 FFI 的不透明型別
因為編譯器會假設空 enum 是無法被實例化的
透過 & 引用操作空 enum 的值會觸發未定義行為

可變參數函式與呼叫慣例

C 的可變參數函式(variadic function)可以在 extern 區塊中用 ... 宣告:

unsafe extern {
    fn foo(x: i32, ...);
}

但 Rust 本身的函式不支援可變參數語法。

在呼叫慣例方面,除了最常見的 extern "C",Rust 還支援 stdcallfastcallsystem 等。其中 system 比較特別,它會根據目標平台自動選擇適當的慣例——在 32 位元 Windows 上是 stdcall,在 64 位元 Windows 上是 C

Nullable Pointer 最佳化

Rust 有一個巧妙的最佳化,如果 enum 剛好有兩個變體,其中一個無資料、另一個包含非空型別(如 &TBox<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,
    }
}

我的觀察

悠奈 avatar
悠奈

讀完 Rustonomicon 的 FFI 章節,我覺得 Rust 在處理 FFI 時有一個很明確的設計哲學
它不會假裝外部世界是安全的
而是把「不安全」的邊界劃得很分明
然後把責任交給開發者

寫 FFI 綁定的過程,本質上是在兩個語言的安全假設之間搭建橋樑。Rust 端用 unsafe 標記出風險區域,再用安全的封裝函式把風險收攏起來。這個模式在實務中經受了大量驗證,是目前 Rust 生態系統中與 C 互通的標準做法。

對於初階 Rust 開發者,我的建議是先理解 extern 區塊和 unsafe 的語義,再學習如何寫安全的封裝。回呼函式和不透明型別的處理可以等到實際需要時再深入。在大多數專案中,社群已經有現成的 -sys crate 提供底層綁定,你要做的通常是理解它的安全封裝層在做什麼。

悠奈 avatar
悠奈

如果你在實際專案中需要手寫 FFI 綁定,推薦看看 cbindgenbindgen 這兩個工具
bindgen 能從 C 標頭檔自動產生 Rust 綁定
cbindgen 則是反過來,從 Rust 程式碼產生 C 標頭檔
省下不少對照型別的時間


本文由蘭堂悠奈撰寫