Sept 9, 2024 edition
写来尝试造福学弟学妹们。很惭愧,只是想要做一点微小的工作。
内容仅包含北京邮电大学《计算机系统基础》课程的讲授和期末考核内容的一部分,并不严格包含 CSAPP 一书中的完整章节。
CSAPP 是一本优秀的书,但其中也有些内容过于理论化、已经过时,也难免出现错误(CSAPP 英文原书第三版官方勘误(尚在不断补充中)),其中文译本也存在翻译水平一言难尽导致难以阅读的问题。瑕不掩瑜,我们仍然推荐大家使用本书学习,本文即是尝试为学习这一册书的后来的朋友提供些许帮助。
CSAPP 的第一部分以 C 语言为媒介过渡到了 x86-64 汇编这一更低的抽象层级,这个过渡方式本身是没有问题的;然而 C 语言本身并非只为了更低级别的抽象服务。书中一系列
“C 语言中负数采用补码”
“整数右移采用算术右移”
“int x = foo(); x * x >= 0
有可能返回假”
“char
有符号”
等论断并不一定符合 C 语言的客观实际,其中有些没有遵守 C 语言规定但符合 x86-64 实际,有些两者都不一定符合,有些在应用层级的 C 语言中会被直接优化成期望之外的行为。笔者认为应当将书中采用的 C 语言与现实中的 C 语言分开看作两门语言,它们的语法一致,但在某些情况下的行为不应混淆。这一点非常重要,所以在前言中指出。
笔者水平有限,无法保证全文正确且易懂;欢迎勘误或改进,提 issue 请通过邮件。
感谢阅读。
Sept 9 2024 注:不久的将来笔者可能会编写一份 pdf 版的本指南。
无符号数就是二进制位加权求和,权是
补码有符号数的二进制最高位是符号位。它和无符号数的区别只在于符号位的权取相反数。
这就是全部,就这么简单。
一个显然的推论:补码有符号负数向无符号数转换时,加符号位权的绝对值的二倍即可。
CSAPP 在这里故弄玄虚。实际上的规则很简单,大体包含了类型系统、整数常量(字面量)、隐式转换三部分。
我认为更应该读 C++ 的材料。我觉得它相比 C 的易读很多,并且其中两门语言的重合部分中 80% 是适用 C 语言的。
算术运算:https://zh.cppreference.com/w/cpp/language/operator_arithmetic
整数常量、字符常量(C 语言条目):https://zh.cppreference.com/w/c/language/integer_constant, https://zh.cppreference.com/w/c/language/character_constant
整数提升和整数转换:https://zh.cppreference.com/w/cpp/language/implicit_conversion#.E6.95.B4.E6.95.B0.E6.8F.90.E5.8D.87
比较运算:https://zh.cppreference.com/w/cpp/language/operator_comparison
整数的类型系统大体就是说,整数类型的排序是 char < short < int < long < long long
,其中每种类型的 signed
版本低于 unsigned
版本。
字面量的默认类型是 int
,如果不能容纳会向上尝试更高的类型。你也可以在字面量中添加后缀指定类型,例如 -1u
。
有几点注意
CSAPP 全书暗戳戳地将 char
当作了 signed char
来讲解而未加任何说明。实际上 char
是非常特殊的:char
, signed char
, unsigned char
是相互独立的三个类型,在不同机器上、不同编译选项下,char
的行为可以是后两者中的任何一个(但并不意味着名义上是同一个类型)。
特殊在哪里呢?对于其他不特殊的所有整数类型,比如 int
,int
和 signed int
是同一个类型的两个名字。
long
在现行 64 位 Windows 的数据模型下是 32 位整数,而 64 位 Linux / macOS 的数据模型下是 64 位整数。而 CSAPP 全书大言不惭采用了后者,默认它是 64 位整数。
这意味着如果你要在自己的 Windows 机器上运行书中的代码,应该果断使用 long long
替换 long
。很多同学似乎没能独立意识到这一点。
long long
实际上是 C99 / GNU C++98 / ISO C++11 才有的类型。我们现在决不应该使用上世纪的语言标准 C89 和 C++98 了。笔者写作时最新的语言标准是 C23 和 C++23。
对于除移位以外的算术运算,首先会将所有低于 int
的操作数提升到 int
,称之为“整数提升”(狭义)。
比如你如果用 C++ 的 std::cout
输出 'A' + ' '
,会发现结果是 97
而不是 'a'
。
C 也是一样,但是不太容易观测,你可以尝试求值表达式 (((short)1 + (short)2) & 0.)
,编译器的 error 会告诉你找不到 int
和 double
之间的运算符 &
(而非 short
和 double
之间的)。不再用字符常量举例是因为 C 语言中的单字节字符常量(比如 'A'
)本身就是 int
类型的。
接着,如果两个操作数类型仍然不同,会将其中较低的类型隐式转换到较高的。
比如 -1u - 114514
的结果是 unsigned int
类型的 4294852781
,而不是 -114515
。
比较运算的操作数如果是算术类型(整数类型当属其中),也会实施如上所述的算术转换。
移位运算不会实施上述的算术转换。此外,考试中若不加说明,我们一般认为 >>
会发生算术右移,但是将来若你在实际环境中遇到这个问题,行为如何还需具体测试。
接着整数类型说。C 有三种浮点类型 float < double < long double
,隐式类型转换中它们均高于任何整数类型。
这并不意味着它们的精度更高,实际上 float
在 32 位整数范围内保存整数的精度甚至不如 int
。
double
的精度严格优于 int
和 float
。
long double
是个实现强相关的类型,它占用的字节数和结构不一定是典型的 64 位 IEEE 浮点数,也不一定是 80 位或 128 位,我们略去不表。
和大多数编程语言一样,C 语言采用 IEEE 754 规定的浮点数,其中 float
和 double
分别采用 32 位(单精度)和 64 位(双精度)的 IEEE 浮点数典型标准。
IEEE 浮点数的实际值以
符号
阶码
没有来源,我自己 YY 的,但这真的很形象啊。
这个网站在实现 64 位 IEEE 浮点数可视化的同时捎带着做了小数点浮动的动画,推荐(需要**):https://bartaz.github.io/ieee754-visualization/
尾数
典型地,IEEE 单精度和双精度浮点数分别拥有以下的结构:
我们在 exponent 和 significand 的分界处放置一个小数点,左侧各位的权依次为
根据
规格化的值,适用于
令
此时
非规格化的值,适用于
此时
非规格化的值用于表示非常接近
特殊值,适用于
此时,若
nan == nan
)全部返回假,唯一的例外是 nan != nan
返回真。
众所周知硬件计算乘法远慢于加法、减法和移位。
尝试用加减和移位优化计算常量
注意:现实生活中你也许应该使用 Intel 助记(如果你真的在现实中用到的话,你会知道为什么的);但是这本书从头到尾用的都是 at&t 助记,所以下面的内容都使用 at&t 助记。
16 位和 8 位的那些真**难背啊,背这玩意干啥啊。
l
结尾的是 8 位的,否则稀奇古怪的是 16 位的。r
开头的是 64 位的,e
开头的是 32 位的。
我们使用 64 位的名字称呼它们。
前 6 个参数分别是 %rdi
, %rsi
, %rdx
, %rcx
, %r8
, %r9
;返回值是 %rax
。
%rsp
是栈指针;%rbx
, %rbp
, %r12
~%r15
是 callee-saved,剩下的是 caller-saved。
CSAPP 一书中涉及到了以下四个位:
CF:最高位进位
ZF:零
SF:符号位(负数)
OF:溢出(上溢和下溢)
所有的算术运算指令会根据其结果设置这些寄存器位的值。
语法 | 数值 |
---|---|
$I | |
R | |
I | |
(R) | |
I(R) | |
(Rb,Ri) | |
I(Rb,Ri) | |
(,Ri,s) | |
I(,Ri,s) | |
(Rb,Ri,s) | |
I(Rb,Ri,s) |
就是说操作数大小不一定的指令可以加 b
, w
, l
, q
指示字节数。 b
是字节 byte,w
是字 word,l
是双字,q
是四字。
其实还有少量命令以 o
结尾,处理八字,下文中会提到这么一个。不过不算严格意义上的后缀。
感觉可能其实有挺多人更习惯用 Intel 汇编的方式啊,况且 at&t 汇编也不是一定要加这个后缀的。
不过考试要考。
mov S, D
存储 source 的值到 destination。
mov
指令的 source 操作数若为立即数最多为 32 位。若需传送 64 位整数,有 movabsq I, R
指令。
movz** S, R
以零扩展传送。没有 movzlq
,因为 movl
本来就会把高位置零。
注意操作数是 R
的时候基本都是指寄存器,就像 I
都指立即数一样。
movs** S, R
以符号扩展传送。
cltq
没有操作数,等价于 movslq %eax, %rax
。专门搞出一条指令是为了省空间。
pushq S
subq $8, %rsp; movq S, (%rsp)
的语法糖。
popq D
movq (%rsp), D; addq $8, %rsp
的语法糖。
leaq S, D
名义上是加载内存地址,实际上可以让寻址表达式 S
只计算地址而不寻址然后存储给 D
。可以拿来做运算,但不在算术运算指令之列。
一堆算术运算指令
指令 | 效果 |
---|---|
inc D | D <- D + 1 |
dec D | D <- D - 1 |
neg D | D <- -D |
not D | D <- ~D |
add S, D | D <- D + S |
sub S, D | D <- D - S |
imul S, D | D <- D * S |
xor S, D | D <- D ^ S |
or S, D | D <- D | S |
and S, D | D <- D & S |
sal S, D , shl S, D | D <- D << S |
sar S, D | D <- D >> S |
shr S, D | D <- D >>> S |
注意移位指令 sal
, shl
, sar
, shr
,它们的第一个操作数只能是立即数或寄存器 %cl
。
CSAPP 书中称指令后缀分别为
b
,w
,l
,q
时,移位量分别采用第一个操作数(立即数或%cl
)的最低 3, 4, 5, 6 位。但是笔者经过实际测试和查阅文档,认为移位量分别会采用其最低 5, 5, 5, (5 或 6) 位,参见 https://godbolt.org/z/K1Wxh8fWv, https://www.felixcloutier.com/x86/sal:sar:shl:shr#description 和 Intel® 64 and IA-32 Architectures Software Developer’s Manual Vol. 2B 4-594。Sept 9 2024 注:上面两个链接指向的内容经常变化,导致给出的链接和索引不一定有效。有兴趣了解的同学请自行寻找相关文档。
几个特殊的算术运算指令,共同点是用到一对 64 位寄存器 %rdx:%rax
组成 128 位的空间。
clto
没有操作数,将 %rax
符号扩展到 %rdx:%rax
,类似于 cltq
。q
和 o
分别代表四字 quad-word 和八字 oct-word。
imulq S
:%rdx:%rax <- S * %rax
,有符号乘法。
这里的 imulq
指令只有一个操作数,与上表中的不同。
mulq S
:%rdx:%rax <- S * %rax
,无符号乘法。
imulq
和 mulq
的区别在于如何对待操作数的符号位。
idivq S
:%rdx <- %rdx:%rax mod S; %rax <- %rdx:%rax / S
,有符号除法。
divq S
:%rdx <- %rdx:%rax mod S; %rax <- %rdx:%rax / S
,无符号除法。
两个只设置条件码的指令
cmp S1, S2
:计算 S2 - S1
的值并设置条件码。
典型地,该指令的意义是将 S2
与 S1
比较,也即“setg
会判断 S2
是否大于 S1
”,而不是相反。
test S1, S2
:计算 S2 & S1
的值并设置条件码。
典型地,S1
和 S2
相同时表现为将其与 0 比较。
set
:使用条件码的指令
指令 | 效果(不要死记) | 描述 |
---|---|---|
sete D , setz D | D <- ZF | 相等 / 零 |
setne D , setnz D | D <- ~ZF | 不相等 / 非零 |
sets D | D <- SF | 负数 |
setns D | D <- ~SF | 非负数 |
setg D , setnle D | D <- ~(SF ^ OF) & ~ZF | 有符号大于 |
setge D , setnl D | D <- ~(SF ^ OF) | 有符号大于等于 |
setl D , setnge D | D <- SF ^ OF | 有符号小于 |
setle D , setng D | D <- (SF ^ OF) |ZF | 有符号小于等于 |
seta D , setnbe D | D <- ~CF & ~ZF | 无符号大于 |
setae D , setnb D | D <- ~CF | 无符号大于等于 |
setb D , setnae D | D <- CF | 无符号小于 |
setbe D , setna D | D <- CF | ZF | 无符号小于等于 |
set
指令接受的操作数是 8 位寄存器。若条件为真,将其值设为 0000 0001
,否则设为 0000 0000
。
CSAPP 原书对此含混其词,仅说“将字节设为 0 或 1”。
jmp
:跳转指令
同样使用条件码,且有和 set
相同的变体 je
, jne
等。
jmp Label
直接跳转,jmp *Operand
从寄存器或内存中取地址间接跳转。
当执行 pc 相对寻址时,pc 的值是跳转指令之后一条指令的地址。因此跳转指令的编码的相对地址要使用下一条指令的地址进行计算。
cmov
:条件传送指令
同样使用条件码,且有和 set
相同的变体 cmove
, cmove
等。
题外话:CSAPP 中关于分支预测的一系列结论已经不完全适用现代 CPU 了。它们应对这种小小分支的能力超出这本书的想象,低抽象下的开发者没有必要担心性能下降而用三目运算符替代 if-else
控制流(高抽象下当然更没有必要)。万一实际中遇到非常复杂的情况,你也可以/应该现场 benchmark 一下。
goto
我们不管这个东西。它和裸汇编已经差不太多了。下面所有控制流都可以说是 goto
的语法糖。
if-else
分支
xxxxxxxxxx
if (condition) {
then_statement;
} else {
else_statement;
}
典型地会被实现为
xxxxxxxxxx
if (!condition)
goto false_tag;
then_statement;
goto done;
false_tag:
else_statement;
done:
此外还有另一种转译方式,请自行思考。
do-while
循环
xxxxxxxxxx
do {
statement;
} while (condition);
典型地会被实现为
xxxxxxxxxx
loop:
statement;
if (condition)
goto loop;
while
循环
xxxxxxxxxx
while (condition) {
statement;
}
第一种转译方法是 gcc 在优化等级 -Og
下的转译方式,称为“跳转到中间(jump to middle)”:
xxxxxxxxxx
goto test;
loop:
statement;
test:
if (condition)
goto loop;
第二种转译方法是 gcc 在优化等级 -O1
下的转译方式,称为“guarded-do”,它实际上将 while
循环转译成了 do-while
循环:
xxxxxxxxxx
if (!condition)
goto done;
loop:
statement;
if (condition)
goto loop;
done:
for
循环
xxxxxxxxxx
for (initialisation; condition; increment) {
statement;
}
实际上就是 while
循环的语法糖:
xxxxxxxxxx
{
initialisation;
while (condition) {
statement;
increment;
}
}
此后和 while
循环的两种转译方式完全相同,不再赘述。
switch-case
分支
Sept 9 2024 注:好像是考试的重点,过太多年了记不太清了。
这玩意就比较玄了。书上讲的是跳转表,但是感觉这玩意并没那么普适的样子。
跳转表最重点的就是形如 jmp *L4(,%rdi,8)
的那一句。它本身实际上就是在连续的地址(此处是 %rdi
指向的)上又存放了一些地址,然后用前面提到过的 jmp *Operand
间接跳转过去。
感觉好水啊,对着书看看吧。
对齐规则:
要使结构体所占空间最小,贪心地将所有字段按照大小降序排序即可。
当然实际应用中可以手动扰动或者对齐一下,在不更优(但也不一定更劣)的空间下让内存布局更好用一点。
也许现实中应该使用更现代的语言(没错说的是 Rust),它会自动采用最优的内存布局。
.text
已编译程序的机器代码
.data
已初始化的全局和静态变量
.bss
未初始化的全局和静态变量
COMMON 未初始化的全局变量;.bss
未初始化的静态变量以及初始化为 0 的全局或静态变量
.symtab
定义和引用的函数和全局变量
ABS 不应该被重定位的符号
UNDEF 未定义的符号
COMMON 还未被分配位置的未初始化的数据目标
强符号:函数和已初始化的全局变量
弱符号:未初始化的全局变量
xxxxxxxxxx
for s in sections {
for r in relocation_entries {
let refptr = (s + r.offset) as *mut usize;
*refptr = match r.relocation_type {
R_X86_64_PC32 => {
let refaddr = addr(s) + r.offset;
addr(r.symbol) + r.addend - refaddr
},
R_X86_64_32 => addr(r.symbol) + r.addend,
_ => unimplemented!(), // 不在课程讲解范围内
}
}
}
类别 | 原因 | 异步/同步 | 返回行为 |
---|---|---|---|
中断 interrupt | 来自 I/O 设备的信号 | 异步 | 返回到下一条指令 |
陷阱 trap | 有意产生 | 同步 | 返回到下一条指令 |
故障 fault | 潜在可恢复的错误 | 同步 | 返回到当前指令或不返回 |
终止 abort | 不可恢复的错误 | 同步 | 不返回 |
Sept 9 2024 注:忘了考试考得多不多了。不过之后的 OS 课你们再次见到它们的。
xxxxxxxxxx
pid_t fork(void); // 子进程返回 0;父进程返回子进程 pid,出错返回 -1
xxxxxxxxxx
pid_t waitpid(pid_t pid, int *statusp, int options); // 成功返回子进程 pid,WNOHANG 返回 0,出错返回 -1
pid_t wait(int *statusp); // wait(&status) 等价于 waitpid(-1, &status, 0);
xxxxxxxxxx
ssize_t read(int fd, void *buf, size_t n); // 成功返回读的字节数,EOF 返回 0,失败返回 -1
int dup2(int oldfd, int newfd); // 成功返回非负的描述符,失败返回 -1