简介:这份《编译原理学习指导》面向计算机专业本科生、考研复习者及自学编译原理的读者,针对课程概念抽象、算法理解困难与教材选择迷茫等问题,提供一份偏重学习路线与知识框架的文档。内容围绕编译器的词法分析、语法分析(LL、递归下降、LR)、语义分析、运行时环境、中间代码生成与代码优化等阶段展开,并比较《编译原理》(龙书)、《现代编译程序设计》《编译原理及实践》三本经典教材的侧重点,帮助读者按基础与动手需求选择参考书。压缩包内为1个doc文档,约34KB,篇幅精炼,适合作为课程同步阅读与复习提纲。目前已有153人学习,可辅助建立从算法本质到编译器构建的整体认识,也能为课程实验和考研备考提供方向性参考。
1. 编译原理学习指导:从三本书到一条能跑通的实践路线
很多人对编译原理的第一印象是「考试课」——词法分析、语法分析、LR 项目集族,背完就忘。但真正上手写代码时会发现,正则匹配、表达式求值、配置文件的解析、模板引擎的渲染,背后都是同一套东西:把一段文本变成一棵树,再把这棵树变成可执行的动作。这份学习指导的价值不在于替你考试,而在于把「龙书」的理论链条和 Tiny C 这类可跑的实践串起来,让你知道哪部分必须自己写,哪部分用 flex/bison 生成就行。适合正在啃编译原理的本科生、准备考研复试的人,以及写过解析器但一直没搞清 FIRST/FOLLOW 到底在算什么的一线开发者。
2. 三本教材怎么分工:龙书、现代编译程序设计、编译原理及实践
选教材这件事上,血泪经验是:不要试图用一本书解决所有问题。编译原理的知识结构天然分两层——理论层(形式语言、自动机、文法分析)和工程层(工具链、代码生成、运行时组织)。前者决定你能不能看懂状态机图,后者决定你能不能真的跑出一个能算 1+2 的编译器。
2.1 龙书:把理论骨架先立住
《Compilers: Principles, Techniques, and Tools》的核心优势是开篇就用一个完整的小例子把编译的各个阶段串了一遍,让你先有全局观,再钻细节。这本书适合放在第一阶段读,重点看三块:
- 词法分析里的正则表达式到 NFA、NFA 到 DFA 的子集构造法,这是唯一必须手推一遍的部分;
- 语法分析里的 LL(1) 和 SLR/LALR 分析表构造,理解「为什么需要向前看 k 个符号」;
- 语义分析与运行时环境章节,尤其是存储组织、活动记录、参数传递这几节,它解释了你写的递归函数在栈上到底长什么样。
代码优化那一章可以第一遍跳过,本科阶段能把前面几章吃透已经够用。常见做法是边读边在纸上画 DFA 和项目集规范族,不要只做眼读。
2.2 现代编译程序设计:面向工程实现的那本
《Modern Compiler Design》的差异点在于它覆盖了 Java 时代的主题——垃圾回收、面向对象语言的编译、解释器与即时编译的取舍。当你要做的不是「生成 x86 汇编」而是「生成一种可执行的中间表示」时,这本书的路子更贴近现实。
我的建议是把它当第二阶段的补充读物,重点看它的中间代码设计和运行时系统部分。表驱动、基于栈的中间表示这些内容,在龙书里讲得偏理论,在这本书里能落到具体的数据结构上。
2.3 编译原理及实践:用 Tiny C 把流程跑通
这本的价值几乎全在 Tiny C 上。它一边讲原理,一边带着你把一个小型 C 子集编译器从词法做到代码生成,还专门讲了 Lex 和 Yacc 的用法。对自学者来说,这是从「看懂了」到「写出来了」之间最短的一座桥。
下面这张表是我按使用场景整理的分工,可以直接抄:
| 教材 | 主攻阶段 | 必须精读的部分 | 可以跳过的部分 |
|---|---|---|---|
| 龙书 | 打理论基础 | 词法、语法分析、运行时环境 | 代码优化、并行相关章节 |
| 现代编译程序设计 | 工程实现 | 中间代码、运行时系统、垃圾回收 | 前端理论推导(与龙书重复) |
| 编译原理及实践 | 动手落地 | Tiny C 全流程、Lex/Yacc 用法 | 与龙书重复的形式化证明 |
翻译质量方面,这三本的中文版都还算可用,读理论时不必强求原版,卡在某个术语时对照英文原文查一下即可。
3. 把理论落成代码:词法、语法、语义三段式实操
理论读完最容易翻车的地方是:合上书什么都不会写。这一章按编译器前端的真实顺序,给出三段可以独立验证的实操,每段都能单独跑起来,不必等整个编译器写完。
3.1 词法分析:先手写一个状态机,再用 flex 生成
手写词法分析器的目的不是替代工具,而是让你亲手体会「正则表达式等价于有限自动机」这句话。下面这段 Python 用状态迁移的方式识别标识符、整数和单字符运算符:
# 极简词法分析器:识别标识符、整数、四则运算符 def tokenize(src): tokens = [] i = 0 n = len(src) while i < n: ch = src[i] if ch.isspace(): i += 1 # 跳过空白,不做 token elif ch.isalpha() or ch == '_': j = i while j < n and (src[j].isalnum() or src[j] == '_'): j += 1 # 贪婪匹配最长的标识符 tokens.append(('ID', src[i:j])) i = j elif ch.isdigit(): j = i while j < n and src[j].isdigit(): j += 1 tokens.append(('NUM', int(src[i:j]))) i = j else: tokens.append(('OP', ch)) # 运算符和括号先按单字符处理 i += 1 return tokens print(tokenize("x1 = 42 + y_2 * 3"))逻辑说明:循环里每个分支对应自动机的一个状态出口,i是读头位置。标识符分支用内层 while 实现最长匹配,这是词法分析的关键约定——永远取能匹配的最长前缀,否则ifx会被切成if和x两个 token。参数上,这里没有做关键字区分,实际工程中会在识别出 ID 后查一张关键字哈希表,命中就改 token 类型。
手写一遍之后,用 flex 重写同一个规则集,对照生成的yy.lex.c,你会直观看到状态表是怎么被自动生成的:
# calc.l:flex 词法规则示例 %{ #include <stdio.h> %} %% [0-9]+ { printf("NUM %s\n", yytext); } [a-zA-Z_][a-zA-Z0-9_]* { printf("ID %s\n", yytext); } [ \t\n]+ { /* 丢弃空白 */ } . { printf("OP %s\n", yytext); } %% int main(void) { yylex(); return 0; } int yywrap(void) { return 1; }执行flex calc.l && gcc lex.yy.c -o calc && echo "a1 = 3 + b" | ./calc就能看到切分结果。注意[0-9]+和标识符规则谁写在前面会影响优先级——flex 按最长匹配优先,长度相同时才取先出现的规则。
3.2 语法分析:递归下降手写,LR 交给工具
递归下降是 LL 思路的直接实现,对每种非终结符写一个函数,代码结构和文法几乎一一对应。以带加减乘除的表达式文法为例:
# 递归下降解析器:expr -> term (('+'|'-') term)* # term -> factor (('*'|'/') factor)* # factor -> NUM | '(' expr ')' class Parser: def __init__(self, tokens): self.toks = tokens self.pos = 0 def peek(self): return self.toks[self.pos] if self.pos < len(self.toks) else (None, None) def eat(self, kind): tok = self.peek() if tok[0] != kind: raise SyntaxError(f"expect {kind}, got {tok}") self.pos += 1 return tok def expr(self): node = self.term() while self.peek()[0] == 'OP' and self.peek()[1] in '+-': op = self.eat('OP')[1] node = (op, node, self.term()) # 左结合:先建左子树 return node def term(self): node = self.factor() while self.peek()[0] == 'OP' and self.peek()[1] in '*/': op = self.eat('OP')[1] node = (op, node, self.factor()) return node def factor(self): kind, val = self.peek() if kind == 'NUM': self.eat('NUM') return ('num', val) if kind == 'OP' and val == '(': self.eat('OP') node = self.expr() self.eat('OP') return node raise SyntaxError(f"unexpected {val}") toks = tokenize("1 + 2 * (3 - 4)") print(Parser(toks).expr())逻辑说明:每个非终结符对应一个方法,方法内部先调下一层,再用 while 循环处理左递归——这是消除直接左递归的标准写法。while而非if保证1+2+3被解析成((1+2)+3)而不是报错。参数上,pos是唯一的可变状态,所有函数共享,所以出错时打印pos就能定位到出错位置。
LR 分析不建议手写。用 bison 定义文法,让工具去算项目集和 ACTION/GOTO 表:
# calc.y:bison 文法骨架 %token NUM %left '+' '-' %left '*' '/' %% expr: expr '+' expr { $$ = $1 + $3; } | expr '-' expr { $$ = $1 - $3; } | expr '*' expr { $$ = $1 * $3; } | expr '/' expr { $$ = $1 / $3; } | '(' expr ')' { $$ = $2; } | NUM { $$ = $1; } ; %%%left的声明顺序直接决定优先级和结合性,声明越靠后优先级越高。这行配置写错是新手最常见的翻车点——语法没冲突,但算出来的2+3*4是 20 而不是 14。
3.3 语义分析与中间代码:把树变成三地址码
语义分析阶段做的事是「完善抽象语法树」,最典型的是类型检查。而中间代码生成最机械也最好懂的做法,是递归遍历语法树,输出基于栈或三地址的指令:
# 从语法树生成三地址码,临时变量按 t1、t2 递增 class CodeGen: def __init__(self): self.tmp = 0 self.code = [] def new_tmp(self): self.tmp += 1 return f"t{self.tmp}" def gen(self, node): if node[0] == 'num': return str(node[1]) op, left, right = node l = self.gen(left) # 后序遍历:先算子树 r = self.gen(right) t = self.new_tmp() self.code.append(f"{t} = {l} {op} {r}") return t tree = Parser(tokenize("1 + 2 * (3 - 4)")).expr() cg = CodeGen() result = cg.gen(tree) for line in cg.code: print(line) print("result ->", result)逻辑说明:后序遍历保证子表达式的指令先于父节点生成,这正是「用递归遍历抽象语法树机械地生成指令」的实现。参数方面,临时变量命名规则一旦确定就要全局一致,否则后续寄存器分配阶段会乱。C 语言里把浮点数赋给整型变量会报类型不匹配,靠的就是在这一层给每个节点标注类型并做兼容性判断。
4. 避坑与排查:自学者最容易卡住的五个地方
这一章记录的是我在带人做 Tiny C 类项目时反复见到的问题,每条按现象、原因、解决来写,遇到时直接对照。
现象一:flex 报「multiple definition of yylex」。原因通常是工程里同时存在手写的词法函数和 flex 生成的lex.yy.c,链接时符号冲突。解决办法是统一入口,把手写版本改名或删掉,让yylex只由 flex 产出;如果确实需要两套,用%option prefix="myprefix_"给生成函数加前缀。
现象二:递归下降解析器在1-2-3上报错或结果错。现象是只算了第一个减号就停下。原因是把while写成了if,或者把左递归消除后的循环位置放错了——循环必须在处理完一个 term 之后继续检查下一个运算符,而不是在进入 term 前判断。检查办法是打断点看pos在每轮循环后是否推进。
现象三:bison 报 shift/reduce conflict 然后运行结果诡异。原因是文法存在二义性,最典型的是表达式文法没声明优先级。%left的顺序就是解决手段,先声明的优先级低,后声明的高,同级左结合用%left、右结合用%right。不要靠%expect把警告压掉,那只是把问题藏起来。
现象四:访问语法树节点时空指针崩溃。原因多为解析阶段出错后返回了None,而代码生成阶段没做判空就递归下去。解决是在gen入口加断言,或者在eat里统一抛异常让上层捕获。另一个常见来源是临时变量作用域搞混,t1在嵌套表达式中被覆盖。
现象五:Tiny C 编译通过但运行结果全是 0。这多半是运行时环境的栈帧布局和代码生成阶段的偏移量对不上。符号表里记录的局部变量偏移,必须和实际入栈顺序严格一致。排查时先打印生成的汇编或中间码,逐个核对 push/pop 顺序,再对照活动记录的内存布局图。
提示:遇到 LR 分析表算不对时,先别怀疑算法,把项目集规范族逐项抄在纸上,多半是闭包计算时漏了某个非终结符的 FIRST 集推导。
5. 用 Tiny C 走通全流程:验证清单与三个进阶技巧
全流程跑通这件事,判断标准不是「代码能编译」,而是能通过一组分层验证。我一般按下面的顺序检查,每层都留一条可复现的命令:
| 验证层 | 输入 | 期望输出 | 失败时的第一检查点 |
|---|---|---|---|
| 词法 | int a = 1; | ID/INT/ID/ASSIGN/NUM/SEMI | 最长匹配、关键字表命中 |
| 语法 | a = b + c * d; | 语法树结构正确,乘号在深层 | 优先级声明、左递归消除 |
| 语义 | int a; a = 1.5; | 类型不匹配告警 | 节点类型标注、赋值兼容检查 |
| 中间码 | 1+2*(3-4) | 后序三地址码序列 | 临时变量生成顺序 |
| 运行 | 完整小程序 | 输出与 gcc 结果一致 | 栈帧偏移、符号表解析 |
进阶上有三个技巧值得单独说。第一个是复用现成语法文件:Minus C、Tiny C 这类教学语言的定义在网上能找到完整版本,拿来改比从零写省几天时间,但一定要自己读一遍产生式,尤其是空产生式和左递归处理,否则调试时根本不知道冲突从哪来。第二个是先做解释器再做代码生成:生成机器码要处理寄存器分配,这是复杂度陡增的分水岭;而生成基于堆栈的中间码再由一个小型虚拟机解释执行,能把前端和运行时的耦合度降到最低,Java 的字节码走的就是这条路。第三个是给每个阶段加独立的 dump 开关:--dump-tokens、--dump-ast、--dump-ir,出问题时能定位到具体阶段,否则整个编译流程就是个黑匣子,你只能看到最后结果错,不知道错在哪一步。
还有一条后悔药:自己定文法时,先写文法再写解析器,不要反过来。同事 A 同学当初是先写函数再拼文法,结果后期为了兼容已有代码在文法里塞了一堆特例,最后连自己都推不出 FIRST 集。从那以后我每次动手前都强制把产生式写在一张纸上,标好哪些是左递归、哪些可以空推导,再开始敲代码。希望帮到你。
本文还有配套的精品资源,点击获取