iT邦幫忙

2025 iThome 鐵人賽

DAY 23
0
Rust

Rust 逼我成為更好的工程師:從 Borrow Checker 看軟體設計系列 第 23

(Day23) Rust FFI 的邊界:不信任的世界與安全外殼

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250917/20124462KA2M7PfuNm.png

Rust 逼我成為更好的工程師:FFI 的邊界:不信任的世界與安全外殼

在前面的篇章中,我們深入理解了 Rust 如何在型別系統中確保記憶體安全。

今天我們要面對一個殘酷的現實:當 Rust 需要與 C 語言互動時,所有的安全保證都會在邊界上消失

FFI (Foreign Function Interface) 是 Rust 與外部世界的橋樑,也是最容易出錯的地方,也就是程式碼的「海關」。

如果你的 Rust 程式碼是一個國家,有自己的法律(型別系統、借用檢查),國民(安全的 Rust 程式碼)都遵守這些法律,所以國內秩序井然。

現在,你需要從另一個國家——像是「C 語言共和國」——進口一些貨物(呼叫一個 C 函式庫,比如 OpenSSL)。

你不能直接開車過去把東西拿回來。你必須通過海關:

  1. 語言要通 (Calling Convention):在海關,你得說官員能聽懂的話。在程式碼裡,這意味著你的 Rust 程式碼必須知道如何把參數(數據)放進正確的 CPU 暫存器或堆疊上,好讓 C 函式能理解。這就是所謂的「呼叫慣例」,extern "C" 就是在告訴 Rust 編譯器:「我們要跟 C 國的海關打交道,用他們的規矩來溝通。」

  2. 貨物格式要對 (Data Layout / ABI):你不能把一個 Rust 的 String 物件(一個包含指標、長度、容量的複雜結構)直接丟給 C。C 國的海關只認識一種貨物:一串以零結尾的字元陣列指標 (*const c_char)。所以你必須把你的貨物打包成對方能識別的格式。這就是為什麼你需要 CString#[repr(C)]#[repr(C)] 就是在對你的資料結構蓋章,保證它的記憶體佈局和 C 國的標準一模一樣。

  3. 責任要清楚 (Memory Management):這是最關鍵的。 如果你從 C 國申請了一塊地(malloc),C 國海關不會幫你記得去歸還。你必須自己拿著地契,在用完之後親自去辦理退還手續(free)。如果你把自己的地借給 C 國用(傳遞指標),你就要保證在對方用完之前,你不會先把地給賣了(drop 掉記憶體),否則對方就會撲個空,然後天下大亂(懸空指標導致的崩潰)。

所以,FFI 的本質就是一個契約。

一個在兩種不同「法律體系」(程式語言)之間簽訂的、關於如何安全交換數據和控制權的契約。

而 unsafe 區塊,就是你親筆簽下這份契約的地方。你是在告訴編譯器:

「我知道這裡要出國了。我知道你(編譯器)的法律管不到外面。從現在起,我,程式設計師本人,負全部責任,保證我會遵守對方的規矩,不出亂子。」

FFI 不是什麼神奇的技術。
它就是程式設計裡那個髒活、累活,是處理歷史遺留問題、與現實世界妥協的必要手段。
它是成年人的世界,自己要為自己的行為負責。

FFI:重新引入「不信任」

為什麼需要 FFI?

現實世界中,我們無法避免與 C 語言互動:

  1. 系統呼叫:作業系統的 API 都是 C 介面
  2. 既有函式庫:大量成熟的 C 函式庫(OpenSSL、SQLite、libcurl)
  3. 效能關鍵路徑:某些底層操作需要直接操作硬體
  4. 跨語言整合:Python、Ruby、Node.js 都可以透過 C 介面呼叫 Rust

但這也意味著:在 FFI 邊界上,Rust 的所有安全保證都失效了

FFI 的本質問題

// 這是一個 C 函式的宣告
extern "C" {
    fn dangerous_c_function(ptr: *mut u8, len: usize) -> i32;
}

// 呼叫它是 unsafe 的
fn call_c_code() {
    let mut buffer = vec![0u8; 1024];
    unsafe {
        // 編譯器無法檢查:
        // 1. ptr 是否有效?
        // 2. len 是否正確?
        // 3. C 函式會不會寫入超過 len 的資料?
        // 4. C 函式會不會保留這個指標?
        dangerous_c_function(buffer.as_mut_ptr(), buffer.len());
    }
}

關鍵洞察:FFI 邊界是 Rust 安全模型的破口,必須用「安全外殼」包裝。

裸指標:回到 C 的世界

裸指標 vs 引用

fn pointer_comparison() {
    let x = 42;
    
    // 安全的引用:編譯器保證有效性
    let safe_ref: &i32 = &x;
    println!("安全引用: {}", safe_ref);
    
    // 裸指標:編譯器不保證任何事
    let raw_ptr: *const i32 = &x as *const i32;
    
    // 解引用裸指標是 unsafe 的
    unsafe {
        println!("裸指標: {}", *raw_ptr);
    }
}

裸指標的特性

  1. 可以為 null:不像引用,裸指標可以是空指標
  2. 不保證有效性:可能指向已釋放的記憶體
  3. 不遵守借用規則:可以同時有多個可變裸指標
  4. 不自動清理:不會觸發 Drop

裸指標的使用場景

use std::ptr;

// 場景 1:與 C 介面互動
extern "C" {
    fn c_malloc(size: usize) -> *mut u8;
    fn c_free(ptr: *mut u8);
}

fn allocate_from_c() -> *mut u8 {
    unsafe {
        let ptr = c_malloc(1024);
        if ptr.is_null() {
            panic!("記憶體分配失敗");
        }
        ptr
    }
}

// 場景 2:實作底層資料結構
struct Node {
    value: i32,
    next: *mut Node,  // 裸指標避免循環引用問題
}

impl Node {
    fn new(value: i32) -> *mut Node {
        Box::into_raw(Box::new(Node {
            value,
            next: ptr::null_mut(),
        }))
    }
}

repr(C):記憶體佈局的承諾

為什麼需要 repr(C)?

// Rust 的預設佈局是未定義的
struct RustLayout {
    a: u8,
    b: u32,
    c: u16,
}
// 編譯器可能重排欄位以優化記憶體使用

// C 相容的佈局
#[repr(C)]
struct CLayout {
    a: u8,
    b: u32,
    c: u16,
}
// 欄位順序與 C 結構體完全相同

實際案例:與 C 函式庫互動

// C 端的結構體定義
// typedef struct {
//     uint32_t id;
//     uint8_t status;
//     char name[32];
// } Device;

#[repr(C)]
struct Device {
    id: u32,
    status: u8,
    name: [u8; 32],
}

extern "C" {
    fn get_device_info(device: *mut Device) -> i32;
}

fn query_device() -> Result<Device, String> {
    let mut device = Device {
        id: 0,
        status: 0,
        name: [0; 32],
    };
    
    unsafe {
        let result = get_device_info(&mut device as *mut Device);
        if result == 0 {
            Ok(device)
        } else {
            Err("查詢失敗".to_string())
        }
    }
}

安全外殼:RAII 與 Drop

問題:誰負責釋放記憶體?

// 糟糕的設計:記憶體洩漏風險
extern "C" {
    fn create_resource() -> *mut Resource;
    fn destroy_resource(ptr: *mut Resource);
}

fn bad_usage() {
    unsafe {
        let resource = create_resource();
        // 使用 resource...
        // 如果這裡發生 panic,destroy_resource 不會被呼叫
        destroy_resource(resource);
    }
}

解決方案:用 RAII 包裝

// 好的設計:自動管理生命週期
struct SafeResource {
    ptr: *mut Resource,
}

impl SafeResource {
    fn new() -> Result<Self, String> {
        unsafe {
            let ptr = create_resource();
            if ptr.is_null() {
                Err("建立資源失敗".to_string())
            } else {
                Ok(SafeResource { ptr })
            }
        }
    }
    
    // 提供安全的方法
    fn do_something(&self) -> Result<(), String> {
        unsafe {
            // 內部使用 unsafe,但對外是安全的
            let result = use_resource(self.ptr);
            if result == 0 {
                Ok(())
            } else {
                Err("操作失敗".to_string())
            }
        }
    }
}

impl Drop for SafeResource {
    fn drop(&mut self) {
        unsafe {
            if !self.ptr.is_null() {
                destroy_resource(self.ptr);
            }
        }
    }
}

// 使用時完全安全
fn safe_usage() -> Result<(), String> {
    let resource = SafeResource::new()?;
    resource.do_something()?;
    // Drop 自動呼叫,即使發生 panic
    Ok(())
}

字串的邊界問題

C 字串 vs Rust 字串

use std::ffi::{CStr, CString};
use std::os::raw::c_char;

extern "C" {
    fn c_print_string(s: *const c_char);
    fn c_return_string() -> *const c_char;
}

// Rust -> C:需要轉換成 null-terminated 字串
fn pass_string_to_c() {
    let rust_string = "Hello, C!";
    
    // 轉換成 C 字串
    let c_string = CString::new(rust_string).expect("CString 轉換失敗");
    
    unsafe {
        c_print_string(c_string.as_ptr());
    }
    // c_string 在這裡被 Drop,記憶體被釋放
}

// C -> Rust:需要處理 null-terminated 字串
fn get_string_from_c() -> String {
    unsafe {
        let c_str_ptr = c_return_string();
        
        // 檢查 null
        if c_str_ptr.is_null() {
            return String::new();
        }
        
        // 轉換成 Rust 字串
        let c_str = CStr::from_ptr(c_str_ptr);
        c_str.to_string_lossy().into_owned()
    }
}

關鍵陷阱

// 危險:生命週期問題
fn dangerous_string_passing() {
    let rust_string = "temporary";
    let c_string = CString::new(rust_string).unwrap();
    
    unsafe {
        // 危險!c_string 會在函式結束時被 Drop
        some_c_function_that_stores_pointer(c_string.as_ptr());
    }
    // c_string 被 Drop,C 端的指標變成懸空指標
}

// 安全:明確生命週期
fn safe_string_passing() {
    let c_string = CString::new("persistent").unwrap();
    
    unsafe {
        // 轉移所有權給 C 端
        let ptr = c_string.into_raw();
        some_c_function_that_stores_pointer(ptr);
        
        // 稍後必須手動釋放
        // let _ = CString::from_raw(ptr);
    }
}

PhantomData:型別系統的幽靈

為什麼需要 PhantomData?

use std::marker::PhantomData;

// 問題:這個結構體沒有使用 T
struct Wrapper<T> {
    ptr: *mut u8,
    // 編譯器不知道這個指標實際上指向 T
}

// 解決方案:用 PhantomData 告訴編譯器
struct SafeWrapper<T> {
    ptr: *mut T,
    _marker: PhantomData<T>,  // 零大小,純型別標記
}

impl<T> SafeWrapper<T> {
    fn new(value: T) -> Self {
        SafeWrapper {
            ptr: Box::into_raw(Box::new(value)),
            _marker: PhantomData,
        }
    }
    
    fn get(&self) -> &T {
        unsafe { &*self.ptr }
    }
}

impl<T> Drop for SafeWrapper<T> {
    fn drop(&mut self) {
        unsafe {
            // 正確地呼叫 T 的 Drop
            let _ = Box::from_raw(self.ptr);
        }
    }
}

實戰案例:包裝 SQLite

use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int, c_void};
use std::ptr;

// C 介面宣告
extern "C" {
    fn sqlite3_open(filename: *const c_char, ppDb: *mut *mut c_void) -> c_int;
    fn sqlite3_close(pDb: *mut c_void) -> c_int;
    fn sqlite3_exec(
        pDb: *mut c_void,
        sql: *const c_char,
        callback: *mut c_void,
        arg: *mut c_void,
        errmsg: *mut *mut c_char,
    ) -> c_int;
}

// 安全包裝
pub struct Database {
    db: *mut c_void,
}

impl Database {
    pub fn open(path: &str) -> Result<Self, String> {
        let c_path = CString::new(path).map_err(|e| e.to_string())?;
        let mut db = ptr::null_mut();
        
        unsafe {
            let result = sqlite3_open(c_path.as_ptr(), &mut db);
            if result == 0 {
                Ok(Database { db })
            } else {
                Err("無法開啟資料庫".to_string())
            }
        }
    }
    
    pub fn execute(&self, sql: &str) -> Result<(), String> {
        let c_sql = CString::new(sql).map_err(|e| e.to_string())?;
        
        unsafe {
            let result = sqlite3_exec(
                self.db,
                c_sql.as_ptr(),
                ptr::null_mut(),
                ptr::null_mut(),
                ptr::null_mut(),
            );
            
            if result == 0 {
                Ok(())
            } else {
                Err("SQL 執行失敗".to_string())
            }
        }
    }
}

impl Drop for Database {
    fn drop(&mut self) {
        unsafe {
            sqlite3_close(self.db);
        }
    }
}

// 使用時完全安全
fn use_database() -> Result<(), String> {
    let db = Database::open("test.db")?;
    db.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY)")?;
    Ok(())
}

總結:FFI 的設計原則

1. 最小化 unsafe 邊界

// 糟糕:unsafe 洩漏到使用端
pub fn bad_api() -> *mut Resource {
    unsafe { create_resource() }
}

// 好的:unsafe 被封裝在內部
pub fn good_api() -> Result<SafeResource, String> {
    SafeResource::new()
}

2. 明確所有權與生命週期

// 誰分配?誰釋放?
pub struct OwnedResource {
    ptr: *mut T,
}

pub struct BorrowedResource<'a> {
    ptr: *const T,
    _marker: PhantomData<&'a T>,
}

3. 用 RAII 管理資源

impl Drop for Resource {
    fn drop(&mut self) {
        // 確保資源被正確釋放
    }
}

4. 驗證 C 端的假設

unsafe {
    let ptr = c_function();
    if ptr.is_null() {
        return Err("null 指標");
    }
    // 使用 ptr
}

FFI 是 Rust 安全模型的邊界:透過「安全外殼」設計模式,我們可以把 unsafe 限制在最小範圍內,對外提供完全安全的 API。

在 FFI 邊界上,信任必須被重新建立,而不是被假設

相關連結與參考資源


上一篇
(Day22) Rust 零成本抽象與效能剖析:先對,再快
下一篇
(Day24) Rust 錯誤處理進階:thiserror、anyhow 與邊界策略
系列文
Rust 逼我成為更好的工程師:從 Borrow Checker 看軟體設計27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言