别重写了!Rust FFI 三明治:让 C 代码继续打工,你只管加安全锁

老板说"用 Rust 重写",你是不是心里一凉?
你在公司维护一个 C 写的核心服务,每天处理几百万请求,稳定运行了三年。突然有一天,技术总监在周会上说:“我们要把这个服务迁移到 Rust。”
你的第一反应是什么?
如果是"好嘞,我从 main 函数开始重写",那我劝你冷静一下。
这就像你家厨房用了三年,水龙头偶尔滴水、灶台有点旧,但做饭没问题。你说要"升级厨房",难道把整个厨房拆了重建?那这三个月你吃什么?
生产环境的 C 代码也是一样。它有些毛病(偶尔段错误、内存泄漏),但它在赚钱。你把它停了重写,收入谁来扛?
今天聊一个更实际的做法:FFI 三明治。这是 Rust FFI 领域里最稳的渐进式迁移方案,让你把 C 迁移到 Rust 的过程变得可控。
FFI Sandwich 是什么?一句话讲明白
三明治你吃过吧?两片面包夹一块肉。
FFI Sandwich(FFI 三明治)也是三层:
你的应用(安全的 Rust 代码)
↓
FFI 中间层(unsafe 但很薄)
↓
C 语言库(久经考验的老代码)
- 上面那片面包:Rust 应用代码,安全的,没有裸指针乱飞
- 中间那层肉:FFI 垫片,用
#[no_mangle] extern "C"做桥接,只处理类型转换 - 下面那片面包:现有的 C 库,稳定、能用、正在赚钱
这个模式故意设计得无聊。没有花哨的宏魔法,没有复杂的泛型体操。无聊就对了,生产环境不需要惊喜。
打个比方,这就像你家请了个翻译。你说中文,对面的老师傅说方言,翻译在中间把话传到。老师傅该怎么干活还怎么干活,你只需要确保翻译别传错话。
先选方向:谁调用谁?
在动手之前,有个关键决定:Rust 调 C,还是 C 调 Rust? 这决定了你的 Rust C 互操作方向。
Rust 调 C(Rust-on-C)
你的 C 库里有打磨了三年的算法,跑得飞快、稳如老狗。你只想在外面加一层安全壳。
给老房子装个新门锁,里面的家具不动,进出更安全了。
C 调 Rust(C-on-Rust)
你要用 Rust 写一个全新的核心模块,但现有的 C 程序还得调用它。
老厨房里换一台新灶,菜谱不用改,火力更猛。
一个原则:每个子系统只选一个方向,别两边互调。开车不能同时踩油门和刹车,技术上可以,但你的脑子会先炸。
跨边界的类型:只许带"通行证"
FFI 边界就像海关,不是什么东西都能过。Rust 安全模型在这一层需要你格外小心。能过关的:

- 指针:
*const T、*mut T - 固定宽度整数:
i32、u64、usize这些 #[repr(C)]结构体:内存布局和 C 一模一样- 缓冲区:用
(指针, 长度)的组合传递,快递单上写清楚"几件货、放哪儿" - 错误码:别用 Rust 的
Result,老老实实返回整数(0 是成功,负数是错误)。这是extern "C"函数的标准做法
过不了关的:
- Rust 的
String、Vec、Option,它们的内存布局 C 根本不认识 panic不能穿越 FFI 边界,在海关放鞭炮两边都炸- Rust 的 drop 语义,C 不知道你在搞什么析构
Rust C 互操作的核心就是在这条边界上把规矩定死。两边各管各的类型,中间只传最原始的数据。
最安全的写法:一个出参、一个状态码
说了这么多,来看代码。这 18 行是 Rust FFI 最安全写法的核心,也是整篇文章最重要的部分:
use std::os::raw::c_int;
#[repr(C)]
pub struct FfiResult {
pub code: c_int, // 0 = 成功, <0 = 出错了
}
#[no_mangle]
pub extern "C" fn rs_sum_u32(
input: *const u32,
len: usize,
out: *mut u64,
) -> FfiResult {
// 先检查指针,空指针直接拒绝
if input.is_null() || out.is_null() {
return FfiResult { code: -1 };
}
// Safety: 调用方保证 input 指向 len 个 u32,out 是有效的
let slice = unsafe { std::slice::from_raw_parts(input, len) };
let sum: u64 = slice.iter().map(|&x| x as u64).sum();
unsafe { *out = sum; }
FfiResult { code: 0 }
}
看到了吗?模式很简单:
- 检查输入:空指针?拒绝。长度不对?拒绝。餐厅服务员先看你有没有预约
- 返回状态码:0 是成功,负数是各种错误。不用异常,不用 panic,
extern "C"函数两边都能看懂 - 通过出参写结果:不在 FFI 层分配内存,谁分配的谁释放
unsafe块尽量小:只在真正需要的地方用,像手术刀一样精准
这个模式你用一百次都不会出大问题。
内存管理:谁分配的谁释放,别乱动
内存管理是 FFI 里最容易踩坑的地方。铁律就一条:
Rust 分配的内存,Rust 释放。C 分配的内存,C 释放。
合租公寓冰箱规则,你买的牛奶你自己喝,别人的东西别碰。
如果 Rust 需要给 C 提供一块数据,你必须同时提供一个 free_* 函数:
#[no_mangle]
pub extern "C" fn rs_create_buffer(size: usize) -> *mut u8 {
let mut buf = Vec::with_capacity(size);
let ptr = buf.as_mut_ptr();
std::mem::forget(buf); // 别让 Rust 自动释放
ptr
}
#[no_mangle]
pub extern "C" fn rs_free_buffer(ptr: *mut u8, size: usize) {
if !ptr.is_null() {
unsafe {
// 重新构造 Vec 让 Rust 正确释放
let _ = Vec::from_raw_parts(ptr, 0, size);
}
}
}
至于线程,别在 FFI 边界上传递线程间共享的缓冲区。两边的运行时对线程的理解不一样,就像两个人对"明天"的定义不同(你说的是工作日明天,他说的是自然日明天),迟早出乱子。
回调函数也要小心。如果必须用回调,就按 C 的老规矩来:函数指针 + void* 上下文,保持简短,别搞什么闭包穿越。
构建系统:别把自己绕进去
构建系统配置不难,但容易踩坑。
Rust 调 C 的场景:
Cargo.toml里设置crate-type = ["cdylib"]或["staticlib"]- 用
cbindgen从 Rust 代码自动生成 C 头文件(和repr(C)一致) build.rs里链接 C 库:println!("cargo:rustc-link-lib=你的C库名")- 最终产物:
libyourlib.so(或.dylib/.dll/.a)加一个.h头文件
C 调 Rust 的场景反过来:
- Rust 编译成动态库或静态库
- 用生成的头文件在 C/C++ 项目里引用(CMake、Bazel、Make 都行)
- 按平台加链接参数
工具就两个:
cbindgen:Rust 导出到 C 的头文件生成器bindgen:C 头文件转 Rust 绑定,放在build.rs里用
测试:让 C 和 Rust 对答案
这步很关键。你不能光靠"我觉得它对了",你得让两边跑一样的输入,比较输出。
做法:
- 准备一批测试数据:边界值、大输入、各种奇葩 locale,越刁钻越好
- 写一个 C 测试工具:调用老的 C 接口,把输出的 hash 打印出来
- 写一个 Rust 测试:调用新的 Rust 接口,同样打印 hash
- 比较两边的结果:一样就对了,不一样就有 bug
# 大致思路
./c_test_harness < test_corpus.bin > c_output.txt
./rust_test_harness < test_corpus.bin > rust_output.txt
diff c_output.txt rust_output.txt
然后把 cargo-fuzz 加进夜间 CI 任务里,让它每天晚上用随机输入轰炸两边的接口。有分歧就报警,第二天来修。
浮点数比较要注意,先约定好精度容差,然后用 assert!((a - b).abs() < 1e-9) 这种方式。浮点数就像做菜放盐,“适量"这个词每个人理解不一样,必须定一个标准。
性能:瓶颈在"过关"次数,不在 Rust
很多人担心 FFI 会拖慢性能。其实 Rust 和 C 的执行速度差不多,真正慢的是跨边界的调用次数。
搬家的时候,效率瓶颈不是你从楼上到楼下跑多快,而是你跑了多少趟。一趟搬一本书跑一百趟,肯定比不过一趟搬一箱子。
所以:
- 批量处理:传
(指针, 长度)一次处理几千条数据,别一条一条调 - 热循环放在一种语言里:C 的算法已经够快,就调一次让它全部处理完
- 别在 FFI 层分配内存:在引擎层(Rust 或 C)分配好再传过去
性能目标:热路径的 p95 延迟加了 FFI 层之后,应该在 +/-3% 以内,甚至更快。如果差太多,八成是调用太频繁,不是 FFI 本身的锅。
用 perf 或 VTune 分析时,重点看调用次数,不只是 CPU 时间。
安全增益:不花钱就能拿到的
即使底下还是 C 代码在跑,加了 Rust 这层壳,你就能拿到这些:
- 输入校验:在 Rust 层检查
(指针, 长度)配对,拒绝离谱的长度值。以前 C 直接信任输入导致的缓冲区溢出?没了 - 递归和循环上限:在 Rust API 层限制递归深度和迭代次数,防止恶意输入搞死服务
- 字符串安全:先把不可信的字符串当
&[u8]处理,显式验证 UTF-8 之后再传给 C - 空指针拦截:所有 null 指针和非法枚举值在入口就被挡住
Rust 不是防火墙。C 代码内部自己乱搞缓冲区,那是 C 自己的问题。但至少外部输入引起的安全问题,被你挡在门外了。这就是 Rust 安全模型在 C 迁移 Rust 过程中最直接的价值。
四步渐进式迁移:别想太远,先走第一步

理论讲完了,怎么落地?
第一步:挑一个最危险的接口,包一层 Rust
找那个最容易崩溃、最容易被攻击的 C 函数。用 Rust 写个安全入口,加上输入校验,灰度放 10% 的流量进来。
先给最爱漏水的那个水龙头换个新的,其他的先不动。
第二步:两条路跑一周,比对结果
让 C 原路径和 FFI 三明治路径同时跑,对比输出。有差异就修,直到完全一致。
第三步:把一块逻辑搬到 Rust 里
在三明治内部,把一段 C 逻辑用 Rust 重写。C 的调用保留作为回退,万一新代码有问题,随时切回去。
第四步:重复,一个接一个来
再找下一个危险的边缘接口,重复上面的步骤。不求快,求稳。每个迭代都让财务看到真实的崩溃次数下降和延迟稳定。
按接口面积迁移,不是按代码行数迁移。先处理最危险的边缘,不是最大的函数。这就是 FFI Sandwich 渐进式迁移的精髓。
怎么知道迁移在起效?
盯四个数:
- 崩溃率:每百万请求的 panic/段错误次数,目标是 FFI 边界引起的归零
- 热路径 p95:加三明治前后对比,+/-3% 以内
- 消灭的 bug 类别:输入校验类、生命周期类,跟踪"又消灭了一类 bug”
- 迁移节奏:每个 sprint 包一个接口,每两个 sprint 搬一块逻辑
这些数字是你和老板沟通的语言。别说"Rust 更安全",说"上个月崩溃减少了 73%",后者能让预算批下来。
这周就能做的三件事
看到这里别光点头,打开终端。
1. 包一个函数
选 C 代码库里最简单的一个函数,用本文的出参模式包一层 Rust 接口。如果需要分配内存,别忘了写 free_*。
2. 建一个对比测试
写个测试工具,准备 100 个边界输入,C 跑一遍、Rust 跑一遍、对比输出。扔进 CI。
3. 数一下调用次数
统计 FFI 函数每秒被调几次、平均批量多大。如果调用量很高但每次只处理一条数据,先批量化,再迁移逻辑。
记住:不要替换在赚钱的代码,包住它。
你的 C 代码库里,最让你头疼的那个函数是哪个?动不动就段错误的解析器,还是跑了十年没人敢碰的加密模块?评论区聊聊,说不定你的坑别人也踩过。
下一篇我们聊怎么把 FFI 三明治升级成"可观测版本",让每次跨边界调用都有日志、有指标、有报警,把迁移过程变得像换轮胎一样可控。
觉得有用?
- 点赞:让更多人看到
- 转发:分享给还在纠结"要不要重写"的同事
- 关注:关注梦兽编程,不错过更多 Rust 实战经验
- 留言:有迁移经验或踩坑故事?评论区聊
你的支持是我坚持写下去的理由。