用原始指针实现自引用缓冲区:从 comprehensive-rust 课程看 unsafe 代码的安全性与设计权衡
本文是 Google Android 团队维护的 Rust 课程(comprehensive-rust)中 "Unsafe Deep Dive → Pinning → Self-Referential Buffer" 单元的核心讲稿扩展。文章以课程提供的 SelfReferentialBuffer 原始指针实现为骨架,逐行剖析其 new、update_cursor、read、write 方法背后的指针运算与内存安全契约,并围绕课程提出的"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)
}
}
实现要点:
start = data.as_ptr()与end = start.add(1024)构造缓冲区首尾指针(add按元素为单位做指针偏移,[u8; 1024]的元素大小是 1,因此等价于前进 1024 字节);- 用
assert!((start..=end).contains(&cursor))做运行时边界检查,确保cursor落在合法区间内才继续; end.offset_from(cursor)计算从cursor到末尾的元素个数(即剩余可用字节数),n_bytes.min(available)防止请求长度越界;- 最后通过
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()andwrite()methods be marked as unsafe? A: Yes, becauseself.cursorwill 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;
}
}
对比原始指针版本,该方案有三个质的飞跃:
- 完全消除了
unsafe:position是普通usize,切片索引self.data[a..b]自带边界检查; - 移动安全:
position是相对偏移量而非绝对地址,实例搬家后偏移量依然有效,不需要update_cursor()这类手动修复; - 引用按需创建:每次
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.md 对Pin语义的讲解),因此指针永远不会因移动而失效; - 方法签名也随之改变:
write接收self: Pin<&mut Self>,通过get_unchecked_mut获取可变引用;read则可以直接利用"对象已固定"的事实从偏移量重建切片。
与原始指针版相比,unsafe 被收敛到少数初始化/取引用点,而不是散落在每个读写路径上,同时彻底消灭了"移动后忘记 update_cursor"这类用户态错误——这正是课程推荐的折中方案。该系列属于 Pinning 教学单元,单元背景可参考 pinning 单元说明。
八、总结:从"能编译"到"健全(sound)"
回顾整个原始指针示例,我们可以提炼出四条可复用的 unsafe 设计准则:
- 把不安全操作收敛到最小范围:
unsafe块应该只包住真正需要绕过检查的那一条语句,而不是整个方法体; - 每个
unsafe块都要配安全注释:写清前置条件、不变式与维持方式,这是他人审查(以及未来你自己维护)的前提; - 需要前置条件的公开方法应当标记为
unsafe:如果调用方不满足条件就会触发 UB,就必须通过unsafe fn把责任显式移交出去——本例中的read()/write()正是如此; - "unsafe 密度"是设计气味的诊断信号:当实现中 unsafe 高频出现时,停下来想一想是不是可以换一种抽象——整数偏移量方案用零 unsafe 达成同样功能,
Pin方案则把 unsafe 压缩到构造阶段。
这篇讲稿来自 comprehensive-rust 课程的 "Unsafe Deep Dive" 单元,对应教学时长为 5 分钟(见文档 front matter 的 minutes: 5 元数据),其价值不在于展示一个可上线的实现,而在于用一个小而完整的反例,让你直观体会 soundness 审查的全过程:什么样的 unsafe 代码可以接受、为什么不可以、以及如何用类型系统(usize 偏移、Pin + PhantomPinned)把风险结构性地消灭掉。理解了这个例子的演进路径,你对 Pin、Unpin、PhantomPinned 以及 FFI 场景下指针语义的理解都会扎实很多。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00