资讯动态

hello程序如何变成进程:编译、汇编、链接与加载全链路拆解

发布时间:2026/10/28 2:10:27 来源:尧图企业网站定制
简介面向计算机系统课程学习者的一份完整大作业参考文档以 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 # ZF1 则跳过错误分支 leaq .LC0(%rip), %rdi # 取字符串地址%rip 相对寻址 call putsPLT # 单参数带换行的 printf 被换成 puts movl $1, %edi call exitPLT .L2: movl $0, -4(%rbp) # i 0 jmp .L3 .L4: call getcharPLT 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 printfPLT movl sleepsecs(%rip), %edi call sleepPLT leave # mov %rbp,%rsp pop %rbp ret # pop 到 %rip这段代码里有几个点值得停下来看。leaq .LC0(%rip), %rdi用的是 RIP 相对寻址不是绝对地址——因为此时还不知道这条指令最终会被放到哪个虚拟地址上只能用“从我这里往前/往后多少字节”来表达。call putsPLT里的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 里第一次输出用的是putsPLT不是printfPLT。源码里明明写的是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_typeET_REL(1)可重定位目标文件还没有最终地址e_machineEM_X86_64(62)目标指令集架构e_entry0可重定位文件没有入口点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 referenceS A - P超出 32 位有符号范围就是 relocation truncatedP算错就是那种“跳到了莫名其妙的地方”的运行时崩溃。3.3 符号解析与强弱符号为什么有时重复定义不报错链接第二步是符号解析。规则不复杂但边界情况多。函数名和已初始化的全局变量是强符号未初始化的全局变量老编译器下和显式标了__attribute__((weak))的是弱符号。约束是强符号最多一个一个强符号配多个弱符号可以弱符号被忽略多个弱符号则取占用空间最大的那个。对 hello 而言真正要紧的是“未定义符号”的处理puts、printf、exit、sleep、getchar这五个符号在.symtab里都标着UNDundefined它们的定义在 libc.so.6 里。链接器如果找不到定义只会给一条undefined reference然后结束如果找到了就要决定走静态链接还是动态链接。hello 默认走后者于是重定位项的类型会从R_X86_64_PC32变成R_X86_64_PLT32为的是给动态链接留出插桩空间。这里有一个后来才被广泛踩到的坑GCC 10 起默认-fno-common多个.o里各写一份int counter;会直接报重复定义。老代码迁移时突然一片红原因就是这条强弱符号规则本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑