跳转到主内容C 字符串、内存归属和错误返回 | 物联网民工

C 字符串、内存归属和错误返回

写 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 怎样保证回调对象还活着、线程规则清楚、注销时不会悬垂。