首页
/ 用原始指针实现自引用缓冲区:从 comprehensive-rust 课程看 unsafe 代码的安全性与设计权衡

用原始指针实现自引用缓冲区:从 comprehensive-rust 课程看 unsafe 代码的安全性与设计权衡

2026-09-09 22:08:24作者:幸俭卉

本文是 Google Android 团队维护的 Rust 课程(comprehensive-rust)中 "Unsafe Deep Dive → Pinning → Self-Referential Buffer" 单元的核心讲稿扩展。文章以课程提供的 SelfReferentialBuffer 原始指针实现为骨架,逐行剖析其 newupdate_cursorreadwrite 方法背后的指针运算与内存安全契约,并围绕课程提出的"unsafe 频繁出现""缺少安全注释""unsafe 块过大"三大教学要点展开,最终对比"整数偏移量"与"Pin 固定"两种替代建模方案,帮助你理解为何这种结构与 Rust 所有权模型天然冲突,以及编写可辩护(sound)unsafe 代码的基本纪律。

一、背景:什么是自引用缓冲区

课程在 self-referential-buffer.md 中给出了定义:自引用缓冲区(self-referential buffer)是一种持有指向自身某个字段引用的类型。其课程示例的 Rust 骨架如下:

pub struct SelfReferentialBuffer {
    data: [u8; 1024],
    cursor: *mut u8,
}

这里的 data 是一块 1 KiB 的内存,cursor 是指向 data 内部某个位置的指针。该结构在 Rust 中并不常见,因为当 SelfReferentialBuffer 实例被移动(move)时,没有任何机制自动更新 cursor 指向的地址——旧的指针仍指向旧的内存位置,成为悬垂指针。

而在带垃圾回收(GC)的语言中,对象地址由 GC 间接管理,这种结构非常自然;C++ 则允许用户自定义移动/拷贝行为来同步指针(详见课程中的 C++ 建模版本,它通过自定义移动构造函数把 cursor 重算为新对象内的相对偏移)。

二、三种建模方式总览

课程的 rust.md 指出,要为上面的 C++ 类找到 Rust 等价物,有三条路线:

建模方式 类型设计要点 课程评价
原始指针(Raw pointers) cursor: *mut u8 与 C++ 语义最接近,但使用起来极其危险
整数偏移量(Integer offsets) cursor: usize 更符合 Rust 习惯,但需要手工创建引用
固定(Pinning) cursor: *mut u8 + _pin: PhantomPinned 允许使用原始指针,同时大幅减少 unsafe

本文聚焦第一种方案(即关联文档 rust-raw-pointers.md 的完整内容),并在后文与另外两种方案对照。

三、原始指针实现逐行剖析

课程的原始指针版本完整代码如下(来自 rust-raw-pointers.md,标注 rust,editable,可直接在课程页面内运行):

#[derive(Debug)]
pub struct SelfReferentialBuffer {
    data: [u8; 1024],
    cursor: *mut u8,
}

impl SelfReferentialBuffer {
    pub fn new() -> Self {
        let mut buffer =
            SelfReferentialBuffer { data: [0; 1024], cursor: std::ptr::null_mut() };

        buffer.update_cursor();
        buffer
    }

    // Danger: must be called after every move
    pub fn update_cursor(&mut self) {
        self.cursor = self.data.as_mut_ptr();
    }

    pub fn read(&self, n_bytes: usize) -> &[u8] {
        unsafe {
            let start = self.data.as_ptr();
            let end = start.add(1024);
            let cursor = self.cursor as *const u8;

            assert!((start..=end).contains(&cursor), "cursor is out of bounds");

            let available = end.offset_from(cursor) as usize;
            let len = n_bytes.min(available);
            std::slice::from_raw_parts(cursor, len)
        }
    }

    pub fn write(&mut self, bytes: &[u8]) {
        unsafe {
            let start = self.data.as_mut_ptr();
            let end = start.add(1024);

            assert!((start..=end).contains(&self.cursor), "cursor is out of bounds");
            let available = end.offset_from(self.cursor) as usize;
            let len = bytes.len().min(available);

            std::ptr::copy_nonoverlapping(bytes.as_ptr(), self.cursor, len);
            self.cursor = self.cursor.add(len);
        }
    }
}

下面逐个方法展开分析。

3.1 结构定义与 new():用空指针占位

#[derive(Debug)]
pub struct SelfReferentialBuffer {
    data: [u8; 1024],
    cursor: *mut u8,
}
  • data: [u8; 1024]:固定 1 KiB 的字节缓冲区;
  • cursor: *mut u8:裸可变指针,指向 data 内部某个位置。

new() 先把 cursor 初始化为 std::ptr::null_mut()(空指针)作为占位,再调用 update_cursor() 把指针绑定到 data 的起始地址,最后返回 buffer注意这里的返回值语义buffer 在返回时经历了一次移动(从栈上局部变量移动到调用方),而课程注释明确警告——update_cursor() 必须在每次移动之后重新调用,否则 cursor 指向的是旧地址。这是整个设计最脆弱的地方。

3.2 update_cursor():移动后的手动修复

// Danger: must be called after every move
pub fn update_cursor(&mut self) {
    self.cursor = self.data.as_mut_ptr();
}

data.as_mut_ptr() 返回指向 data 首元素的裸指针。由于 Rust 的移动是"位拷贝",实例换地址后,cursor 仍然保留旧值,因此必须由调用者手动调用本方法重新计算。这个"手动修复"约定没有任何编译期保障——忘记调用就会产生指向已失效内存的悬垂指针,这正是 unsafe 代码"靠纪律而非靠类型系统"维持正确性的典型体现。

3.3 read():指针运算与手工切片

pub fn read(&self, n_bytes: usize) -> &[u8] {
    unsafe {
        let start = self.data.as_ptr();
        let end = start.add(1024);
        let cursor = self.cursor as *const u8;

        assert!((start..=end).contains(&cursor), "cursor is out of bounds");

        let available = end.offset_from(cursor) as usize;
        let len = n_bytes.min(available);
        std::slice::from_raw_parts(cursor, len)
    }
}

实现要点:

  1. start = data.as_ptr()end = start.add(1024) 构造缓冲区首尾指针(add 按元素为单位做指针偏移,[u8; 1024] 的元素大小是 1,因此等价于前进 1024 字节);
  2. assert!((start..=end).contains(&cursor))运行时边界检查,确保 cursor 落在合法区间内才继续;
  3. end.offset_from(cursor) 计算从 cursor 到末尾的元素个数(即剩余可用字节数),n_bytes.min(available) 防止请求长度越界;
  4. 最后通过 std::slice::from_raw_parts(cursor, len) 从裸指针+长度凭空构造切片返回。

3.4 write():非重叠拷贝与游标推进

pub fn write(&mut self, bytes: &[u8]) {
    unsafe {
        let start = self.data.as_mut_ptr();
        let end = start.add(1024);

        assert!((start..=end).contains(&self.cursor), "cursor is out of bounds");
        let available = end.offset_from(self.cursor) as usize;
        let len = bytes.len().min(available);

        std::ptr::copy_nonoverlapping(bytes.as_ptr(), self.cursor, len);
        self.cursor = self.cursor.add(len);
    }
}

write()read() 对称:

  • 同样先断言 cursor 在界内,再计算剩余容量并截断写入长度;
  • std::ptr::copy_nonoverlapping(bytes.as_ptr(), self.cursor, len) 把调用方传入的字节直接拷贝到 cursor 指向的位置。该函数要求源与目标不重叠(此处源是外部切片、目标是缓冲区内部,天然满足);
  • 拷贝完成后 self.cursor = self.cursor.add(len) 把游标向前推进 len 个字节,形成"流式写入"语义。

注意:整个方法体只有一段 assert! 作为防御,没有任何关于别名、生命周期或安全前置条件的注释

四、课程教学要点:这份代码为什么"不健全(unsound)"

关联文档的讲稿备注(<details> 块)给出了三个核心论点,这也是本课时的教学重点:

4.1 unsafe 频繁出现,是"设计不当"的信号

课程明确提示:"Emphasize that unsafe appears frequently. This is a hint that another design may be more appropriate."(强调 unsafe 出现得很频繁,这暗示可能还有更合适的设计)。

统计可见,read()write() 的方法体几乎被 unsafe 块整体包裹,加上 new() 中隐含的移动陷阱,几乎每个公开方法都依赖 unsafe 才能工作。课程把这种"unsafe 密度"当作设计气味的诊断指标:一个把不安全性摊开到所有操作上的 API,往往意味着底层抽象选错了——后面会看到 Pin 方案如何把 unsafe 收敛到构造期。

4.2 缺少安全注释(safety comments),因此代码是不健全的

课程原话:"unsafe blocks lack safety comments. Therefore, this code is unsound."

这是 Rust 社区的重要约定:每一个 unsafe 块都必须配以安全注释,说明调用方需要满足哪些前置条件(preconditions)、由谁保证这些条件成立。本实现的两个 unsafe 块既没有说明"调用者必须先调用 update_cursor()"这一前置条件,也没有论证"cursor 一定指向 data 内部"这一不变式如何维持,因此无法通过人工审查来确认其正确性——从这个意义上说,它是 unsound 的。

4.3 unsafe 块过大,违反"最小化"原则

课程指出:"unsafe blocks are too broad. Good practice uses smaller unsafe blocks with specific behavior, specific preconditions and specific safety comments."

良好实践要求:

  • 尽量小的 unsafe:只把真正需要避开安全检查的少数语句放进 unsafe,而不是包住整个方法;
  • 具体的行为:每个 unsafe 块只做一件明确的事(如"从已知合法的裸指针构造切片");
  • 具体的前置条件:明确列出调用方必须保证什么(如"指针必须非空且在界内");
  • 具体的安全注释:用注释说明为什么此刻执行这个操作是安全的。

对比之下,read()/write() 把断言、指针运算、切片构造全部塞进一个大块,既难审查也难以迁移,是反面教材。

五、课堂问答:为什么 read()write() 必须标记为 unsafe

课程在讲稿备注中设置了一组问答,原文如下:

Q: Should the read() and write() methods be marked as unsafe? A: Yes, because self.cursor will be a null pointer unless written to.

即:read()write() 应当标记为 unsafe,因为 self.cursor 在未被写入过的情况下是 null_mut()——回想 new() 里的初始化顺序:虽然 new() 会调用 update_cursor() 修复指针,但如果一个实例通过其他途径构造(或移动后忘记修复),cursor 就可能为 null 或指向陈旧地址。此时调用 read()/write(),内部指针运算与 from_raw_parts/copy_nonoverlapping 都会触发未定义行为(UB)。

这正是 Rust 的 API 设计原则:如果一个方法的正确性依赖调用方满足特定前置条件,且编译器无法验证,那么该方法就必须标记为 unsafe,把责任显式移交给调用者。当前实现的"公开非 unsafe 方法 + 内部裸奔指针运算"组合,等于在普通安全代码里埋下了 UB 炸弹。

六、对照方案一:整数偏移量(更地道的 Rust 写法)

课程在 rust-offset.md 给出了最符合 Rust 习惯的替代方案:把指针换成相对偏移量,在使用时按需创建引用:

#[derive(Debug)]
pub struct SelfReferentialBuffer {
    data: [u8; 1024],
    position: usize,
}

impl SelfReferentialBuffer {
    pub fn new() -> Self {
        SelfReferentialBuffer { data: [0; 1024], position: 0 }
    }

    pub fn read(&self, n_bytes: usize) -> &[u8] {
        let available = self.data.len().saturating_sub(self.position);
        let len = n_bytes.min(available);
        &self.data[self.position..self.position + len]
    }

    pub fn write(&mut self, bytes: &[u8]) {
        let available = self.data.len().saturating_sub(self.position);
        let len = bytes.len().min(available);
        self.data[self.position..self.position + len].copy_from_slice(&bytes[..len]);
        self.position += len;
    }
}

对比原始指针版本,该方案有三个质的飞跃:

  1. 完全消除了 unsafeposition 是普通 usize,切片索引 self.data[a..b] 自带边界检查;
  2. 移动安全position 是相对偏移量而非绝对地址,实例搬家后偏移量依然有效,不需要 update_cursor() 这类手动修复;
  3. 引用按需创建:每次 read()&self 现场构造子切片,借用检查器可以正常管理生命周期。

代价是每次都要做一次"偏移量→引用"的转换,且无法直接表达"指到一半再指回来"这类指针式操作——但对本场景来说,这正是课程评价的"更自然、更地道"。

七、对照方案二:Pin<Box<Self>> 固定(保留指针但收窄 unsafe)

如果业务语义必须保留裸指针,课程在 rust-pin.md 给出的方案是用 Pin 固定对象地址:

use std::marker::PhantomPinned;
use std::pin::Pin;

/// A self-referential buffer that cannot be moved.
#[derive(Debug)]
pub struct SelfReferentialBuffer {
    data: [u8; 1024],
    cursor: *mut u8,
    _pin: PhantomPinned,
}

impl SelfReferentialBuffer {
    pub fn new() -> Pin<Box<Self>> {
        let buffer = SelfReferentialBuffer {
            data: [0; 1024],
            cursor: std::ptr::null_mut(),
            _pin: PhantomPinned,
        };
        let mut pinned = Box::pin(buffer);

        unsafe {
            let mut_ref = Pin::get_unchecked_mut(pinned.as_mut());
            mut_ref.cursor = mut_ref.data.as_mut_ptr();
        }

        pinned
    }

    pub fn read(&self, n_bytes: usize) -> &[u8] {
        unsafe {
            let start = self.data.as_ptr();
            let end = start.add(self.data.len());
            let cursor = self.cursor as *const u8;

            assert!((start..=end).contains(&cursor), "cursor is out of bounds");

            let offset = cursor.offset_from(start) as usize;
            let available = self.data.len().saturating_sub(offset);
            let len = n_bytes.min(available);

            &self.data[offset..offset + len]
        }
    }

    pub fn write(mut self: Pin<&mut Self>, bytes: &[u8]) {
        let this = unsafe { self.as_mut().get_unchecked_mut() };
        unsafe {
            let start = this.data.as_mut_ptr();
            let end = start.add(1024);

            assert!((start..=end).contains(&this.cursor), "cursor is out of bounds");
            let available = end.offset_from(this.cursor) as usize;
            let len = bytes.len().min(available);

            std::ptr::copy_nonoverlapping(bytes.as_ptr(), this.cursor, len);
            this.cursor = this.cursor.add(len);
        }
    }
}

关键差异与代价:

  • 构造函数签名变为 new() -> Pin<Box<Self>>:因为 Pin<Ptr> 必须配合 Box 这类指针类型使用,因此引入了一次堆分配
  • 初始化时机被规范化:在 Box::pin(buffer) 把对象固定到堆上之后,用 Pin::get_unchecked_mut() 短暂破坏固定保证来初始化 cursor。这一步 unsafe 的契约是:SelfReferentialBuffer 一旦被固定,在 drop 之前内存位置不可变(参见 definition-of-pin.mdPin 语义的讲解),因此指针永远不会因移动而失效;
  • 方法签名也随之改变write 接收 self: Pin<&mut Self>,通过 get_unchecked_mut 获取可变引用;read 则可以直接利用"对象已固定"的事实从偏移量重建切片。

与原始指针版相比,unsafe收敛到少数初始化/取引用点,而不是散落在每个读写路径上,同时彻底消灭了"移动后忘记 update_cursor"这类用户态错误——这正是课程推荐的折中方案。该系列属于 Pinning 教学单元,单元背景可参考 pinning 单元说明

八、总结:从"能编译"到"健全(sound)"

回顾整个原始指针示例,我们可以提炼出四条可复用的 unsafe 设计准则:

  1. 把不安全操作收敛到最小范围unsafe 块应该只包住真正需要绕过检查的那一条语句,而不是整个方法体;
  2. 每个 unsafe 块都要配安全注释:写清前置条件、不变式与维持方式,这是他人审查(以及未来你自己维护)的前提;
  3. 需要前置条件的公开方法应当标记为 unsafe:如果调用方不满足条件就会触发 UB,就必须通过 unsafe fn 把责任显式移交出去——本例中的 read()/write() 正是如此;
  4. "unsafe 密度"是设计气味的诊断信号:当实现中 unsafe 高频出现时,停下来想一想是不是可以换一种抽象——整数偏移量方案用零 unsafe 达成同样功能,Pin 方案则把 unsafe 压缩到构造阶段。

这篇讲稿来自 comprehensive-rust 课程的 "Unsafe Deep Dive" 单元,对应教学时长为 5 分钟(见文档 front matter 的 minutes: 5 元数据),其价值不在于展示一个可上线的实现,而在于用一个小而完整的反例,让你直观体会 soundness 审查的全过程:什么样的 unsafe 代码可以接受、为什么不可以、以及如何用类型系统(usize 偏移、Pin + PhantomPinned)把风险结构性地消灭掉。理解了这个例子的演进路径,你对 PinUnpinPhantomPinned 以及 FFI 场景下指针语义的理解都会扎实很多。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
docsdocs
暂无描述
Markdown
899
5.83 K
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
925
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.84 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
601
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
395
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.04 K
525