如何返回动态错误字符串给 C?

解读

在国内 Rust 岗位面试中,这道题常被用来考察候选人是否真正踩过“FFI 坑”。面试官想听的不是“把 String 直接甩给 C”,而是:

  1. 你清楚 Rust 的内存由谁分配、由谁释放
  2. 你能给出 C 端可安全调用、Rust 端不泄漏 的完整方案;
  3. 你能在 不引入额外依赖 的前提下,把 错误码 + 错误串 一起返回,并兼顾 多线程重入UTF-8 合法性

一句话:这道题考的是 跨语言所有权转移与资源管理 的硬实力。

知识点

  • CString/CStr:Rust 与 C 的零终止字节桥梁,CString::new 会二次分配,失败时返回 NUL 错误
  • Box<str>/Box<[u8]>/Vec<u8>:堆上连续内存,Box::into_raw 交出所有权,C 端拿到 *mut c_char。
  • libc::strlen + libc::free:C 端必须按 Rust 同一分配器 释放,否则直接 UB;默认系统 malloc/free 与 Rust 的 System 分配器并不保证兼容
  • thread_local! + RefCell:若走“静态缓冲区”方案,需用 线程局部存储 避免重入竞争,容量上限 要在文档里写死。
  • errno 风格:返回 负错误码,同时提供 last_error_message() 接口,双函数配对 是最常见工业实践。
  • #[no_mangle] + extern "C"符号名不能被 Rust 编译器改写,ABI 必须是 C。
  • unsafeFFI 边界全是 unsafe,但 逻辑安全 由程序员保证;文档里要写明调用契约(谁分配、谁释放、字符串有效期多久)。

答案

下面给出 “错误码 + 动态字符串” 的工业级模板,零依赖、单文件、Cargo 可直接 build-cdylib,C 端示例一并给出,面试时可直接手撕

// lib.rs
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int};
use std::ptr::{self, NonNull};

/// 返回码:0 表示成功,负值表示错误
pub const ERR_OK: c_int = 0;
pub const ERR_INVALID_INPUT: c_int = -1;
pub const ERR_UTF8: c_int = -2;

/// 把 Rust 侧的错误串交给 C 端,C 端使用完后必须调用 rust_string_free
#[no_mangle]
pub extern "C" fn rust_string_free(s: *mut c_char) {
    if s.is_null() {
        return;
    }
    unsafe {
        // 重新把所有权拿回来,drop 时自动释放
        let _ = CString::from_raw(s);
    }
}

/// 示例函数:如果 name 为空返回错误,否则返回问候语
/// 成功时 *out 指向新分配的 CString,返回 ERR_OK;
/// 失败时 *out 设为 NULL,返回负错误码,并可通过 rust_last_error_msg 拿到详细描述
#[no_mangle]
pub extern "C" fn rust_greet(
    name: *const c_char,
    out: *mut *mut c_char,
) -> c_int {
    // 初始化输出参数
    unsafe { *out = ptr::null_mut(); }

    let name = match unsafe { CStr::from_ptr(name).to_str() } {
        Ok(s) => s,
        Err(_) => return ERR_UTF8,
    };

    if name.is_empty() {
        // 错误详情放到线程局部,不污染返回值
        set_last_error("name must not be empty");
        return ERR_INVALID_INPUT;
    }

    let greeting = format!("Hello, {}!", name);
    let c_str = match CString::new(greeting) {
        Ok(s) => s,
        Err(_) => return ERR_UTF8, // 包含 NUL 字节,理论上不可能
    };

    // 交出所有权给 C
    *out = c_str.into_raw();
    ERR_OK
}

/* ---------- 线程局部错误串 ---------- */
use std::cell::RefCell;
thread_local! {
    static LAST_ERROR: RefCell<Option<CString>> = RefCell::new(None);
}

fn set_last_error(s: &str) {
    LAST_ERROR.with(|slot| {
        *slot.borrow_mut() = CString::new(s).ok();
    });
}

/// 线程安全地拿到最后一次错误描述,返回指针生命周期到下一次调用或线程结束
/// 返回指针 **只读**,**不允许 free**
#[no_mangle]
pub extern "C" fn rust_last_error_msg() -> *const c_char {
    LAST_ERROR.with(|slot| {
        match &*slot.borrow() {
            Some(cstr) => cstr.as_ptr(),
            None => ptr::null(),
        }
    })
}

C 端调用契约(面试时可直接写在白板上):

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>

// 返回负值表示错误
int rust_greet(const char *name, char **out);
void rust_string_free(char *s);
const char *rust_last_error_msg(void);

int main(void) {
    char *msg = NULL;
    int rc = rust_greet("Rustacean", &msg);
    if (rc == 0) {
        printf("%s\n", msg);
        rust_string_free(msg);   // 必须配对
    } else {
        fprintf(stderr, "error(%d): %s\n", rc, rust_last_error_msg());
    }
    return 0;
}

关键点回顾

  1. rust_string_freeCString::from_raw 严格配对,杜绝内存泄漏
  2. rust_last_error_msg 返回的指针 不允许 free,生命周期由线程局部管理,避免并发踩踏
  3. 所有 unsafe 块都有明确前置条件,并在函数注释里写清 调用者契约国内面试官最吃“契约”二字

拓展思考

  1. 多线程场景:如果 C 端会并发调用,线程局部方案仍安全,但 错误缓冲需按线程隔离;若想全局统一日志,可改用 Arc<Mutex<HashMap<ThreadId, CString>>>,但 锁开销 要在文档里说明。
  2. 大错误串:线程局部缓冲默认 4 KB 即可覆盖 99% 场景,若可能返回 堆栈回溯,可让 C 端先调用 rust_error_len() 拿到长度,再一次性 malloc 后由 rust_error_copy_to(buf, len) 填充,避免二次拷贝
  3. 自定义分配器:在 嵌入式 no_std 场景,可把 Rust 的 System 换成 jemalloc 或 tlsf,并在 C 端统一用 rust_alloc/rust_free 导出符号保证同一块内存池
  4. 错误分级:国内金融、区块链代码审计要求 错误码必须带模块 ID,可把 32 位错误码拆成 8 位模块 + 16 位行号 + 8 位级别rust_last_error_code() 返回整型,rust_last_error_message() 返回可读串,审计时直接定位到 Rust 源码行
  5. ABI 版本:交付给第三方 SDK 时,在 so 文件名里带 abi 版本号(如 librust_demo_1_0.so),并在 头文件里写死 RUST_ABI_VERSION防止 Rust 升级后布局变化导致 UB

把以上五点展开任意一点,都能让面试官直接给你打 A+