CMU 15-213 CSAPP:从内存到浮点数的那些坑(Data)
2026/7/22 3:58:23 网站建设 项目流程

这是一篇系统级编程(CS:APP / 深入理解计算机系统)的学习笔记。在重新翻阅这篇笔记时,我把曾经课堂上“戛然而止”的思维片段进行了补全。如果你也对 C/C++ 底层、内存布局、二进制的那些“玄学”Bug 感兴趣,希望这篇笔记能帮到你。

Lec 01 Course Review

Memory (内存的边界与信任)

在学习 C/C++ 的初期,我们经常会遇到数组越界的问题。多数情况下,C++ 不会在运行时进行边界检查
为什么?因为 C/C++ 的设计哲学是“You don’t pay for what you don’t use”(绝不为你不使用的功能付出代价)。运行时检查边界需要消耗 CPU 时钟周期,C/C++ 选择了极致的性能,把内存的控制权完全信任并交给了程序员。

但这也埋下了安全隐患:黑客可以利用这个漏洞(比如经典的缓冲区溢出攻击 Buffer Overflow),篡改内存中相邻块重要数据的值,甚至修改函数的返回地址来执行恶意代码。

“Cpp will not complain the memory access, but the os will.” (C++ 不会抱怨你的内存访问,但操作系统会。)

如果你越界访问了不属于你的内存页,操作系统就会无情地介入,并赏你一个Segmentation Fault (段错误)然后杀掉你的进程。

数据与内存结合应用:
内存不仅要存得对,还要读得快。在 C/C++ 中,二维数组在内存中是按行连续存储的。

Row by row access better than col by col access. (按行访问远快于按列访问)

补充知识:这背后的核心是CPU Cache(缓存)的空间局部性。当你读取数组一个元素时,CPU 会把包含该元素的整个 Cache Line(通常是 64 字节)一起搬进高速缓存。如果按行遍历,接下来的数据已经在 Cache 里了(Cache Hit);如果按列遍历,每次跳跃都会导致缓存未命中(Cache Miss),性能差异可能会达到几十倍!


Lec 02 Bits, Bytes, and Integers

位运算符号 shift operations

位运算是计算机最喜欢的底层操作,主要有左移<<和右移>>

位运算符的基本作用:

  • 左移 (Left Shift):丢弃最高位,在右侧补 0。相当于乘以 2。
  • 右移 (Right Shift):分为两种:
    • 逻辑右移 (Logical Shift):左侧一律补 0。通常用于无符号数
    • 算术右移 (Arithmetic Shift):左侧补最高位(符号位)的值。用于有符号数,保证右移后负数依然是负数。

类型转换有关的 surprises

在 C 语言中,无符号数 (Unsigned) 和有符号数 (Signed) 混用是一个巨大的灾难现场。

核心规则:当一个表达式中同时存在有符号数和无符号数时,C 语言会隐式地将有符号数强制转换为无符号数,然后再进行比较或运算。

仔细看下面的幻灯片,比如-1 < 0U这个比较:
直觉上-1当然小于0,但因为00U(无符号),-1会在底层被解释为无符号数的最大值UMAX(所有比特位全为 1)。所以比较变成了UMAX < 0,结果居然是False


Lec 03 Bits, Bytes, and Integers cont

无符号加法与溢出 (Overflow)

无符号数的加法在底层就像是一个时钟表盘,本质是模运算 (Modular Arithmetic)
如果两个数相加超过了系统能表示的最大值,它不会报错,而是会“绕一圈”回到起点(Wraps around)。这就是溢出

⚠️ Risky Code Block:无符号数永远 >= 0

因为无符号数永远不可能是负数,在写循环条件时非常容易写出“死循环”Bug。

看看下面幻灯片里的例子for (unsigned i = cnt-2; i >= 0; i--)
你以为当i减到0之后,再减1就会变成-1,然后退出循环?错!0 - 1在无符号数下会溢出变成最大的正数UMAX,所以i >= 0这个条件永远为真。这就是为什么说“你想耍点小技巧的时候会变得十分诡异”。

如何优雅地解决?
将判断条件改为<形式,或者使用size_t(它本质上是平台字长的无符号整数) 并配合合理的边界检查,永远不要去测试无符号数是否小于 0。

Byte ordering (字节序)

一个跨越多个字节的数据(比如 4 字节的int),在内存中是怎么排列的?

  • 大端序 (Big Endian):最高有效字节(最重要的数据)存在最前面(低地址)。类似人类的阅读习惯。
  • 小端序 (Little Endian):最低有效字节存在最前面(低地址)。x86 和 ARM 处理器通常用这种。

课外故事补全:“Endian” 这个词出自乔纳森·斯威夫特的《格列佛游记》。在小人国里,人们为了“吃白煮蛋应该从大头打破还是从小头打破”爆发了战争。计算机科学家借用了这个概念,大头打蛋派就是 Big-Endian,小头打蛋派就是 Little-Endian。这只是存放数据的两种习惯,并无绝对优劣,但在网络传输时(网络字节序统一规定为大端序),必须注意大小端的转换。

C Integer 判断 (有趣的谜题)

理解了前面的隐式转换和溢出,就可以来看看这些经典的坑人 Puzzle 了,用来检验自己是否真的理解了底层的位运算和比较规则。


Lec 04 Floating Point

昔日留言回应:
“不理解。 -2025/9/26”
别急,随着时间推移,现在的我来为你彻底讲明白浮点数这座大山!

浮点数动态范围 (Dynamic Range)

浮点数为什么能在 32 个 bit 里面表示比int范围大得多的数字?并且还能表示小数?
答案是 IEEE 754 标准,它把数字表示成了类似科学计数法的形式:V = (-1)^s * M * 2^E

  • 符号位 s (Sign):决定正负。
  • 指数 exp (Exponent):决定数字的范围大小(类似于 10 的多少次方)。
  • 尾数 frac (Mantissa/Fraction):决定数字的精度。

你曾经困惑的“去规范化”与“规范化”到底是什么?

通常情况下(规范化数),为了省掉一个 bit 的空间,尾数的小数点前默认总是隐藏着一个1(即1.xxxx)。这就是规范化数 (Normalized numbers),用来表示绝大多数正常的数字。

但问题来了:如果我们要表示非常非常接近于0的极小数字怎么办?如果全是1.xxxx,那能表示的最小的数就卡在某个值下不去了,0 周围会出现一段无法表示的“空洞”。

所以规定了去规范化数 (Denormalized numbers)

  1. 当指数部分全是 0 时,触发“去规范化”模式。
  2. 此时,尾数部分的小数点前不再隐藏1,而是变成了0(即0.xxxx)。
  3. 它的主要意义就是为了表示非常接近于 0 的极小数,填补 0 周围的空洞,实现“逐渐下溢 (Gradual Underflow)”,让数字能平滑地过渡到绝对的 0。

动态范围解释:
动态范围就是浮点数能表示的分布跨度。从 0 开始往上走:先是密集的“去规范化数”,然后是“最小的正规范化数”,接着越来越稀疏,直到“最大的规范化数”。下图非常直观地展示了这种在数轴上“越靠近 0 越密集,越远离 0 越稀疏”的特性。

Round action (舍入机制)

当一个数字不能被精确的浮点数表示时,我们需要舍入。IEEE 标准有几种舍入模式:向零舍入、向下舍入、向上舍入。
但最重要的是默认模式:向偶数舍入 (Round to nearest even)

为什么不直接“四舍五入”?
在统计学中,如果你总是遇到.5就向上舍入(比如 1.5 变 2,2.5 变 3),那么在一个庞大的数据集中,总体平均值就会不自然地偏大(这就是所谓的统计偏差)。
采用“向偶数舍入”(Banker’s Rounding 银行家舍入法),1.5 会变成 2,但 2.5 遇到半中间时,会向最近的偶数舍入变成 2。这样一半时间往上,一半时间往下,总体误差会在宏观上互相抵消

Round in binary data (二进制里的舍入):
在二进制里,“偶数”意味着最末位(Least Significant Bit)是0

  • 如果后面的数字不到一半,就直接丢掉。
  • 如果后面的数字超过一半,就进位。
  • 如果正好卡在中间(也就是后面正好是10000...),那就看要保留的最后一位:是1就进位变成0(偶数),是0就保持0(已经是偶数)。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询