跳转到主内容FFI:让 Rust 调用 C | 物联网民工

FFI:让 Rust 调用 C

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 是调用方提供的 buffer
  • capacity 是 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 能检查、能释放、能组合的类型。

继续阅读