老板说"用 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 安全模型在这一层需要你格外小心。能过关的:

FFI 边界就像海关,合法类型通过,非法类型被拦截

  • 指针:*const T*mut T
  • 固定宽度整数:i32u64usize 这些
  • #[repr(C)] 结构体:内存布局和 C 一模一样
  • 缓冲区:用 (指针, 长度) 的组合传递,快递单上写清楚"几件货、放哪儿"
  • 错误码:别用 Rust 的 Result,老老实实返回整数(0 是成功,负数是错误)。这是 extern "C" 函数的标准做法

过不了关的:

  • Rust 的 StringVecOption,它们的内存布局 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 }
}

看到了吗?模式很简单:

  1. 检查输入:空指针?拒绝。长度不对?拒绝。餐厅服务员先看你有没有预约
  2. 返回状态码:0 是成功,负数是各种错误。不用异常,不用 panic,extern "C" 函数两边都能看懂
  3. 通过出参写结果:不在 FFI 层分配内存,谁分配的谁释放
  4. 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 的场景

  1. Cargo.toml 里设置 crate-type = ["cdylib"]["staticlib"]
  2. cbindgen 从 Rust 代码自动生成 C 头文件(和 repr(C) 一致)
  3. build.rs 里链接 C 库:println!("cargo:rustc-link-lib=你的C库名")
  4. 最终产物:libyourlib.so(或 .dylib/.dll/.a)加一个 .h 头文件

C 调 Rust 的场景反过来:

  1. Rust 编译成动态库或静态库
  2. 用生成的头文件在 C/C++ 项目里引用(CMake、Bazel、Make 都行)
  3. 按平台加链接参数

工具就两个:

  • cbindgen:Rust 导出到 C 的头文件生成器
  • bindgen:C 头文件转 Rust 绑定,放在 build.rs 里用

测试:让 C 和 Rust 对答案

这步很关键。你不能光靠"我觉得它对了",你得让两边跑一样的输入,比较输出。

做法:

  1. 准备一批测试数据:边界值、大输入、各种奇葩 locale,越刁钻越好
  2. 写一个 C 测试工具:调用老的 C 接口,把输出的 hash 打印出来
  3. 写一个 Rust 测试:调用新的 Rust 接口,同样打印 hash
  4. 比较两边的结果:一样就对了,不一样就有 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 本身的锅。

perfVTune 分析时,重点看调用次数,不只是 CPU 时间。

安全增益:不花钱就能拿到的

即使底下还是 C 代码在跑,加了 Rust 这层壳,你就能拿到这些:

  • 输入校验:在 Rust 层检查 (指针, 长度) 配对,拒绝离谱的长度值。以前 C 直接信任输入导致的缓冲区溢出?没了
  • 递归和循环上限:在 Rust API 层限制递归深度和迭代次数,防止恶意输入搞死服务
  • 字符串安全:先把不可信的字符串当 &[u8] 处理,显式验证 UTF-8 之后再传给 C
  • 空指针拦截:所有 null 指针和非法枚举值在入口就被挡住

Rust 不是防火墙。C 代码内部自己乱搞缓冲区,那是 C 自己的问题。但至少外部输入引起的安全问题,被你挡在门外了。这就是 Rust 安全模型在 C 迁移 Rust 过程中最直接的价值。

四步渐进式迁移:别想太远,先走第一步

渐进式迁移:C 模块逐个被 Rust 替换

理论讲完了,怎么落地?

第一步:挑一个最危险的接口,包一层 Rust

找那个最容易崩溃、最容易被攻击的 C 函数。用 Rust 写个安全入口,加上输入校验,灰度放 10% 的流量进来。

先给最爱漏水的那个水龙头换个新的,其他的先不动。

第二步:两条路跑一周,比对结果

让 C 原路径和 FFI 三明治路径同时跑,对比输出。有差异就修,直到完全一致。

第三步:把一块逻辑搬到 Rust 里

在三明治内部,把一段 C 逻辑用 Rust 重写。C 的调用保留作为回退,万一新代码有问题,随时切回去。

第四步:重复,一个接一个来

再找下一个危险的边缘接口,重复上面的步骤。不求快,求稳。每个迭代都让财务看到真实的崩溃次数下降和延迟稳定。

按接口面积迁移,不是按代码行数迁移。先处理最危险的边缘,不是最大的函数。这就是 FFI Sandwich 渐进式迁移的精髓。

怎么知道迁移在起效?

盯四个数:

  1. 崩溃率:每百万请求的 panic/段错误次数,目标是 FFI 边界引起的归零
  2. 热路径 p95:加三明治前后对比,+/-3% 以内
  3. 消灭的 bug 类别:输入校验类、生命周期类,跟踪"又消灭了一类 bug”
  4. 迁移节奏:每个 sprint 包一个接口,每两个 sprint 搬一块逻辑

这些数字是你和老板沟通的语言。别说"Rust 更安全",说"上个月崩溃减少了 73%",后者能让预算批下来。

这周就能做的三件事

看到这里别光点头,打开终端。

1. 包一个函数

选 C 代码库里最简单的一个函数,用本文的出参模式包一层 Rust 接口。如果需要分配内存,别忘了写 free_*

2. 建一个对比测试

写个测试工具,准备 100 个边界输入,C 跑一遍、Rust 跑一遍、对比输出。扔进 CI。

3. 数一下调用次数

统计 FFI 函数每秒被调几次、平均批量多大。如果调用量很高但每次只处理一条数据,先批量化,再迁移逻辑。

记住:不要替换在赚钱的代码,包住它。


你的 C 代码库里,最让你头疼的那个函数是哪个?动不动就段错误的解析器,还是跑了十年没人敢碰的加密模块?评论区聊聊,说不定你的坑别人也踩过。

下一篇我们聊怎么把 FFI 三明治升级成"可观测版本",让每次跨边界调用都有日志、有指标、有报警,把迁移过程变得像换轮胎一样可控。


觉得有用?

  1. 点赞:让更多人看到
  2. 转发:分享给还在纠结"要不要重写"的同事
  3. 关注:关注梦兽编程,不错过更多 Rust 实战经验
  4. 留言:有迁移经验或踩坑故事?评论区聊

你的支持是我坚持写下去的理由。