Rust 的 FFI(Foreign Function Interface)讓我們可以呼叫 C 函式庫,聽起來很美好,但要正確實作實在是不容易。手寫綁定容易出錯,維護成本又高;bindgen 雖然能自動產生綁定,可是用起來還是得碰 unsafe;safe wrapper 提供了安全的 API,但設計起來頗繁瑣。三條路各有各的痛點。
這篇我想以綁定 FFmpeg 的 libavformat 為例,從頭到尾走一遍:
- 三種 FFI 方式的優缺點比較
- 手寫綁定的常見陷阱:結構體大小、欄位順序、對齊、生命週期…
- Bindgen 的工作原理:如何用 libclang 解析 C 標頭檔
- Safe Wrapper 設計原則:RAII、類型狀態、錯誤處理、thiserror
- 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 程式碼。重點就在「真正的」這三個字——它看的是實際安裝的標頭檔,而不是你憑印象猜的版本。
工作流程
為什麼能避免手寫的陷阱
| 陷阱 | 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 架構應該是這樣三層:
- 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 慣例,filter、map 串成一條鏈就解決了。
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 可能遺漏特殊情況 |
| 效能調校 | 需要實際測量和分析 |
實際工作流程
這樣一輪走下來大概 30 分鐘就搞定,而不是以前那種搞一整天的光景。
為什麼 FFI 特別適合 AI 輔助
- 模式明確:bindgen 設定、RAII wrapper、錯誤處理都有標準模式
- 正確性可驗證:能編譯、能執行、測試通過
- 重複性高:每個函式的 wrapper 結構類似
- 文件密集:需要大量的 Safety 說明和使用範例
機械性的苦工交給 AI,人類把腦力留給設計決策和驗證——我想這分工頗合理的。
結論
繞了一大圈,FFI 開發的最佳實踐其實就是 bindgen + safe wrapper 這兩層:
- Bindgen 顧好 ABI 正確性(結構體大小、欄位順序、對齊)
- Safe Wrapper 顧好語意正確性(資源管理、呼叫順序、錯誤處理)
以前這個組合太繁瑣,大家忍不住抄捷徑;現在有了 AI coding agent,把它完整做出來的成本實在是低太多了。
所以別再「先用 unsafe 頂著」了啦。讓 AI 幫你把 bindgen + safe wrapper 生出來,你只要負責審查跟測試就好。
這就是 AI 時代的 FFI 開發:人類做設計決策,AI 做繁瑣實作,最後得到一份安全、又符合慣例的 Rust 綁定。這買賣怎麼算都划得來吧。 :-)