内存模型
本节讨论 C 语言的“内存模型” (Memory model):它描述的是抽象机器 (Abstract machine) 视角下,对象如何占据存储、如何被寻址、以及哪些访问方式是被标准允许的。
注意
这里的“内存”是标准中的抽象概念,不等同于某个具体 CPU 的物理内存布局。编译器只需要保证程序的可观察行为符合标准即可。
1. 字节 (Byte) 与对象
1.1 字节是最小的可寻址单位
在 C 语言抽象机器中:
- “字节”是最小可寻址的存储单元;
- 一个字节包含
CHAR_BIT个位 (Bit); sizeof(T)返回类型T的对象所占的字节数(类型为size_t)。
注意:CHAR_BIT 不要求等于 8;8 位字节只是最常见实现。
1.2 对象占据一段连续的存储
一个对象的存储可以被视为一段连续的字节序列。对象的对象表示 (Object representation) 就是这段字节序列的内容。
本章后续的 9.2 对象表示 会专门讨论“对象表示”和“值表示”之间的区别。
2. 地址、指针与对齐
2.1 指针指向对象的起始地址
在语义上,指向对象的指针可以理解为“指向该对象所占字节序列的第一个字节”。
2.2 对齐要求是硬约束
每个对象类型都有一个对齐要求 (Alignment requirement)。当对象的地址不满足对齐要求时,通过该类型的左值访问该对象,通常是未定义行为 (Undefined Behavior, UB)。
对齐相关内容见:
3. 有效类型 (Effective type) 与别名规则
3.1 为什么需要这套规则
编译器的很多优化(例如向量化、寄存器缓存、公共子表达式消除)都依赖一个前提:
某段存储在“类型层面”被如何访问,是可预测的。
标准用“有效类型” (Effective type) 与一条访问约束来表达这个前提。业界常把这条约束称为“严格别名规则” (Strict aliasing rule)。
3.2 核心结论(可操作版)
当你手上有一段存储,你可以安全地:
- 用与该对象有效类型兼容 (Compatible) 的类型去访问它;
- 用其有效类型的带限定符版本(例如
const版本)去访问它; - 用字符类型(
char、signed char、unsigned char)去访问它的对象表示; - 用
memcpy在不同类型之间搬运对象表示(避免用指针强转后解引用)。
反过来,如果你把 float* 强转成 uint32_t* 然后解引用,标准一般不保证这是合法的吗:这属于“以不兼容类型访问对象”,很容易触发 UB。
3.3 示例:类型惩罚 (Type punning) 的标准写法
下面示例演示如何在不违反别名规则的前提下,读取一个 float 的对象表示:
#include <stdint.h>
#include <stdio.h>
#include <string.h>
static uint32_t float_to_u32(float x) {
uint32_t u = 0;
memcpy(&u, &x, sizeof u);
return u;
}
int main(void) {
float x = 1.0f;
uint32_t bits = float_to_u32(x);
printf("0x%08x\n", bits);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
为什么是 memcpy
memcpy 以字节为单位搬运对象表示,不需要“把同一段存储同时当作两种不兼容类型的对象”。这也是跨平台代码中最稳妥的写法。
4. 生存期与悬垂指针
当对象的生存期结束后:
- 指向该对象的指针可能成为悬垂指针 (Dangling pointer);
- 继续通过该指针访问对象,通常是 UB。
生存期的细节见 9.3 生存期。
5. 习题
- 用你自己的话解释“抽象机器”与“物理机器”的区别,并说明这一区别对“内存布局讨论”有什么影响。
- 写一个函数
dump_bytes(const void* p, size_t n),把一段存储按十六进制输出;并用它观察:int的对象表示;double的对象表示;struct { char c; int x; }的对象表示(关注填充字节)。
- 阅读并判断:下面代码在标准层面是否一定正确?如果不一定,请给出标准写法。
float x = 1.0f;
unsigned u = *(unsigned*)&x;2