第1章 引论
导读
编译原理是计算机科学中最经典、最基础的课程之一。本书(俗称"龙书")自1977年首版以来,一直是全球高校计算机科学专业的标准教材。本章作为全书的引论,将从宏观视角为读者建立编译器的整体认知框架,帮助你理解编译器在计算机系统中的位置与作用,了解编译过程的基本阶段,掌握程序设计语言与机器之间的桥梁是如何搭建的。
在深入学习每一个技术细节之前,我们需要先回答几个根本性的问题:什么是编译器?为什么需要编译器?编译器是如何工作的?它与解释器有什么区别?编译器与其他系统软件(如操作系统、汇编器、链接器)之间是什么关系?
本章将系统性地回答这些问题,并为后续各章的深入学习奠定概念基础。无论你是初次接触编译原理的学生,还是有一定经验希望系统复习的工程师,本章的内容都将帮助你建立起完整的知识地图。
核心概念详解
1.1 编译器与解释器
编译器(Compiler)是一种程序,它将用某种高级程序设计语言(源语言)编写的代码转换为另一种语言(目标语言)的等价表示。编译过程最重要的特征在于:它生成一个独立的目标程序,该目标程序可以脱离编译器和源程序独立运行。
解释器(Interpreter)则直接执行源程序的操作,而不生成独立的目标代码。解释器逐行读取源代码,分析其含义,并立即执行相应的操作。
两者各有优劣:
| 特性 | 编译器 | 解释器 |
|---|---|---|
| 执行速度 | 快(直接执行机器码) | 慢(需要实时翻译) |
| 启动时间 | 长(需要完整编译) | 短(立即开始执行) |
| 调试便利性 | 较差 | 较好(可逐行调试) |
| 平台依赖性 | 目标代码依赖平台 | 解释器依赖平台 |
| 内存使用 | 运行时不需编译器 | 运行时需保留解释器 |
现代实践中,许多语言采用了混合方式。例如Java语言,先由编译器将Java源代码编译为字节码(bytecode),再由Java虚拟机(JVM)解释执行或即时编译(JIT)执行。这种方式结合了两者的优点。
1.2 编译器的结构
一个典型的编译器由多个阶段(phase)组成,每个阶段负责将源代码的一种表示转换为另一种表示。经典的编译过程可以分为以下阶段:
1. 词法分析(Lexical Analysis)
词法分析是编译的第一个阶段,它读取源程序的字符流,将其组织成有意义的词素(lexeme),并为每个词素生成一个记号(token)。例如,语句 position = initial + rate * 60 会被分解为如下记号序列:
ID(position) ASSIGN ID(initial) PLUS ID(rate) MULT NUM(60)词法分析器(也称为扫描器,scanner)通常使用有限自动机(Finite Automata)来实现。
2. 语法分析(Syntax Analysis)
语法分析是编译的第二个阶段,它根据语言的语法规则,将词法分析产生的记号组织成语法结构——语法树(syntax tree)。语法树中的每个内部节点代表一个运算,叶子节点代表运算的操作数。
语法分析器(也称为解析器,parser)检查源程序是否满足语言的语法规则,如果不满足则报告语法错误。
3. 语义分析(Semantic Analysis)
语义分析利用语法树和语义规则来检查源程序的语义一致性。它进行类型检查,确保运算符和操作数兼容。例如,如果数组名被当作简单变量使用,语义分析器将报告类型错误。
语义分析还包括类型转换(coercion),当运算符两侧类型不一致时,自动插入类型转换操作。
4. 中间代码生成
在语义分析之后,编译器通常会生成一种中间表示(Intermediate Representation, IR)。这种中间表示既便于进一步处理,又接近目标机器的形式。常见的中间表示包括三地址码(three-address code)和抽象语法树(AST)。
5. 代码优化
代码优化阶段对中间代码进行变换,使其产生更优的目标代码。优化可以是局部的(如常量折叠、公共子表达式消除),也可以是全局的(如循环优化、死代码消除)。
6. 代码生成
代码生成阶段将优化后的中间表示转换为目标机器的机器代码或汇编代码。这个阶段需要处理寄存器分配、指令选择和指令调度等问题。
1.3 符号表管理
贯穿编译全过程的一个重要数据结构是符号表(Symbol Table)。符号表记录了源程序中各个名字(标识符)的信息,包括:
- 名字的词法信息(如标识符的拼写)
- 类型信息(如整型、浮点型、数组、记录等)
- 存储信息(如地址、偏移量、寄存器分配)
- 作用域信息(如全局、局部、参数等)
符号表通常使用哈希表实现,以支持高效的查找和插入操作。
1.4 错误处理
编译器必须能够检测源程序中的各种错误,并向用户提供有意义的错误信息。错误可以分为以下几类:
- 词法错误:如非法字符
@出现在不允许的位置 - 语法错误:如缺少分号、括号不匹配
- 语义错误:如类型不匹配、变量未声明
- 逻辑错误:如死循环、不可达代码(这类错误编译器通常难以检测)
错误处理策略包括:
- 恐慌模式(Panic Mode):发现错误后跳过若干输入符号,直到遇到同步记号(如分号)
- 短语层次恢复(Phrase-Level Recovery):在局部进行替换或删除以修正错误
- 错误产生式(Error Productions):在文法中加入错误产生式来捕获常见错误
- 全局纠正(Global Correction):理论上最优但实际中代价太高
重要知识点
1.5 编译器的分析与综合
编译过程可以抽象为两个主要部分:
分析(Analysis)部分将源程序分解为其组成成分,并施加语法和语义约束。分析阶段产生源程序的中间表示。分析部分包括:
- 词法分析
- 语法分析
- 语义分析
综合(Synthesis)部分根据中间表示构造目标程序。综合阶段包括:
- 中间代码生成
- 代码优化
- 代码生成
这种"分析-综合"的二分法是编译器设计的基本范式。
1.6 程序设计语言的分类
从编译器的角度,程序设计语言可以从以下几个维度进行分类:
按范式分类:
- 命令式语言(Imperative):C、C++、Java、Go
- 函数式语言(Functional):Haskell、ML、Lisp
- 逻辑式语言(Logic):Prolog
- 声明式语言(Declarative):SQL、HTML
按类型系统分类:
- 静态类型 vs 动态类型
- 强类型 vs 弱类型
- 显式类型 vs 隐式类型
按执行方式分类:
- 编译型语言(C、C++、Go、Rust)
- 解释型语言(Python、Ruby、JavaScript)
- 混合型语言(Java、C#)
1.7 编译器与其他系统软件的关系
编译器不是孤立存在的,它与其他系统软件有着密切的关系:
与操作系统的关系:
- 编译器生成的目标代码需要操作系统的支持才能运行
- 操作系统提供系统调用接口,编译器生成的代码通过这些接口与硬件交互
- 某些编译器(如GCC)本身也是操作系统生态的一部分
与链接器的关系:
- 编译器生成的是目标文件(.o 或 .obj),其中可能包含未解析的外部引用
- 链接器将多个目标文件和库文件合并为可执行文件
- 静态链接在编译时完成,动态链接在运行时完成
与汇编器的关系:
- 如果编译器的目标是汇编语言,则需要汇编器将汇编代码转换为机器码
- 汇编器的工作相对简单,主要是指令到二进制编码的一对一映射
与预处理器的关系:
- 预处理器在编译器之前运行,处理宏替换、文件包含、条件编译等
- C/C++的预处理器(cpp)是一个典型的例子
- 预处理器的输出才是编译器的真正输入
1.8 编译器的设计方法
设计编译器有多种方法论:
单次遍编译(Single-Pass Compilation):
- 所有编译阶段在一次遍历中完成
- 适用于简单语言或资源受限的环境
- 优点:实现简单,速度快
- 缺点:难以进行全局优化,前向引用处理困难
多遍编译(Multi-Pass Compilation):
- 每个阶段独立执行,前一阶段的输出作为后一阶段的输入
- 适用于复杂语言和需要优化的场景
- 优点:模块化程度高,便于维护和扩展
- 缺点:需要多次读写中间文件,速度较慢
增量编译(Incremental Compilation):
- 只重新编译发生变化的部分
- 适用于大型项目的快速开发迭代
- Java的增量编译器是典型例子
即时编译(Just-In-Time Compilation, JIT):
- 在程序运行时将字节码编译为机器码
- 可以基于运行时信息进行优化
- Java HotSpot VM 和 .NET CLR 都使用 JIT 编译
常见误区
误区一:编译器就是翻译器
编译器不仅仅是简单的翻译器。翻译器(如Google翻译)将一种自然语言翻译为另一种自然语言,两者在语义上是等价的。而编译器在翻译过程中会进行大量的分析和优化,生成的目标代码在结构上可能与源代码完全不同。编译器需要理解源代码的语义,并保证语义等价性。
误区二:编译器只能生成机器码
编译器的目标语言可以是任何语言,包括:
- 机器语言或汇编语言(如C编译器)
- 字节码(如Java编译器)
- 另一种高级语言(如早期的C++到C编译器,TypeScript到JavaScript编译器)
- 硬件描述语言(如HLS工具将高级语言转为Verilog)
误区三:编译器只在编译时工作
现代编译器的功能已经远远超出了传统的"编译"范畴:
- IDE中的实时语法检查和代码补全
- 静态分析工具(如lint工具)
- 代码格式化工具(如prettier、gofmt)
- 类型检查器(如TypeScript的tsc --noEmit)
这些都是编译技术的应用,但它们并不生成可执行代码。
误区四:学习编译原理对实际开发没有帮助
学习编译原理的价值远不止于"写一个编译器":
- 理解语言的底层机制,写出更高效的代码
- 掌握形式语言和自动机理论,提升抽象思维能力
- 为开发DSL(领域特定语言)打下基础
- 理解工具链的工作原理,更好地使用调试器、性能分析器等工具
- 为从事虚拟机、解释器、代码生成等领域的工作做准备
实践应用
1.9 编译技术的实际应用
编译技术在日常软件开发中有广泛的应用:
Web开发中的编译:
- TypeScript 编译器将 TypeScript 代码编译为 JavaScript
- Babel 将新语法特性转译为旧版浏览器支持的语法
- Webpack、Rollup 等打包工具内部使用了编译技术进行模块解析和代码转换
- SASS/LESS 编译器将样式预处理语言编译为 CSS
数据库中的编译:
- SQL 查询编译器将 SQL 语句编译为查询执行计划
- 查询优化器使用与编译器代码优化类似的技术
- JIT 编译在数据库引擎中也有应用(如 SQLite 的 JIT 扩展)
文档处理中的编译:
- LaTeX 编译器将 LaTeX 源码编译为 PDF
- Markdown 处理器将 Markdown 转换为 HTML
- 模板引擎(如Jinja2、EJS)将模板编译为可执行代码
配置语言中的编译:
- JSON/YAML 解析器本质上也是编译器的前端
- Nginx 配置文件的解析
- Docker Compose 文件的解析
1.10 一个简单的编译示例
让我们通过一个简单的例子来理解编译的全过程。考虑以下C语言程序:
int main() {
int a = 10;
int b = 20;
int c = a + b;
return c;
}词法分析输出:
KEYWORD(int) ID(main) SYMBOL(() SYMBOL()) SYMBOL({)
KEYWORD(int) ID(a) SYMBOL(=) NUM(10) SYMBOL(;)
KEYWORD(int) ID(b) SYMBOL(=) NUM(20) SYMBOL(;)
KEYWORD(int) ID(c) SYMBOL(=) ID(a) SYMBOL(+) ID(b) SYMBOL(;)
KEYWORD(return) ID(c) SYMBOL(;)
SYMBOL(})语法分析输出(抽象语法树):
FunctionDeclaration
├── ReturnType: int
├── Name: main
├── Parameters: []
└── Body: Block
├── VarDecl(a, int, 10)
├── VarDecl(b, int, 20)
├── VarDecl(c, int, BinaryOp(+, a, b))
└── Return(c)中间代码(三地址码):
main:
a = 10
b = 20
t1 = a + b
c = t1
return c目标代码(x86汇编):
main:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 10
mov DWORD PTR [rbp-8], 20
mov eax, DWORD PTR [rbp-4]
add eax, DWORD PTR [rbp-8]
mov DWORD PTR [rbp-12], eax
mov eax, DWORD PTR [rbp-12]
pop rbp
ret从这个例子可以看到,编译过程经历了多次表示的变换,每次变换都使代码更接近最终的机器表示,同时保留了原始程序的语义。
本章小结
本章作为全书的引论,介绍了编译原理的基本概念和整体框架。我们学习了以下核心内容:
编译器与解释器的区别:编译器生成独立的目标程序,解释器直接执行源程序。现代语言通常采用混合方式。
编译器的六个阶段:词法分析、语法分析、语义分析、中间代码生成、代码优化和代码生成。每个阶段都有明确的输入和输出。
符号表管理贯穿编译全过程,是连接各阶段的重要数据结构。
错误处理是编译器的重要组成部分,包括恐慌模式、短语层次恢复等策略。
编译器的分析与综合范式是理解编译过程的基本框架。
编译技术的广泛应用不仅限于传统编译器,还涵盖Web开发、数据库、文档处理等众多领域。
理解这些基本概念对于后续深入学习各个编译阶段至关重要。从下一章开始,我们将通过一个具体的例子——简单语言翻译器——来实际体验编译器的构建过程。