跳转到主内容unsafe:把 C 风险限制在边界 | 物联网民工

unsafe:把 C 风险限制在边界

C 开发者第一次看到 unsafe,很容易把它理解成“Rust 里可以写 C 的地方”。

这个理解太粗。

unsafe 不是关闭 Rust 所有检查,也不是把整段代码变回 C。它只允许你做少数编译器无法静态证明安全的操作,例如解引用裸指针、调用 FFI 函数、访问可变全局变量。

真正重要的不是“能不能写 unsafe”,而是:把这些风险限制在多小的边界里。

C 默认就是 unsafe 边界

C 里很多操作都靠程序员保证前提:

int read_byte(const uint8_t *ptr)
{
    return ptr[0];
}

调用方必须保证:

  • ptr 不是 NULL
  • ptr 指向至少 1 字节有效内存
  • 这段内存在访问期间没有被释放
  • 当前访问不会和其他写入产生数据竞争

C 类型无法完整表达这些前提。

Rust 安全代码默认不允许直接做这类事。要解引用裸指针,必须进入 unsafe:

unsafe fn read_byte(ptr: *const u8) -> u8 {
    unsafe { *ptr }
}

这不是因为解引用本身一定错,而是因为编译器无法证明 ptr 一定有效。

unsafe 允许少数额外操作

unsafe 主要允许几类操作:

  • 解引用裸指针
  • 调用 unsafe fn
  • 访问或修改 static mut
  • 实现 unsafe trait
  • 访问 union 字段

普通 Rust 规则仍然存在。类型检查、所有权、借用、匹配、错误处理不会消失。

例如:

unsafe fn read_byte(ptr: *const u8) -> u8 {
    if ptr.is_null() {
        return 0;
    }

    unsafe { *ptr }
}

if、return、类型检查都还是正常 Rust。只有 *ptr 这一步需要 unsafe,因为它依赖额外前提:这个指针虽然非空,但还必须可读、对齐、指向有效对象。

unsafe fn 表示调用方也要承担前提

如果一个函数本身要求调用方保证某些安全条件,可以把函数标成 unsafe fn:

unsafe fn slice_from_raw<'a>(ptr: *const u8, len: usize) -> &'a [u8] {
    unsafe { std::slice::from_raw_parts(ptr, len) }
}

调用这个函数的人必须保证:

  • ptr 指向 len 字节有效内存
  • 这段内存至少在返回的 slice 使用期间有效
  • 内存没有被同时可变访问
  • len 和实际可访问范围匹配

这些前提必须写在文档里,通常用 # Safety 说明。

unsafe fn 的意思不是“函数内部必然危险”,而是“安全调用这个函数需要调用方满足额外前提”。

安全封装要把前提关在里面

更好的做法通常是:让 unsafe 很小,把它封装在安全 API 后面。

例如:

fn first_byte(data: &[u8]) -> Option<u8> {
    data.first().copied()
}

这个函数完全不需要 unsafe,因为 slice 已经保证了指针和长度的关系。

如果必须从裸指针进入:

unsafe fn first_byte_from_raw(ptr: *const u8, len: usize) -> Option<u8> {
    if ptr.is_null() || len == 0 {
        return None;
    }

    let data = unsafe { std::slice::from_raw_parts(ptr, len) };
    data.first().copied()
}

unsafe 只出现在把裸指针转换成 slice 的地方。后面尽量回到安全 Rust,用 slice 的边界和方法处理数据。

这就是常见策略:

裸指针 / FFI / 硬件边界 -> 小 unsafe 块 -> 安全 Rust 类型

FFI 是 unsafe 的高频来源

调用 C 函数通常是 unsafe:

unsafe extern "C" {
    fn strlen(s: *const std::os::raw::c_char) -> usize;
}

fn len_from_c_string(s: &std::ffi::CStr) -> usize {
    unsafe { strlen(s.as_ptr()) }
}

C 函数不知道 Rust 的借用和生命周期。Rust 也无法证明 C 函数不会保存指针、不会越界、不会违反线程安全。

所以 FFI 边界要特别明确:

  • 指针是否允许为空
  • 字符串是否 NUL 结尾
  • 内存归谁释放
  • C 是否会保存指针
  • 回调在哪个线程调用
  • 错误码怎样返回

unsafe 只是提醒你这里有无法由 Rust 编译器验证的前提。真正的安全来自边界设计。

unsafe 不应该扩散

坏的写法是把大量业务逻辑都塞进 unsafe:

unsafe fn parse_and_handle(ptr: *const u8, len: usize) {
    // parse protocol
    // update state
    // call callbacks
    // manage resources
}

更稳的写法是先把边界转换成安全类型:

unsafe fn parse_from_raw(ptr: *const u8, len: usize) -> Result<(), ParseError> {
    if ptr.is_null() {
        return Err(ParseError::Null);
    }

    let data = unsafe { std::slice::from_raw_parts(ptr, len) };
    parse_packet(data)
}

fn parse_packet(data: &[u8]) -> Result<(), ParseError> {
    // safe parsing logic
    Ok(())
}

这样 unsafe 只负责“从不可证明的外部世界进入 Rust 类型”。真正的解析逻辑仍然是安全代码。

这一篇先记住什么

C 和 Rust 的第一层对应关系是:

裸指针解引用              -> unsafe
C 函数调用                -> unsafe extern / unsafe call
void * + len              -> unsafe 转成 &[u8] 后再安全处理
全局可变状态              -> static mut / unsafe,优先避免
文档写“调用方保证有效”   -> unsafe fn 的 Safety 前提
封装好的安全接口          -> 小 unsafe 边界 + 安全 Rust API

unsafe 不是“这里不用遵守 Rust 规则”。

它表示:这里有一些编译器无法证明的前提,需要程序员保证。工程上更好的做法,是让 unsafe 尽量小、尽量靠近 FFI 或裸指针边界,然后尽快回到安全 Rust 类型。

下一篇可以进入 FFI。unsafe 是边界标记,FFI 则要具体处理 C ABI、符号导出、字符串、内存归属和错误返回。