CS:APP 第三章练习题,是我每次给学弟学妹答疑时被问得最多的一块。原因很简单:第三章《程序的机器级表示》一上来就把汇编、寄存器、栈帧、条件码全砸在你脸上。很多人C语言还写得明明白白,可一想到自己写的那几行 if、while、struct 到机器层面是这么跑的,整个人就懵了。而这一章的练习题,恰好把这些“常识崩塌点”变成了一道道具体问题:给你一段汇编,让你还原 C 代码;给你一个栈布局,让你说出局部变量在哪;给你一个结构体,让你算它的偏移和对齐。
这篇分享不想帮你抄答案,而是想告诉你刷这一章练习题的“正确姿势”。我会把练习题背后真正要考的东西掰开揉碎,按题型给出通用解题流程,再用几个模拟题带你完整走一遍分析过程。适合正在啃教材的学生、想补底层基础的开发者,以及准备系统方向面试、想验证自己是不是真懂计算机的人。
1. 刷第三章练习题前,先搞懂这章到底在考什么
1.1 这章练习题的“底层考点地图”
《深入理解计算机系统》第三章讲的是 x86-64 体系结构下的机器级编程,也就是汇编语言层面的程序表示。章节练习题数量多、跨度大,但它从来没有脱离几条主线。
我理解的主线有四条:第一,数据的表达与移动,包括字节、字、双字、四字这些规格怎么选,寄存器怎么分配,mov 指令的 b/w/l/q 后缀怎么读,寻址模式怎么计算有效地址,符号扩展和零扩展有哪些区别。第二,算术与逻辑操作,重点是 leaq 这种“假寻址真运算”的用法,以及加减、位操作、移位如何映射到 C 表达式。第三,控制结构的机器实现,包括条件码、cmp/test、set 指令、jmp 系列、循环翻译成汇编的标准模板、switch 跳转表。第四,过程调用与复杂数据布局,包括参数传递规则、返回值位置、栈帧结构、callee-saved 与 caller-saved 寄存器的分工、递归;数组的地址计算、struct 和 union 的字段偏移、对齐规则;浮点寄存器与指令。
练习题基本都是围绕这四条主线来出的,只是呈现方式各不相同。我刚开始刷的时候,最大的误区是把每道题当成独立知识点去背,后来才发现它们全是一张“程序执行图”的不同切片。第二个误区是光看不动手。这一章靠读是读不懂的,你必须自己把 C 代码编译成汇编,再反过来从汇编推断 C,形成双向映射,才算真正建立语感。
1.2 练习题的四种常见“面孔”
我把这一章的练习题归纳成四种面孔。识别出题型,你就知道该调用哪套解法。
第一种是概念计算题。给你一组寄存器和内存状态,让你计算某个寻址模式生成的地址,或者判断某条指令有没有副作用。这类题考基本功,看起来简单,但后缀看错一个字母,结果就会差一个数量级。比如区分movl %eax, %edx和movl %eax, (%rdx),前者拷的是寄存器值,后者写的是内存。
第二种是 C 代码还原题。这是第三章数量最大也最经典的题型:给一段函数汇编,让你写出对应的 C 语言函数体。它考的是你能不能从指令序列中识别出 if 条件、循环结构、switch 分支、参数位置、局部变量布局这类高层信息。做这类题的关键是“按序识别模式”:先看函数入口和出口,确认参数寄存器;再看比较指令和跳转指令确定分支结构;最后根据算术指令还原表达式。
第三种是栈帧与布局题。题目可能会给你一个函数的 prologue 和 epilogue,让你画出栈帧图,找出局部变量在栈里的偏移,或者判断某个偏移位置对应什么类型。这类题需要你熟记 rsp 和 rbp 的分工,以及 16 字节对齐规则。我通常建议先把栈底到栈顶的示意画出来,把已知的槽位填上,再去补未知项,比盯着汇编硬想要清晰得多。
第四种是综合实现题。比如给定一个数组或结构体访问的汇编片段,让你反推类型定义;或者给定跳转表,让你恢复 switch 结构。这类题把数组、结构体、控制流全部揉在一起,是章节末尾的高强度题。做这种题,我的习惯是分屏对比:左边贴汇编,右边自己写 C,逐块对应。
| 题型 | 常见提问方式 | 核心能力 |
|---|---|---|
| 概念计算 | 该寻址模式生成什么地址?执行后结果多少? | 指令与寄存器的机械准确性 |
| C代码还原 | 这段汇编对应什么 C 函数? | 模式识别与逆向表达 |
| 布局推断 | 局部变量在栈中哪个位置?字段偏移是多少? | 栈帧与对齐规则理解 |
| 综合实现 | 恢复原 C 函数/结构体定义 | 多维知识点整合 |
2. 核心知识点拆解:这几个“坎”不过,题刷再多也白搭
2.1 寄存器、后缀和寻址模式是基本功中的基本功
x86-64 有 16 个 64 位通用寄存器。做题时重点关注 rax、rbx、rcx、rdx、rsi、rdi、rsp、rbp,以及 r8 到 r15。每个寄存器都有 32 位、16 位、8 位的子名称,练习题特别喜欢考你:一条指令到底改了寄存器的哪一部分。
这里有一条机械规则必须刻在脑子里:指令只修改它后缀指定的那一部分,而且写入 32 位寄存器时,高 32 位会被自动清零;但写入 8 位或 16 位寄存器,不会动高位。这个细节在分析movl和movq造成的差异时极其重要。举个例子,假设%rax里原本存了一个 64 位数0xFFFFFFFFFFFFFFFF,执行movl %eax, %ecx之后,rcx 会变成0x00000000FFFFFFFF;但如果执行movw %ax, %cx,rcx 的高 48 位原封不动。还原 C 代码时看到movl %eax, %eax,要知道这是在利用写 32 位寄存器清零高位的特性做零扩展。
寻址模式的有效地址计算公式是:Imm(rb, ri, s)对应Imm + R[rb] + R[ri] * s,比例因子 s 只能是 1、2、4、8。做概念题时不要一上来就算,先判断有没有基址、有没有变址、比例因子是否合法。我记得一个特别容易错的地方:当操作数大小是 b 时,读取寄存器仍然读完整的 64 位,只是写入时只写低 8 位。你在纸上模拟执行时,必须把“读完整、写局部”写进状态表,否则后面步骤全错。
我随手编一个小例子,你感受下:假设%rax保存地址0x100,地址0x100处存放双字0x47,地址0x108处存放双字0x13。那么movl 8(%rax), %edx的有效地址是8 + 0x100 = 0x108,执行后%edx变成0x13,而%rax本身不变。这里最容易错的是把8(%rax)和(%rax)混为一谈。
另外,leaq 是第三章练习题里的常客。它不访问内存,只把地址计算的结果写入目标寄存器。C 代码里常见的x*3、x*5、x*7这类乘法,编译器经常用 leaq 展开。比如leaq (%rdi, %rdi, 2), %rax就是rdi*3。看到 leaq 不要按 mov 去解读,它是算术,不是取数。
2.2 条件码、cmp/test,以及如何恢复 if 和循环
控制类是这一章的重灾区。x86-64 的条件执行依赖 EFLAGS 里的条件码:CF 进位标志、ZF 零标志、SF 符号标志、OF 溢出标志。cmp和test只设置条件码,不写结果;setg、setl、seta、setb这类指令再根据条件码组合把 0 或 1 写入目标寄存器的低 8 位,后面通常跟一个movzbl或movzwl把结果扩展成 32 位或 64 位。这就是 C 语言布尔表达式返回 int 的典型汇编形态。
做“给汇编还原 if 或逻辑表达式”的题,判断顺序是:先找cmp/test的操作数顺序。AT&T 语法里cmpq src2, src1实际是拿src1 - src2,所以看到cmpl %esi, %edi,计算的是edi - esi,也就是比较第一个参数与第二个参数。很多人在这里把方向搞反,导致setg的语义颠倒。判断有符号还是无符号,就看用的是哪一组:setl/setg是有符号,setb/seta是无符号,对应到 C 里的关系运算符就清清楚楚。
举个例子,cmpq %rsi, %rdi后面跟jge .L2,含义是“如果rdi - rsi >= 0就跳转到.L2”,翻译成 C 就是if (x >= y) goto L2;,如果再配合某个分支先返回、另一个分支继续执行,你就能还原出一个完整的 if-else。
循环的汇编形态有固定套路。do-while 会先执行循环体,再在底部做条件判断;while 需要额外加一个跳转处理;for 循环会被改写成 while 再套 do-while 的框架。当你看到汇编底部有一个条件跳转往回跳到某个标签,循环体代码夹在中间,基本就是 do-while 骨架。反过来,如果前面先有一个无条件跳转跳到底部的条件判断,再根据条件跳回循环体,这就是 while 的翻译形态。把这两种形态当成模板背下来,比每次逐条分析快得多。
2.3 栈帧与过程调用:会画栈图,就成功了一半
第三章最关键的体系结构知识是栈帧。调用函数时,调用者把前 6 个整数/指针参数依次放入 rdi、rsi、rdx、rcx、r8、r9,多余参数压栈。被调函数入口通常会做push %rbp; mov %rsp, %rbp,也可能没有帧指针,直接用 rsp 相对寻址。返回值放 rax。被调函数只要用到 callee-saved 寄存器 rbx、rbp、r12-r15,就必须在入口 push、返回前 pop,这是调用约定里谁都不能破坏的规矩。
栈帧题目通常是给函数开头和结尾,让你画栈图。我的实操建议是:先把栈顶和栈底写出来,标出返回地址的位置,再把 push 过的寄存器依次往下放,最后通过变量访问的偏移来推断局部变量的位置。一个容易忽略的点是栈对齐。System V 调用约定要求调用前栈顶按 16 字节对齐,所以函数入口 push rbp 之后,rsp 变成 8 字节对齐;如果后续还要调用其他函数,编译器会先 sub 某个值让 rsp 重新保持 16 字节对齐。分析栈帧时,看到subq $24, %rsp这类操作,不要只当它是分配局部变量,它可能在同时做对齐调整。
画一张简化栈图大概是这样:
高地址 +------------------+ | 调用者栈帧区域 | +------------------+ | 返回地址 | <- 函数入口时的 rsp +------------------+ | 保存的 %rbp | <- 进入后 push %rbp +------------------+ | 局部变量 / 对齐区 | <- 当前 %rsp +------------------+ 低地址把这张图刻在脑子里,后面所有栈帧题都能往里面套。
2.4 数组与结构体:偏移计算是硬功夫
数组访问的核心公式只有一个:A[i]的地址等于A + i * sizeof(元素)。二维数组A[R][C]的元素A[i][j]地址等于A + (i * C + j) * sizeof(元素)。练习题里经常出现类似movl A(,%rdi,4), %eax的指令,比例因子 4 说明元素大小是 4 字节,大概率是 int 数组;比例因子 8 则常见于 double 数组或指针数组。
结构体部分,最重要的是记住对齐规则:每个字段的起始偏移必须是该字段自身大小的整数倍;结构体总大小必须是最大对齐数的整数倍。我在刷题时总结了一个快速填偏移表的方法:从偏移 0 开始,逐个字段判断当前偏移能不能整除字段大小,不能就补 padding。
举例说明,一个结构体包含 char、int、double 三个字段:
- char 在偏移 0;
- int 需要 4 字节对齐,当前偏移是 1,所以要补 3 个 padding 字节,int 在偏移 4;
- double 需要 8 字节对齐,当前偏移是 8,刚好可以,double 在偏移 8;
- 总大小 8 + 8 = 16,16 是 8 的整数倍,所以 sizeof 是 16。
这个计算看起来简单,但一旦字段变成数组或者嵌套结构体,就特别容易漏算。处理嵌套结构体时,先把内层结构体单独算一遍大小和内部偏移,再把它当一个整体放到外层里继续对齐。
2.5 浮点指令入门:别用整数寄存器那套
第三章后半段的浮点部分,难度其实比整数部分低,但概念是全新的。浮点操作使用 xmm0-xmm15 寄存器,宽 128 位,标量操作只用低 32 位或 64 位。movss/movsd对应加载和存储单精度/双精度浮点,addss/addsd、mulss/mulsd对应算术运算。参数传递规则是:浮点参数传 xmm0-xmm7,返回浮点用 xmm0。整数和浮点参数混用时,注意不要在编号上生套整数规则。
浮点比较用ucomiss/ucomisd,它也会设置条件码,但符号标志和进位标志的组合方式和整数 cmp 不一样。常见模式是比较浮点后接ja、jb这类跳转。我在刷这部分的建议是:如果不想深究浮点边界情况,先抓住“无符号序数比较”这一点,把条件码组合背下来,应付还原if (a < b)这类题完全够用。
3. 手把手实操:三个题型模拟精讲
3.1 模拟题一:从一段汇编还原 C 函数
先看一段我最常拿来做示例的汇编,自拟,不是原书原题:
func_a: movl %edi, -8(%rsp) movl %esi, -12(%rsp) movl -8(%rsp), %edx movl -12(%rsp), %eax cmpl %eax, %edx setg %al movzbl %al, %eax ret分析步骤可以拆成三块。第一步,看入参和出参。函数只用 ret 一个返回指令,操作数来自 edi 与 esi,所以它接收两个 int 参数,返回 int。第二步,看比较方向。cmpl %eax, %edx实际是edx - eax,也就是第一个参数减第二个参数;setg表示有符号大于才置 1,因此当第一个参数大于第二个参数时返回 1,否则返回 0。第三步,看扩展。setg只写 al 一个字节,movzbl %al, %eax把 al 零扩展到 32 位,恰好是 C 语言布尔表达式返回 int 的标配。
综合下来,原 C 代码很可能是:
int func_a(int x, int y) { return x > y; }这道题的陷阱有两个:一是不要被“先存栈再读回”迷惑,那是-O0编译的典型输出;二是别把cmpl的方向看反。我在现场做题时会在草稿纸上写一行“cmp 右操作数, 左操作数 -> 左 - 右”,再继续推导,亲测能避免大量低级错误。
3.2 模拟题二:跳转表与 switch 结构还原
switch 是第三章最有挑战性的内容。我用一个小例子说明套路。假设汇编里有这样的片段:
jmp *.L4(,%rdi,8) .L3: movl $100, %eax ret而数据段里有:
.L4: .quad .L2 .quad .L3 .quad .LL3如果跳转表第一个元素对应 case 0,第二个对应 case 1,第三个对应 case 2,那你就能建立“索引 -> 标签”的映射。比如.L3这个分支执行return 100,那么 case 1 就返回 100。之后再看.L2、.LL3分别是 return 什么,就能把整个 switch 还原出来。
完整还原 switch 的实操步骤是:
- 确认跳转目标表的位置和索引寄存器;
- 把表的内容导出成“索引 -> 标签”的映射表;
- 逐个标签分析分支行为,记录每个分支做了什么;
- 把“分支行为”与“索引”组合,还原成 case 语句。
这个流程说起来简单,但练习题经常把连续索引拆成几段,或者带上 default 分支。更阴险的是,编译器会对索引做范围偏移。比如 case 从 1 开始,汇编里可能先sub $1, %rdi再jmp *.L4(,%rdi,8)。看到 jmp 之前有算术指令,一定要追一下这是不是索引范围调整,否则你会把 case 0 和 case 1 对错位。
3.3 模拟题三:结构体偏移与数组访问的综合题
我设计一道综合度稍高的模拟题,看看第三、四、五节的知识怎么串起来。
假设有定义:
struct rec { char first; int score; double weight; };前面我们已经手算过:first 偏移 0,score 偏移 4,weight 偏移 8,结构体总大小 16。如果此时看到一条指令:
movl 8(%rax,%rcx,1), %edx并且 rax 是某个struct rec*数组的基地址,rcx 是索引 i,你可能会下意识认为这是在访问第 i 个元素的 weight 字段。但注意movl只取 4 个字节,而 weight 是 double,占 8 字节。所以这里要么是访问一个更小的结构体字段,要么 rax 根本不是struct rec*数组的基地址,而是某个指针数组的地址。
这类混淆正是综合题常用的陷阱。我的建议是把结构体布局先写在一边,再把指令访问的偏移和布局表逐项对照。对上了,访问字段和类型就确定了;对不上,优先怀疑基址是不是数组的数组、是不是指针数组、或者索引有没有先做过缩放。
3.4 做题时的工具箱:gcc、objdump 和 gdb 怎么配合
手工推理很重要,但很多练习题能通过在机器上动手验证来快速确认。我的工作流是这样的。
先用 gcc 编译 C 源码并生成汇编:
gcc -Og -S func.c -o func.s-Og是保留调试体验的优化级别,生成的汇编和教材示例非常接近。想直接看机器码,就再生成目标文件并用 objdump 反汇编:
gcc -Og -c func.c -o func.o objdump -d func.o想观察运行时栈帧和寄存器状态,就用 gdb。把断点设在函数入口,然后用info registers、x/20gx $rsp等命令逐条查看。这种“猜完再验证”的闭环,比光看题干死磕学得快很多。我实际操作时,会把生成汇编、手写笔记、推导的 C 代码放在同一屏,逐行对照,哪里对不上基本一眼就能看出来。
4. 刷题过程常见问题与排查技巧实录
4.1 我见过的高频踩坑点
第一个高频错点是 mov 与 lea 混用。mov 是从某处取值写入目标,lea 只计算地址,不访问内存。如果题目问movl (%rdi), %eax和leaq (%rdi), %rax的区别,前者取出 rdi 指向内存中的 32 位数值,后者只是把 rdi 这个数值本身当成“地址”赋给 rax。这个点想通以后,很多地址计算题就不会被绕晕。
第二个高频错点是符号扩展与零扩展。C 语言中 int 转 long 是有符号扩展,汇编里体现为movslq;int 转 unsigned long 是零扩展,有时体现为movl %eax, %eax,利用写 32 位寄存器清零高位的特性。不少同学在这里把movslq写成movq或movl,导致后续所有比较行为判断全错。
第三个高频错点是循环边界条件方向。汇编底部的条件跳转,如果条件为真则跳回循环体,循环继续;如果为假则落到后面的 ret。题干里的 C 代码通常先递增循环变量再做条件判断,还原时如果机械翻译,容易多算一次迭代,或者漏掉边界值。
第四个高频错点是结构体对齐只算字段不加尾 padding。题目如果问sizeof(struct),最后一定要检查总大小是不是最大对齐数的整数倍,不整除就补到整数倍为止。这个尾 padding 最容易被忽略。
我把常见问题整理成速查表:
| 现象 | 最可能原因 | 快速验证方法 |
|---|---|---|
| leaq 被当成取内存 | 混淆地址计算与取值 | 检查指令是否真的带括号访问 |
| 比较结果反了 | cmpl 方向看反 | 写下“左-右”再选 set 指令 |
| sizeof 算错 | 忘记尾 padding | 用最大对齐数验证整除 |
| switch 索引对不上 | 编译器先做了范围偏移 | 看 jmp 前是否有 sub 指令 |
| 浮点参数错位 | 沿用整数寄存器规则 | 换成 xmm 编号再对应位次 |
4.2 用 gdb 验证反推结论
做这类逆向题,最怕的是自己总结的规律是错的,还浑然不觉。我的验证流程是:自己先完成还原,把还原出来的 C 代码重新用gcc -Og -S编译,再和题干给出的汇编比较。如果关键指令序列一致,说明还原基本正确;如果不一致,先看差异出现在哪个语义块。是多了一段mov,还是少了一个分支判断,很快就能定位到问题出在哪。
另一个高效技巧是用 gdb 的单步执行观察寄存器和条件码的实时变化。在函数入口下断点,用ni逐条执行,每执行完一条就看一眼 rsp、rax、eflags。特别是遇到条件跳转,你能在屏幕上直接看到它是跳了还是没有跳,这对建立“汇编行为到 C 语义”的直觉特别有帮助。
4.3 刷题策略:哪些题值得反复做,哪些可以放
说实话,第三章练习题不需要每道都刷十遍。我的建议是:
- 必刷:各类“汇编还原 C”题里涉及 if、while、栈帧、结构体、switch 的小题,这是考试和面试的高频;
- 选刷:单纯考察寄存器编号、后缀用途的概念题,会算一遍就够;
- 可以放:大量重复的简单数值计算题,除非你连寻址模式都还没理解。
刷题顺序也很重要。我推荐第一遍按章节顺序做,不对答案,只标记错题;第二遍只做错题,每道题强制用“画栈图 + 写 C 原文 + 编译对照”三步走;第三遍只做综合题,把前面所有知识串起来。三轮下来,第三章就不再是一堆孤立的汇编片段,而是能见树见林的系统知识。
5. 刷完全部练习题,我的几点真实体会
5.1 把“背汇编”改成“猜意图”,效率翻倍
我一开始喜欢背指令表,后来发现真正有用的,是记住一条指令在 C 语义里扮演什么角色。看到setg后面跟着movzbl,我脑子里自动关联“这是一个 bool 表达式的结果”;看到jmp *.L4(,%rdi,8)之前有add或sub,我会想到“switch 索引范围调整”。这种语义联想才是解题速度的来源。如果你做题时还在逐条翻译“这条指令是把哪个寄存器搬到哪”,速度一定会慢,而且容易丢上下文。
5.2 画栈图和偏移表是终极武器
无论是栈帧题、结构体题还是综合题,一张图能解决大部分混乱。我至今保持的习惯是:凡是遇到涉及内存布局的练习题,先在草稿纸上画一个从栈顶到栈底的方框,注明每一行的地址或偏移、长度、内容。很多时候,答案不是算出来的,而是“看图看出来的”。尤其当你面对这种有嵌套结构体、有多维数组、有栈参数的题目时,一张清晰的图比十行推理过程都好使。
5.3 留下一个可以迁移的套路清单
最后分享一个我总结的做题清单,平时练习和面试复习都能直接用:
- 先确认指令后缀,确定操作数宽度;
- 对任何 cmp,都写下“左操作数 - 右操作数”的条件码语义;
- 看见 push,立即更新栈图;
- 看见跳转表 jmp,先恢复索引范围再析出 case;
- 算完结构体 size,检查是否满足对齐;
- 验证题答时,用编译器对拍,不凭感觉。
这个清单看起来简单,但在时间紧张的状态下能把出错率降一个量级。第三章练习题的价值不只是让你会做题,而是让你形成一种“代码在机器上到底是什么样”的直觉。一旦建立起这种直觉,后面再学内存层次、链接、异常控制流,就有了共同的落点。希望这篇分享能让你少走一些我走过的弯路。