第4章 工具链
从源代码到可执行文件
章节导读
当你敲下 gcc hello.c -o hello 然后回车,一秒钟后一个可执行文件就躺在了磁盘上。这一秒钟里发生了什么?答案是:四个独立的程序接力完成了一场精密的翻译——预处理器(cpp)展开宏和头文件,编译器(cc1)把 C 代码翻译成汇编语言,汇编器(as)把汇编翻译成机器码并打包成目标文件,链接器(ld)把多个目标文件和库拼装成最终的可执行文件。这条流水线就是「工具链」(Toolchain)。大多数程序员一辈子只把它当作一个黑盒命令,但 Ian Wienand 在书中坚持:理解工具链,是理解「程序到底是什么」的必经之路。因为可执行文件不是源代码的「编译结果」这么简单——它是一个有精确格式的二进制数据结构,操作系统按这个格式加载它,动态链接器按这个格式解析它,调试器按这个格式解读它。
| 阶段 | 工具 | 输入 | 输出 | 核心任务 | 关键产物 |
|---|---|---|---|---|---|
| 预处理 | cpp (gcc -E) | .c 源文件 | 展开后的 .c | 宏展开、文件包含、条件编译 | 纯文本,无 # 指令 |
| 编译 | cc1 (gcc -S) | 预处理后 .c | .s 汇编文件 | 语法分析、优化、代码生成 | 汇编指令序列 |
| 汇编 | as (gcc -c) | .s 汇编文件 | .o 目标文件 | 指令编码、符号表生成 | ELF 目标文件 |
| 链接 | ld (gcc -o) | .o + .a/.so | 可执行文件 | 符号解析、重定位、合并 | ELF 可执行文件 |
为什么需要这么多阶段?根源在于关注点分离。C 语言的宏和头文件是文本层面的机制,预处理器用纯文本替换实现它们,根本不懂 C 语法——这解释了为什么宏的坑如此之多(忘记加括号、多次求值),因为预处理器眼里只有字符。编译器前端把 C 翻译成中间表示(IR),中端在 IR 上做与机器无关的优化(常量折叠、死代码消除、循环优化),后端把 IR 翻译成特定架构的汇编——这个三段式设计让 GCC 能用一套优化代码支持几十种 CPU。汇编器做几乎一对一的翻译,把助记符变成机器码,但此时函数调用的目标地址还是未知的——main.c 里调用 printf,printf 在另一个文件甚至另一个库里,地址要等链接时才能确定,所以汇编器在目标文件里留下「重定位条目」(relocation entry),标记「这里将来要填一个地址」。链接器负责最后的拼图:收集所有目标文件,把同名的节合并(所有 .text 拼成一个大代码段),解析符号引用(谁定义了 printf?在 libc 里),再根据重定位条目把真实地址填进去。
这一切的载体是 ELF 格式(Executable and Linkable Format)——Unix 世界的通用二进制容器。ELF 文件有两套「视图」:链接视图由节头表(Section Header Table)描述,把文件切成 .text、.data、.bss、.rodata、.symtab 等节,供链接器使用;执行视图由程序头表(Program Header Table)描述,把文件切成若干个段(Segment),告诉操作系统内核如何把文件映射进内存。同一个字节序列,两种解释方式,这是 ELF 设计最精妙也最让初学者困惑的地方。理解了 ELF,你就能回答一系列实际问题:为什么全局变量初始化为 0 不占文件体积(.bss 只记录大小不记录内容)?为什么字符串常量改不了(.rodata 映射为只读页)?为什么 strip 之后程序变小但还能运行(符号表是调试信息,执行不需要)?为什么 readelf 和 objdump 看到的结构不一样(一个按节一个按指令)?
本章还将对比编译型、解释型和混合型语言的实现策略,理解静态库与共享库的权衡——这直接引出第 5 章动态链接的主题。学完本章,建议你养成一个新习惯:每写一个 C 程序,都用 gcc -E/-S/-c/-v 把四个阶段的中间产物都看一遍,用 readelf 解剖生成的 ELF。工具链不再是黑盒,而是你手中透明的、可检查的、可操纵的工具——这正是「bottom up」思维方式的体现。
核心概念
- 编译器
- 汇编器
- 链接器
- ELF
- 符号表
- 静态库
- 共享库
要点精析
编译型、解释型与混合型语言的权衡。编译型语言(C/C++/Rust/Go)在运行前把源代码整体翻译成机器码,优点是执行速度快(直接跑原生指令,且编译器有全局视野做跨函数优化)、发布物是自包含的二进制;缺点是编译耗时、跨平台要为每个目标重新编译、开发迭代周期长。解释型语言(Python/Ruby/JavaScript 的传统形态)由解释器逐条读取源代码或字节码并执行,优点是启动快、跨平台(一次编写到处运行)、动态特性实现自然;缺点是执行慢(每条指令都要经过解释器分发循环)、必须携带解释器运行。混合型是当代主流:Java/C# 先编译成平台无关的字节码,再由虚拟机(JVM/CLR)执行,配合 JIT(Just-In-Time)编译器在运行时把热点字节码编译成本地机器码,兼顾了可移植性与速度;现代 JavaScript 引擎(V8)更是多级流水:Ignition 解释器先跑,热点函数交给 Sparkplug/TurboFan 分层 JIT。JIT 的独特优势是能做「运行时投机优化」:根据实际运行的类型分布和分支统计生成特化代码,配合去优化(deoptimization)回退机制,峰值性能可以超过静态编译。权衡的本质是:翻译工作放在何时做(提前 AOT vs 运行时 JIT)、翻译的粒度(整体 vs 热点)、以及类型信息在何时确定(静态 vs 动态)。GraalVM 的 native-image 又把 Java 拉回 AOT 阵营——钟摆永远在启动速度与峰值性能之间摆动。
预处理阶段——宏展开、文件包含与条件编译。预处理器(GNU 工具链中是 cpp,集成在 gcc 里)对源代码做纯文本变换,输出仍是 C 代码(.i 文件,用 gcc -E 查看)。三大工作:其一,#include 文件包含——把指定文件的内容原样插入,尖括号形式搜索系统路径(-I 和默认目录),引号形式先搜当前目录;头文件没有「只包含一次」的魔法,靠 include guard(#ifndef/#define/#endif)或 #pragma once 防止重复展开,这就是为什么每个头文件都要写卫士宏。其二,宏展开——#define 的函数式宏是带参数的文本替换:#define MAX(a,b) ((a)>(b)?(a):(b)) 中每个参数都加括号防止运算符优先级破坏语义,但文本替换仍有多次求值陷阱(MAX(i++, j) 会让 i 加两次),C99 的 inline 函数或 GCC 的 statement expression 是更安全的替代。其三,条件编译——#ifdef/#if/#else 按宏的存在与取值裁剪代码,用于跨平台适配(#ifdef _WIN32)、调试开关(#ifdef DEBUG)、特性配置(autoconf/cmake 生成的 config.h)。预处理器不懂 C 语法带来的经典事故:宏参数含逗号会破坏参数配对(用可变参数宏解决)、字符串化 # 与连接 ## 运算符的展开时机、do { } while(0) 包裹多语句宏的原因(让宏在 if-else 中表现得像单条语句)。理解「预处理只是文本替换」这一本质,就能预判所有宏陷阱。
编译阶段——从 C 到汇编的翻译与优化。编译器前端(GCC 的 cc1)先做词法分析(把字符流切成 token)和语法分析(构造抽象语法树 AST),再进行语义分析(类型检查、作用域解析),然后把 AST 降级为中间表示(GCC 用 GIMPLE/RTL,LLVM 用 SSA 形式的 LLVM IR)。中端优化在 IR 上进行,与目标机器无关:常量折叠与传播(x = 2+3 直接变 x = 5)、死代码消除(不可达分支删除)、公共子表达式消除、循环不变量外提、函数内联(把小函数的体嵌入调用点,消除调用开销并打开后续优化空间)、循环展开与向量化(把标量循环变成 SIMD 指令)。SSA(静态单赋值)形式是现代优化的基石:每个变量只被赋值一次,使数据流分析变得简单精确。后端把优化后的 IR 翻译成目标汇编:指令选择(IR 操作映射到具体指令,常用树模式匹配)、寄存器分配(把无限虚拟寄存器映射到有限物理寄存器,图着色算法,溢出到栈)、指令调度(重排指令适配流水线特性,隐藏延迟)。用 gcc -S 可以看到最终汇编输出,加 -O0/-O2/-O3 对比能直观看到优化器的工作:-O0 的汇编几乎逐行对应 C 代码(每个变量都从栈上加载),-O2 的汇编面目全非(变量进了寄存器、循环被改写、分支被重排)。理解这个阶段让你明白:你写的 C 代码和 CPU 执行的指令之间隔着一条深邃的翻译链,「代码的样子」不等于「执行的样子」——这是读懂性能问题和未定义行为后果的前提。
汇编阶段——从助记符到目标文件。汇编器(GNU as)把 .s 汇编文本翻译成机器码,输出可重定位目标文件(.o,用 gcc -c 生成)。汇编与机器指令几乎一一对应,但汇编器还承担两项关键工作。第一,符号管理:汇编器扫描代码,收集本文件定义的符号(函数名、全局变量名,标记为 GLOBAL)和引用的外部符号(调用了但没定义的,标记为 UNDEF),写入符号表(.symtab 节)。第二,生成重定位条目:当指令中需要填入一个「现在还不知道的地址」——比如 call printf 的目标地址、全局变量的绝对地址——汇编器先填 0 或占位值,同时在 .rel.text/.rela.text 节记录一条重定位条目:「在文件偏移 X 处,按 R_X86_64_PLT32 类型,填入符号 printf 的地址加修正量」。链接器稍后据此回填真实地址。x86-64 常见的重定位类型有几十种:R_X86_64_PC32(PC 相对 32 位,用于近跳转)、R_X86_64_PLT32(经过 PLT 的函数调用)、R_X86_64_32/64(绝对地址)、R_X86_64_GOTPCREL(经 GOT 的 PC 相对引用,位置无关代码用)。用 readelf -r foo.o 可以看到所有重定位条目。理解重定位是理解链接和动态加载的钥匙:整个链接过程本质上就是「合并节 + 解析符号 + 应用重定位」三件事。汇编器的另一价值是它是编译器输出的「检查点」——排查编译器 bug 或理解某段代码的机器行为时,读汇编是最直接的手段,objdump -d 反汇编与 gcc -S 正向输出互为印证。
链接阶段——符号解析与重定位的合并艺术。链接器(GNU ld,或更快的 gold/lld)把多个 .o 文件和库组合成一个可执行文件或共享库,工作分三步。第一步,符号解析(Symbol Resolution):链接器维护一张全局符号表,每个符号要么已定义(DEF,在某个 .o 的 .text/.data 中)要么未定义(UNDEF);它扫描所有输入文件,为每个 UNDEF 符号寻找唯一的 DEF。规则细节充满陷阱:强符号(函数和已初始化全局变量)与弱符号(未初始化全局变量)共存时选强符号,多个弱符号任选其一——这条古老规则是「多文件同名全局变量导致诡异数据损坏」的根源,现代 GCC 默认 -fno-common 把未初始化全局变量也放进 .bss 变成强符号,重复定义直接报错。找不到定义报 undefined reference,找到多个强定义报 duplicate symbol。第二步,节合并:所有输入文件的同名节拼接——全部 .text 合成一个代码段、全部 .data 合成一个数据段,.bss 合并后只记录总大小。第三步,重定位:合并后每个符号有了最终地址,链接器遍历所有 .o 的重定位条目,把真实地址(或相对偏移)回填到指令中,例如把 call 指令里的占位 0 换成目标函数与本指令的 PC 相对距离。链接器还负责确定程序的入口地址(_start,由 crt1.o 提供,它初始化 libc 后才调用 main)和生成程序头表。静态库(.a)只是 .o 文件的归档(ar 打包),链接器按需抽取其中被引用的成员;这个「按需」特性导致静态库在链接命令中的顺序敏感——库要放在引用它的 .o 之后,否则符号解析时库成员会被跳过。
ELF 文件格式详解——一个文件、两种视图。ELF(Executable and Linkable Format)是 System V 引入、被 Linux/BSD/Solaris 采用的标准二进制格式,有三种类型:可重定位文件(.o,ET_REL)、可执行文件(ET_EXEC,加载地址固定)、共享目标文件(.so,ET_DYN,可加载到任意地址;开启 PIE 后现代 Linux 的可执行文件也是 ET_DYN)。ELF 文件的骨架:开头是 64 字节的 ELF 头(ELF Header),包含魔数(0x7F 'E' 'L' 'F')、类别(32/64 位)、字节序(第 5 字节,1 为小端——呼应第 1 章)、机器类型(EM_X86_64/EM_AARCH64)、入口地址(e_entry)、程序头表和节头表的位置。文件中有两张索引表:节头表(Section Header Table)描述链接视图,把文件划分为几十个节,每节有名字、类型、标志、大小、对齐要求——链接器和 objdump 使用这张表;程序头表(Program Header Table)描述执行视图,把节组合成若干个段(Segment),每段记录虚拟地址、文件偏移、内存大小、文件大小、权限标志(RWX)——内核加载器只使用这张表。关键洞察:段是节的运行时打包,一个 PT_LOAD 段通常包含多个属性相同的节(可读可执行段打包 .text+.rodata,可读写段打包 .data+.bss);.o 文件没有程序头表(不可执行),可执行文件可以没有节头表(strip 后仍可运行)。readelf -h/-S/-l 分别查看 ELF 头、节头表、程序头表,是解剖任何 ELF 文件的标准三板斧。
常见节——.text、.data、.bss、.rodata 的分工。代码和数据按属性分节存放,属性决定映射权限,这是安全与效率的双重设计。.text 节存放机器指令,标志是 SHF_ALLOC + SHF_EXECINSTR,运行时映射为「可读可执行、不可写」(W^X 原则:可写与可执行互斥,防御代码注入攻击——攻击者即使把 shellcode 写进内存也无法执行)。.rodata 存放只读数据:字符串字面量、const 全局变量、switch 跳转表,映射为只读,试图修改字符串字面量(char p = "hi"; p[0] = 'H';)触发 SIGSEGV 正是这个机制。.data 存放已初始化的全局/静态变量(int g = 42;),可读写,初始值记录在文件中,加载时直接映射。.bss(Block Started by Symbol,戏称 Better Save Space)存放未初始化或初始化为 0 的变量——文件中不存内容只存大小,因为内核映射匿名页时天然清零,一个 1GB 的未初始化数组在 .o 和可执行文件中只占几十字节的节头记录。其他重要节:.symtab 符号表(函数与变量的名字、类型、绑定、所在节)、.strtab 字符串表(符号名的实际字符)、.rel.text/.rela.text 重定位表、.init/.fini(初始化/终止代码)、.dynamic(动态链接信息,第 5 章详述)、.got/.plt(动态链接的跳转设施)、.note(构建标识)、.debug_(DWARF 调试信息,strip 可去除)。用 size 命令看一个程序的 text/data/bss 三段大小,用 nm 列出符号及其所在节(T/D/B/R 大写字母表示全局符号),是快速了解程序构成的日常操作。
符号表——可见性与名字修饰。符号表(.symtab)记录每个符号的名字、类型(函数/对象)、绑定属性(LOCAL 局部/GLOBAL 全局/WEAK 弱)、所在节索引和值(节内偏移或地址)。C 的 static 关键字在文件层面表示内部链接:符号标记为 LOCAL,只在当前 .o 内可见,链接器不会用它解析其他文件的引用,也不会与其他文件的同名符号冲突——这是 C 语言控制符号可见性的基本手段。未加 static 的全局函数和变量默认 GLOBAL,全程序唯一。GCC 还支持 visibility 属性(__attribute__((visibility("hidden"))))和 -fvisibility=hidden 编译选项,把符号从动态符号表中隐藏,减少动态链接开销并避免符号冲突,共享库开发的标准实践。C++ 的名字修饰(Name Mangling)把函数签名编码进符号名以支持重载:void foo(int) 变成 _Z3fooi,不同编译器/平台的修饰方案不同,这是 C++ 库二进制不兼容的根源之一;extern "C" 关闭修饰,让 C++ 代码能与 C 库互操作。动态符号表(.dynsym)是 .symtab 的子集,只包含动态链接需要的符号,strip 删除 .symtab 但必须保留 .dynsym。诊断工具链:nm -C 列出符号(-C 反修饰),undefined symbol 错误用 nm 查哪个 .o 定义了它,duplicate symbol 错误查哪两个 .o 冲突,ldd 查运行时依赖,c++filt 单独反修饰名字。符号表是链接器、加载器、调试器、性能分析器(perf 靠它把地址翻译回函数名)共同的语言。
静态库与共享库的权衡。静态库(.a)是目标文件的归档,链接时把被引用的成员整体复制进可执行文件:优点是自包含(发布一个文件即可运行,无依赖地狱)、加载快(无需运行时符号解析)、链接时优化空间大(LTO 可以跨库内联);缺点是每个程序都带一份 libc 副本浪费磁盘和内存(100 个程序静态链接 libc,物理内存里就有 100 份)、库升级必须重新编译所有使用它的程序、多个静态库间的循环依赖处理复杂。共享库(.so,Windows 的 .dll,macOS 的 .dylib)在链接时只记录依赖关系,运行时由动态链接器加载并解析符号:优点是磁盘和内存中一份代码被所有进程共享(.text 段映射同一物理页,呼应第 3 章虚拟内存的共享机制)、库可以独立升级(只要 ABI 兼容,替换 .so 文件即可,无需重编译使用者)、支持运行时按需加载(dlopen 插件机制);缺点是启动时有链接开销、部署要管理依赖链(ldd 查看,LD_LIBRARY_PATH/rpath 指定搜索路径,版本不匹配导致「依赖地狱」)、跨库调用经过 PLT/GOT 间接跳转略慢且不利于内联优化。选择准则:应用程序发布追求部署简单选静态(Go 默认全静态是它流行的重要原因);系统级公共库(libc、libssl)必须共享,否则安全补丁无法统一推送;插件体系必须用共享库 + dlopen。现代折中方案:静态链接自己的业务代码 + 动态链接系统基础库,兼顾自包含与系统库更新能力。第 5 章将深入共享库的运行时机制。
构建系统——make 与依赖图的思想。当项目从单文件变成几百个源文件,每次改动都全量重编译不可接受(Chromium 全量编译要数小时),增量构建成为刚需。make(1976 年 Stuart Feldman 在贝尔实验室写出)的核心思想是依赖图 + 时间戳:Makefile 声明「目标: 依赖」规则和构建命令,make 比较目标与依赖的修改时间,只重建「依赖比目标新」的部分,并按依赖拓扑序执行。头文件依赖是 C 项目的特殊难题:改一个 .h 要重编译所有包含它的 .c,手工维护依赖不现实,gcc -MMD -MP 自动生成 .d 依赖文件让 make 包含。make 的局限(增量语义粗糙、并行构建 -j 需要显式声明依赖、跨平台差)催生了新一代构建系统:CMake 生成原生构建脚本(事实上的 C++ 跨平台标准)、Ninja(Google 出品,只做快速增量执行,Chromium/LLVM 使用)、Bazel/Meson 等。理解构建系统的本质是理解「编译单元、依赖关系、增量性」三要素:编译单元越小增量越精细但链接负担越重(C++20 的 modules 和 Unity Build 是两个相反方向的调整);依赖声明不精确会导致「改了头文件没重编译」的诡异 bug;分布式构建(ccache 缓存编译结果、远程编译集群)是大型项目的必然演进。无论工具如何变化,「声明依赖、最小重建」的思想从 make 到 Bazel 一脉相承——这也是第 5 章动态链接「把符号解析推迟到运行时」思想的同构:都是把昂贵的工作尽量推迟、只做必要的部分。
代码示例
/* 四个阶段的中间产物:一个命令看清整条工具链 */
/* hello.c */
#include <stdio.h>
#define GREETING "Hello"
int counter = 42;
int uninitialized;
const char *msg = "world";
static int helper(int x) { return x * 2; }
int main(void) {
printf("%s, %s! counter=%d helper=%d\n",
GREETING, msg, counter, helper(counter));
return 0;
}
/* 在 shell 中依次执行:
gcc -E hello.c -o hello.i 预处理产物:宏已展开、stdio.h 已插入
gcc -S hello.i -o hello.s 编译产物:汇编文本,可见 call printf
gcc -c hello.s -o hello.o 汇编产物:ELF 可重定位目标文件
gcc hello.o -o hello 链接产物:ELF 可执行文件
观察要点:
1. hello.i 有几千行——stdio.h 全部展开了,GREETING 变成了字面量
2. hello.s 中 counter 在 .data 节(值 42 写入汇编 .long 42),
uninitialized 在 .bss 节(.comm 或 .local 指令,不占文件空间),
msg 指向的字符串在 .rodata 节(.string "world"),
helper 是局部符号(汇编中无 .globl 声明)
3. readelf -h hello.o 显示 Type: REL(可重定位),无程序头表;
readelf -r hello.o 显示 printf 的重定位条目 R_X86_64_PLT32
4. readelf -h hello 显示 Type: DYN(PIE 可执行),
readelf -l hello 显示两个 PT_LOAD 段:R+E 和 R+W
5. nm hello.o 中 printf 是 U(未定义),counter 是 D,
helper 是 t(小写表示局部符号),uninitialized 是 B
6. size hello 输出 text/data/bss 三段大小,
验证 uninitialized 只在 bss 计数、不占 data
这个练习把本章所有概念落在一个 20 行的程序上,
建议逐条命令执行并对照 readelf/nm/size 输出。 *//* 符号解析陷阱:强符号、弱符号与多文件冲突 */
/* file1.c */
#include <stdio.h>
extern int shared_value;
void modify(void);
int main(void) {
printf("before: shared_value = %d\n", shared_value);
modify();
printf("after : shared_value = %d\n", shared_value);
return 0;
}
/* file2.c —— 注意这里类型不一致! */
double shared_value = 3.14;
void modify(void) {
shared_value = 2.718;
}
/* 编译链接:
gcc -fno-common file1.c file2.c -o trap
现代 GCC 会直接报错:multiple definition of shared_value
——这是好事!但老版本 GCC(默认 -fcommon)能链接通过,
运行结果更加诡异:file1 把 shared_value 当 int 读,
file2 按 double 写,8 字节的 IEEE 754 位模式
被按 4 字节整数解读,打印出无意义的数字,
且 modify 写入 2.718 后 file1 读到的低 4 字节
是 double 位模式的尾部碎片。
这个例子演示了链接器符号解析只看名字不看类型:
C 的分离编译模型下,类型检查发生在单个编译单元内,
跨文件的同名符号只要链接器找到定义就 happily 连上,
类型不匹配是未定义行为的温床。
防御手段:全局变量尽量加 static 限制在文件内;
跨文件共享的变量用 extern 声明放在头文件中,
定义只出现在一个 .c 里;开启 -fno-common(GCC 10 起默认)
让重复定义在链接期报错而不是静默合并;
大型项目用 -Wl,--no-undefined 和 sanitizer 兜底。 *//* 静态库的制作与链接顺序敏感性 */
/* mathutil.c */
int add(int a, int b) { return a + b; }
int mul(int a, int b) { return a * b; }
/* mathutil.h */
/* int add(int a, int b); int mul(int a, int b); */
/* main.c */
#include <stdio.h>
#include "mathutil.h"
int main(void) {
printf("add=%d mul=%d\n", add(2, 3), mul(4, 5));
return 0;
}
/* 构建步骤:
gcc -c mathutil.c -o mathutil.o
ar rcs libmathutil.a mathutil.o 归档为静态库
gcc main.c -L. -lmathutil -o app 正确:库在源文件之后
gcc -L. -lmathutil main.c -o app2 错误顺序!
第二条命令在老式链接器配置下报
undefined reference to add/mul:
链接器从左到右扫描输入,遇到 libmathutil.a 时
还没有任何未解析符号,于是一个成员都不抽取;
随后处理 main.o 产生对 add/mul 的引用,
但库已经扫描过了,不会回头。
因此静态库必须放在引用它的目标文件之后,
库之间有依赖时被依赖的库放更后面,
循环依赖用 -Wl,--start-group ... --end-group 包裹。
共享库没有这个顺序问题(运行时才解析符号)。
验证静态链接效果:
ar t libmathutil.a 列出归档成员;
nm app | grep add 看到 add 已复制进可执行文件(T 类型);
ldd app 不显示 libmathutil——静态库不留运行时依赖。
对比 gcc main.c -L. -lmathutil -static-libgcc 等
选项理解部分静态链接的用法。 *//* 用 readelf/objdump 解剖 ELF:重定位如何被填充 */
/* lib.c —— 编译为共享库 */
int lib_var = 7;
int lib_func(int x) { return x + lib_var; }
/* use.c —— 使用共享库 */
#include <stdio.h>
extern int lib_func(int x);
int main(void) {
printf("%d\n", lib_func(35));
return 0;
}
/* 构建与观察:
gcc -fPIC -shared lib.c -o libdemo.so
gcc use.c -L. -ldemo -o app
export LD_LIBRARY_PATH=.
1. readelf -d app | grep NEEDED
看到 app 依赖 libdemo.so——链接时只记录了依赖关系
2. objdump -d app | grep -A2 call
可执行文件中对 lib_func 的调用是
call *0x2fe2(%rip) 之类的间接调用,
目标是 GOT 表项而非 lib_func 本身——
因为共享库加载地址运行时才确定
3. readelf -r app
看到 R_X86_64_JUMP_SLOT 重定位条目指向 lib_func,
这个条目将由动态链接器在运行时填写
4. readelf --dyn-syms libdemo.so | grep lib_func
lib_func 出现在动态符号表 .dynsym 中(GLOBAL DEFAULT)
5. 对比静态链接版本:
gcc use.c lib.c -o app_static
objdump -d app_static | grep -B1 -A3 "<lib_func>"
看到 lib_func 的代码被整体复制进可执行文件,
main 对它是直接 call(PC 相对寻址,无 GOT 间接)
这个实验是第 4 章与第 5 章的桥梁:
静态链接在链接期填好一切地址,
动态链接把填地址的工作推迟到运行期,
GOT/PLT/延迟绑定的全部细节在下一章展开。 */实验笔记
本章实验的目标是把工具链的每个阶段都变成可以观察、可以操纵的透明流程。实验一「四阶段解剖」:编写代码示例中的 hello.c,依次生成 .i/.s/.o/可执行文件,对每个产物完成指定观察:统计 .i 的行数并找到 GREETING 宏展开的位置;在 .s 中定位 .data/.bss/.rodata 三个节的声明并解释每个全局变量去了哪里;用 readelf -h/-S/-r 完整解读 .o 文件的 ELF 头、节表和重定位表,画出「节-符号-重定位」的关系图;最后用 readelf -l 解读可执行文件的程序头表,解释两个 PT_LOAD 段的权限为什么不同。实验二「优化器观察」:写一个包含常量表达式、死代码、可内联小函数、简单循环的 C 程序,分别用 -O0 和 -O2 生成汇编,逐段对比找出优化器做的每一处改动(常量折叠在哪里、死代码删了什么、循环变成了什么样),再用 gcc -fopt-info-vec 观察自动向量化的报告;尝试用 volatile、-fno-inline 等开关「阻止」某项优化,验证你对优化器行为的理解。实验三「自制静态库与链接顺序」:按代码示例完成 libmathutil.a 的制作,复现链接顺序错误并解释原因;用 ar x 解包静态库验证它就是 .o 的归档;用 nm 对比静态链接与动态链接两个版本可执行文件的符号表差异,用 size 对比体积,用 ldd 对比运行时依赖。实验四「ELF 手工修改」:用 objcopy 把可执行文件的 .comment 节删除、用 strip 去除符号表,对比前后 size 和能否正常运行、gdb 能否调试;进阶任务是用 dd 和 printf 构造一个最小 ELF 可执行文件(只用 write 和 exit 两个系统调用打印 hello,不依赖 libc),参考「A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux」——这个实验会让你对 ELF 头的每个字段都产生肌肉记忆。所有实验的输出(readelf/nm/objdump 的关键片段)整理成一份「ELF 解剖报告」。
延伸阅读
- 《程序员的自我修养——链接、装载与库》(俞甲子等)——中文世界讲解工具链最透彻的书籍,从编译、链接、装载到动态链接、运行库逐层展开,与本章和第 5 章内容高度对应,强烈推荐作为主参考。
- 《深入理解计算机系统》第 7 章「链接」——CSAPP 对静态链接、符号解析、重定位、共享库的讲解精炼准确,配套的 Bomb Lab 要求读汇编拆炸弹,是练习「读工具链产物」的绝佳实验。
- Ian Wienand《Computer Science from the Bottom Up》在线原文(bottomupcs.com)的 Toolchains 与 Linking 章节——本章的直接来源,作者用大量 readelf/objdump 实例展示 ELF 细节,免费在线阅读。
- 《Linkers and Loaders》(John R. Levine)——链接与加载领域的专著,横跨多个平台对比链接器设计,讲透了重定位、符号解析、库搜索的历史与工程权衡。
- System V ABI / ELF 规范原文(sco.com 的 ELF-64 Object File Format 文档)——ELF 格式的权威定义,重定位类型表和各节语义的最终裁决者,遇到 readelf 输出中不认识的字段时查它。
- GCC 官方文档的 Options 章节与 Ulterior Motifs 系列博客——-E/-S/-c/-v、-fno-common、-fvisibility、LTO 等选项的准确语义;理解每个编译选项背后的机制比死记选项更有价值。
- 《Crafting Interpreters》(Robert Nystrom,免费在线)——亲手实现一门语言的编译器(树遍历解释器)和虚拟机(字节码 + JIT 思路),从生产者视角理解本章讲的翻译流水线。
- A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux(Brian Raiter)——手工构造最小 ELF 可执行文件的经典教程,是理解 ELF 头、程序头表和 _start 入口的最佳动手材料。