WEBKT

Rust FFI 避坑指南:深入剖析导致 Segment Fault 的三大“夺命”操作

2 0 0 0

在 Rust 的世界里,“内存安全”是编译器给我们的承诺。然而,当你跨过 unsafe 大门,通过 FFI(外部函数接口)与 C 语言或 JavaScript (Node-API/Wasm) 交互时,这个承诺会瞬间失效。FFI 就像是在两座摩天大楼之间拉起的一根钢丝绳,稍有不慎,程序就会因为 Segmentation fault 而崩塌。

在长期的底层开发实践中,我们总结了三个最容易导致段错误的“夺命”操作,即使是经验丰富的 Rustacean 也可能在此翻车。

1. 致命的“临时变量”:CString 的悬空指针

这是 FFI 新手最常掉进去的坑。假设你需要将一个 Rust 字符串传递给 C 函数:

// 错误示例
extern "C" {
    fn process_data(s: *const c_char);
}

fn call_c_logic() {
    let my_string = String::from("Hello FFI");
    // 错误点:CString::new(my_string).unwrap().as_ptr() 
    unsafe {
        process_data(CString::new(my_string).unwrap().as_ptr());
    }
}

为什么会崩?
CString::new() 创建了一个临时的 CString 对象。紧接着调用的 .as_ptr() 获取了指向该对象内部缓冲区的指针。然而,这个临时的 CString 在整行语句执行完后会被立即 Drop(析构)。当 C 函数 process_data 试图读取这个指针时,它指向的是一块已经被释放或可能被重新分配的内存。

正确姿势:
必须确保 CString 的生命周期覆盖整个 C 函数的调用过程。

let c_str = CString::new("Hello FFI").unwrap();
unsafe {
    process_data(c_str.as_ptr());
} // c_str 在这里才被 Drop

2. 所有权的迷失:跨语言的 Box 释放

在 Rust 侧分配一块内存(如 Box<T>),然后传给 C 维护,这是常见的需求。但如果你直接传递原始指针,往往会导致内存泄漏或重复释放。

崩溃场景:
C 侧代码习惯于调用 free() 来释放内存,但 Rust 的 Box 使用的是自己的分配器(通常是 jemalloc 或系统分配器),直接用 C 的 free 去释放 Rust 的指针,行为是未定义的。

深度避坑:
如果你要把 Rust 对象交给 C 管理,必须使用 Box::into_raw 来放弃所有权,并由 Rust 侧提供一个专门的销毁函数。

#[no_mangle]
pub extern "C" fn create_context() -> *mut Context {
    let ctx = Box::new(Context::new());
    Box::into_raw(ctx) // 显式放弃所有权,防止 Rust 侧 Drop
}

#[no_mangle]
pub extern "C" fn free_context(ptr: *mut Context) {
    if ptr.is_null() { return; }
    unsafe {
        // 重新包装回 Box,利用 Rust 的 Scope Drop 来自动释放内存
        let _ = Box::from_raw(ptr);
    }
}

注意: 绝对不要在 JS/C 侧缓存该指针并试图在多处释放。


3. ABI 布局的谎言:忽视 #[repr(C)]

这是最隐蔽的段错误来源。Rust 的默认内存布局(repr(Rust))是不确定的,编译器为了优化可能会重排字段顺序。而 C 语言期望的是严格按照声明顺序排列的布局。

错误示例:

struct Data {
    id: u32,
    is_active: bool,
    value: u64,
}

extern "C" {
    fn update_data(d: *mut Data);
}

如果你将上面的 Data 结构体传给 C,C 编译器可能会认为 is_active 后面有填充字节(Padding)以对齐 u64,而 Rust 编译器可能为了节省空间把 idis_active 紧密排列,甚至调换顺序。

结果: C 函数访问 value 字段时,实际上读取的是错误的偏移地址,轻则逻辑错误,重则非法地址访问导致 Segfault。

正确姿势:
所有需要跨 FFI 传递的结构体,必须强制使用 #[repr(C)]

#[repr(C)]
struct Data {
    id: u32,
    is_active: bool,
    value: u64,
}

此外,对于包含 Option<&T>bool 的结构体,要格外小心。bool 在 Rust 中占 1 字节,但在某些老旧 C 编译器中可能是 4 字节。建议在 FFI 接口中使用 u8 代替 bool

总结

FFI 是 Rust 的力量延伸,也是安全防线的终点。要避免段错误,请时刻铭记:

  1. 生命周期:确保 CString 或 Buffer 在 C 调用结束前不被 Drop。
  2. 所有权循环:谁申请谁释放,通过 into_rawfrom_raw 建立明确的契约。
  3. 布局一致性:永远不要在没有 #[repr(C)] 的情况下向外部传递结构体。

处理 FFI 时,要把每一行代码都当成可能引爆系统的地雷去审视,这才是系统级程序员应有的敬畏之心。

硬核架构师 RustFFI内存安全

评论点评