Rust黑魔法(1)
Rust黑魔法(1)
在学习 Tokio 与 Rust 异步编程的过程中,我时常会遇到各种 unsafe 语法与底层原语,实现方式也常常带着“黑魔法”般的精巧与惊奇。为了系统地消化这些内容,我计划开启一个全新的系列,用于记录自己在阅读 Rust 黑魔法(底层实现、编译器行为、不安全代码)过程中产生的一系列思考与笔记。 除了 Rust 本身,本系列也会同步梳理相关的计算机基础知识,包括内存模型、并发原理、数据布局、指针语义、调度器机制等与“黑魔法”紧密联系的主题。本系列不强调固定的章节结构,将以探索式写作为主。至于何时完结,则取决于我能否把整个 unsafe 体系贯穿得足够完整。
注意:看本系列需要具备一些计算机体系结构和操作系统的一些相关知识
数据布局(data layout)
一开始我看黑魔法的时候比较懵,不知道什么是数据布局,不知道他的重要性,也不知道为何黑魔法要把它放到第二章节,后面才知道,这个东西的重要性就如同gc runtime对于gc语言的重要性。所以必须要详细解释一下。
什么是数据布局?
具体的定义是这样的:
编译器在内存中对数据类型进行实际物理组织方式的完整规范。
它定义了一个类型在内存中如何存储、对齐、排列、偏移、大小等一切底层结构。
我们都知道所有在编程语言的数据结构,特别是高级编程语言,所有的数据结构都是抽象逻辑结构,那么数据布局就是把这些抽象的数据结构都全部以物理的形式固定下来,那么给谁用呢?这里就是十分明显了,给CPU。那么CPU又是如何使用的呢,我们看这个例子:
抽象的结构
我们以这个结构体为例子:
struct Point {
x: u8,
y: u32,
z: u16,
}那么在CPU层面上,并不存在struct这种东西,那么在CPU里面是是什么呢?只有一块连续的内存区域,从某个基地址开始,后面是一个个字节。字段能被访问,完全依赖于“从这个基地址起,偏移多少字节是哪个字段”。真正决定“这块结构体一共占多少字节”“每个字段的偏移是多少”的,就是数据布局(data layout)。当然,直接的类型以及字段偏移CPU也是看不懂的,这里就是需要编译器去讲这些类型分配多少内存讲给CPU听。那么数据布局在这里就起了作用。所以编译器给这段抽象结构生成一个物理布局。具体长这样:
+------------+
| x: u8 | offset 0
+------------+
| y: u32 | offset 1~4
+------------+
| z: u16 | offset 5~6
+------------+因此,整个结构体大小为12 字节,字段访问方式(CPU 使用方式)如下:
| 字段 | 偏移(offset) | 大小 |
|---|---|---|
| x | 0 | 1 |
| y | 1 | 4 |
| z | 5 | 2 |
那么,CPU 是如何实际访问这些字段的呢?
- 访问
x,CPU实际执行的是:
load byte from address 0x1000- 访问
y,CPU并不知道“这是一个 u32 字段”,它只知道:
load 4 bytes starting from address 0x1001- 访问
z,CPU读取:
load 2 bytes from address 0x1005从上面的步骤我们可以看到,CPU层面上对结构体的操作都是以指针算数为主,在代码层面上,我们可以简单的直接调用point.x,那么在CPU层面上,就变成了具体的物理指针操作了。 数据布局在这里到底有什么用呢,我们从上面的例子看出,编译器给每一个结构体属性都是根据其类型进行分配的,每个属性都是不同的大小,对应CPU上面分配的都是不同数量的页帧,那么每次我们调用对应的结构体属性,CPU都要根据对应偏移量去计算对应的指针地址,这里其实是会消耗一些CPU性能的。如果说,我们将这些属性的类型做一个对齐(alignment),无疑是对CPU的性能提升是巨大的。 还是上面一个例子,我们假设这些结构体的属性按照4byte做对齐,那么整个结构体的物理布局就会编程这样:
+------------+
| x: u8 | offset 0
+------------+
| padding | offset 1~3
+------------+
| y: u32 | offset 4~7
+------------+
| z: u16 | offset 8~9
+------------+
| padding | offset 10~11
+------------+| 字段 | 偏移(offset) | 大小 | 对齐 |
|---|---|---|---|
| x | 0 | 1 | 4 |
| y | 4 | 4 | 4 |
| z | 8 | 2 | 2 |
那么基于这个布局,CPU直接按照固定数量去跳转对应的指针地址,并且不需要额外的地址计算。 最后 CPU 的角度看,第一个例子访问无法利用对齐优势,需要多次读取和拼接数据,甚至在某些平台上会触发硬件异常。(在正常的 Rust 默认布局下,编译器会自动保证对齐,不会产生这类问题。只有在手动破坏对齐(比如 repr(packed) + unsafe 乱搞)时,未对齐访问才可能导致异常或严重的性能问题。)因此:
- 未对齐结构体更紧凑,但性能更差,并且寻址成本更高
- 对齐结构体虽然更大,但访问成本更低、CPU 指令更简单、性能更好 这就是为什么数据布局(data layout)以及对齐(alignment)对于底层程序设计格外重要。
对齐(alignment)
对齐在编程语言中如此重要,Rust是怎么做的呢?Rust默认情况下,是通过结构体最大的类型做对齐,我们上面的对齐例子就很好的说明了这一点,结构体里面x y z中y是最大的,所以所有的属性都是按照y大小作对齐,此时的对齐是4byte,也许你会问,如果说结构体里面的属性类型是一个动态类型(DST)呢,比如说Box<dyn MyTrait>,[T]这应该如何处理?如果是,没有大小的类型(ZST),比如说Option,()这些应该怎么办呢?最后,如果是空类型,这又应该怎么办呢?我们继续看。
动态类型(DST)
对于动态类型,在编译期里面是无法知道其数据大小的,所有的DST不能单独存在,只能通过某种“胖指针”被引用:
| DST | 胖指针类型 |
|---|---|
str | &str = (ptr, len) |
[T] | &[T] = (ptr, len) |
dyn Trait | &dyn Trait = (ptr, vtable) |
胖指针是由两个部分组成,物理指针和元数据,不同DST的元数据不同:
&str的元数据是长度&[T]的元数据是长度&dyn Trait的元数据是 指向虚表(vtable)的指针
为什么 DST 需要胖指针?
之所以所有动态大小类型都必须依赖胖指针,是因为
DST 的大小在编译期未知,因此无法在栈上直接分配,只能通过指针访问。
例如:
let s: str; //不可能被编译这个例子实际上会有这些未知问题:
- 栈上开多少空间?
- 访问字段时对应的偏移是多少?
- 如何生成 load/store 指令? 编译器甚至连
s占多少字节都不知道,因此没办法产生代码。于是,Rust 的解决方案是:把 DST 的大小和行为信息放在“元数据”里,通过胖指针传递给编译器与运行时。这意味着:CPU 永远只看得到“指针 + 元数据”,而 DST 本体的末端大小,则在运行时由元数据决定。
那么 vtable 到底是什么?
对于 &dyn Trait,胖指针[^1]为:
(data_ptr, vtable_ptr)vtable 并不是“数据布局表”,而是一个:动态分派表(function jump table) 内部通常包含:
- 类型的析构函数(drop_in_place)
- 类型大小(size)
- 类型对齐方式(align)
- trait 中每个方法对应的函数地址(如 Display::fmt) 因此,调用一个 dyn trait 方法:
obj.method()到CPU就会被执行成:
- 读取 vtable_ptr
- 根据方法在 vtable 的固定偏移找到方法入口
- 跳转执行
DST 的数据布局由什么决定?
DST 从来不改变其底层类型的真实数据布局,它只是隐藏了大小信息。例如:
let x: &dyn Display = &10u32;底层仍然是一个 u32 值,所以具体是放在内存中的 u32 布局,在这里只是被一个胖指针以 “dyn Display” 的方式引用。
总结
动态大小类型无法独立存在,因此 Rust 通过“胖指针”把大小信息(或方法表地址)与真实数据关联起来。 胖指针是 CPU 可直接理解的物理结构,而 DST 的底层内存布局仍然由其具体类型决定。
零大小类型(ZST)
零大小类型(ZST)指的是:在编译期就能确定其占用空间为 0 字节的类型。也就是说,这种类型在运行时完全不占用内存。典型的 ZST 包括:
- 单元类型
() - 空元组
struct Empty; - 只包含 ZST 字段的 struct(例如
struct A(());) PhantomData<T>- 零值枚举(variant 只有一个且没有数据) ZST 的本质原因是:它们的值之间没有可区分的“状态差异”,因此无需在内存中存储任何实际数据。
ZST 对 Vec<T> 的特殊行为
一个很经典的黑魔法:
let v: Vec<()> = vec![(), (), ()];
println!("{}", v.len()); // 3
println!("{}", v.capacity()); // 3虽然长度 = 3,但数据本体确实占用 0 字节内存。Rust 为 Vec<()> 做了特殊优化,使得:
- len表示元素数量
- 但底层buffer不需要分配实际空间 这是 Rust 所谓“零成本抽象”的典型体现:
抽象存在,运行时代码却几乎没有开销。
空类型(EST)
空类型(EST),在 Rust 中通常指:
never type:`!`它的含义是:永远不可能存在任何值的类型。它比ZST更极端,ZST仍然“存在”,只是不占空间,但!是根本不存在任何值。
为什么会存在这种类型?
因为它代表了:
- 不返回(永远不会完成)
- 永远不会产生值
- 逻辑上不可能的情况 典型场景:
fn never_return() -> ! {
loop {}
}这段代码永远不回来,因此返回类型是!。
EST 与 ZST 的本质区别
| 属性 | ZST(零大小类型) | EST / !(空类型) |
|---|---|---|
| 是否有值 | 有,但所有值都等价 | 完全没有任何值 |
| 占用空间 | 0 字节 | 不存在,所以也无需空间 |
| 能否构造 | 可以构造(()) | 永远不能构造 |
| 语义 | “值存在但不需要存储” | “值根本不可能存在” |
| 用途 | 标记、泛型参数、能力系统 | 不返回、流程控制、bottom type |
总结
- ZST:运行期大小为 0 的类型,用来做“类型级信息”,代表“存在但无需存储”。
- EST(
!):真正的“无底类型”,表示“不可能存在的值”,常用于不可返回的控制流。
EST和ZST是如何对齐的?
从上面我们知道,EST是不会存在于结构体上面的,所以该类型不存在对齐,那么ZST是如何对齐的呢,其逻辑非常简单:ZST 的对齐方式,由其内部字段的最大对齐决定。 最简单的 ZST:
struct Empty;它没有字段,因此它的对齐 = 1(最小对齐)。
size = 0 align = 1再举个例子:
struct Marker(u64);这里的Marker是ZST嘛?不是的,因为它包含实际字段。让我们换成真正的 ZST:
struct ZstWithAlign { _marker: PhantomData<u64>, }PhantomData<u64> 是ZST。但是它里面又具体的类型:u64,所以对应对齐 = 8。因此这个 ZST 的对齐也是 8:
size = 0 align = 8结构体虽然 size = 0,但它仍然被认为需要“8 字节对齐”。为什么 PhantomData 会这样? 因为它用于在类型系统中表达“这个结构体在逻辑上包含了某个类型”。
ZST在实际内存中的行为(非常重要)
ZST 的对齐只在以下情况下有用:
作为结构体字段
比如:
struct S {
x: (),
y: u32,
}其中 x 是 ZST,并且它占 0 字节,但它的“出现位置”可能影响后续字段的对齐。比如:
offset(x) = 0 offset(y) = 4 // 因为 y 要 4 字节对齐作为数组元素
比如:
let arr: [(); 100];即使有 100 个元素:
size = 0 * 100 = 0 align = 1作为泛型参数影响对齐
比如:
struct Wrapper<T> {
_marker: PhantomData<T>,
}如果T的类型是u128,那么
size = 0 align = 16小结
EST (!) 没有布局,也没有对齐,因为它不可能拥有值。但是ZST 虽然大小为 0,但仍然有对齐,对齐由其内部字段或 PhantomData 所决定。对齐是类型属性,而不是大小属性。并且ZST的对齐会影响结构体、数组、泛型布局,这是很多 Rust “零开销抽象”背后的关键机制。ZST 的对齐不是为了它自己,而是为了让类型系统能表达‘逻辑上包含某个类型’。
总结
在本章中,我们从最基础的“数据布局(Data Layout)”入手,梳理了 Rust 底层内存结构背后的真实面貌:
CPU 只认识字节与偏移,而抽象的数据结构 —— struct、trait、切片、字符串 —— 全部需要经过编译器转译成 具体的物理布局 才能被访问。而对齐(alignment)作为数据布局的核心组成部分,直接影响到 CPU 的寻址效率、访存性能,甚至可能导致硬件异常。我们进一步探讨了三类特殊的布局对象:
- DST(动态大小类型):它们无法单独存在,需要通过胖指针把大小信息或 vtable 持有在元数据中。
- ZST(零大小类型):运行时大小为 0,但仍然保留对齐属性,用于构建零开销抽象。
- EST(空类型):本质上没有任何值,因此不会参与任何内存布局。 这些内容共同揭示了一个事实:
Rust 的类型系统不仅存在于语义层,更深深镶嵌在内存布局与机器模型中。
这正是 Rust 在安全性与性能之间实现“零成本抽象(Zero-Cost Abstraction)”的基础。
下一章预告:repr(...) —— Rust 的布局控制开关
本章主要解析了“数据布局是什么”与“Rust 默认是如何组织布局”。
但在真实工程与 unsafe 代码中,我们不能只依赖编译器的默认行为。
很多时候我们需要:
- 让 struct 的布局与 C 完全一致
- 消除字段重排,确保偏移稳定
- 让 ZST 参与布局或保持透明
- 定义紧凑结构(packed)
- 确定 enum 的 discriminant 布局 这些控制能力全部来自 Rust 提供的一系列布局属性,下一章将逐一拆解这些布局属性的含义、行为、风险以及在 unsafe 中的正确使用方式。
[^1]: 在 Rust 社区常用术语中,这类指针经常被称为 “fat pointer”。虽然 Rustonomicon 本身并未明确使用这个词,但其对 DST和trait object的描述(如元数据 + vtable)与社区中 “fat pointer = ptr + metadata” 的模型一致。