简介:这是一份《深入理解计算机系统》(CSAPP)自学笔记,面向正在啃原书、备考或刚入门的计算机专业学生,很适合用零散时间快速建立全局认知。笔记以精简条目和图示方式组织,内容从CPU、FPU、GPU、内存、BIOS、USB、PCI等硬件组件入手,延伸到编译系统、进程上下文、虚拟地址空间、进程与线程等操作系统抽象,并对每个部件给出英文全称、缩写和功能说明。整份资源为一个PDF文件,约3.47MB,目录结构清晰,可导入平板、手机或打印后随读随查。已有531人浏览学习,是教材之外值得留存的补充资料。笔记完整展示hello程序从预处理、编译、汇编、链接到运行的执行链路,同时补充了DMA传输、存储分层和Linux虚拟地址空间等图解,能帮助读者把数据在磁盘、内存、处理器与I/O设备间的流动过程串起来,降低深入原书的门槛。
1. CSAPP自学笔记:为什么这本书值得啃下来
CSAPP自学笔记写到第三遍,我才敢说真正摸到了“深入理解计算机系统”的门槛。这本书不是让你背概念,而是逼你亲手把数据表示、汇编、存储层次、并发这些底层地基挖开,配合csapp实验才见效。适合两类人:写业务代码遇到性能瓶颈无从下手的,以及系统崩溃后只能重启、靠试错碰运气的调试者。我的建议很直接:别急着刷题,先把lab做透。书读得再熟,不如亲手把一个浮点数用位运算拆开一次。
2. 深入理解计算机系统的三大主线:先立住数据表示、汇编与内存层次
自学CSAPP最忌讳按章节平推。书里有十三章,但真正串联起所有实验的只有三条线:数据表示、过程调用、内存层次。先立住这三块,后面bomb、cache、shell、proxy都有落脚点。我在笔记里给这三块各画了一页速查,遇到问题先翻对应页,比重新啃书快得多。
2.1 数据表示和位运算:补码、溢出与掩码
第一章为什么容易翻车?因为很多人直接跳到datalab,发现一个isTmax都能卡半天。数据表示的核心不是记住“1字节是8位”,而是建立补码和无符号编码的直觉。写过业务代码的都知道,unsigned混用int会出诡异问题,但很少有人能说清背后的位模式。
做CSAPP实验前,我要求自己在草稿纸上推一遍4位补码表:0111是7,1000是-8,1111是-1。然后把表扩展成32位,理解符号位扩展。其中一个高频操作是掩码取位。例如想保留一个数的最低n位,最直接的是这样:
#include <stdio.h> unsigned lower_bits(unsigned x, int n) { // 返回 x 的低 n 位,n 取值 0~31 unsigned mask = (1U << n) - 1U; return x & mask; }逻辑在第4行:1U << n把1移到第n+1位,减1后得到低n位全1的掩码。注意必须用1U而不是有符号的1,否则n=31时1<<31是有符号未定义行为,这在CSAPP里反复强调。参数上,n不能等于32,因为1U << 32仍然是未定义行为,要先用if (n == 32) return x;兜底。
另一个更隐蔽的坑是隐式转换。int y = -1; unsigned u = y;,此时y被当作0xffffffff参与运算。我踩过一次后,给自己立了条规矩:只要涉及位运算和比较,先明确操作数是有符号还是无符号。笔记里我画了下面这张对照表:
(int)0x80000000 == -21474836480x7fffffff的补码加1会变成0x80000000- 判断
a + b是否溢出,不能直接看结果,要看两个操作数和结果的符号位关系
这个阶段不需要急着写代码,我建议用gdb配合表达式验证。比如启动一个空程序,用p/x打印十六进制,或者用p/t打印二进制,观察补码加法后的位。每验证一条,就在笔记里记一条,后面datalab的很多函数就是这些规则的组合。
2.2 汇编与过程调用:用gdb反汇编把调用栈摆到台面上
很多读者把第三章汇编当“机器盲文”跳过了,结果bomb lab里拆炸弹时只能靠猜。过程调用的本质在汇编层面非常清晰:调用者把参数放进寄存器,call指令压入返回地址,被调函数用栈帧保存局部变量。只要你见过一遍真实的栈布局,后面看任何递归、缓冲区溢出都有坐标系。
可复现的操作是:写一个几行的C函数,用-g编译,再进gdb反汇编。
gcc -g -o demo demo.c gdb ./demo (gdb) start (gdb) layout asm (gdb) break main (gdb) run (gdb) info registers rsp rbp rip第一条命令是编译带调试信息的程序。layout asm会打开汇编窗口,让你逐条看反汇编代码和当前执行位置。break main在main函数入口打断点。最后一条info registers能同时看到栈指针rsp、帧指针rbp和指令指针rip。
对着这个画面,你会发现call指令做了两件事:把rip压栈,然后跳转到函数入口。函数开头通常是push %rbp; mov %rsp, %rbp,这就是建立新栈帧。参数传递在x86-64下前6个依次用rdi、rsi、rdx、rcx、r8、r9。我在笔记里专门抄了一段反汇编,标出每条指令对应的高层语义,比如leal (%rdi,%rsi), %eax在计算x+y。
如果你跟我一样记不住寄存器,就在gdb里加一条.gdbinit:
set print asm-demangle on set disassembly-flavor intel用Intel语法看起来更真实。这一步的重点不是背指令,而是能指着任何一条call前后,说清栈指针怎么变、返回值放哪。bomb lab里需要你心算栈偏移时,这一章的价值就体现出来了。
2.3 内存层次与局部性:从寄存器到磁盘的时间量级表
数据表示讲的是“数怎么存”,汇编讲的是“指令怎么跑”,而内存层次决定两者到底跑多快。CSAPP的第六章给了经典的局部性原理:程序倾向于重复访问已用过的数据,也倾向于访问相邻数据。这两句话背出来很轻松,但只有量化过时间量级后才有意义。
我整理了一张常用量级表,放在笔记开头:
- 寄存器:约0.5到1纳秒,容量几百字节
- L1 Cache:约1纳秒,几十KB
- L2 Cache:约4纳秒,几百KB
- 主存:约100纳秒,GB级
- 磁盘(SSD):约100微秒,TB级
- 磁盘(机械):约10毫秒,TB级
注意这些数字不是精确基准,但数量级不会骗人。从寄存器到内存差了200倍,而一旦落到磁盘,就是百万倍的差距。很多业务系统慢,不是CPU慢,是cache miss和磁盘IO在拖后腿。
要验证局部性不需要高性能工具,perf就够:
perf stat -e cache-references,cache-misses ./your_program这个命令统计程序运行期间cache的引用和缺失次数。我通常只看cache-misses占cache-references的比例。如果超过10%,说明数据访问模式很差。配合后面的Cache实验,你能直观看到循环嵌套顺序对性能的影响:同样算一个矩阵求和,行优先比列优先快一个数量级,原因就是空间局部性。
在这个阶段,我给自己定的验收标准是:看完这三块,能独立解释“为什么一个简单的数组循环也会慢”。如果解释不了,回头补,别急着往下走。
3. csapp实验datalab:用位操作把浮点数的黑匣子撬开
datalab是CSAPP系列里公认适合第一个做的实验,因为它只依赖数据表示那一章。它把一堆真实的“黑匣子”函数摆到你面前,要求你用有限的位运算符重写出来。跑完这个lab,补码溢出、IEEE754浮点格式、掩码和移位就会从抽象概念变成肌肉记忆。
3.1 环境准备与实验约束:先读README和Makefile,再动手
下载并解压lab后,目录里通常有bits.c、btest、dlc、Makefile。我见过太多人上来就改bits.c,结果连编译器规则都摸不清。第一步永远是读README,确认以下几点:
- 只能使用白名单运算符:
! ~ & ^ | + << >> - 不能使用控制结构:
if、while、循环、三目运算符 - 常数只能在
0x00到0xff之间,不能直接写0x7fffffff - 某些函数禁止使用大于8位的常数,评分时会检查
编译和测试的基本命令是:
make ./dlc bits.c ./btest ./btest -f isTmax第一条命令生成btest可执行文件。dlc是合法性检查器,会报出你用了非法运算符或非法常数,比如“Illegal operator (==)”。btest是功能测试器,不带参数会跑全部函数,带-f只跑指定函数。我在写每个函数前后都会各跑一次dlc和btest,确保没有隐藏犯规。
3.2 用bits.c实现一个整数函数:从逻辑门到补码边界
以isTmax为例,它要求判断一个数是否是32位补码的最大值0x7fffffff。新手容易想到用==判断,但这违反运算符合法性。换一个思路:最大数加1得到0x80000000,它的相反数也是0x80000000,而0x80000000恰好是~0x7fffffff。
利用这个自反性可以写成:
/* * isTmax - returns 1 if x is the maximum signed int, * otherwise returns 0. * 允许运算符:! ~ & ^ | + << >> */ int isTmax(int x) { int plus1 = x + 1; int negx = ~x; int is_neg = !(plus1 ^ negx); // x+1 == ~x 时异或为0 return is_neg & !!(plus1); }第8行计算x+1,第9行计算~x。第10行用异或判断两者是否相等:相等时异或结果为0,!把它变成1。但注意x等于-1时,x+1为0,~x也为0,异或还是0,也会通过判断。所以第11行加上!!(plus1),排除plus1为0的情况。!!是把任意非零值变成1、零值保持0的惯用法。
这个函数的重点是理解补码的自反关系,以及如何用位运算代替==。跑./btest -f isTmax如果全绿,就可以继续下一个函数。但注意!!是允许的,因为!在白名单里,连续两次合法。
3.3 floatScale2:把IEEE754的规格化与非规格化过一遍
浮点题是datalab的分水岭。floatScale2要求返回2 * x对应的无符号位表示,输入是一个无符号整数,它的位模式被解释成单精度浮点数。难点在于IEEE754不是简单的补码:最高位是符号,接着8位指数,低23位尾数。指数为0时是非规格化数,指数为255时是Inf或NaN。
我的实现是先把三个字段拆出来:
unsigned floatScale2(unsigned uf) { unsigned sign = uf & 0x80000000u; // 最高位 unsigned exp = uf & 0x7f800000u; // 第23~30位 unsigned frac = uf & 0x007fffffu; // 低23位 if (exp == 0x7f800000u) return uf; // Inf或NaN原样返回 if (exp == 0) { frac <<= 1; // 非规格化数直接左移 return sign | frac; } exp += 0x00800000u; // 指数加1 if (exp == 0x7f800000u) return sign | exp; // 溢出到Inf return sign | exp | frac; }拆字段分别用了三个掩码:0x80000000取符号,0x7f800000取指数,0x007fffff取尾数。第6行先处理指数全1的情况,此时无论尾数是否为0,按IEEE规则都应该原封不动返回,因为NaN不能被整数倍放大成另一个NaN。第8行处理非规格化数,直接让尾数左移一位,相当于乘以2;如果尾数溢出到指数段,进位设计恰好自动完成从非规格化到规格化的转换。第12行给指数段加1,注意这里操作的是exp字段,不会污染低位尾数。最后如果指数变成全1,返回Inf,丢弃尾数是因为正向溢出时尾数应为0。
我踩过的坑是忘记先判exp == 0x7f800000u。如果只做exp += 0x00800000,NaN的尾数会跟着一起变,btest立刻报错。所以写浮点函数时,边界判断一定放在最前面。
4. Cache杀手:用实验验证局部性,别让性能分析停留在黑匣子
Cache lab是CSAPP里最能“治不服”的实验。它会让你摸到自己的程序在硬件缓存面前有多脆弱。很多人觉得自己写的循环很自然,但缺失率一统计,才发现访问模式完全踩在缓存反模式上。
4.1 为什么局部性是系统性能的隐藏钥匙
Cache背后的硬件原理并不复杂:CPU拿数据时,会把相邻的64字节一并带进Cache。如果程序下一次访问的是同一块里的数据,就是命中;如果跳到远处,就是miss,必须回主存取,代价可能是几十倍。
关键参数是两个:容量和相联度。常见Cache是组相联结构,地址被拆成三部分:块内偏移、组索引、标记。定位时先根据组索引找到组,再在组内并行比较标记,命中则返回数据。组内能装的行数叫相联度,越大越不容易冲突,但硬件成本越高。
CSAPP的Cache lab Part A要求写一个LRU缓存模拟器,本质就是把这个查找过程用C语言复现一遍。写模拟器不是目的,目的是让你对地址切分有直觉。
4.2 用计时器对比顺序访问与随机访问:一条命令看差距
验证局部性不需要模拟器,一个简单的计时程序就够了。下面这段代码对比二维数组按行访问和按列访问的耗时:
#include <stdio.h> #include <stdlib.h> #include <time.h> #define N 4096 int main() { int *a = (int *)malloc(N * N * sizeof(int)); long sum = 0; clock_t start, end; start = clock(); for (int i = 0; i < N; i++) for (int j = 0; j < N; j++) sum += a[i * N + j]; // 行优先:连续地址 end = clock(); printf("row first: %.3f sec\n", (double)(end - start) / CLOCKS_PER_SEC); start = clock(); for (int i = 0; i < N; i++) for (int j = 0; j < N; j++) sum += a[j * N + i]; // 列优先:跨行跳跃 end = clock(); printf("col first: %.3f sec\n", (double)(end - start) / CLOCKS_PER_SEC); free(a); return 0; }第11行和第15行分别对应行优先与列优先。行优先每次访问地址加4字节,落在同一个64字节cache块里,能连续命中;列优先每次地址跳跃约4KB,几乎每次都开新的cache块,miss率急剧上升。clock()统计的是CPU时间,在Linux下一般能稳定复现出10倍以上差距。
如果你在云主机上跑,记得先设置taskset -c 0 ./program,把进程绑到单核上,防止调度器迁移导致缓存干扰。这个命令行参数很常见,但很多人漏掉。
4.3 cache lab的模拟器思路:把地址拆成标记、组索引和偏移
写完计时程序后,Cache lab Part A的模拟器就顺理成章了。假设一个Cache容量1KB,块大小32字节,则偏移字段占5位,组数32,组索引占5位,剩余22位是标记。对于地址0x12345678,切分如下表格:
| 地址字段 | 位数 | 值 |
|---|---|---|
| 标记 | 22 | 0x12345 |
| 组索引 | 5 | 0x1a |
| 块内偏移 | 5 | 0x18 |
实现模拟器时,我常用一个结构体数组:
#define SETS 32 #define WAYS 4 typedef struct { unsigned tag; int valid; unsigned lru; } line; line cache[SETS][WAYS];访问时,先从地址addr算出组索引set = (addr >> 5) & (SETS - 1),再遍历该组所有行,找到tag匹配且valid为1的。如果没找到,就是miss,选择一个lru最小的行替换。LRU计数器每次命中或替换时更新,实现不算难,但地址切分错一个位,整个模拟器就废了。我在这上面返工过一次,后面避坑章里细说。
5. CSAPP自学避坑指南:四个让人翻车的现场与排查思路
自学CSAPP最折磨人的不是看不懂,而是实验环境或细节卡住你三天,最后发现是个低级错误。我把高频问题按现象、原因、解决三部分记在笔记里,分享其中四个。
5.1 datalab里dlc报错:运算符不在白名单里
现象:写完isTmax,执行./dlc bits.c,输出“Illegal operator (==)”或“Constant too large”。
原因:datalab规则限定只能使用! ~ & ^ | + << >>,并且常数不能超过0xff。==、>、三目运算符都不行,直接写0x7fffffff也不行。
解决:改写为位运算。判断a == b可以用!(a ^ b);判断非零可以用!!x;需要0x7fffffff时,通过~(1 << 31)不行,因为常数超限,可以用0xff拼接:(((0x7f) << 24) | (0xff << 8) | 0xff),但更好的做法是直接用~0和移位构造。我的习惯是先写一个朴素版本,再逐条替换非法运算符,每替换一次就重跑dlc,不会一次性大改。
5.2 float函数边界用例失败:NaN和Infinity没做好归一
现象:floatScale2对0x7f800001(NaN)返回了0x7f800002,而btest期望原值返回。
原因:我只处理了指数全1的情况,但把指数和尾数混在一起做加法。结果指数位进位成功,尾数却被改变。IEEE规定NaN和Inf的指数全1,任何操作都不能改动它们的位模式,除非你想造出新的NaN。
解决:把边界判断放在函数最前面。看到exp == 0x7f800000就直接return uf,不管尾数是什么。同理,负零0x80000000也要保留符号位,不能因为指数为0就把它当正零处理。写完后,用./btest -f floatScale2跑,再补充几个手造边界检查:0x7f800000、0x7f800001、0x80000000、0x00000000。
5.3 汇编实验的栈对齐问题:踩过一次就再也忘不掉
现象:在bomb lab里自己用汇编写了一个递归函数,调用printf时格式化串输出乱码,甚至段错误。GDB单独运行函数正常,但合在一起就崩。
原因:System V AMD64调用约定要求,call指令执行前,栈指针rsp必须按16字节对齐。如果函数入口处push了奇数个寄存器,调用printf时rsp就偏了8字节,printf里的SSE指令访问未对齐栈变量就会出错。
解决:写汇编函数时,调用别的函数之前先手动对齐。常见做法是在栈上多占8字节:subq $8, %rsp,然后调用,调用回来再addq $8, %rsp。或者更省事的是让C编译器生成汇编,再用objdump看它的对齐策略,照着改自己的代码。这个错误很难肉眼发现,建议在gdb里用x/2gx $rsp检查调用前后的栈内容。
5.4 cache lab地址切分错误:偏移、组索引、标记哪个在前
现象:自己写的cache模拟器,无论怎么替换算法,测试结果全是miss;或者得分极低,但逻辑看起来没问题。
原因:地址切分方向反了。很多人习惯把地址高位当组索引,实际上硬件地址最低位是块内偏移,中间是组索引,最高位是标记。如果先挖低位,那组索引就被截掉了。
解决:先拿到块大小,计算offset_bits = log2(block_size)。再拿到组数,计算set_bits = log2(num_sets)。然后组索引是(addr >> offset_bits) & (num_sets - 1),标记是addr >> (offset_bits + set_bits)。不要用addr >> (32 - tag_bits)这样的高位提取方式,除非你确认台数参数。我后来把切分过程写成了一个打印函数,每次测试前先打印地址三部分的十六进制,肉眼核对一遍再做替换策略。
6. 用proxy lab收尾:把自学笔记变成可复现的工程习惯
所有单点知识最后会在proxy lab里汇合。它要求写一个并发HTTP代理,处理浏览器请求并转发到目标服务器。这个实验把套接字、多线程、文件描述符、缓存验证、信号处理全部串起来,做完你会觉得前面所有章节都是这次练习的注脚。
我分享一个最关键的工程习惯:先让一个线程串行跑通完整请求,再加并发。串行版本能帮你确认代理逻辑正确,并发版本才涉及锁和资源生命周期。并发部分我常用的是线程池而不是每次pthread_create,因为代理场景短连接多,反复创建线程的系统调用开销会吃掉吞吐。但CSAPP实验里用简单多线程也能过,重点在于正确处理连接关闭。
验证时不要只测功能,要测并发压力。一条命令就能看出代理问题:
ab -n 1000 -c 50 -X 127.0.0.1:8080 http://example.com/-n是总请求数,-c是并发数,-X指定代理地址。如果代理实现正确,这个命令的失败率应该为0;如果出现apr_socket_recv: Connection reset,多半是线程结束后close了还在用的fd,或者信号处理不当造成僵尸进程积累。
我的随身笔记里,最后一条写的既不是命令也不是原理,而是一句教训:任何一个实验,先打印关键地址,再谈优化。数据表示、cache地址、socket描述符,它们是同一个东西——地址和位的切分。看懂这一步,CSAPP的每个lab都不会是无源之水。希望帮到你。
本文还有配套的精品资源,点击获取