Rust 指针算术核心:ptr::add的无符号偏移语义与安全契约深度解析
【免费下载链接】rustEmpowering everyone to build reliable and efficient software.项目地址: https://gitcode.com/GitHub_Trending/ru/rust
导读
ptr::add是 Rust 标准库中所有原始指针(*const T、*mut T)以及NonNull<T>共有的核心方法,用于在指针基础上向前移动指定个数的元素。本文以本仓库 Rust 编译器与标准库源码中的官方 API 文档 library/core/src/ptr/docs/add.md 为骨架,结合其真实实现(const_ptr.rs、mut_ptr.rs、non_null.rs)与模块级文档 library/core/src/ptr/mod.rs,逐条拆解其语义、三条 Safety 前置条件、底层intrinsics::offset调用链,以及与offset、byte_add、wrapping_add等相邻 API 的选型差异。读完本文,你将能够准确判断"何时能用add、何时必须换用其他方法",并写出符合安全契约的指针算术代码。
ptr::add的核心语义:只能前进的无符号偏移
根据 add.md 的原始定义:
Adds an unsigned offset to a pointer.
add向指针添加一个无符号偏移量,因此它只能把指针向前移动(或原地不动)。这一约束直接来自其参数类型:
add接受count: usize(无符号);- 而
offset接受count: isize(有符号),可以按正负值前进或后退。
官方文档明确建议:如果偏移方向需要依据值本身决定(即可能前移也可能后移),应当改用接收有符号偏移的offset方法。
count的单位是 T,而不是字节
这是最容易踩坑的一点:
countis in units of T; e.g., acountof 3 represents a pointer offset of3 * size_of::<T>()bytes.
add的count以被指向类型 T 的个数为单位。例如:
- 对
*const u8调用add(3),实际偏移3 * 1 = 3字节; - 对
*const u32调用add(3),实际偏移3 * 4 = 12字节。
因此ptr.add(1)恒等于指向"下一个T元素"的指针,这与数组/切片索引的步长语义完全一致。仓库中的官方示例也体现了这一点——const_ptr.rs 中对字符串首字节指针执行add(1)、add(2)依次取得b'2'、b'3':
let s: &str = "123"; let ptr: *const u8 = s.as_ptr(); unsafe { assert_eq!(*ptr.add(1), b'2'); assert_eq!(*ptr.add(2), b'3'); }若想以字节为单位偏移,则应使用byte_add(见下文"相邻 API 对比"小节)。
三条 Safety 前置条件:何时调用是未定义行为
add是unsafe fn,其安全契约的权威文本就是本文档。文档明确声明:
If any of the following conditions are violated, the result is Undefined Behavior.
以下三条中任意一条被违反,结果即为未定义行为(UB)。
条件一:字节偏移必须能装进isize
The offset in bytes,
count * size_of::<T>(), computed on mathematical integers (without "wrapping around"), must fit in anisize.
在数学整数(不发生环绕)上计算count * size_of::<T>(),其乘积必须能放入isize。之所以是isize,是因为底层intrinsics::offset的字节偏移参数是有符号的,且指针算术的"合法跨度"被限制在isize::MAX之内。
条件二:结果地址必须能装进usize
Let
resultbeself.addr() + count * size_of::<T>(), computed on mathematical integers. This must fit in ausize.
设result = self.addr() + count * size_of::<T>()(仍在数学整数上计算),该结果地址必须能被usize表示——即不能越过地址空间上限发生"概念上的回绕"。
条件三:非零偏移必须始终停留在同一个分配(allocation)内
If the computed offset is non-zero, then
selfmust be derived from a pointer to some allocation, and the entire memory range betweenselfandresult(i.e.,self.addr()..result) must be in bounds of that allocation.
如果计算出的偏移非零,则:
self必须派生自某个 allocation 的指针(即携带正确的 provenance);self与result之间的整个内存区间self.addr()..result必须完整落在该 allocation 的边界之内。
注意区间是self.addr()..result(因为是向前移动,起点必小于终点),而offset方法对应的文档(offset.md)中该区间写作min(self.addr(), result)..max(self.addr(), result),以兼容向后移动的情形。这是add与offset文档在边界描述上唯一的差异。
关于什么是 allocation,mod.rs 给出了精确定义:
Anallocationis a subset of program memory which is addressable from Rust, and within which pointer arithmetic is possible. Examples of allocations include heap allocations, stack-allocated variables, statics, and consts.
即:堆分配、栈上变量、静态变量、常量都属于 allocation;并且每个(栈上)变量都被视为独立的 allocation——这解释了为什么跨变量进行指针算术是非法的。
蕴含关系:为什么第三条隐含前两条
文档给出了一个重要的推论:
Allocations can never be larger than
isize::MAXbytes and they can only contain addresses representable byusize, so technically the last condition implies the first two.
由于 allocation 的大小不可能超过isize::MAX字节,且其地址必然可用usize表示,因此只要满足"指针停留在同一 allocation 内"这一条,前两条(isize/usize不溢出)在数学上自动成立。
由此得到一个非常实用的安全结论:
vec.as_ptr().add(vec.len())(forvec: Vec<T>) is always safe.
只要Vec的长度是合法的(长度以 T 为单位),把首指针向前移动len个元素恰好落在缓冲区末尾(one-past-the-end),这是add最经典、最安全的合法用法之一。
从源码看实现:intrinsics::offset与调试期溢出检查
add的文档通过#[doc = include_str!("./docs/add.md")]被同时嵌入三处定义(const_ptr.rs、mut_ptr.rs、non_null.rs),因此三种指针类型的add共享同一份安全契约。它们的实现也高度一致,以*const T为例(const_ptr.rs):
pub const unsafe fn add(self, count: usize) -> Self where T: Sized, { #[cfg(debug_assertions)] const fn runtime_add_nowrap(this: *const (), count: usize, size: usize) -> bool { const_eval_select!( @capture { this: *const (), count: usize, size: usize } -> bool: if const { true } else { let Some(byte_offset) = count.checked_mul(size) else { return false; }; let (_, overflow) = this.addr().overflowing_add(byte_offset); byte_offset <= (isize::MAX as usize) && !overflow } ) } #[cfg(debug_assertions)] // Expensive, and doesn't catch much in the wild. ub_checks::assert_unsafe_precondition!( check_language_ub, "ptr::add requires that the address calculation does not overflow", ( this: *const () = self as *const (), count: usize = count, size: usize = size_of::<T>(), ) => runtime_add_nowrap(this, count, size) ); // SAFETY: the caller must uphold the safety contract for `offset`. unsafe { intrinsics::offset(self, count) } }从中可以提炼出几个源码级事实:
- 最终落到
intrinsics::offset:所有add最终都委托给编译器内建函数intrinsics::offset(self, count),真正的地址计算由 LLVM 等后端完成。*mut T版本(mut_ptr.rs)与之完全对称。 - 调试构建下的安全检查:在
debug_assertions开启时,通过ub_checks::assert_unsafe_precondition!检查地址计算是否溢出,其判定逻辑(runtime_add_nowrap)逐条对应前两条 Safety 条件:count.checked_mul(size):乘积溢出(即不满足"装入isize")则检查失败;this.addr().overflowing_add(byte_offset):地址回绕则检查失败;byte_offset <= (isize::MAX as usize):显式验证isize::MAX界限。 源码注释也承认该检查"代价较高,且在生产环境捕获不了多少问题"(Expensive, and doesn't catch much in the wild),因此只在 debug 断言下生效,绝不应当把它当作安全保证——编译为 release 后这些检查会被剔除。
const支持:方法标注了rustc_const_stable(feature = "const_ptr_offset", since = "1.61.0"),自 1.61 起可在const fn中使用;const_eval_select!保证编译期求值时直接放行(if const { true })。T: Sized约束:add要求T: Sized,因为"元素个数 × 元素大小"只有在类型大小已知时才有意义;对动态大小类型(?Sized)请使用byte_add之类的字节级方法。
NonNull<T>::add的特殊之处
non_null.rs 中的add通过transmute(intrinsics::offset(self.as_ptr(), count))复用*mut T的底层实现,其注释补充了一个关键推论:
Additionally safety contract of
offsetguarantees that the resulting pointer is pointing to an allocation, there can't be an allocation at null, thus it's safe to constructNonNull.
即:只要offset的安全契约成立,结果指针必然指向某个 allocation,而 null 不可能成为 allocation 的地址,因此可以安全地重构出NonNull。这保证了NonNull::add的返回值永远非空。
与相邻 API 的选型对比
| 方法 | 参数类型 | 移动方向 | 是否unsafe | 单位 | 源码位置 |
|---|---|---|---|---|---|
add | usize | 仅前进 | 是 | T的个数 | const_ptr.rs |
offset | isize | 前进或后退 | 是 | T的个数 | const_ptr.rs |
byte_add | usize | 仅前进 | 是 | 字节 | const_ptr.rs |
wrapping_add | usize | 仅前进 | 否 | T的个数 | const_ptr.rs |
offsetvsadd:当偏移方向取决于运行值(可能为负)时用offset;当只向前遍历时用add。add的文档与 offset.md 的安全契约在字节偏移与地址范围上完全一致,仅边界区间写法不同。byte_addvsadd:byte_add纯粹是"先cast::<u8>()再add"的便捷封装(const_ptr.rs),以字节为单位;对?Sized胖指针,它只改动数据指针、保留 metadata(with_metadata_of)。以字节操作缓冲区(如手写 memcpy、解析二进制格式)时优先用它。wrapping_addvsadd:wrapping_add是安全方法,通过wrapping_offset实现,地址计算允许回绕,不触发 UB;代价是无法获得add那样激进的编译器优化。add文档的完整签名后还附有一句补充:Consider using
wrapping_addinstead if these constraints are difficult to satisfy. The only advantage of this method is that it enables more aggressive compiler optimizations.即:当难以满足上述安全约束时,请改用
wrapping_add——add相比它的唯一优势是允许编译器做更激进的优化。这是选型时最直接的决策依据。
典型安全用法与边界警示
安全模式:vec.as_ptr().add(vec.len())
如前所述,add文档明确保证vec.as_ptr().add(vec.len())对任意Vec<T>都是安全的——len恰为元素个数,结果指向缓冲区末尾的 one-past-the-end 位置,仍在同一 allocation 内。这是遍历、切片视图、FFI 接口中"尾指针"的标准获取方式。
危险模式:越过 allocation 边界
以下做法违反条件三,属于 UB,切勿模仿:
// 反例:两个独立的栈变量,跨 allocation 算术 let a = [1u8; 4]; let b = [2u8; 4]; let p = a.as_ptr().add(4); // 错误:离开 a 的 allocation 进入 b即便"碰巧"地址相邻,编译器仍可依据 provenance 规则将其优化为任意结果——这正是 mod.rs 反复强调"指针的 provenance 决定其源自哪个 allocation,可解引用当且仅当访问范围完全落在该 allocation 内"的原因。
长度换算警示
当元素类型不是u8时,务必确认count是"元素个数"而非"字节数"。例如对一个*const u32想前进 8 字节,应写add(2)而非add(8);若业务数据天然以字节计量,应改用byte_add(8),避免手写count * size_of::<T>()的乘法引入溢出与笔误。
总结
ptr::add是 Rust 指针算术体系中"单向前进"的基础原语,其语义可浓缩为三点:参数是无符号的usize;count以T为单位而非字节;只能前进不能后退。它的安全契约由三条条件构成——字节偏移装入isize、结果地址装入usize、非零偏移必须停留在同一 allocation 内——其中第三条在数学上蕴含前两条,并由此推导出vec.as_ptr().add(vec.len())恒安全的实用结论。从本仓库源码可见,三个指针类型共享同一份契约文档,实现上统一收敛到intrinsics::offset,并在 debug 断言下附带溢出检查;当约束难以满足时,官方建议切换到不产生 UB 的wrapping_add。理解这些细节,是写出既高效又不越界的 unsafe 指针代码的前提。
延伸阅读
- 本文档原文:library/core/src/ptr/docs/add.md
- 有符号版本契约:library/core/src/ptr/docs/offset.md
*const T::add实现:library/core/src/ptr/const_ptr.rs#L838-L873*mut T::add实现:library/core/src/ptr/mut_ptr.rs#L937-L972NonNull<T>::add实现:library/core/src/ptr/non_null.rs#L619-L628- allocation 与 provenance 的模块级定义:library/core/src/ptr/mod.rs#L105-L128
【免费下载链接】rustEmpowering everyone to build reliable and efficient software.项目地址: https://gitcode.com/GitHub_Trending/ru/rust
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考