16.1 这节课解决什么问题
到目前为止 Rust 都在自己的世界里运行。可现实是:Rust 很少孤军奋战 ------要么被 App 壳(Swift/Kotlin/JS/Python)调用,要么调用别人的 C 库。跨语言调用就叫 FFI(Foreign Function Interface)。
本课先不用任何工具,亲手搭一座 Rust↔C 的桥,把下面五件事彻底搞懂:
rust
① extern "C":把 Rust 函数按 C 的调用约定导出(入口协议)
② repr(C):让 Rust 结构体的内存布局与 C 一致(数据协议)
③ FFI-safe 类型:哪些类型能过桥、哪些不能(String/Vec 不能直接传)
④ 指针与所有权:过桥后谁拥有、谁释放(所有权协议)
⑤ 异常边界:panic 不能越过 FFI(安全协议)
搞懂这 5 条,第 19-21 课的 UniFFI 在你眼里就不再是魔法------它只是自动生成上面这些样板的代码生成器。
💡 为什么先学"手动版"再上 UniFFI?UniFFI 生成的绑定一旦出问题(布局不对、字符串乱码、崩溃),你必须能读懂底层在干嘛。本课 = 读懂 UniFFI 生成代码的"字典"。
16.2 调用约定与导出:extern "C" + no_mangle
16.2.1 Rust 函数怎么被 C 找到
C 链接器按符号名找函数。Rust 编译器会改函数名(mangling,带上模块/泛型信息),且默认不保证 ABI 稳定。两个咒语解决:
rust
// lib.rs ------ 导出一个可被 C 调用的函数
#[no_mangle] // ① 关闭名字改编:符号就叫 rust_add
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {
a + b
}
extern "C":告诉编译器按 C 的调用约定(参数怎么放寄存器/栈、谁清理)导出;#[no_mangle]:导出符号名保持rust_add不变,C 那边才能extern int rust_add(int,int);直接链接;- 只标一个不标另一个都会失败/找不到符号。
⚠️ 一个函数导出即成为公共 API 契约:签名(尤其参数类型)一旦发布就不能随便改,改了老调用方就崩。UniFFI 帮你生成的是带版本管理的导出,但你仍要理解"导出 = 冻结签名"。
16.2.2 工程形态:cdylib 静态库
要让别的语言链接,Cargo.toml 要声明产物类型:
toml
[lib]
name = "myffi"
crate-type = ["cdylib"] # 动态库:.so / .dylib / .dll(App/解释器加载用)
bash
cargo build --release
# 产物:target/release/libmyffi.dylib (macOS)/ .so(Linux)/ .dll(Windows)
💡 如果还要给 Rust 自己当库用,写成
crate-type = ["cdylib", "rlib"]。实战篇 17 课的 core 只被 Rust 内部用是lib;19 课导出到 Swift/Kotlin 时加cdylib(或staticlib给 iOS)。
16.3 内存布局:repr(C) 让结构体"长成 C 的样子"
16.3.1 默认布局不可靠
Rust 结构体默认布局是未规定的 :编译器会重排字段以省内存(对齐填充优化)。C 那边按它自己的规则解析同一块内存就会错位。显式声明 repr(C) 后,布局与 C 编译器一致:
rust
// Rust 侧:把"点"按 C 布局导出
#[repr(C)]
pub struct Point {
pub x: f64,
pub y: f64,
}
#[no_mangle]
pub extern "C" fn point_distance(a: *const Point, b: *const Point) -> f64 {
// 从裸指针解引用------unsafe!调用方保证指针有效
let a = unsafe { &*a };
let b = unsafe { &*b };
let dx = a.x - b.x;
let dy = a.y - b.y;
(dx * dx + dy * dy).sqrt()
}
对应 C 侧:
c
// caller.c
#include <math.h>
#include <stdio.h>
typedef struct { double x, y; } Point;
extern double point_distance(const Point *a, const Point *b);
int main(void) {
Point p1 = {0.0, 0.0};
Point p2 = {3.0, 4.0};
printf("distance = %f\n", point_distance(&p1, &p2)); // 5.0
return 0;
}
bash
# 编译 C 侧并链接 Rust 动态库
cc caller.c -L target/release -lmyffi -lm -o caller
# macOS 上动态库要能找到:DYLD_LIBRARY_PATH=target/release ./caller
# Linux: LD_LIBRARY_PATH=target/release ./caller
# 输出:distance = 5.000000
16.4 FFI-safe 类型:哪些能过桥
| Rust 类型 | 能过桥? | 说明 |
|---|---|---|
固定宽整数:i32/u64/f32/f64 |
✅ | C 的 int/long/double 对应固定版 |
bool(在 C 里要按 uint8_t 约定) |
⚠️ | 明确用 u8 更稳 |
*const T / *mut T |
✅ | C 指针直传 |
&T(按指针传) |
✅(作为指针参数) | 生命周期由约定管 |
String / Vec / &str |
❌ | 内部是 ptr+len+cap 三件套且有所有权语义,不能直接过 |
Result<T, E> / Option 复杂变体 |
❌ | 无稳定 ABI,需自己映射 |
fn 指针 |
✅ | C 回调 |
过桥的两条大原则:
rust
原则一:标量按值传,复合按指针传(&T 或 *const T)
原则二:任何"会 alloc 的数据"(String/Vec)跨越边界时,
必须显式转换成"指针 + 长度"并规定好谁负责释放
16.5 字符串与所有权:过桥的两座桥
16.5.1 字符串出 Rust(Rust → C)
Rust 的 String 是拥有型,不能直接传。标准姿势:
rust
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
/// 生成问候语,返回 C 字符串指针。
/// 调用方用完后必须调 myffi_free_string 释放!
#[no_mangle]
pub extern "C" fn greet(name: *const c_char) -> *mut c_char {
if name.is_null() {
return std::ptr::null_mut();
}
// C 字符串 → Rust &str(复制进来)
let name = unsafe { CStr::from_ptr(name) };
let name = match name.to_str() {
Ok(s) => s,
Err(_) => return std::ptr::null_mut(), // 非法 UTF-8
};
// 构造结果并"泄漏"给调用方:Rust 侧不再管它
let result = CString::new(format!("你好,{name}!")).unwrap();
result.into_raw() // 移交指针;释放责任在调用方
}
/// 配套的释放函数(内存是谁 alloc 的就由谁 free 的规则)
#[no_mangle]
pub extern "C" fn myffi_free_string(ptr: *mut c_char) {
if !ptr.is_null() {
unsafe { drop(CString::from_raw(ptr)) } // 从指针"接回"所有权再释放
}
}
对应的 C 使用:
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
extern char *greet(const char *name);
extern void myffi_free_string(char *ptr);
int main(void) {
char *msg = greet("小灵");
if (msg) {
printf("%s\n", msg);
myffi_free_string(msg); // 用 Rust 的释放函数归还
}
return 0;
}
⚠️ 内存管理铁律(面试必问):谁分配、谁释放。 Rust 分配的(
into_raw出来的指针)必须由 Rust 释放(配一个*_free导出);C 的malloc的不要拿CString::from_raw去 free。UniFFI 生成代码里同样遵循这条,只是把配对做进了每次调用。
16.5.2 字符串入 Rust(C → Rust,已在上例出现过)
rust
// C 给的是 *const c_char(NUL 结尾的字节)
let c = unsafe { CStr::from_ptr(ptr) }; // 借用视图,不做拷贝
let s: &str = c.to_str().unwrap_or(""); // 转 &str(校验 UTF-8)
// 想要自己的 String? s.to_string()
16.6 panic 不能过桥:catch_unwind 兜底
panic 跨 FFI 边界是未定义行为(C 里没有 unwind 机制,直接炸掉整个进程/损坏栈)。导出函数里必须把可能 panic 的逻辑包住:
rust
use std::panic::{catch_unwind, AssertUnwindSafe};
/// 所有导出函数的"守门员":panic 就地消化成错误码
fn guard<F: FnOnce() -> i32>(f: F) -> i32 {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(code) => code,
Err(_) => {
eprintln!("Rust panic 被 FFI 边界截获");
-1 // 约定:负数为内部错误
}
}
}
#[no_mangle]
pub extern "C" fn safe_divide(a: i64, b: i64) -> i64 {
guard(|| {
if b == 0 {
panic!("除零"); // 会被 catch_unwind 接住 → -1
}
a / b
}) as i64
}
💡 工程惯例:在导出层做"薄包装 + catch_unwind",把真实逻辑放在内部函数里(不 panic 的纯逻辑),导出函数只负责"翻译参数 + 兜底"。UniFFI 生成的代码对每个导出函数都内置了类似的 panic 处理,这正是手写版教会你的安全直觉。
16.7 不透明句柄(opaque handle):藏起复杂对象
传递复杂对象(比如一个"会话管理器")时不想暴露内部结构------只传一个指针:
rust
use std::os::raw::c_char;
use std::ffi::CStr;
// 一个假装复杂的内部对象
pub struct Session {
name: String,
}
/// 创建会话:返回堆上指针(Box::into_raw),调用方持"句柄"
#[no_mangle]
pub extern "C" fn session_new(name: *const c_char) -> *mut Session {
let name = if name.is_null() { String::new() } else {
unsafe { CStr::from_ptr(name) }.to_string_lossy().into_owned()
};
Box::into_raw(Box::new(Session { name })) // 所有权交给调用方(以句柄形式)
}
#[no_mangle]
pub extern "C" fn session_name(handle: *const Session) -> *mut c_char {
if handle.is_null() { return std::ptr::null_mut(); }
let s = unsafe { &*handle };
std::ffi::CString::new(s.name.clone()).unwrap().into_raw() // 记得配 free
}
#[no_mangle]
pub extern "C" fn session_free(handle: *mut Session) {
if !handle.is_null() {
unsafe { drop(Box::from_raw(handle)) } // 接回所有权并释放
}
}
要点:Box::into_raw 把堆对象"降级"成裸指针交给对方;Box::from_raw 在归还时把所有权接回来------安全的 Rust 对象以不透明句柄活在 FFI 里,Rust 内部仍然享受所有权保障。
16.8 Python 快速验证:ctypes(不需要写 C)
macOS/Linux 自带 Python 即可验证导出(Windows 用 os.add_dll_directory):
python
# test_myffi.py
import ctypes
lib = ctypes.CDLL("target/release/libmyffi.dylib") # Linux 改 .so
# 简单整数函数
lib.rust_add.argtypes = [ctypes.c_int32, ctypes.c_int32]
lib.rust_add.restype = ctypes.c_int32
print("add:", lib.rust_add(30, 12)) # 42
# 字符串:声明返回指针 + 配套 free
lib.greet.argtypes = [ctypes.c_char_p]
lib.greet.restype = ctypes.c_void_p
lib.myffi_free_string.argtypes = [ctypes.c_void_p]
p = lib.greet(b"Python")
s = ctypes.string_at(p).decode("utf-8") # 复制出来后立刻释放
lib.myffi_free_string(p)
print(s) # 你好,Python!
💡 用 Python 当"FFI 测试台"非常高效:比写 C 快、比开 App 轻,16.9 练习全部用它验证。实战 20 课给 Swift/Kotlin 包壳时,"先 Python 验证 → 再补 UI"是省时间的标准顺序。
16.9 📝 动手练习
参考实现放 code/16-ffi/(写作时同步给出)。
- 最小导出 :建一个
cdylibcrate,导出rust_add/rust_mul;用 ctypes 从 Python 调用并断言结果。 - repr(C) 结构体 :导出
struct Vec2 { x: f64, y: f64 }的len()与dot();C 或 ctypes 各验证一次(ctypes 用class Vec2(ctypes.Structure)对位字段)。 - 字符串往返 :复刻 16.5.1,加入中文与非 UTF-8 字节两种输入(非 UTF-8 用
b"bad\xff"),验证非法输入返回 null 而非崩溃。 - panic 拦截 :导出
safe_divide,从 Python 调除零,确认返回 -1 而不是进程崩溃;再注释掉guard直接extern "C" fn里 panic,观察进程行为差异并记录。 - 句柄对象 :实现
session_new/name/free(16.7),Python 侧:创建两个会话、读名字、释放、释放后再访问应自行崩溃(ctypes segfault 是预期教学现象------体会"不透明句柄协议靠自律")。 - 所有权推演题(重点) :解释为什么
greet的返回值必须配myffi_free_string而不是让 C 直接free();为什么CStr::from_ptr之后不能持有该视图超过调用方内存的生命周期。 - 读报错 :故意忘写
#[no_mangle]或忘写extern "C",记录链接错误/undefined symbol 的现象与解决。
验收门禁:能默写导出一个函数的 3 个要素;能说出 String/Vec 不能直接过桥的替代方案;能解释 into_raw/from_raw、CString/CStr 各自的角色;能说清 catch_unwind 为什么是 FFI 导出函数的标配。
✅ 本节小结
- 导出三要素 :
#[no_mangle]+extern "C"+cdylib产物; - 布局 :
#[repr(C)]让结构体与 C 内存布局一致; - FFI-safe:标量/指针可过,String/Vec/Result 需手工映射成"指针+长度"或错误码;
- 字符串 :
CString::into_raw移交 + 配套*_free接回(谁分配谁释放);CStr::from_ptr只借不拥有; - panic :不得越过 FFI,导出层
catch_unwind兜底成错误码; - 句柄 :
Box::into_raw/from_raw让复杂 Rust 对象以不透明指针活到别处; - 验证:Python ctypes 是最快的 FFI 测试台;
- 认知:手动 FFI = 布局协议 + 所有权协议 + 安全协议三者叠加。UniFFI 的价值不是魔法,而是把这套协议自动、一致地生成。
下一课预告 :进入实战篇 。第 17 课《项目总览与核心架构》------先看我们要交付什么(AI 助手跨端 App 的 Rust 核心),再搭出 core lib crate:领域模型 + LLM 流式桥 + 历史存储的接口分层。16 课的 FFI 心智 + 13-15 课的 IO 技能将在这里第一次合体。