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

FFI:让 C 调用 Rust

让 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。