std::ptr::addr_of! 宏在不安全的 Rust 中起着关键作用,允许在不经过创建引用的中间步骤的情况下直接创建字段的原始指针。当处理 #[repr(packed)] 结构时,字段可能驻留在未对齐的内存偏移处,违反了引用类型固有的对齐要求。尝试通过 & 操作符为这些未对齐数据创建引用会立即导致未定义行为,无论引用是否随后被使用。addr_of! 宏通过直接从字段的地址中生成原始指针来规避这一点,绕过了引用强加的对齐和有效性不变式。这一区别对于声音的 FFI 交互和底层内存操作至关重要,尤其是在打包数据布局普遍存在的情况下。
在为遗留二进制协议开发高性能解析器时,工程团队遇到了一个 #[repr(packed)] 结构,其中一个 u32 字段被故意放置在 1 字节的偏移位置,以匹配外部硬件寄存器映射。最初的实现试图使用 &packet.status_register 来借用此字段以传递给验证函数,却不知道这样创建了一个未对齐的引用,并触发了立即的未定义行为。
第一个考虑的解决方案是移除 packed 属性,并手动插入填充字节以强制对齐。这种方法通过允许自然引用创建来保证安全性,但破坏了与硬件规范的二进制兼容性,并在传输这些结构的大型数组时浪费了内存带宽。
第二种方法建议使用指针算术来手动计算字段地址,使用 unsafe { &*(base_ptr.add(1) as *const u32) } 。虽然这种方法避免了直接字段访问的语法,但它仍然通过 &* 解引用操作符实现引用,如果结果指针没有正确对齐,则构成未定义行为,提供的安全性并没有比原始的天真的借用更高,并且可能误导未来的维护者。
团队最终选择了第三种解决方案,利用 std::ptr::addr_of! 来派生未对齐字段的原始指针,而不创建中间引用。然后将此指针传递给 std::ptr::read_unaligned,以安全地将值复制到正确对齐的局部变量中。该策略保持了所需的内存布局,同时严格遵循 Rust 的内存模型,导致代码通过了 Miri 的严格测试,并在包括 ARM 和 x86_64 在内的多个目标架构上正常工作。
为什么为未对齐数据创建引用会构成未定义行为,即使引用被立即转换为原始指针?
在 Rust 中,创建引用(例如 &packed.field)的行为不仅仅是指针计算,而是对编译器的一个断言,目标内存满足该引用类型的所有不变式,包括对齐和有效性(用于读取)。LLVM 后端和 Rust 的优化器假定这些不变式在引用创建时立即成立,从而启用诸如加载存储重排序或推测加载等激进优化。即使引用立刻被转换为 *const T,优化器可能已经发出了假设对齐访问的指令,或者它可能在 LLVM 元数据中将引用值标记为 dereferenceable,导致在具有严格对齐要求的架构上出现错误编译。因此,未定义行为发生在引用创建的那一刻,而不是在解引用的时刻,使得未对齐引用的存在对程序的正确性构成威胁。
addr_of! 与将现有引用使用 as *const _ 转换有何不同,为什么需要这个宏?
当编写 &packed.field as *const T 时,Rust 编译器首先创建一个引用(触发对齐检查和潜在的 UB),然后才将该有效引用转换为原始指针。相反,std::ptr::addr_of! 直接作用于位置表达式(字段),生成一个原始指针,而从不构造中间引用。这一点至关重要,因为编译器将 addr_of! 的内部视为一种特殊构造,跳过引用有效性检查,而 as 关键字执行的值到值转换要求源值(引用)是有效的。使用该宏确保指针派生本身不会因对齐违规而引入未定义行为,从而提供了获取潜在未对齐数据地址的唯一可靠路径。
在使用 addr_of_mut! 获取包含 UnsafeCell 的结构中的字段指针时,需要考虑哪些额外因素?
当 #[repr(packed)] 结构包含 UnsafeCell<T> 时,获取内部的可变指针需要仔细处理 Rust 的别名规则。UnsafeCell 提供内部可变性,但为未对齐的 UnsafeCell 字段创建可变引用(&mut)仍然违反对齐要求并且是未定义行为。候选人常常假设 UnsafeCell 在某种程度上使指针免于对齐规则,但它仅仅免除了排他引用别名保证(noalias),而不是对齐要求。使用 addr_of_mut! 会产生一个 *mut T,在最终解引用或传递给 UnsafeCell::raw_get 时仍必须尊重底层类型的对齐,这就需要使用 read_unaligned 或 write_unaligned 来访问实际数据。