ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

编译原理学习指导:从词法语法到代码生成的动手路线与避坑指南

编译原理学习指导:从词法语法到代码生成的动手路线与避坑指南

简介:这份《编译原理学习指导》文档面向计算机专业本科生、考研备考者以及希望深入理解编译器工作机制的自学者,针对编译原理课程抽象难懂、算法门槛高、教材选择困难等问题提供系统梳理。资源为单个doc文档,压缩包约34KB,内容围绕词法分析、语法分析(LL、递归下降、LR算法)、语义分析、运行时环境、中间代码生成与代码优化等核心阶段展开,并重点推荐了《Compilers: Principles, Techniques, and Tools》(龙书)、《Modern Compiler Design》和《编译原理及实践》三本经典教材,对比其理论深度与实践侧重,帮助读者按需选书。文档还结合正则表达式与自动机原理讲解词法分析的理论化过程,并说明LL与LR算法的学习难点及Yacc等工具的实际应用思路。目前已有153人学习,适合作为课程复习、考研梳理与自学入门的参考材料。

1. 从“看得懂”到“写得出”:编译原理学习指导到底该啃哪几块硬骨头

很多人第一次翻开编译原理教材,翻到 LR 自动机那一章就合上了,然后安慰自己“反正以后也不写编译器”。这个判断其实只对了一半——不写编译器是真的,但编译原理里那套把非结构化文本转成结构化树、再机械遍历树生成指令的思路,几乎贯穿了后来所有跟“解析”和“转换”沾边的工作。正则表达式引擎、JSON 解析器、模板引擎、查询优化器、甚至构建工具里的依赖分析,底层都是同一套东西。这份学习指导的价值不在于帮你通过考试,而在于把词法分析、语法分析、语义分析、运行时环境、中间代码生成这几块串成一条能动手复现的线,让你从“看得懂伪代码”走到“能自己写一个能跑的小编译器”。适合正在学这门课、准备考研复试、或者想补上这块底层认知的开发者。下面按我实际拆书和动手的顺序,把选书、工具链、算法边界和踩坑点一次讲清。

2. 选书不是选封面:三本经典教材的定位差异与阅读顺序

2.1 龙书、现代编译程序设计、编译原理及实践各自解决什么问题

这三本书经常被放在一起推荐,但它们的写作目标完全不同,混着读容易互相打架。龙书的正式名字是《Compilers: Principles, Techniques, and Tools》,封面那条红龙让它得了这个外号。它的核心价值在于理论框架的完整性——从词法分析的正则表达式与有限自动机,到 LL 和 LR 的语法分析表构造,再到语法制导翻译和类型检查,每一块都给出了形式化定义和证明思路。它开篇就用一个实际的小语言例子把整个编译流程走了一遍,让你先看到全貌再钻细节,这一点比很多国内教材上来就堆定义要友好得多。但龙书对代码生成和运行时环境的讲解偏理论,寄存器分配、指令选择这些工程细节着墨不多。

《Modern Compiler Design》的定位正好补上这块。它关注的是实践中的编译技术,书里给了不少实际程序代码,而且专门讨论了 Java 这类解释执行语言里的垃圾回收算法——这在传统教材里是看不到的,因为 Java 流行起来的时候龙书早就定型了。如果你想自己动手做一个能处理现代语言特性的编译器,这本书的参考价值更高。

《编译原理及实践》则是入门友好型。它讲解细致但不过分深入,重点在于带着你逐步实现一个叫 Tiny C 的小型编译器。整本书看完,你手里会有一个能跑的完整编译系统,而且作者对 Lex 和 Yacc 这两个工具的说明非常详细,国内教材很少能做到这个程度。我的建议阅读顺序是:先翻《编译原理及实践》建立动手信心和工具链认知,同时用龙书补理论深度,等这两本吃得差不多了再翻《Modern Compiler Design》看工程实现。英文版和中文版翻译质量都不错,不必纠结原版,理解理论实质比抠字眼重要。

2.2 按教学侧重分配精力:词法、语法优先,代码优化后置

本科教学不可能把编译原理所有部分讲完,这不是偷懒,是课时和认知规律决定的。从实际学习曲线看,词法分析和语法分析必须优先吃透,语义分析和运行时环境次之,代码生成和代码优化可以放到最后甚至暂时跳过。

词法分析相对简单,很多人没学过编译原理也能写出能用的词法分析器。但课程里引入正则表达式和自动机理论,目的是让这件事从“手写一堆 if-else”上升到“用形式化方法描述和自动生成”。这个思维转变比写出代码本身更重要。语法分析是真正的分水岭,LL 自顶向下和 LR 自底向上两条路线,前者里的递归下降算法必须能自己手写,后者理解原理即可,实践中基本用 Yacc 这类工具生成。LR 算法劝退了大量自学者,但说实话,除非你要去写编译器前端或者做程序分析工具,否则没必要死磕到手推分析表。

语义分析、类型检查、语法制导翻译这些部分,本质是在完善语法分析得到的抽象语法树。比如 C 语言里把浮点数直接赋给整数会报类型不匹配,就是这一步查出来的。C++ 的多态函数让这部分更复杂,但教材通常只讲处理策略,因为新问题总在出现。运行时环境这部分我强烈建议认真看,它讲的是程序怎么存储、怎么装载、怎么执行,符号表、参数传递、动态存储分配这些内容,哪怕你以后不碰编译器,写普通程序时理解这些也会让你对内存和性能有更准的判断。中间代码生成和代码优化,本科阶段了解即可,真要做深,单独开一学期都不够。

3. 把理论跑起来:Lex/Yacc 工具链与递归下降的动手路径

3.1 Windows 下用 flex 和 bison 搭出可复现的词法语法分析环境

Lex 和 Yacc 是 Unix 系统下的经典工具,Windows 上要用,常见做法是装一个模拟 Unix 环境的工具集,里面会带 flex.exe 和 bison.exe。装好之后,整个流程分三步:写词法文件、写语法文件、编译链接。下面是一个能识别简单算术表达式并计算结果的完整例子,你可以直接抄作业。

先写词法文件calc.l:

%{ #include "calc.tab.h" %} %% [0-9]+ { yylval = atoi(yytext); return NUMBER; } "+" { return '+'; } "-" { return '-'; } "*" { return '*'; } "/" { return '/'; } "(" { return '('; } ")" { return ')'; } [ \t\n] { /* 跳过空白 */ } . { return yytext[0]; } %%

这段的逻辑很直接:%%之间是规则区,左边是正则表达式匹配模式,右边是匹配后执行的动作。[0-9]+匹配一个或多个数字,用atoi转成整数存进yylval,然后返回记号NUMBER给语法分析器。运算符和括号直接返回字符本身作为记号。最后一条.是兜底规则,匹配任何没被前面规则捕获的字符,直接返回该字符,这样语法分析器能报出非法输入。

再写语法文件calc.y:

%{ #include <stdio.h> #include <stdlib.h> int yylex(void); void yyerror(const char *s); %} %token NUMBER %left '+' '-' %left '*' '/' %% input: /* 空 */ | input line ; line: '\n' { printf("> "); } | exp '\n' { printf("= %d\n> ", $1); } ; exp: NUMBER { $$ = $1; } | exp '+' exp { $$ = $1 + $3; } | exp '-' exp { $$ = $1 - $3; } | exp '*' exp { $$ = $1 * $3; } | exp '/' exp { $$ = $1 / $3; } | '(' exp ')' { $$ = $2; } ; %% void yyerror(const char *s) { fprintf(stderr, "错误: %s\n", s); } int main(void) { printf("> "); yyparse(); return 0; }

%left声明运算符的结合性和优先级,'+' '-'优先级低于'*' '/',所以1+2*3会先算乘法。exp的规则里$$表示当前规则归约后的值,$1、$3表示右边第几个符号的值。main函数里先打印提示符再调用yyparse()启动解析循环。

编译命令如下:

flex calc.l bison -d calc.y gcc -o calc calc.tab.c lex.yy.c -lfl

bison -d会额外生成calc.tab.h,里面包含记号定义,词法文件需要包含它。-lfl链接 flex 的库。跑起来后输入1+2*3回车,应该输出= 7。这个例子虽然小,但完整覆盖了词法规则、语法规则、优先级处理和错误报告,把这三步跑通,后面换更复杂的语言只是规则数量的问题。

3.2 递归下降手写解析器:什么时候必须自己写,怎么写不翻车

递归下降是 LL 算法里最适合手写的形式,每个非终结符对应一个函数,函数体里按产生式顺序匹配输入。它的优点是直观、好调试、容易加语义动作,缺点是遇到左递归文法会无限递归,需要先消除左递归。什么时候必须自己写?当你不在 C 平台、用不了 Yacc 的时候,比如 Java 或某些脚本语言环境里,手写递归下降是最现实的选择。

下面是一个能解析加减乘除表达式的递归下降解析器核心部分,用 Python 写,逻辑清晰:

class Parser: def __init__(self, tokens): self.tokens = tokens self.pos = 0 def peek(self): return self.tokens[self.pos] if self.pos < len(self.tokens) else None def consume(self, expected=None): tok = self.peek() if expected and tok != expected: raise SyntaxError(f"期望 {expected},实际 {tok}") self.pos += 1 return tok def parse_expr(self): # expr -> term (('+' | '-') term)* left = self.parse_term() while self.peek() in ('+', '-'): op = self.consume() right = self.parse_term() left = left + right if op == '+' else left - right return left def parse_term(self): # term -> factor (('*' | '/') factor)* left = self.parse_factor() while self.peek() in ('*', '/'): op = self.consume() right = self.parse_factor() left = left * right if op == '*' else left // right return left def parse_factor(self): # factor -> NUMBER | '(' expr ')' tok = self.peek() if tok == '(': self.consume('(') val = self.parse_expr() self.consume(')') return val return int(self.consume())

parse_expr处理加减,parse_term处理乘除,parse_factor处理数字和括号。每个函数先解析一个更高优先级的子表达式,然后循环处理当前优先级的运算符。这种分层结构天然解决了优先级问题,不需要像 Yacc 那样声明%left。consume方法带期望值检查,不匹配就抛异常,方便定位错误。这个解析器配合一个简单的词法切分函数就能跑,输入1+2*3会返回 7,输入(1+2)*3会返回 9。

手写递归下降最容易翻车的地方是左递归。比如把expr -> expr '+' term直接翻译成函数,parse_expr一上来就调自己,栈直接爆掉。解决办法是改写成循环形式,就像上面代码里while self.peek() in ('+', '-')那样。另一个坑是回溯,递归下降默认不回溯,如果文法有公共前缀,需要提取左公因子或者用预测集来选产生式。对于课程作业级别的小语言,循环加分层基本够用。

4. 避坑与排查:学编译原理时最容易卡住的五个地方

4.1 现象:LR 分析表构造看不懂,越看越焦虑

原因:LR 算法涉及项目集规范族、闭包、GOTO 函数、冲突消解,概念密度远高于 LL。很多教材在这一章突然加速,导致自学者断档。

解决:先接受“理解即可,不必手推”这个定位。找一个小文法,用 Yacc 跑一遍,观察它生成的y.output文件里状态机长什么样,把状态和项目对应起来看。如果考研需要手推,就找三五个典型文法反复练,练到能默写出闭包和 GOTO 的步骤为止。不要一上来就啃最复杂的文法。

4.2 现象:词法文件写好了,语法文件一编译就报一堆冲突

原因:Yacc 默认用 LALR(1) 算法,如果文法有移进-归约冲突或归约-归约冲突,它会报 warning 但仍然生成解析器,运行时行为可能不符合预期。

解决:先看y.output里冲突发生在哪个状态,定位到具体规则。移进-归约冲突通常用优先级声明%left、%right、%nonassoc解决,归约-归约冲突往往意味着文法有歧义,需要改写。不要忽略 warning,它是在告诉你解析器有“玄学”行为。

4.3 现象:递归下降解析器遇到嵌套括号或长表达式时栈溢出

原因:递归深度和输入嵌套深度成正比,Python 默认递归限制是 1000 层,深层嵌套直接触发RecursionError。

解决:对于课程作业级别,调大sys.setrecursionlimit能顶一阵。但更根本的做法是把递归改成显式栈驱动的迭代形式,或者用运算符优先级解析(Pratt parsing)来替代多层递归。如果只是解析算术表达式,调度场算法(Shunting-yard)也是常见替代方案。

4.4 现象:语义分析阶段类型检查规则越写越多,最后变成一团乱麻

原因:类型系统本身可以很复杂,C++ 的多态、模板、隐式转换让规则数量爆炸。教材通常只给策略不给完整实现,自己动手时容易陷入补丁摞补丁。

解决:先限定语言子集,只支持基本类型和显式转换,把类型检查做成对抽象语法树的递归遍历,每个节点返回自己的类型,父节点根据子节点类型判断是否合法。遇到复杂特性先跳过,保证核心流程能跑通。类型检查的架构比规则数量更重要。

4.5 现象:生成的中间代码能跑但结果不对,又不知道从哪查

原因:代码生成依赖抽象语法树的结构,如果语法分析阶段树建错了,后面全错。但错误往往到运行时才暴露,定位困难。

解决:在语法分析完成后加一个树打印函数,把抽象语法树按缩进打印出来,肉眼检查结构是否符合预期。常见错误包括运算符优先级搞反、括号没正确处理、赋值语句的左右子树颠倒。树对了,代码生成基本不会错。这个打印函数我每次写解析器都会先加上,比调试器还管用。

5. 从 Tiny C 到自己的小语言:验证编译器是否真正跑通的三步法

学完理论、跑通工具链之后,怎么判断自己是真的会了而不是背下来了?我的习惯是走三步验证。第一步,拿《编译原理及实践》里的 Tiny C 源码,在本地编译运行,输入一段包含变量声明、赋值、条件判断和循环的小程序,确认输出符合预期。这一步是建立基准,知道一个“能跑的编译器”长什么样。第二步,在 Tiny C 的基础上加一个语言特性,比如增加for循环或者do-while,从词法规则、语法规则、语义检查到代码生成完整走一遍。这一步会逼你面对所有环节的衔接问题,是最有效的检验。第三步,换一个目标平台,比如把原来生成堆栈式中间代码改成生成三地址码,或者把解释执行改成生成简单的汇编。这一步不要求完整,哪怕只生成加减乘除的汇编,也能让你对寄存器分配和指令选择有切身体会。

下面这张表是我在扩展 Tiny C 时记录的几个关键改动点和对应影响,供你参考:

改动点涉及阶段常见错误验证方式
增加for循环语法规则、语义分析、代码生成循环变量作用域处理错误打印 AST 确认循环体挂载正确
增加bool类型词法、类型检查、代码生成与整数类型混淆写类型不匹配用例确认报错
改用三地址码中间代码生成临时变量命名冲突对比新旧输出语义一致性
增加函数调用语法、语义、运行时环境参数传递顺序和栈帧布局单步跟踪调用前后栈变化

最后说一个我自己的习惯:每次改完编译器,不管改动多小,都强制走一遍“词法→语法→AST 打印→语义检查→代码生成→运行”的完整链路,哪怕只是加了一个运算符。因为编译器的各阶段耦合太紧,局部测试很容易漏掉跨阶段的回归。从那以后我每次扩展语法都先打印 AST 确认结构,再往下走,省了很多后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进