写 FFI 时,extern "C" 只是入口。
真正容易出事故的地方通常是三类:
C 字符串到底是不是有效的 NUL 结尾字符串
返回的指针由谁分配、由谁释放、用哪个函数释放
错误是整数码、输出参数,还是错误文本
C 代码里这些靠接口文档和团队约定维护。Rust 可以把一部分规则包进类型,但跨语言边界本身仍然要设计清楚。
这一篇专门把字符串、内存归属和错误返回放在一起讲。前面两篇 FFI 分别讲 C 调 Rust、Rust 调 C;这里看两边都会遇到的边界契约。
const char * 不是 &str
C 里的字符串通常是:
const char *name;
它可能表示:
- NUL 结尾字符串
- 指向只读静态内存
- 指向调用方临时 buffer
- 允许为
NULL - 不是 UTF-8,而是本地编码或二进制名字
Rust 的 &str 表示一段已知长度、合法 UTF-8 的文本。它不等于 const char *。
C 传进来的字符串,边界上先用 CStr:
use std::ffi::CStr;
use std::os::raw::c_char;
pub unsafe fn name_from_c(ptr: *const c_char) -> Result<&'static CStr, NameError> {
if ptr.is_null() {
return Err(NameError::Null);
}
Ok(CStr::from_ptr(ptr))
}
这个例子故意有问题:返回 &'static CStr 通常不对。CStr::from_ptr 得到的引用到底能活多久,取决于 C 指针背后的内存。边界函数通常不要把这个引用长期返回,而是立即使用或复制成 Rust 拥有的值。
更常见的安全包装是复制:
fn copy_name_from_c(ptr: *const c_char) -> Result<String, NameError> {
if ptr.is_null() {
return Err(NameError::Null);
}
let name = unsafe { CStr::from_ptr(ptr) };
let name = name.to_str().map_err(|_| NameError::InvalidUtf8)?;
Ok(name.to_owned())
}
复制以后,Rust 拿到的是自己的 String,不再依赖 C 那块内存是否继续有效。
传给 C 的字符串用 CString
Rust 的 &str 不能直接传给 C:
let name = "device";
// C 需要 const char *,不是 &str
边界上用 CString:
use std::ffi::CString;
fn open_device(name: &str) -> Result<(), OpenError> {
let c_name = CString::new(name).map_err(|_| OpenError::InteriorNul)?;
let ret = unsafe { ffi::device_open(c_name.as_ptr()) };
if ret == 0 {
Ok(())
} else {
Err(OpenError::Failed(ret))
}
}
CString::new 会拒绝内部 NUL 字节,因为 C 会在第一个 NUL 处停止。
还要注意 as_ptr() 的生命周期:这个指针只在 c_name 活着时有效。如果 C 函数只是同步读取,没问题;如果 C 会保存这个指针,不能这样传。
要让 C 保存字符串,必须重新设计归属:
C 复制字符串,Rust 只临时传入
或 Rust 把拥有的 CString 交给 C,并提供 Rust 释放函数
或 Rust 持有 owner,C 只保存配套注册对象里的指针
不能只把一个临时 CString::as_ptr() 交给 C 长期保存。
谁分配,谁释放
跨语言返回内存时,第一条规则是:
哪边分配,通常就由哪边提供释放函数。
Rust 分配字符串给 C:
use std::ffi::CString;
use std::os::raw::c_char;
#[unsafe(no_mangle)]
pub extern "C" fn device_make_name() -> *mut c_char {
match CString::new("device-01") {
Ok(name) => name.into_raw(),
Err(_) => std::ptr::null_mut(),
}
}
#[unsafe(no_mangle)]
pub extern "C" fn device_free_string(ptr: *mut c_char) {
if ptr.is_null() {
return;
}
unsafe {
drop(CString::from_raw(ptr));
}
}
C 侧契约必须写清楚:
char *device_make_name(void);
void device_free_string(char *ptr);
调用方必须知道:
返回值可能为 NULL
非 NULL 返回值必须用 device_free_string 释放
不能用 free()
不能重复释放
反过来,如果 C 分配内存给 Rust,Rust 必须调用 C 的释放函数,不能用 Rust 的 allocator 释放。
buffer 输出优先让调用方提供内存
跨语言接口里,很多时候让调用方提供 buffer 更简单。
C API 常见形状:
int device_get_name(char *out, size_t capacity, size_t *out_len);
Rust 导出给 C 时也可以采用这个模型:
#[repr(C)]
pub enum DeviceError {
Ok = 0,
Null = 1,
TooSmall = 2,
}
#[unsafe(no_mangle)]
pub extern "C" fn device_get_name(
out: *mut u8,
capacity: usize,
out_len: *mut usize,
) -> DeviceError {
if out.is_null() || out_len.is_null() {
return DeviceError::Null;
}
let name = b"device-01\0";
unsafe {
*out_len = name.len();
}
if capacity < name.len() {
return DeviceError::TooSmall;
}
unsafe {
std::ptr::copy_nonoverlapping(name.as_ptr(), out, name.len());
}
DeviceError::Ok
}
这个接口的好处是:内存由 C 调用方提供,Rust 不把新分配内存交给 C。缺点是调用方要处理 TooSmall,可能需要先问长度再分配。
这类接口适合高频调用、嵌入式边界、或不想跨 allocator 传内存的场景。
错误码适合跨 ABI,Result 留在 Rust 内部
Rust 内部应该用 Result:
fn parse_config(text: &str) -> Result<Config, ConfigError> {
// ...
Ok(Config {})
}
C 边界应该返回 C 能理解的形状:
#[repr(C)]
pub enum ConfigStatus {
Ok = 0,
Null = 1,
InvalidUtf8 = 2,
ParseFailed = 3,
}
边界函数负责映射:
#[unsafe(no_mangle)]
pub extern "C" fn config_parse(name: *const c_char, out: *mut ConfigHandle) -> ConfigStatus {
if name.is_null() || out.is_null() {
return ConfigStatus::Null;
}
let name = unsafe { CStr::from_ptr(name) };
let Ok(name) = name.to_str() else {
return ConfigStatus::InvalidUtf8;
};
match parse_config(name) {
Ok(config) => {
unsafe { *out = ConfigHandle::from_config(config) };
ConfigStatus::Ok
}
Err(_) => ConfigStatus::ParseFailed,
}
}
不要把 Rust 的 Result、String、Box<dyn Error> 直接跨 C ABI 暴露出去。
错误文本也要有归属
C 调用方常常希望拿到错误文本。最稳的办法有两种。
第一种:返回静态字符串:
#[unsafe(no_mangle)]
pub extern "C" fn config_status_message(status: ConfigStatus) -> *const c_char {
match status {
ConfigStatus::Ok => c"ok".as_ptr(),
ConfigStatus::Null => c"null pointer".as_ptr(),
ConfigStatus::InvalidUtf8 => c"invalid utf-8".as_ptr(),
ConfigStatus::ParseFailed => c"parse failed".as_ptr(),
}
}
这种字符串不需要调用方释放,但内容只能是固定文本。
第二种:返回新分配字符串,并提供释放函数。这个模式更灵活,但归属规则必须写清楚。
不要返回指向临时 String 的指针:
// 错误模型:函数返回后 String 已释放
// CString::new(message).unwrap().as_ptr()
如果要返回动态错误文本,要么让调用方提供 buffer,要么 into_raw() 后配套 free 函数。
头文件要写所有权规则
FFI 的很多 bug 不是 Rust 代码写错,而是 C 头文件没说清楚。
头文件旁边至少要说明:
/*
* Returns 0 on success.
* out/out_len must not be NULL.
* If capacity is too small, returns DEVICE_TOO_SMALL and writes required length to out_len.
* The output string is NUL-terminated when DEVICE_OK is returned.
*/
int device_get_name(char *out, size_t capacity, size_t *out_len);
/*
* Returns a Rust-allocated string. Caller must release it with device_free_string.
* Do not call free() on the returned pointer.
*/
char *device_make_name(void);
void device_free_string(char *ptr);
Rust 的类型系统帮不了 C 调用方读懂你的隐含约定。跨语言 API 必须把这些约定写成 C 能看懂的文档和头文件。
这一篇先记住什么
C/Rust 边界上,字符串、内存和错误要分开设计:
C const char * 输入 -> *const c_char -> CStr -> 可选 UTF-8 校验 -> String/&str
Rust 字符串传给 C -> CString,且 as_ptr 不能被 C 长期保存
Rust 分配给 C -> into_raw + Rust free 函数
C 分配给 Rust -> C free 函数 + Rust Drop 包装
调用方提供 buffer -> ptr + capacity + out_len + TooSmall
Rust Result -> 边界转成 C 错误码
错误文本 -> 静态字符串,或明确归属的动态字符串
FFI 设计里最重要的问题不是“怎么把类型写出来”,而是每个指针背后的内存、生命周期和错误语义由谁负责。
下一篇讲 FFI 回调和 void *ctx。那是同一类问题的另一面:C 保存函数指针和上下文以后,Rust 怎样保证回调对象还活着、线程规则清楚、注销时不会悬垂。