Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

English Original

第 12 章:Unsafe Rust —— 受控的危险 🔴

你将学到:

  • 五种 Unsafe “超能力” 及其各自的适用场景。
  • 编写可靠的抽象 (Sound Abstractions):安全 API 与 Unsafe 内部实现。
  • FFI 模式:在 Rust 中调用 C 代码 (以及反向调用)。
  • 常见的未定义行为 (UB) 陷阱 以及 Arena/Slab 分配器模式。

12.1 五种 Unsafe “超能力”

unsafe 开启了五项编译器无法验证的操作:

#![allow(unused)]
fn main() {
// SAFETY: 下文内联解释了每项操作的安全性。
unsafe {
    // 1. 解引用裸指针 (Raw Pointer)
    let ptr: *const i32 = &42;
    let value = *ptr; // 指针可能是悬空的或为空

    // 2. 调用 unsafe 函数
    let layout = std::alloc::Layout::new::<u64>();
    let mem = std::alloc::alloc(layout);

    // 3. 访问可变静态变量 (Mutable Static Variable)
    static mut COUNTER: u32 = 0;
    COUNTER += 1; // 如果多个线程访问,会产生数据竞争 (Data race)

    // 4. 实现 unsafe trait
    // unsafe impl Send for MyType {}

    // 5. 访问 union 的字段
    // union IntOrFloat { i: i32, f: f32 }
    // let u = IntOrFloat { i: 42 };
    // let f = u.f; // 重新解释位模式 —— 可能是垃圾数据
}
}

核心原则:unsafe 并没有关闭借用检查器或类型系统。它仅仅开启了这五种特定的功能。所有其他的 Rust 规则依然适用。

编写可靠的抽象 (Sound Abstractions)

unsafe 的目的是围绕不安全的操作构建 安全抽象:

#![allow(unused)]
fn main() {
/// 一个容量固定的、栈分配的缓冲区。
/// 所有公共方法都是安全的 —— unsafe 已被封装在内部。
pub struct StackBuf<T, const N: usize> {
    data: [std::mem::MaybeUninit<T>; N],
    len: usize,
}

impl<T, const N: usize> StackBuf<T, N> {
    pub fn new() -> Self {
        StackBuf {
            // 每个元素都是独立的 MaybeUninit —— 无需使用 unsafe。
            // `const { ... }` 代码块(Rust 1.79+)允许我们重复
            // 一个非 Copy 的 const 表达式 N 次。
            data: [const { std::mem::MaybeUninit::uninit() }; N],
            len: 0,
        }
    }

    pub fn push(&mut self, value: T) -> Result<(), T> {
        if self.len >= N {
            return Err(value); // 缓冲区已满 —— 将原值返回给调用者
        }
        // SAFETY: len < N,因此 data[len] 位于边界内。
        // 我们将一个有效的 T 写入 MaybeUninit 槽位中。
        self.data[self.len] = std::mem::MaybeUninit::new(value);
        self.len += 1;
        Ok(())
    }

    pub fn get(&self, index: usize) -> Option<&T> {
        if index < self.len {
            // SAFETY: index < len,且 data[0..len] 均已初始化。
            Some(unsafe { self.data[index].assume_init_ref() })
        } else {
            None
        }
    }
}

impl<T, const N: usize> Drop for StackBuf<T, N> {
    fn drop(&mut self) {
        // SAFETY: data[0..len] 已初始化 —— 需要正确地释放 (drop) 它们。
        for i in 0..self.len {
            unsafe { self.data[i].assume_init_drop(); }
        }
    }
}
}

编写可靠 (Sound) Unsafe 代码的三原则:

  1. 记录不变式 (Invariants) —— 每个 // SAFETY: 注释都应解释该操作为何有效。
  2. 封装 (Encapsulate) —— 将 Unsafe 细节隐藏在安全 API 内部;确保用户无法触发 UB。
  3. 最小化 (Minimize) —— 尽量减小 unsafe 代码块的范围。

FFI 模式:从 Rust 调用 C

#![allow(unused)]
fn main() {
// 声明 C 函数签名:
extern "C" {
    fn strlen(s: *const std::ffi::c_char) -> usize;
    fn printf(format: *const std::ffi::c_char, ...) -> std::ffi::c_int;
}

// 安全包装器 (Safe wrapper):
fn safe_strlen(s: &str) -> usize {
    let c_string = std::ffi::CString::new(s).expect("字符串包含空字节");
    // SAFETY: c_string 是一个有效的、以 null 结尾的字符串,在调用期间保持存活。
    unsafe { strlen(c_string.as_ptr()) }
}

// 从 C 调用 Rust (导出函数):
#[no_mangle]
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {
    a + b
}
}

常用的 FFI 类型:

Rust 类型C 类型备注
i32 / u32int32_t / uint32_t固定宽度,安全
*const T / *mut Tconst T* / T*裸指针 (Raw pointers)
std::ffi::CStrconst char* (借用)以 null 结尾,借用方式
std::ffi::CStringchar* (所有权)以 null 结尾,所有权方式
std::ffi::c_voidvoid不透明指针目标 (Opaque)
Option<fn(...)>可为空的函数指针None 等同于 NULL

常见的未定义行为 (UB) 陷阱

陷阱示例为什么是 UB
空指针解引用*std::ptr::null::<i32>()解引用空指针在任何时候都是 UB
悬空指针在 drop() 后解析指针内存可能已被重用
数据竞争两个线程同时写入 static mut未同步的并发写入
错误的 assume_initMaybeUninit::<String>::uninit().assume_init()读取未初始化的内存。注意:[const { MaybeUninit::uninit() }; N] (Rust 1.79+) 是创建 MaybeUninit 数组的安全方式 —— 无需 unsafe 或 assume_init (见上文 StackBuf::new())
别名违规对同一数据创建两个 &mut违反了 Rust 的别名模型 (Aliasing Model)
无效的枚举值std::mem::transmute::<u8, bool>(2)bool 只能是 0 或 1

何时在生产环境中使用 unsafe:

  • FFI 边界 (调用 C/C++ 代码)。
  • 性能关键的内层循环 (为了避免边界检查)。
  • 构建底层原语 (Vec、HashMap —— 它们的内部实现都使用了 unsafe)。
  • 只要能避免,就绝不要在应用逻辑中使用它。

12.2 自定义分配器 —— Arena 与 Slab 模式

在 C 语言中,你会针对特定的分配模式编写自定义的 malloc() 替代品 —— 比如一次性释放所有内存的 Arena 分配器、用于固定大小对象的 Slab 分配器,或者用于高吞吐量系统的池分配器。Rust 通过 GlobalAlloc trait 和分配器库提供了同样的能力,并增加了 在编译时防止“释放后使用” (Use-after-free) 的优势,这可以通过生命周期约束的 Arena 来实现。

Arena 分配器 —— 批量分配,批量释放

Arena 分配器通过向前移动指针来分配内存。单个条目无法被单独释放 —— 整个 Arena 会被一次性释放。这非常适合处理请求作用域或帧作用域 (Frame-scoped) 的分配:

#![allow(unused)]
fn main() {
use bumpalo::Bump;

fn process_sensor_frame(raw_data: &[u8]) {
    // 为这一帧的分配创建一个 Arena
    let arena = Bump::new();

    // 在 Arena 中分配对象 —— 每个约耗时 2ns (仅仅是指针移动)
    let header = arena.alloc(parse_header(raw_data));
    let readings: &mut [f32] = arena.alloc_slice_fill_default(header.sensor_count);

    for (i, chunk) in raw_data[header.payload_offset..].chunks(4).enumerate() {
        if i < readings.len() {
            readings[i] = f32::from_le_bytes(chunk.try_into().unwrap());
        }
    }

    // 使用 readings...
    let avg = readings.iter().sum::<f32>() / readings.len() as f32;
    println!("帧平均值: {avg:.2}");

    // `arena` 在此处被释放 —— 所有分配在 O(1) 时间内一次性释放
    // 没有逐个对象的析构开销,也没有碎片化问题
}
fn parse_header(_: &[u8]) -> Header { Header { sensor_count: 4, payload_offset: 8 } }
struct Header { sensor_count: usize, payload_offset: usize }
}

Arena vs 标准分配器:

特性Vec::new() / Box::new()Bump Arena
分配速度~25ns (调用 malloc)~2ns (指针移动)
释放速度逐个对象的析构函数O(1) 批量释放
碎片化会 (针对长寿命进程)Arena 内部无碎片
生命周期安全堆内存 —— 在 Drop 时释放Arena 引用 —— 编译时作用域约束
使用场景通用目的请求/帧/批处理

typed-arena —— 类型安全的 Arena

当 Arena 中的所有对象都是同一类型时,typed-arena 提供了一个更简单的 API,返回绑定到 Arena 生命周期的引用:

#![allow(unused)]
fn main() {
use typed_arena::Arena;

struct AstNode<'a> {
    value: i32,
    children: Vec<&'a AstNode<'a>>,
}

fn build_tree() {
    let arena: Arena<AstNode<'_>> = Arena::new();

    // 分配节点 —— 返回存活时间与 Arena 一致的 &AstNode
    let root = arena.alloc(AstNode { value: 1, children: vec![] });
    let left = arena.alloc(AstNode { value: 2, children: vec![] });
    let right = arena.alloc(AstNode { value: 3, children: vec![] });

    // 构建树 —— 只要 `arena` 存活,所有引用就都是有效的
    // (对于真正的可变树,修改需要内部可变性)

    println!("根节点: {}, 左子节点: {}, 右子节点: {}", root.value, left.value, right.value);

    // `arena` 在此处释放 —— 所有节点一次性释放
}
}

Slab 分配器 —— 固定大小的对象池

Slab 分配器预先分配一个由固定大小槽位组成的池。对象是单独分配和返回的,但由于所有槽位大小相同,因此消除了碎片,并实现了 O(1) 的分配/释放:

#![allow(unused)]
fn main() {
use slab::Slab;

struct Connection {
    id: u64,
    buffer: [u8; 1024],
    active: bool,
}

fn connection_pool_example() {
    // 预先为连接分配一个 Slab
    let mut connections: Slab<Connection> = Slab::with_capacity(256);

    // 插入返回一个键 (usize 索引) —— O(1)
    let key1 = connections.insert(Connection {
        id: 1001,
        buffer: [0; 1024],
        active: true,
    });

    let key2 = connections.insert(Connection {
        id: 1002,
        buffer: [0; 1024],
        active: true,
    });

    // 通过键访问 —— O(1)
    if let Some(conn) = connections.get_mut(key1) {
        conn.buffer[0..5].copy_from_slice(b"hello");
    }

    // 移除返回该值 —— O(1),该槽位将被下次插入重用
    let removed = connections.remove(key2);
    assert_eq!(removed.id, 1002);

    // 下次插入会重用已释放的槽位 —— 无碎片
    let key3 = connections.insert(Connection {
        id: 1003,
        buffer: [0; 1024],
        active: true,
    });
    assert_eq!(key3, key2); // 同一个槽位被重用了!
}
}

为 no_std 实现极简 Arena

对于无法引入 bumpalo 的裸机环境,这里有一个基于 unsafe 构建的极简 Arena:

#![allow(unused)]
#![cfg_attr(not(test), no_std)]

fn main() {
use core::alloc::Layout;
use core::cell::{Cell, UnsafeCell};

/// 一个由固定大小字节数组支持的简单堆块分配器 (Bump Allocator)。
/// 非线程安全 —— 在多核环境中请配合锁或按核心独立使用。
///
/// **重要提示**:与 `bumpalo` 类似,此 Arena 在释放时不调用已分配条目的析构函数。
/// 实现 `Drop` 的类型(如文件句柄、套接字等)会产生资源泄漏。
/// 请仅分配不含重要 `Drop` 实现的类型,或在 Arena 释放前手动 drop 它们。
pub struct FixedArena<const N: usize> {
    // 此处必须使用 UnsafeCell:我们要通过 `&self` 修改 `buf`。
    // 如果没有 UnsafeCell,将 &self.buf 转换为 *mut u8 将是 UB
    // (违反了 Rust 的别名模型 —— 共享引用意味着不可变)。
    buf: UnsafeCell<[u8; N]>,
    offset: Cell<usize>, // 用于 &self 分配的内部可变性
}

impl<const N: usize> FixedArena<N> {
    pub const fn new() -> Self {
        FixedArena {
            buf: UnsafeCell::new([0; N]),
            offset: Cell::new(0),
        }
    }

    /// 在 Arena 中分配一个 `T`。空间不足时返回 `None`。
    pub fn alloc<T>(&self, value: T) -> Option<&mut T> {
        let layout = Layout::new::<T>();
        let current = self.offset.get();

        // 向上对齐 (Align up)
        let aligned = (current + layout.align() - 1) & !(layout.align() - 1);
        let new_offset = aligned + layout.size();

        if new_offset > N {
            return None; // Arena 已满
        }

        self.offset.set(new_offset);

        // SAFETY:
        // - `aligned` 位于 `buf` 边界内 (已在上方检查)
        // - 对齐正确 (已对齐至 T 的要求)
        // - 无别名冲突:每次分配返回一个唯一的、非重叠的区域
        // - UnsafeCell 授权了通过 &self 进行修改的权限
        // - Arena 的存活时间超过返回的引用 (调用者需确保)
        let ptr = unsafe {
            let base = (self.buf.get() as *mut u8).add(aligned);
            let typed = base as *mut T;
            typed.write(value);
            &mut *typed
        };

        Some(ptr)
    }

    /// 重置 Arena —— 会使之前所有的分配失效。
    ///
    /// # Safety
    /// 调用者必须确保不存在任何指向 Arena 分配数据的引用。
    pub unsafe fn reset(&self) {
        self.offset.set(0);
    }

    pub fn used(&self) -> usize {
        self.offset.get()
    }

    pub fn remaining(&self) -> usize {
        N - self.offset.get()
    }
}
}

选择分配器策略

注意:下方的图表使用了 Mermaid 语法。它可以在 GitHub 以及支持 Mermaid 的工具(如带有 mermaid 插件的 mdBook)中渲染。

graph TD
    A["你的分配模式是什么?"] --> B{是否为同一类型?}
    A --> I{"运行环境?"}
    B -->|是| C{是否需要单独释放?}
    B -->|否| D{是否需要单独释放?}
    C -->|是| E["<b>Slab<b><br/>slab crate<br/>O(1) 分配 + 释放<br/>基于索引访问"]
    C -->|否| F["<b>typed-arena<b><br/>批量分配,批量释放<br/>生命周期受限的引用"]
    D -->|是| G["<b>标准分配器<b><br/>Box, Vec 等<br/>通用的 malloc"]
    D -->|否| H["<b>Bump Arena<b><br/>bumpalo crate<br/>~2ns 分配,O(1) 批量释放"]
    
    I -->|no_std| J["FixedArena (自定义)<br/>或 embedded-alloc"]
    I -->|std| K["bumpalo / typed-arena / slab"]
    
    style E fill:#91e5a3,color:#000
    style F fill:#91e5a3,color:#000
    style G fill:#89CFF0,color:#000
    style H fill:#91e5a3,color:#000
    style J fill:#ffa07a,color:#000
    style K fill:#91e5a3,color:#000
C 语言模式Rust 等效方案关键优势
自定义 malloc() 池#[global_allocator] 实现类型安全、易于调试
obstack (GNU)bumpalo::Bump生命周期约束,无“释放后使用”
内核 Slab (kmem_cache)slab::Slab<T>类型安全、基于索引
栈分配的临时缓冲区FixedArena<N> (见上文)无需堆内存、const 可构造
alloca()[T; N] 或 SmallVec编译时确定大小,无 UB

交叉引用:关于裸机环境分配器的设置 (在使用 embedded-alloc 时配合 #[global_allocator]),请参阅《面向 C 程序员的 Rust 培训》第 15.1 节“全局分配器设置”,该节涵盖了嵌入式特定的引导过程。

关键要点 —— Unsafe Rust

  • 记录不变式 (SAFETY: 注释)、在安全 API 后进行封装、最小化 Unsafe 作用域。
  • [const { MaybeUninit::uninit() }; N] (Rust 1.79+) 取代了旧的 assume_init 反模式。
  • FFI 需要 extern "C"、#[repr(C)] 以及对空值和生命周期的仔细处理。
  • Arena 和 Slab 分配器以牺牲通用灵活性为代价,换取了极高的分配速度。

另请参阅: 第 4 章 关于 Unsafe 代码在型变和 Drop 检查方面的交互。第 9 章 关于 Pin 和自引用类型。


练习:围绕 Unsafe 编写安全包装器 ★★★ (~45 分钟)

编写一个 FixedVec<T, const N: usize> —— 一个固定容量、栈分配的向量。 要求如下:

  • push(&mut self, value: T) -> Result<(), T> 当缓冲区满时返回 Err(value)。
  • pop(&mut self) -> Option<T> 返回并移除最后一个元素。
  • as_slice(&self) -> &[T] 借用已初始化的元素。
  • 所有公共方法必须是安全的;所有 Unsafe 部分必须使用 SAFETY: 注释进行封装。
  • Drop 必须清理所有已初始化的元素。
🔑 参考答案
use std::mem::MaybeUninit;

pub struct FixedVec<T, const N: usize> {
    data: [MaybeUninit<T>; N],
    len: usize,
}

impl<T, const N: usize> FixedVec<T, N> {
    pub fn new() -> Self {
        FixedVec {
            data: [const { MaybeUninit::uninit() }; N],
            len: 0,
        }
    }

    pub fn push(&mut self, value: T) -> Result<(), T> {
        if self.len >= N { return Err(value); }
        // SAFETY: len < N,因此 data[len] 在边界内。
        self.data[self.len] = MaybeUninit::new(value);
        self.len += 1;
        Ok(())
    }

    pub fn pop(&mut self) -> Option<T> {
        if self.len == 0 { return None; }
        self.len -= 1;
        // SAFETY: data[len] 之前已初始化 (减量前 len > 0)。
        Some(unsafe { self.data[self.len].assume_init_read() })
    }

    pub fn as_slice(&self) -> &[T] {
        // SAFETY: data[0..len] 均已初始化,且 MaybeUninit<T>
        // 与 T 的内存布局相同。
        unsafe { std::slice::from_raw_parts(self.data.as_ptr() as *const T, self.len) }
    }

    pub fn len(&self) -> usize { self.len }
    pub fn is_empty(&self) -> bool { self.len == 0 }
}

impl<T, const N: usize> Drop for FixedVec<T, N> {
    fn drop(&mut self) {
        // SAFETY: data[0..len] 已初始化 —— 需要逐个释放。
        for i in 0..self.len {
            unsafe { self.data[i].assume_init_drop(); }
        }
    }
}

fn main() {
    let mut v = FixedVec::<String, 4>::new();
    v.push("hello".into()).unwrap();
    v.push("world".into()).unwrap();
    assert_eq!(v.as_slice(), &["hello", "world"]);
    assert_eq!(v.pop(), Some("world".into()));
    assert_eq!(v.len(), 1);
}