1. 为什么第三章的习题值得反复刷
《CSAPP》第三章讲的是程序的机器级表示,说白了就是汇编语言和C代码之间的那层窗户纸。很多人学这一章的时候觉得枯燥,觉得都什么年代了还看汇编,但真正做过底层调试、性能优化或者安全分析的人都知道,这一章才是整本书最硬核的部分。你写的每一行C代码,编译器到底把它变成了什么,栈帧怎么布局,寄存器怎么分配,函数调用时参数怎么传、返回值怎么给,这些问题的答案全在第三章里。
我当初第一次刷第三章习题的时候,感觉自己看懂了课本,结果一做题就露馅。尤其是涉及到栈帧偏移计算、结构体对齐、数组寻址这些题目,稍微换个形式就懵了。后来我总结出一个经验:第三章的习题不能只看不做,必须动手把每道题的汇编代码逐行拆解,画出栈帧图,标出每个寄存器的值,才能真正理解。这篇文章就是把我做第三章后半部分习题时的思路、踩过的坑和一些实用技巧整理出来,适合正在啃这本书的读者,也适合已经学过但想复习巩固的朋友。
第三章后半部分的习题主要集中在过程调用、数组分配、结构体与联合体、缓冲区溢出这几个主题上。这些题目有个共同特点:它们不是孤立的知识点,而是相互交织的。比如你算栈帧偏移的时候要用到对齐规则,算对齐的时候又要考虑结构体成员的排列顺序,而结构体的传递方式又和过程调用的参数传递规则密切相关。所以做这些题的时候,不能只盯着当前这一道题,要把前后知识串起来。
2. 过程调用与栈帧的核心考点拆解
2.1 栈帧布局的底层逻辑
过程调用这部分,核心就一句话:调用者保存寄存器和被调用者保存寄存器的分工。这个分工不是随便定的,它直接决定了函数调用时哪些值需要压栈、哪些值可以放心用。我见过很多人在做这类题的时候死记硬背,结果遇到稍微复杂的嵌套调用就乱了。其实只要理解了这个分工背后的逻辑,大部分题目都能推出来。
调用者保存寄存器包括%rax、%rcx、%rdx、%rsi、%rdi、%r8到%r11。为什么它们归调用者管?因为这些寄存器要么用来传参数,要么用来存返回值,要么就是临时工,调用者本来就不指望它们在函数调用后还保持原值。被调用者保存寄存器包括%rbx、%rbp、%r12到%r15,还有%rsp。这些是"长期工",被调用者如果要用它们,必须先把原值压栈保存,用完再恢复。
栈帧的布局也有固定套路。调用函数的时候,参数从右往左依次入栈(或者用寄存器传递前六个参数),然后执行call指令,把返回地址压栈。进入被调用函数后,第一件事通常是push %rbp,把调用者的栈底指针保存起来,然后mov %rsp, %rbp建立自己的栈帧。接下来根据需要在栈上分配局部变量空间,这个空间的大小要满足16字节对齐的要求。
注意:栈帧对齐是个高频考点。x86-64的ABI要求%rsp在函数调用时必须是16字节对齐的。如果你在计算栈帧大小时忽略了这一点,算出来的偏移量就会差8字节,后面的题目全错。
2.2 参数传递的寄存器规则
x86-64传参数有个明确的顺序:前六个整型或指针参数依次放入%rdi、%rsi、%rdx、%rcx、%r8、%r9。超过六个的部分通过栈传递,从右往左压栈。浮点参数走另一套寄存器,%xmm0到%xmm7。混合参数的时候,整型和浮点分别计数,互不干扰。
这个规则看起来简单,但做题的时候容易犯两个错误。第一个错误是搞混参数顺序,比如把%rdi和%rsi弄反了。我的建议是每次做题前先在草稿纸上写下"rdi, rsi, rdx, rcx, r8, r9"这个顺序,对照着填。第二个错误是忽略了参数类型对寄存器使用的影响。比如一个参数是int,另一个是long,虽然都用通用寄存器传,但在汇编里访问的寄存器名可能不同(比如%edi vs %rdi),这个细节在分析汇编代码时很关键。
2.3 返回值与调用者保存的配合
返回值通常放在%rax里,如果是浮点数则放在%xmm0里。这里有个容易忽略的点:当函数返回一个结构体时,如果结构体比较大,调用者会隐式传递一个指针作为第一个参数,指向存放返回值的空间。这个隐藏参数会占用%rdi,导致后面的参数整体后移一个寄存器。这个考点在第三章的习题里出现过好几次,很多人第一次遇到都会栽跟头。
调用者保存寄存器的使用也有讲究。当调用者需要在函数调用后继续使用某个临时值时,它必须把这个值保存到自己的栈帧里,或者放到被调用者保存寄存器里。这个选择会影响汇编代码的结构,也是分析题目时判断"这段代码在干什么"的重要线索。
3. 数组与指针运算的实战解析
3.1 一维数组的寻址模式
一维数组的寻址其实就是一个公式:地址 = 基地址 + 索引 × 元素大小。汇编里对应的形式是(%基址寄存器, %索引寄存器, 比例因子),比例因子可以是1、2、4、8,分别对应char、short、int、long或指针。这个模式看起来简单,但题目往往会在这里设置陷阱。
比如有一道题给出了这样的汇编:movslq (%rdi,%rsi,4), %rax。这里movslq是把32位有符号数扩展到64位,比例因子是4说明元素是4字节,也就是int类型。%rdi是数组基址,%rsi是索引。如果你能快速识别出这些信息,题目就迎刃而解了。但如果对指令后缀和比例因子的对应关系不熟,就会卡住。
我的经验是准备一张速查表,把常见的指令后缀和数据类型对应关系列出来:b对应1字节,w对应2字节,l对应4字节,q对应8字节。比例因子和数据类型也是一一对应的。做题的时候先看指令后缀确定数据宽度,再看比例因子验证,两个信息互相印证,基本不会出错。
3.2 多维数组的行优先展开
多维数组在内存里是按行优先存储的,也就是说A[i][j]的地址等于基地址 + (i × 列数 + j) × 元素大小。这个公式本身不难,但题目往往会把它和指针运算结合起来,增加复杂度。
有一类题目会给出这样的C代码:int *p = &A[0][0]; return p[i*N + j];,然后让你写出对应的汇编。这里的关键是要理解p是一个指向int的指针,i*N+j是线性索引。汇编里会先计算i*N+j,然后用比例因子4去寻址。如果你能识别出这是多维数组的线性化访问,就能快速写出正确的汇编。
另一类题目反过来,给出汇编让你还原C代码。这时候要注意观察寻址模式里的计算过程。如果看到类似(%rdi,%rsi,4)加上一个常数偏移,那很可能是二维数组的某个固定行。如果看到两个寄存器相乘再寻址,那可能是动态计算的线性索引。
3.3 指针数组与数组指针的区分
这是第三章的一个经典难点。int *p[10]是指针数组,p是一个包含10个指针的数组,每个元素是int*。int (*p)[10]是数组指针,p是一个指针,指向包含10个int的数组。这两个在汇编层面的表现完全不同。
指针数组的寻址是基地址 + 索引 × 8,因为每个元素是指针,占8字节。数组指针的寻址是基地址 + 索引 × 40,因为每个元素是包含10个int的数组,占40字节。做题的时候,先看C声明确定类型,再根据类型计算元素大小,最后写出寻址模式。这个流程走下来,基本不会出错。
实操心得:我习惯在做这类题的时候,先在草稿纸上画出内存布局图。把数组的每个元素画成一个小方块,标上地址和值,然后对照汇编代码逐行验证。这个方法虽然费时间,但能帮你建立直观的理解,做多了之后画图的速度会越来越快。
4. 结构体与联合体的对齐与访问
4.1 结构体对齐的计算方法
结构体对齐是第三章习题里最容易出错的部分。核心规则有三条:第一,每个成员的偏移量必须是该成员大小的整数倍;第二,结构体的总大小必须是最大成员大小的整数倍;第三,编译器可能会在成员之间插入填充字节。
举个例子,假设有这样一个结构体:
struct S { char c; int i; long l; short s; };计算过程是这样的:c是char,占1字节,偏移0。i是int,占4字节,要求偏移是4的倍数,所以偏移4,中间填充3字节。l是long,占8字节,要求偏移是8的倍数,偏移8,中间没有填充。s是short,占2字节,偏移16。总大小目前是18字节,但最大成员是long,要求总大小是8的倍数,所以填充到24字节。
这个计算过程看起来机械,但题目往往会变化结构体成员的顺序,或者加入数组、嵌套结构体等元素。我的建议是每次做题都老老实实画表格,列出每个成员的偏移、大小和填充,最后算总大小。不要试图心算,容易出错。
4.2 结构体作为参数的传递方式
结构体作为参数传递时,规则和基本类型不同。如果结构体小于等于16字节,它会通过寄存器传递,拆分成两个8字节的部分分别放入两个寄存器。如果大于16字节,调用者会在栈上分配空间,把结构体的地址作为隐藏参数传递。
这个规则在做题时经常被忽略。有一道题给出了一个包含三个int的结构体作为参数,很多人以为会通过栈传递,实际上12字节的结构体会被拆成两个8字节的部分,分别放入%rdi和%rsi。如果你不知道这个规则,就会完全做错。
4.3 联合体的内存布局
联合体的所有成员共享同一块内存,大小等于最大成员的大小。这个规则本身简单,但联合体经常和结构体嵌套使用,增加复杂度。比如一个结构体里包含一个联合体,联合体里又有不同类型的成员,这时候计算偏移和大小就要格外小心。
有一类题目会利用联合体来考察你对内存布局的理解。比如给出一个联合体,让你判断某个成员的值在特定情况下是什么。这类题目的关键是理解联合体的成员是重叠的,写入一个成员会影响其他成员的值。做题的时候,把联合体的内存画成一条字节带,每个成员占用的字节范围标出来,然后根据写入操作确定每个字节的值。
5. 缓冲区溢出与栈保护机制
5.1 缓冲区溢出的原理
缓冲区溢出是第三章的一个重点,也是安全领域的基础知识。原理很简单:当程序向一个固定大小的缓冲区写入超过其容量的数据时,多余的数据会覆盖栈上的其他内容,包括返回地址。如果攻击者精心构造输入,就可以改变程序的执行流程。
课本上的经典例子是用gets函数读取输入,gets不检查输入长度,所以容易造成溢出。题目通常会给出一个函数的汇编代码,让你计算缓冲区的大小,然后构造一个输入来覆盖返回地址。计算缓冲区大小的关键是看栈帧分配了多少空间,以及缓冲区在栈帧中的位置。
5.2 栈保护机制的工作方式
现代编译器默认会加入栈保护机制,也就是在栈帧中插入一个canary值。函数返回前会检查这个值是否被修改,如果被修改就调用__stack_chk_fail终止程序。这个机制在汇编里的表现是在栈帧的某个位置存放一个从%fs:0x28读取的值,返回前用xor比较。
做题的时候,如果看到汇编里有mov %fs:0x28, %rax和xor %fs:0x28, %rax这样的指令,就说明启用了栈保护。这时候计算缓冲区大小时要注意,canary占8字节,而且它的位置通常在缓冲区和保存的寄存器之间。如果你忽略了canary,算出来的偏移量就会错。
5.3 绕过栈保护的常见思路
虽然栈保护增加了溢出的难度,但并不是不可绕过。常见的思路包括覆盖函数指针、利用长跳转指令、或者通过信息泄露获取canary的值。这些内容在第三章的习题里可能不会深入涉及,但理解这些思路有助于你更好地理解栈保护机制的设计意图。
注意:做缓冲区溢出相关的题目时,一定要在虚拟机或隔离环境中进行实验。直接在自己的工作机上运行可能有风险,尤其是涉及到执行shellcode的题目。安全第一,不要图省事。
6. 常见问题与排查技巧实录
6.1 栈帧偏移计算总是差8字节
这是最常见的问题,原因通常是忽略了栈对齐或者canary。排查步骤是这样的:先确认函数是否启用了栈保护,如果启用了,canary占8字节。然后检查栈帧分配的大小是否是16的倍数,如果不是,说明有对齐填充。最后检查是否有被调用者保存寄存器被压栈,每个占8字节。把这三部分加起来,再和汇编里的sub指令对比,就能找到差异。
6.2 分不清mov和movl的区别
mov是通用移动指令,具体宽度由操作数决定。movl明确表示移动32位。在AT&T格式的汇编里,指令后缀很重要。movl会把目标寄存器的高32位清零,而movq不会。这个区别在分析涉及32位和64位混合运算的题目时很关键。我的建议是做题时把每条指令的后缀都标出来,养成习惯后就不会混淆了。
6.3 结构体对齐算出来的大小和答案对不上
先检查最大成员的大小,确认总大小是否是它的整数倍。然后逐个检查每个成员的偏移是否满足对齐要求。特别注意数组和嵌套结构体,它们的对齐要求是元素或内部最大成员的对齐要求。如果还是对不上,可能是编译器有特殊的对齐选项,或者题目假设了不同的ABI。这时候可以尝试用gcc -S生成汇编来验证,实际编译一下往往能发现问题的根源。
6.4 数组寻址的比例因子搞错
比例因子和数据类型是一一对应的:char对应1,short对应2,int对应4,long和指针对应8。如果看到比例因子是4但C代码里是long数组,那肯定有问题。排查的时候先看C代码里数组的元素类型,再看汇编里的比例因子,两者必须一致。如果不一致,要么是你看错了C代码,要么是编译器做了某种优化。
6.5 过程调用题目中参数顺序弄反
x86-64的参数顺序是%rdi、%rsi、%rdx、%rcx、%r8、%r9。做题时先在草稿纸上写下这个顺序,然后对照C代码逐个填入。如果参数超过六个,第七个及以后的参数从右往左压栈。注意栈上的参数顺序和寄存器参数的顺序是相反的,这一点容易搞混。
| 常见问题 | 排查方向 | 快速修复 |
|---|---|---|
| 栈帧偏移差8字节 | 检查canary和对齐 | 加上canary的8字节,确认16字节对齐 |
| mov与movl混淆 | 检查指令后缀 | 标注每条指令的宽度 |
| 结构体大小对不上 | 检查最大成员对齐 | 重新计算填充字节 |
| 比例因子错误 | 检查数组元素类型 | 对照C代码确认类型 |
| 参数顺序弄反 | 检查寄存器顺序 | 按rdi,rsi,rdx,rcx,r8,r9排列 |
7. 我刷第三章习题的一些个人习惯
我做第三章习题的时候有个习惯,就是每道题都实际编译一遍。把C代码写出来,用gcc -S -O0生成汇编,然后和题目里的汇编对比。虽然题目里的汇编可能和编译器生成的不完全一样,但通过对比能发现很多细节。比如编译器怎么处理对齐、怎么分配寄存器、怎么优化掉冗余指令,这些观察对理解题目背后的原理非常有帮助。
另一个习惯是把每道题的栈帧图画出来。不只是在脑子里想,而是真的用笔画在纸上。把%rsp、%rbp、返回地址、保存的寄存器、局部变量、canary都标出来,然后标注每个位置的偏移量。画多了之后,看到汇编代码就能在脑子里自动生成栈帧图,做题速度会快很多。
还有一点,第三章的习题不要一次刷太多。这部分内容密度大,一次刷太多容易疲劳,效率反而低。我的做法是每天刷5到8道,刷完之后把错题整理出来,过两天再重做一遍。错题重做的时候不要看答案,自己从头推一遍,确认真的理解了再放过。
最后说一个容易被忽略的点:第三章的很多习题是相互关联的。比如过程调用的题目可能用到数组寻址的知识,结构体的题目可能涉及对齐和参数传递。做题的时候要有意识地把这些知识点串起来,形成一个完整的知识网络。这样不仅做题正确率高,对后面章节的理解也会更顺畅。