我上周刚把 HNU 的计算机系统第四次课后作业交掉。和前三份作业比起来,计算量其实还好,真正让人头疼的是它逼着你在“数据表示、汇编、链接、Cache”这四个知识模块之间来回横跳。如果你现在也在啃 CSAPP,或者正被学校计算机系统导论课程的课后答案折磨,这篇可以当成一份复习笔记看:我不会替你把所有题目答案都摆出来,但会把每类题该从哪个角度下笔、容易在哪一步翻车、用什么方式自查,尽量说透。
这套作业最有价值的地方在于,它不直接问“补码是什么”,而是给你一段位运算代码问输出;不直接问“寻址方式有哪些”,而是给一段反汇编让你逆推 C 源码;不直接问“链接器怎么工作”,而是构造两个 .c 文件让你判断全局变量最终的值。说白了,就是在模拟一个完整链路:源码如何被编译成汇编,汇编如何被链接成可执行文件,可执行文件运行时又如何访问内存和 Cache。
1. 第四次作业到底在考什么:整体设计思路
1.1 题目范围和课程进度的对应关系
按照计算机系统课程的正常进度,第四次作业一般落在“数据表示 + x86-64 汇编 + 链接 + 内存层次结构”这几章。前几次作业可能还在单项训练,但第四次开始就进入交叉考核了。你会发现同一道题里既要求你会换算补码,又要求你能看懂汇编里的地址计算,还要在最后用 Cache 公式算一次命中问题。
这不是故意为难人,而是这门课的核心目标本来就是让你建立“程序在机器上到底怎么跑”的整体画面。题目把多个知识点串在一起,就是希望你以后看到一段 C 代码时,能自动脑补出它变成汇编、经过链接、最终在内存和缓存里活动的过程。
1.2 做题之前先定好顺序,别一上来就硬算
我的建议是先做“数据表示和位运算”,再做“汇编阅读”,接着做“链接符号解析”,最后解决“Cache 地址计算”。原因很简单:
数据表示是后续所有题目的底色。比如汇编里出现leaq (%rdi,%rsi,4), %rax,你得先理解寄存器里存的可能是无符号数、有符号数还是地址;链接题里判断全局变量覆盖关系,也要先知道变量的类型和初值在目标文件里是怎么记录的。
汇编题目能帮你快速进入“机器视角”。一旦你看惯了寄存器、内存引用、条件跳转,再去理解链接器处理符号的方式就会顺很多。链接过程本质上就是处理目标文件之间的“引用与定义关系”,而目标文件是由汇编器生成的,二者有直接的前后依赖。
所以我每次拿到作业都会先花十分钟把题目扫一遍,把纯计算题标出来先做,把综合题放到后面。这个习惯避免了我反复在“数值表示”和“Cache 地址”之间切换导致的最低级算错。
1.3 工具准备:能省大量体力
我这次做作业主要用了三样东西:GCC、Objdump、GDB。写小片段验证位运算结果时,直接写个 C 文件编译运行;读汇编判断题时,用objdump -d看目标文件反汇编;遇到循环和函数调用逻辑不清楚时,再用 GDB 的单步指令调试看寄存器。其实还有一个更快的办法,就是用在线 Compiler Explorer 写一段 C,不同编译器版本、不同优化级别下生成的汇编都能直接看到。
不过我得提醒一句:验证归验证,作业答纸上不能只写“我编译运行了,结果是这样”。老师想看到的是你对编码规则和底层机制的解释。工具只是帮你确认自己的推理方向没错,不能替代推理本身。
2. 数据表示与位运算:看似送分,实际上最容易看走眼
2.1 有符号数、无符号数混用:位模式没变,解释方式变了
第四次作业里最经典的陷阱题,大概就是给你一段类似这样的代码:
int main(void) { unsigned int u = 0xFFFFFFFFu; int s = (int)u; printf("%u %d\n", u, s); }问输出是什么。答案是4294967295 -1。底层那 32 个 bit 没有发生任何变化,只是printf的格式化符号决定了这串 bit 被解释成无符号数还是有符号数。
很多人会在这一步掉坑,是因为心里默认“强制类型转换会改变底层值”。实际上在整数表示范围内,C 里的有符号与无符号转换通常就是“重新解释位模式”,并不产生额外的存储变化。真正危险的是比较操作:
unsigned int u = 0; if (u < -1) { // 你以为不会执行,实际上会执行 }这里-1被转成无符号数后变成0xFFFFFFFF,所以0 < 4294967295成立。如果你在作业里遇到这类判断,一定要先想清楚:两边都是无符号了吗?混合比较的转换方向是什么?
2.2 移位和位运算的边界:理解“未定义行为”不能靠猜
关于移位,作业里常考两类问题:一是移位方向导致的符号位问题,二是移位数量等于或超过位宽的情况。
比如1 << 31,在很多编译环境里你会得到0x80000000,看起来像INT_MIN。但严格按 C 标准说,有符号整数左移溢出是未定义行为。也就是说,编译器把它优化成什么都有可能。做作业时如果题目没有特别说明“假设使用补码表示且采用算术规则”,一定不要把它当成一个确定结论。
还有个常见的陷阱是移位计数:
int x = 1; int y = x << 32;在一个 32 位 int 上,这个行为是未定义的。不过 x86-64 硬件在做移位时,可能会只取移位量的低 5 位或低 6 位,所以x << 32在机器层面可能等于x << 0。这是硬件行为,不是 C 语言语义。作业里如果出这种题,大概率是想考察你能不能区分“C 语言的抽象规则”和“具体机器的行为”,而不是让你蒙一个输出。
位运算符还有一组经典优先级坑:
if (x & 1 == 0) // 实际会被解析成 x & (1 == 0)==的优先级高于&,所以很多人想表达(x & 1) == 0,却写成了上面这句。写位运算表达式时,宁可多打括号,也不要挑战自己和批改作业人的耐心。
2.3 浮点数:舍入、特殊值、不要做等值比较
浮点数题目在第四次作业里通常不会缺席。常见考法是给你一个 IEEE 754 单精度浮点数的十六进制位模式,让你写出它表示的十进制值。
举个最基础的例子:
float f = 0x3F800000; // 这不是 C 里直接赋值,我这里只表示位模式0x3F800000的符号位为 0,阶码字段是0x7F,换算成十进制是 127,减去偏置 127 得到指数 0,尾数字段为 0,所以这个数就是1.0f。
作业里如果出现非规格化数,也要会算。比如单精度浮点数中,指数位全 0 时表示非规格化数,最小的正非规格化数要按2^-149来算。很多人第一次算这个值都会卡住,因为从位模式看尾数只有 23 位,但别忘了非规格化数已经隐含了指数2^-126,再乘上尾数最低位对应的2^-23,才是最终的2^-149。
还有一道很常见的判断题:
float a = 0.1f; float b = 0.2f; float c = a + b;问c == 0.3f是否成立。答案是不成立。0.1、0.2、0.3 在二进制里都是无限循环小数,转成 IEEE 754 时会各自舍入,运算结果还会再舍入一次,最后得到的值和字面量 0.3 的最近浮点表示并不一致。所以浮点数比较要用误差范围,或者干脆避免等值比较。
3. 汇编阅读:从指令码逆推 C 逻辑
3.1 先锁定寄存器、内存访问、条件和跳转四类信息
反汇编阅读题最容易让人懵的一点,是看到一个函数的一大串汇编就不知道从哪里看起。我的习惯是先做信息提取,别急着理解每一行。
先看参数用什么寄存器传进来。x86-64 的整数参数通常按顺序使用rdi、rsi、rdx、rcx、r8、r9,返回值放在rax。然后看函数里有没有栈指针调整,有的话说明可能在调用别的函数或需要保存局部变量,没有的话说明逻辑比较直。
接着看内存访问指令。movq (%rdi), %rax和leaq (%rdi), %rax看起来很像,但前者是从内存读值,后者只是计算地址。如果题目问“哪个指令访问了内存”,leaq绝对不能选。
最后看条件跳转。cmp和test会改变条件码,紧随其后的je、jne、jle、jg等决定程序走哪条路径。把条件跳转标签之间的代码块划分出来,C 里的if、while、for基本就出来了。
3.2 寻址公式:一个通用公式解决所有地址计算
x86-64 的内存寻址常见形式是:
Imm(Reg1, Reg2, Scale) = Imm + Reg1 + Reg2 * Scale其中Scale只能是 1、2、4、8。例如:
9(%rdi, %rsi, 4)表示9 + %rdi + 4 * %rsi。
这句经常被拿来考两个点:一是你能不能从寄存器里存的“地址”和“整数”中正确识别哪个是数组基址、哪个是下标;二是你能不能看出这个表达式到底是一次内存访问还是单纯算术计算。
还有一个易错点:leaq虽然长得很像读取内存,但它实际上不会访问内存,只是把地址计算结果写入目标寄存器。所以下面这种代码:
leaq (%rax, %rax, 2), %rax是在算rax = rax * 3,不是在读数组。
3.3 一个完整的反推示例
假设题目给你这样一段简化后的汇编:
my_max: cmpq %rsi, %rdi jle .L2 movq %rdi, %rax jmp .L3 .L2: movq %rsi, %rax .L3: leaq (%rax, %rax), %rax ret这个汇编对应的 C 逻辑可以这样推:
rdi和rsi是参数 a 和 b。cmpq %rsi, %rdi会计算rdi - rsi。如果结果小于等于 0,也就是a <= b,那就跳转到.L2,此时选b作为结果;否则选a。选出来之后,.L3处用leaq (%rax, %rax), %rax把结果乘以 2。所以它对应的是:
long my_max(long a, long b) { long t = a > b ? a : b; return t * 2; }这里最有迷惑性的是leaq (%rax, %rax), %rax。看见括号条件反射地以为在访问内存,那就错了。它只是在计算rax + rax,也就是乘以 2。这类题做得多了就会发现,leaq在编译器眼里就是一个“不用额外一条指令的加法乘法组合器”。
3.4 遇到循环时怎么读
循环在汇编里的特征很固定:一个比较指令控制跳转回某个入口标签,循环体在标签和比较之间反复执行。例如:
.Loop: addq $1, (%rdi) addq $4, %rdi cmpq %rsi, %rdi jb .Loop这段代码先给rdi指向的内存值加 1,然后让rdi向后移动 4 个字节,相当于 C 里的p++,比较是否还小于rsi指向的结束位置。它对应的循环大概是:
while (p < end) { (*p)++; p++; }读循环时我习惯先找“退出条件”,再回头看“循环体做了什么”。只要把cmp和jb/jge这一对找出来,循环框架就完成了,剩下的都是往框架里填细节。
4. 链接与符号解析:多个文件放一起才是真正的坑
4.1 强符号、弱符号对最终值的影响
链接题是第四次作业里区分度最大的一块,因为很多人平时写代码都是单个文件,根本没遇到过两个文件里定义了同名全局变量会怎么样。
C 语言里,初始化的全局变量定义是“强符号”,未初始化的全局变量定义是“弱符号”。链接器的规则是:出现多个同名强符号直接报错;强符号和弱符号共存时,选择强符号;多个弱符号共存时,选择一个随机的或者由链接器决定。
比如a.c里写:
int x = 5;b.c里写:
int x;两个文件一起链接时,x最终会指向a.c里那个强符号,所以值是 5。但如果你在b.c里也初始化了x = 10,那就是两个强符号冲突,链接阶段就会报multiple definition错误。
这里有一个实际调试验教训:别只看源文件里写了什么,还要看编译参数。如果你用-fno-common编译,很多本来的“弱符号合并”行为会变成链接错误。作业里如果让你判断“能不能链接成功”,记得把编译选项也放进判断范围内。
4.2 链接器不看类型,只看符号名
链接器在处理跨文件引用时,本质上关心的是“这个名字有没有定义”。它对类型的检查很弱,甚至可以说基本不管。所以一个文件里声明:
int foo(char *s);另一个文件里定义:
int foo(int x) { return x + 1; }链接器通常不会拦你,因为符号名都是一样的foo。但程序跑起来之后,调用方式完全不匹配,可能拿到一个莫名其妙的返回值,甚至直接崩溃。
这就是为什么做链接题时不能只看“有没有报错”,还要去理解符号解析只负责把引用和定义对上,类型一致性是编译器的职责。一旦跨文件,编译器各看各的,这个检查就漏掉了。
4.3 静态库的链接顺序:命令顺序不是玄学
第四次作业如果考到链接,大概率还会配一道关于静态库顺序的判断题。最常见的是:
gcc main.o -lm -o app和
gcc -lm main.o -o app第一种通常没问题,第二种可能报“undefined reference to sin”之类的错误。原因在于链接器是顺序扫描目标文件和库的。它一边扫描一边维护一个“当前还没解决的符号表”。当扫描到静态库时,只有库里的某个目标文件能解决当前未解决符号,链接器才会把它拉进来。
如果-lm在main.o前面,扫描到libm.a的时候,链接器还不知道main.o里需要sin,自然不会提取数学库里的相关目标文件。等扫完main.o发现有未解决的sin,已经不会再回头去重新扫一遍libm.a了。所以静态库一般建议放在源文件或目标文件后面,实在不行可以用--start-group和--end-group包起来,但这属于进阶处理,作业题里用不到。
5. 内存地址与 Cache:公式都会背,但字段位常取错
5.1 直接映射 Cache 地址划分实例
Cache 计算题看起来就是套公式,但每次都会有人取错位。先看一个典型题目思路。
假设一个直接映射 Cache,共有 64 个缓存行,每行 32 字节,主存地址为 32 位,访问地址0x12345678。求 tag、set index、block offset。
第一步确认位宽:
- 块内偏移位数 = log2(32) = 5
- 组索引位数 = log2(64) = 6
- 标记位数 = 32 - 5 - 6 = 21
第二步按位切地址。0x12345678的低 5 位是块内偏移:
0x12345678 & 0x1F = 0x18所以 block offset 是0x18,也就是十进制 24。
第三步把地址右移 5 位后取低 6 位:
(0x12345678 >> 5) & 0x3F = 0x33set index 是0x33,对应十进制 51。
剩下的高 21 位就是 tag,0x12345678 >> 11 = 0x2468A。
这里最容易出错的地方是把组索引和块内偏移搞反,或者直接用整个地址低位当作 offset。还有个细节:如果题目给的是“64 个缓存行”而不是“64 组”,你要先想清楚关联度。只有直接映射时,行数才等于组数。对于 2 路组相联,组数等于缓存行数除以 2,组索引位数也要相应减少。
5.2 局部性怎么影响实际运行
Cache 题不光是计算,有时候会给两段循环问你哪段执行更快。这种题考察的是空间局部性和时间局部性。
比如一个1024 x 1024的二维整型数组,按行访问:
for (i = 0; i < N; i++) for (j = 0; j < N; j++) sum += a[i][j];这种写法每次往后访问相邻的 4 个字节,一个 64 字节的缓存行能装下 16 个 int。第一次访问某行某列时可能发生一次缺失,但紧接着的 15 个数据都能命中。时间局部性也还不错,外层循环再次回到同一行时,距离前面访问还没有太久。
如果换成按列访问:
for (j = 0; j < N; j++) for (i = 0; i < N; i++) sum += a[i][j];每次访问都跳到下一行的同一列,间隔是N * 4字节,也就是 4096 字节。一个缓存行里只取了一个 int,剩下的全部浪费,而且每跳一次大概率都是缓存缺失。这个对比在作业里通常要求你说明“为什么”,核心就是讲清楚缓存行大小、数组元素大小和访问步长三个量之间的关系。
5.3 组相联和全相联的换算要点
关于 Cache 映射方式,最好自己整理成一张速查表:
| 映射方式 | 组数 / 集合数 | 组索引位数 | 判断命中时要比较的标记数 |
|---|---|---|---|
| 直接映射 | 缓存行数 | log2(行数) | 1 个 tag |
| n 路组相联 | 缓存行数 / n | log2(组数) | 同一组里 n 个 tag 中匹配一个 |
| 全相联 | 只有 1 组 | 0 位 | 全部行 tag 都要比 |
做组相联题时,大家最容易遗漏的是“每组有几行”会影响 tag 比较数量,但不影响偏移位和组索引位数。组索引位数只取决于组的总数,而不是缓存行总数。
6. 交作业前的自查清单与实测技巧
6.1 五分钟自查五条硬规则
我每次做完一套计算机系统作业,都会按下面五条重新扫一遍,能拦下大部分低级失误:
- 每个整数题目里,我都明确标注了它是有符号数还是无符号数吗?
- 浮点数计算结果有没有用过等号去比较?
- 汇编题里,
leaq和movq的内存访问语义有没有区分开? - 链接题里,强符号、弱符号、静态库顺序三个因素我都考虑了吗?
- Cache 地址切分时,offset、index、tag 对应的位段我都换算成二进制核对了吗?
这五条看着基础,但第四次作业的批改往往就是按这些关键点给分。前面的步骤错了,后面算得再热闹也拿不到分。
6.2 用编译器验证小片段,但别依赖线上答案
如果对某个位运算结果不放心,最快的方式是写一个最小 C 文件,编译运行看一眼输出。你也可以把一段 C 用gcc -S -O0生成汇编,和题目里给的汇编做对比,确认寄存器分配和条件跳转逻辑。这里我建议优先用-O0,因为-O1以上会把很多计算折叠起来,比如直接算出常量,反而不利于对照阅读。
网上能找到不少“计算机系统导论课后答案”,但它们只能帮你对答案,不能帮你理解为什么。实际做题时,把每个答案对应的原理写出来,比抄十个答案更有价值。尤其是第四次的链接和 Cache 题,稍微改一个参数,网上的答案就完全没法用了。
6.3 最后说一点个人习惯
我做过几次这种综合作业之后最大的体会是,不必把所有汇编指令背下来。真正有用的是画“数据流”:值从哪里来,存在哪个寄存器,要不要访问内存,条件码怎么影响跳转。把这个流程想清楚,不管题目怎么变,都能拆出同一个骨架。
这套作业做完,你对“一个 C 文件是怎么变成机器上跑的进程”这件事,应该会有一个明显更完整的画面。以后再看到奇怪的 Bug,至少能分清楚它是出在位层面、指令层面、链接层面,还是 Cache 层面。这个判断力,可能才是这份作业真正想留给你的东西。