如何将 Rust 错误码映射到 C 负数?

解读

在国内做 Rust 与 C 的 FFI(Foreign Function Interface)时,**“把 Rust 的 Result 变成 C 能识别的负整数”**是面试官高频追问的点。
核心矛盾有三层:

  1. Rust 侧用 enum 或常量表达错误,值域通常从 0 开始;
  2. C 侧习惯用 < 0 表示失败,0 表示成功,>0 可能表示警告或句柄;
  3. ABI 边界必须保证编译器不会重新排布枚举值,且跨平台时 int32_tc_int 宽度一致。

面试官想听的不是“-1 就行”,而是**“怎么保证映射可维护、可扩展、零开销,还能让 C 用户一眼看懂”**。

知识点

  1. repr(C) 枚举的整型约定:不加 repr(C) 的 Rust enum 不保证布局,加 repr(i32) 后才能与 C int 一一对应。
  2. 错误值域划分:把 0 留给成功,正数留给警告/句柄,负数留给错误,与 Linux errno 风格对齐。
  3. const 负常量的编译期折叠:Rust 支持 const ERR_XXX: i32 = -(模块基值 + 偏移),在编译期完成负数映射,无运行时开销。
  4. 宏或 build.rs 生成映射表:国内大厂代码审查要求“禁止手写魔法数”,用 build.rs 扫描枚举自动生成 C 头文件,保证单一代码源。
  5. #[no_mangle] + extern "C" 导出函数签名:返回 c_int 而非 i32,避免 i32c_int 在 64 位 Windows 上宽度不一致的坑。
  6. panic=abort 与 unwind 安全:Rust 侧必须 catch_unwind 或直接用 panic=abort,防止跨 FFI 边界 unwind 导致 C 进程崩溃——国内安全红线一票否决。

答案

步骤一:用 repr(i32) 定义错误枚举,并预留 0 为成功

#[repr(i32)]
pub enum MyError {
    Ok = 0,
    InvalidArg = -1,
    Timeout = -2,
    Io = -3,
}

步骤二:提供 into_c_errno 方法,保证零成本转换

impl MyError {
    pub const fn into_c_errno(self) -> c_int {
        self as c_int
    }
}

步骤三:在 FFI 导出函数里统一使用

#[no_mangle]
pub extern "C" fn do_something(ptr: *const c_char) -> c_int {
    if ptr.is_null() {
        return MyError::InvalidArg.into_c_errno();
    }
    match unsafe { CStr::from_ptr(ptr).to_str() } {
        Ok(_) => MyError::Ok.into_c_errno(),
        Err(_) => MyError::InvalidArg.into_c_errno(),
    }
}

步骤四:通过 build.rs 生成 C 头文件,避免手写魔法数

// build.rs
use std::io::Write;
fn main() {
    let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
    let mut f = std::fs::File::create(out.join("my_error.h")).unwrap();
    writeln!(f, "#pragma once").unwrap();
    writeln!(f, "#define MY_OK 0").unwrap();
    writeln!(f, "#define MY_INVALID_ARG -1").unwrap();
    writeln!(f, "#define MY_TIMEOUT -2").unwrap();
    writeln!(f, "#define MY_IO -3").unwrap();
}

步骤五:在 Cargo.toml 开启 panic=abort,防止跨语言 unwind

[profile.release]
panic = "abort"

这样即可在编译期完成 Rust 错误码到 C 负数的映射,零运行时开销,且 C 用户只需包含自动生成的头文件即可。

拓展思考

  1. 分层错误空间:国内大型 C++ 存量系统往往把错误码拆成 “模块号 8bit + 错误号 24bit”,Rust 侧可用 #[repr(i32)]const 表达式实现 const MOD_FOO: i32 = 0x02000000; const ERR_BAR: i32 = -(MOD_FOO | 123);,既兼容老协议,又保留 Rust 枚举可读性。
  2. 错误字符串回传:C 侧拿到负数后想打印日志,可再导出一个 extern "C" fn my_error_str(code: c_int) -> *const c_char,内部用 phf 静态哈希表做 O(1) 查表,避免每次分配。
  3. 多线程与重入:如果错误码需要携带 errno 式线程局部上下文,可用 std::cell::RefCell<Option<MyError>> 的线程局部存储,但 FFI 边界必须保证无隐藏状态,否则审计会被打回。
  4. Rust 1.73+ 的 core::ffi::c_int 保证:在 #![no_std] 嵌入式场景,同样可以用 c_int 做负数映射,无需依赖 std,满足国内车规与航天 Rust 试点要求。