简介:这份文档面向计算机系统课程学习者与准备CSAPP大作业的学生,以Hello程序从源码到可执行文件、再到运行与终止的生命周期为主线,系统串联计算机系统概述、预处理、编译、汇编、链接、进程管理、信号与异常、内存管理及输入输出管理等核心知识。压缩包仅含一个文档文件,类型为doc,大小约6.44MB,正文按章节组织,涉及Ubuntu环境下的命令操作、预处理后的头文件展开与宏替换、编译汇编中间结果、可重定位目标文件结构、进程与异常分析等内容,便于对照课程要求梳理实验流程。资源已有358人学习或下载,适合用作大作业报告框架参考、底层知识复习和实验步骤查漏补缺;读者可借此理解Linux下程序生成、加载与运行的关键环节,并积累报告撰写与过程分析思路。
1. 一张知识地图:hello 从 hello.c 到进程退出要穿过哪几层
CSAPP 的知识点散在教材十几个章节里,但一份 hello 程序的生命周期作业就能把它们串成一条完整主线。把这条线摊开看,是一层层向下穿透的过程:源文件层做预处理与编译,目标文件层做汇编与链接,机器指令层落到寄存器与栈帧,内核层走 fork 与 execve,硬件层做地址翻译与缓存,设备层收在 I/O 接口上。每一层都留下一个可以被验证的实物——hello.i、hello.s、hello.o、hello、进程号、页表项、文件描述符,复习时能指着产物说话,比背概念靠谱得多。
这份笔记适合已经学过一轮、但知识点在脑子里还是零散状态的人。先看清自己现在停在哪一层,再按层往下补细节,比从头重读一遍教材省时间。中间几章按“是什么、重点在哪、怎么验证”的顺序排,最后一章给出可照着走的复习清单。
2. 构建链路四步:四个中间文件与它们各自留下的证据
一个 hello.c 走到可执行文件 hello,编译系统把它切成四个阶段,每个阶段都留下一个能打开看的文件。很多人复习时记住了四个阶段的名字,却说不出每个阶段的输入输出边界在哪、产物里多了什么少了什么、报错时该回头看哪个文件。真正吃透这条链路的标志是:拿到任意一个中间文件,你能说出它是文本还是二进制、里面有没有符号表、能不能被加载器直接执行。下面按四个阶段的顺序拆,每段配一条命令、一份产物结构、一个验证动作。
2.1 预处理:hello.i 里多出来的三千行是什么
预处理器是整套工具链里最容易被低估的一环。它做的事听着简单——把以 # 开头的行解释成预处理指令并执行——但它直接决定了编译器前端看到的源文件长什么样。ISO C 要求支持的指令包括条件编译族 #if、#ifdef、#ifndef、#else、#elif、#endif,宏定义 #define,源文件包含 #include,行控制 #line,错误指令 #error,实现相关杂注 #pragma,以及单独一个 # 构成的空指令。这些指令的共同点是:在词法分析之前就已经被处理干净,编译器根本看不到它们的存在。
| 指令 | 作用 | 处理时机 |
|---|---|---|
| #include | 把指定文件递归复制到当前位置 | 预处理最开始 |
| #define | 宏名替换为实际值,可带参数 | 文本扫描时逐个替换 |
| #if / #ifdef 系列 | 按条件裁剪代码块 | 宏替换之后判定 |
| #line | 改写后续行号与文件名标记 | 影响报错定位 |
| #error | 条件不满足时直接中断编译 | 判定为真时触发 |
| #pragma | 传给具体实现的杂注 | 由实现自行解释 |
实际操作只要一条命令,-E 的含义是“停在预处理阶段,不往下走”:
# 只做预处理,-E 表示到此为止,产物仍是 C 文本 gcc -E hello.c -o hello.i # 等价的独立预处理器调用,用重定向写入文件 cpp hello.c > hello.i # 对比行数,直观看到展开规模 wc -l hello.c hello.i参数上,-E 是阶段开关,-o 指定输出文件名;如果省略 -o,结果会直接打到标准输出,容易刷屏。cpp 是预处理器本体,gcc -E 内部调用的就是它,两者结果一致。作业里观察到的现象是 hello.c 展开成 3065 行,而开发者写的源程序出现在第 3046 行及之后——前面三千行几乎全是展开内容。这些内容归为三类:stdio.h、unistd.h、stdlib.h 三个头文件被逐层递归复制进来,头文件里还套着头文件,展开量迅速放大;宏定义被替换成实际值,宏名在 hello.i 里已经不存在;条件编译把不满足条件的分支整段裁掉,同时所有注释被清除。
提示:在 hello.i 里搜索以 # 开头的行,会看到大量形如
# 1 "hello.c"的标记。那是 #line 留下的行号信息,用来让后续阶段的报错指回原始文件,不是注释。
2.2 编译:五个阶段与 hello.s 里的伪指令
编译是整个链路里理论密度最高的一段。它的任务是把预处理后的 C 文本翻译成汇编语言程序,工作过程分成五个阶段:词法分析把字符流切成记号并标注非法记号;语法分析把记号序列按文法归约成中间表示并建立符号表;语义检查与中间代码生成做静态语义核对;代码优化把中间表示换成功能等价但更省时间或空间的版本;目标代码生成输出汇编。前两个阶段合称源程序分析,语法错误就报在这一步。
# 从预处理结果生成汇编,-S 停在编译阶段 gcc -S hello.i -o hello.s # 也可以从 .c 一步到 .s,gcc 会自动先跑一遍预处理 gcc -S hello.c -o hello.s-S 是阶段开关,产物 hello.s 依然是纯文本,可以直接用编辑器打开。这一步的关键认知是:hello.s 里以点开头的名字不是指令助记符,不会被翻译成机器码,它们是给汇编器的指示,称为汇编指示或伪操作。把它们认全,读汇编才不会卡壳。
| 伪指令 | 含义 |
|---|---|
| .file | 声明源文件名,供调试信息使用 |
| .text | 其后内容归入代码段 |
| .globl | 声明符号为全局,链接时对其他模块可见 |
| .data | 已初始化的全局变量与静态变量区 |
| .rodata | 只读数据区,常放字符串常量与 #define 常量 |
| .align | 指定后续数据的地址对齐方式 |
| .section | 显式切换到某个段 |
| .long / .string | 声明 long 型数据 / 声明字符串 |
| .size / .type | 声明符号大小 / 声明是函数还是对象 |
2.3 汇编:hello.s 到 hello.o 的 ELF 可重定位格式
汇编器把 hello.s 翻译成机器语言指令,并把这些指令打包成可重定位目标程序的格式,结果写进 hello.o。注意这里的“可重定位”三个字:hello.o 里的地址还不是最终地址,凡是引用了外部符号或全局数据的地方都留着空位和重定位条目,等链接器来填。这也是它不能直接执行的原因。
# 汇编:文本汇编文件 -> 二进制可重定位目标文件 gcc -c hello.s -o hello.o # 把 ELF 的全部结构导出成可读文本,方便逐节对照 readelf -a hello.o > hello.elf # 只看节头表,快速确认有哪些节、各自多大、文件偏移在哪 readelf -S hello.o-c 表示“编译或汇编到目标文件为止,不链接”。readelf -a 会把 ELF 头、节头表、符号表、重定位表、反汇编统统打出来,量很大,重定向到文件再慢慢看更实际。下面的节区表是读 hello.o 时必须能对号入座的:
| 节 | 内容 | 特点 |
|---|---|---|
| .text | 已编译的机器代码 | 只读可执行 |
| .rodata | 只读数据,如格式串、跳转表 | 只读 |
| .data | 已初始化的全局与静态变量 | 可读写,占文件空间 |
| .bss | 未初始化或初始化为 0 的全局与静态变量 | 只占内存不占文件空间 |
| .symtab | 符号表,记录函数与全局变量 | 链接时被读取 |
| .rel.text / .rel.data | 代码段与数据段的重定位条目 | 链接器据此改地址 |
| .debug | 调试符号,需 -g 生成 | 可剥离 |
| .strtab | 符号名对应的字符串表 | 与 .symtab 配合 |
.bss 那一行是高频考点:它不在文件里占实际字节,只记录“需要多少字节、起始在哪”,加载时由内核把对应内存清零。所以在 hello.o 的节头表里能看到 .bss 有大小,却没有对应的文件内容。
2.4 链接:符号解析、重定位与虚拟地址空间的定型
链接器做两件事:符号解析,把所有目标模块里引用的符号和定义的符号对上号;重定位,把每个符号的引用改写成最终运行时的地址。做完这两步,输出的 hello 才有确定的入口地址和段布局,能被加载器直接送进内存执行。
# 链接:把 hello.o 与所需库合并成可执行文件 gcc -o hello hello.o # 查看 ELF 头,重点是入口地址与文件类型 readelf -h hello # 查看程序头表,也就是加载器眼里的段视图 readelf -l hello # 查看依赖的共享库,验证动态链接是否生效 ldd hello这里有个容易混淆的点:节头表是给链接器看的,程序头表是给加载器看的,两者不是一一对应。链接之后再看 hello 的虚拟地址空间,布局大致如下:
| 区域 | 位置 | 内容 |
|---|---|---|
| 只读代码段 | 低地址,典型从 0x400000 起 | .init、.text、.rodata |
| 读写数据段 | 紧接代码段之后 | .data、.bss |
| 运行时堆 | 数据段之后,向高地址增长 | malloc 分配的内存 |
| 共享库映射区 | 地址空间中上部 | libc.so 等共享对象 |
| 用户栈 | 高地址,向低地址增长 | 局部变量、返回地址、参数 |
| 内核区 | 最高一段 | 用户态不可访问 |
hello 用到了 printf、exit、sleep、getchar,这些都在标准 C 库里,因此它是动态链接的:可执行文件里只保留 PLT 桩和 GOT 表项,真正的函数体在 libc.so 中,运行时由动态链接器把地址填进 GOT。这也解释了为什么反汇编 hello 时看到的调用目标是printf@plt而不是 printf 的实体。
3. 机器级代码:hello.s 里数据、跳转与调用的翻译规则
从 C 到汇编,编译器其实只做三件事:给每个变量找位置、把每种控制结构翻译成比较加跳转、把每次函数调用翻译成寄存器传参加栈帧切换。把这三件事的规则记牢,任意一段 hello 级别的代码都能读下来。这一层是整份作业里最容易“看得懂但写不出”的部分,原因是教材给的规则是正向的,而读汇编需要反向推理——看到一条 cmpl,要能倒推出它对应源码里的哪个 if。
3.1 数据落位:字符串、整型与数组分别放在哪
hello 里出现的字符串有两条:"Usage: Hello 学号 姓名!\n"和"Hello %s %s\n"。中文部分按 UTF-8 编码,本质仍是一串字节。按作业里的观察,这两条格式串出现在 .data 段,而伪指令表里 .rodata 才是字符串常量的常规去处——这个差异恰好说明字符串放哪个段并不是语言规定的,而是编译器按优化级别和读写属性自行决定的,复习时不要把它当成铁律背。
整型的处理更能看出编译器的取舍。int sleepsecs = 2.5;是一次隐式强制类型转换,浮点常量被截断成整数 2,sleepsecs 作为全局 int 占 4 字节,初值由 .data 段直接给出。argc是 main 的第一个参数,通过 %edi 传进来,第 28 行和第 30 行把它与 3 作比较来决定走哪条分支。循环变量i是局部变量,落在栈上 (%rbp-4) 的位置,第 37 行赋初值 0,第 53 行自增,第 55 行与 10 比较。
数组方面,char *argv[]是指针数组,x86-64 下每个指针 8 字节,所以 argv[i] 的访问就是首地址加减 8 的整数倍。argv 作为第二个参数放在 %rsi 中,首地址是 (%rbp-32);第 41 行把 argv[2] 送进 %rdx,第 44 行把 argv[1] 送进 %rsi,正好对应 printf 的第三、第二个可变参数。
3.2 控制转移:cmp 搭配 je 与 jle 的条件码逻辑
C 里的 if 和 for 在汇编里没有对应指令,编译器一律翻译成“比较 + 条件跳转”。比较指令不产生结果,只设置条件码:ZF 表示结果为零,SF 表示结果为负,OF 表示有符号溢出。跳转指令再根据这些标志位决定走向。
if(argc != 3)的翻译是先用 cmpl 把 argc 与 3 相减,用差值设置条件码,再用 je 判断 ZF。若 ZF 为 0 说明 argc 减 3 不为零,即 argc 确实不等于 3,就顺序执行 if 体内的代码;若 ZF 为 1 则直接跳到 .L2 跳过整块。for(i=0; i<10; i++)的翻译稍有不同:第 55 行用 cmpl 比较 i 与 9,第 56 行用 jle 在“小于等于”时跳回循环体入口 .L4,否则顺序执行到 getchar()。注意这里是拿 i 和 9 比而不是和 10 比,因为“i 小于 10”等价于“i 小于等于 9”,编译器顺手省掉一次减法。
/* 源码语义:两个分支,一个循环 */ if (argc != 3) { /* 参数个数不对,打印用法后退出 */ } for (i = 0; i < 10; i++) { /* 循环体里调用 getchar */ }对应到汇编,关键是记住条件跳转是“满足条件就跳”,不是“满足条件就执行”。读代码时先看跳转目标在哪,再倒推源码结构,比逐条翻译快得多。
3.3 函数调用:栈帧、寄存器传参与 PLT 跳转
main 函数的原型是int main(int argc, char *argv[]),它本身不会被谁“主动”调用,而是由系统启动函数__libc_start_main通过 call 指令拉起来的。call 指令做两件事:把下一条指令的地址压栈作为返回地址,然后跳转到目标。函数返回时执行 ret,等价于把返回地址弹出到 %rip。栈帧的建立靠 %rbp:进入函数后先把旧的 %rbp 压栈,再把当前 %rsp 赋给 %rbp,之后通过对 %rbp 做减法来开辟局部空间。函数结束时执行 leave,等价于mov %rbp,%rsp加pop %rbp,把栈恢复到调用前状态。
x86-64 的参数传递走寄存器,顺序是 %rdi、%rsi、%rdx、%rcx、%r8、%r9,超出的部分才走栈;返回值放 %eax。hello 里的几处调用正好覆盖了这些规则:第一次 printf 把"Usage: Hello 学号 姓名!\n"的首地址送进 %rdi,编译器发现没有格式说明符,直接把它降级成puts@PLT;第二次带%s %s,必须用printf@PLT。exit(1)把 1 送进 %edi 再调exit@PLT;sleep(sleepsecs)把全局变量送进 %edi 再调sleep@PLT;循环里的getchar()直接调getchar@PLT。@PLT 后缀说明这是动态链接的延迟绑定入口,第一次调用时才会真正解析到 libc.so 里的地址。
4. 运行期:fork、execve、地址翻译与 I/O 落点
构建完成只说明磁盘上多了个文件,真正让 hello 动起来的是内核。在终端敲下./hello,shell 要 fork 出子进程、execve 换掉子进程的地址空间、内核给它分配时间片、MMU 把每条指令的虚拟地址翻成物理地址、printf 最终落成一次 write 系统调用。这一章把运行期拆成五段,每段都对应一个能被观察到的现象,比如进程号变化、信号处理函数的返回值、缺页次数的跳动。
4.1 fork 与 execve:shell 把 hello 换进当前进程的两步
shell 是一个常驻的用户态程序,它的处理流程是循环读命令行、解析参数、判断是不是内建命令,若不是就 fork 出子进程,由子进程 execve 加载目标程序,父进程则 waitpid 等待子进程结束。fork 调用一次返回两次:父进程拿到子进程的 PID,子进程拿到 0,靠这个返回值区分两条执行路径。fork 出来的子进程几乎完全复制了父进程的地址空间,但采用的是写时复制,页表项被标记为只读,谁写谁触发缺页再复制,所以开销远小于全量拷贝。
execve 则是在当前进程里加载并运行新程序,用 hello 替换掉子进程里原本的 shell 代码。它要完成四步:删除当前进程虚拟地址用户部分里已存在的区域结构;为新程序的代码、数据、bss 和栈创建新的区域结构,其中代码和数据映射到 hello 文件里的 .text 与 .data,bss 和栈是请求二进制零的,映射到匿名文件,初始长度为零;如果程序与共享对象链接,比如标准 C 库 libc.so,就把这些共享对象映射到用户地址空间的共享区域;最后设置当前进程上下文里的程序计数器,让它指向代码区域的入口点。
注意:作业里把调用简写成
execve("hello", NULL, NULL)是为了突出“加载”这个动作。标准签名是execve(const char *filename, char *const argv[], char *const envp[]),真实调用会传入完整的参数数组和环境变量数组。
4.2 异常与信号:Ctrl-C、Ctrl-Z 与 SIGCHLD 的回收链
异常是控制流的突变,按原因和处理方式分四类。中断是异步的,来自外部设备,比如键盘敲击和定时器;陷阱是有意的,比如系统调用,执行完返回下一条指令;故障是出错但可修复,比如缺页,处理完重新执行引发故障的那条指令;终止是不可恢复的硬件错误,直接结束进程。这条分类线很重要,因为缺页、除零、非法内存访问分别对应不同的后续动作,混在一起记就会在分析现象时判断失误。
信号是内核发给进程的软中断通知。Ctrl-C 让终端驱动给前台进程组发 SIGINT,默认动作是终止;Ctrl-Z 发 SIGTSTP,默认动作是挂起;子进程结束时内核给父进程发 SIGCHLD,父进程靠它知道该去回收了。进程对信号的处置有三种:默认动作、忽略、捕获。捕获就是用 signal 或 sigaction 注册一个处理函数,函数执行完返回到被中断的那条指令继续跑。这里有个血泪经验:sleep 被信号打断会提前返回,返回值是剩余未睡完的秒数,如果代码里写的是while(sleep(n))之外的简单判断,就会误以为睡够了。
回收这一步也容易断链。子进程结束后并不会立刻消失,它变成僵尸进程等待父进程读取退出状态,直到 waitpid 取走状态码,内核才把它占用的所有痕迹清干净。shell 的整个生命周期就是 fork、execve、waitpid 三个动作循环,这也是 hello 的 0 到 0 里最后一个 0 的落点。
4.3 地址翻译:段式、页式、TLB 与三级 Cache 的四道关
程序里的地址是逻辑地址,由段选择符加段内偏移构成。段式管理先用段选择符查段描述符表拿到段基址,加上偏移得到线性地址;在平坦模型下段基址通常为零,于是线性地址等于虚拟地址。页式管理再把虚拟地址切成两部分:高位是虚拟页号,低位是页内偏移。x86-64 的四级页表把 48 位虚拟地址中的 36 位页号再切成四段各 9 位,加上 12 位页内偏移,逐级索引 PGD、PUD、PMD、PTE,最终从页表项里取出物理页框号,拼上偏移就是物理地址。
这条路上有两级加速。TLB 是 MMU 内部的一张翻译缓存,存的是虚拟页号到物理页框号的映射,命中就不用走四级页表;未命中才查页表,查到后回填 TLB。拿到物理地址后再查 Cache:物理地址被切成标记、组索引、块偏移,用组索引定位到组,用标记逐一比对,命中直接取数据,未命中才去下一级或主存取整个块。TLB 命中不代表 Cache 命中,这两件事经常被混为一谈——前者省的是地址翻译,后者省的是访存。
4.4 缺页与动态分配:写时复制的页面和 malloc 的取舍
缺页故障发生在页表项有效位为 0 的时候,说明这个虚拟页还没装进物理内存。处理流程是:处理器把虚拟地址和控制信息存进寄存器,触发缺页异常,内核的缺页处理程序据此判断是合法访问还是越界,合法就选一个牺牲页、必要时写回磁盘、把目标页调入并更新页表项,然后重新执行引发故障的那条指令。fork 的写时复制正是靠这套机制实现的:父子进程共享只读页面,任何一方写入都会触发缺页,内核这时才真正复制一份。
动态存储分配解决的是编译期无法确定大小的问题。malloc 从堆里划出一块,free 归还,中间不移动已分配的块。分配器的设计是一场取舍:吞吐率高意味着分配回收快,利用率高意味着碎片少,两者很难兼得。实现方式从隐式空闲链表到显式空闲链表再到分离空闲链表,本质上都是拿查找时间换空间利用率。碎片分两种,内部碎片是分配块大于请求造成的浪费,外部碎片是空闲块总量够但没有单块够大。
4.5 I/O 落点:Unix I/O 接口与 printf、getchar 的实现
Linux 的设备管理遵循“一切皆文件”,每个打开的文件对应一个文件描述符,进程通过描述符表找到打开文件表项,再找到 v-node 或 inode。最底层的接口是 Unix I/O 那一组:open 打开、read 读、write 写、lseek 定位、stat 取元数据、close 关闭。上层所有带缓冲的库函数最终都要落到这组调用上。
printf 的路径是:先按格式串解析出各个参数,把结果拼进 FILE 结构里的用户态缓冲区,等缓冲区满、遇到换行(行缓冲)或显式 fflush 时,才调用 write 把整块内容交给内核。getchar 走反方向:它不一次读一个字节,而是调用 read 一次性读入一整块到缓冲区,之后每次调用只是从缓冲区里取下一个字符,取空才再触发一次 read。理解了这层缓冲,才能解释为什么 printf 之后紧接一个崩溃,输出可能一行都没出来——内容还躺在用户态缓冲区里没来得及 write。
5. 易漏易错点:五处最容易被跳过的地方
复习到这一层,概念基本都见过,真正拉开差距的是那些“看课本时觉得理所当然、动手时才发现对不上”的细节。下面五条是按翻车频率排的,每一条都给出漏在哪、为什么漏、怎么补。
易漏点一:预处理产物的行号标记。打开 hello.i 看到三千多行,只注意到前面是头文件内容,忽略了大量以 # 开头的行。为什么漏:习惯性把这些行当注释,扫一眼就翻过去了。怎么补:单独 grep 出所有以 # 开头的行,对照原始文件确认每一条 #line 记录的是哪个文件的第几行,这样能看懂后续阶段报错为什么能指回 hello.c 而不是 hello.i。
易漏点二:.bss 只占内存不占文件。为什么漏:readelf -S 里 .bss 明明有 size,容易误以为文件里也有对应数据。怎么补:对比节头表里的文件偏移和大小两栏,.bss 的文件大小为 0,只在装载时按大小清零分配。顺带记住 .data 和 .bss 的分界就是“初值是否为零”,显式初始化为 0 的变量同样进 .bss。
易漏点三:字符串到底在 .data 还是 .rodata。为什么漏:伪指令表里写着 .rodata 放字符串,于是当成定论,遇到实际产物在 .data 就怀疑自己看错。怎么补:用 -O0 和 -O2 各生成一份 hello.s 做 diff,观察同一段代码在不同优化级别下数据放置和指令选择的差异,结论自然从“背规则”变成“看上下文”。
易漏点四:printf 与 puts 的选择。为什么漏:以为源码写 printf 就一定调 printf。怎么补:回到 hello.s 里核对两条输出语句的调用目标,带格式说明符的走 printf@PLT,不带说明符、只输出一个字符串的会被编译器替换成 puts@PLT,两者行为在自动补换行这一点上还有差别。
易漏点五:TLB、页表、Cache 的层级混淆。为什么漏:三者都跟“地址”打交道,复述流程时容易串成一条线上依次经过的关。怎么补:画一张顺序表,明确每关的输入是什么、输出是什么、命中就不用往下走——TLB 输入虚拟页号输出物理页框号,页表输入虚拟页号输出物理页框号,Cache 输入物理地址输出数据块。三者是两段串联关系,不是三段并列关系。
6. 复习清单:按产物过一遍比按章节过一遍快
这份作业的知识点虽然横跨大半本教材,但落到复习上其实有一条很省力的路径:不按章节顺序看,而是按产物顺序看。每拿到一个文件,先问自己四个问题——它是什么格式、由哪条命令生成、里面有哪些节或字段、下一步谁消费它。四个问题答不上来,说明这一层还没通,翻回对应章节补,比从头通读效率高得多。
| 顺序 | 产物 | 生成命令 | 验证动作 | 对应知识 |
|---|---|---|---|---|
| 1 | hello.i | gcc -E | wc -l 对比行数,grep '^#' | 头文件展开、宏替换、条件编译 |
| 2 | hello.s | gcc -S | 认全所有点开头的伪指令 | 五阶段、数据落位、控制转移 |
| 3 | hello.o | gcc -c | readelf -S 看节头表与 .bss | ELF 可重定位格式、符号表 |
| 4 | hello | gcc -o | readelf -l、ldd、反汇编找 @plt | 符号解析、重定位、动态链接 |
| 5 | 进程 | ./hello | ps 看进程状态变化 | fork、execve、waitpid |
| 6 | 地址 | 无需命令 | 手推一遍 48 位虚址的拆分 | 段式、四级页表、TLB、Cache |
几个能立刻用上的小技巧:用readelf -S hello.o的输出对照节区表逐行核对,比看教材图示记得牢;用gcc -O0 -S和gcc -O2 -S各存一份汇编,diff 一下就能看清优化到底动了什么,比如循环展开、函数内联、printf 降级为 puts 都是在这一步发生的;遇到信号相关的问题,用 strace 跟一遍系统调用,能看到 fork、execve、write、wait4 的实际发生顺序,比对着书想象直观。
我自己的教训是,第一遍做这份作业时把所有精力花在“写出正确结果”上,预处理、汇编、链接都是一条命令跑完就往下走,中间的 .i 和 .s 文件根本没打开看过。等到复习时才发现,能背出四个阶段的名字,却说不清 hello.i 里第 3046 行之前到底是什么,也说不出 hello.o 的 .bss 为什么文件大小是零。从那以后我每次碰编译链路,都强制走一遍“生成产物、打开产物、说出结构、再做下一步”的流程,慢是慢一点,但每一层都是自己看过的,不是听来的。希望这份清单帮到你。
本文还有配套的精品资源,点击获取