土法炼钢兴趣小组的算法知识备份

Rust FFI 实战:当你不得不和 C 库打交道

目录

Rust 社区有句话:“Rewrite it in Rust”。但现实比口号骨感得多。

OpenSSL 有 70 万行 C 代码,SQLite 有 15 万行,Linux 内核有 2800 万行。你不可能把它们全部重写。即使你的项目是纯 Rust,只要你需要调用操作系统 API、链接一个 C 写的压缩库、或者嵌入一个脚本引擎,你就不得不跨越那条边界——FFI(Foreign Function Interface)

问题在于:Rust 的所有权系统在 FFI 边界彻底失效。一旦一个指针通过 extern "C" 传给 C 代码,borrow checker 就看不到了。*mut T 不携带生命周期,不携带所有权信息,不携带任何编译期保证。你在 C 那边对它做什么,Rust 完全不知道。

如果你还没读过 Unsafe Rust 完全指南,建议先看那篇。本文假设你已经理解 unsafe 块的基本语义。

Rust FFI 边界示意图

一、Rust FFI 基础

extern “C” 和调用约定

Rust 有自己的 ABI(Application Binary Interface),但这个 ABI 没有稳定性承诺。不同版本的 rustc 可能生成不同的函数调用约定、参数传递方式和结构体布局。所以跨语言调用必须使用 C ABI——它是事实标准。

声明一个外部 C 函数:

extern "C" {
    fn strlen(s: *const std::ffi::c_char) -> usize;
}

调用时必须包在 unsafe 里,因为编译器无法验证:

use std::ffi::CString;

fn main() {
    let s = CString::new("hello").expect("CString::new failed");
    let len = unsafe { strlen(s.as_ptr()) };
    println!("length = {len}"); // 5
}

#[repr(C)] 结构体布局

Rust 默认的结构体布局是未指定的——编译器可以自由重排字段顺序以优化对齐和大小。这意味着如果你把一个普通 Rust 结构体的指针传给 C,字段偏移量可能完全对不上。

// ❌ C 读到的字段偏移量可能是错的
struct Point {
    x: f64,
    y: f64,
    label: u8,
}

// ✅ 保证和 C 结构体的内存布局一致
#[repr(C)]
struct Point {
    x: f64,
    y: f64,
    label: u8,
}

基本类型映射

Rust 和 C 的基本类型大小不总是一样。int 在大多数平台上是 32 位,但这不是 C 标准保证的。std::ffi 模块提供了精确匹配:

C 类型 Rust 类型 说明
int c_int 通常 i32,但不保证
unsigned int c_uint 通常 u32
long c_long 平台相关:32 或 64 位
char c_char 可能是 i8 或 u8
size_t usize 指针大小的无符号整数
void* *mut c_void 无类型指针
const char* *const c_char C 字符串指针

一条黄金法则:永远不要用 i32 代替 c_int,即使在你的平台上它们恰好一样大。代码要表达意图,不是碰巧能跑。


二、bindgen:从 C 头文件生成 Rust 绑定

手写 extern "C" 绑定对于三五个函数还行,但面对一个有几百个函数和几十个结构体的 C 库,手写就是自杀。这就是 bindgen 存在的理由。

工作原理

bindgen 内部使用 libclang 解析 C/C++ 头文件,构建 AST(抽象语法树),然后把类型、函数、常量、枚举翻译成对应的 Rust 代码。它不是正则表达式匹配——它是真正的编译器前端解析。

build.rs 集成

标准做法是在 build.rs 中调用 bindgen:

// build.rs
fn main() {
    println!("cargo:rerun-if-changed=wrapper.h");

    let bindings = bindgen::Builder::default()
        .header("wrapper.h")
        .parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
        .generate()
        .expect("Unable to generate bindings");

    let out_path = std::path::PathBuf::from(
        std::env::var("OUT_DIR").unwrap()
    );
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("Couldn't write bindings!");
}

然后在 lib.rs 里引入:

#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]

include!(concat!(env!("OUT_DIR"), "/bindings.rs"));

那三行 #![allow(...)] 几乎是 bindgen 项目的标配——C 的命名风格和 Rust 不一样,你不会想去手动改几百个生成的名字。

常见配置

let bindings = bindgen::Builder::default()
    .header("wrapper.h")
    // 只生成以 mylib_ 开头的函数和类型
    .allowlist_function("mylib_.*")
    .allowlist_type("mylib_.*")
    // 把 C 的 int 类型映射为 i32(而非 c_int)
    .default_enum_style(bindgen::EnumVariation::Rust {
        non_exhaustive: true,
    })
    // 对所有结构体自动实现 Debug
    .derive_debug(true)
    // 屏蔽某些不需要的类型
    .blocklist_type("__.*")
    .generate()
    .unwrap();

allowlistblocklist 是你的武器:一个大型 C 库的头文件可能 include 了整个系统头文件树,你不需要为 struct stat 生成绑定。

处理 C 宏

bindgen 的一大痛点:C 宏。因为宏是预处理器展开的,不经过编译器前端,libclang 看不到函数式宏(function-like macros)的定义。

// bindgen 能处理这个:
#define MAX_BUFFER_SIZE 4096

// bindgen 处理不了这个:
#define ALIGN_UP(x, align) (((x) + (align) - 1) & ~((align) - 1))

常量宏会被翻译成 Rust 的 pub const,但函数式宏需要你手动翻译成 Rust 函数或 const fn

生成代码的质量

bindgen 生成的代码是正确但不优雅的。所有指针都是裸指针,没有 Option、没有 Result、没有生命周期标注。这是它的设计意图——它生成的是 -sys crate 级别的低层绑定,安全封装是你的事。


三、cbindgen:给 C 调用者提供 Rust 接口

反过来——你用 Rust 写了一个库,C/C++ 项目要调用它。这时需要 cbindgen。

#[no_mangle] 和 extern “C”

首先,你的 Rust 函数必须满足两个条件:

// #[no_mangle]:阻止 Rust 编译器修改符号名
// extern "C":使用 C 调用约定
#[no_mangle]
pub extern "C" fn mylib_create_engine(config_path: *const c_char) -> *mut Engine {
    // ...
}

没有 #[no_mangle],Rust 会把函数名 mangle 成类似 _ZN5mylib13create_engine17h8a3b... 这样的东西——C 的链接器找不到它。

cbindgen.toml 配置

# cbindgen.toml
language = "C"
header = "/* Auto-generated by cbindgen. Do not edit. */"
include_guard = "MYLIB_H"
autogen_warning = "/* Warning: this file is autogenerated by cbindgen. */"
tab_width = 4

[export]
include = ["Engine", "EngineConfig", "ErrorCode"]

[export.rename]
"ErrorCode" = "mylib_error_t"

运行命令:

cbindgen --config cbindgen.toml --crate mylib --output mylib.h

生成的头文件:

/* Auto-generated by cbindgen. Do not edit. */

#ifndef MYLIB_H
#define MYLIB_H

#include <stdint.h>

typedef struct Engine Engine;

typedef enum mylib_error_t {
    Ok = 0,
    InvalidInput = 1,
    IoError = 2,
} mylib_error_t;

struct Engine *mylib_create_engine(const char *config_path);

void mylib_destroy_engine(struct Engine *engine);

mylib_error_t mylib_process(struct Engine *engine,
                            const uint8_t *data,
                            uintptr_t len);

#endif /* MYLIB_H */

cdylib vs staticlib

Cargo.toml 中:

[lib]
crate-type = ["cdylib"]      # 生成 .so / .dylib / .dll
# 或者
crate-type = ["staticlib"]   # 生成 .a / .lib

cdylib 适合动态链接,但要注意:它会把 Rust 标准库静态链接进去,导致产物较大。staticlib 适合嵌入 C 项目,但需要链接时指定 Rust 的依赖(如 -lpthread -ldl -lm)。


四、内存所有权跨语言传递

这是 FFI 里最容易翻车的地方,没有之一。

如果你读过 所有权 vs RAII,你知道 Rust 的所有权系统在编译期保证「每块内存有且仅有一个 owner」。但在 FFI 边界,这个保证被打穿了。你必须自己维护一条铁律:

谁分配,谁释放。

Rust 分配的内存用 Rust 释放,C 分配的内存用 C 释放。不要用 free() 释放 Rust 的 Box,不要用 drop 释放 C 的 malloc。它们底层使用的 allocator 可能完全不同。

Box::into_raw / Box::from_raw

把 Rust 对象的所有权「借出」给 C:

// Rust 侧:创建对象,把所有权转移给 C
#[no_mangle]
pub extern "C" fn engine_new() -> *mut Engine {
    let engine = Box::new(Engine::default());
    Box::into_raw(engine) // 所有权转移:Rust 不再负责释放
}

// Rust 侧:C 用完后,把所有权还回来
#[no_mangle]
pub extern "C" fn engine_free(ptr: *mut Engine) {
    if ptr.is_null() {
        return;
    }
    // 重新获得所有权,函数结束时自动 drop
    unsafe { drop(Box::from_raw(ptr)); }
}

关键点:Box::into_raw 之后,这块内存就脱离了 Rust 的管辖。如果 C 那边忘记调用 engine_free,就是内存泄漏。如果 C 调用了两次 engine_free,就是 double-free——未定义行为。

CString / CStr

字符串是另一个雷区。Rust 的 String 是 UTF-8 编码、不以 null 结尾的。C 的字符串是以 null 结尾的 char*

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

// Rust → C:需要创建 CString(会追加 \0)
fn rust_to_c() {
    let rust_str = "hello world";
    let c_string = CString::new(rust_str).expect("内含 null 字节");
    let ptr: *const c_char = c_string.as_ptr();
    unsafe { some_c_function(ptr); }
    // c_string 在这里 drop,ptr 失效!
    // 如果 some_c_function 保存了 ptr 供之后使用 → use-after-free
}

// C → Rust:需要用 CStr 包装(不获取所有权)
#[no_mangle]
pub extern "C" fn process_name(name: *const c_char) {
    if name.is_null() {
        return;
    }
    let c_str = unsafe { CStr::from_ptr(name) };
    match c_str.to_str() {
        Ok(s) => println!("name = {s}"),
        Err(_) => eprintln!("invalid UTF-8"),
    }
    // 注意:我们没有获取 name 的所有权,不能释放它
}

回调函数中的生命周期陷阱

C 库经常需要你传入回调函数。这在 Rust 中意味着你要用函数指针,而函数指针捕获不了环境——除非你用 void* 传递上下文。

// C 的回调签名:
// typedef void (*callback_t)(void* user_data, int event_code);
// void register_callback(callback_t cb, void* user_data);

type CallbackFn = extern "C" fn(*mut c_void, c_int);

extern "C" {
    fn register_callback(cb: CallbackFn, user_data: *mut c_void);
}

struct MyContext {
    counter: u64,
    name: String,
}

extern "C" fn my_callback(user_data: *mut c_void, event_code: c_int) {
    let ctx = unsafe { &mut *(user_data as *mut MyContext) };
    ctx.counter += 1;
    println!("[{}] event {event_code}, count = {}", ctx.name, ctx.counter);
}

fn setup() {
    let mut ctx = Box::new(MyContext {
        counter: 0,
        name: "sensor-1".to_string(),
    });
    let ctx_ptr = &mut *ctx as *mut MyContext as *mut c_void;
    unsafe { register_callback(my_callback, ctx_ptr); }
    // ⚠️ 如果 ctx 在这里被 drop,C 库后续调用回调时就是 use-after-free!
    // 必须用 Box::into_raw 或 mem::forget 让 ctx 活够久
    std::mem::forget(ctx);
}

这种模式极其常见,也极其容易出错。你必须保证上下文对象的生命周期至少和回调的注册期一样长

安全封装模式

把所有的 unsafe 集中到一个地方,暴露安全的 API 给外部使用者:

pub struct SafeEngine {
    ptr: *mut ffi::Engine,
}

impl SafeEngine {
    pub fn new(config: &str) -> Result<Self, EngineError> {
        let c_config = CString::new(config)
            .map_err(|_| EngineError::InvalidConfig)?;
        let ptr = unsafe { ffi::engine_create(c_config.as_ptr()) };
        if ptr.is_null() {
            Err(EngineError::CreateFailed)
        } else {
            Ok(SafeEngine { ptr })
        }
    }

    pub fn process(&mut self, data: &[u8]) -> Result<(), EngineError> {
        let code = unsafe {
            ffi::engine_process(self.ptr, data.as_ptr(), data.len())
        };
        match code {
            0 => Ok(()),
            e => Err(EngineError::from_code(e)),
        }
    }
}

impl Drop for SafeEngine {
    fn drop(&mut self) {
        if !self.ptr.is_null() {
            unsafe { ffi::engine_destroy(self.ptr); }
            self.ptr = std::ptr::null_mut();
        }
    }
}

// 线程安全标注——只有在你确认 C 库是线程安全的时候才加
// unsafe impl Send for SafeEngine {}
// unsafe impl Sync for SafeEngine {}

使用者完全不需要写 unsafe

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut engine = SafeEngine::new("/etc/mylib.conf")?;
    engine.process(b"some data")?;
    Ok(())
    // engine 在这里自动 drop,C 资源自动释放
}

这就是 Rust FFI 的核心哲学:unsafe 不是禁忌,而是隔离区。你在最底层用 unsafe 和 C 打交道,然后在上面盖一层安全的 Rust API。整个 crate 只有底层那一小块是 unsafe 的。更多关于 unsafe 的设计哲学,参见 Unsafe Rust 完全指南


五、实战:为 C 库写安全的 Rust wrapper

我们以一个简化的 zlib 绑定为例,演示完整的 sys crate + safe wrapper 模式。

-sys crate:原始绑定

社区惯例是把 C 库的原始绑定放在一个 -sys crate 里。比如 libz-sys 只负责链接 zlib、暴露 C 函数签名,不做任何安全封装。

# libz-sys/Cargo.toml
[package]
name = "libz-sys"
version = "0.1.0"
links = "z"
build = "build.rs"

[build-dependencies]
bindgen = "0.71"
pkg-config = "0.3"
// libz-sys/build.rs
fn main() {
    // 用 pkg-config 找到系统的 zlib
    pkg_config::Config::new()
        .atleast_version("1.2.11")
        .probe("zlib")
        .expect("zlib not found");

    let bindings = bindgen::Builder::default()
        .header("wrapper.h") // 内容就一行:#include <zlib.h>
        .allowlist_function("compress.*|uncompress.*|deflate.*|inflate.*")
        .allowlist_type("z_stream.*")
        .allowlist_var("Z_OK|Z_STREAM_END|Z_BUF_ERROR|Z_MEM_ERROR")
        .parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
        .generate()
        .expect("bindgen failed");

    let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
    bindings.write_to_file(out.join("bindings.rs")).unwrap();
}

safe wrapper crate

-sys crate 之上,构建用户友好的 API:

// safe-zlib/src/lib.rs
use libz_sys as ffi;
use std::io;

pub fn compress(input: &[u8]) -> io::Result<Vec<u8>> {
    let mut dest_len: std::ffi::c_ulong =
        (input.len() + input.len() / 1000 + 12) as _;
    let mut dest = vec![0u8; dest_len as usize];

    let result = unsafe {
        ffi::compress(
            dest.as_mut_ptr(),
            &mut dest_len,
            input.as_ptr(),
            input.len() as _,
        )
    };

    match result {
        ffi::Z_OK => {
            dest.truncate(dest_len as usize);
            Ok(dest)
        }
        ffi::Z_MEM_ERROR => Err(io::Error::new(
            io::ErrorKind::OutOfMemory,
            "zlib: out of memory",
        )),
        ffi::Z_BUF_ERROR => Err(io::Error::new(
            io::ErrorKind::Other,
            "zlib: output buffer too small",
        )),
        code => Err(io::Error::new(
            io::ErrorKind::Other,
            format!("zlib: unexpected error code {code}"),
        )),
    }
}

注意这个 API 的特点:

  1. 输入是 &[u8],不是裸指针——编译器保证它有效
  2. 输出是 Result<Vec<u8>>——错误通过类型系统传播,不是检查返回值
  3. 内部的 unsafe 完全被封装——调用者不需要知道 zlib 的存在

错误处理:C 的错误码 → Rust 的 Result

C 库通常用两种方式报告错误:返回值和 errno。把它们统一翻译成 Result

use std::io;

#[derive(Debug, thiserror::Error)]
pub enum ZlibError {
    #[error("zlib: stream error")]
    Stream,
    #[error("zlib: data error (corrupted input)")]
    Data,
    #[error("zlib: out of memory")]
    Memory,
    #[error("zlib: buffer error")]
    Buffer,
    #[error("zlib: version mismatch")]
    Version,
    #[error("zlib: unknown error ({0})")]
    Unknown(i32),
}

impl ZlibError {
    fn from_code(code: i32) -> Self {
        match code {
            -2 => ZlibError::Stream,
            -3 => ZlibError::Data,
            -4 => ZlibError::Memory,
            -5 => ZlibError::Buffer,
            -6 => ZlibError::Version,
            other => ZlibError::Unknown(other),
        }
    }
}

Drop trait 实现自动资源释放

对于有状态的 C 对象(如 zlib 的 z_stream),用 Drop 实现 RAII:

pub struct Deflater {
    stream: Box<ffi::z_stream>,
    initialized: bool,
}

impl Deflater {
    pub fn new(level: i32) -> Result<Self, ZlibError> {
        let mut stream = Box::new(unsafe { std::mem::zeroed::<ffi::z_stream>() });
        let ret = unsafe {
            ffi::deflateInit_(
                &mut *stream,
                level,
                ffi::zlibVersion(),
                std::mem::size_of::<ffi::z_stream>() as _,
            )
        };
        if ret != ffi::Z_OK {
            return Err(ZlibError::from_code(ret));
        }
        Ok(Deflater { stream, initialized: true })
    }
}

impl Drop for Deflater {
    fn drop(&mut self) {
        if self.initialized {
            unsafe { ffi::deflateEnd(&mut *self.stream); }
            self.initialized = false;
        }
    }
}

这就是 所有权 vs RAII 那篇文章讨论的模式在 FFI 场景下的应用:Rust 的 Drop 保证了即使发生 panic 或提前 return,C 的资源也会被正确释放。

Send / Sync 标注

默认情况下,包含裸指针的类型既不是 Send 也不是 Sync——编译器不知道底层 C 库是否线程安全。如果你确认它是,需要手动标注:

// 只有当你确认 C 库是线程安全的时候才这么做
unsafe impl Send for Deflater {}

// Sync 意味着多个线程可以同时持有 &Deflater
// 大多数 C 库做不到这一点——它们的内部状态不是线程安全的
// unsafe impl Sync for Deflater {}

不要随意标注。一个错误的 unsafe impl Sync 可以让你的安全封装形同虚设。


六、高级话题

Rust → C → Rust 回调地狱

有些场景下,Rust 调用 C 库,C 库再通过回调调用 Rust。这种三明治结构是 FFI 最复杂的模式:

// Rust 侧的回调实现
extern "C" fn progress_callback(
    user_data: *mut c_void,
    percent: c_int,
) -> c_int {
    // 这里从 C 的上下文「回到」了 Rust
    let closure = unsafe { &mut *(user_data as *mut Box<dyn FnMut(i32) -> bool>) };
    if closure(percent as i32) { 0 } else { -1 }
}

pub fn do_work_with_progress<F>(mut on_progress: F) -> Result<(), Error>
where
    F: FnMut(i32) -> bool,
{
    let mut closure: Box<dyn FnMut(i32) -> bool> = Box::new(on_progress);
    let user_data = &mut closure as *mut _ as *mut c_void;

    let ret = unsafe {
        ffi::do_work(progress_callback, user_data)
    };

    if ret == 0 { Ok(()) } else { Err(Error::from_code(ret)) }
}

这段代码有三个微妙的正确性要求:

  1. closure 必须活得比 ffi::do_work 调用更长(这里是 OK 的——它在栈上)
  2. progress_callback 不能 panic(下面会讲)
  3. 如果 C 库在另一个线程调用回调,closure 必须是 Send

panic 跨 FFI 边界 = UB

这是 Rust FFI 中最常被忽略的陷阱:如果 Rust 代码在 extern "C" 函数里 panic,而 panic 试图穿过 C 的栈帧回退(unwind),这是未定义行为

C 不知道 Rust 的 panic 是什么。它没有对应的 unwind 表。栈会被破坏。

解决方案——每个 extern "C" 函数都用 catch_unwind 包裹:

use std::panic;

#[no_mangle]
pub extern "C" fn mylib_process(data: *const u8, len: usize) -> c_int {
    let result = panic::catch_unwind(|| {
        let slice = unsafe { std::slice::from_raw_parts(data, len) };
        // 正常的 Rust 逻辑,可能 panic
        process_data(slice)
    });

    match result {
        Ok(Ok(())) => 0,
        Ok(Err(_)) => -1,     // 业务错误
        Err(_) => -2,          // Rust panic 被捕获,不会跨越 FFI 边界
    }
}

Rust 从 1.71 开始,在 extern "C" 函数中如果发生未捕获的 panic,默认行为是 abort 而不是 UB。但你仍然应该用 catch_unwind 来优雅地处理错误,而不是直接炸掉整个进程。

async Rust + FFI 的困境

async Rust 和 FFI 天然不兼容,因为:

  1. C 的阻塞调用会冻结整个 async executor 线程
  2. 你不能在 await 点上持有跨 FFI 的裸指针(指针不是 Unpin/Send 的)
  3. C 库的回调可能在任意线程触发,不在 async runtime 的控制下

常见的应对策略:

use tokio::task;

async fn async_compress(data: Vec<u8>) -> io::Result<Vec<u8>> {
    // 把同步 FFI 调用丢到阻塞线程池
    task::spawn_blocking(move || {
        compress(&data) // 同步 FFI 调用
    }).await?
}

spawn_blocking 把 FFI 调用隔离到单独的线程池,不阻塞 async runtime 的 worker 线程。代价是一次线程上下文切换。

性能注意事项

FFI 调用本身的开销非常小——大约等同于一次间接函数调用(通过 PLT 表),在大多数情况下可以忽略不计。但有几个场景需要注意:

// ❌ 每次调用都分配 + 检查
fn call_c_many_times(names: &[&str]) {
    for name in names {
        let c_name = CString::new(*name).unwrap();
        unsafe { ffi::process_name(c_name.as_ptr()); }
    }
}

// ✅ 预分配缓冲区
fn call_c_many_times_fast(names: &[&str]) {
    let mut buf = Vec::with_capacity(256);
    for name in names {
        buf.clear();
        buf.extend_from_slice(name.as_bytes());
        buf.push(0); // null terminator
        unsafe { ffi::process_name(buf.as_ptr() as *const c_char); }
    }
}

替代方案:cxx crate

如果你的目标是 C++ 而非 C,cxx crate 提供了一种完全不同的方法。它不是生成 unsafe extern "C" 绑定,而是通过一个声明式的桥接定义来同时生成 Rust 和 C++ 侧的胶水代码:

// src/main.rs
#[cxx::bridge]
mod ffi {
    unsafe extern "C++" {
        include!("mylib/include/engine.h");

        type Engine;
        fn create_engine(path: &CxxString) -> UniquePtr<Engine>;
        fn process(self: Pin<&mut Engine>, data: &[u8]) -> Result<()>;
    }

    extern "Rust" {
        fn on_progress(percent: i32) -> bool;
    }
}

cxx 的优势:

代价是灵活性:cxx 支持的类型有限,纯 C 库不适用,你必须按照它的 DSL 来定义接口。

特性 bindgen cxx
目标语言 C C++
安全性 全部 unsafe,需手动封装 编译期检查,大部分操作安全
灵活性 任意 C 库 需要 C++ 配合修改
生态成熟度 极高(事实标准) 中等(快速增长)
适用场景 绑定已有 C 库 新建 Rust/C++ 混合项目

多线程 FFI 的坑

FFI 加上多线程,复杂度不是相加而是相乘。下面是几个在 code review 里反复看到的问题。

Thread-Local Storage 差异

C 的 __thread / thread_local 和 Rust 的 thread_local! 底层实现不同。C 用的是 ELF TLS(通过 fs 段寄存器),Rust 可能用 pthread_key_create。后果:

Mutex 跨 FFI 边界

不要试图把 Rust 的 std::sync::Mutex 传给 C:

// ❌ 绝对不要这样做
#[repr(C)]
struct SharedState {
    mutex: std::sync::Mutex<Data>,  // Mutex 内存布局不是 C 兼容的
}

// ✅ 用 C 兼容的锁
use libc::{pthread_mutex_t, pthread_mutex_init, pthread_mutex_lock, pthread_mutex_unlock};

struct FfiMutex {
    inner: UnsafeCell<pthread_mutex_t>,
}

impl FfiMutex {
    fn new() -> Self {
        let mut mutex = unsafe { std::mem::zeroed::<pthread_mutex_t>() };
        unsafe { pthread_mutex_init(&mut mutex, std::ptr::null()) };
        Self { inner: UnsafeCell::new(mutex) }
    }

    fn lock(&self) {
        unsafe { pthread_mutex_lock(self.inner.get()) };
    }

    fn unlock(&self) {
        unsafe { pthread_mutex_unlock(self.inner.get()) };
    }
}

// 手动标注线程安全性——你必须确保这是对的
unsafe impl Send for FfiMutex {}
unsafe impl Sync for FfiMutex {}

Panic 穿越 FFI 边界(再强调一遍)

前面 6.2 节讲了 catch_unwind,但在多线程场景下还有一个额外陷阱:如果 C 库在自己创建的线程中回调 Rust,而 Rust 代码 panic 了——catch_unwind 的调用栈里没有 Rust 的 landing pad,直接 UB。

// C 库在自己的线程池里调用这个回调
extern "C" fn worker_callback(data: *mut c_void) {
    // 这里必须 catch_unwind,因为调用栈上方是 C 的线程入口
    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
        let state = unsafe { &*(data as *const WorkerState) };
        state.process();  // 可能 panic 的 Rust 代码
    }));
}

Send/Sync 标注的责任

FFI 类型默认不是 Send 也不是 Sync——这是 Rust 的保守默认。如果你需要跨线程使用,必须手动标注,而且你在为正确性负全部责任

Async Rust + FFI 的取消安全

当 Rust async task 调用 C 阻塞函数(通过 spawn_blocking),如果 task 被取消了会怎样?

async fn risky_ffi_call(data: Vec<u8>) -> Result<()> {
    let handle = tokio::task::spawn_blocking(move || {
        unsafe { ffi::slow_process(data.as_ptr(), data.len()) }
        // 如果 async task 被 drop,这个闭包还会继续执行到完成
        // data 会在闭包结束后被 drop——这是安全的
    });

    // 但如果这里 await 被取消(比如 select! 的另一个分支先完成):
    // handle 被 drop → JoinHandle 被 drop → 但线程还在跑!
    // C 函数还在用 data → data 还活着(因为 move 了)→ OK
    // 但如果 data 是 Arc 并且其他地方修改了它 → 数据竞争
    handle.await?
}

规则:spawn_blocking 中的 FFI 调用,所有数据都要 move 进去,不要用引用。取消后线程会继续执行到完成(JoinHandle drop 不会 kill 线程)。

FFI Code Review 检查清单

每次提交 FFI 代码之前,过一遍这 15 条:

  1. 所有权文档:每个跨 FFI 传递的指针,注释里写清楚”谁分配、谁释放”
  2. Null 指针检查:所有从 C 返回的指针,使用前检查 is_null()
  3. Buffer 长度校验:永远不要信任 C 传来的长度参数,做 bounds check
  4. 对齐验证#[repr(C)] 的结构体用 static_assert 检查 size 和 alignment 与 C 侧一致
  5. 错误码传播:C 函数返回值 ≤ 0 时,转换为 Rust 的 Result::Err,不要 ignore
  6. 线程安全标注Send/Sync 标注有对应的安全性论证注释
  7. Panic 边界:每个 extern "C" 函数体被 catch_unwind 包裹
  8. Drop 实现:持有 C 资源的 Rust 类型实现了 Drop,调用正确的释放函数
  9. repr(C) 标注:所有跨 FFI 共享的结构体都有 #[repr(C)]
  10. 生命周期文档:裸指针对应的 Rust 引用的生命周期约束在注释中明确
  11. 字符串处理CString/CStr 正确使用,没有中间的 null 字节问题
  12. 回调安全:传给 C 的回调函数不持有悬垂引用,user_data 的生命周期 >= 回调使用期
  13. 内存分配器一致性:Rust 分配的内存不在 C 侧 free(),反之亦然
  14. ABI 兼容性-sys crate 的 C 头文件版本和运行时链接的库版本一致
  15. Fuzzing/Sanitizer:用 cargo fuzz + AddressSanitizer 跑过 FFI 边界的输入

结语

FFI 是 Rust 安全模型里开的一扇后门。当你走过那扇门,borrow checker 不再保护你,所有权系统不再自动管理,你回到了 C 程序员几十年来面对的那些经典问题:谁分配、谁释放、指针有没有悬垂、线程安不安全。

但 Rust 给了你一样 C 没有的东西:把 unsafe 封装起来的能力。你可以在最底层和 C 赤膊相见,然后在上面盖一层安全的 API,让所有使用者都不需要碰 unsafe。这种分层隔离才是 Rust FFI 设计的精髓。

记住三条规则:

  1. 谁分配,谁释放。永远不要用错误的 allocator 释放内存。
  2. 每个 extern "C" 函数都要 catch_unwind。panic 跨 FFI 边界是 UB。
  3. 最小化 unsafe 表面积。写一个 -sys crate 做原始绑定,再写一个安全的 wrapper crate。

如果你对 unsafe 本身的语义和使用场景还有疑问,回头看 Unsafe Rust 完全指南。如果你想理解为什么 Rust 的所有权比 C++ 的 RAII 更可靠,看 所有权 vs RAII

在 FFI 边界写 unsafe 不可耻。可耻的是不把 unsafe 封装好就暴露给用户。


By .