让 C 调用 Rust,不是把 Rust 函数原样暴露给 C。
C 能理解的是 C ABI:符号名、调用约定、整数、指针、结构体布局、谁分配内存、谁释放内存、错误怎么返回。Rust 内部的 String、Vec、Result、trait、panic,都不应该直接跨过 C 边界。
更稳的模型是:
C 调用
-> extern "C" 包装函数
-> 检查指针和长度
-> 转成 Rust 安全类型
-> 调用内部 Rust 逻辑
-> 把结果转成 C 能理解的返回值
这一篇只讲“C 调 Rust”。Rust 调 C 下一篇再讲。
Rust ABI 不等于 C ABI
Rust 普通函数不能直接当作 C 函数导出:
fn checksum(data: &[u8]) -> u16 {
data.iter().map(|b| *b as u16).sum()
}
这个函数参数是 &[u8],在 Rust 里是安全的 slice 视图。但 C 不知道 Rust slice 的 ABI,也不知道 Rust 的函数名改写规则。
要给 C 调用,需要写一个 C ABI 包装:
#[unsafe(no_mangle)]
pub extern "C" fn iot_checksum(ptr: *const u8, len: usize) -> u16 {
if ptr.is_null() || len == 0 {
return 0;
}
let data = unsafe { std::slice::from_raw_parts(ptr, len) };
checksum(data)
}
fn checksum(data: &[u8]) -> u16 {
data.iter().map(|b| *b as u16).sum()
}
这里分成两层:
iot_checksum是 C 边界,使用裸指针和长度checksum是 Rust 内部逻辑,使用安全 slice
这个分层很重要。FFI 边界可以不优雅,但内部逻辑应该尽快回到安全 Rust。
C 头文件要写清楚
C 侧看到的应该是头文件:
#include <stddef.h>
#include <stdint.h>
uint16_t iot_checksum(const uint8_t *ptr, size_t len);
头文件就是 C 调用方的契约。它要说清楚:
- 指针是否允许为
NULL len的含义- 返回值怎样解释
- 函数是否会保存指针
- 是否线程安全
Rust 代码里写了什么不等于 C 调用方能知道什么。跨语言边界必须用 C 能读懂的接口说明。
错误返回优先用 C 能理解的形式
Rust 内部可以用 Result:
fn parse_packet(data: &[u8]) -> Result<u8, ParseError> {
if data.len() < 2 {
return Err(ParseError::TooShort);
}
Ok(data[0])
}
C 边界更适合返回整数错误码,并用输出参数带回结果:
#[repr(C)]
pub enum IotError {
Ok = 0,
Null = 1,
TooShort = 2,
}
#[unsafe(no_mangle)]
pub extern "C" fn iot_parse_first(ptr: *const u8, len: usize, out: *mut u8) -> IotError {
if ptr.is_null() || out.is_null() {
return IotError::Null;
}
let data = unsafe { std::slice::from_raw_parts(ptr, len) };
match parse_packet(data) {
Ok(value) => {
unsafe { *out = value };
IotError::Ok
}
Err(ParseError::TooShort) => IotError::TooShort,
}
}
这看起来比 Rust 内部代码笨,但它适合 C ABI:
- 返回值是 C 可理解的 enum/整数
- 输出参数由 C 提供
- 空指针在边界检查
- Rust 内部错误被映射成 C 错误码
不要把 Result<T, E> 直接暴露给 C。
字符串边界用 CStr 和 CString
C 传入字符串,通常是 NUL 结尾的 const char *。
Rust 边界可以这样处理:
use std::ffi::CStr;
use std::os::raw::c_char;
#[unsafe(no_mangle)]
pub extern "C" fn iot_name_len(name: *const c_char) -> usize {
if name.is_null() {
return 0;
}
let c_name = unsafe { CStr::from_ptr(name) };
match c_name.to_str() {
Ok(text) => text.len(),
Err(_) => 0,
}
}
这里有两层边界:
CStr::from_ptr要求 C 指针有效且 NUL 结尾to_str要求内容是合法 UTF-8
C 字符串和 Rust &str 不是一回事。C 字符串先转 CStr,确认编码后才能变成 &str。
Rust 分配的内存要提供释放函数
如果 Rust 返回一段新分配的字符串给 C,必须同时提供释放函数。
use std::ffi::CString;
use std::os::raw::c_char;
#[unsafe(no_mangle)]
pub extern "C" fn iot_make_name() -> *mut c_char {
let s = CString::new("device-01").unwrap();
s.into_raw()
}
#[unsafe(no_mangle)]
pub extern "C" fn iot_free_string(ptr: *mut c_char) {
if ptr.is_null() {
return;
}
unsafe {
drop(CString::from_raw(ptr));
}
}
规则必须写清楚:
iot_make_name 返回的指针由 Rust 分配
C 调用方必须用 iot_free_string 释放
不能用 free()
不能重复释放
这是 FFI 里最重要的边界之一:谁分配,谁释放,用哪个函数释放。
panic 不应该跨过 C 边界
Rust 的 panic 不应该穿过 C ABI 边界。
FFI 导出函数应该尽量捕获或避免 panic,把错误转成 C 能理解的返回值。简单策略是:边界函数不要 unwrap 外部输入,不要让内部错误直接 panic。
例如:
#[unsafe(no_mangle)]
pub extern "C" fn iot_do_work(ptr: *const u8, len: usize) -> IotError {
if ptr.is_null() {
return IotError::Null;
}
let data = unsafe { std::slice::from_raw_parts(ptr, len) };
match do_work(data) {
Ok(()) => IotError::Ok,
Err(_) => IotError::TooShort,
}
}
内部 Rust 可以用 Result,边界统一转错误码。
这一篇先记住什么
C 调 Rust 的第一层对应关系是:
Rust 内部函数 -> 普通安全 Rust API
C 可调用函数 -> pub extern "C" fn
Rust slice -> C 指针 + len
Rust Result -> C 错误码 + 输出参数
Rust String / Vec -> 不直接跨 C 边界
C const char * -> CStr,再按需转 &str
Rust 分配给 C 使用的内存 -> 必须提供 Rust 释放函数
panic -> 不跨 C ABI,转错误返回
FFI 的核心不是语法,而是边界契约。
C 看到的是 ABI、指针、整数和头文件;Rust 内部可以继续用 Result、slice、String、Vec 和 trait。好的 FFI 设计,就是用一层薄包装把两边隔开。
下一篇可以讲 Rust 调 C。那会反过来处理链接 C 库、声明 extern "C"、调用 unsafe C 函数和把 C 返回值包装成 Rust API。