前面几篇讲的是函数级 FFI:extern "C"、字符串、内存归属、回调和 void *ctx。
真正交付给 C 项目时,还要回答另一个问题:
C 工程到底链接什么文件?
头文件声明哪些符号?
这些符号的 ABI 能不能长期稳定?
升级 Rust 代码后,C 侧要不要改?
C 开发者熟悉 .a、.so、.h 和 ABI。Rust 交付给 C 使用时,也要回到这些概念,而不是把 Cargo 内部模型暴露给 C。
C 调用方看到的是库文件和头文件
Rust crate 内部可以有模块、泛型、trait、Result、String、Vec。
C 调用方不应该看到这些。C 侧应该看到:
libdevice.a 或 libdevice.so
device.h
链接参数
运行时库搜索路径
版本和 ABI 说明
头文件里声明 C ABI 函数:
#include <stddef.h>
#include <stdint.h>
typedef enum {
DEVICE_OK = 0,
DEVICE_NULL = 1,
DEVICE_FAILED = 2,
} device_status_t;
device_status_t device_checksum(const uint8_t *data, size_t len, uint16_t *out);
Rust 侧导出对应符号:
#[repr(C)]
pub enum DeviceStatus {
Ok = 0,
Null = 1,
Failed = 2,
}
#[unsafe(no_mangle)]
pub extern "C" fn device_checksum(
data: *const u8,
len: usize,
out: *mut u16,
) -> DeviceStatus {
if data.is_null() || out.is_null() {
return DeviceStatus::Null;
}
let data = unsafe { std::slice::from_raw_parts(data, len) };
let value = data.iter().map(|b| *b as u16).sum();
unsafe {
*out = value;
}
DeviceStatus::Ok
}
这就是边界:C 看到稳定 ABI,Rust 内部继续保持 Rust 风格。
staticlib:把 Rust 打成静态库
如果要给 C 链接静态库,在 Cargo.toml 里设置:
[lib]
crate-type = ["staticlib"]
构建后会得到类似:
target/release/libdevice.a
C 侧链接时把它当普通静态库:
cc main.c -L target/release -ldevice -o app
静态库的特点是:
链接进最终可执行文件
部署时少一个运行时 .so
升级库通常要重新链接应用
产物可能更大
还要处理 Rust 依赖的系统库链接
staticlib 适合嵌入到已有 C 程序、交付单个二进制、或部署环境不方便放动态库的场景。
cdylib:生成 C 能加载的动态库
如果要生成 .so、.dylib 或 .dll,用 cdylib:
[lib]
crate-type = ["cdylib"]
构建后可能得到:
target/release/libdevice.so
C 侧链接:
cc main.c -L target/release -ldevice -o app
运行时还要让动态链接器找到 .so:
LD_LIBRARY_PATH=target/release ./app
动态库的特点是:
应用运行时加载库文件
可以独立升级库,但 ABI 必须兼容
需要处理库搜索路径、rpath、安装位置
对符号导出和版本管理更敏感
给 C 项目交付 Rust 动态库时,cdylib 比 Rust 自己的 dylib 更合适。dylib 面向 Rust 编译器生态,不是给 C 当稳定 ABI 的默认选择。
不要把 Rust ABI 当交付契约
Rust 普通函数、trait、泛型、String、Vec、Result 都不是 C ABI 契约。
下面这种不是 C 能稳定调用的接口:
pub fn parse(data: &[u8]) -> Result<Vec<u8>, ParseError> {
// ...
Ok(Vec::new())
}
给 C 的边界应该是:
#[unsafe(no_mangle)]
pub extern "C" fn device_parse(
data: *const u8,
len: usize,
out: *mut DeviceBuffer,
) -> DeviceStatus {
// boundary checks, conversion, error mapping
DeviceStatus::Ok
}
第一层规则:
Rust 内部 API 可以变
C ABI 包装层要稳定
如果每次重构 Rust 内部模块都影响 C 头文件,那说明边界放错了位置。
符号导出要可控
C 链接器找的是符号名。Rust 默认会改写符号名,所以导出 C 函数时要明确:
#[unsafe(no_mangle)]
pub extern "C" fn device_version() -> u32 {
1
}
这会导出 device_version 这个符号,而不是 Rust mangled name。
但不要把所有内部函数都 no_mangle pub extern "C"。导出符号越多,ABI 承诺越大。更稳的做法是只导出一小组 C API:
device_create
device_destroy
device_read
device_write
device_last_error
内部 Rust 函数保持普通 fn 或 pub(crate),不要成为 C ABI 的一部分。
如果动态库需要严格控制导出符号,后续还可以用 linker version script、导出列表或平台特定配置。这属于发布工程问题,但原则不变:导出面越小,维护成本越低。
头文件是 ABI 的文字版本
Rust 编译通过,不代表 C 调用方会正确使用。
头文件要写清楚:
/*
* Returns DEVICE_OK on success.
* data may be NULL only when len is 0.
* out must not be NULL.
* The function does not retain data or out after returning.
*/
device_status_t device_checksum(const uint8_t *data, size_t len, uint16_t *out);
对 C 调用方来说,头文件和注释就是 API。它必须说明:
- 指针是否允许为
NULL - 长度单位是什么
- 输出参数由谁提供
- 返回值怎么解释
- 函数是否保存指针
- 是否线程安全
- 哪个版本开始引入这个函数
不要指望 C 用户读 Rust 源码来理解这些契约。
ABI 稳定不是源码兼容
C 头文件不变,也不代表 ABI 一定兼容。
这些改动都可能破坏 ABI:
- 改变
#[repr(C)]结构体字段顺序 - 改变 enum 的底层表示或取值
- 改变函数参数类型
- 改变
size_t和固定宽度整数的对应关系 - 改变谁分配和谁释放内存
- 改变动态库导出的符号名
- 改变回调线程或重入规则
#[repr(C)] 只是在 Rust 侧请求 C 风格布局。你仍然需要把它和 C 头文件保持一致。
必要时给 ABI 加测试:
#[test]
fn layout_matches_c_contract() {
assert_eq!(std::mem::size_of::<DeviceStatus>(), 4);
assert_eq!(std::mem::align_of::<DeviceHeader>(), 4);
}
更严格的项目会用 bindgen/cbindgen、C 编译测试、ABI 检查工具或符号列表比较。
版本号要区分 API 和 ABI
Rust crate 的版本号服务 Rust 包生态。C ABI 还需要自己的兼容性判断。
动态库常见做法是:
源代码版本:1.4.2
C API 版本:device_api_version()
动态库 soname:libdevice.so.1
头文件宏:DEVICE_API_VERSION
例如导出一个版本函数:
#[unsafe(no_mangle)]
pub extern "C" fn device_api_version() -> u32 {
1
}
C 侧可以在初始化时检查:
if (device_api_version() != DEVICE_API_VERSION) {
/* reject incompatible library */
}
版本策略不一定复杂,但必须存在。尤其是动态库,一旦被多个 C 程序或脚本依赖,ABI 就不能随意改。
这一篇先记住什么
Rust 交付给 C 使用时,核心不是“Cargo 能不能编译”,而是:
staticlib -> 生成给 C 静态链接的 .a
cdylib -> 生成给 C 动态链接/加载的 .so/.dylib/.dll
dylib -> 面向 Rust,不是 C ABI 默认选择
extern "C" -> C 调用约定
no_mangle -> 导出稳定符号名
#[repr(C)] -> C ABI 布局边界
头文件 -> C 调用方看到的真实契约
ABI 稳定 -> 函数签名、布局、符号、内存归属、线程规则都不能随意变
把 Rust 用在 C 项目里,最好分成两层:内部 Rust API 可以持续重构,外层 C ABI 保持小而稳定。
下一篇讲 build.rs、#[link] 和 pkg-config。库形态解决“产物是什么”,构建脚本解决“Rust 或 C 编译器怎样找到并链接它”。