简介:面向哈工大计算机系统漫游(CSAPP)课程初学者的实验1配套资料包,针对二进制转换、按位运算、寻址与内存管理、x86汇编、函数调用栈、编译链接、系统调用等高频难点,提供系统性的学习支撑,适合正在完成Lab1或需要补底层基础的高校本科生。压缩包约969.39MB,内含实验代码、数据文件及实验指导文档,供读者对照练习和撰写报告。目前已有630人学习下载。资料可与C语言底层特性结合,帮助理解指针操作、malloc/free内存分配、结构体布局,以及虚拟地址与物理地址的映射关系;同时讲解gprof/perf等性能分析工具的使用,便于定位代码热点。通过逐步拆解函数调用过程中参数的传递、返回值的处理和堆栈帧的建立与销毁,读者能清晰把握程序执行脉络。更重要的是,资料梳理了从预处理、编译、汇编到链接的完整可执行文件生成流程,降低链接错误排查门槛,为后续存储层次、异常控制流等进阶实验打下扎实基础。
1. HIT csapp实验1《计算机系统漫游》:这一关为什么值得你认真做
HIT csapp实验1《计算机系统漫游》可能是整门课里最容易被低估的一次实验。它不要求你写出多少新功能,反而逼你把已经写过的 C 程序拆开看:位、字节、地址、栈、进程、虚拟内存。很多人做完后的第一反应是,原来 hello.c 从按下回车到屏幕输出,中间发生了这么多事。这个实验适合三类人:刚切换到 Linux 命令行的、对编译链接只停留在“点一下运行”的,以及后续要做数据实验和炸弹实验但心里没底的人。它的核心价值在于,把教材第 1 章的抽象概念全部落成可操作的工具链命令,这一关过了,后面讨论性能、缓存、异常控制流才站得住脚。
2. 先把环境打穿:从工具链检查到最小运行流程
2.1 工具链清单:缺一个就做不下去
实验1最容易翻车的地方不是代码,而是机器上缺工具。按经验,这份清单里至少要有六样:gcc、make、python3、objdump、readelf、gdb。gcc 管编译,make 管构建流程,python3 管评分脚本,objdump 和 readelf 用来观察程序内部结构,gdb 用来在卡住时看寄存器与内存。缺了任何一样,后面所有操作都会卡在奇怪的地方。
检查工具是否齐备,不要用“凭感觉”,直接用下面这段命令:
# 逐个检查工具是否存在,并输出版本信息 command -v gcc && gcc --version | head -n1 command -v make && make --version | head -n1 command -v python3 && python3 --version command -v objdump && objdump --version | head -n1 command -v readelf && readelf --version | head -n1 command -v gdb && gdb --version | head -n1command -v的作用是查找命令路径,找到才会继续执行后面的版本输出。如果某一行没有输出,说明对应的包没装。很多实验指导给的是 Ubuntu 系命令,先更新索引再安装工具链,这一步没什么取巧空间,缺什么补什么即可。装完后重新跑一次检查,直到六行都出现版本号。
| 工具 | 在实验1中的用途 | 缺失时的典型现象 |
|---|---|---|
| gcc | 预处理、编译、汇编、链接 | 无法生成可执行文件 |
| make | 按规则构建和清理 | 找不到make命令 |
| python3 | 运行评分脚本 driver.py | ./driver.py直接报错 |
| objdump | 反汇编查看机器码 | 无法看到汇编与字节关系 |
| readelf | 查看 ELF 头、符号表、节区 | 无法分析链接是否完整 |
| gdb | 断点调试、查看内存 | 只能靠打印语句猜问题 |
工具链这关没必要追求新版,稳定够用就行。特别提醒一点,如果实验要求编译 32 位程序,还需要gcc-multilib这类的 32 位运行库,否则会报缺少头文件或无法找到 crt1.o。遇到这类报错,先查平台位数,不要急着改代码。
2.2 读懂实验包的 Makefile:第一次构建就规范化
拿到实验包后的第一件事不是打开源码,而是先读 Makefile。Makefile 规定了实验的构建方式,评分脚本最终也是通过它来编译你的代码。很多人习惯自己新建工程、自己写编译命令,结果和课程要求的编译器选项不一致,最后评分跑出 0 分。正确的做法是,完全尊重实验包自带的 Makefile,只在需要时少量调整。
一份典型实验1的 Makefile 简化后长这样:
# 实验1 的 Makefile 示例:目标为 hello,源文件为 hello.c CC = gcc CFLAGS = -Wall -O1 -g TARGET = hello SRCS = hello.c $(TARGET): $(SRCS) $(CC) $(CFLAGS) -o $(TARGET) $(SRCS) clean: rm -f $(TARGET)这里CC指定编译器,CFLAGS是编译选项,-Wall打开常见警告,-O1做一级优化,-g保留调试信息。$(TARGET)前面顶格的命令必须是 Tab 开头,不能是空格,这是新手最容易踩的格式坑。clean目标负责清理产物,保证下一次构建从源码重新开始。
除非课程明确要求,否则不要擅自把-O1改成-O2或-O3。更高级别的优化可能在位操作类题目里改变运算顺序,也可能让调试时变量值变得“不可见”。实验1阶段,可读性和可调试性比性能重要。
2.3 跑通最小闭环:清理、构建、运行、验证
环境装好后,做一次“空跑”是检验理解的最快方式。所谓空跑,就是从干净状态构建一次,运行一次,再验证结果。命令如下:
# 进入实验目录,注意先看清楚当前路径 cd ~/csapp-lab1 # 清理之前可能存在的构建产物 make clean # 重新构建项目 make # 列出生成的文件,确认可执行文件已出现 ls -l # 运行实验对应的程序,这里以 hello 为例 ./hellomake clean的作用是把之前编译出来的目标文件和可执行文件全部删掉,避免旧文件干扰新结果。make会按照 Makefile 中的规则重新编译。ls -l用来确认产物是否生成,顺便看文件权限是否为可执行。如果运行时报Permission denied,先执行chmod +x hello或者确认构建过程没有异常退出。
跑通之后,再进入验证环节。多数实验包会带一个评分脚本,例如driver.py,它的作用是对你的实现做自动化测试。运行方式一般是python3 driver.py,但具体文件名要以实验包里的 README 或者 write-up 为准。如果这一步能跑出分数或测试结果,说明你的环境、构建、运行链路是完整的,后面的代码改动才有意义。
3. “计算机系统漫游”到底考什么:把第1章映射到实验任务
3.1 位与上下文:先想清楚数据在内存里是什么样
教材第 1 章开篇的结论是:信息就是位加上下文。同样一串 0x41,在文本文件里是字符 A,在机器指令里可能是立即数,在地址里可能是某个变量的内存位置。实验1里的功能往往围绕位操作和字节输出展开,所以第一件事是培养“看字节”的习惯。
下面的代码可以帮你在任意机器上确认整数的内存表示:
#include <stdio.h> int main(void) { int x = 0x12345678; unsigned char *p = (unsigned char *)&x; for (int i = 0; i < 4; i++) { printf("byte[%d] = 0x%02x\n", i, p[i]); } return 0; }这段代码把 int 指针强转成 unsigned char 指针,然后按字节打印。0x%02x保证每个字节以两位十六进制输出。在小端机器上,你会看到低地址存放的是低字节,输出顺序是 78 56 34 12;在大端机器上则相反。实验1的输出题、位操作题,本质都是在和你确认“你理解的内存布局是不是和机器一致”。
理解位与上下文的实际意义在于:当你写x & 0xff时,你要知道这是在取低 8 位;当你把某个结构体按字节发送或保存时,不同机器可能给出不同结果。实验1不会要求你做跨机器通信,但后面的实验会,这里打好基础不亏。
3.2 从源码到可执行文件:四阶段翻译在命令层面的体现
“计算机系统漫游”这一章花了大量篇幅讲程序的生命周期:从源程序到可执行文件,要经过预处理、编译、汇编、链接四个阶段。实验题经常要求你在报告里写清楚这个流程,或者问你某条命令对应的阶段是什么。与其死记硬背,不如亲手把每个阶段跑一遍。
用下面这组命令,可以完整观察四阶段产物:
# 预处理:把 #include 和 #define 展开,生成 .i 文件 gcc -E hello.c -o hello.i # 编译:把 .i 翻译成汇编,生成 .s 文件 gcc -S hello.i -o hello.s # 汇编:把 .s 翻译成机器指令,生成 .o 目标文件 gcc -c hello.s -o hello.o # 链接:把 .o 和库文件合并,生成可执行文件 gcc hello.o -o hello-E只做预处理不编译,适合看头文件展开后的样子。-S生成汇编文件,是观察编译器如何翻译 C 代码的关键步骤,实验1的报告里如果要求贴汇编片段,一般就是看这个文件。-c只到汇编阶段,生成的目标文件还不能直接运行。最后的-o指定输出文件名,完成链接。
对实验1来说,你至少需要能从hello.s中找到main对应的汇编片段,并能解释栈指针rsp的变化。如果这一步完全看不懂,后面学过程调用、栈帧、缓冲区溢出时会非常吃力。
3.3 三条抽象线索:进程、虚拟内存、文件
第 1 章后半段的核心是操作系统的三个抽象:文件是对 I/O 设备的抽象,虚拟内存是对主存和磁盘的抽象,进程是对处理器、主存和 I/O 设备的抽象。实验1不一定直接考这些概念,但它决定了你能否解释“为什么程序崩溃时是段错误”。
在实验1阶段,我习惯让初学者做三件事:用ps看进程状态,用/proc/self/maps看虚拟内存分布,用strace看系统调用。下面这条命令可以在任何 Linux 机器上观察一个程序运行时的系统调用序列:
# 追踪 hello 运行过程中的所有系统调用 strace -f ./hello-f选项表示同时追踪子进程。输出里你会看到execve、brk、write、exit_group等系统调用。write就是那个把字符从用户空间送到内核,再显示到屏幕的调用。能看到这一层,你才算真正“漫游”了一次计算机系统。
对实验1而言,这三条抽象线索不一定要写进代码,但它们能帮你排查很多怪问题。比如程序运行缓慢,你可能会想到缺页中断;文件读取异常,你可能会想到文件描述符的概念。后续实验的评分方式、自动化测试手段,全都建立在你能准确理解“程序到底在跑什么”的基础上。
4. 动手写代码时的参数细节:四个必调点
4.1 补码与打印:为什么 0xffffffff 等于 -1
实验1里最常见的输出数据是整数,而整数在计算机里以补码形式存储。补码的意思简单说:最高位是符号位,负数通过取反加一得到。于是0xffffffff在无符号视角下是 4294967295,在有符号视角下是 -1。写打印代码时,格式说明符一旦用错,结果就是天壤之别。
#include <stdio.h> int main(void) { int x = -1; unsigned int y = 0xffffffffu; printf("有符号输出: %d\n", x); printf("无符号输出: %u\n", y); printf("十六进制输出: 0x%x\n", y); return 0; }%d把参数解释为有符号整数,%u解释为无符号整数,%x按十六进制输出。同一个二进制位模式,用不同格式符会得到完全不同的显示。实验题里如果要求输出十六进制,直接用0x%x就可以;如果要求有符号十进制,用%d;如果要求无符号十进制,用%u。这看似基础,但我在复查别人代码时看到过太多“打印结果明明对,评分却不对”的案例,最后查出都是格式符和题目要求不匹配。
4.2 移位与掩码:n=0、符号位、算术右移
位操作是实验1的常客。初学者写掩码时经常卡在两个地方:一是1 << n当 n 等于 31 时符号位问题,二是右移到底是逻辑右移还是算术右移。以取整数的低 n 位为例:
/* 返回 x 的低 n 位,n 的范围是 0 到 31 */ int get_low_bits(int x, int n) { int mask = (1 << n) - 1; return x & mask; }这个实现当 n=0 时,1 << 0等于 1,减一后 mask 为 0,结果正确。当 n=31 时,1 << 31在 int 下变成0x80000000,即符号位为 1,这是一个实现定义行为,在绝大多数机器上结果是负数,但(1 << 31) - 1得到的0x7fffffff仍然符合掩码语义。当 n=32 时,1 << 32是未定义行为,你必须提前拦截。
右移的坑更隐蔽。对有符号数,C 标准没有强制规定算术右移,但几乎所有实际编译器都采用算术右移。如果你写x >> n且 x 是负数,高位补的是符号位而不是 0。需要逻辑右移时,必须先把 x 转换成无符号类型:
/* 逻辑右移示例:无符号右移高位补0 */ unsigned int logical_right_shift(int x, int n) { return (unsigned int)x >> n; }把int强转为unsigned int后再移位,高位补 0,行为可预期。实验1的位操作题里,看到负数右移结果不对,先查类型,不要急着改算法。
4.3 断言与测试用例:本地验证的写法
很多人的验证方式是改 main 函数,用 printf 打印几个结果,肉眼对比。这在实验1阶段非常危险,因为评分脚本用的测试用例往往包含边界值和随机值,肉眼验证覆盖不了那么多。更可靠的方式是用断言或循环批量测试。
#include <assert.h> void test_get_low_bits(void) { assert(get_low_bits(0x12345678, 8) == 0x78); assert(get_low_bits(0x12345678, 0) == 0); assert(get_low_bits(-1, 4) == 0xf); assert(get_low_bits(0x7fffffff, 31) == 0x7fffffff); }assert宏在条件不满足时直接终止程序并报告行号,比 printf 直观得多。测试函数里集中放边界值:0、全 1、符号位为 1、n 取最大合法值。把这些测试函数放在一个单独的文件里调用,不要污染实验要提交的源文件。
评分脚本跑挂,绝大多数情况不是算法大方向错,而是某个边界值没覆盖。把边界用例列成一张表:n 取 0、1、31,x 取 0、所有位全 1、符号位为 1,逐个验证,比写十个普通用例都有用。
4.4 命令行传参:从 argv 到数值转换
实验1如果要求程序接收命令行参数,常见的做法是把字符串转成整数。这里有个经典坑:直接用atoi不检查错误,遇到非法输入默默返回 0,很难排查。正确做法是使用strtol并检查 endptr。
#include <stdio.h> #include <stdlib.h> int main(int argc, char *argv[]) { if (argc < 2) { fprintf(stderr, "用法: %s 数值\n", argv[0]); return 1; } char *end = NULL; long val = strtol(argv[1], &end, 0); // end 指向第一个无法解析的字符 if (end == argv[1] || *end != '\0') { fprintf(stderr, "无法解析: %s\n", argv[1]); return 1; } printf("解析结果: %ld (0x%lx)\n", val, val); return 0; }strtol第三个参数传 0 表示自动识别进制:0x 开头按十六进制,0 开头按八进制,其余按十进制。end在解析失败时等于argv[1],在解析成功后指向字符串末尾的\0。同时满足end != argv[1]和*end == '\0'才说明整段字符串都是合法数字。
实验题的输入格式一定要看清:是要求十进制、十六进制,还是两种都接受。如果题目明确说支持0x前缀,strtol的 base 传 0 最省事;如果题目只允许十进制,base 传 10,否则0x12会被错误地解析成 18。
5. 避坑指南:实验1最常见的5个翻车现场
5.1 现象:报错 undefined reference to 'main'
刚接触实验包的人喜欢自己建一个test.c,在里面写 main 函数来测别人的函数,最后提交时忘了删。评分脚本编译时会把你的实现文件和测试框架的 main 函数链接到一起,于是出现两个 main,链接器直接报错。原因很简单:提交文件中包含了不该出现的 main 定义。
解决方法是把测试代码和提交代码分开。实现文件只放题目要求的函数,不要放 main。自己的测试单独放在mytest.c里,这个文件不参与提交。提交前用grep -n "int main"检查一遍要交的文件,确保 main 只出现在测试文件里。
5.2 现象:本地测试全过,评分全挂
这个坑我见得太多了。本地用自己写的编译命令,比如gcc -O0 -m64,评分脚本用的是gcc -O1 -m32,两种编译选项下,未定义行为的表现完全不同。尤其是位操作题,-O0下的中间变量和-O1下的寄存器分配不一样,结果可能就变了。
解决方法是严格用实验包自带的 Makefile 构建,不要在本地另起一套编译参数。如果实验允许修改 Makefile,也只在变量层面改,不要动整体结构。提交前先make clean && make,再用评分脚本跑一次,确保和你自己测试用的产物完全一致。
5.3 现象:字节序打印出来的顺序和想象相反
写大小端测试程序时,很多人以为内存地址从小到大依次存放高字节到低字节,结果打印出来是反的。这不是程序错了,而是机器是 little-endian。原因在于 x86 系列处理器把低位字节放在低地址,这是历史设计选择,不是 bug。
解决方法不是去改打印逻辑,而是认识到字节序是平台相关属性。实验题如果要求“按内存地址顺序打印每个字节”,那就要按实际内存布局输出;如果要求“按数值高位到低位输出”,那就要手动移位。先读清楚题目要求的是“内存视角”还是“数值视角”,再决定要不要反转。
5.4 现象:换了机器之后分数漂移
自己在笔记本上跑 90 分,到课程服务器上变成 70 分。这类问题通常不是代码逻辑变了,而是机器位数、编译器版本、甚至 libc 版本不同造成的。比如sizeof(int)在 32 位和 64 位下都是 4,但sizeof(long)不一样;某些实现依赖了int的宽度假设,在 64 位系统上跨界了。
解决方法是把代码里所有和类型宽度相关的假设显式化:用int32_t、uint32_t等固定宽度类型,对移位位数加编译期检查,不依赖sizeof推算术。如果一个函数规定“x 是 32 位有符号整数”,那就把参数类型直接写成int32_t,而不是int。
5.5 现象:make 报错或者文件内容乱码,找不到原因
Windows 上编辑的代码传到 Linux 后,每行结尾可能带了回车符\r,Makefile 对这种文件非常敏感,会报 “missing separator” 或者奇怪语法错误。源码文件虽然能编译,但字符串比较类题目可能因为包含\r导致结果不对。
解决方法是用file命令查看文件类型,如果输出里有with CRLF line terminators,就说明格式不对。用sed -i 's/\r$//'去掉回车符,或者直接在 Linux 下重新用文本编辑器保存为 LF 格式。提交前养成习惯:所有文件统一用 LF 换行、UTF-8 无 BOM 编码。
6. 进阶:读懂测试脚本,把实验1的收获沉淀成回归基线
实验1做完别急着清理目录,我习惯用一个小工具把成果固化下来。具体做法是给每个需要验证的函数写一个 Python 脚本,读取测试用例,调用编译好的可执行文件,比对输出。脚本不复杂,但能让你后续改代码时随时知道“这次改动有没有影响之前的行为”。
#!/usr/bin/env python3 import subprocess cases = [ ("0x12345678", "0x78"), ("0x12345678", "0x0"), ("-1", "0xf"), ] for args, expected in cases: out = subprocess.check_output(["./solver", args], text=True).strip() status = "PASS" if out == expected else "FAIL" print(f"{status}: {args} -> {out}, expected {expected}")这个脚本做的事情很简单:把每个用例的输入传给程序,拿到 stdout 后和期望值做字符串比较。subprocess.check_output会捕获程序输出,text=True让输出以文本形式返回。你可以把边界值、随机值、评分脚本里的公开用例都塞进cases列表,后续每次改动后跑一遍,形成一个最小回归基线。
有了这个基线,再遇到“之前是好的,现在挂了”的问题,可以直接二分定位是哪次改动引入的。这个方法在实验1的帮助有限,但到了后面的数据实验和炸弹实验,你会感谢这个习惯。我后来做任何实验,都会先花十几分钟读测试脚本,看它用哪些参数、比较哪些字段、对格式有多严格,这个习惯帮我省掉了大量返工时间。实验1只是起点,把工具链、补码、字节序、测试意识这些地基打牢,后面的路会顺很多。希望帮到你。
本文还有配套的精品资源,点击获取