CSAPP实验1实战:追踪hello程序一生,建立计算机系统全景图

CSAPP实验1实战:追踪hello程序一生,建立计算机系统全景图 简介这是哈工大CSAPP实验1「计算机系统漫游」的配套资源包面向计算机专业学生、考研备考者及对计算机系统底层感兴趣的自学者用于解决入门实验中对二进制、内存、汇编等基础概念理解不深、实践无从下手的问题。压缩包约969.39MB包含实验代码、数据样本和实验指导文档等材料围绕二进制表示与运算、寻址与内存管理、汇编语言编程、函数调用与栈帧、C语言指针与内存布局、编译与链接流程、性能分析与系统调用等关键实验环节展开。已有629人浏览学习资源结构清晰既可对照指导文档逐步完成实验操作也可借助代码与数据深入验证运行机制最终撰写完整的实验报告。通过亲手调试和排错学习者能快速建立计算机系统的整体认知提升底层编程与问题分析能力是哈工大CSAPP课程实验1的实用辅助资料。 如果你正在学 CSAPP《深入理解计算机系统》翻到第一章《计算机系统漫游》的时候多半会觉得这不就是科普吗讲了一堆名词hello 程序走了一遍流程然后呢然后等到你打开 HIT csapp 实验1 的题目对着那一大串问题发懵的时候才会明白——课本上那 40 多页的漫游其实是一张全书的地图而实验1就是逼你把这张地图亲手铺开。这篇内容就是围绕 HIT csapp 实验1 写的。它适合正在做这个实验、或者刚学完 CSAPP 第一章、想知道这一步到底该学什么的读者。我会把实验背后要练的核心能力、环境怎么搭、hello 程序一生该怎么拆解、以及我实际做的时候踩过的坑和排查思路都过一遍。这些内容不针对某个具体答案而是给你一套能落到键盘上的方法让你做完实验之后不是交一份报告了事而是真的在脑子里建立起计算机系统的地图。1. 这个实验真正要你练的东西别把漫游当科普1.1 表面任务是追踪 hello 的一生底层任务是建立系统全景图HIT 实验1 的题目往往不复杂核心就是让你跟踪一个 hello.c 从源文件变成进程、最后输出字符串的完整过程。看起来像是做题写出预处理命令、编译命令、链接命令然后跑一遍、截几张图、回答几个问题。但你要是真把它当成填报告就浪费了这个实验。CSAPP 全书的结构非常特殊——第一章不是热身而是整本书的骨架。操作系统、硬件、编译器、链接器、运行时、网络、并发这些后续章节讲的所有机制在第一章都以 hello 程序为载体出现过一遍。实验1 就是检查你有没有看懂这张全景图。我见过不少同学交上去的报告readelf、objdump、strace 的输出贴了一大堆但问一句为什么链接器要生成两类不同的符号表或者hello 进程的虚拟内存里那几段分别是谁安排的就答不上来。原因就是他们把实验当成了命令执行练习而不是概念连接练习。所以做实验前我建议你先做一件事打开课本第一章把 hello 的执行流程用一张图自己画一遍从键盘输入 ./hello 开始一直到终端输出 hello world 结束。能画出来再做实验画不出来就先回炉看书。实验只是验证你的理解不是替代理解。1.2 hello 的一生课本第 1 章给了一张怎样的地图课本里那一串流程梳理下来大概是这样的源程序 hello.c 由预处理器展开头文件和宏生成 hello.i编译器把 hello.i 翻译成汇编程序 hello.s汇编器把 hello.s 翻译成机器指令打包成可重定位目标文件 hello.o链接器把 hello.o 和 libc 等库文件合并生成可执行目标文件 helloshell 通过加载器把 hello 加载到虚拟内存创建进程并跳转到入口CPU 取指执行指令和数据通过缓存、虚拟内存、TLB 层层访问程序调用 printf最终通过系统调用 write 把字符串写到终端。这张表你用文字看可能觉得啰嗦但它是整个实验1的操作地图。因为 HIT 实验1 的每个问题几乎都能对到下面这张表里生命阶段关键工具/命令对应 CSAPP 章节预处理gcc -E / cpp第 1 章 1.2编译gcc -S第 1 章 1.2汇编gcc -c / as第 1 章 1.2链接gcc / ld第 1 章 1.2、第 7 章加载运行./helloshell 与加载器第 1 章 1.4、第 8 章进程与虚拟内存ps、/proc/PID/maps第 1 章 1.5、1.6、第 9 章系统调用与 IOstrace、man syscalls第 1 章 1.5、第 10 章缓存与局部性perf、time第 1 章 1.7、第 6 章我强烈建议你把这张表打印出来放旁边。后面每执行一条命令、每记录一个输出都在表上找到对应位置。做完之后你对第一章的理解会比只看书深得多。2. 环境准备用 Docker 固定一套可复现的 Linux 工具链2.1 为什么我建议用 Docker 而不是在本机直接装HIT 实验1 很多同学会直接在 Windows 上装虚拟机或者用 WSL。说实话WSL 已经很好用了但有一个问题不同发行版、不同 gcc 版本、不同 glibc 版本会直接影响 readelf 和 objdump 的输出内容。比如你用的 Ubuntu 18.04 和同学的 Ubuntu 22.04反汇编出来的指令格式可能就有细微差异报告里贴出来的关键行和课本对照时容易对不上自己也会困惑。这时候 Docker 的优势就很明显它能把整套工具链固定在一个镜像里。你拉一个 ubuntu:22.04 的容器在里面装好实验需要的工具做完实验一删干净利落不会污染宿主机而且随时可以重新创建同一个环境。类比一下就是做菜的时候把配方固定下来面粉用哪个牌子、水放多少克都写死不管在谁家厨房做成品味道都差不多。2.2 拉一个 Ubuntu 容器并安装必备工具环境搭建的步骤不难几个命令的事# 拉取 Ubuntu 22.04 镜像并进入交互式容器把当前目录挂载到容器内 /workspace docker run -it --name csapp-lab1 -v $(pwd):/workspace ubuntu:22.04 bash # 进入容器后先更新源然后安装工具链 apt update apt install -y build-essential gdb binutils strace vim file这里我特别提醒两点build-essential这个包会一次性装好 gcc、g、make、libc-dev 等编译链接必需的东西。很多新手只装 gcc结果到链接那一步报缺库其实是基础包没装全。binutils提供 readelf、objdump、as、ld 这些工具。Ubuntu 镜像默认不一定带需要手动装。strace用来追踪系统调用实验里统计 hello 进程和内核打交道的过程非常有用。-v $(pwd):/workspace是把宿主机当前目录挂进容器这样你的 hello.c 写在宿主机上容器里也能直接用避免在容器内部编辑文件丢失。装完之后用gcc --version和readelf --version验证一下能正常输出版本号就算环境通了。我建议再顺手执行一句hello world的编译运行确认整个流程没问题再开始正式实验。3. 实操拆解把 hello 传送带的每个工位都看一遍3.1 预处理、编译、汇编、链接四个阶段到底各自做了什么这一步很多教程都会让你跑gcc -E、gcc -S、gcc -c、gcc四步但关键不在于跑命令而在于观察每个阶段的产物变化。先准备一个最简单的 hello.c#include stdio.h int main() { printf(hello, world\n); return 0; }第一步预处理gcc -E hello.c -o hello.i预处理做的事情是展开头文件、替换宏、处理条件编译。你打开 hello.i 会发现原本只有 6 行的源文件变成了几百行甚至上千行因为 stdio.h 里的一大堆声明都被塞进来了。你可以顺手wc -l hello.c hello.i对比一下行数这个数字对比比任何解释都有说服力。第二步编译gcc -S hello.i -o hello.s这一步把 C 代码翻译成汇编。打开 hello.s你会看到 main 函数对应的汇编片段movl $0, %eax、leaq .LC0(%rip), %rdi这种。这是你第一次能直观看到 C 里面的 printf 调用变成了什么。第三步汇编gcc -c hello.s -o hello.o汇编器把汇编指令变成机器码但此时生成的 hello.o 还是一个半成品。你可以用file hello.o看一下它会告诉你这是 ELF 可重定位目标文件注意关键词是 relocatable。这个文件里面的地址还没有被最终确定符号引用是待重定位的状态。第四步链接gcc hello.o -o hello链接器把 hello.o 和 libc 的库文件合并生成可执行目标文件。再次file hello你会看到输出变成了 ELF 64-bit LSB executable而且这时它已经可以被操作系统加载运行了。这四个阶段每一步都对应课本第 1 章的图 1.3 到 1.7。我自己的经验是每做完一步就停下来用file看一下产物类型再想一下这个阶段解决的是什么翻译问题。比如汇编阶段解决的是助记符到机器码链接阶段解决的是多个目标文件和库之间的符号引用。这样做完一遍你脑子里就不会再把 gcc 当成一个黑盒子了。3.2 从文件到进程用 readelf、objdump、strace 把它跑起来看编译链接完成之后hello 是一个躺在磁盘上的可执行文件。接下来要做的是看它怎么从文件变成进程。先用 readelf 看可执行文件的头部信息readelf -h hello重点关注 Entry point address入口点地址和 Program Headers 的数量。入口点就是加载器把程序加载到内存后CPU 第一条指令的地址。你可以顺手对比一下readelf -h hello.o会发现可重定位文件里很多字段和可执行文件不一样这就是还没链接好和链接好了之间的区别。再看程序头表Segmentreadelf -l hello这里你会看到 LOAD 段。课本上说的加载器将可执行文件的内容映射到虚拟内存指的就是操作系统根据这些 LOAD 段的信息把文件中的代码段、数据段映射到进程的虚拟地址空间。你可以记下 LOAD 段的 VirtAddr后面在/proc/pid/maps里核对。接着用 objdump 反汇编objdump -d hello | grep -A 20 main观察 main 函数里对 printf 的调用方式。你可能会看到它通过call指令跳到一个 PLT过程链接表里的桩函数而不是直接跳到 printf 的实现地址。这就是延迟绑定的雏形说白了就是第一次调用 printf 时再去动态库里找真正的函数地址。这个概念在第七章会展开讲但实验1里你先见到它就有个印象。最后是运行阶段用 strace 追踪系统调用strace -f -o trace.log ./hello-f参数很重要它告诉 strace 同时跟踪 fork 出来的子进程。虽然 hello 只有一个进程但加上这个参数更安全。打开 trace.log你会看到一堆系统调用execve(./hello, ...)加载器被调用当前进程被替换成 hello 的代码和数据brk(NULL)、brk(0x...)扩展堆为 printf 的缓冲区等做准备write(1, hello, world\n, 13)把字符串写到标准输出fd 1 就是终端exit_group(0)进程退出。看到这里你应该有一种哦原来 hello 的一生是这样的感觉。再用./hello 让程序在后台跑然后cat /proc/$!/maps查看它的虚拟内存布局核对代码段、堆、共享库、栈的位置。这一步完成后第一章 1.4 到 1.6 的内容就基本在你脑子里活了。4. 我在这个实验里踩过的坑附排查思路4.1 系统调用数量统计对不上strace 的 -f 参数是个大坑我第一次做这个实验的时候统计 hello 的系统调用数量怎么数都跟同学的不一样。折腾了半天发现自己犯了一个低级错误# 错误示范没有加 -f strace -o trace.log ./hello这次追踪到的 write 调用出现在最前面但后面的很多系统调用尤其是子进程或者某些通过 clone 创建的子任务产生的调用都没有被记录。虽然 hello 本身不 fork 子进程但在一些复杂一点的例子里动态链接器的辅助进程、或者你用 shell 脚本调用时的子 shell都会让结果不完整。排查思路很简单先看 trace.log 里第一行是不是execve如果不是说明跟踪起点不对再看有没有exit_group或exit收尾。正常情况下一个完整的进程生命周期应该从 execve 开始到 exit group 结束。中间你可以用这行命令统计每种系统调用出现次数awk {print $2} trace.log | sort | uniq -c | sort -rn这个输出比肉眼数靠谱多了。如果你发现 write 的次数和代码里 printf 调用的次数对不上先别怀疑系统调用这层去检查你的代码和重定向方式这就引出第二个坑。4.2 printf 的重定向与缓冲区为什么输出顺序会变如果把实验里最简单的 hello.c 改成一行代码里塞两个 printf或者加一个 fork你会发现一个很有意思的现象直接在终端跑输出是 2 行但重定向到文件里输出变成了 4 行甚至顺序都变了。c #include stdio.h #include unistd.hint main() { printf(hello ); fork(); printf(world\n); return 0; }直接运行终端会输出两遍 hello world。但如果 ./hello out.txt文件里会出现 4 行内容。原因在于printf 是带缓冲区标准库函数用户态缓冲区的内容默认是行缓冲遇到换行或者全缓冲重定向到文件时变成全缓冲。fork 的时候缓冲区还没有被刷出去子进程复制了父进程整个地址空间当然也包括那份还没写出的缓冲区。于是父进程、子进程各自在退出时 flush 一遍内容就重复了。 这个坑在实验1里其实是个隐藏加分点。它说明你已经理解了用户态的 stdio 缓冲和内核态的 write 系统调用是两层东西。排查这类问题的方法很简单在关键位置加 fflush(stdout); 看行为是否变化或者用 strace 看 write 调用出现的时机就能定位到底是谁复制了缓冲区。 ### 4.3 写报告时最不该踩的坑把课本目录当章节 这是我最想提醒的一件事。HIT 实验1 的报告我见过不少写得像课本目录摘抄第一章讲了什么、第二章讲了什么然后贴几张命令截图就完了。但实验报告的考察点不是你对目录的复述能力而是你有没有真正操作过、有没有把现象和概念对应上。 我自己的经验是报告里每写一条命令就要回答三个问题 - 这条命令的输入是什么、输出是什么 - 输出里的关键字段代表什么含义 - 这个字段和课本里的哪个机制是对应的 比如我贴 readelf -l hello 的输出时不仅要写有一个 LOAD 段还要写这个 LOAD 段把文件偏移 0x2e0 处的代码映射到虚拟地址 0x401000权限是 R E这说明它对应的是代码段加载器就是根据它来设置进程虚拟内存的。 这样的报告不需要多长但每个结论都有出处老师一眼就能看出来你是真的做完了整套流程而不是复制了几行输出。 ## 5. 做完之后我留下了一套什么样的系统心智模型 ### 5.1 硬件、OS、编译器、链接器各管一段合起来是一台完整的机器 做完实验1我脑海里一直留着一句话hello 的一生本质上是信息在不同形态之间翻译和流转的过程。 翻译发生在编译器这边C 源码先是变成文本形式的汇编再变成二进制形式的目标文件然后经过链接变成可执行文件。流转发生在操作系统这边加载器把磁盘上的可执行文件搬到虚拟内存创建进程、分配栈和堆CPU 一条接一条地取指执行遇到 printf 就触发系统调用数据穿过缓存和内存最终写到终端设备上。 你可以用一个厨房类比来理解编译器是写菜谱的链接器是配菜打包的加载器是后厨的排单员进程是每个厨师的工作台虚拟内存是每个厨师专属的一块案板厨房设备的缓存就是手边最常用的调料瓶。实验1 就是让你从头到尾看一遍一份食材变成端上桌的菜的完整过程。这个过程看明白了后面章节讲的每一个机制都只是某个环节的更深入展开。 ### 5.2 这个实验对后续 Shell Lab、Malloc Lab、Proxy Lab 意味着什么 很多同学做完实验1之后紧接着去做实验2、实验3会觉得跨度很大。但从我的经验看实验1留下的概念地图几乎是后面所有实验的地基 - 做 Shell Lab 的时候你要实现 shell 的 job 控制。如果你在实验1里理解了 fork、execve、waitpid 这些系统调用是进程生命周期的关键节点shell 的设计就不会那么难。 - 做 Malloc Lab 的时候你要自己写 malloc 和 free。如果你在实验1里理解了虚拟内存中堆段的位置、为什么 brk 和 mmap 都能扩展堆你就能明白 malloc 到底是在操作什么而不会把它当成一个纯算法题。 - 做 Proxy Lab 的时候你要写一个并发网络代理涉及大量的 read/write 和缓冲区处理。如果你在实验1里已经理解了系统调用返回值的处理、缓冲区和文件描述符的概念写网络代码的时候会顺手很多。 所以我一直觉得实验1 不是一个可以水过去的实验。你在这个实验上多花半天时间后面几个实验就能少熬几个夜。 最后分享一个小技巧做这个实验的时候把课本第 1 章的图 1.4 到图 1.12 打印出来放旁边每在终端执行一条命令、每看懂一个输出就在图上对应的位置画一个勾。等你把整张图都画满了再去写实验报告你会有一种这知识它进脑子了的感觉。别问我怎么知道的我第一次做的时候画满图之后写报告几乎没翻课本全部内容都是顺着地图自己讲出来的。这就是实验1 该有的效果。 p a hrefhttps://download.csdn.net/download/qq_62260432/87811415 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p