基础类型与内存布局

11 分钟
本页目录

理解布局需要区分三件事:类型允许哪些值,这次编译采用什么存储方式,语言或标准库承诺哪些表示。一次 size_of 的输出可以帮助观察当前目标,不能把所有观察值都当成跨版本 ABI。

大小不等于对齐

类型大小需要注意
u8/i8、u16/i16、u32/i32、u64/i64、u128/i128分别 1、2、4、8、16 字节对齐仍取决于目标
f32、f644、8 字节NaN 等值影响比较语义
bool1 字节有效值仅 false/true,不是任意 u8
char4 字节Unicode 标量值,不是任意 u32
usize/isize目标地址宽度对应的大小不适合作为跨平台文件字段
()0 字节,对齐 1零大小不代表所有 ZST 都对齐 1

固定宽度整数大小固定,对齐不一定等于大小,例如某些 32 位目标的 u64 对齐为 4。指向 Sized 类型的指针大小、对齐与 usize 相同;DST 指针的元数据又是另一层表示。类型布局

char 是排除代理区的 Unicode 码点,即 Unicode scalar value。用户眼中的一个字形可能由多个标量值组成;'あ' 作为 char 占 4 字节,作为 UTF-8 文本占 3 字节。字符串细节见 字符串与路径。

整数溢出:按意图选择操作

普通算术是否检查溢出受编译配置控制,不能只按 debug/release 名称绝对划分。需要可预测策略时,直接调用 checked、wrapping、saturating 或 overflowing 方法:

fn main() {
    assert_eq!(255u8.checked_add(1), None);
    assert_eq!(255u8.wrapping_add(1), 0);
    assert_eq!(255u8.saturating_add(1), 255);
    assert_eq!(255u8.overflowing_add(1), (0, true));
    assert_eq!(i32::MIN.checked_div(-1), None);
    assert_eq!(10i32.checked_div(0), None);
}

整数除零和有符号最小值除以 -1 也需要考虑;不能以为关闭加法溢出检查就让所有整数运算都安全地回绕。常量求值中的错误还可能在编译期被诊断。运算符

数值转换:可表示范围也属于类型契约

as 不是“确认数值放得下”的检查。整数窄化会截掉高位,有符号/无符号转换也有明确的位解释规则;若文件长度来自 u64 而本机使用 usize,应先做可失败转换,再检查业务和分配上限。能转成 usize 仍不等于允许分配这么多内存。

fn main() {
    let count = 300u16;
    assert_eq!(count as u8, 44);
    assert!(u8::try_from(count).is_err());
    assert_eq!(u32::from(255u8), 255);
    assert_eq!((-1i8) as u8, 255);
    // 数值到字节使用明确端序,不依赖当前 CPU。
    let bytes = 300u32.to_be_bytes();
    assert_eq!(bytes, [0, 0, 1, 44]);
    assert_eq!(u32::from_be_bytes(bytes), 300);
}

浮点也有有效但不满足普通数学直觉的值:NaN 不是未初始化内存,而是合法浮点值;partial_cmp 可能返回 None。排序若采用 total_cmp,使用的是指定总序,与普通 == 的语义不完全相同,例如它区分正零和负零。是否允许 NaN、无穷或负零,应在业务边界单独定义。数值转换、f64::total_cmp

结构布局:字段偏移为什么需要填充

a填充 3Bb:4Bc:2B填充 2B01481012repr(C) 示例:字段间填充与尾部填充此图假设 u32 对齐为 4、u16 对齐为 2;其他目标必须按自身 ABI 计算。
use std::mem::{align_of, offset_of, size_of};

#[repr(C)]
struct Header { a: u8, b: u32, c: u16 }

fn main() {
    println!("size={} align={} offsets={},{},{}",
        size_of::<Header>(), align_of::<Header>(),
        offset_of!(Header, a), offset_of!(Header, b), offset_of!(Header, c));
    if align_of::<u32>() == 4 && align_of::<u16>() == 2 {
        assert_eq!(size_of::<Header>(), 12);
        assert_eq!(offset_of!(Header, b), 4);
        assert_eq!(offset_of!(Header, c), 8);
    }
}

repr(C) 按声明顺序对齐各字段,再把总大小补齐到结构对齐。尾部填充使数组中下一个结构仍对齐。默认 repr(Rust) 不保证字段声明顺序,也不承诺始终选择最小布局。

repr(C) 不是序列化格式:填充字节可能未初始化,端序与目标 ABI 仍不同,字段本身也可能不是 C 兼容类型。不要把整个结构直接读取成字节就当作安全协议编码。

用偏移推导数组步长与字段重排

对图中的 Header,a 占用偏移 0;b 要求 4 字节对齐,因此从 4 开始;c 接在偏移 8。最后一个字段结束于 10,而结构最大对齐为 4,所以总大小补到 12。数组第二个元素从 12 开始,其 b 位于 16,仍满足对齐。

如果允许改动 repr(C) 的字段声明顺序,把字段排成 b、c、a,在这些对齐假设下会变成偏移 0、4、6,总大小为 8。它减少空间,却同时改变 ABI 和字段偏移,不能对已经发布的外部接口悄悄这样优化。

外层 repr(C) 也不会递归改写内部 Rust struct 的表示。两个类型大小、对齐相同,还不意味着拥有相同有效值集合、析构行为或调用 ABI;这些条件要分别检查。Rust 布局规则

repr(transparent)、align 与 packed 改变什么

表示作用不能推导的结论
repr(C)按相应 C 布局规则组织字段所有内部字段都自动成为 C ABI 类型
repr(transparent)满足约束的包装继承所包装字段的布局与 ABI任意多字段包装都允许,或业务含义自动相同
repr(align(N))提高类型对齐要求实际对象一定独占某个固定硬件 cache line
repr(packed) / packed(N)降低字段和结构的对齐约束普通引用可以指向未对齐字段

packed 最容易出现隐式借用问题:字段按值读取可能合法,但 &packed.field 创建未对齐引用不合法,某些格式化或断言宏也会隐式借用。先复制到局部变量再借用,或在确实需要指针读取时使用 &raw const 和 read_unaligned,同时满足初始化、范围与所有权要求。

#[repr(C, packed)]
struct Packed { kind: u8, count: u32 }

fn main() {
    let packet = Packed { kind: 1, count: 300 };
    let copied = packet.count; // 按值读取 Copy 字段,不创建 &u32。
    assert_eq!(copied, 300);
    let ptr = &raw const packet.count;
    // SAFETY: ptr 指向仍存活且已初始化的 u32 字段;read_unaligned 不要求对齐。
    // u32 是 Copy,读取不引入双重释放或其他所有权重复问题。
    let read_back = unsafe { ptr.read_unaligned() };
    assert_eq!(read_back, 300);
    assert_eq!(packet.kind, 1);
    assert_eq!(std::mem::size_of::<Packed>(), 5);
}

这只解释 packed 对本地布局的影响,不能替代协议解码:字段中的整数仍按主机数值表示,外部数据还可能非法或不足。read_unaligned 只放宽对齐要求,没有把任意地址、未初始化值或非 Copy 所有权复制变安全。read_unaligned

逐字节看字段与填充

填充公式中的“向上取整”来自地址约束:字段起点必须是其对齐的整数倍,数组步长必须是结构对齐的整数倍。格子中的点是填充,不是业务数据,也不保证字节已初始化。改变声明顺序可以减少这些点,却会改变外部可见布局。

正在呈现知识画面
逐字节看字段与填充

repr(C) 逐字段向上对齐,最后补到整体对齐;改变顺序也改变 ABI。

数组传参:所有权语义与实际拷贝分开

[T; N] 的长度参与类型,布局连续,大小为 size_of::() * N。数组不天然属于栈:它也能在 Box、静态存储或其他对象中。

数组在 T: Copy 时可以 Copy;[String; N] 等非 Copy 数组按值传递会移动所有权。编译器可以优化机器层的搬运,不能把 move 描述为“逐元素 Clone”或保证一定执行一次全数组 memcpy。

需要只读访问任意长度数据时,&[T] 通常方便;需要把数组交给接收方负责时,按值传递有明确意义。选择首先取决于所有权,随后再测量大值传递的开销。

ZST 仍有类型、对齐和行为

零大小类型的值不需要为字段数据占字节,但可以有方法、Drop 行为和对齐要求。多个 ZST 可能有相同地址,不能用地址识别逻辑对象。

use std::sync::atomic::{AtomicUsize, Ordering};
static DROPS: AtomicUsize = AtomicUsize::new(0);
struct Marker;
impl Drop for Marker {
    fn drop(&mut self) { DROPS.fetch_add(1, Ordering::Relaxed); }
}
fn main() {
    assert_eq!(std::mem::size_of::<Marker>(), 0);
    { let _markers = [Marker, Marker]; }
    assert_eq!(DROPS.load(Ordering::Relaxed), 2);
}

因此“ZST 完全不存在于运行时”不成立。Vec 仍有长度与逐项析构语义,即使元素存储无需通常的非零分配。

niche:利用内部类型不使用的表示

引用不能为 null,NonZeroU32 不能为 0,这些未使用表示可以用于编码 Option 的 None。标准库明确保证一组类型的 Option 与原类型大小、对齐和调用 ABI 相同;应以该列表为准。Option 表示保证

use std::mem::size_of;
use std::num::NonZeroU32;
fn main() {
    assert_eq!(size_of::<Option<&u8>>(), size_of::<&u8>());
    assert_eq!(size_of::<Option<NonZeroU32>>(), size_of::<NonZeroU32>());
    println!("Option<bool> observed size: {}", size_of::<Option<bool>>());
    println!("nested Option reference observed size: {}", size_of::<Option<Option<&u8>>>());
}

Option 常能利用多余位模式压缩,但不要把 None 的具体字节值固定为 2。嵌套 Option 需要区分外层 None、Some(None)、Some(Some(_)),不能直接把内层已有的空指针表示重复使用。

表示优化也不意味着无需判断是否有值,更不允许创建非法 bool 或无效引用。内存大小、控制流成本和有效值约束应分别理解。

外部字节与有效值:不要先造非法值再检查

外部字节可能截断、越界或非法显式解码与校验长度、端序、范围、UTF-8有效的 Rust 值类型不变量已成立u8 的 256 种位模式 ≠ bool 的两个有效值从字节到 Rust 值:布局匹配之后还必须满足有效值约束MaybeUninit 允许保存未初始化存储,不会免除 assume_init 时的有效性证明。

bool 占 1 B,但只有 0、1 是其有效表示;char 占 4 B,但代理区码点不是合法 char。引用除了非空,还有对齐、生命周期与可访问内存等约束。用 unsafe 先把字节强转为这些类型,再检查它们是否合法,已经把检查放在太晚的位置。未定义行为与有效值

更直接的方式是从最弱承诺的字节开始,逐项建立更强类型。下面规定一个 5 B wire record:第一个字节是 0/1 enabled,后四字节为大端 uint32 count。它的 wire 长度是协议规定,与任何 Rust struct 的 size 无关。

#[derive(Debug, PartialEq)]
struct Record { enabled: bool, count: u32 }

fn decode(input: &[u8]) -> Result<Record, &'static str> {
    if input.len() != 5 { return Err("length"); }
    let enabled = match input[0] {
        0 => false,
        1 => true,
        _ => return Err("invalid bool"),
    };
    let count = u32::from_be_bytes(input[1..5].try_into().map_err(|_| "length")?);
    if count > 1000 { return Err("business limit"); }
    Ok(Record { enabled, count })
}
fn main() {
    assert_eq!(decode(&[1, 0, 0, 1, 44]), Ok(Record { enabled: true, count: 300 }));
    assert_eq!(decode(&[2, 0, 0, 0, 0]), Err("invalid bool"));
    assert_eq!(decode(&[1, 0]), Err("length"));
    assert_eq!(decode(&[1, 0, 0, 3, 233]), Err("business limit"));
    assert_eq!(char::from_u32(0xd800), None);
}

MaybeUninit 用来表示尚未保证存在有效 T 的存储。在所有必要字段都正确初始化后才能取得 T;全零内存不是所有 T 的合法值,引用、NonZero 和某些 enum 尤其如此。MaybeUninit<T> 与 T 的大小和对齐关系也不意味着 Option<MaybeUninit<T>> 能沿用 T 原来的 niche 优化。MaybeUninit

阅读一段布局代码时,可以依次问:是否仅观察当前目标;是否依赖已承诺表示;是否建立了有效值;是否满足所有权;是否还需要跨平台 wire 编码。五个答案分别明确,比仅用“内存大小相同”证明安全更可靠。