Rust 调 C,表面上只是多写一段 extern "C" 声明。
真正需要处理的是另一件事:C 函数没有 Rust 的借用、生命周期、Result 和析构规则。Rust 可以调用它,但编译器无法证明这个调用是否满足 C API 的前提。
更稳的模型是:
Rust 安全 API
-> 检查和准备参数
-> 小 unsafe 块调用 C
-> 把错误码、输出参数、裸指针转回 Rust 类型
C 头文件才是入口
假设已有一个 C 库,头文件长这样:
#include <stddef.h>
#include <stdint.h>
int sensor_read(uint8_t *out, size_t capacity, size_t *out_len);
C 调用方通常会这样理解它:
out是调用方提供的 buffercapacity是 buffer 容量out_len返回实际写入长度- 返回
0表示成功,非0表示错误
这些规则在 C 里靠文档和约定维护。Rust 调用它时,也必须先承认这些规则存在,而不是把它当成普通 Rust 函数。
extern 声明只是 ABI 描述
Rust 侧需要声明 C 函数:
use std::ffi::c_int;
unsafe extern "C" {
fn sensor_read(out: *mut u8, capacity: usize, out_len: *mut usize) -> c_int;
}
这段代码不是实现函数,而是告诉 Rust:
- 函数名叫
sensor_read - 使用 C 调用约定
- 参数和返回值按 C ABI 解释
- 调用这个函数需要
unsafe
unsafe extern "C" 的重点不是语法,而是边界:Rust 编译器不知道 C 函数会不会越界写、会不会保存指针、会不会在失败时留下半初始化数据。
用安全函数包住 C 调用
不要让裸指针和错误码扩散到业务代码里。
可以先写一个 Rust 包装函数:
use std::ffi::c_int;
#[derive(Debug)]
pub enum SensorError {
Failed(c_int),
OutputTooLarge,
}
pub fn read_sensor() -> Result<Vec<u8>, SensorError> {
let mut buf = vec![0_u8; 256];
let mut out_len = 0_usize;
let ret = unsafe { sensor_read(buf.as_mut_ptr(), buf.len(), &mut out_len) };
if ret != 0 {
return Err(SensorError::Failed(ret));
}
if out_len > buf.len() {
return Err(SensorError::OutputTooLarge);
}
buf.truncate(out_len);
Ok(buf)
}
这层包装做了几件事:
- Rust 分配
Vec<u8>,保证 buffer 有效 - C 只在
unsafe块里被调用 - C 错误码被转成
Result - 输出长度被检查后才用于
truncate - 调用方拿到的是拥有所有权的
Vec<u8>
调用方不需要知道 C API 用了输出参数,也不需要接触裸指针。
字符串要先变成 C 能理解的形状
C 函数如果接收 const char *:
int device_open(const char *name);
Rust 不能把 &str 直接传过去。C 要的是 NUL 结尾字符串,Rust 的 &str 只是 UTF-8 字节片段,不保证末尾有 \0。
边界上要用 CString:
use std::ffi::{c_int, CString};
use std::os::raw::c_char;
unsafe extern "C" {
fn device_open(name: *const c_char) -> c_int;
}
pub fn open_device(name: &str) -> Result<(), c_int> {
let c_name = CString::new(name).map_err(|_| -1)?;
let ret = unsafe { device_open(c_name.as_ptr()) };
if ret == 0 {
Ok(())
} else {
Err(ret)
}
}
CString::new 会拒绝内部包含 NUL 字节的字符串。as_ptr() 只在 c_name 活着时有效,所以 C 函数不能保存这个指针长期使用,除非 API 明确要求并另外设计内存归属。
C 返回指针时先问归属
C API 有时会返回指针:
const char *device_last_error(void);
如果这是 C 库内部保存的只读字符串,Rust 可以借用读取:
use std::ffi::CStr;
use std::os::raw::c_char;
unsafe extern "C" {
fn device_last_error() -> *const c_char;
}
pub fn last_error() -> Option<String> {
let ptr = unsafe { device_last_error() };
if ptr.is_null() {
return None;
}
let text = unsafe { CStr::from_ptr(ptr) };
Some(text.to_string_lossy().into_owned())
}
这段代码把 C 字符串复制成 Rust 拥有的 String。复制之后,Rust 代码不再依赖 C 库内部那块内存。
如果 C 返回的是新分配内存,规则就不同。Rust 必须调用 C 提供的释放函数,不能拿 Rust 的 allocator 去释放 C 分配的内存。
char *device_get_name(void);
void device_free_string(char *ptr);
Rust 侧可以把它封装成 RAII:
use std::ffi::CStr;
use std::os::raw::c_char;
unsafe extern "C" {
fn device_get_name() -> *mut c_char;
fn device_free_string(ptr: *mut c_char);
}
struct CDeviceString(*mut c_char);
impl Drop for CDeviceString {
fn drop(&mut self) {
if !self.0.is_null() {
unsafe { device_free_string(self.0) };
}
}
}
pub fn device_name() -> Option<String> {
let raw = unsafe { device_get_name() };
if raw.is_null() {
return None;
}
let owned = CDeviceString(raw);
let text = unsafe { CStr::from_ptr(owned.0) };
Some(text.to_string_lossy().into_owned())
}
Drop 保证函数返回时调用 C 的释放函数。Rust 代码拿到的是普通 String,C 指针不会泄漏到外层 API。
链接配置不应该淹没接口设计
Rust 知道函数声明以后,还要在链接阶段找到 C 库。
简单场景可以用 #[link]:
#[link(name = "sensor")]
unsafe extern "C" {
fn sensor_read(out: *mut u8, capacity: usize, out_len: *mut usize) -> std::ffi::c_int;
}
更复杂的项目通常会用 build.rs 输出链接参数,或者通过 pkg-config 找到系统库。
这些是构建问题,不应该改变 API 原则:外层安全 Rust API,内部小范围 unsafe,边界处明确指针、长度、字符串、错误码和释放规则。
不要让 C 保存临时 Rust 指针
这类代码很危险:
pub fn register_buffer() {
let mut buf = vec![0_u8; 128];
unsafe { c_register_buffer(buf.as_mut_ptr(), buf.len()) };
}
如果 C 库只是同步读取,可能没问题。可如果它把指针保存起来,函数返回后 buf 被释放,C 手里就剩一个悬垂指针。
C API 如果要保存 Rust 提供的内存,必须把生命周期设计清楚:
- 由 Rust 创建一个长期存在的 owner
- 在 owner 的
Drop里调用 C 的注销函数 - C 侧不能在注销后继续访问指针
- 多线程回调要明确线程安全要求
这种设计通常需要单独封装成一个结构体,而不是把裸指针交给任意调用方。
Rust 调 C 的默认写法
Rust 调 C 时,最值得固定下来的习惯是:
C 头文件
-> Rust unsafe extern 声明
-> 小 unsafe 调用
-> Result / Vec / String / 自定义类型
-> 对外暴露安全 API
C 边界可以使用裸指针、错误码和输出参数。Rust 内部不应该继续使用这些形状。
好的 FFI 封装,不是把 C API 按语法翻译成 Rust,而是把 C 的约定变成 Rust 能检查、能释放、能组合的类型。