Rust编译器原理-第5章 内存布局:编译器如何排列数据

《Rust 编译器原理》完整目录

第5章 内存布局:编译器如何排列数据

"如果你不理解数据在内存中的样子,你就不理解你的程序。" ------ Mike Acton

每一个 Rust 值在运行时都是一段连续的字节。编译器必须决定每个字段放在哪个偏移量、整体占多少空间、按多少字节对齐。本章将走进 rustc_abi 的源码,揭示从字段重排到 Niche 优化的完整算法。

:::tip 本章要点

  • 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 以满足对齐要求:

css 复制代码
偏移    字段         大小    说明
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。编译器的策略是把对齐要求最大的字段排在前面:

scss 复制代码
偏移    字段         大小    说明
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%)。

graph LR subgraph "repr(C) --- 24 字节" C1["a
1B"] C2["pad
7B"] C3["b
8B"] C4["c
2B"] C5["pad
6B"] end subgraph "repr(Rust) --- 16 字节" R1["b
8B"] R2["c
2B"] R3["a
1B"] R4["pad
5B"] end style C2 fill:#ef4444,color:#fff,stroke:none style C5 fill:#ef4444,color:#fff,stroke:none style R4 fill:#ef4444,color:#fff,stroke:none style C1 fill:#3b82f6,color:#fff,stroke:none style C3 fill:#3b82f6,color:#fff,stroke:none style C4 fill:#3b82f6,color:#fff,stroke:none style R1 fill:#10b981,color:#fff,stroke:none style R2 fill:#10b981,color:#fff,stroke:none style R3 fill:#10b981,color:#fff,stroke:none

你可以用 -Zprint-type-sizes 来验证编译器的实际决策:

bash 复制代码
cargo +nightly rustc -- -Zprint-type-sizes
python 复制代码
print-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
}
rust 复制代码
布局(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);  // 一样大!没有额外开销

实际的内存字节表示:

rust 复制代码
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]  // 全零
  → 值为零,所以是 None
flowchart TD A["Option<&i32> --- 8 字节"] --> B{"8 字节的值是 0x0?"} B -->|"是"| C["None
无数据"] B -->|"否"| D["Some(&i32)
值本身就是指针地址"] E["传统 tagged layout
tag (8B) + pointer (8B) = 16B"] -.->|"Niche 优化后"| F["仅 pointer (8B)
0x0 编码 None"] style A fill:#3b82f6,color:#fff,stroke:none style C fill:#ef4444,color:#fff,stroke:none style D fill:#10b981,color:#fff,stroke:none style F fill:#f59e0b,color:#fff,stroke:none

Niche 在编译器中的定义

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,刚好够 OptionNone 变体使用。

哪些类型有 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 是 0x0
  • Option<&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)) --- 合法指针
flowchart TD A["Option<Option<&i32>>
8 字节"] --> B{"值 == 0x1?"} B -->|"是"| C["None(外层)"] B -->|"否"| D{"值 == 0x0?"} D -->|"是"| E["Some(None)(内层 None)"] D -->|"否"| F["Some(Some(&i32))
值就是指针地址"] style A fill:#3b82f6,color:#fff,stroke:none style C fill:#ef4444,color:#fff,stroke:none style E fill:#f59e0b,color:#fff,stroke:none style F fill:#10b981,color:#fff,stroke:none

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 = None

5.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 是 padding

5.7 String 和 Vec:栈上的三元组

Vec<T> 的内存布局

Vec<T> 在栈上是一个 24 字节的"三元组":

rust 复制代码
// Vec<T> 等价于:
struct Vec<T> {
    ptr: *mut T,   // 8 字节 --- 指向堆上分配的缓冲区
    len: usize,    // 8 字节 --- 当前元素数量
    cap: usize,    // 8 字节 --- 分配的容量(可容纳的元素数)
}
graph LR subgraph "栈上 --- 24 字节" V1["ptr
8B"] V2["len = 3
8B"] V3["cap = 5
8B"] end subgraph "堆上 --- cap * size_of::<T>()" H1["elem 0"] H2["elem 1"] H3["elem 2"] H4["(未使用)"] H5["(未使用)"] end V1 --> H1 style V1 fill:#3b82f6,color:#fff,stroke:none style V2 fill:#10b981,color:#fff,stroke:none style V3 fill:#f59e0b,color:#fff,stroke:none style H1 fill:#8b5cf6,color:#fff,stroke:none style H2 fill:#8b5cf6,color:#fff,stroke:none style H3 fill:#8b5cf6,color:#fff,stroke:none style H4 fill:#374151,color:#9ca3af,stroke:none style H5 fill:#374151,color:#9ca3af,stroke:none

具体内存字节(假设 vec![1u32, 2, 3],堆地址 0x5590_1234_0000):

ini 复制代码
栈上 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),
        ..
    }
}
graph LR subgraph "&[u32] --- 16 字节(胖指针)" P1["ptr: *const u32
8B"] P2["len: usize
8B"] end subgraph "目标数据(不被切片拥有)" D1["1u32"] D2["2u32"] D3["3u32"] end P1 --> D1 subgraph "&u32 --- 8 字节(瘦指针)" Q1["ptr: *const u32
8B"] end style P1 fill:#3b82f6,color:#fff,stroke:none style P2 fill:#10b981,color:#fff,stroke:none style Q1 fill:#f59e0b,color:#fff,stroke:none style D1 fill:#8b5cf6,color:#fff,stroke:none style D2 fill:#8b5cf6,color:#fff,stroke:none style D3 fill:#8b5cf6,color:#fff,stroke:none

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 的 LayoutDatasizeSize::ZEROfields 为空的 FieldsShape::Arbitrary

ZST 的实际用途

  1. PhantomData<T> :不占空间,但告诉编译器你在逻辑上"拥有" T,影响 Drop Check 和 variance。

  2. () :函数没有返回值时的返回类型。Vec<()> 不分配堆内存------它只是一个计数器。

  3. 标记类型struct Send;struct Sync; 等,在类型系统中携带约束信息。

  4. HashMap 退化为 HashSetHashMap<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_ofalign_of编译器内建函数 (intrinsics),在单态化阶段直接替换为常量。类型的布局信息存储在 LayoutData 结构体中:

LayoutData 包含 sizealign(即 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,直接是字段布局)
  • 多变体 enumlayout_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 缓存。

这意味着:

  1. 如果你紧接着访问的数据在同一条缓存行中,速度极快(L1 命中)
  2. 如果一个 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

输出示例:

python 复制代码
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 bytes

std::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, 8

5.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 编译器如何计算类型的内存布局:

  1. Struct 布局repr(Rust) 允许字段重排,编译器通过对齐组排序和 NicheBias 双排列策略,在减少 padding 的同时优化 niche 位置。

  2. Enum 布局:编译器同时计算 tagged layout 和 niche filling layout,选择更小(或 niche 更大)的那个。Niche 填充利用类型中"不可能的位模式"来编码变体信息,完全消除 tag 的空间开销。

  3. 胖指针&[T]&str&dyn Trait 都是 16 字节的 ScalarPair,包含数据指针和元数据(长度或 vtable)。

  4. 堆分配类型Vec/String 是 24 字节的三元组,Box/Rc/Arc 是 8 字节的单指针。

  5. ZST:零大小类型不占空间但在类型系统中携带信息,编译器对它们有专门的优化路径。

理解了数据在内存中的物理形态,我们就为后续的章节打下了基础。下一章,我们进入类型系统的核心------编译器如何通过单态化将泛型的零成本抽象承诺变为现实。

相关推荐
杨艺韬2 小时前
Rust编译器原理-第3章 借用检查器:编译器如何证明内存安全
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第9章 async/await:状态机的编译器变换
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第8章 Trait Object 与虚表:运行时多态的内存布局
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第13章 FFI:与 C 世界的桥梁
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第4章 生命周期:编译器如何推断引用的有效范围
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第7章 Trait 静态分发:零成本抽象的编译器实现
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第18章 设计哲学与架构决策
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第12章 unsafe:安全抽象的逃生舱
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第17章 增量编译:让重编译只做必要的事
rust·编译器