CSAPP实验1漫游:用gcc、strace与ELF解析跑通计算机系统生命周期观测
简介这套压缩包是哈工大CSAPP实验1“计算机系统漫游”的配套学习材料面向计算机专业本科生及系统入门学习者帮助完成实验的同时建立对底层运行机制的整体认知。资源以zip形式打包体积约969.39MB包含实验涉及的代码、数据与指导文档对应实验报告中需要分析的二进制转换与位运算、虚拟地址与页表映射、x86指令集汇编、函数调用栈帧、C指针与结构体内存布局、编译链接流程、性能剖析及系统调用等核心环节。读者借助其中材料可练习十进制与十六进制转换、按位与/或/异或运算理解参数传递与堆栈帧的建立销毁并学会用gprof、perf等工具分析程序性能系统梳理实验1知识脉络。目前已有630人学习下载适合课程同步练习、考前回顾或自学计算机系统基础的场景。1. 实验1到底在做什么从 hello 到屏幕的一次完整漫游CSAPP 实验1计算机系统漫游最劝退的地方不是代码量而是它要求你把一个 hello 程序从源文件到屏幕输出的每一站都讲清楚。很多同学上来就 gcc hello.c -o hello跑完就以为结束结果卡在进程启动后到底经历了什么这个问题上。这个实验适合两类人一类是刚学完 C 语法、想拆开编译器与操作系统黑匣子的新手另一类是能调程序但说不清 /proc/maps 和系统调用关系的开发者。把漫游这条线走通后续数据实验和攻击实验会少踩很多坑。实验成绩的关键也不是输出对不对而是你能不能用自己的命令输出和观测数据把这条链路串起来。2. 环境准备gcc、binutils、strace 组成的最小观测台2.1 为什么这个实验离不开 Linux 与三个基础工具CSAPP 漫游实验的观测对象是程序在操作系统里的完整生命周期。在纯图形界面上也能编译运行但你观察不到三样关键东西ELF 文件格式、/proc 文件系统、系统调用级别的行为追踪。实验手册里的 readelf、objdump、strace 都是 Linux 生态里的标准工具所以环境第一步就是准备一台 Linux 机器。常见做法是装一个主流发行版或者用一台闲置的 Linux 服务器。我个人建议不要在图形界面里折腾命令行操作反而更贴近后面几个实验的节奏。三个工具各管一段gcc 负责把源码变成可执行文件binutils 里的 readelf、objdump、size、file 负责解析可执行文件的内部结构strace 负责在程序运行时监听它进入内核的每一次系统调用。配合 /proc 伪文件系统你就能同时看到静态结构和动态行为两条线。这里要提醒一句不要用在线编译器代替本地环境。在线编译器只给你看运行结果不给你看文件、进程和系统调用而实验报告的素材恰恰来自这些观测手段。提示实验期间所有编译命令建议都加 -g 参数生成调试符号。没加 -g 的二进制也能反汇编但函数名会变成一串地址报告里写起来很痛苦。2.2 最小安装与验证三行命令确认工具链可用在一个带 apt 的 Linux 发行版上安装命令如下sudo apt update sudo apt install -y gcc make binutils file strace ltrace gcc --version readelf --version strace -V安装完成后gcc --version 会输出编译版本readelf --version 验证 binutils 就位strace -V 输出跟踪工具的版本。注意 make 建议显式安装因为实验手册里很多编译命令借助 Makefile 组织缺了 make 会直接报 make: command not found。ltrace 不是必须的但它能跟踪库函数调用在第 5.2 节排查 printf 时很有用。第一个验证实验写一个 hello.c编译后检查文件类型。cat hello.c EOF #include stdio.h int main(void) { printf(hello, system\n); return 0; } EOF gcc -g -o hello hello.c file hello正常输出会看到 ELF 64-bit LSB pie executable。看到 ELF 三个字母说明文件已经是 Linux 可执行格式后续 readelf 解析才有对象。如果输出是 cannot open 或者 gcc: command not found回到安装步骤逐个排查。对大多数同学来说64 位环境默认就够用不要手欠加 -m32除非实验手册明确要求否则会撞上 32 位运行库缺失的问题白白消耗半天。2.3 常用观测指令速查实验1 会反复用到这些命令下面这张表是实验1最常用的观测指令每个命令对应一个观测维度后面章节会逐个展开。命令观测对象典型用途file hello文件格式确认是可执行文件、位数、是否动态链接size hello段大小看 text/data/bss 三段的字节数readelf -h helloELF 头看入口点地址、程序头表位置、节区表位置readelf -S hello节区表看 .text/.data/.bss/.rodata/.interp 等关键节readelf -l hello程序头表看可执行文件加载到内存时的段映射objdump -d hello反汇编把 .text 里的机器码还原成汇编指令nm hello.o符号表看哪些符号未定义U或已在当前目标文件中定义ldd hello动态库依赖显示程序运行时依赖哪些共享库strace ./hello系统调用跟踪 execve、write、exit_group 等调用cat /proc/PID/maps虚拟内存查看进程地址空间里的内存映射这些命令都不需要额外配置。实验报告里的很多示意图其实就是把这些命令的输出整理成流程。file 告诉你文件格式readelf -l 告诉你要加载哪些段strace 告诉你加载之后调用了什么。建议每跑一条命令就把输出重定向保存到 lab1/ 目录后面写报告时统一引用而不是临时补截图。3. 编译链接阶段用 gcc -E/-S/-c/-o 把 hello.c 拆成四步3.1 预处理、编译、汇编、链接分别看什么实验1 的漫游起点不是 main 函数而是源文件。常见做法是用 gcc 的分阶段参数把 hello.c 从文本变成机器码的每一步都停下来观察。gcc -E hello.c -o hello.i gcc -S hello.c -o hello.s gcc -c hello.c -o hello.o gcc -o hello hello.o-E 只做预处理展开 #include、处理 #define、删除注释生成一个仍然能读懂但行数暴增的 .i 文件。-S 把预处理结果翻译成汇编这一层能看到 main 对应的汇编骨架比如 printf 会被编译成一个 call 指令。-c 生成可重定位目标文件 .o里面的地址还是占位符不能直接运行。最后一步把 .o 和 C 运行库链接成真正可执行文件。四个阶段的特征可以浓缩成一张表阶段命令产物关键特征预处理gcc -E hello.c -o hello.ihello.i头文件展开文本可读编译gcc -S hello.c -o hello.shello.s汇编代码可读但未汇编汇编gcc -c hello.c -o hello.ohello.o机器码未链接链接gcc -o hello hello.ohello可执行文件地址已解析先解释重定位的概念编译每个 .c 文件时编译器并不知道 printf 最终会在内存里的哪个地址也不知道其他文件里定义的全局变量在哪所以它在 .o 里留了很多待填的空位等着链接器统一填充。你如果单独 gcc -c 之后直接执行 ./hello.o会报 cannot execute binary file因为它还没有被链接成可执行格式。链接阶段做的事把多个 .o 的 .text 合并成一段把 .data 合并成另一段解析符号引用最后确定虚拟地址。实验1 里最容易写进报告的一句话是可执行文件的地址不是编译时决定的而是链接时决定的。这句话能解释为什么每次重新编译入口地址可能变化特别是开启 PIE 之后。3.2 用 readelf/objdump 把 ELF 的内部结构拍下来编译链接之后可执行文件是二进制黑盒。要把它变成报告素材用 readelf 是标准姿势。readelf -h hello readelf -S hello | grep -E \.text|\.data|\.bss|\.rodata|\.interp|\.dynamic readelf -l hello-h 看 ELF 头重点关注 Entry point 这一项它告诉你第一条用户态指令的虚拟地址。-S 列出节区表.text 是代码、.data 是已初始化全局变量、.bss 是未初始化全局变量、.rodata 是只读常量比如字符串 hello, system\n 就放在这里。-l 看程序头表描述这个文件加载进内存后哪些字节放进哪些地址空间。这两者经常被搞混-S 展示文件内部的节-l 展示运行时内存的段。报告里画图时建议把这两层分开画老师追问细节时你才答得清楚。objdump 负责把机器码还原成可读的汇编objdump -d hello | grep -A 30 main这能直接看到 main 函数里的 call printfplt也就是调用动态链接器里的 printf。漫游到这一步你已经能看到从文本到汇编到机器码的完整链条。想进一步验证 .o 里留下的空位可以用 nm 和 objdump 看重定位信息nm hello.o objdump -r hello.onm 输出里会出现U printfU 表示 undefined即 printf 这个符号还没有被解析。objdump -r 会列出重定位条目它告诉你链接器需要在哪些位置填上真正的地址。这两个命令的输出放在报告里比纯文字描述重定位更有说服力。3.3 ldd 与动态链接hello 运行时依赖了哪些库动态链接是现代 Linux 默认的链接方式hello 本身并不包含 printf 的实现它只包含一个指向动态库的引用。ldd 能列出这些引用ldd hello readelf -d hello | grep NEEDEDldd 输出里通常有 libc.so.6 和 ld-linux-x86-64.so.2前者提供 printf、malloc 等 C 库函数后者是动态链接器负责在程序启动时把 libc 加载进内存并把符号解析到位。readelf -d 里的 NEEDED 条目和 ldd 输出对应一个看文件里声明依赖哪些库一个看运行时实际解析到哪些库。注意区分ldd 会真的启动一次动态链接器来解析路径所以它可能受到 LD_LIBRARY_PATH 影响而 readelf 只是静态读文件里的 NEEDED 记录。值得多做一步的观察把 hello 改成静态链接。gcc -static -o hello_static hello.c ls -lh hello hello_static ldd hello_static静态链接版本通常会从几 K 涨到几百 K因为 libc 被整个拷贝进了文件。如果报错找不到 libc.a先补装静态库再试。ldd 对静态版本输出 not a dynamic executable这个对比能让动态/静态链接的概念在报告里立起来。实验1 不需要深入重定位细节但能解释清这个体积差是明显的加分项。如果 ls 显示的大小差别不明显检查一下是否链接器自动用了 PIE可以加-no-pie再编译一版对比。注意readelf -d hello | grep NEEDED 的 NEEDED 顺序通常和 ldd 左侧的依赖顺序一致但不要用它来推断加载顺序加载顺序由依赖关系决定不是简单按列表顺序。4. 加载运行阶段execve、/proc 与 strace 联合观测进程4.1 shell 如何启动 hellofork execve 的协作编译链接结束进入运行这个动态阶段。你在终端里输入 ./hello 回车shell 会先 fork 出一个子进程然后用 execve 把 hello 的可执行文件从磁盘读入内存。fork 可以理解为复制当前进程execve 则是用新程序覆盖这个复制的进程。两个调用一前一后缺一不可。实验1 里要看到这个过程最直接的方式是用 strace 直接跟踪 hellostrace -e traceexecve ./hello输出里第一行就是 execve(./hello, [./hello], ...)这一瞬间就是程序被操作系统加载的起点。但你看不到 shell 的 fork因为 strace 只跟踪了 hello 自己。想把 shell 的动作也拆开可以写一个简化版 shell#include unistd.h #include sys/wait.h #include stdio.h int main(void) { pid_t pid fork(); // 复制出子进程 if (pid 0) { execl(./hello, ./hello, NULL); // 子进程被 hello 覆盖 } else { wait(NULL); // 父进程等待子进程结束 } return 0; }这个 C 程序几乎就是 shell 行为的简化版。编译后从外部跟踪gcc -g -o run_hello run_hello.c strace -f ./run_hello-f 参数要求跟踪 fork 出来的子进程。输出里会依次出现 fork、execve、openat、write、exit_group。execve 的参数会显示 ./hello说明子进程确实被新程序替换。把这一段的 strace 输出贴到报告里shell 如何启动一个程序就讲清楚了。4.2 /proc/PID/maps进程地址空间长什么样程序运行起来的瞬间操作系统要为它搭建虚拟内存。最直观的观测窗口是 /proc 文件系统。但如果你直接 ./hello进程几乎瞬间结束根本来不及查 /proc。常见做法是让程序配合睡眠cat slow_hello.c EOF #include stdio.h #include unistd.h int main(void) { printf(hello, system\n); sleep(30); return 0; } EOF gcc -g -o slow_hello slow_hello.c ./slow_hello PID$! cat /proc/$PID/maps kill $PID只要程序不退出/proc/$PID/maps 每次读取都能看到当前地址空间的布局。每一行的字段依次是地址范围、权限位、偏移量、设备号、inode、路径名。一次典型的输出大致是00400000-00401000 r-xp 00000000 08:01 12345 /home/user/slow_hello 00401000-00402000 r--p 00001000 08:01 12345 /home/user/slow_hello 00402000-00403000 rw-p 00002000 08:01 12345 /home/user/slow_hello ... 7ffc8a000000-7ffc8a021000 rw-p 00000000 00:00 0 [stack]对照 readelf -l 的输出你能看到文件里的段和内存里的映射如何一一对应。权限位里 r-x 是代码段r-- 是只读数据段rodatarw- 是数据段和堆栈。还要强调一个点maps 里的地址是虚拟地址不是物理地址。数据可能还在磁盘文件里可能已经读进内存甚至可能在 CPU 缓存里操作系统通过页表把虚拟地址翻译成物理地址。CSAPP 漫游一章里的 cache、主存、磁盘分工在 maps 输出里就能讲出一层实际意义。4.3 strace 输出printf 到底做了什么系统调用实验1 里最容易问倒学生的问题printf 是不是系统调用答案是否定的。printf 是 glibc 库函数它把格式化好的字符串放进缓冲区真正触发内核的动作是把缓冲区内容写到文件描述符 1标准输出也就是 write 系统调用。用 strace 能直接看到这个事实strace -e tracewrite ./slow_hello输出里会有一行write(1, hello, system\n, 14) 14。注意 14 是字符串长度write 的第三个参数是字节数。你还可以顺手验证printf 的返回值是 14write 的返回值也是 14两者往往一致但 printf 多做了格式化和缓冲。这一层的差别正是库函数和系统调用的经典分界线。想完整看进程从生到死的系统调用谱系用 strace 加时间戳strace -tt -T ./slow_hello-tt 输出每个系统调用发生的时间-T 显示每个调用消耗的时长。实验报告里加上这段输出能证明观察是实时的、可复现的。不过 sleep(30) 会让 strace 卡住 30 秒建议把 slow_hello 里的睡眠改成 2 秒避免自己等得心慌。进程退出前strace 末尾会看到 exit_group(0)它表示进程组里的所有线程一起退出。至此从源文件到进程退出的完整路径已经全部在你的终端里了。注意用 ./slow_hello 的方式启动后记得 kill $PID 或者 wait不然它会一直占着 PID写脚本时加一行 trap 清理是更好的习惯。5. 避坑手册实验1 最常见的四个翻车现场与排查如果你在实验1 里翻过车大概率是踩了下面四个坑之一。它们不是功能缺陷大多是工具用法和理解层次的问题。先说结论实验1 的验证环节不能只靠眼睛看终端输出必须靠命令记录、重定向保存和关键参数复现否则报告里的每个截图都经不起追问。5.1 现象readelf -S 里全是看不懂的 .gnu.* 段以为自己写错了原因现代 gcc 默认生成很多元数据段比如 .comment、.note.gnu.property、.gnu.hash、.got.plt它们记录编译器版本、安全属性、全局偏移表等信息。它们不影响运行每次编译都会出现实验1 不需要逐个解释。解决不要纠结冷门段用readelf -S -W hello换宽输出报告里只挑 .text/.data/.bss/.rodata/.interp/.dynamic 六类关键节说明。如果实在好奇 .got.plt就补一句该过程涉及延迟绑定细节在链接阶段展开既体现了思考又不跑题。5.2 现象strace 里找不到 printf只看到 write原因strace 只跟踪系统调用printf 是库函数库里最终调用 write 系统调用。中间还有缓冲机制比如未加换行符的字符串不会立刻 flush可能要到进程退出才写。解决先用ltrace ./hello看库层调用能看到 printf再用strace ./hello看内核层调用能看到 write。把两组输出并排放在报告里恰好构成库函数 vs 系统调用的分层图。A同学当时卡了整整一个下午就是没搞懂这两层工具的区别。5.3 现象/proc/PID/maps 打开后找不到 hello 自己只有一堆 .so原因进程执行太快等你敲 cat 时它已经退出看到的可能是被复用的其他进程地址空间。另一个常见原因是权限普通用户读自己启动的进程没问题读别的用户启动的进程会被拦截maps 输出为空或报错。解决不要直接观察瞬时进程用一个带 sleep(30) 的版本后台启动后立刻读取。排查权限时用同一个用户执行和读取别用 sudo 登录另一个会话去看。读取结束后 kill 掉进程并在报告里记录 PID 号方便阅读者复现。5.4 现象gcc -E 生成的 hello.i 巨大编辑器直接卡死原因hello.c 只有几行但 #include stdio.h 会递归展开上百个头文件生成几千行文本。图形编辑器要做全量语法高亮性能差一点的机器直接卡住。解决别用编辑器打开。用wc -l hello.i看行数用grep -v ^# hello.i | head -50看过滤后的有效代码。实验报告里只需要写预处理把 stdio.h 展开成三千多行再附两行关键内容即可不需要整文件截图。那一周好几个同学在群里喊死机其实都是同一个原因。如果你还遇到其他怪现象可以按下面这张表先自查报错或现象排查方向gcc: command not found工具链未安装或 PATH 未配置cannot execute binary file用了 -c 生成的 .o 直接执行/proc/PID/maps 为空权限不足或进程已退出strace: command not found未安装 strace 包静态链接后文件巨大正常现象libc 被整体拷贝6. 进阶把零散命令合成一键观测脚本让实验数据可复现写报告时最怕的是数据是手动补的老师一问命令就露馅。建议把前面几章的命令整合成一个观测脚本每次实验只跑一次脚本输出全部存档。#!/bin/bash # usage: ./observe.sh hello BIN${1:?usage: ./observe.sh binary} echo file type file $BIN echo ELF header readelf -h $BIN | sed -n 1,18p echo program headers (LOAD/DYNAMIC/INTERP) readelf -l $BIN | grep -E LOAD|DYNAMIC|INTERP echo dynamic deps ldd $BIN echo strace summary strace -c -o /tmp/trace_sum.txt $BIN cat /tmp/trace_sum.txt脚本里的 strace -c 是关键它以汇总表形式输出每个系统调用的调用次数、错误次数和耗时而不是刷屏。配合前面章节的 /proc/maps你已经能把静态文件结构 动态系统调用两条证据链都留档。写报告时只要把终端回显保存为 .txt每个结论都能回溯到具体命令这就是可复现性。进阶一步给脚本加一个时间戳文件名比如./observe.sh hello lab1_report_$(date %Y%m%d_%H%M%S).txt这样每次观测结果互不覆盖老师追问数据来源时你还能回放当时的命令。我自己的习惯是每跑一个实验阶段就保存一份输出文件名带上时间和命令名。实验1 做完之后后续的数据实验和攻击实验都沿用这个脚本思路省掉了大量重复截图的时间。这个习惯比任何小技巧都值钱。希望帮到你。本文还有配套的精品资源点击获取