Rust:unsafe代码的正确使用

Rust 的安全保证建立在编译器的静态检查上,但有些事情编译器做不了——比如调用 C 库、操作硬件、或者实现某些零开销抽象。这时候就需要 unsafe。用好 unsafe 是 Rust 进阶的必经之路。

unsafe 的五种能力

unsafe 块中,你可以做五件平时不允许的事:

1. 解引用裸指针

let x = 42;
let r1 = &x as *const i32;  // 创建裸指针是安全的
let r2 = x as *const i32 as *mut i32;

unsafe {
    println!("r1 = {}", *r1);  // 解引用裸指针需要 unsafe
}

裸指针不受借用规则约束,可以同时存在多个可变裸指针。但也意味着编译器不再保证内存安全,你得自己保证。

2. 调用 unsafe 函数

unsafe fn dangerous() {
    // 这里可以做任何 unsafe 操作
}

unsafe {
    dangerous();
}

3. 实现 unsafe trait

unsafe trait Foo {
    fn method(&self);
}

unsafe impl Foo for i32 {
    fn method(&self) {
        println!("{}", self);
    }
}

标准库里 SendSync 就是 unsafe trait。自动推导覆盖了大部分类型,但如果你用了裸指针等,可能需要手动实现。

4. 访问或修改可变静态变量

static mut COUNTER: u32 = 0;

fn increment() {
    unsafe {
        COUNTER += 1;  // 访问可变静态变量需要 unsafe
    }
}

可变静态变量在多线程下有数据竞争风险,所以需要 unsafe。实际项目中很少直接用,一般会用 AtomicU32Mutex<T> 替代。

5. 访问 extern 函数(FFI)

extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("abs(-3) = {}", abs(-3));
    }
}

何时需要 unsafe

大部分 Rust 代码不需要 unsafe。但这些场景绕不开:

  • 调用 C/C++ 库(FFI):最常见的理由
  • 实现底层数据结构:比如链表、跳表等需要裸指针
  • 性能关键路径:跳过边界检查(get_unchecked
  • 和操作系统交互:系统调用、内存映射

FFI 调用 C 库示例

假设要调用 C 的 strlen 函数:

use std::ffi::CString;
use std::os::raw::c_char;

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

fn safe_strlen(s: &str) -> usize {
    let c_string = CString::new(s).expect("CString::new failed");
    unsafe {
        strlen(c_string.as_ptr())
    }
}

fn main() {
    let len = safe_strlen("hello");
    println!("length: {}", len);  // 5
}

注意 safe_strlen 是安全函数,它内部使用了 unsafe,但对外暴露的接口是安全的。这就是"安全抽象"的思路。

安全抽象 unsafe 代码

unsafe 代码的核心原则:缩小 unsafe 的范围,对外提供安全接口

标准库里到处都是这个模式。比如 Vec<T> 内部用了裸指针管理内存,但对外的 pushpopget 都是安全的。

一个实际例子——实现一个 split_at_mut

fn split_at_mut(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = slice.len();
    assert!(mid <= len);
    
    let ptr = slice.as_mut_ptr();
    
    unsafe {
        (
            std::slice::from_raw_parts_mut(ptr, mid),
            std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

编译器不允许同时有两个 &mut 引用指向同一个 slice,但我们知道这两个切片是不重叠的,所以用 unsafe 绕过检查是合理的。关键是外层函数的签名是安全的,且 assert! 保证了 mid 不会越界。

常见陷阱

1. 悬垂指针

let r;
{
    let x = 42;
    r = &x as *const i32;
}
// x 已经被释放,r 是悬垂指针
unsafe {
    println!("{}", *r);  // 未定义行为!
}

裸指针没有生命周期检查,用完一定要确认指向的数据还活着。

2. 类型双关(Type Punning)

// 危险!不同类型的内存布局可能不同
let x: u32 = 42;
let y: f32 = unsafe { std::mem::transmute(x) };

transmute 是最危险的 unsafe 操作之一,它会把一种类型的字节直接重新解释为另一种类型。除非你完全理解两种类型的内存布局,否则不要用。

3. 违反不变量

let mut v = vec![1, 2, 3];
unsafe {
    v.set_len(100);  // 声称有100个元素,但实际只有3个
}
// 后续访问 v[50] 就是未定义行为

unsafe 代码必须维护类型的不变量。Vec 的不变量是 len <= capacitylen 范围内的元素都已初始化。

实践建议

  1. 能不用 unsafe 就不用。先想想有没有安全的替代方案。
  2. unsafe 块尽量小。只把确实需要 unsafe 的那一行包起来,不要整个函数都标 unsafe。
  3. 写好注释。每个 unsafe 块旁边都应该写清楚为什么这是安全的(Safety invariant)。
  4. #[deny(unsafe_op_in_unsafe_fn)]。Rust 2024 edition 默认开启,在 unsafe fn 内部也要求显式 unsafe 块。
  5. 用 Miri 测试cargo +nightly miri test 能检测出很多未定义行为。

unsafe 不是 Rust 的缺陷,恰恰是它的优势——在需要的时候能突破安全边界,又能通过安全抽象把风险控制在最小范围。