Skip to content

CMU 15-213

CSAPP / CMU 15-213 的前两章的几个点


1. 第一个点:offset 是什么

在 PPT 里反复出现一个词:offset(偏移)。

一开始我下意识把它当成“某种抽象地址计算”,但很快发现这样理解会出问题。

真正对齐之后,我才意识到:

  • offset 不是概念性的“位置”
  • 它只是从一个起始地址开始,往后数了多少个 byte

比如在一个 struct 里:

  • a[0] 在 offset 0
  • a[1] 在 offset 4
  • d 在 offset 8

这里的 4、8 完全来自于:

  • sizeof(int) == 4(在当前平台)
  • C 数组下标 = 指针 + 偏移

一旦我把 offset 理解成“字节计数”,后面很多现象就不再神秘了。


2. 第二个点:为什么是 4?int 不是抽象的吗

我一开始的疑问是:

为什么偏移是 4?int 不是一个抽象类型吗?

这里 CSAPP 默认了一个非常重要、但不会反复提醒你的前提:

  • 讨论的是现实机器上的 C
  • 而不是 C 语言标准允许的所有可能实现

在主流平台(x86/x86-64 + 主流 OS + 主流编译器)上:

  • int = 4 byte
  • double = 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

这会直接导致一些和直觉完全相反的结果。

典型场景包括:

  • intunsigned 混合比较
  • 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 行为
  • 对齐与性能
  • 越界、未定义行为

都会变成玄学。

Released under the MIT License.