1. 为什么第三章的习题值得反复刷
《CSAPP》第三章讲的是程序的机器级表示,这一章在整本书里的地位很特殊——它不像第二章那样纯讲数据表示,也不像第五章那样直接讲性能优化,而是把C代码和汇编之间的那层窗户纸彻底捅破。很多人第一次看这章的时候觉得"不就是汇编吗",然后翻到习题部分直接跳过,等到后面做缓冲区溢出实验或者性能优化实验的时候才发现,第三章的底子没打好,后面全是空中楼阁。
我自己带过几轮操作系统和体系结构的辅导,发现一个规律:凡是第三章习题认真做完的人,后面实验基本不会卡在"看不懂汇编"这种低级问题上。反过来,那些第三章草草翻过的人,到了Attack Lab或者Performance Lab就开始抓瞎,回头再补第三章,时间成本至少翻倍。
这篇内容针对的是第三章后半部分的习题。前半部分主要围绕操作数格式、数据传送指令、算术逻辑运算这些基础内容展开,后半部分则深入到控制流、过程调用、数组和结构体访问、以及浮点运算这些更贴近真实程序行为的主题。如果你正在刷这一章的习题,或者刷完了想对答案但发现网上答案要么不全要么有错,那这篇内容应该能帮到你。
需要说明的是,我给的答案不是官方标准答案,而是基于我自己做题和教学过程中验证过的解法。有些题目官方答案给得很简略,我会把推导过程补全;有些题目存在多种解法,我会说明为什么选这种而不是那种。另外,第三章的习题和具体机器架构有关,这里默认以x86-64为基准,这也是《CSAPP》第三版主要使用的架构。
2. 控制流相关习题的拆解思路
2.1 条件跳转与条件传送的等价转换
第三章后半部分有一类题目要求把条件跳转形式的汇编转换成条件传送形式,或者反过来。这类题目看起来简单,但实际做的时候很容易掉进坑里。
先回顾一下核心区别:条件跳转(如je、jne、jg等)会根据标志位决定是否跳转,控制流可能分叉;条件传送(如cmovge、cmovl等)则是根据标志位决定是否把源操作数传送到目的寄存器,控制流不改变。两者在语义上可以等价,但条件传送通常能避免分支预测失败带来的性能损失。
以一道典型题目为例:给定一段用cmp和jge实现的条件赋值,要求改写成cmovge形式。原始代码大概是这样的:
cmpq %rsi, %rdi jge .L1 movq %rsi, %rax jmp .L2 .L1: movq %rdi, %rax .L2:这段代码的逻辑是:如果rdi >= rsi,把rdi赋给rax,否则把rsi赋给rax。用条件传送改写后:
movq %rsi, %rax cmpq %rsi, %rdi cmovge %rdi, %rax这里的关键点在于:先把rsi的值放到rax作为默认值,然后比较rdi和rsi,如果rdi >= rsi,就用cmovge把rdi传送到rax覆盖掉默认值。这样无论条件是否成立,rax最终都会得到正确的结果,而且没有分支。
注意:条件传送的一个限制是,源操作数必须能被安全地求值。如果源操作数的计算有副作用(比如可能触发缺页异常或者修改内存),那么无条件地计算它可能会引入原始代码中没有的问题。这是考试和实际工程中都需要注意的点。
2.2 循环结构的汇编还原
另一类常见题目是给出一段汇编代码,要求还原出对应的C代码。这类题目考察的是对循环模式(do-while、while、for)在汇编层面表现形式的识别能力。
do-while循环的汇编模式最明显:循环体在前,条件判断在后,条件成立则跳回循环体开头。while循环则通常先跳转到条件判断处,条件不成立则跳出。for循环在汇编层面和while几乎一样,因为编译器会把初始化、条件判断、更新步骤拆开处理。
我一般建议按这个顺序来还原:先找跳转指令,确定循环的边界;再看条件判断用的是哪些寄存器和比较指令,推断循环变量的类型和范围;最后看循环体内部的操作,确定循环在做什么。这个方法听起来简单,但实际做题的时候,很多人会忽略寄存器被覆盖的情况,导致还原出来的C代码逻辑不对。
举个例子,有一段汇编:
.L3: addq %rdx, %rax addq $1, %rdx cmpq %rcx, %rdx jl .L3这段代码的循环体是rax += rdx; rdx++;,条件是rdx < rcx。还原成C代码就是:
do { rax += rdx; rdx++; } while (rdx < rcx);但如果你没注意到rdx在循环体里被修改了,可能会误以为循环条件是固定的,那就错了。所以做这类题的时候,一定要把循环体内所有被修改的寄存器都标出来,看看哪些是循环变量,哪些是累加器。
2.3 switch语句的跳转表实现
switch语句在汇编层面有两种实现方式:跳转表(jump table)和一系列条件判断。当case值的范围比较密集时,编译器倾向于用跳转表;当case值稀疏或者数量很少时,编译器会用条件判断链。
跳转表的汇编形式通常是这样:
cmpq $7, %rdi ja .L8 jmp *.L4(,%rdi,8)这里.L4是一个跳转表,每个表项8字节(因为存的是地址),%rdi是索引。ja .L8是边界检查,如果%rdi大于7(无符号比较),就跳到默认分支。
做这类题目的时候,关键是要把跳转表的内容和各个case分支对应起来。跳转表在汇编里通常以.quad伪指令的形式出现,每个.quad后面跟一个标签。你需要根据标签的顺序和case值的范围,确定每个case对应哪个标签。
有一道题我印象很深,跳转表里有重复的标签,说明多个case共享同一个代码块。这种情况在C语言里对应的是多个case标签共用一个break之前的代码。还原的时候要注意把这种情况体现出来。
3. 过程调用与栈帧的习题精讲
3.1 参数传递寄存器的使用规则
x86-64的过程调用约定规定,前6个整型参数依次放在%rdi、%rsi、%rdx、%rcx、%r8、%r9中,超过6个的参数通过栈传递。浮点参数则使用%xmm0到%xmm7。返回值放在%rax(整型)或%xmm0(浮点)。
习题里经常出现的一类问题是:给定一个函数调用,问某个参数在哪个寄存器或栈位置。做这类题的时候,要注意参数的类型和顺序。如果参数既有整型又有浮点,整型和浮点分别按各自的顺序使用对应的寄存器组,互不干扰。
比如一个函数void f(int a, double b, int c, double d),参数分配是:a在%edi,b在%xmm0,c在%esi,d在%xmm1。整型参数按a、c的顺序使用%rdi、%rsi,浮点参数按b、d的顺序使用%xmm0、%xmm1。
这里有个容易搞混的地方:参数在寄存器里的顺序是按参数列表的顺序分配的,但整型和浮点各自维护自己的计数器。所以第3个整型参数不一定在
%rdx,如果前面有浮点参数的话。
3.2 栈帧布局与局部变量访问
每个函数调用都会在栈上创建一个栈帧,包含返回地址、保存的寄存器、局部变量等。x86-64的栈是向下增长的,%rsp指向栈顶,%rbp通常用作帧指针(但在优化编译时可能被省略)。
栈帧的典型布局从高地址到低地址是:调用者的栈帧、返回地址、旧的%rbp、被调用者保存的寄存器、局部变量、参数构造区(用于传递给其他函数的参数)。
习题里常见的是给出一段汇编,要求画出栈帧图或者计算某个局部变量的地址。做这类题的关键是跟踪%rsp的变化。每次push操作%rsp减8,每次pop操作%rsp加8,subq $N, %rsp则减N。
有一道题是要求计算一个局部数组元素的地址。汇编里用leaq -32(%rbp), %rax这样的指令来获取数组首地址,然后通过偏移访问各个元素。你需要根据%rbp的值和偏移量算出实际地址。如果题目给了%rbp的初始值,直接加减就行;如果没给,通常用相对地址表示。
3.3 递归函数的汇编分析
递归函数在汇编层面和普通函数没有本质区别,都是通过call指令调用自身。但递归函数的栈帧会不断累积,直到达到基准条件。
分析递归函数的汇编时,我通常按这个步骤来:先找基准条件的判断,确定递归什么时候停止;再看递归调用前的参数准备,确定每次递归参数如何变化;最后看递归调用后的返回值处理,确定如何组合子问题的结果。
以阶乘函数为例,汇编大概是:
fact: cmpq $1, %rdi jle .L1 pushq %rbx movq %rdi, %rbx leaq -1(%rdi), %rdi call fact imulq %rbx, %rax popq %rbx ret .L1: movq $1, %rax ret这里%rbx是被调用者保存的寄存器,用来保存当前的n,因为递归调用会覆盖%rdi。基准条件是n <= 1时返回1。递归调用后,用imulq把当前n和子问题的结果相乘。
做这类题的时候,要注意哪些寄存器在递归调用前后需要保存。调用者保存的寄存器(如%rdi、%rsi、%rax等)在call之后可能被修改,所以如果递归调用后还需要用到这些值,必须提前保存到被调用者保存的寄存器或栈上。
4. 数组、结构体与联合体的内存布局习题
4.1 多维数组的地址计算
C语言的多维数组在内存中是按行优先存储的。对于一个声明为T A[R][C]的数组,元素A[i][j]的地址是A + (i * C + j) * sizeof(T)。
习题里经常给出一个多维数组的声明和一段汇编,要求计算某个元素的地址或者还原访问的索引。做这类题的关键是确定数组的维度大小和元素类型大小。
比如一个int A[4][5],元素大小4字节,每行5个元素,所以行大小是20字节。访问A[i][j]的地址是A + i*20 + j*4。如果汇编里是leaq (%rdi,%rdi,4), %rax,那就是rdi * 5,说明在计算行偏移。
有一类题目会给出一个指针数组或者变长数组的访问,这时候地址计算会更复杂一些。变长数组的维度在编译时未知,所以编译器会生成乘法指令来计算偏移。看到imulq或者leaq配合移位操作,基本就是在算数组偏移。
4.2 结构体对齐与填充
结构体的内存布局受对齐规则影响:每个成员的偏移必须是其对齐要求的倍数,结构体的总大小必须是最大成员对齐要求的倍数。编译器会在成员之间插入填充字节来满足对齐要求。
习题里常见的是给出一个结构体声明,要求计算各个成员的偏移和结构体的总大小。做这类题的时候,按顺序处理每个成员:先看当前偏移是否满足该成员的对齐要求,不满足就填充到满足为止;然后放置成员,更新偏移;最后把总大小对齐到最大成员对齐要求的倍数。
举个例子:
struct S { char c; int i; double d; char c2; };在x86-64下,char对齐1字节,int对齐4字节,double对齐8字节。处理过程:c在偏移0,占1字节;i需要4字节对齐,所以填充3字节,i在偏移4,占4字节;d需要8字节对齐,当前偏移8已经满足,d在偏移8,占8字节;c2在偏移16,占1字节;总大小需要是8的倍数,所以填充7字节,最终大小24字节。
这里有个经验:调整结构体成员的顺序可以减少填充,从而减小结构体大小。把大的成员放在前面,小的放在后面,通常能得到更紧凑的布局。这在内存敏感的嵌入式场景里很重要。
4.3 联合体与位域的特殊处理
联合体(union)的所有成员共享同一块内存,大小等于最大成员的大小。习题里经常考察联合体如何用于类型转换或者查看数据的二进制表示。
位域(bit-field)则允许按位分配成员,但具体的布局依赖于编译器和架构。x86-64下,位域通常从低位向高位分配,但跨字节时的行为需要查编译器文档。
有一道题是给一个包含位域的结构体,要求写出某个值在内存中的表示。做这类题的时候,要明确位域的分配顺序和字节序。小端模式下,低位字节在低地址,位域从低位开始分配。
5. 浮点运算与SIMD指令的习题解析
5.1 浮点寄存器与参数传递
x86-64的浮点运算使用SSE/AVX指令集,浮点参数通过%xmm0到%xmm7传递,返回值在%xmm0。浮点指令通常以ss(标量单精度)、sd(标量双精度)、ps(打包单精度)、pd(打包双精度)结尾。
习题里常见的是给出一段浮点运算的汇编,要求还原C代码或者计算某个表达式的值。做这类题的时候,要注意浮点数的精度和舍入模式。addss是单精度加法,addsd是双精度加法,混用会导致精度损失。
比如计算float f(float a, float b) { return a * b + a; },汇编大概是:
mulss %xmm1, %xmm0 addss %xmm0, %xmm0这里%xmm0先保存a,%xmm1保存b,mulss计算a*b存回%xmm0,然后addss %xmm0, %xmm0把%xmm0加到自身,相当于a*b + a*b?不对,这里我写错了。正确的应该是先把a保存到另一个寄存器,或者调整指令顺序。实际编译器会生成类似:
movss %xmm0, %xmm2 mulss %xmm1, %xmm0 addss %xmm2, %xmm0先把a复制到%xmm2,然后%xmm0 = a * b,最后%xmm0 = a*b + a。这个例子说明做浮点题的时候要仔细跟踪每个寄存器的值。
5.2 浮点比较与条件跳转
浮点比较用ucomiss(单精度)或ucomisd(双精度)指令,设置标志位的方式和整数比较略有不同。浮点比较会设置ZF、PF、CF标志位,其中PF表示无序(NaN)情况。
条件跳转指令在浮点比较后使用的方式和整数类似,但要注意ja/jb等指令在浮点比较后的语义。比如ucomiss之后,如果a > b,则CF=0且ZF=0,所以ja会跳转;如果a < b,则CF=1,jb会跳转;如果相等,ZF=1,je会跳转。
有一类题目要求把浮点比较的汇编转换成C代码,这时候要注意NaN的处理。C语言里浮点比较和NaN的关系是:任何与NaN的比较(除了!=)都返回false。汇编里的ucomiss会把NaN情况设置为ZF=1、PF=1、CF=1,所以条件跳转的选择会影响NaN的处理结果。
5.3 SIMD打包运算的习题
SIMD指令可以同时对多个数据元素进行运算,这在图像处理、科学计算等领域很常见。习题里可能会给出使用addps、mulps等打包指令的汇编,要求还原C代码或者解释运算结果。
打包指令操作的是%xmm寄存器的128位,可以容纳4个单精度浮点数或2个双精度浮点数。addps把两个%xmm寄存器里的4个单精度数分别相加,结果存回第一个寄存器。
做这类题的时候,关键是把%xmm寄存器拆成多个通道来理解。比如%xmm0里有[a0, a1, a2, a3],%xmm1里有[b0, b1, b2, b3],addps %xmm1, %xmm0之后,%xmm0变成[a0+b0, a1+b1, a2+b2, a3+b3]。
有一道题是要求用SIMD指令优化一个数组求和函数。标量版本是循环累加,SIMD版本则是每次处理4个元素,用addps累加到一个%xmm寄存器,最后再把4个通道的结果加起来。这种题目考察的是对SIMD并行性的理解,以及如何把标量算法映射到打包运算上。
6. 做第三章习题时容易踩的坑
6.1 混淆AT&T和Intel汇编语法
《CSAPP》使用的是AT&T语法,操作数顺序是"源在前,目的在后",寄存器和立即数都有前缀(%和$)。如果你之前接触的是Intel语法,很容易把操作数顺序搞反。
比如movq %rsi, %rdi在AT&T语法里是把%rsi的值赋给%rdi,而在Intel语法里mov rdi, rsi才是同样的意思。做题的时候一定要看清题目用的是哪种语法,搞反了整道题就全错了。
我自己的习惯是,做题前先在草稿纸上写一行"AT&T: src, dst",提醒自己操作数顺序。这个习惯帮我避免了很多低级错误。
6.2 忽略寄存器位宽的影响
x86-64的寄存器有不同位宽:%rax是64位,%eax是低32位,%ax是低16位,%al是低8位。对%eax的写操作会自动清零%rax的高32位,但对%ax或%al的写操作不会影响高位。
习题里经常出现用movl(32位)而不是movq(64位)来操作寄存器的情况。如果你没注意到位宽,可能会误以为高位被保留了,实际上movl会清零高位。
比如movl $0, %eax会把%rax的高32位也清零,而movw $0, %ax只清零低16位,高48位保持不变。这个区别在分析涉及指针或长整型的代码时特别重要。
6.3 栈对齐的隐含要求
x86-64的ABI要求调用函数时,%rsp必须是16字节对齐的。这意味着在call指令之前,%rsp的值应该是16的倍数。如果函数里用了push或者subq改变了%rsp,需要确保在调用其他函数之前恢复对齐。
习题里有一类问题是要求计算某个函数调用时%rsp的值,或者判断某段代码是否满足栈对齐要求。做这类题的时候,要记住:call指令会把返回地址压栈,所以进入被调用函数时,%rsp的值是16的倍数减8。被调用函数通常会用pushq %rbp再减8,使%rsp重新16字节对齐。
如果函数里分配了奇数个8字节的局部变量,%rsp就会失去16字节对齐,这时候需要在调用其他函数前额外调整。这个细节在写汇编或者分析编译器输出时很关键,但在C语言层面通常由编译器自动处理。
6.4 条件码的读取时机
条件码(CF、ZF、SF、OF)是由算术逻辑指令设置的,但很多指令都会修改条件码。如果你在cmp之后插入了一条add,条件码就被覆盖了,原来的比较结果就丢了。
习题里有一类陷阱题:在cmp和条件跳转之间插入了其他指令,问条件跳转是否还能正确执行。答案通常是不能,因为中间指令修改了条件码。做这类题的时候,要仔细检查cmp和jxx之间有没有其他会修改标志位的指令。
常见的会修改标志位的指令包括:所有算术指令(
add、sub、imul等)、逻辑指令(and、or、xor等)、移位指令(shl、shr等)。mov、lea、push、pop这些指令不修改标志位。
6.5 浮点数的精度陷阱
浮点数运算不满足结合律和分配律,(a+b)+c不一定等于a+(b+c)。习题里如果要求计算浮点表达式的结果,必须严格按照汇编指令的顺序来算,不能随意重排。
另外,单精度和双精度的转换也会引入误差。float转double是精确的,但double转float可能丢失精度。如果汇编里出现了cvtss2sd或cvtsd2ss,要注意转换对结果的影响。
有一道题是给出一段浮点运算的汇编,要求判断结果是否和C代码的直接计算结果一致。答案往往是不一致,因为编译器可能做了优化或者指令顺序和C代码的求值顺序不同。这类题目提醒我们,浮点运算的结果依赖于具体的指令序列,不能想当然。
7. 从习题到实战的过渡建议
第三章的习题做完之后,下一步自然是做Attack Lab和Performance Lab。这两个实验都直接依赖第三章的知识:Attack Lab需要你理解栈帧布局和缓冲区溢出的原理,Performance Lab需要你理解汇编指令的延迟和吞吐量。
我的建议是,在做习题的时候就有意识地往实验上靠。比如做到栈帧相关的题目时,想一想如果这里有一个缓冲区溢出漏洞,攻击者会怎么利用;做到循环优化的题目时,想一想哪些指令组合能提高流水线效率。
另外,第三章的很多习题在现实工作中也有直接对应。比如分析崩溃转储(core dump)的时候,你需要从汇编层面理解程序在崩溃时的状态;做性能剖析的时候,你需要看懂编译器生成的汇编来找出热点代码的瓶颈。这些技能不是考试完就丢的,而是会伴随整个职业生涯。
如果你在做题过程中发现某些题目反复出错,建议回到对应的章节重新读一遍。第三章的内容前后关联很强,前面的操作数格式没搞懂,后面的过程调用分析就会很吃力。与其硬着头皮往下做,不如花半小时把基础补牢。
最后说一个我自己的习惯:每做完一道有代表性的题目,我会把解题思路和关键点记在一个单独的文档里,过一周再回来看一遍。这个复习周期能帮我发现当时没注意到的细节,也能在后续做题时快速回忆起类似的解法。第三章的习题量不小,但真正有价值的题型就那么几类,把每类题目的解法吃透,比刷完所有题目但每道都似懂非懂要强得多。