开门见山做个实验。写一行 C,然后让 gcc 一步步给你看它做了什么:
$ gcc -E hello.c -o hello.i # 预处理:宏展开,#include 摊平
$ gcc -S hello.i -o hello.s # 编译:生成汇编
$ gcc -c hello.s -o hello.o # 汇编:生成机器码
$ gcc hello.o -o hello # 链接:拼上库,出可执行文件
第二步(gcc -S)就是狭义的「编译器」:把高级语言翻译成低级语言。本课说的编译器都是这个意义的,而它内部还藏着一条流水线。本章的任务是画出这条流水线的全景,以及回答一个元问题:学编译原理到底学的是什么(答案:不是学 gcc 怎么用,是学「如何处理结构化文本」这套通用的自动化思想)。
编译器把源程序翻译到目标程序,分三个工段:
源代码 → [前端] → 中间表示 IR → [中端] → 优化后的 IR → [后端] → 目标机器码
词法/语法/语义分析 机器无关优化 代码生成
前端读你的代码,判断「它是不是合法的、语义是什么」,产出结构化的中间表示。后端面向特定机器,把 IR 翻译成 x86、ARM 或别的什么。中端夹在中间做机器无关优化(死代码删除、常量折叠、循环优化),它既不用懂你的语言,也不用懂你的 CPU。
这个分层的价值在「乘法交换律」式的事实:m 种语言 × n 种机器,朴素做法要写 m×n 个编译器,分层后只要 m 个前端 + n 个后端 + 1 个共享中端。LLVM 的成功就是这个算术:Clang(C/C++)、Rust、Swift 共享 LLVM 后端,一个后端优化升级,所有语言受益。这是工程史上「解耦」最有力的案例之一,计算机组成课第 1 章的 ISA 抽象在此再次现身。
a[i] = b * 2 为例前端的三步走,拿一行代码走全程:
词法分析(lexing):把字符流切成单词(记号 token):
a [ i ] = b * 2
→ IDENT(a) LBRACK IDENT(i) RBRACK ASSIGN IDENT(b) STAR NUM(2)
语法分析(parsing):把 token 流按文法组织成树(抽象语法树 AST):
=
/ \
a[i] *
| / \
i b 2
语义分析:查类型、查作用域:i 是 int,能当下标;b * 2 类型匹配;a 是 int 数组,赋值合法。通过后给 AST 附上类型注解。
三步各自产出,层层依赖,第一章先建立这个「流水线」直觉,第 2 章起逐段实现。
和编译器并排的概念是解释器:不生成目标代码,直接边解析边执行。Python(CPython 默认实现)、Ruby 是解释器,Java 是「编译到字节码 + 解释/JIT 执行」的混合体,JavaScript 引擎(V8)是「解释起步、热点 JIT」的另一个混合体。两者的分界线越来越像光谱而非二分:
本课第 10 章的迷你编译器编译到一个自制的虚拟机(字节码解释器),正好把「编译 + 解释」两头都体验一遍。
三个副产品,每个都比「会写编译器」应用更广:
处理结构化文本的全套工具。正则(词法)、上下文无关文法(语法)、递归下降/表驱动解析,这套东西在配置文件解析、协议解析、SQL 引擎、模板引擎里全是同款。学完第 3 章,你会突然看懂 JSON 解析器是怎么写的。
自动化的思维方式。词法分析器的 DFA、语法分析表的 LR 自动机、数据流分析的不动点迭代,共同气质是「把人的直觉变成可机械执行的过程」。数据库查询优化器(数据库课第 9 章)就是数据流分析的近亲。
对语言设计的基本鉴赏力。为什么 C 的 int *p 声明难读、为什么 C++ 模板报错像天书、为什么 Rust 借用检查能在编译期做,这些问题只有懂编译器的人能答到位。第 13 章(C 指针课)解析复杂声明用的右左法则,本质就是文法规则。
gcc -E、gcc -S,打开生成的文件,各找出一个「意料之外」的细节(比如 #include 展开后有多少行、局部变量怎么变成了寄存器名)。gcc -S 输出的汇编里,变量名全部消失了(只剩寄存器和栈偏移)。对照 C 指针课第 10 章(栈帧)解释:变量名这个信息在编译的哪个阶段被消费掉了?dis 模块(import dis; dis.dis(lambda: 1+2)),看看字节码长什么样。对照第 7 章(三地址码)你会发现它和经典 IR 的血缘。