如何处理 C 字符串与 Rust 字符串的双向转换?

解读

在国内后端、嵌入式、区块链、云原生等 Rust 岗位面试中,“C 字符串 ↔ Rust 字符串”必考边界问题。面试官想确认三点:

  1. 是否理解** Rust 字符串的 UTF-8 不变性与内存布局**;
  2. 是否掌握** unsafe 边界与零拷贝权衡**;
  3. 能否在不引入 UB(未定义行为)的前提下,给出可工程落地的健壮代码(含错误处理、空指针、非法 UTF-8、生命周期标注)。

知识点

  1. Rust 侧类型

    • String:堆分配、可变、UTF-8。
    • &str:胖指针(ptr + len),UTF-8。
    • *const c_char / *mut c_char:C 侧裸指针,以 \0 结尾,单字节或平台编码,不保证 UTF-8
  2. C 侧约定

    • 传入 Rust:指针可能为 NULL,可能非 UTF-8。
    • 传出 Rust:调用方最终须调用配套 free 函数(谁分配谁释放原则)。
  3. 核心 API

    • std::ffi::{CStr, CString, OsStr, OsString}
    • std::os::raw::c_char
    • std::slice::from_raw_parts(unsafe)
    • libc::strlen(需 libc 依赖,嵌入式可自写)
  4. 安全边界

    • NULL 检查先于任何解引用。
    • UTF-8 校验CStr::to_str 返回 Result,必须处理。
    • 生命周期:Rust 不能持有 C 传入的指针超过函数返回边界,除非显式拷贝
    • 分配器一致性:Rust 默认使用 jemalloc/system malloc;若 C 侧用自定义 malloc,禁止直接 CString::into_raw 后由 C 侧 free

答案

下面给出双向转换的完整工程模板,可直接写进白板,也可在 IDE 中跑通,符合国内代码审计标准

use std::ffi::{CStr, CString};
use std::os::raw::c_char;

/// C -> Rust:将 C 字符串转为 Rust &str
/// # Safety
/// 调用方保证 `ptr` 为合法以 `\0` 结尾的字符串,或 NULL。
/// 返回 `None` 表示空指针或非法 UTF-8。
#[no_mangle]
pub unsafe extern "C" fn c_to_rust_str(ptr: *const c_char) -> Option<String> {
    if ptr.is_null() {
        return None;
    }
    // 用 CStr::from_ptr 做边界检查,内部调 strlen
    let c_str = unsafe { CStr::from_ptr(ptr) };
    // 显式 UTF-8 校验,拒绝非法字节序列
    match c_str.to_str() {
        Ok(s) => Some(s.to_owned()), // 拷贝到 Rust 侧,生命周期安全
        Err(_) => None,
    }
}

/// Rust -> C:将 Rust &str 转出给 C 调用方
/// # Safety
/// 返回指针由 Rust 分配,C 侧使用完后**必须**调用 `rust_string_free`。
/// 返回 NULL 表示 Rust 侧传入空串或分配失败。
#[no_mangle]
pub unsafe extern "C" fn rust_to_c_str(rust: *const c_char) -> *mut c_char {
    if rust.is_null() {
        return std::ptr::null_mut();
    }
    // 先还原为 &str
    let c_slice = unsafe { CStr::from_ptr(rust) };
    let utf8 = match c_slice.to_str() {
        Ok(s) => s,
        Err(_) => return std::ptr::null_mut(),
    };
    // 分配 CString
    match CString::new(utf8) {
        Ok(c_string) => c_string.into_raw(), // 所有权转移给 C
        Err(_) => std::ptr::null_mut(),      // 中间含 \0,非法
    }
}

/// C 侧调用完后,必须将指针传回 Rust 释放
/// # Safety
/// `ptr` 必须是由 `rust_to_c_str` 返回的非空指针。
#[no_mangle]
pub unsafe extern "C" fn rust_string_free(ptr: *mut c_char) {
    if ptr.is_null() {
        return;
    }
    // 重新取得 CString 所有权,drop 时自动释放
    unsafe {
        let _ = CString::from_raw(ptr);
    }
}

使用约束总结

  • C 侧拿到 *mut c_char 后,禁止再写入,否则破坏内部 \0 结尾约定。
  • 禁止 C 侧直接 free(ptr),必须走 rust_string_free,保证同一分配器。
  • 高频调用,可额外提供零拷贝版本:Rust 侧返回结构体 { ptr: *const c_char, len: usize },但需约定只读、生命周期不超过下一次 Rust 函数返回,并文档注明。

拓展思考

  1. 非 UTF-8 场景(GBK、Latin-1):
    国内老系统常用 GBK,此时 CStr::to_str 必失败。可引入 encoding_rs 库,在 c_to_rust_str 内做 GBK→UTF-8 转码,转码失败返回 None,保证 Rust 内部永远 UTF-8

  2. 零拷贝双向视图(高级):
    若 C 侧保证内存只读且生命周期可控,可返回 &str 的胖指针结构体,避免 String::to_owned 拷贝;但需在 Rust 侧用 PhantomData 标注生命周期,防止悬垂。面试中提及即可体现深度。

  3. 错误码 vs Option
    国内部分安全规范要求错误码枚举而非 NULL。可把返回值改成 Result<String, CConvertError> 的 FFI 安全版本,通过 out-param 返回错误码,满足车载、金融审计要求。

  4. WASM 嵌入前端
    若 Rust 编译到 wasm32-unknown-unknownCString::into_raw 得到的指针属于 WASM Linear Memory,JS 侧需导出 rust_string_free 并在同一模块实例内调用,否则出现重复 free 或内存泄漏

  5. 性能极致
    热路径(如网络包解析)可预分配 Vec<u8> 作为缓冲区,用 std::slice::from_raw_parts_mut 让 C 侧直接写入,再在外部做一次性 UTF-8 校验,避免多次分配;但代码复杂度与 UB 风险同步升高,面试时权衡表达即可。