《程序员自我修养》读书总结(二)
Author: Once Day Date: 2026年2月5日
一位热衷于Linux学习和开发的菜鸟,试图谱写一场冒险之旅,也许终点只是一场白日梦...
漫漫长路,有人对你微笑过嘛...
全系列文章可参考专栏: 书籍阅读_Once-Day的博客-CSDN博客
参考文章:
文章目录
- 《程序员自我修养》读书总结(二)
-
-
-
- [2. 编译和链接](#2. 编译和链接)
-
- [2.1 GCC编译步骤](#2.1 GCC编译步骤)
- [2.2 典型链接行为](#2.2 典型链接行为)
- [2.3 编译器的行为](#2.3 编译器的行为)
- [2.3 链接器行为](#2.3 链接器行为)
-
-
2. 编译和链接
2.1 GCC编译步骤
从《程序员自我修养》的视角看,gcc hello.c 的四个阶段(预处理、编译、汇编、链接)并不是简单的流水线,而是逐步把"人类可读的程序描述"转化为"操作系统可装载的二进制实体"。
hello.c
源码文件
(1)预处理
cpp
头文件
*.h
hello.i
预处理后源码
(2)编译
gcc 前端/后端
hello.s
汇编代码
(3)汇编
as
hello.o
目标文件
静态库
*.a
(4)链接
ld
可执行文件
ELF
(1)预处理阶段由 cpp 完成,它并不理解 C 语义,只做文本级展开:头文件被完整插入、宏被替换、条件编译被裁剪,这一步决定了编译单元的边界,也解释了为何头文件设计不当会导致编译时间和符号污染问题。生成的 hello.i 本质上是一个"去掉了模块边界"的巨大源码文件,这正是 C/C++ 编译模型的核心特征之一。
(2)编译阶段才真正进入语言层面,gcc 调用前端对语法和语义进行分析,构建抽象语法树并进行优化,最终输出与具体架构相关的汇编代码 hello.s。这里体现了《程序员自我修养》中强调的"前端/后端分离"思想:同一份中间表示可以映射到不同 CPU 架构,这也是跨平台编译器得以存在的基础。此阶段常见的错误多为类型不匹配、未声明符号等,它们在链接前就被捕获。
(3)汇编阶段由 as 完成,它将汇编指令翻译为机器指令,并生成 ELF 目标文件 hello.o。目标文件并不是"半成品可执行文件",而是包含代码段、数据段、符号表和重定位信息的容器,其中的符号地址往往是未定的。例如:extern int g; 在此时只会生成一个未定义符号记录,而不会分配最终地址。
(4)链接阶段是《程序员自我修养》讨论最深入的部分,ld 会把多个目标文件和静态库合并,解析符号引用并完成重定位,最终生成操作系统可装载的可执行文件。链接器既要解决"符号在哪里"的问题,也要决定"它们如何布局在虚拟地址空间中",这一步直接影响程序启动方式、内存模型以及库的复用效率,因此理解链接过程对大型 C/C++ 工程尤为关键。
2.2 典型链接行为
在传统 UNIX 工具链中,链接器 ld 承担着将多个目标文件与库整合为可执行文件的职责。下面这条命令展示了一个典型的静态链接过程,链接器需要在用户代码、运行时支持代码以及标准库之间建立完整而一致的符号关系。链接顺序在这里并非形式问题,而是直接决定了符号是否能够被正确解析。
c
ld -static crt1.o crti.o crtbeginT.o hello.o -start-group -lgcc -lgcc_eh -lc -end-group crtend.o crtn.o
首先出现的 crt1.o、crti.o 和 crtbeginT.o 并非用户直接编写的代码,它们由编译器驱动自动加入,属于 C 运行时启动文件。
crt1.o通常定义程序入口_start,负责在内核完成装载后接管执行流;crti.o与crtn.o成对出现,用于放置构造和析构段的前后框架;crtbeginT.o则参与全局构造函数表的管理,为后续 C/C++ 运行时初始化奠定基础。
紧随其后的 hello.o 是用户目标文件,包含 main 函数及业务逻辑。此时 main 尚无法直接执行,因为它依赖于运行库提供的初始化、I/O 和异常处理等能力。链接器在扫描 hello.o 时,会记录其未解析符号,并等待后续输入文件来补全,这种"边扫描、边解析"的策略使得输入顺序变得尤为重要。
-start-group 与 -end-group 包裹的部分用于解决静态库之间的循环依赖 问题。-lgcc、-lgcc_eh 与 -lc 分别对应 GCC 运行时支持库、异常处理支持库以及 C 标准库。普通情况下,链接器只会单向扫描静态库,但在 group 内部,ld 会反复扫描这些库,直到不再出现新的未解析符号,从而避免手工调整库顺序的复杂性。
c
/* hello.c */
#include <stdio.h>
int main(void) {
printf("hello, world\n");
return 0;
}
在这个简单示例中,printf 最终来自 libc,而整数除法或栈展开等细节可能由 libgcc 提供支持,这些依赖在源代码层面并不直观,却在链接阶段集中暴露。
命令尾部的 crtend.o 与 crtn.o 负责为构造/析构段收尾。crtend.o 通常标记全局构造函数数组的结束位置,而 crtn.o 则补齐在 crti.o 中开启的段结构,保证生成的 ELF 文件在布局和语义上都符合运行时装载器的预期。
整体来看,这条链接命令体现了从程序入口到运行库支持的完整静态链接链路。
2.3 编译器的行为
编译器的工作通常被划分为若干逻辑阶段,它们从源代码的文本形式出发,逐步抽象并重构程序的语义,最终生成可执行的机器指令。这种分阶段设计既降低了实现复杂度,也使得编译器在可维护性和可扩展性上具备良好特性。每一阶段都有清晰的输入与输出,并通过中间表示进行衔接。
词法分析(lex)是编译流程的起点,其职责是将字符流切分为有意义的记号(Token)。例如关键字、标识符、常量和运算符都会被识别并分类。词法分析器通常基于有限自动机实现,能够高效地跳过空白和注释,并为后续阶段提供结构化的输入。此阶段并不关心语法结构,只关注"单词"是否合法。
语法分析(yacc)在词法分析的结果之上工作,核心目标是验证记号序列是否符合语言的文法规则,并构建抽象语法树(AST)。该过程往往采用自底向上的 LR 分析或自顶向下的递归下降算法。语法分析解决的是"句子是否通顺"的问题,一旦发现不符合文法的结构,编译器就会在这一阶段报告语法错误。
在语义分析阶段,编译器开始真正理解程序的含义。它会结合符号表检查变量是否声明、类型是否匹配、作用域是否正确,以及函数调用是否合法等问题。与语法分析不同,语义分析往往需要跨越语法结构进行全局判断,例如类型提升规则或 const 限定检查,这些都是文法本身无法表达的约束。
完成语义检查后,编译器会将 AST 转换为中间语言(IR)。IR 是一种与具体硬件无关的表示形式,既保留程序语义,又便于分析和变换。现代编译器(如 LLVM、GCC)通常采用三地址码或 SSA 形式,使控制流和数据依赖显式化,为后续优化提供良好基础。
c
// 下面复杂的语句会拆分为多条简单的语句(三地址码, x = y op z)
a = b + c * d;
上述语句在 IR 中可能被拆解为多条简单指令,每条只包含一次运算,从而降低分析难度。
目标代码生成阶段负责将中间语言映射到具体体系结构的指令集,包括寄存器分配、指令选择与调用约定处理。随后进行的优化可能贯穿多个阶段,例如常量折叠、死代码消除、循环展开等。这些优化在不改变程序语义的前提下提升执行效率,使最终生成的目标代码更贴近人工编写的高质量汇编。
2.3 链接器行为
链接器的历史可以追溯到早期计算机尚不具备完整编译系统的年代。在最初的程序开发中,开发者往往直接编写汇编代码,不同功能模块被分别翻译为目标文件,如何将这些代码片段拼接成一个可执行程序,成为比"编译"更早出现的工程需求。因此,链接器在发展时间上早于现代编译器,其核心职责也长期围绕代码整合与地址修正展开。
在链接阶段,地址与空间分配是首要工作之一。每个目标文件在生成时都假定自身从零地址开始布局,代码段、数据段之间的相对偏移是确定的,但它们在最终可执行文件中的绝对位置尚未确定。链接器需要根据整体布局策略,将来自不同目标文件的段合并,并为它们分配连续或对齐后的虚拟地址空间。
符号决议(符号绑定)是链接器最具语义性的行为。目标文件中既包含符号定义,也包含对外部符号的引用。链接器通过遍历所有输入文件,建立全局符号表,将未定义引用绑定到唯一且可见的定义上。这个过程不仅涉及名称匹配,还要处理强弱符号、重复定义以及静态与全局可见性的规则,从而保证程序语义的一致性。
c
/* a.c */
int x = 10;
/* b.c */
extern int x;
int y = x + 1;
在 b.o 中,对 x 的访问只是一个未解析符号,只有在链接阶段才能确定其真实来源。
重定位是符号决议之后的关键步骤。链接器在确定最终地址布局后,需要修正目标文件中所有与地址相关的引用,将原本基于段内偏移的地址调整为最终的运行地址。不同体系结构定义了不同类型的重定位项,链接器必须按约定对指令或数据进行精确修补,确保程序在装载和执行时能够正确访存。
整体来看,链接器并不理解高级语言的语法,却深刻参与程序语义的最终落地。从早期简单的地址拼接工具,到今天支持静态、动态链接和复杂符号规则的系统组件,链接器始终围绕地址、符号与重定位这三大核心问题不断演进。

Once Day
也信美人终作土,不堪幽梦太匆匆......
如果这篇文章为您带来了帮助或启发,不妨点个赞👍和关注!
(。◕‿◕。)感谢您的阅读与支持~~~