Skip to content
Published at:

22. Unsafe Code — 不安全代码

Rust 的安全保证在绝大多数场景下足够强大,但系统编程有时需要突破这些限制。unsafe 关键字让你告诉编译器:"我知道自己在做什么,请相信我。"本章介绍 unsafe 代码的五大能力、原始指针操作、未定义行为的边界、以及如何用 safe 抽象包装 unsafe 实现。

为什么需要 unsafe

所有编程语言最终都要操作裸机和字节。Rust 的类型系统、借用检查器和生命周期分析是抽象层,而这些抽象层本身是用 unsafe 代码实现的。Vec<T> 的高效缓冲区管理、std::io 的系统调用交互、std::sync 的并发原语——底层都依赖 unsafe。

unsafe 并不是"关闭所有安全检查"的开关。进入 unsafe 上下文后,Rust 仍然进行类型检查、生命周期检查和借用检查。unsafe 只解锁了五个额外的能力。

unsafe 块解锁的五种能力

rust
unsafe {
    // 1. 调用 unsafe 函数
    // 2. 解引用原始指针
    // 3. 访问 union 字段
    // 4. 访问可变 static 变量
    // 5. 访问 extern 函数(FFI)
}

unsafe 块的主要作用是吸引人工审查——它标记出编译器无法验证的区域,提醒 reviewer 仔细检查。

unsafe 函数

将函数标记为 unsafe 表示调用者必须遵守额外的合约:

rust
/// # Safety
///
/// 调用者必须确保 bytes 只包含 ASCII 字符(0x00..=0x7f)。
/// 违反此合约将导致未定义行为。
pub unsafe fn from_bytes_unchecked(bytes: Vec<u8>) -> Ascii {
    Ascii(bytes)
}

调用 unsafe 函数必须在 unsafe 块内进行。合约应写在文档注释中。

未定义行为(Undefined Behavior)

未定义行为是 Rust 假设绝对不可能发生的事情。当代码违反 unsafe 特性的合约时,程序的行为变得不可预测——可能崩溃、可能产生错误结果、可能为攻击者打开后门。关键规则:

  • 禁止读取未初始化的内存
  • 禁止创建无效的基础值:空引用、非 0/1 的 bool、非 Unicode 码点的 char、非有效 UTF-8 的 str
  • 必须遵守引用规则:共享引用只读、可变引用独占、引用不能比被引用对象活得更久
  • 禁止解引用空指针、未对齐指针、悬垂指针
  • 禁止用指针访问关联内存范围之外的地址
  • 禁止数据竞争:两个线程非同步地访问同一内存,且至少一个为写
  • 禁止在跨越 FFI 边界时展开栈(unwinding 进入 C 代码)

不使用 unsafe 特性的 safe 代码被保证不会触发以上任何一条。一旦使用 unsafe,你就要自己负责。

原始指针(Raw Pointers)

原始指针是 Rust 中最灵活也最危险的数据类型:

rust
let mut x = 10;
let ptr_x: *mut i32 = &mut x;      // 从可变引用创建
let ptr_y: *const i32 = &x;         // 从共享引用创建

unsafe {
    *ptr_x += 5;                     // 解引用必须在 unsafe 内
    println!("{}", *ptr_y);          // 15
}

原始指针 vs 引用的区别

特性引用 (&T, &mut T)原始指针 (*const T, *mut T)
空值不允许允许 (std::ptr::null())
自动解引用支持必须显式 *ptr
借用检查编译时强制无检查
生命周期有界无界
别名规则编译时保证无保证
算术运算不支持offset, add, sub
线程安全通过 Send/Sync 推导默认都不是

指针算术

rust
let arr = [10, 20, 30, 40];
let ptr: *const i32 = arr.as_ptr();

unsafe {
    assert_eq!(*ptr, 10);
    assert_eq!(*ptr.add(1), 20);     // 等同于 ptr.offset(1)
    assert_eq!(*ptr.add(2), 30);

    // offset_from 计算两个指针间的元素数量
    let end = ptr.add(4);
    assert_eq!(end.offset_from(ptr), 4);
}

ptr.offset(n) 产生指向第 n 个(从 0 开始)元素的指针。offset 要求结果指针不超出原始对象的内存范围(指向"末尾之后第一个位置"是允许的,但不能解引用)。wrapping_offset 是更宽松的版本,不保证结果在范围内。

指针运算前必须确保类型正确对齐。std::mem::align_of::<T>() 返回对齐要求。

创建原始指针是安全的,解引用才需要 unsafe

rust
// safe:创建、传递、比较
let ptr = &value as *const i32;
let is_null = ptr.is_null();
let addr = ptr as usize;

// unsafe:通过指针读取或写入
unsafe {
    let val = *ptr;
    *mut_ptr = 42;
}

安全地使用原始指针

解引用原始指针时必须遵守的规则:

  1. 指针不能为空或悬垂(指向已释放或已移动的值)
  2. 指针必须按照类型对齐
  3. 指针指向的值必须是该类型的有效值(不能将无效位模式解释为 boolchar
  4. 通过指针获取引用时,必须遵守引用规则
  5. 指针运算的结果必须仍然在同一个内存对象内

从原始指针读写值

std::ptr 模块提供低级别内存操作:

rust
// ptr::read — 从指针处移出值(所有权转移)
let value = unsafe { std::ptr::read(src_ptr) };

// ptr::write — 将值写入指针位置(覆盖,不 drop 原有值)
unsafe { std::ptr::write(dest_ptr, new_value); }

// ptr::copy — 复制 count 个元素(非重叠或可处理重叠)
unsafe { std::ptr::copy(src, dest, count); }

// ptr::copy_nonoverlapping — 更快的复制(要求不重叠)
unsafe { std::ptr::copy_nonoverlapping(src, dest, count); }

// ptr::drop_in_place — 原地析构值,不释放内存
unsafe { std::ptr::drop_in_place(ptr); }

这些函数是 Vec::pushVec::popVec::insert 等标准库方法的底层基础。

安全抽象:用 safe 接口包装 unsafe

Rust 的哲学是用 unsafe 实现 safe 的 API。标准库中几乎所有类型都是这样构建的。关键原则是:模块内部可以使用 unsafe,但对外的公共接口必须是 safe 的,并且模块自身保证所有 unsafe 操作的合约都被满足。

示例——Ascii 类型,确保只包含 ASCII 字节:

rust
mod my_ascii {
    pub struct Ascii(Vec<u8>);  // 内部字段不公开

    impl Ascii {
        // safe 构造器——检查输入
        pub fn from_bytes(bytes: Vec<u8>) -> Result<Ascii, NotAsciiError> {
            if bytes.iter().any(|&b| !b.is_ascii()) {
                return Err(NotAsciiError(bytes));
            }
            Ok(Ascii(bytes))
        }
    }

    impl From<Ascii> for String {
        fn from(ascii: Ascii) -> String {
            // 安全:ASCII 文本一定是有效 UTF-8
            unsafe { String::from_utf8_unchecked(ascii.0) }
        }
    }
}

Ascii 的公共 API 保证内部数据总是有效的 ASCII,因此调用 String::from_utf8_unchecked(一个 unsafe 函数)的合约在模块内部得到满足。用户无需写任何 unsafe

unsafe trait

某些 trait 带有编译器无法验证的合约。实现它们需要 unsafe impl

rust
// Send 和 Sync 是 unsafe trait 的典型例子
unsafe impl Send for MyType {}
unsafe impl Sync for MyType {}

Send 的合约:类型可以安全地转移到其他线程。Sync 的合约:类型可以安全地跨线程共享。为不恰当的类型实现它们会导致数据竞争。

调用者在使用它们时通常也依赖这些合约:

rust
fn send_to_thread<T: Send>(value: T) {
    thread::spawn(move || { /* 使用 value */ });
}

Union 类型

Union 让多个字段共享同一块内存,类似于 C 的 union:

rust
#[repr(C)]
union FloatOrInt {
    f: f32,
    i: i32,
}

let u = FloatOrInt { i: 1 };
unsafe {
    assert_eq!(u.i, 1);
}

let u = FloatOrInt { f: 1.0 };
unsafe {
    // 查看同一段内存的整数表示
    println!("0x{:08x}", u.i);  // 0x3f800000
}

写入 union 字段是 safe 的,读取是 unsafe 的——编译器无法知道当前存储的是哪个变体,也不会检查位模式是否有效。

#[repr(C)] 属性保证所有字段从偏移 0 开始。不使用时,Rust 可以自由优化布局。

match union 时,每个分支必须指定精确的字段名,但不会做判别——由程序员保证与最后写入的字段一致。

可变 static 变量

rust
static mut COUNTER: u32 = 0;

unsafe {
    COUNTER += 1;  // 读和写都是 unsafe
}

可变 static 变量天然不是线程安全的——任何线程随时可以读写。使用它们需要 unsafe 块。更安全的替代:AtomicUsizeMutex<T> 包装的静态变量、或 lazy_static!

内联汇编

asm! 宏允许直接嵌入汇编指令(nightly 特性):

rust
use std::arch::asm;

let x: u64;
unsafe {
    asm!("mov {}, 42", out(reg) x);
}
assert_eq!(x, 42);

asm! 与 LLVM 的内联汇编集成,支持指定输入/输出寄存器、clobber 列表和选项标记。

panic 安全性

在 unsafe 代码中,如果 panic 发生在"临时打破不变量"和"恢复不变量"之间,类型会处于不一致状态,导致未定义行为:

rust
// 危险:如果 panic 发生在 read 和 gap.end 更新之间,
// GapBuffer 的不变量被破坏
let element = unsafe { std::ptr::read(self.space(self.gap.end)) };
// ... 如果这里有 panic ...
self.gap.end += 1;  // 这行不会执行

原则:unsafe 代码中打破不变量的区域应当极短,且不能包含任何可能 panic 的操作。

小结

  • unsafe 不是全局关闭安全检查——它只在五个特定领域解除限制,其余所有安全检查照常生效。
  • unsafe 的职责在人而非编译器:你必须自己保证代码遵守了相应的合约,否则将导致未定义行为。
  • 原始指针是灵活但危险的底层工具:创建和传递是 safe 的,解引用需要 unsafe。
  • 安全抽象是 Rust 的核心理念:用 unsafe 实现底层细节,用 safe API 封装它们,让使用者无需接触 unsafe。
  • 使用 unsafe 前应先问自己:是否真的需要?是否有 safe 的替代方案?是否有性能数据证明 unsafe 是必要的?
  • 未定义行为不仅仅是"可能得到错误结果"——它意味着编译器可以做出任意假设,程序的行为完全不可预测。