CSAPP

Sept 27, 2023 edition

笔者前言

写来尝试造福学弟学妹们。很惭愧,只是想要做一点微小的工作。

内容仅包含北京邮电大学课程《计算机系统基础》的部分讲授和期末考核内容,并不严格包含 CSAPP 一书中的完整章节。

CSAPP 是一本优秀的书,但其中也有些内容过于理论化、已经过时,也难免出现错误(CSAPP 英文原书第三版官方勘误(尚在不断补充中)),其中文译本也存在翻译水平底下、难以阅读的问题。瑕不掩瑜,我们仍然推荐大家使用本书学习,本文即是尝试为学习这一册书的后来的朋友提供些许帮助。

CSAPP 的第一部分以 C 语言为媒介过渡到了 x86-64 汇编这一更低的抽象层级,这个过渡方式本身是没有问题的;然而 C 语言本身也在不断的更新迭代、发展优化中,书中一系列“C 语言中负数采用补码”“整数右移采用算术右移”“int x = foo(); x * x >= 0 有可能返回假”“char 有符号”等论断并不一定符合 C 语言的客观实际,其中有些没有遵守 C 语言规定但符合 x86-64 实际,有些两者都不一定遵守,有些在应用层级的 C 语言中会被直接优化成为期望之外的行为。笔者认为应当将书中采用的 C 语言与其他场合中的 C 语言分开看作两门语言,它们的语法相互贯通,但对于某些特定情况下的行为不应混淆。这一点非常重要,所以在前言中指出。

笔者水平有限,无法保证全文正确且易懂;欢迎勘误或改进,目前请通过邮件。目前也可以通过邮件索要本文的 markdown 源文件。

感谢阅读。

2. 信息与不带优化的操作系统级 C

二进制 & 无符号数 & 补码有符号数

无符号数就是二进制位加权求和,权是 2 的幂。

补码有符号数的二进制最高位是符号位。它和无符号数的区别只在于符号位的权取相反数。

这就是全部,就这么简单。

C 的整数类型

CSAPP 在这里故弄玄虚。实际上的规则很简单,大体包含了类型系统、整数常量(字面量)、隐式转换三部分。

我更习惯让你去读 C++ 的材料。我觉得它相比 C 的易读很多,并且其中两门语言的重合部分中 80% 是适用 C 语言的。

整数的类型系统大体就是说,整数类型的排序是 char < short < int < long < long long,其中每种类型的 signed 版本低于 unsigned 版本。

字面量的默认类型是 int,如果不能容纳会向上尝试更高的类型。你也可以在字面量中添加后缀指定类型,例如 -1u

系统级 C 的整数类型的算术运算和比较运算

对于除移位以外的算术运算,首先会将所有低于 int 的操作数提升到 int,称之为“整数提升”(狭义)。

接着,如果两个操作数类型仍然不同,会将其中较低的类型隐式转换到较高的。

比较运算的操作数如果是算术类型(整数类型当属其中),也会实施如上所述的算术转换。

移位运算不会实施上述的算术转换。此外,考试中若不加说明,我们一般认为 >> 会发生算术右移,但是将来若你在实际环境中遇到这个问题,行为如何还需具体测试。

C 的浮点类型

接着整数类型说。C 有三种浮点类型 float < double < long double,隐式类型转换中它们均高于任何整数类型。

IEEE 浮点数

和大多数编程语言一样,C 语言采用 IEEE 754 规定的浮点数,其中 floatdouble 分别采用 32 位(单精度)和 64 位(双精度)的 IEEE 浮点数典型标准。

IEEE 浮点数的实际值以 V=(1)s×2E×M 的形式表示,其中

典型地,IEEE 单精度和双精度浮点数分别拥有以下的结构:

0000 0000 0exponent000 0000 0000 0000 0000 0000significand0000 0000 0000exponent0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000significand

我们在 exponent 和 significand 的分界处放置一个小数点,左侧各位的权依次为 20,21,22,,右侧各位的权依次为 21,22,23,。记 exponent 编码出的一个整数为 e,significand 编码出的一个小数为 f

根据 e 的值,浮点数被分为三类。

  1. 规格化的值,适用于 e 既非全 0 也非全 1(单精度 255,双精度 2047)的情况。

    k 为 exponent 的位数,设置一个偏置量 B=2k11(单精度 127,双精度 2047)。

    此时 E=eBM=1+f

  2. 非规格化的值,适用于 e0 的情况。

    此时 E=eBM=f

    非规格化的值用于表示非常接近 0 的数(包括 +00)。

  3. 特殊值,适用于 e 为全 1 的情况。

    此时,若 f=0,则 V=V=,取决于符号位 s。否则 V=NaN(Not a Number)。

    NaN 是一个非常特殊的数字,用于描述不能被表示的运算结果。NaN 具有“吞噬性”,任何它参与的算术运算全部返回 NaNNaN 不具有和其他浮点数的偏序性质,它参与的比较运算(包括 nan == nan)全部返回假,唯一的例外是 nan != nan 返回真。

用移位优化常量乘法

众所周知硬件计算乘法远慢于加法、减法和移位。

尝试用加减和移位优化计算常量 K 乘以变量时可以考察 K 的二进制表示中 0 的数量+11 的数量中的较小值。

3. 不带优化的操作系统级 C 与 x86-64 at&t 汇编

x86-64 通用寄存器

16 位和 8 位的那些真**难背啊,背这玩意干啥啊。

l 结尾的是 8 位的,否则稀奇古怪的是 16 位的。r 开头的是 64 位的,e 开头的是 32 位的。

前 6 个参数分别是 %rdi, %rsi, %rdx, %rcx, %r8, %r9;返回值是 %rax

%rsp 是栈指针;%rbx, %rbp, %r12~%r15 是 callee-saved,剩下的是 caller-saved。

x86-64 条件码寄存器

CSAPP 一书中涉及到了以下四个位:

所有的算术运算指令会根据其结果设置这些寄存器位的值。

汇编寻址

语法数值
$II
RR
IM[I]
(R)M[R]
I(R)M[I+R]
(Rb,Ri)M[Rb+Ri]
I(Rb,Ri)M[I+Rb+Ri]
(,Ri,s)M[Ris]
I(,Ri,s)M[I+Ris]
(Rb,Ri,s)M[Rb+Ris]
I(Rb,Ri,s)M[I+Rb+Ris]

s 的值似乎只能是 1,2,48

at&t 汇编指令后缀

就是说操作数大小不一定的指令可以加 b, w, l, q 指示字节数。 b 是字节 byte,w 是字 word,l 是双字,q 是四字。

感觉可能其实有挺多人更习惯用 Intel 汇编的方式啊,况且 at&t 汇编也不是一定要加这个后缀的。

不过考试要考。

x86-64 汇编的一些指令

控制流、语法糖与汇编

  1. goto

    我们不管这个东西。它和裸汇编已经差不太多了。下面所有控制流都可以说是 goto 的语法糖。

  2. if-else 分支

    典型地会被实现为

    此外还有另一种转译方式,请自行思考。

  3. do-while 循环

    典型地会被实现为

  4. while 循环

    第一种转译方法是 gcc 在优化等级 -Og 下的转译方式,称为“跳转到中间(jump to middle)”:

    第二种转译方法是 gcc 在优化等级 -O1 下的转译方式,称为“guarded-do”,它实际上将 while 循环转译成了 do-while 循环:

  5. for 循环

    实际上就是 while 循环的语法糖:

    此后和 while 循环的两种转译方式完全相同,不再赘述。

  6. switch-case 分支

    这玩意就比较玄了。书上讲的是跳转表,但是感觉这玩意并没那么普适的样子。

    跳转表最重点的就是形如 jmp *L4(,%rdi,8) 的那一句。它本身实际上就是在连续的地址(此处是 %rdi 指向的)上又存放了一些地址,然后用前面提到过的 jmp *Operand 间接跳转过去。

函数调用 & 栈 & 数组/指针运算

感觉好水啊,对着书看五分钟完事了吧。

结构体对齐与空间

对齐规则:K 字节的基本对象的地址必须是 K 的倍数;结构体本身亦需要一定的对齐量满足所有成员的对齐要求。

要使结构体所占空间最小,贪心地将所有字段按照大小降序排序即可。

当然实际应用中可以手动扰动或者对齐一下,在不更优(但也不一定更劣)的空间下让内存布局更好用一点。

7. 链接

section

强弱符号

重定位

8. 异常控制流

异常

类别原因异步/同步返回行为
中断 interrupt来自 I/O 设备的信号异步返回到下一条指令
陷阱 trap有意产生同步返回到下一条指令
故障 fault潜在可恢复的错误同步返回到当前指令或不返回
终止 abort不可恢复的错误同步不返回

进程控制

10. Linux 系统 I/O 接口