把 unsafe 理解成「关掉借用检查器的开关」会在 FFI 边界上立刻失效。unsafe 只是把几条编译器不肯帮你证明的契约交给人:指针有效、别名不打架、调用约定一致、生命周期在对岸仍成立。C 不承认 Result,不承认 Drop,不承认 panic 展开。两边能对话,是因为同意了同一套 ABI(二进制接口):参数怎么放寄存器、结构体怎么垫字节、谁分配谁释放。写错的不是风格问题,是未定义行为,症状可以隔着几秒、几条线程才出现。

这篇文章只讲这条边界:哪些类型能过 C ABI、所有权如何交接、panic 为什么不能穿越、安全封装该有多厚。读完你应该能独立回答三个问题:为什么不能把 Vec<u8> 直接扔给 C、catch_unwind 包的是哪一层、一块由 Rust 分配的缓冲凭什么必须由 Rust 释放。

1. ABI 是合同,不是源码兼容

两个编译单元能互相调,靠的不是头文件看起来像,是生成的机器码对「第几个参数在哪个寄存器、返回值怎么回来、栈谁清」意见一致。extern "C" 选用平台的 C 调用约定。extern "Rust" 是 Rust 自己的,不稳定,不能当跨语言合同。#[no_mangle] 关掉名字修饰,让 C 能按符号名找到函数。没有它,链接器看到的是一串哈希过的 Rust 符号,C 侧 extern void foo() 会 undefined reference。

类型布局是合同的另一半。Rust 默认 repr(Rust):字段顺序、填充、判别值都可以被编译器重排,为了对齐和空间。这个布局没有跨编译版本的保证,更没有对 C 的保证。要过边界,结构体必须 #[repr(C)],枚举若要当 C 的整数必须 #[repr(C)] 或 #[repr(u32)] 并确认无 payload,或者干脆不要把带数据的枚举传过去。

#[repr(C)]
pub struct FrameHeader {
    pub len: u32,
    pub flags: u16,
    pub ty: u8,
    pub _pad: u8,
}

#[no_mangle]
pub extern "C" fn frame_header_size() -> usize {
    std::mem::size_of::<FrameHeader>()
}

_pad 写出来是为了和 C 头文件的填充对齐,并提醒读代码的人:这里的空洞是合同的一部分。用 #[repr(C, packed)] 能去掉填充,但未对齐访问在部分架构上是 CPU 异常,在 x86 上只是慢。除非硬件协议逼你 packed,否则用显式填充字段,两边 sizeof 用测试钉死。

胖指针过不了 C ABI。&[u8]、&str、dyn Error、Box<dyn Trait> 都是「指针 + 长度或 vtable」。C 的 char* 只有指针。你传 &str 当 *const c_char,对岸会把长度字段当成地址去解引用。这是 FFI 里最常见的一类静默内存破坏。切片必须拆成 ptr + len 两个整数参数;字符串必须变成以 \0 结尾的 CString,或同时传长度。

2. 能过边界的值和绝对不能过的值

能过:整数、f32/f64、bool(作为 1 字节时要和 C 的 _Bool 确认)、裸指针、repr(C) 且字段也全是能过的结构体、函数指针(extern "C" fn)。Option<extern "C" fn()> 在 Rust 里和可空函数指针同布局,这是少数几个稳定保证。Option<NonNull<T>> 同理。

不能过:Vec、String、HashMap、Rc/Arc(除非只传裸指针并放弃安全)、带生命周期的引用(对岸没有 'a)、带 Drop 胶水的类型(对岸不会在栈展开时跑你的析构)。String 的内部是指针、长度、容量三个字;C 的 char* 只有一个。即便你把 as_ptr() 给了 C,容量和所有权仍在 Rust 这边——C 若 free 这个指针,分配器就进错堆。

整数宽度要写死。Rust 的 usize/c_int、C 的 int/long/size_t 在 LP64 和 LLP64 上不是同一套。Windows 的 long 是 32 位,Linux x86_64 是 64 位。跨平台 FFI 用 i32/u32/u64 和 *mut u8 加 usize 长度,不要用 c_long 当「够大的整数」。回调里的 size_t 对 usize。

布尔和枚举:C 的 enum 宽度实现定义。不要把 Rust enum 直接当 C enum 用,除非 repr(u32) 且 C 侧也固定为 uint32_t。标志位用独立的 u32,不要用 bitfield 结构体——两边编译器的 bitfield 布局几乎一定拧。

3. 所有权交接:谁分配,谁释放,长度的单位是什么

边界上每一块内存都要能回答三问:谁 alloc、谁 free、长度是字节还是元素。混用 Rust 全局分配器和 libc malloc 是未定义的。Rust 分配的 Vec 必须 Vec::from_raw_parts 回来再 drop;C malloc 的必须 libc::free。不要「看起来都是堆」就互相释放。

长度单位写进函数名和头文件:foo_write(ptr, len_bytes) 而不是两个都叫 n。元素数组过边界要同时约定元素大小和对齐。C 侧用 sizeof(*ptr),Rust 侧用 size_of::<T>(),CI 里用一个导出函数返回 size_of 让 C 测试来比。单位错一次,就是按元素数当字节数去 from_raw_parts,分配器在远方崩溃。 只读借出和移交所有权不要用同一个函数。foo_view 约定调用期间指针有效、返回后失效;foo_take 约定对岸负责 free。文档和符号名都要分开。一个函数「有时借有时给」是双重释放和 UAF 的温床。Rust 侧对应两个安全函数:一个收 &[u8],一个收 Vec<u8> 并 forget。 错误返回时已经分配的资源要回到安全封装的 Drop,而不是在 extern "C" 里手写五路释放。失败路径最容易漏。把已经移交的指针先放进一个本地的 CBuf,成功时 mem::forget 再把裸指针返回,失败时让 CBuf 析构。这是 RAII 在边界上的最后一次发挥作用。 对外函数的线程模型写进头文件:可重入否、可并发否、是否要求同一线程。Rust 的 Send 只在安全侧生效。C 调用约定里没有 Send,你只能用文档和 debug 断言。生产上曾经「单线程用好好的」库被丢进 Tokio 阻塞池,第一次并发就在 C 的全局状态上毁掉。这不是 Tokio 的错,是 FFI 合同没写线程。

典型的 Rust 拥有、借给 C 看:

use std::os::raw::c_char;
use std::ffi::CString;
use std::ptr;

#[no_mangle]
pub extern "C" fn rust_version() -> *const c_char {
    static S: &str = "1.0.0\0";
    S.as_ptr() as *const c_char
}

#[no_mangle]
pub extern "C" fn copy_to_c(src: *const u8, len: usize) -> *mut u8 {
    if src.is_null() {
        return ptr::null_mut();
    }
    let slice = unsafe { std::slice::from_raw_parts(src, len) };
    let mut v = slice.to_vec();
    let p = v.as_mut_ptr();
    std::mem::forget(v);
    p
}

#[no_mangle]
pub extern "C" fn buffer_free(ptr: *mut u8, len: usize) {
    if ptr.is_null() {
        return;
    }
    unsafe { drop(Vec::from_raw_parts(ptr, len, len)); }
}

copy_to_c 把所有权放弃给对岸(forget 免得 drop),并要求对岸稍后用 buffer_free 还回来。长度参数必须是当时的 len,且 cap 在这个简化例子里被当成等于 len——如果你 reserve 过,必须把 cap 也传出去,否则 from_raw_parts 用错容量会在 dealloc 时毁掉分配器。三个字一起走:ptr、len、cap。 合同写进头文件,最好写成一个 repr(C) 结构体一次传。

C 拥有、Rust 只借:函数期间指针有效,返回后 Rust 不再碰。把这个写进文档和安全封装的生命周期——安全侧应当是 fn with_c_buf(buf: &[u8], f: impl FnOnce(*const u8, usize)),让 & 的寿命盖住整个回调。不要把 C 指针存进全局再在下次请求用。

空指针:C 用 NULL 表示可选。Rust 侧立刻检查 is_null(),再 slice::from_raw_parts。from_raw_parts(null, 0) 在当前实现里对零长度常常能忍,但规范要求指针非空或来自正确分配;用 NonNull 或显式分支,不要靠实现细节。

4. panic 不能过边界,异常也不能

Rust panic 默认栈展开,跑 drop。C 没有对应的展开表来配合 Rust 的 landing pad。panic 穿过 extern "C" 是未定义行为:可能直接 abort,可能把 C 的栈拆烂,可能在几帧之后才崩。C++ 异常穿过 Rust 同理。合同是:边界上的函数要么正常返回,要么返回错误码,绝不抛。

正确形状:对外函数 catch_unwind,把 panic 变成错误码或记日志后返回。AssertUnwindSafe 要审:闭包里如果改了一半状态,catch 之后那半状态仍然坏着。能 catch 不等于能恢复。服务进程里对外 FFI 失败后,优先把这个会话丢弃,而不是继续在半初始化对象上跑。

panic = abort 的二进制里没有展开,panic 直接杀进程。

这对纯 Rust 服务常常正当:不变量破了,展开过程本身可能再坏。嵌进别人进程的 cdylib 则相反——你的 assert 不该带走宿主。所以同一份库代码面对两种最终二进制时,对外 extern "C" 仍然必须 catch_unwind:在 unwind 的宿主里它把 panic 变成错误码,在 abort 的宿主里它几乎不会被走到,但源码同一份。不要用 cfg 把 catch 去掉图省事,那会让库在两种产品里行为分叉。 边界上返回错误码的同时,用 thread-local 或显式 out 参数留下最近一次 panic 的消息,方便宿主打日志。消息要在 catch 时立刻格式化成拥有的 String 或固定缓冲,不要把 &str 指向已经在展开中死去的栈。长度截断,编码 UTF-8,宿主用你提供的 free 释放。 这对纯 Rust 服务常常是正当的(见构建篇);对嵌进别人进程的 cdylib 通常不正当——你的 assert 不该带走整个 Python/Java/游戏引擎。嵌入学库用 catch_unwind + 明确的 abort 策略文档。测试里用 should_panic 测内部不变量,用 C 测试程序测边界返回码,两套都要有。

从 C 调回来的回调里,Rust 若 panic,同样炸。回调是另一条边界。给 C 的函数指针必须是 extern "C" fn,内部再 catch_unwind。不要把 Rust 闭包直接当 C 函数指针——环境指针要自己做成 userdata: *mut c_void,类型擦除,回来再转,并证明 C 不会在 userdata 释放之后还调。

5. 安全封装:unsafe 的半径等于边界的厚度

公开 API 应当是安全的。unsafe 只出现在 mod ffi 或 unsafe fn 的薄薄一层,外面用类型把契约重新闭合:

  • 指针变成 &[u8] / &mut [u8] / NonNull 包在结构体里,构造函数检查对齐和空。
  • C 资源变成带 Drop 的句柄,Drop 里调 C 的 free。不要让用户手动配对。
  • 错误码变成 Result。不要把负数 errno 当成功路径上的 usize 返回。
  • 线程约定写成 Send/Sync 或显式不实现。C 库若要求「只能在创建它的线程上用」,句柄就不要 Send。
pub struct CBuf {
    ptr: *mut u8,
    len: usize,
}

impl CBuf {
    pub fn as_slice(&self) -> &[u8] {
        if self.ptr.is_null() {
            &[]
        } else {
            unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
        }
    }
}

impl Drop for CBuf {
    fn drop(&mut self) {
        if !self.ptr.is_null() {
            unsafe { buffer_free(self.ptr, self.len); }
        }
    }
}

unsafe impl Send for CBuf {}
unsafe impl Sync for CBuf {}

这里 Send/Sync 是一句声明:你认为对岸的缓冲可以跨线程读。如果 C 库内部有线程局部,这句声明就是漏洞。unsafe impl 必须写在紧挨着的注释里:凭什么。没有注释的 unsafe impl Send 在代码评审里应当直接打回。

封装还要处理重入。C 回调里再进 Rust,Rust 再调 C,锁顺序会和纯 Rust 时不同。文档写明「这个回调里不能再调 xxx」。能用类型禁止的就禁止:把回调期间的句柄做成 &CallbackCtx,不提供会重入的 &mut 方法。

6. 字符串、编码、路径

C 字符串是 \0 结尾的字节,编码经常是「本地」或「我也不知道」。Rust 的 String 是 UTF-8。CString::new 会在内部发现 \0 时失败——二进制数据不是 C 字符串。路径在 Windows 上是 UTF-16,在 Unix 上是任意字节。CString 从 Path 转,在 Windows 上会走有损或宽字符 API,必须选对 u16 版本。

不要把 String::as_ptr() 给 C 当 C 字符串:Rust String 不保证尾随 \0,也不保证之后没人改它。需要 CString,并保证 C 用完之前 CString 还活着。返回给 C 的字符串要么静态,要么堆分配并给 free 函数,要么拷进调用方提供的缓冲(带长度,返回写入的字节或截断标志)。第三种最不容易泄漏,也最麻烦,嵌入式和系统 API 常用。

日志和错误信息过边界,优先错误码 + 调用方自己格式化。传堆上的错误字符串就要规定谁 free,还要规定编码。少一条合同,就多一次 double-free 或乱码。

7. 异步、运行时和 FFI 合不来的那部分

C 函数默认是同步、阻塞、不知道 Waker。在 Tokio worker 上调一个可能堵 200ms 的 C 压缩库,效果和 std::fs::read 一样:偷走 worker。规则不变:可能阻塞的 FFI 进 spawn_blocking,并且取消听不到——见调度那篇。

反过来,把 Rust async 暴露给 C:C 没有 await。常见做法是完成回调(userdata + extern "C" fn(code, *mut u8, usize)),或 C 侧提供的 event loop 注册 fd。把 Waker 塞给 C 几乎总是错的——Waker 是 Rust runtime 的对象,C 不知道怎么 clone 和 wake。要接,就在 Rust 侧保持任务活着,C 只在 fd 就绪时调一个 extern "C" 入口,入口里 waker.wake()。所有权:Waker 必须活到 wake 之后或明确取消。

不要在 extern "C" 里 Runtime::block_on。嵌套 runtime、死锁、和调用方已有的事件循环打架,三位一体。库若需要异步,把 runtime 句柄作为初始化参数传进来,或只提供同步 API 让调用方自己绑。

8. 构建:cdylib、cbindgen、头文件测试

crate-type = ["cdylib"] 产出 C 能 dlopen 的动态库。staticlib 给 C 静态链。符号默认除了 #[no_mangle] pub extern "C" 都该隐藏,避免把 Rust 标准库内部符号漏出去撞车。#[no_mangle] 太多等于没有命名空间,前缀用项目名。

头文件用 cbindgen 从 Rust 生成,或手写后用测试比较 sizeof/offsetof。生成物进版本控制还是 CI 生成,选一个:进版本控制能 review ABI 变化;CI 生成能防手改头文件和源分叉。ABI 变化是 breaking,该打版本号,不能只当内部重构。

测试不能只测 Rust 侧。最小 C 程序或 cc crate 编一个 C 文件来调导出函数:空指针、零长度、错误码、free 两次(第二次应是安全的 no-op 或明确 abort,不要静默把堆打烂)。Miri 测不了真实 C,但能测你封装里那些 from_raw_parts 的 Rust 侧。AddressSanitizer 对边界另一侧特别有用,CI 里至少一条 asan 构建。

9. 取舍:直接 FFI、bindgen、cbindgen、 Diplomat、protobuf

手写 extern "C" 最薄,适合五个函数的小边界。函数一多,类型容易和头文件分叉。bindgen 从 C 头生成 Rust,适合吃现成 C 库;生成代码脏,要在 build.rs 里收白名单,不要把整份 POSIX 头吸进来。cbindgen 反向,适合 Rust 做实现、C 做宿主。

更厚的工具( Diplomat、cxx、UniFFI)用代码生成维持两边类型,减少手写 repr(C)。代价是构建图变复杂、调试要看生成物、不是所有类型都能表达。对象生命周期仍要人想清楚:生成器不会把「谁 free」变成魔法。

protobuf / FlatBuffers 是另一条路:不共享内存,只共享序列化合同。拷贝换来版本演化和语言无关。热路径上的共享内存 FFI 更快,也更脆。先问这份边界是不是性能关键、是不是同进程。同进程且热,FFI;跨进程或要演化,用字节协议。不要用 FFI 模拟 RPC,也不要用 RPC 模拟一次函数调用。

10. 排障:崩在对岸、泄漏、双重释放、偶然能跑

  1. 崩在 C 里、栈上全是 ??。 先查 ABI:repr(C) 丢了、参数顺序、调用约定(stdcall vs C)、32/64 位混链。再查字符串是否缺 \0、切片是否把胖指针当瘦指针传。
  2. 双重释放或分配器断言。 两边都认为自己该 free。画一次所有权:每个指针从出生到死亡只出现一个析构。forget 和 from_raw_parts 必须成对。
  3. 泄漏。 只 forget 不 free;C 回调里分配的 Rust 对象没人 drop;panic 在 catch 之外提前返回忘了释放已经移交的指针。失败路径用 defer 式的 RAII 封装,不要在 extern "C" 里手写五路 goto cleanup 而不包类型。
  4. 偶然能跑。 未定义行为的典型。优化级别一变、多一个字段、换了 allocator 就炸。用 asan/valgrind/Miri 抓,不要用「多试几次」。
  5. 死锁。 C 持锁调进 Rust,Rust 再调回 C 抢另一把锁。把回调能调的子集写进文档,最好写成窄 trait。
  6. Send 过宽。 句柄被 spawn 到别的线程,C 库内部炸。去掉 unsafe impl Send,用类型把句柄钉在线程上,或在文档和运行时检查线程 id。

版本:C 库用 SONAME 和符号版本;Rust 侧 links 键让 Cargo 拒绝链两份。同一进程链两份 openssl 的事故足够多。pkg-config 和 vendored 特性要在工作区里统一,见构建篇。

11. 和所有权模型如何重新闭合

FFI 并没有废除所有权,它只是在边界上把证明交给人。过了边界,立刻用安全类型把三条不变量捡回来:单一所有者(Drop 句柄)、别名(切片而不是到处飞的裸指针)、寿命(回调期间借用,不存全局)。unsafe 块应当短到可以在脑子里模拟执行,并且有相邻的注释写明假设。假设在 C 侧文档改版时会过期——封装测试要当合同测试来跑。

合同测试至少包括:空指针、零长度、最大长度、错误码、free 两次、错误路径是否释放已分配资源、回调恰好一次。C 小程序或 cc 编出来的测试和 Rust 侧 Miri 互补:Miri 看不到真正的 C,C 测试看不到 Rust 的别名模型。两边都绿,才敢说边界闭合。缺一边,就是在用「目前没崩」当证明。

跨语言的错误用整数码,再在 Rust 侧变成 thiserror 枚举。不要把 C 的 errno 直接泄漏给上层业务。这和错误处理那篇的反腐败层是同一位置,只是对岸从「另一个 crate」换成了「另一种语言」。

对齐和端序是 ABI 合同里最容易被「能跑」掩盖的部分。repr(C) 按 C 规则对齐:u8 后面跟 u32 会垫 3 字节。Rust 侧用 offset_of! 和 C 侧 offsetof 写成测试,在 CI 里对每个过边界的结构体跑一遍。端序:网络协议大端,Intel 小端,整数过 FFI 若两边各解一次,中间不要再 to_be_bytes 一次。把「谁负责换端」写进函数名:write_u32_be 比 write_u32 诚实。

回调里的重入和锁构成死锁温床。C 库在持有内部锁时调你的 extern "C" 回调,回调里再调这个库的另一个函数,对方再抢同一把锁——经典。Rust 侧看起来只是「在回调里调了一个安全封装」。封装必须文档化:哪些函数在回调中禁止。更好的形状是回调只 try_send 到有界队列,真正的工作回到 Rust 任务里做,那时 C 库的锁已经释放。这和异步篇、背压篇又接上:FFI 回调是不能阻塞、不能重入的生产者。

动态加载(dlopen / libloading)比直接链接多一个失败模式:符号缺失、版本不匹配、两个库各带一份 allocator。不要跨 dlopen 边界传递 Box 或 Vec——两边的 drop 可能链到不同的 jemalloc。只传 C 认识的布局,释放函数和分配函数从同一份动态库导出。插件 ABI 用版本号握手,先调 plugin_abi_version(),不对就拒绝加载,不要在第一次业务调用时 SIGSEGV。

和 Python/Java/Go 的边界常常不是手写 extern "C",而是一层生成器:PyO3、JNI、cgo。生成器替你做了一部分 ABI 和转换,但所有权问题一个都没消失。PyO3 的 PyBuffer 在 Python 对象活着时指向它的内存,你不能把切片 spawn 到 Tokio 任务里再回去释放 GIL——寿命绑在 GIL 和对象上。JNI 的 jbyteArray 可能被 JVM 搬走,GetByteArrayElements 有的实现会拷贝。Go 的 cgo 有自己的栈和 P,回调进 Rust 再进 Go 的开销和重入规则更严。选生成器是为了少写样板,不是为了少想寿命。每一层仍然要回答:这块内存现在算谁的,跨过这次调用之后还能不能碰。

WASM 是另一种 ABI:线性内存、wasm32 指针是 32 位偏移,不是宿主指针。extern "C" 在 wasm 里仍然有效,但 Vec 的指针传给宿主必须先放到线性内存里,宿主看到的是偏移。wasm-bindgen 用生成代码藏这件事,失败模式变成 JS 侧的类型错误,而不是 SIGSEGV。宿主和 wasm 之间不要共享原生锁;各自的运行时、各自的分配器。panic = abort 在 wasm 里往往更干净,因为展开表贵而且和 JS 异常模型合不来。

安全性:FFI 是攻击面。对岸传来的长度当 usize 用之前检查溢出和上限;传来的指针要非空、对齐、且你有理由相信指向的是约定的对象(不透明指针用 cookie 或世代号防 UAF)。不要把 Rust 的 enum 判别值暴露成 C 可以随便写的整数再 transmute 回来——非法判别值是未定义行为。用 repr(u32) 加显式 match,未知值当错误码。签名校验、版本握手、把 unsafe 留在薄层,这些比在业务里写「C 侧保证合法」有用。C 侧保证在恶意输入或内存损坏之后不成立。

不透明指针是跨语言对象的正当形状。Rust 侧 Box::into_raw 把所有权交给 C 当 *mut Foo,C 只保存和传回,不解读内存。方法都是 extern "C" fn foo_bar(p: *mut Foo, ...),第一件事检查空,转回 &mut Foo。销毁函数 foo_free 里 drop(Box::from_raw(p))。不要给 C 一份 repr(C) 的内部字段让它自己改——字段一加,ABI 就破。不透明指针的 ABI 是指针宽度,稳定得多。文档写明线程:这个 *mut Foo 是否 Send。C 不会帮你 Send 检查,把句柄做成只能通过函数用,并在 debug 构建里用线程 id 断言。

cbindgen 生成的头文件要当制品来 review。结构体字段顺序、#include、#ifdef __cplusplus 的 extern "C" 包裹,少一层就会在 C++ 里被名字修饰。生成配置里把 usize 映射成 size_t,把 u8 映射成 uint8_t,不要让 cbindgen 写出 unsigned long long 这种在 Windows 上拧的类型。CI 比较生成头和仓库里的头,不一致就失败——这是 ABI 回归测试。人 review 的是「这次差异是不是一次 breaking」。

和信号安全、fork、allocator 的交叉很少写进教程,但会在生产里炸。malloc 在持锁时进程被信号打断,信号处理函数里再进 Rust 分配,死锁。fork 之后只安全 async-signal-safe 的调用,Rust 的全局分配器和锁几乎都不是。嵌入到会 fork 的宿主(某些 web 服务器、语言运行时)时,初始化要在 fork 之后,或彻底避免在 FFI 里碰锁和堆。这些约束写进封装的安全文档,而不是等 hang 了再猜。

不透明指针比暴露 repr(C) 字段稳:Box::into_raw 交给 C,方法都是 foo_bar(p, ...) 先查空再转 &mut,销毁 from_raw 恰好一次。字段一加 ABI 就破;指针宽度当 ABI,稳定得多。debug 构建用线程 id 断言「只能在创建线程上用」,C 不会帮你做 Send 检查。 Windows 上多个 CRT 各有 malloc,Debug/Release 也不能混。Linux 上静态 jemalloc 的 Rust 和 glibc 的 C 同样不能互相 free。释放函数和分配函数来自同一模块,符号写成 foo_alloc / foo_free。 回调 userdata 是类型擦除的所有权。完成回调可能零次、一次、两次。封装用 Option<*mut T> 第一次 take,第二次空操作并打日志。测试覆盖成功、失败、取消三条路径恰好一次释放。 信号处理函数和 fork 之后几乎不能进 Rust 堆和锁。嵌入会 fork 的宿主时,初始化放 fork 之后,或避免 FFI 里碰锁。union 过边界除非硬件逼你,tag 和 payload 必须一起传,合法 tag 集合用生成代码两边共享。cbindgen 头文件当制品 review,CI 比较生成结果,usize 映射成 size_t,C++ 包裹 extern "C"。

12. 小结

FFI 的原理是 ABI 合同 + 所有权交接 + panic 隔离。能过边界的是 C 认识的瘦类型;Vec/String/胖指针必须拆。分配和释放同一边负责。unsafe 的半径等于边界的厚度,越薄越好,外面用 Drop 和 Result 重新变成 Rust。下一篇回到异步管道内部:Stream 的拉取节奏,以及没有背压时内存如何先于 CPU 爆炸。

落地时从最小边界做起:先把导出函数缩到五个以内,每个函数写明指针空值、长度单位、谁 free、线程、错误码。五个函数的合同能写清,五十个才能复制。合同写不清就不要加第五十一个。unsafe 行数不是勇气指标,是需要人证明的表面积。表面积越大,Miri 和 asan 越难罩住。每加一个导出函数,先加一条 C 测试再合入。没有对岸测试的 FFI,只是在 Rust 侧自己和自己玩。对岸测试不过,等于合同没签字。签字之前不要把符号暴露给生产宿主。合同测试不过关,就还没有 FFI,只有一堆未定义行为的入口。