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

6 阅读23分钟

《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 位平台):

类型大小(字节)对齐(字节)
bool11
u8 / i811
u16 / i1622
u32 / i32 / f3244
u64 / i64 / f6488
u128 / i128168(注意!不是 16)
usize / isize88
&T / *const T88

struct 的对齐 = 所有字段对齐值中的最大值。struct 的大小必须是自身对齐的整数倍(这样 struct 数组中的每个元素也能满足对齐要求)。

填充(Padding)

当字段的自然偏移不满足下一个字段的对齐要求时,编译器在中间插入填充字节(padding bytes)。这些字节不存储有意义的数据,纯粹是为了对齐而浪费的空间。

5.2 Struct 布局:字段排列的两种策略

让我们从一个具体的 struct 开始:

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 的对齐边界(80x08    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%)。

graph LR
    subgraph "repr(C) — 24 字节"
        C1["a<br/>1B"]
        C2["pad<br/>7B"]
        C3["b<br/>8B"]
        C4["c<br/>2B"]
        C5["pad<br/>6B"]
    end
    subgraph "repr(Rust) — 16 字节"
        R1["b<br/>8B"]
        R2["c<br/>2B"]
        R3["a<br/>1B"]
        R4["pad<br/>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 来验证编译器的实际决策:

cargo +nightly rustc -- -Zprint-type-sizes
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 的布局计算。关键的排序逻辑在这一段:

// 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 填充优化。

// 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
}

偏移计算的核心循环

排序完成后,编译器按新顺序逐个放置字段。核心逻辑非常直接——对齐、放置、推进:

// 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。编译器直接复用内部字段的对齐和表示:

#[repr(transparent)]
struct Meters(f64);
// Meters 和 f64 有完全相同的内存表示和调用约定

repr(packed):压缩对齐

repr(packed) 将所有字段的对齐降低到 1,消除一切 padding。代价是访问未对齐字段需要生成更多指令,且 Rust 禁止对 packed 字段取引用:

#[repr(packed)]
struct Packed { a: u8, b: u64, c: u16 }
// 总大小:11 字节(1+8+2),对齐:1 字节

repr(align(N)):提升对齐

repr(align(N)) 将对齐提升到至少 N 字节,常用于缓存行对齐和 SIMD:

#[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)来标识当前是哪个变体,加上一个足够大的空间来存放最大变体的数据。

enum Shape {
    Circle(f64),           // 8 字节 payload
    Rectangle(f64, f64),   // 16 字节 payload
    Point,                 // 0 字节 payload
}
布局(Tagged Layout):
偏移    内容              大小
0x00    tag              1 字节(3 个变体,u8 足够)
0x01    [padding]        7 字节(对齐到 f648 字节)
0x08    payload          16 字节(最大变体 Rectangle 的大小)
总大小:24 字节,对齐:8 字节
变体tag 值payload 使用
Circle0前 8 字节存 f64
Rectangle1全部 16 字节存两个 f64
Point2不使用

判别值大小的选择

编译器选择 tag 的整数类型时使用最小的足够大的类型。在 compiler/rustc_middle/src/ty/layout.rs 中:

// 选择最小的无符号整数来容纳判别值范围
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 浪费:

// 使用第一个非 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>

// 引用永远不为空(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]  // 全零
  → 值为零,所以是 None
flowchart TD
    A["Option&lt;&i32&gt; — 8 字节"] --> B{"8 字节的值是 0x0?"}
    B -->|"是"| C["None<br/>无数据"]
    B -->|"否"| D["Some(&i32)<br/>值本身就是指针地址"]

    E["传统 tagged layout<br/>tag (8B) + pointer (8B) = 16B"] -.->|"Niche 优化后"| F["仅 pointer (8B)<br/>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> 额外开销
&T1..=MAX0(空指针)0 字节
&mut T1..=MAX00 字节
NonZeroU321..=u32::MAX00 字节
NonZeroUsize1..=usize::MAX00 字节
bool0..=12..=255(254 个值)0 字节
char0..=0xD7FF, 0xE000..=0x10FFFF0x110000..=0xFFFFFFFF0 字节
Ordering0, 1, 255 (即 -1)大量0 字节
u80..=2551 字节(需要 tag)
u640..=u64::MAX8 字节(需要 tag)

Niche 填充算法深入

layout_of_enum 中的 calculate_niche_filling_layout 是 Niche 优化的核心。让我们逐步分析:

第一步:找到最大变体和它的 niche

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 值,用来区分不同的非最大变体。

第二步:检查其他变体能否适配

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

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 比较,选择更小的

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 优化可以递归应用:

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

实际编码:

含义
0x0Some(None) — 内层的 None
0x1None — 外层的 None
>= 0x4 且 4 的倍数Some(Some(&i32)) — 合法指针
flowchart TD
    A["Option&lt;Option&lt;&i32&gt;&gt;<br/>8 字节"] --> B{"值 == 0x1?"}
    B -->|"是"| C["None(外层)"]
    B -->|"否"| D{"值 == 0x0?"}
    D -->|"是"| E["Some(None)(内层 None)"]
    D -->|"否"| F["Some(Some(&i32))<br/>值就是指针地址"]

    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 优化的更多例子

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) 相同的布局规则——字段可以被重排。

// (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 方法中计算:

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 倍:

#[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 字节的"三元组":

// Vec<T> 等价于:
struct Vec<T> {
    ptr: *mut T,   // 8 字节 — 指向堆上分配的缓冲区
    len: usize,    // 8 字节 — 当前元素数量
    cap: usize,    // 8 字节 — 分配的容量(可容纳的元素数)
}
graph LR
    subgraph "栈上 — 24 字节"
        V1["ptr<br/>8B"]
        V2["len = 3<br/>8B"]
        V3["cap = 5<br/>8B"]
    end

    subgraph "堆上 — cap * size_of::&lt;T&gt;()"
        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):

栈上 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:

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 字节:

assert_eq!(size_of::<&[u32]>(), 16);  // 指针 + 长度
assert_eq!(size_of::<&str>(), 16);     // 指针 + 长度
assert_eq!(size_of::<&u32>(), 8);      // 普通引用只有指针

胖指针在编译器中用 ScalarPair 表示——两个标量值组成一对:

// 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<br/>8B"]
        P2["len: usize<br/>8B"]
    end

    subgraph "目标数据(不被切片拥有)"
        D1["1u32"]
        D2["2u32"]
        D3["3u32"]
    end

    P1 --> D1

    subgraph "&u32 — 8 字节(瘦指针)"
        Q1["ptr: *const u32<br/>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:

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 字节),指向一个堆上的控制块:

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 中有一类特殊的类型,在内存中不占用任何空间

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)) 确保每个线程的数据独占一条缓存行:

#[repr(align(64))]
struct PerThreadCounter {
    count: AtomicU64,
}
// 每个计数器占 64 字节,独占一条缓存行

5.14 实用工具:查看类型布局

-Zprint-type-sizes

Nightly 编译器提供了 -Zprint-type-sizes 来查看所有类型的布局:

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 bytes

std::mem 中的函数

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

let layout = std::alloc::Layout::new::<Vec<String>>();
println!("size: {}, align: {}", layout.size(), layout.align());  // 24, 8

5.15 常见类型的完整布局一览

作为本章的总结,以下是 64 位平台上常见 Rust 类型的内存布局:

类型sizealign栈上字节说明
bool11[0x00][0x01]254 个 niche
char44[xx, xx, xx, xx]Unicode scalar value,有 niche
i3244[xx, xx, xx, xx]无 niche
f6488[xx, xx, xx, xx, xx, xx, xx, xx]无 niche
&T88[ptr 8B]1 个 niche (0x0)
&[T]168[ptr 8B][len 8B]胖指针
&str168[ptr 8B][len 8B]胖指针
&dyn Trait168[ptr 8B][vtable 8B]胖指针
Box<T>88[ptr 8B]1 个 niche (0x0)
Box<[T]>168[ptr 8B][len 8B]胖指针
Option<&T>88[ptr/0x0 8B]niche 优化
Option<bool>11[0x00/0x01/0x02]niche 优化
Vec<T>248[ptr 8B][len 8B][cap 8B]堆分配
String248[ptr 8B][len 8B][cap 8B]= Vec<u8>
HashMap<K,V>488控制块 + 指针实现相关
()01无字节ZST
PhantomData<T>01无字节ZST
[T; 0]0T 的对齐无字节ZST
Rc<T>88[ptr 8B]指向堆上控制块
Arc<T>88[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:零大小类型不占空间但在类型系统中携带信息,编译器对它们有专门的优化路径。

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