简介:面向计算机系统课程学习者的一份完整大作业参考文档,以 Hello 程序的“一生”为主线,串联从 C 源文件到可执行文件、再到进程运行的完整链路。文档围绕预处理、编译、汇编、链接四个阶段逐章展开,给出 Ubuntu 环境下的操作命令与中间结果解析,包括预处理后源文件扩展至三千余行的头文件与宏展开解读,并延伸至进程管理、信号与异常、内存地址与 I/O 管理等操作系统层面的知识点,配有摘要、目录与分章小结,结构接近正式课程报告。压缩包内共 1 个 doc 文件,体积约 6.44MB,可直接查阅,也可作为撰写同类大作业的框架模板,便于对照章节组织实验记录、补齐可重定位目标文件等细节分析。目前已有 358 人学习下载,适合正在完成 CSAPP 或计算机系统课程大作业、需要梳理知识脉络与报告写法的本科生参考。
1. 一个 hello 程序凭什么值得拆到底:从 Program 到 Process,每一步都在改变“谁替你做决定”
十几行的 hello.c,编译出来不到 20KB 的可执行文件,跑起来打印两行字符串就退出。多数人第一次听说有人用一整门课来分析它,第一反应是过度设计。但真正反直觉的地方在于:hello 里每一条指令的地址、每一个变量的位置、每一次函数调用的走向,都不是你写的,是预处理器、编译器、汇编器、链接器、加载器和内核一层层替你决定的。这份计算机系统大作业做的事情,就是把这些决定从产物里一条条掏出来,让“程序怎么变成进程”这条链路从黑匣子变成可复现的过程:hello.c 扩成 3065 行的 hello.i,落到带 .text/.rodata 的 hello.s,打包成带重定位表的 hello.o,链接出有虚拟地址的 hello,再经 fork/execve 变成一张页表。搞懂这条链,后面再用 gdb、再排查链接错误、再读别的程序的汇编,都不是从零开始。
2. 预处理与编译:从 hello.c 到 hello.s,编译器到底替你决定了什么
2.1 预处理器为什么故意“不懂类型”:3065 行 hello.i 的由来
预处理的命令简单到没有存在感,gcc -E hello.c -o hello.i,或者更底层的cpp hello.c > hello.i。但产物会先给你一个下马威:原本几十行的 hello.c,展开后 3065 行,自己写的那几行被挤到 3046 行之后。前面两千多行不是凭空生成的,是三份头文件(stdio.h 那一套)递归包含展开的结果——stdio.h 里又 include 了别的头,逐层复制,最终形成一个巨大的纯文本。
这一步做四件事:把头文件内容复制进源文件;用实际值替换#define定义的宏;按#if/#ifdef的条件裁剪代码;顺手去掉注释。注意它的工作对象是“记号”而不是“语法结构”,预处理器完全不知道int是什么,也不知道括号配不配对,它只认#开头的行。
为什么要把这层单独拆出来,而不是并进编译器里?常见的实现思路是历史遗留加工程收益:C 诞生年代要在不同字长、不同系统调用接口的机器间共用一套源码,需要一个能在语法分析之前就把整段代码整段裁掉的机制,#ifdef干的就是这个活。把它放在编译前端之前,前后的职责边界非常干净:预处理器管文本拼接,编译器管语义。代价也很直接——宏是纯文本替换,没有类型、没有作用域、没有副作用保护。#define MAX(a,b) ((a)>(b)?(a):(b))写成MAX(i++, j++)时 i 可能加两次,这类翻车至今仍然是 C 代码里的经典血泪经验。
2.2 编译五阶段与汇编伪指令:.text、.rodata、.globl 各自在回答什么
gcc -S hello.i -o hello.s得到汇编。教材上把编译拆成词法分析、语法分析、语义检查与中间代码生成、代码优化、目标代码生成五段,但在 hello.s 里你能直接看到的,是这五段跑完之后留下的痕迹。
打开 hello.s 会看到一堆以点开头的名字:.file、.text、.globl、.data、.align、.section、.rodata、.long、.string、.size、.type。它们不是指令助记符,不会被翻译成机器码,而是给汇编器的“伪操作”,负责告诉汇编器这段字节该归到哪个节、符号对外是否可见、地址按几字节对齐。下面是一段和你手上那份材料结构一致的片段,注释标了每一处在回答什么问题:
.file "hello.c" # 源文件名,写进调试信息 .text # 以下是代码段 .section .rodata # 只读数据段,字符串常量放这里 .LC0: .string "Usage: Hello %s %s\n" .LC1: .string "Hello %s %s\n" .globl sleepsecs # 符号对链接器可见 .data # 已初始化全局变量 .align 4 .type sleepsecs, @object .size sleepsecs, 4 sleepsecs: .long 2 # int sleepsecs = 2.5 被截断成 2 .globl main .type main, @function main: pushq %rbp # 保存调用者的栈帧基址 movq %rsp, %rbp subq $32, %rsp # 开辟 32 字节栈帧 movl %edi, -20(%rbp) # argc 落在栈上 movq %rsi, -32(%rbp) # argv 落在栈上 cmpl $3, -20(%rbp) # 比较 argc 与 3,设置条件码 je .L2 # ZF=1 则跳过错误分支 leaq .LC0(%rip), %rdi # 取字符串地址,%rip 相对寻址 call puts@PLT # 单参数带换行的 printf 被换成 puts movl $1, %edi call exit@PLT .L2: movl $0, -4(%rbp) # i = 0 jmp .L3 .L4: call getchar@PLT addl $1, -4(%rbp) # i++ .L3: cmpl $9, -4(%rbp) # i 与 9 比,等价于 i < 10 jle .L4 leaq .LC1(%rip), %rdi movq -32(%rbp), %rax movq 16(%rax), %rdx # argv[2],指针步长 8 字节 movq 8(%rax), %rsi # argv[1] movl $0, %eax # 可变参数函数要求 %al 置 0 call printf@PLT movl sleepsecs(%rip), %edi call sleep@PLT leave # mov %rbp,%rsp + pop %rbp ret # pop 到 %rip这段代码里有几个点值得停下来看。leaq .LC0(%rip), %rdi用的是 RIP 相对寻址,不是绝对地址——因为此时还不知道这条指令最终会被放到哪个虚拟地址上,只能用“从我这里往前/往后多少字节”来表达。call puts@PLT里的@PLT更关键:printf、puts、exit、sleep、getchar 全在 libc 里,编译期不可能知道它们的地址,只能先留一个跳板,这个跳板的真实地址等到链接和加载阶段才填,后面第 3 章会把这个坑挖开。cmpl $3, -20(%rbp)后跟je,是 if 分支的标准译法:cmp做减法但不保存结果,只设置条件码,je检查 ZF 是否为 1。
2.3 从汇编反推源码:寄存器、栈帧和那次静默截断
看汇编最有价值的练习,是把它重新翻译回源码,看编译器做了哪些你没想到的决定。
argc从%edi进来,argv从%rsi进来,这是 System V AMD64 调用约定:整数和指针参数依次经过%rdi、%rsi、%rdx、%rcx、%r8、%r9。但注意它们的第二站——argc被写到了-20(%rbp),argv被写到了-32(%rbp)。为什么参数已经躺在寄存器里了还要往栈上挪一次?因为在-O0下编译器不做寄存器分配优化,每个局部变量都要有一个确定的栈槽,这样调试器才能按名字找到它。一旦开了-O2,这两个 store 大概率会消失。
局部的i落在-4(%rbp),cmpl $9, -4(%rbp)加jle对应的是源码里的i < 10——编译器把“小于 10”改写成了“小于等于 9”,这是把比较边界往编译期挪的常见手法。
最值得单独拎出来的是sleepsecs: .long 2这一行。源码写的是int sleepsecs = 2.5;,汇编里直接变成了整数 2,编译过程不报错。这是因为浮点到整型的隐式转换在 C 里是合法的,规则是向零截断。不加-Wall的话,这个精度丢失就这么静悄悄地发生了。你手上这份材料把它记在只读数据区附近,从产物上直接看到了这个截断——比在源码上盯着看有效得多。
2.4 printf("...\n") 被换成 puts:优化发生在哪一层
hello.s 里第一次输出用的是puts@PLT,不是printf@PLT。源码里明明写的是printf。这不是笔误,是 GCC 的一个标准优化:当格式串里没有任何转换说明符、并且以\n结尾时,printf的语义和puts完全等价,而puts不需要跑格式解析,省掉一次可变参数的遍历。
这个细节之所以值得讲,是因为它说明了一件事:优化不是在你写的源码层面发生的,而是在中间表示和目标代码生成之间。同一条源码,在不同优化级别下产出的汇编可能完全不同,所以排查问题时要看的是产物,不是源码。反过来,如果格式串里带了%s却漏传参数,编译器不会做这个替换,printf会老老实实去栈上/寄存器里找那个不存在的参数,运行时读到野地址,直接段错误——这类问题的根因永远在调用点,不在 printf 里面。
3. 汇编与链接:可重定位目标文件里那些等着被填的地址空位
3.1 ELF 头与节表:一个 .o 文件到底打包了什么
gcc -c hello.s -o hello.o得到的不是“半成品程序”,而是一个格式严格定义的目标文件,readelf -a hello.o能把它全部拆开。文件头里几个字段必须先看懂:
| 字段 | hello.o 的取值 | 含义 |
|---|---|---|
e_ident | \x7f E L F | 魔数,前 4 字节定格式 |
e_type | ET_REL(1) | 可重定位目标文件,还没有最终地址 |
e_machine | EM_X86_64(62) | 目标指令集架构 |
e_entry | 0 | 可重定位文件没有入口点 |
e_shoff/e_shnum | 偏移 / 节数 | 节头表位置和节的数量 |
e_shstrndx | 索引 | 节名字符串表所在的节号 |
e_type这一项是理解后面所有内容的前提:ET_REL意味着这个文件里的所有地址都是“相对本节开头的偏移”,不是虚拟地址;链接出可执行文件后变成ET_EXEC(固定地址)或ET_DYN(位置无关,动态库和 PIE 都用它)。
节表里的常见成员:.text放机器码,.rodata放字符串和常量,.data放已初始化的全局变量,.bss放未初始化或初值为 0 的全局变量,.symtab是符号表,.strtab是符号名字符串,.rela.text和.rela.data是重定位表,.comment里躺着编译器版本号。
.bss不占文件空间这件事值得单独说一句。它里面全是 0,没必要在磁盘上老老实实存几 KB 的零字节,文件里只记一个sh_size就够了。加载时内核把这段虚拟内存映射到一个全零的匿名页(请求二进制零),写的时候才真正分配物理页。这是典型的用地址空间换文件体积——磁盘上的 hello 因此比它加载后占的内存小一截。
3.2 重定位表与 R_X86_64_PLT32:为什么外部调用要留桩
.o里最容易被忽略、但信息密度最高的部分是重定位表。readelf -r hello.o的输出长这样:
Relocation section '.rela.text' at offset 0x4a8 contains 5 entries: Offset Info Type Sym. Value Sym. Name + Addend 000000000012 000b00000004 R_X86_64_PLT32 0000000000000000 puts - 4 00000000001a 000500000004 R_X86_64_PLT32 0000000000000000 exit - 4 00000000002f 000c00000004 R_X86_64_PLT32 0000000000000000 getchar - 4每一项三个字段。Offset是要被修改的位置在本节内的偏移,也就是那条call指令后面 4 字节占位符的地址。Info的高 32 位是符号表下标,低 32 位是重定位类型。Addend是加数,-4是因为 x86 的 CALL 指令在计算相对偏移时,%rip已经指向下一条指令了,要把这 4 字节补回去。
R_X86_64_PLT32(类型 4)表示“这里要填一个 32 位的、相对于 PLT 桩的 PC 相对偏移”。R_X86_64_PC32(类型 2)则是普通的 PC 相对引用,用于引用本文件内已定义的符号或数据。
为什么是 PC 相对而不是绝对地址?因为.o编出来的时候,谁也说不准它会被链接到虚拟地址空间的哪个位置。用相对距离表达,只要目标在 ±2GB 范围内,链接器就可以自由安排各个节的位置。这个决定后来直接支撑了位置无关代码(PIE)和共享库——同一份 libc.so 的机器码,可以被映射到任意进程的任意地址上,靠的就是这种相对寻址能力。
链接器填这个空位的核心逻辑,用伪代码表达最清楚:
// 链接器处理一条重定位项的核心步骤(示意) for (rela in section.relas) { sym = symtab[rela.sym_index]; if (sym.is_defined) { S = sym.value; // 符号在本文件内的最终虚拟地址 } else if (is_plt_type(rela.type)) { S = alloc_plt_stub(sym); // 外部函数:分配一个 PLT 桩 } else { error("undefined reference to %s", sym.name); } P = section.addr + rela.offset; // 占位符本身的地址 A = rela.addend; // 加数,通常是 -4 value = S + A - P; // PC 相对偏移 write32(P, value); // 把算出的偏移写进那条 call }S + A - P这个式子看着简单,但它一口气解释了三种常见报错:S找不到就是undefined reference;S + A - P超出 32 位有符号范围就是 relocation truncated;P算错就是那种“跳到了莫名其妙的地方”的运行时崩溃。
3.3 符号解析与强弱符号:为什么有时重复定义不报错
链接第二步是符号解析。规则不复杂,但边界情况多。
函数名和已初始化的全局变量是强符号,未初始化的全局变量(老编译器下)和显式标了__attribute__((weak))的是弱符号。约束是:强符号最多一个;一个强符号配多个弱符号可以,弱符号被忽略;多个弱符号则取占用空间最大的那个。
对 hello 而言,真正要紧的是“未定义符号”的处理:puts、printf、exit、sleep、getchar这五个符号在.symtab里都标着UND(undefined),它们的定义在 libc.so.6 里。链接器如果找不到定义,只会给一条undefined reference然后结束;如果找到了,就要决定走静态链接还是动态链接。hello 默认走后者,于是重定位项的类型会从R_X86_64_PC32变成R_X86_64_PLT32,为的是给动态链接留出插桩空间。
这里有一个后来才被广泛踩到的坑:GCC 10 起默认-fno-common,多个.o里各写一份int counter;会直接报重复定义。老代码迁移时突然一片红,原因就是这条强弱符号规则
本文还有配套的精品资源,点击获取