Appearance
第5章 内存布局:编译器如何排列数据
"如果你不理解数据在内存中的样子,你就不理解你的程序。" —— Mike Acton
每一个 Rust 值在运行时都是一段连续的字节。编译器必须决定每个字段放在哪个偏移量、整体占多少空间、按多少字节对齐。本章将走进 rustc_abi 的源码,揭示从字段重排到 Niche 优化的完整算法。
本章要点
repr(Rust)允许编译器重排字段顺序以减少 padding,repr(C)保持源码顺序- struct 的大小 = 所有字段大小 + padding,对齐 = 最大字段的对齐
- enum 的布局 = 判别值(discriminant)+ 最大变体的 payload
- Niche 优化:利用类型中"不可能出现的位模式"来存储判别值,消除额外空间开销
Option<&T>大小等于&T,因为空指针 0x0 被用作None的判别值- Vec 和 String 在栈上是 24 字节的
(ptr, len, cap)三元组 - 切片引用
&[T]和&str是 16 字节的胖指针(ptr, len) - ZST(零大小类型)不占用任何内存,但在类型系统中携带信息
- 编译器在
univariant_biased中会进行两轮排列,选择 Niche 位置更优的那个
5.1 对齐与填充:内存布局的基本法则
在深入任何具体类型之前,我们需要先理解两个基本概念:对齐(alignment)和填充(padding)。
为什么需要对齐
现代 CPU 不是一个字节一个字节地读取内存的——它们以**字(word)**为单位进行内存访问,通常是 4 字节或 8 字节。如果一个 8 字节的 u64 值恰好从地址 0x08 开始(8 的倍数),CPU 可以一次操作就把它读出来;但如果它从 0x03 开始,CPU 可能需要读取两个 word 再拼接,速度大幅下降。某些架构(如早期的 ARM)甚至会在未对齐访问时直接触发硬件异常。
所以,编译器在摆放字段时必须遵守对齐规则:每个类型都有一个对齐值(alignment),它的起始地址必须是该值的倍数。
Rust 中基本类型的对齐规则(64 位平台):
| 类型 | 大小(字节) | 对齐(字节) |
|---|---|---|
bool | 1 | 1 |
u8 / i8 | 1 | 1 |
u16 / i16 | 2 | 2 |
u32 / i32 / f32 | 4 | 4 |
u64 / i64 / f64 | 8 | 8 |
u128 / i128 | 16 | 8(注意!不是 16) |
usize / isize | 8 | 8 |
&T / *const T | 8 | 8 |
struct 的对齐 = 所有字段对齐值中的最大值。struct 的大小必须是自身对齐的整数倍(这样 struct 数组中的每个元素也能满足对齐要求)。
填充(Padding)
当字段的自然偏移不满足下一个字段的对齐要求时,编译器在中间插入填充字节(padding bytes)。这些字节不存储有意义的数据,纯粹是为了对齐而浪费的空间。
5.2 Struct 布局:字段排列的两种策略
让我们从一个具体的 struct 开始:
rust
struct Foo {
a: u8, // 1 字节,对齐 1
b: u64, // 8 字节,对齐 8
c: u16, // 2 字节,对齐 2
}repr(C):保持源码顺序
如果使用 #[repr(C)],字段严格按声明顺序排列。编译器在每个字段之间以及末尾插入 padding 以满足对齐要求:
偏移 字段 大小 说明
0x00 a (u8) 1
0x01 [padding] 7 填充到 b 的对齐边界(8)
0x08 b (u64) 8
0x10 c (u16) 2
0x12 [padding] 6 填充到结构体对齐边界(8)
总大小:24 字节,对齐:8 字节24 个字节中,只有 11 个字节存储了有效数据,浪费率超过 54%。
repr(Rust):编译器重排字段
默认的 repr(Rust) 允许编译器重新排列字段顺序,以最小化 padding。编译器的策略是把对齐要求最大的字段排在前面:
偏移 字段 大小 说明
0x00 b (u64) 8 最大对齐的字段排在前面
0x08 c (u16) 2
0x0A a (u8) 1
0x0B [padding] 5 填充到结构体对齐边界(8)
总大小:16 字节,对齐:8 字节同一个 struct,repr(Rust) 比 repr(C) 节省了 8 字节(33%)。
你可以用 -Zprint-type-sizes 来验证编译器的实际决策:
bash
cargo +nightly rustc -- -Zprint-type-sizesprint-type-size type: `Foo`: 16 bytes, alignment: 8 bytes
print-type-size field `.b`: 8 bytes
print-type-size field `.c`: 2 bytes
print-type-size field `.a`: 1 bytes
print-type-size end padding: 5 bytes编译器源码:字段排序算法
在 compiler/rustc_abi/src/layout.rs 中,univariant_biased 函数负责 struct 的布局计算。关键的排序逻辑在这一段:
rust
// compiler/rustc_abi/src/layout.rs — univariant_biased 中的排序逻辑
if optimize_field_order && fields.len() > 1 {
if repr.can_randomize_type_layout() && cfg!(feature = "randomize") {
// -Z randomize-layout: 打乱字段顺序,帮助发现依赖布局的 bug
optimizing.shuffle(&mut rng);
} else {
// 正常优化路径
let alignment_group_key = |layout: &F| {
if let Some(pack) = pack {
layout.align.abi.min(pack).bytes()
} else {
let align = layout.align.bytes();
let size = layout.size.bytes();
// 将 [u8; 4] 和 align-4 字段归为同一组
let size_as_align = align.max(size).trailing_zeros();
size_as_align as u64
}
};
optimizing.sort_by_key(|&x| {
let f = &fields[x];
let niche_size = f.largest_niche.map_or(0, |n| n.available(dl));
let niche_size_key = match niche_bias {
NicheBias::Start => !niche_size, // 大 niche 排前面
NicheBias::End => niche_size, // 大 niche 排后面
};
(
cmp::Reverse(alignment_group_key(f)), // 大对齐排前面
niche_size_key, // 按 niche 偏好排列
inner_niche_offset_key, // niche 在字段内部的偏移
)
});
}
}这个排序并不是简单地"按对齐从大到小排"。它引入了对齐组(alignment group)的概念——[u8; 4] 虽然对齐是 1,但大小是 4,在没有 repr(packed) 时会被当作对齐 4 的字段来排序(通过 trailing_zeros 计算)。这比朴素排序能发现更多优化机会。
更精妙的是 NicheBias 机制——编译器会做两轮排列(一次 niche 靠前,一次 niche 靠后),然后选择 niche 位置更好的那个布局。这是为了让 niche 尽可能靠近 struct 的边缘,方便 enum 的 niche 填充优化。
rust
// univariant 中的双排列逻辑
pub fn univariant(...) -> ... {
let layout = self.univariant_biased(fields, repr, kind, NicheBias::Start);
if let Ok(layout) = &layout {
if let Some(niche) = layout.largest_niche {
let head_space = niche.offset.bytes();
let tail_space = layout.size.bytes() - head_space - niche_len;
// 如果默认排列的 niche 不在边缘,尝试靠后排列
if fields.len() > 1 && head_space != 0 && tail_space > 0 {
let alt_layout = self.univariant_biased(
fields, repr, kind, NicheBias::End
);
// 选择 niche 靠近边缘的那个布局
if alt_head_space > head_space && alt_head_space > tail_space {
return Ok(alt_layout);
}
}
}
}
layout
}偏移计算的核心循环
排序完成后,编译器按新顺序逐个放置字段。核心逻辑非常直接——对齐、放置、推进:
rust
// univariant_biased 中的偏移计算(简化)
let mut offsets = IndexVec::from_elem(Size::ZERO, fields);
let mut offset = Size::ZERO;
for &i in &in_memory_order {
let field = &fields[i];
let field_align = if let Some(pack) = pack {
field.align.min(AbiAlign::new(pack))
} else {
field.align
};
offset = offset.align_to(field_align.abi);
offsets[i] = offset; // 用源码顺序的索引存偏移
offset = offset.checked_add(field.size, dl)?;
}
let size = offset.align_to(align); // 最终大小对齐到结构体对齐offsets[i] 使用源码顺序的索引,而遍历用 in_memory_order。即使内存中字段顺序变了,通过字段名访问时编译器仍然知道正确的偏移。
5.3 repr 属性详解
repr(C):C 语言兼容
repr(C) 保证布局与 C 编译器完全相同——字段按声明顺序排列,padding 按 C 的规则插入。在源码中,ReprFlags::IS_C 属于 FIELD_ORDER_UNOPTIMIZABLE,阻止字段重排和 enum niche 优化。
repr(transparent):零成本封装
repr(transparent) 保证封装类型与其唯一非零大小字段有相同的 ABI。编译器直接复用内部字段的对齐和表示:
rust
#[repr(transparent)]
struct Meters(f64);
// Meters 和 f64 有完全相同的内存表示和调用约定repr(packed):压缩对齐
repr(packed) 将所有字段的对齐降低到 1,消除一切 padding。代价是访问未对齐字段需要生成更多指令,且 Rust 禁止对 packed 字段取引用:
rust
#[repr(packed)]
struct Packed { a: u8, b: u64, c: u16 }
// 总大小:11 字节(1+8+2),对齐:1 字节repr(align(N)):提升对齐
repr(align(N)) 将对齐提升到至少 N 字节,常用于缓存行对齐和 SIMD:
rust
#[repr(align(64))]
struct CacheLine { data: [u8; 64] }repr 属性对照表
| repr | 字段顺序 | 对齐 | Niche 优化 | 用途 |
|---|---|---|---|---|
repr(Rust) | 编译器重排 | 自动最优 | 允许 | 默认,最节省空间 |
repr(C) | 源码顺序 | C ABI 兼容 | 禁止 | FFI 交互 |
repr(packed) | 源码顺序 | 1 或指定值 | 禁止 | 极致节省空间 |
repr(align(N)) | 编译器重排 | 至少 N 字节 | 允许 | 缓存行对齐、SIMD |
repr(transparent) | 唯一非 ZST 字段 | 和内部类型相同 | 允许 | newtype 模式 |
5.4 Enum 布局:带标签的联合体
Rust 的 enum 是带标签的联合体(tagged union)。编译器为每个 enum 分配一个标签(tag,也叫 discriminant)来标识当前是哪个变体,加上一个足够大的空间来存放最大变体的数据。
rust
enum Shape {
Circle(f64), // 8 字节 payload
Rectangle(f64, f64), // 16 字节 payload
Point, // 0 字节 payload
}布局(Tagged Layout):
偏移 内容 大小
0x00 tag 1 字节(3 个变体,u8 足够)
0x01 [padding] 7 字节(对齐到 f64 的 8 字节)
0x08 payload 16 字节(最大变体 Rectangle 的大小)
总大小:24 字节,对齐:8 字节| 变体 | tag 值 | payload 使用 |
|---|---|---|
| Circle | 0 | 前 8 字节存 f64 |
| Rectangle | 1 | 全部 16 字节存两个 f64 |
| Point | 2 | 不使用 |
判别值大小的选择
编译器选择 tag 的整数类型时使用最小的足够大的类型。在 compiler/rustc_middle/src/ty/layout.rs 中:
rust
// 选择最小的无符号整数来容纳判别值范围
let unsigned_fit = Integer::fit_unsigned(cmp::max(min as u128, max as u128));
let signed_fit = cmp::max(Integer::fit_signed(min), Integer::fit_signed(max));
let at_least = if repr.c() {
tcx.data_layout().c_enum_min_size // repr(C): 通常是 i32
} else {
Integer::I8 // repr(Rust): 从 u8 开始
};
// 优先选择无符号(与 clang 一致)
if unsigned_fit <= signed_fit {
(cmp::max(unsigned_fit, at_least), false)
} else {
(cmp::max(signed_fit, at_least), true)
}对于 repr(Rust) 的 enum:
- 变体数 <= 256:
u8(1 字节) - 变体数 <= 65536:
u16(2 字节) - 以此类推
但编译器可能放大 tag 类型以匹配第一个数据字段的对齐,减少 padding 浪费:
rust
// 使用第一个非 ZST 字段的对齐来决定 tag 大小
let mut ity = if repr.c() || repr.int.is_some() {
min_ity
} else {
Integer::for_align(dl, start_align).unwrap_or(min_ity)
};例如,如果 enum 的第一个数据字段是 u32(对齐 4),tag 可能从 u8 提升到 u32——虽然 tag 本身只需要 1 字节,但用 4 字节避免了 3 字节的 padding,不会增加整体大小。
ScalarPair 优化
当每个 enum 变体只有一个非 ZST 标量字段、且所有变体的该字段在相同偏移时,编译器将 (tag, payload) 表示为 ScalarPair——函数调用时通过两个寄存器传递,而不是走内存。
5.5 Niche 优化:消除判别值的空间开销
Niche 优化是 Rust 编译器最精妙的布局优化之一。它的核心思想是:
如果一个类型的某些位模式(bit pattern)永远不会出现,编译器可以用这些"不可能的值"来编码 enum 的变体信息,从而完全消除 tag 字段。
经典案例:Option<&T>
rust
// 引用永远不为空(0x0 是无效地址)
// 所以 Option<&T> 可以用 0x0 表示 None
assert_eq!(size_of::<&i32>(), 8);
assert_eq!(size_of::<Option<&i32>>(), 8); // 一样大!没有额外开销实际的内存字节表示:
Option<&i32> = Some(&val):
[0x48, 0xF5, 0x12, 0x00, 0x01, 0x00, 0x00, 0x00] // 某个有效地址
→ 值不为零,所以是 Some,指针值就是这 8 个字节
Option<&i32> = None:
[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] // 全零
→ 值为零,所以是 NoneNiche 在编译器中的定义
在 compiler/rustc_abi/src/lib.rs 中,Niche 结构体包含三个字段:offset(在类型中的偏移)、value(原始类型 Int/Float/Pointer)和 valid_range(合法值的 wrapping 范围)。Niche::available 方法通过计算 (valid_range.end+1)..valid_range.start 的大小来确定有多少个不合法的值可用。
对于 &i32(valid_range = 1..=u64::MAX),不合法的值只有 0x0 一个,available = 1,刚好够 Option 的 None 变体使用。
哪些类型有 Niche
| 类型 | 合法值范围 | 不合法值(Niche) | Option<T> 额外开销 |
|---|---|---|---|
&T | 1..=MAX | 0(空指针) | 0 字节 |
&mut T | 1..=MAX | 0 | 0 字节 |
NonZeroU32 | 1..=u32::MAX | 0 | 0 字节 |
NonZeroUsize | 1..=usize::MAX | 0 | 0 字节 |
bool | 0..=1 | 2..=255(254 个值) | 0 字节 |
char | 0..=0xD7FF, 0xE000..=0x10FFFF | 0x110000..=0xFFFFFFFF | 0 字节 |
Ordering | 0, 1, 255 (即 -1) | 大量 | 0 字节 |
u8 | 0..=255 | 无 | 1 字节(需要 tag) |
u64 | 0..=u64::MAX | 无 | 8 字节(需要 tag) |
Niche 填充算法深入
layout_of_enum 中的 calculate_niche_filling_layout 是 Niche 优化的核心。让我们逐步分析:
第一步:找到最大变体和它的 niche
rust
let largest_variant_index = variant_layouts
.iter_enumerated()
.max_by_key(|(_i, layout)| layout.size.bytes())
.map(|(i, _layout)| i)?;
// 使用最大变体中的最大 niche
let niche = variant_layouts[largest_variant_index].largest_niche?;为什么选最大变体?因为 niche 填充的策略是:最大变体保持原样(它是 untagged_variant),其他变体的数据塞进这个变体的"缝隙"中。niche 所在的位置存储 tag 值,用来区分不同的非最大变体。
第二步:检查其他变体能否适配
rust
let all_variants_fit = variant_layouts.iter_enumerated_mut().all(|(i, layout)| {
if i == largest_variant_index {
return true; // 最大变体不需要调整
}
if layout.size <= niche_offset {
return true; // 变体数据完全在 niche 之前,不冲突
}
// 尝试将变体数据放在 niche 之后
let this_offset = (niche_offset + niche_size).align_to(this_align);
if this_offset + layout.size > size {
return false; // 放不下
}
// 调整字段偏移
for offset in offsets.iter_mut() {
*offset += this_offset;
}
true
});第三步:reserve niche 值
Niche::reserve 方法决定如何分配 niche 值给各个变体。它的策略非常精妙——尽量让 None 占据 niche 0:
rust
pub fn reserve(&self, cx: &C, count: u128) -> Option<(u128, Scalar)> {
let niche = v.end.wrapping_add(1)..v.start;
let available = niche.end.wrapping_sub(niche.start) & max_value;
if count > available {
return None; // niche 不够用
}
// 策略:尽量让 None(count==1 时的唯一值)占据 niche 零
// 这样 Option<NonZeroU32> 的 None 就是 0,
// 启用 if let Some(x) 的零测试优化
let distance_end_zero = max_value - v.end;
if v.start <= distance_end_zero {
if count <= v.start {
move_start(v) // 向前扩展 valid_range
} else {
move_end(v) // 向后扩展 valid_range
}
} else { ... }
}第四步:与 tagged layout 比较,选择更小的
rust
let best_layout = match (tagged_layout, niche_filling_layout) {
(tl, Some(nl)) => {
match (tl.size.cmp(&nl.size), niche_size(&tl).cmp(&niche_size(&nl))) {
(Greater, _) => nl, // niche 更小 → 选 niche
(Equal, Less) => nl, // 同大但 niche 方案有更大的 niche → 选 niche
_ => tl, // 否则选 tagged(codegen 更简单)
}
}
(tl, None) => tl,
};嵌套 Niche 优化
Niche 优化可以递归应用:
rust
assert_eq!(size_of::<Option<&i32>>(), 8); // 0x0 = None
assert_eq!(size_of::<Option<Option<&i32>>>(), 8); // 仍然 8 字节!Option<Option<&i32>> 怎么做到和 &i32 一样大?
&i32的合法范围是1..=MAX,niche 是 0x0Option<&i32>用 0x0 表示None,于是它的合法范围变成0..=MAX,但指针要求 4 字节对齐,所以 0x1、0x2、0x3 都是无效的Option<Option<&i32>>可以用 0x1 表示外层的None
实际编码:
| 值 | 含义 |
|---|---|
0x0 | Some(None) — 内层的 None |
0x1 | None — 外层的 None |
>= 0x4 且 4 的倍数 | Some(Some(&i32)) — 合法指针 |
Niche 优化的更多例子
rust
use std::mem::size_of;
// NonZero 系列
assert_eq!(size_of::<Option<std::num::NonZeroU64>>(), 8); // 0 = None
// bool 有 254 个 niche 值
assert_eq!(size_of::<Option<bool>>(), 1); // 2 = None
// Result 也受益
assert_eq!(size_of::<Result<&i32, ()>>(), 8); // 0x0 = Err(())
// 嵌套的 NonZero
assert_eq!(size_of::<Option<Option<std::num::NonZeroU8>>>(), 1);
// char 有大量 niche
assert_eq!(size_of::<Option<char>>(), 4); // 0x110000 = None5.6 Tuple 和 Array 布局
Tuple
Tuple 本质上就是匿名的 struct,遵循与 repr(Rust) 相同的布局规则——字段可以被重排。
rust
// (u8, u64, u16) 的布局与 struct { u8, u64, u16 } 相同
assert_eq!(size_of::<(u8, u64, u16)>(), 16); // 不是 24
// 编译器重排后:
// 偏移 0x00: u64 (8B)
// 偏移 0x08: u16 (2B)
// 偏移 0x0A: u8 (1B)
// 偏移 0x0B: padding (5B)Array
[T; N] 的布局是 N 个 T 紧密排列,元素之间没有额外的 padding(stride = element size)。编译器在 array_like 方法中计算:
rust
pub fn array_like(
&self,
element: &LayoutData,
count_if_sized: Option<u64>,
) -> LayoutCalculatorResult {
let count = count_if_sized.unwrap_or(0);
let size = element.size.checked_mul(count, &self.cx)
.ok_or(LayoutCalculatorError::SizeOverflow)?;
Ok(LayoutData {
fields: FieldsShape::Array { stride: element.size, count },
largest_niche: element.largest_niche.filter(|_| count != 0),
align: element.align,
size,
..
})
}注意:数组继承了元素的 niche 信息(largest_niche),但只在 count != 0 时。空数组 [T; 0] 是 ZST,没有 niche。
如果 T 内部有 padding,每个元素都会包含这些 padding,N 个元素的浪费会被放大 N 倍:
rust
#[repr(C)]
struct Wasteful {
a: u8, // 1B
// padding 7B
b: u64, // 8B
}
// size = 16B, 其中 7B 是 padding
// [Wasteful; 1000] = 16000B, 其中 7000B 是 padding5.7 String 和 Vec:栈上的三元组
Vec<T> 的内存布局
Vec<T> 在栈上是一个 24 字节的"三元组":
rust
// Vec<T> 等价于:
struct Vec<T> {
ptr: *mut T, // 8 字节 — 指向堆上分配的缓冲区
len: usize, // 8 字节 — 当前元素数量
cap: usize, // 8 字节 — 分配的容量(可容纳的元素数)
}具体内存字节(假设 vec![1u32, 2, 3],堆地址 0x5590_1234_0000):
栈上 24 字节:
[00, 00, 34, 12, 90, 55, 00, 00] ptr = 0x0000_5590_1234_0000
[03, 00, 00, 00, 00, 00, 00, 00] len = 3
[04, 00, 00, 00, 00, 00, 00, 00] cap = 4 (allocator 可能分配了 4 个元素的空间)
堆上 16 字节(4 * 4B):
[01, 00, 00, 00] elem[0] = 1u32
[02, 00, 00, 00] elem[1] = 2u32
[03, 00, 00, 00] elem[2] = 3u32
[??, ??, ??, ??] elem[3] = 未初始化(len=3,不可访问)String 的内存布局
String 本质上就是 Vec<u8>,只是保证内容是合法的 UTF-8:
rust
assert_eq!(size_of::<String>(), 24);
assert_eq!(size_of::<Vec<u8>>(), 24);
// 内部表示完全相同:(ptr, len, cap)例如 String::from("Hello") 在栈上是 [ptr 8B][len=5 8B][cap>=5 8B],ptr 指向堆上的 [48, 65, 6C, 6C, 6F]("Hello" 的 UTF-8 字节)。
5.8 Slice 和 str:胖指针
切片引用 &[T] 和字符串切片 &str 是胖指针(fat pointer),在栈上占 16 字节:
rust
assert_eq!(size_of::<&[u32]>(), 16); // 指针 + 长度
assert_eq!(size_of::<&str>(), 16); // 指针 + 长度
assert_eq!(size_of::<&u32>(), 8); // 普通引用只有指针胖指针在编译器中用 ScalarPair 表示——两个标量值组成一对:
rust
// compiler/rustc_abi/src/layout/simple.rs
pub fn scalar_pair(cx: &C, a: Scalar, b: Scalar) -> Self {
let b_offset = a.size(dl).align_to(b_align);
let size = (b_offset + b.size(dl)).align_to(align);
LayoutData {
fields: FieldsShape::Arbitrary {
offsets: [Size::ZERO, b_offset].into(),
..
},
backend_repr: BackendRepr::ScalarPair(a, b),
..
}
}trait object 引用 &dyn Trait 也是 16 字节胖指针,但第二个字段是 vtable 指针而不是长度。
5.9 Box、Rc、Arc:智能指针的布局
Box<T>
Box<T> 在栈上只有 8 字节——它就是一个指针。但因为它保证非空,所以有 niche:
rust
assert_eq!(size_of::<Box<i32>>(), 8);
assert_eq!(size_of::<Option<Box<i32>>>(), 8); // niche 优化!Rc<T> 和 Arc<T>
Rc<T> 和 Arc<T> 在栈上也是单个指针(8 字节),指向一个堆上的控制块:
rust
assert_eq!(size_of::<Rc<i32>>(), 8);
assert_eq!(size_of::<Arc<i32>>(), 8);
assert_eq!(size_of::<Option<Rc<i32>>>(), 8); // niche 优化
assert_eq!(size_of::<Option<Arc<i32>>>(), 8); // niche 优化堆上控制块的布局是 strong_count(8B) + weak_count(8B) + data——引用计数在前,数据在后。当 Box 装的是 unsized 类型时,变成胖指针:Box<[i32]> 和 Box<dyn Trait> 都是 16 字节。
5.10 零大小类型(ZST)
Rust 中有一类特殊的类型,在内存中不占用任何空间:
rust
assert_eq!(size_of::<()>(), 0);
assert_eq!(size_of::<PhantomData<String>>(), 0);
assert_eq!(size_of::<[u8; 0]>(), 0);
struct Empty;
assert_eq!(size_of::<Empty>(), 0);
struct Marker;
assert_eq!(size_of::<Marker>(), 0);在编译器中,ZST 的 LayoutData 的 size 为 Size::ZERO,fields 为空的 FieldsShape::Arbitrary。
ZST 的实际用途
PhantomData<T>:不占空间,但告诉编译器你在逻辑上"拥有"T,影响 Drop Check 和 variance。():函数没有返回值时的返回类型。Vec<()>不分配堆内存——它只是一个计数器。标记类型:
struct Send;、struct Sync;等,在类型系统中携带约束信息。HashMap 退化为 HashSet:
HashMap<K, ()>就是一个HashSet<K>——value 不占空间。
ZST 对布局的影响
ZST 字段不影响 struct 的大小,但可能影响对齐——一个 #[repr(align(64))] 的 ZST 会把包含它的 struct 对齐提升到 64 字节。编译器在 univariant_biased 中对 ZST 有特殊处理:它们不参与 ScalarPair 的判断(filter(|f| !f.is_zst()))。
5.11 size_of 和 align_of 在编译器层面的实现
size_of 和 align_of 是编译器内建函数(intrinsics),在单态化阶段直接替换为常量。类型的布局信息存储在 LayoutData 结构体中:
LayoutData 包含 size、align(即 size_of/align_of 的来源)、fields(字段偏移和顺序)、variants(变体信息)、backend_repr(Scalar/ScalarPair/Memory)、largest_niche(最大的 niche)等字段,是编译器中所有布局相关查询的基础。
5.12 LayoutCalculator:布局计算的统一入口
LayoutCalculator 的核心方法 layout_of_struct_or_enum 是一个分发器——它先过滤掉"不可居住"的变体(uninhabited 且只含 ZST),然后决定走 struct 路径还是 enum 路径:
- 只有一个有效变体的 enum 被当作 struct 处理(没有 tag,直接是字段布局)
- 多变体 enum 走
layout_of_enum,同时计算 tagged 和 niche filling 两种方案
Union 布局
union 的所有字段从偏移 0 开始重叠,大小取最大字段的大小。关键区别:union 的 largest_niche 永远是 None——因为字段可以重叠,编译器无法判断哪些位模式是不合法的。这就是为什么 Option<MyUnion> 无法享受 niche 优化。
5.13 缓存友好的布局:性能影响
内存布局不只是"占多少字节"的问题——它直接影响 CPU 缓存的效率。
缓存行(Cache Line)
现代 CPU 以 64 字节的缓存行为单位从内存读取数据。当你访问一个字节时,CPU 会把整条缓存行(64 字节)加载到 L1 缓存。
这意味着:
- 如果你紧接着访问的数据在同一条缓存行中,速度极快(L1 命中)
- 如果一个 struct 跨越两条缓存行,每次访问需要两次缓存加载
字段重排的性能收益
编译器重排字段不仅减少大小,还改善缓存利用率。以前面的 Foo 为例,[Bloated; 100](repr(C))= 2400 字节需要 38 条缓存行,而 [Compact; 100](repr(Rust))= 1600 字节只需 25 条缓存行——节省 34% 的缓存占用。
热数据/冷数据分离
当 struct 中某些字段被频繁访问(热数据),另一些很少使用(冷数据)时,分离它们可以显著提升缓存命中率。游戏引擎中常用的 SoA(Structure of Arrays)模式就是这个思想的极致应用——将同类字段的值连续存放,最大化缓存利用率。
防止 False Sharing
多线程场景中,不同线程修改同一缓存行中的不同变量会导致缓存行在核心之间"乒乓"——这就是 false sharing。用 repr(align(64)) 确保每个线程的数据独占一条缓存行:
rust
#[repr(align(64))]
struct PerThreadCounter {
count: AtomicU64,
}
// 每个计数器占 64 字节,独占一条缓存行5.14 实用工具:查看类型布局
-Zprint-type-sizes
Nightly 编译器提供了 -Zprint-type-sizes 来查看所有类型的布局:
bash
RUSTFLAGS="-Zprint-type-sizes" cargo +nightly build 2>&1 | head -30输出示例:
print-type-size type: `Option<Box<dyn Error>>`: 16 bytes, alignment: 8 bytes
print-type-size variant `Some`: 16 bytes
print-type-size field `.0`: 16 bytes
print-type-size variant `None`: 0 bytes
print-type-size type: `Vec<u8>`: 24 bytes, alignment: 8 bytes
print-type-size field `.len`: 8 bytes
print-type-size field `.buf`: 16 bytesstd::mem 中的函数
rust
use std::mem;
println!("size: {}", mem::size_of::<Vec<u8>>()); // 24
println!("align: {}", mem::align_of::<Vec<u8>>()); // 8
// 查看值的实际字节
let v: u32 = 0xDEAD_BEEF;
let bytes: [u8; 4] = unsafe { mem::transmute(v) };
println!("{:02X?}", bytes); // [EF, BE, AD, DE](小端序)std::alloc::Layout
rust
let layout = std::alloc::Layout::new::<Vec<String>>();
println!("size: {}, align: {}", layout.size(), layout.align()); // 24, 85.15 常见类型的完整布局一览
作为本章的总结,以下是 64 位平台上常见 Rust 类型的内存布局:
| 类型 | size | align | 栈上字节 | 说明 |
|---|---|---|---|---|
bool | 1 | 1 | [0x00] 或 [0x01] | 254 个 niche |
char | 4 | 4 | [xx, xx, xx, xx] | Unicode scalar value,有 niche |
i32 | 4 | 4 | [xx, xx, xx, xx] | 无 niche |
f64 | 8 | 8 | [xx, xx, xx, xx, xx, xx, xx, xx] | 无 niche |
&T | 8 | 8 | [ptr 8B] | 1 个 niche (0x0) |
&[T] | 16 | 8 | [ptr 8B][len 8B] | 胖指针 |
&str | 16 | 8 | [ptr 8B][len 8B] | 胖指针 |
&dyn Trait | 16 | 8 | [ptr 8B][vtable 8B] | 胖指针 |
Box<T> | 8 | 8 | [ptr 8B] | 1 个 niche (0x0) |
Box<[T]> | 16 | 8 | [ptr 8B][len 8B] | 胖指针 |
Option<&T> | 8 | 8 | [ptr/0x0 8B] | niche 优化 |
Option<bool> | 1 | 1 | [0x00/0x01/0x02] | niche 优化 |
Vec<T> | 24 | 8 | [ptr 8B][len 8B][cap 8B] | 堆分配 |
String | 24 | 8 | [ptr 8B][len 8B][cap 8B] | = Vec<u8> |
HashMap<K,V> | 48 | 8 | 控制块 + 指针 | 实现相关 |
() | 0 | 1 | 无字节 | ZST |
PhantomData<T> | 0 | 1 | 无字节 | ZST |
[T; 0] | 0 | T 的对齐 | 无字节 | ZST |
Rc<T> | 8 | 8 | [ptr 8B] | 指向堆上控制块 |
Arc<T> | 8 | 8 | [ptr 8B] | 指向堆上控制块 |
本章小结
本章我们从最底层的对齐规则出发,一路深入到 rustc_abi 的源码,完整揭示了 Rust 编译器如何计算类型的内存布局:
Struct 布局:
repr(Rust)允许字段重排,编译器通过对齐组排序和 NicheBias 双排列策略,在减少 padding 的同时优化 niche 位置。Enum 布局:编译器同时计算 tagged layout 和 niche filling layout,选择更小(或 niche 更大)的那个。Niche 填充利用类型中"不可能的位模式"来编码变体信息,完全消除 tag 的空间开销。
胖指针:
&[T]、&str、&dyn Trait都是 16 字节的 ScalarPair,包含数据指针和元数据(长度或 vtable)。堆分配类型:
Vec/String是 24 字节的三元组,Box/Rc/Arc是 8 字节的单指针。ZST:零大小类型不占空间但在类型系统中携带信息,编译器对它们有专门的优化路径。
理解了数据在内存中的物理形态,我们就为后续的章节打下了基础。下一章,我们进入类型系统的核心——编译器如何通过单态化将泛型的零成本抽象承诺变为现实。