CMU 15-213
CSAPP / CMU 15-213 的前两章的几个点
1. 第一个点:offset 是什么
在 PPT 里反复出现一个词:offset(偏移)。
一开始我下意识把它当成“某种抽象地址计算”,但很快发现这样理解会出问题。
真正对齐之后,我才意识到:
- offset 不是概念性的“位置”
- 它只是从一个起始地址开始,往后数了多少个 byte
比如在一个 struct 里:
a[0]在 offset 0a[1]在 offset 4d在 offset 8
这里的 4、8 完全来自于:
sizeof(int) == 4(在当前平台)- C 数组下标 = 指针 + 偏移
一旦我把 offset 理解成“字节计数”,后面很多现象就不再神秘了。
2. 第二个点:为什么是 4?int 不是抽象的吗
我一开始的疑问是:
为什么偏移是 4?int 不是一个抽象类型吗?
这里 CSAPP 默认了一个非常重要、但不会反复提醒你的前提:
- 讨论的是现实机器上的 C
- 而不是 C 语言标准允许的所有可能实现
在主流平台(x86/x86-64 + 主流 OS + 主流编译器)上:
int= 4 bytedouble= 8 byte
这是 ABI 级别冻结下来的现实约定,而不是语法规则。
CSAPP 在前两章里,其实一直在把你从“语言抽象”拉回“具体平台假设”,只是它不太显眼。
3. bit / byte / word:我一开始没分清的三个尺度
在真正理解 offset 和内存布局之前,我其实还有一个更底层的混乱:bit、byte、word 在我脑子里是糊在一起的。
对齐之后,这三个概念必须严格区分:
- bit:最小的物理状态单位,只表示 0 或 1,本身没有语义
- byte:最小的可寻址单位,内存地址指向的是 byte,不是 bit
- word:CPU 自然处理的数据宽度,是架构相关的概念
在现代主流机器上:
- 1 byte = 8 bit
- 32 位 CPU:word = 4 byte
- 64 位 CPU:word = 8 byte
真正关键的一点是:
所有地址、offset、sizeof,讨论的尺度都是 byte; word 只影响“一次搬多少数据最自然”,不影响地址本身。
这一步不对齐,后面看到任何内存示意图都会下意识看错。
4. 字节序:多字节数据是怎么摊在内存里的
字节序(endianness)只回答一个问题:
多字节数据中,低位字节和高位字节,谁放在低地址?
在主流平台(x86 / x86-64)上,采用的是 little-endian:
- 低位字节 → 低地址
而 big-endian 正好相反:
- 高位字节 → 低地址
字节序不会影响 C 表达式层面的数值计算,但在以下场景必须在意:
- 按 byte 观察内存(调试、反汇编)
- 跨机器传输数据(网络、文件格式)
- 类型重解释(cast / union / memcpy)
CSAPP 在前两章反复出现字节序,是为了让你在“看内存”时不会产生错觉。
5. IEEE 754:浮点数不是实数
第二章里关于浮点数的内容,我一开始是直接跳过的,但后来发现至少有一个默认必须对齐。
核心事实是:
浮点数不是数学实数,而是一套用有限 bit 模拟实数的协议。
以 double 为例(64 bit):
- 1 bit:符号
- 11 bit:指数
- 52 bit:尾数
浮点数表示的是:
± 1.fraction × 2^(exponent - bias)这意味着:
- 大多数十进制小数无法精确表示
- 浮点数是离散的,不连续的
- 运算结果是“最接近的值”,不是“正确值”
6. 规格化数、非规格化数和特殊值
IEEE 754 里还引入了一些非数学意义的数值,用来保证运算的连续性。
- 规格化数(normalized):正常的浮点表示,精度最高
- 非规格化数(subnormal):指数为 0,用来避免突然下溢到 0
以及三类特殊值:
- ±0:+0 和 -0 bit pattern 不同
- ±∞:来自溢出或除以 0
- NaN:非法运算的结果,且
NaN != NaN
这些值不是异常,而是 IEEE 754 明确定义的合法结果。
7. 第三个卡点:volatile 到底在防什么
在 struct 越界写的例子里,有一个我一开始忽略的关键字:volatile。
真正理解之后,它的作用可以非常直白地描述成一句话:
别信你自己算过的值,每次都给我从内存里重新读。
没有 volatile 时:
- 编译器可能把
s.d = 3.14记在寄存器里 - 后面的
return s.d直接用寄存器 - 内存里就算被越界写改烂,也不影响结果
加了 volatile 之后:
- 写是真的写内存
- 读也是真的从内存读
于是“踩内存”的后果才会被观测到。
4. 第二章里真正危险的一点:signed / unsigned 的解释差异
第二章我并没有细看运算推导。
同一串比特,用 signed 和 unsigned 解释,得到的值可能完全不同。
比如在 32 位下:
0xFFFFFFFF- unsigned:4294967295
- signed:-1
bit pattern 没变,变的只是解释方式。
问题出在 C 的运算和比较规则:只要参与运算的一方是 unsigned,另一方往往会被转成 unsigned。
这会直接导致一些和直觉完全相反的结果。
典型场景包括:
int和unsigned混合比较- unsigned 做减法永远不会“变负”
size_t(unsigned)和-1这类哨兵值同框
这些都不是 bug,而是规则本身。
5. 为什么 C 要设计成这样
一开始我本能的反应是:
这种规则明显很容易写出错误结果,为什么要这么设计?
后来对齐之后,发现这是一个刻意的取舍。
C 的目标从来不是“帮你避免错误”,而是:
- 行为必须确定
- 规则必须在编译期就能决定
- 不能引入运行时检查成本
在 signed / unsigned 混用时,只有一种方向能保证这一点:
- 把 signed 转成 unsigned,是确定、无歧义的映射
- 反过来把 unsigned 转成 signed,可能直接溢出、失去可移植性
所以 C 宁可选择一个反直觉但一致的规则,也不选择“看起来合理但不确定”的行为。
这也解释了为什么第二章反复强调:
机器只保证一致性,不保证合理性。
6. word 不是指令:我一开始混掉的一个概念
在整理这些概念时,我还发现自己一开始把 word 和“机器指令”混在了一起,这也是一个典型的默认错位。
对齐之后,结论其实很干脆:
- word 不是指令
- word 说的是数据尺度,不是行为
更准确地说:
- word = CPU 自然处理的数据宽度
- 32 位机器:word = 4 byte
- 64 位机器:word = 8 byte
word 影响的是:
- 寄存器宽度
- 一次 load / store 的自然大小
- ABI 里的对齐规则
而机器指令是另一回事:
- 指令是“CPU 要做什么”的编码
- 指令长度和 word 长度没有必然关系
- 在现实架构里(比如 x86),指令甚至是变长的
我之所以会把两者混在一起,是因为很多简化模型里,会默认“一条指令正好占一个 word”。但这是教学简化,不是现实机器的普遍事实。
把这一点分清之后,后面再看到:
- word-sized access
- word boundary
- 对齐到 word
我就能明确知道:这是在讨论数据访问和性能,而不是指令本身。
7. 前两章到底在干什么
回头看,前两章做的事情其实非常集中:
- 把“类型”“变量”“运算”从抽象概念
- 强制还原成 bit pattern + byte 编址 + 数据尺度(word)+ 解释规则
它并不是在教新技巧,而是在统一一套讨论现实机器时必须默认接受的坐标系。
如果这些坐标没对齐,后面谈:
- 栈和堆
- cache 行为
- 对齐与性能
- 越界、未定义行为
都会变成玄学。
