Rust 的 FFI(Foreign Function Interface)讓我們可以呼叫 C 函式庫,但正確實作並不容易。手寫綁定容易出錯,維護成本高;bindgen 自動產生綁定但仍需要 unsafe;safe wrapper 提供安全的 API 但設計繁瑣。

本文以綁定 FFmpeg 的 libavformat 為例,完整介紹:

  1. 三種 FFI 方式的優缺點比較
  2. 手寫綁定的常見陷阱:結構體大小、欄位順序、對齊、生命週期…
  3. Bindgen 的工作原理:如何用 libclang 解析 C 標頭檔
  4. Safe Wrapper 設計原則:RAII、類型狀態、錯誤處理、thiserror
  5. AI Coding Agent 如何加速開發:讓最佳實踐不再繁瑣

如果你正在考慮綁定一個 C 函式庫,或是想了解為什麼「先用 unsafe 頂著」是個壞主意,這篇文章應該能幫到你。

三種 FFI 方式

1. 手寫 FFI(Manual)

最傳統的方式:手動撰寫 extern "C" 宣告。

#[link(name = "avformat")]
extern "C" {
    pub fn avformat_open_input(
        ps: *mut *mut AVFormatContext,
        url: *const c_char,
        fmt: *const c_void,
        options: *mut *mut c_void,
    ) -> c_int;
}

優點:

  • 完全掌控型別定義
  • 無編譯時依賴
  • 只定義需要的部分

缺點:

  • 容易出錯:必須精確對應 C 的結構體佈局
  • 維護負擔:函式庫更新時需手動同步
  • ABI 細節容易遺漏

2. Bindgen 自動生成

使用 bindgen 在編譯時從 C 標頭檔自動產生綁定:

// build.rs
let bindings = bindgen::Builder::default()
    .header_contents("wrapper.h", r#"
        #include <libavformat/avformat.h>
    "#)
    .allowlist_function("avformat_open_input")
    .generate()
    .expect("Failed to generate bindings");

優點:

  • 準確:直接從 C 標頭檔產生
  • 完整:包含所有型別、函式、常數
  • 可維護:標頭檔更新時自動同步

缺點:

  • 編譯時需要 libclang
  • 產生的程式碼冗長
  • 可能包含不需要的內容

3. 安全封裝(Safe Wrapper)

在 bindgen 綁定之上,建立符合 Rust 慣例的 API:

pub struct FormatContext {
    ptr: *mut bindgen::AVFormatContext,
}

impl FormatContext {
    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
        // 安全的 Rust API,內部處理所有 unsafe
    }
}

impl Drop for FormatContext {
    fn drop(&mut self) {
        unsafe { bindgen::avformat_close_input(&mut self.ptr); }
    }
}

優點:

  • 使用者不需要寫 unsafe
  • RAII 自動管理資源
  • 編譯器協助防止錯誤

缺點:

  • 需要額外的抽象層
  • 可能有輕微的效能開銷
  • 設計 API 需要深思熟慮

手寫 FFI 的常見陷阱

手寫 FFI 看似簡單,但有許多隱藏的坑。以下是實際可能發生的錯誤:

1. 結構體大小不匹配

// 你的定義(40 bytes)
#[repr(C)]
pub struct AVPacket {
    pub pts: i64,
    pub dts: i64,
    pub data: *mut u8,
    pub size: i32,
}

// 實際 C 結構體(104 bytes)
// 還有很多你沒定義的欄位

後果: 當 FFmpeg 寫入結構體時,會覆寫超出你定義範圍的記憶體 → 程式崩潰或資料損壞。

2. 欄位順序錯誤

// 錯誤的順序
pub struct AVRational {
    pub den: c_int,  // 你把分母放前面
    pub num: c_int,
}

// C 標頭檔的定義
struct AVRational {
    int num;  // 分子在前!
    int den;
};

後果: 你的 num 讀到分母,den 讀到分子 → 計算錯誤、除以零。

3. 對齊問題

#[repr(C)]
pub struct Misaligned {
    pub flag: u8,
    pub value: u64,  // 期望 8-byte 對齊
}

後果: 在某些平台上,C 會在 flag 後加 7 bytes 的 padding。如果 Rust 沒有,value 欄位就會讀到垃圾值。

4. 列舉值不匹配

// 你的猜測
pub enum AVMediaType {
    Video = 0,
    Audio = 1,
}

// 實際 FFmpeg(不同版本)
// FFmpeg 4.x: Video = 0, Audio = 1
// FFmpeg 6.x: Unknown = -1, Video = 0, Audio = 1

後果: 檢查 if type == Video 可能失敗,因為數值已經偏移。

5. 指標生命週期

// C 函式內部儲存了這個指標
let path = CString::new("/path/to/file").unwrap();
avformat_open_input(&mut ctx, path.as_ptr(), ...);
drop(path);  // CString 被釋放!
// ctx 現在持有一個指向已釋放記憶體的懸空指標

後果: Use-after-free → 崩潰或安全漏洞。

6. 呼叫慣例錯誤

extern "C" fn callback(x: i32) -> i32  // 假設是 cdecl

// 但 FFmpeg 在 Windows 上可能期望 stdcall

後果: 堆疊損壞、錯誤的返回值。

7. 版本相依的欄位

// 在 FFmpeg 5.0 上正常運作
pub struct AVCodecParameters {
    pub codec_type: AVMediaType,
    pub codec_id: u32,
    pub bit_rate: i64,
}

// FFmpeg 6.0 在 bit_rate 之前新增了一個欄位

後果: bit_rate 現在從錯誤的偏移量讀取 → 得到無意義的值。


這就是為什麼 bindgen 是更安全的選擇

Bindgen 如何避免這些陷阱

Bindgen 的核心原理是:在編譯時讀取實際的 C 標頭檔,使用 libclang 解析 AST,然後產生對應的 Rust 程式碼

工作流程

flowchart TD A["C 標頭檔 (avformat.h)"] --> B["libclang 解析"] B --> C["AST(抽象語法樹)"] C --> D["bindgen 轉換"] D --> E["Rust 綁定 (bindings.rs)"]

為什麼能避免手寫的陷阱

陷阱 Bindgen 如何解決
結構體大小 從 AST 讀取精確的 sizeof,產生正確大小的 Rust struct
欄位順序 按照 C 標頭檔中的宣告順序產生欄位
對齊問題 讀取每個欄位的 alignof,自動加入正確的 #[repr(C)] 和 padding
列舉值 直接從標頭檔讀取每個列舉的數值
版本相依 每次編譯時重新產生,自動適應安裝的函式庫版本

實際產生的程式碼

當 bindgen 處理 AVRational 時:

// bindgen 產生的程式碼(自動)
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct AVRational {
    pub num: ::std::os::raw::c_int,  // 順序正確
    pub den: ::std::os::raw::c_int,
}

對於複雜的結構體如 AVFormatContext

// bindgen 產生數百行,包含所有欄位
#[repr(C)]
pub struct AVFormatContext {
    pub av_class: *const AVClass,
    pub iformat: *const AVInputFormat,
    pub oformat: *const AVOutputFormat,
    pub priv_data: *mut ::std::os::raw::c_void,
    pub pb: *mut AVIOContext,
    pub ctx_flags: ::std::os::raw::c_int,
    pub nb_streams: ::std::os::raw::c_uint,
    pub streams: *mut *mut AVStream,
    // ... 還有幾十個欄位 ...
}

Bindgen 的限制

Bindgen 不是萬能的,它無法解決

1. 跨版本相容性

編譯時:FFmpeg 6.0 安裝 → bindings.rs 對應 FFmpeg 6.0
執行時:FFmpeg 7.0 安裝 → ABI 不相容 → 未定義行為

解決方案: 重新編譯,或使用版本檢查。

2. 指標生命週期

Bindgen 只產生型別定義,不會幫你管理生命週期:

// bindgen 產生的函式簽名
pub fn avformat_open_input(
    ps: *mut *mut AVFormatContext,
    url: *const ::std::os::raw::c_char,  // 誰負責這個指標的生命週期?
    // ...
) -> ::std::os::raw::c_int;

解決方案: 在 safe wrapper 層處理。

3. 語意正確性

Bindgen 確保 ABI 正確,但不保證你正確使用 API:

// 這段程式碼 ABI 正確,但語意錯誤
let ctx = avformat_open_input(...);
// 忘記呼叫 avformat_find_stream_info()
let streams = (*ctx).nb_streams;  // 可能是 0 或垃圾值

解決方案: 閱讀文件,或使用 safe wrapper 強制正確的呼叫順序。

結論:三層防護

理想的 FFI 架構是三層:

block-beta columns 1 block:layer1 A["Safe Wrapper(語意正確性)← 人類設計"] end block:layer2 B["Bindgen 綁定(ABI 正確性)← 工具產生"] end block:layer3 C["C 函式庫(實際實作)← 外部依賴"] end
  • Bindgen 確保 Rust 和 C 之間的 ABI 匹配
  • Safe Wrapper 確保 API 被正確使用
  • 人類 負責設計 wrapper 的 API 和處理邊界情況

手寫 FFI 適合學習底層原理,但在生產環境中,bindgen + safe wrapper 的組合才是正確的選擇。

Safe Wrapper 的設計原則

Safe wrapper 是 FFI 的最後一道防線,設計得好可以讓使用者完全不需要接觸 unsafe。以下是幾個關鍵原則:

1. RAII:資源獲取即初始化

原則: 用 Rust 的所有權系統管理 C 資源的生命週期。

pub struct FormatContext {
    ptr: *mut AVFormatContext,  // C 資源
}

impl FormatContext {
    pub fn open(path: &str) -> Result<Self> {
        let mut ptr = std::ptr::null_mut();
        let ret = unsafe { avformat_open_input(&mut ptr, ...) };
        if ret < 0 {
            return Err(AvError::from_code(ret));
        }
        Ok(FormatContext { ptr })  // 獲取資源
    }
}

impl Drop for FormatContext {
    fn drop(&mut self) {
        unsafe { avformat_close_input(&mut self.ptr); }  // 自動釋放
    }
}

效果: 使用者不可能忘記釋放資源,編譯器保證。

2. 類型狀態模式:編譯時強制正確順序

原則: 用不同的類型表示不同的狀態,讓編譯器阻止錯誤的呼叫順序。

// 未初始化的 context
pub struct UninitializedContext { ptr: *mut AVFormatContext }

// 已讀取 stream info 的 context
pub struct ReadyContext { ptr: *mut AVFormatContext }

impl UninitializedContext {
    pub fn find_stream_info(self) -> Result<ReadyContext> {
        let ret = unsafe { avformat_find_stream_info(self.ptr, ...) };
        if ret < 0 {
            return Err(...);
        }
        Ok(ReadyContext { ptr: self.ptr })
    }
}

impl ReadyContext {
    // 只有 ReadyContext 才能讀取 packets
    pub fn read_packet(&mut self) -> Result<Packet> { ... }
}

效果: 不可能在 find_stream_info() 之前呼叫 read_packet(),編譯器會報錯。

3. 借用而非擁有:避免不必要的複製

原則: 對於 C 結構體內的資料,返回借用而非複製。

impl Packet {
    // 返回借用,避免複製整個 packet 資料
    pub fn data(&self) -> Option<&[u8]> {
        unsafe {
            let ptr = (*self.ptr).data;
            let size = (*self.ptr).size;
            if ptr.is_null() || size <= 0 {
                None
            } else {
                Some(std::slice::from_raw_parts(ptr, size as usize))
            }
        }
    }
}

效果: 零複製存取,效能與直接使用 C API 相同。

4. 錯誤類型化:用 Rust 的 Result 取代錯誤碼

原則: 把 C 的錯誤碼轉換成有意義的 Rust 錯誤類型。

#[derive(Debug, thiserror::Error)]
pub enum AvError {
    #[error("End of file")]
    Eof,

    #[error("Failed to open input: {0}")]
    OpenInput(String),

    #[error("FFmpeg error ({code}): {message}")]
    Ffmpeg { code: i32, message: String },
}

impl AvError {
    pub fn from_code(code: i32) -> Self {
        if code == AVERROR_EOF {
            return AvError::Eof;
        }
        let message = get_error_string(code);
        AvError::Ffmpeg { code, message }
    }
}

效果: 使用者可以用 match 處理特定錯誤,IDE 有自動完成。

為什麼用 thiserror?

thiserror 是定義錯誤類型的最佳選擇,原因如下:

1. 自動實作 std::error::Error

// 手寫需要這些
impl std::fmt::Display for AvError { ... }
impl std::error::Error for AvError { ... }

// thiserror 一行搞定
#[derive(thiserror::Error)]

2. 錯誤訊息內嵌在類型定義中

#[derive(Debug, thiserror::Error)]
pub enum AvError {
    #[error("Failed to open '{path}': {reason}")]
    OpenInput { path: String, reason: String },

    #[error("Codec not found: {0}")]
    CodecNotFound(String),
}

程式碼和文件在同一處,不會不同步。

3. 支援錯誤鏈(Error Source)

#[derive(Debug, thiserror::Error)]
pub enum AvError {
    #[error("IO error")]
    Io(#[from] std::io::Error),  // 自動實作 From<std::io::Error>

    #[error("Invalid path: {0}")]
    InvalidPath(#[source] std::ffi::NulError),  // 保留原始錯誤
}

使用者可以用 .source() 追溯錯誤來源。

4. 與 anyhow 完美搭配

// 函式庫用 thiserror 定義具體錯誤
#[derive(thiserror::Error)]
pub enum AvError { ... }

// 應用程式用 anyhow 統一處理
fn main() -> anyhow::Result<()> {
    let ctx = FormatContext::open("video.mp4")?;  // AvError 自動轉換
    Ok(())
}

5. 零執行時開銷

thiserror 是純粹的 proc-macro,所有程式碼在編譯時產生,執行時沒有任何額外成本。

比較:不同錯誤處理方式

方式 優點 缺點
返回 i32 錯誤碼 與 C API 一致 無類型安全、難以理解
手寫 Error trait 完全控制 樣板程式碼多
thiserror 簡潔、類型安全 需要依賴(但很輕量)
anyhow::Error 最簡單 丟失具體類型,不適合函式庫

結論: 函式庫應該用 thiserror 定義具體錯誤類型,讓使用者可以精確處理每種錯誤情況。

5. 隱藏指標:不暴露原始指標給使用者

原則: 所有原始指標都應該是 struct 的私有欄位。

pub struct FormatContext {
    ptr: *mut AVFormatContext,  // 私有!
}

impl FormatContext {
    // 提供安全的存取方法
    pub fn nb_streams(&self) -> usize {
        unsafe { (*self.ptr).nb_streams as usize }
    }

    // 如果真的需要原始指標(進階使用),標記為 unsafe
    pub unsafe fn as_ptr(&self) -> *mut AVFormatContext {
        self.ptr
    }
}

效果: 一般使用者完全不需要寫 unsafe

6. 迭代器模式:讓集合存取符合 Rust 慣例

原則: 用迭代器取代 C 風格的索引存取。

impl FormatContext {
    pub fn streams(&self) -> impl Iterator<Item = StreamInfo> + '_ {
        (0..self.nb_streams()).map(move |i| self.stream_info(i).unwrap())
    }
}

// 使用方式
for stream in ctx.streams() {
    println!("Stream {}: {:?}", stream.index, stream.media_type);
}

效果: 符合 Rust 慣例,可以用 filtermap 等方法鏈。

7. 文件化不變量:清楚說明 Safety 條件

原則: 即使是 safe 函式,也要說明前提條件和可能的 panic。

impl FormatContext {
    /// Read the next packet from the container.
    ///
    /// # Returns
    /// - `Ok(true)` if a packet was read
    /// - `Ok(false)` if end of file reached
    /// - `Err(...)` if an error occurred
    ///
    /// # Panics
    /// Never panics. All errors are returned as `Err`.
    pub fn read_packet(&mut self, packet: &mut Packet) -> Result<bool> {
        // ...
    }
}

設計檢查清單

設計 safe wrapper 時,問自己這些問題:

問題 如果答案是「否」
使用者需要寫 unsafe 嗎? 提供更高層的 API
使用者可能忘記釋放資源嗎? 實作 Drop
使用者可能呼叫順序錯誤嗎? 使用類型狀態模式
使用者可能傳入無效參數嗎? 在建構時驗證
錯誤訊息有意義嗎? 定義專屬的錯誤類型
API 符合 Rust 慣例嗎? 參考標準庫的設計

好的 safe wrapper 讓使用者感覺像在用純 Rust 函式庫,完全不知道底下是 C。

AI Coding Agent 與 FFI 開發

Bindgen + Safe Wrapper 的組合是最佳實踐,但過去有個問題:太繁瑣了

設定 build.rs、處理 pkg-config、設計 wrapper API、實作 Drop、寫文件、寫測試… 這些工作加起來可能比實際的業務邏輯還多。這也是為什麼很多人選擇「先用 unsafe 頂著,以後再說」。

AI coding agent 改變了這個權衡。

AI 擅長的 FFI 任務

1. Build Script 設定

告訴 AI:「用 bindgen 綁定 libavformat,只需要這幾個函式…」

AI 會產生完整的 build.rs

// AI 產生,包含 pkg-config 偵測、bindgen 設定、錯誤處理
fn main() {
    let lib = pkg_config::probe_library("libavformat")
        .expect("libavformat not found");

    let bindings = bindgen::Builder::default()
        .header_contents("wrapper.h", r#"#include <libavformat/avformat.h>"#)
        .allowlist_function("avformat_open_input")
        .allowlist_function("avformat_find_stream_info")
        // ... 完整設定
        .generate()
        .expect("Failed to generate bindings");

    // 輸出到正確位置
    bindings.write_to_file(out_path.join("bindings.rs")).unwrap();
}

手寫這個需要查文件、試錯、處理邊界情況。AI 幾秒鐘搞定。

2. Safe Wrapper 骨架

告訴 AI:「為 AVFormatContext 建立 safe wrapper,實作 RAII」

AI 會產生符合所有設計原則的程式碼:

pub struct FormatContext {
    ptr: *mut AVFormatContext,
}

impl FormatContext {
    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> { ... }
    pub fn nb_streams(&self) -> usize { ... }
    pub fn streams(&self) -> impl Iterator<Item = StreamInfo> + '_ { ... }
}

impl Drop for FormatContext {
    fn drop(&mut self) { ... }
}

包含:

  • 正確的生命週期標註
  • Result 錯誤處理
  • 迭代器 API
  • Drop 實作

3. 錯誤類型定義

告訴 AI:「用 thiserror 定義錯誤類型,涵蓋 FFmpeg 的錯誤碼」

#[derive(Debug, thiserror::Error)]
pub enum AvError {
    #[error("End of file")]
    Eof,

    #[error("Failed to open input: {0}")]
    OpenInput(String),

    #[error("FFmpeg error ({code}): {message}")]
    Ffmpeg { code: i32, message: String },
}

4. 文件和測試

AI 自動產生:

  • /// # Safety 段落
  • 參數和返回值說明
  • 基本的單元測試
  • 使用範例

這些在手動開發時往往被省略,但對使用者很重要。

人類的角色

AI 不是萬能的。人類仍然負責:

任務 為什麼需要人類
決定要綁定哪些函式 需要理解業務需求
審查 API 設計 確保符合 Rust 慣例和使用者期望
驗證 ABI 正確性 編譯成功不代表執行時正確
處理邊界情況 AI 可能遺漏特殊情況
效能調校 需要實際測量和分析

實際工作流程

flowchart TD A["1. 人類:「綁定 libavformat 的 open/read/close」"] --> B["2. AI:產生 Cargo.toml、build.rs、bindgen 設定"] B --> C["3. 人類:cargo build,確認編譯成功"] C --> D["4. AI:產生 safe wrapper(FormatContext, Packet...)"] D --> E["5. 人類:審查 API,要求修改(「加上迭代器」)"] E --> F["6. AI:修改 + 補充文件和測試"] F --> G["7. 人類:用真實檔案測試,確認功能正確"]

整個過程可能只需要 30 分鐘,而不是以前的一整天。

為什麼 FFI 特別適合 AI 輔助

  1. 模式明確:bindgen 設定、RAII wrapper、錯誤處理都有標準模式
  2. 正確性可驗證:能編譯、能執行、測試通過
  3. 重複性高:每個函式的 wrapper 結構類似
  4. 文件密集:需要大量的 Safety 說明和使用範例

AI 處理這些機械性工作,人類專注於設計決策和驗證。

結論

FFI 開發的最佳實踐是 bindgen + safe wrapper

  • Bindgen 確保 ABI 正確性(結構體大小、欄位順序、對齊)
  • Safe Wrapper 確保語意正確性(資源管理、呼叫順序、錯誤處理)

過去這個組合太繁瑣,很多人選擇捷徑。現在有了 AI coding agent,完整實作的成本大幅降低。

不要再「先用 unsafe 頂著」了。讓 AI 幫你產生正確的 bindgen + safe wrapper,你只需要審查和測試。

這就是 AI 時代的 FFI 開發:人類做設計決策,AI 做繁瑣實作,最終得到安全、符合慣例的 Rust 綁定。


專案原始碼:rust-52-projects/libavformat-ffi