这是一篇系统级编程CS:APP / 深入理解计算机系统的学习笔记。在重新翻阅这篇笔记时我把曾经课堂上“戛然而止”的思维片段进行了补全。如果你也对 C/C 底层、内存布局、二进制的那些“玄学”Bug 感兴趣希望这篇笔记能帮到你。Lec 01 Course ReviewMemory (内存的边界与信任)在学习 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但因为0是0U(无符号)-1会在底层被解释为无符号数的最大值UMAX所有比特位全为 1。所以比较变成了UMAX 0结果居然是FalseLec 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)当指数部分全是 0 时触发“去规范化”模式。此时尾数部分的小数点前不再隐藏1而是变成了0即0.xxxx。它的主要意义就是为了表示非常接近于 0 的极小数填补 0 周围的空洞实现“逐渐下溢 (Gradual Underflow)”让数字能平滑地过渡到绝对的 0。动态范围解释动态范围就是浮点数能表示的分布跨度。从 0 开始往上走先是密集的“去规范化数”然后是“最小的正规范化数”接着越来越稀疏直到“最大的规范化数”。下图非常直观地展示了这种在数轴上“越靠近 0 越密集越远离 0 越稀疏”的特性。Round action (舍入机制)当一个数字不能被精确的浮点数表示时我们需要舍入。IEEE 标准有几种舍入模式向零舍入、向下舍入、向上舍入。但最重要的是默认模式向偶数舍入 (Round to nearest even)。为什么不直接“四舍五入”在统计学中如果你总是遇到.5就向上舍入比如 1.5 变 22.5 变 3那么在一个庞大的数据集中总体平均值就会不自然地偏大这就是所谓的统计偏差。采用“向偶数舍入”Banker’s Rounding 银行家舍入法1.5 会变成 2但 2.5 遇到半中间时会向最近的偶数舍入变成 2。这样一半时间往上一半时间往下总体误差会在宏观上互相抵消。Round in binary data (二进制里的舍入)在二进制里“偶数”意味着最末位Least Significant Bit是0。如果后面的数字不到一半就直接丢掉。如果后面的数字超过一半就进位。如果正好卡在中间也就是后面正好是10000...那就看要保留的最后一位是1就进位变成0偶数是0就保持0已经是偶数。
CMU 15-213 CSAPP:从内存到浮点数的那些坑(Data)
这是一篇系统级编程CS:APP / 深入理解计算机系统的学习笔记。在重新翻阅这篇笔记时我把曾经课堂上“戛然而止”的思维片段进行了补全。如果你也对 C/C 底层、内存布局、二进制的那些“玄学”Bug 感兴趣希望这篇笔记能帮到你。Lec 01 Course ReviewMemory (内存的边界与信任)在学习 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但因为0是0U(无符号)-1会在底层被解释为无符号数的最大值UMAX所有比特位全为 1。所以比较变成了UMAX 0结果居然是FalseLec 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)当指数部分全是 0 时触发“去规范化”模式。此时尾数部分的小数点前不再隐藏1而是变成了0即0.xxxx。它的主要意义就是为了表示非常接近于 0 的极小数填补 0 周围的空洞实现“逐渐下溢 (Gradual Underflow)”让数字能平滑地过渡到绝对的 0。动态范围解释动态范围就是浮点数能表示的分布跨度。从 0 开始往上走先是密集的“去规范化数”然后是“最小的正规范化数”接着越来越稀疏直到“最大的规范化数”。下图非常直观地展示了这种在数轴上“越靠近 0 越密集越远离 0 越稀疏”的特性。Round action (舍入机制)当一个数字不能被精确的浮点数表示时我们需要舍入。IEEE 标准有几种舍入模式向零舍入、向下舍入、向上舍入。但最重要的是默认模式向偶数舍入 (Round to nearest even)。为什么不直接“四舍五入”在统计学中如果你总是遇到.5就向上舍入比如 1.5 变 22.5 变 3那么在一个庞大的数据集中总体平均值就会不自然地偏大这就是所谓的统计偏差。采用“向偶数舍入”Banker’s Rounding 银行家舍入法1.5 会变成 2但 2.5 遇到半中间时会向最近的偶数舍入变成 2。这样一半时间往上一半时间往下总体误差会在宏观上互相抵消。Round in binary data (二进制里的舍入)在二进制里“偶数”意味着最末位Least Significant Bit是0。如果后面的数字不到一半就直接丢掉。如果后面的数字超过一半就进位。如果正好卡在中间也就是后面正好是10000...那就看要保留的最后一位是1就进位变成0偶数是0就保持0已经是偶数。