Skip to content

Rust FFI 与 C/C++ 互操作

1. 这是什么

Rust 虽然是一门现代系统语言,但它并不是孤立存在的。
在真实工程里,你经常会遇到这些场景:

  • 需要复用已有 C / C++ 库
  • 需要把 Rust 暴露给别的语言调用
  • 需要接入操作系统层面的原生接口
  • 需要在已有老系统中逐步引入 Rust

这类问题通常都落在一个主题上:

  • FFI(Foreign Function Interface,外部函数接口)

简单说,FFI 讨论的是:

  • Rust 怎样和其他语言交换函数、数据、内存与边界责任

而在 Rust 生态里,最常见、最基础的互操作对象就是 C / C++。

2. 为什么这一题很重要

因为很多人学习 Rust 时,会自然以为所有能力都得“纯 Rust 重写”。
但真实世界并不是这样:

  • 已有大量成熟 C / C++ 库
  • 操作系统 ABI 和底层接口很多都以 C 方式暴露
  • 企业老系统里常有巨量 C / C++ 资产
  • 某些性能关键模块需要跨语言逐步迁移

所以 FFI 的价值不是“炫技”,
而是让 Rust 能进入真实生态,而不是只能活在理想化全新项目里。

3. 先建立直觉

3.1 FFI 的本质不是“语法桥接”,而是“边界契约”

很多初学者会把 FFI 理解成:

  • 调一下外部函数
  • 把某个头文件转过来
  • 把结构体字段对齐一下

这些当然重要,但它们只是表面。
FFI 真正的核心是:

  • 跨语言边界上的契约是否清楚

这个契约包括:

  • 调用约定
  • 数据布局
  • 所有权
  • 生命周期
  • 错误传播
  • 线程安全
  • 谁负责分配和释放内存

一旦这些没说清楚,问题就不会只是“调用失败”,
而是可能直接变成未定义行为。

3.2 跨语言边界最重要的是“别假设对方懂 Rust”

Rust 内部有很多强约束:

  • 所有权
  • 借用
  • 生命周期
  • 枚举语义
  • trait 抽象
  • panic 行为

但一旦过了 FFI 边界,对方并不理解这些概念。
从 C / C++ 的角度看,真正能稳定理解的是:

  • ABI
  • 原始指针
  • 明确数据布局
  • 显式内存责任

所以 FFI 边界的一个基本原则是:

  • 不要把 Rust 内部抽象直接幻想成对方也能自然理解

4. 为什么 FFI 天生和 unsafe 强相关

4.1 因为编译器无法完整证明跨语言边界的安全性

Rust 在语言内部能做很多安全检查,
但到了 FFI 边界,编译器无法替你完全验证这些事情:

  • 外部函数是否真的按声明那样工作
  • 指针是否有效
  • 数据布局是否真的匹配
  • 对方是否会越界写内存
  • 对方是否会长期持有你传过去的地址

所以 FFI 常常意味着:

  • 一部分安全责任从编译器转移回程序员

这也是为什么 FFI 总会和 unsafe 紧密相关。

4.2 unsafe 不是“Rust 不安全了”,而是“你在手动承担边界证明责任”

这点很关键。
在 FFI 里使用 unsafe,并不是说 Rust 的安全承诺失效了,
而是说:

  • 这段边界逻辑无法完全靠编译器证明
  • 你必须自己保证前提成立

所以成熟的 FFI 实践不是“尽量别写”,
而是:

  • unsafe 压缩在最小边界里,并把不变量说明白

5. Rust 与 C/C++ 互操作真正难的是什么

5.1 难点不是“函数能不能调通”,而是“责任怎么划分”

第一次做 FFI 时,很多人最关心:

  • 能不能链接成功
  • 参数能不能传过去
  • 返回值能不能拿回来

但工程上真正危险的通常是:

  • 这块内存谁分配、谁释放
  • 这段数据什么时候失效
  • 错误怎么返回
  • panic 是否能跨边界传播
  • 多线程下谁拥有共享状态

所以 FFI 的难点不是“调用成功”,
而是:

  • 调用成功之后,还能不能长期正确地活下去

5.2 数据类型映射只是第一层,语义映射更难

int 对上 i32、把指针传过去,通常只是开始。
更难的是语义层:

  • Rust 的 String 怎么暴露给 C
  • Rust 的 Vec<T> 是否能安全交给外部长期持有
  • C++ 的对象生命周期如何映射到 Rust
  • 错误是返回码、空指针,还是结构化错误对象

这说明 FFI 实践不能只看类型对不对,
还要看语义是否被正确降维到边界契约里。

6. 为什么 C 和 C++ 要分开看

6.1 Rust 与 C 互操作通常更直接,因为 C 的 ABI 更稳定、更简单

C 风格接口通常更适合作为跨语言边界,
原因在于它更接近:

  • 简单函数
  • 明确数据布局
  • 稳定 ABI
  • 显式内存管理

所以很多复杂语言互操作,最后都会绕到一层 C ABI。

6.2 C++ 的难度更高,因为它的抽象和 ABI 都更复杂

C++ 并不仅仅是“多一点语法”的 C。
它还带来:

  • name mangling
  • 类、继承、虚函数
  • 模板
  • 异常
  • 更复杂的对象生命周期

这些都让直接互操作变得更棘手。
所以工程上常见的策略往往是:

  • 尽量在 C++ 一侧包一层更稳定的 C 风格边界
  • 再让 Rust 对接这层边界

7. 真实项目里真正该建立的能力

7.1 把 FFI 边界做窄

越宽的 FFI 边界,意味着越多无法由 Rust 自动保护的地方。
所以成熟实践往往会倾向于:

  • 让跨语言边界尽量薄
  • 边界外保持简单稳定的数据约定
  • 边界内恢复 Rust 的安全抽象

7.2 明确所有权与释放责任

FFI 里最容易出事故的常见问题之一就是:

  • 谁拥有这块内存
  • 谁负责释放
  • 释放时机是什么
  • 重复释放会不会发生
  • 外部是否会悬挂引用

如果这些没有明确写成边界契约,后续 bug 往往非常难查。

7.3 把 panic、错误和线程模型都当成边界议题

不要只盯着“数据怎么传”。
跨语言边界时,这些也都要明确:

  • panic 是否会跨边界传播
  • 错误是如何编码给外部的
  • 外部线程能否安全调用这段 Rust 逻辑
  • 全局状态是否线程安全

8. 常见误区

8.1 误区一:只要类型对应上,FFI 就没问题了

不对。
类型匹配只是开始,所有权、生命周期和 ABI 契约才是关键。

8.2 误区二:unsafe 说明 Rust 在这块没价值了

不对。
Rust 仍然能把不安全范围压缩到边界附近,并在内部恢复强约束。

8.3 误区三:Rust 可以直接自然理解 C++ 的全部抽象

不现实。
C++ 的 ABI 和对象模型复杂得多,通常需要更谨慎的桥接层。

8.4 误区四:FFI 的目标只是把函数调通

不够。
真正目标是建立长期稳定、可维护、不会悄悄踩内存的跨语言边界。

9. 一个更实用的判断思路

如果你在设计 Rust 与 C/C++ 的互操作边界,可以先问:

  1. 这层边界能否尽量缩窄,只暴露最稳定的接口
  2. 数据布局、所有权和释放责任是否写得足够清楚
  3. panic、错误、线程安全和生命周期是否都有明确约定
  4. 是否真的需要直接对接 C++ 抽象,还是先包一层 C ABI 更稳妥
  5. unsafe 是否被限制在最小必要范围,而不是扩散进业务逻辑

10. 建议学习顺序

建议按这个顺序进入:

  1. 先理解 FFI 是边界契约,而不是简单函数调用
  2. 再理解 ABI、数据布局、所有权和生命周期这些基础概念
  3. 再区分 Rust 对接 C 与对接 C++ 的难度差异
  4. 再补齐错误处理、panic 边界和线程安全意识
  5. 最后再进入 bindgen、cbindgen、构建脚本与真实项目桥接实践

11. 自测标准

  • 能解释 FFI 的核心问题为什么是边界契约而不是语法桥接
  • 能理解 Rust 与 C/C++ 互操作时为什么必须格外关注所有权和内存释放责任
  • 能知道 unsafe 在 FFI 中意味着手动承担边界安全证明责任
  • 能区分 Rust 对接 C 与对接 C++ 的难度差异
  • 能意识到一个成熟 FFI 设计的重点是让边界尽量窄、契约尽量清楚