ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

编译原理概述:从词法分析到代码生成、优化与面试实验

编译原理概述:从词法分析到代码生成、优化与面试实验 第一次被编译原理按在地上摩擦是大二下学期的实验课老师在黑板上写下一段不到十行的表达式文法然后要求一周内交出一个能跑通四则运算的词法分析加语法分析程序。当时我对着屏幕熬了两个通宵最后靠着一本翻烂的教材和几份课程讲义才把代码跑起来。后来工作里写 DSL、做配置解析、看 JVM 的字节码、调 SQL 执行计划才发现当年那些看似抽象的自动机、文法、语法制导翻译其实一直都在用只是换了个名字而已。这篇内容是我把编译原理这门课从头到尾重新梳理一遍的结果围绕编译原理概述这个核心把词法分析、语法分析、语义分析、中间代码、代码优化、目标代码生成这条主线串起来顺带把课程实验怎么下手、选择题容易踩的坑、面试高频问法一起讲清楚。适合三类人正在上这门课、被实验和作业卡住的同学准备考研或者面试、需要快速捡回知识点的朋友以及写了几年代码但一直没弄明白我写的这行字到底是怎么变成机器能跑的东西的开发者。阅读门槛不高会写一点 Python 或者 C 就够复杂的地方我都配了类比和可运行的例子。1. 编译原理到底在讲什么一段代码的完整旅行1.1 编译器在工具链里的真实位置很多人对编译原理的误解是这是一门教你怎么写编译器的课。这话只对了一小半。编译器只是这门知识最典型的应用形态真正的内核是一套把形式化的源语言描述转换成等价的目标语言描述的通用方法论。你写下一行a b c * 60;从人类可读的文本到最终 CPU 能执行的指令中间要经过一串极其规整的加工工序每一道工序都在做同一件事的变体识别结构、验证合法性、改写表示。这条流水线大致是这样的词法分析把字符流切成一个个有意义的记号Token语法分析把 Token 序列组织成一棵语法树语义分析检查类型、作用域这些语法层面看不出来的约束中间代码生成把语法树翻译成与具体机器无关的三地址码之类的形式代码优化在这个中间表示上做等价的变换让它跑得更快更省最后目标代码生成把它落到具体硬件上处理寄存器分配和指令选择。再加上前面的预处理和后面的汇编、链接就是一条完整链条。为什么要把工序切得这么细而不是一步到位直接生成机器码关键在于关注点分离。人类读的是带缩进和注释的文本机器执行的是寄存器搬来搬去的指令这两者之间的语义鸿沟太大一口气跨过去既写不对也维护不了。切成多段之后每一段都有清晰的输入输出契约每一段都可以独立测试出了问题也能快速定位是哪一层没做对。这是一条工程上非常成熟的经验任何复杂的转换先分解再逐段验证。我印象很深的一个例子是前端语法高亮的实现。编辑器的语法着色器其实就是个极度简化的词法分析器它只做 Token 切分不做后续步骤。搞懂了最长匹配和关键字优先这两条规则你就能明白为什么有些编辑器在处理ab这种写法时会给出很怪的颜色标记。1.2 前端与后端的边界为什么这条分界线如此重要编译原理里有一条几乎是所有教材都会强调的分界线前端负责理解源语言后端负责生成目标代码中间表示是这两者的接口。前端包括词法分析、语法分析、语义分析和中间代码生成这部分与源语言强相关后端包括优化和目标代码生成这部分与目标机器强相关。这条分线带来的好处是实打实的工程收益。假设你要支持三门语言乘以两种 CPU 架构如果写成一体化的编译器就是 3×2 等于 6 份工作如果坚持前后端分离、共用一个中间表示工作量就降到 32 等于 5 份。语言数量越多、目标平台越杂分离的收益越明显。LLVM 之所以能成为今天很多语言和编译器的公共基础设施靠的就是这套统一中间表示加可插拔后端的设计。对我们学习者来说这条分线还有一个很实际的用途它告诉你哪部分知识是通用思维哪部分是工程细节。自动机理论、文法、属性文法这些属于通用思维学一次受用终身寄存器分配的具体算法、某款处理器的指令集特点属于工程细节用的时候现查就行。复习的时候按这个分类去分配精力效率会高很多。1.3 学这门课的真实收益面试、工程与思维方式先说最直接的面试。编译原理在技术面试里的出现频率远比想象中高。JVM 相关岗位会问字节码和 JIT语言方向会问 LL 和 LR 的区别后端岗位会问你有没有做过表达式解析或者配置 DSL。即便是常规业务开发面试官也喜欢用你知道一段代码从写出来到跑起来发生了什么来探你的深度这个问题答得漂亮很加分。再说工程。日常开发中大量场景本质上是小型编译任务正则表达式引擎、JSON 和 YAML 解析器、SQL 分析、模板引擎、路由匹配、日志格式解析、低代码平台的公式计算。这些场景不需要你写出工业级编译器但需要你知道该用正则还是该上文法、用现成的解析库还是手写递归下降。选错路线的代价可能是几千行难以维护的代码。最后是思维方式。形式化地描述一个语言的语法、证明某个分析算法的正确性、把一个转换拆成可验证的多步,这些训练会实打实地改变你写代码的方式。我现在设计模块时习惯先想清楚输入是什么形状、输出是什么形状、中间的表示形式是什么这套习惯基本就是从这门课里带出来的。2. 词法分析把字符流切成有意义的记号2.1 从字符流到 Token正则与有限自动机的关系词法分析要做的事情听起来很简单从左到右扫一遍源代码把连续的字符切成一个个 Token每个 Token 带上它的类别和值。比如sum price * 0.85;这行会被切成标识符(sum)、赋值号、标识符(price)、乘号、实数(0.85)、分号。但怎么切是个技术活。关键知识点在于每种 Token 都可以用一个正则表达式来描述而每个正则表达式都可以机械地转换成一个有限自动机有限自动机天生就适合被程序高效执行。这三者是等价的三种表达方式同一件事的三种说法。标识符的正则描述是字母或下划线开头后跟零个或多个字母、数字、下划线整数的正则描述是非零数字开头后跟若干数字或者单个零浮点数则要在整数基础上加上小数点和小数部分。标准流程是三步走先用 Thompson 构造法把正则表达式变成带 ε 边的非确定有限自动机NFA再用子集构造法把 NFA 确定化得到确定有限自动机DFA最后做 DFA 最小化把状态数压到最少。整个流程是纯机械的写成程序完全没有难度这也是为什么有 Lex、Flex 这类词法分析器生成工具——你把正则规则和对应的动作写进去它自动生成 C 代码。理解这套机制的好处是你能算清楚代价。NFA 匹配一个长度为 n 的输入最坏是指数级的因为要反复回溯DFA 是严格线性的每个字符走一步。这就是为什么工业级词法分析器几乎都走 DFA 路线哪怕它要预先花时间把自动机建出来并吃掉一部分内存。2.2 手写一个词法分析器可直接复现的步骤很多课程实验要求不许用生成工具必须手写。手写其实不难核心就是一个带游标的大循环加上一组判别函数。我用 Python 写个能跑的最小版本跑通之后你换成 C 或者 Java 逻辑完全一样import re TOKEN_SPEC [ (NUMBER, r\d(\.\d)?), (ID, r[A-Za-z_]\w*), (ASSIGN, r), (PLUS, r\), (MINUS, r-), (STAR, r\*), (SLASH, r/), (LPAREN, r\(), (RPAREN, r\)), (SEMI, r;), (SKIP, r[ \t\r\n]), (MISMATCH, r.), ] TOKEN_RE re.compile(|.join( (?P%s%s) % (name, pat) for name, pat in TOKEN_SPEC )) def tokenize(src): tokens [] for mo in TOKEN_RE.finditer(src): kind mo.lastgroup value mo.group() if kind SKIP: continue if kind MISMATCH: raise SyntaxError(非法字符 %r % value) tokens.append((kind, value)) return tokens if __name__ __main__: for t in tokenize(sum price * 0.85;): print(t)跑出来你会看到(ID, sum)、(ASSIGN, )、(ID, price)这样一串结果。这里有几个细节值得说清楚。第一正则的匹配顺序就是优先级顺序。Python 的re模块在处理|分支时是按书写顺序依次尝试的所以NUMBER一定要写在ID前面否则0.85的开头0会被ID的规则先吃掉。更稳妥的做法是把关键字规则整体放在标识符规则之前这是词法分析器里最经典的顺序陷阱。第二最长匹配原则。真正规范的词法分析器遵循的是尽可能吃最多字符的规则比如应该被识别成大于等于而不是大于紧接着等于0.85要整体识别成一个浮点数而不是0、.、85。上面这份实现是靠正则本身的贪婪匹配来近似实现最长匹配的对于实验级别的输入够用但如果要处理ab这类边界写法就得显式把更长的模式排在前面或者用 DFA 的读到不能读再回退一步策略。第三关键字识别。常见的做法是先按标识符切出来再拿一个哈希表查一下是不是关键字这样规则表能少写一堆分支。有的教材喜欢把关键字直接写成独立的正则规则放在最前面两种方式都对前者在关键字数量多的时候更省事。2.3 词法分析最容易踩的坑第一个坑是转义字符和字符串字面量。很多同学写的词法分析器一遇到字符串就崩因为它默认换行是分隔符。字符串的正则必须单独处理形如(?:[^\\]|\\.)*要考虑到反斜杠转义和未闭合引号的情况。未闭合引号属于错误处理范畴正确的做法是报告行号和列号而不是直接抛个异常了事。第二个坑是浮点数和点号的歧义。obj.field里的点是成员访问1.5里的点是小数点..是范围运算符。这个歧义只能靠上下文来解决属于词法分析和语法分析之间的模糊地带。常见的工程做法是让词法分析器只输出一个通用的点记号把区分工作交给语法分析器如果语言里有浮点数就要求小数点后必须紧跟数字用类似\d\.\d这样的正则把它和单独的点区分开。第三个坑是注释和行号统计。注释是个很典型的识别出来但丢弃的 Token处理它的时候必须顺手把行号统计做对否则后续报错的行号全是乱的。我的建议是在扫描循环里维护一个line和col变量每消费一个换行符就更新别指望后处理去数。提示做词法分析实验时先写一个只输出 Token 序列的调试版本把测试用例全部打印出来肉眼核对一遍再去写语法分析。跳过这一步直接写后面的部分出问题时会分不清是切词切错了还是解析写错了。3. 语法分析从 Token 序列还原出语法树3.1 上下文无关文法为什么它够用又不过火Token 序列本身没有结构语法分析的任务是把它组织成一棵能反映嵌套关系的树。描述这种结构用的工具是上下文无关文法CFG它包括终结符、非终结符、产生式和开始符号四要素。表达式文法的经典写法是E - E T | T、T - T * F | F、F - ( E ) | id。为什么是上下文无关因为它假设一个非终结符能展开成什么只看它自己不看它在句子中出现的位置。这个假设让分析算法变得可行代价是它表达不了所有语言现象。像变量必须先声明再使用这种规则就无法用 CFG 描述只能挪到语义分析阶段去做。这是编译原理里一个非常关键的分工思想不同的约束放在不同的层处理能形式化的形式化不能形式化的靠符号表加手工检查。用 CFG 描述语言的时候有两个性质必须搞清楚。一个是二义性指同一个句子存在两棵不同的语法树。最著名的例子就是经典的悬空 else 问题if (a) if (b) s1; else s2;中的 else 到底跟哪个 if 配对。二义性对编译是灾难因为两棵树可能对应完全不同的语义。解决手段是改写文法把优先级编码进去或者给运算符指定结合性和优先级的规则。另一个是左递归像E - E T这种产生式如果直接写成自顶向下的递归函数会立即陷入无限递归。这个问题必须靠消除左递归的机械变换来解决思路是把A - Aα | β改写成A - βA、A - αA | ε。3.2 LL 与 LR两条路线的取舍语法分析算法大致分两派。自顶向下从开始符号出发尝试推导出输入串代表是递归下降和 LL(1) 预测分析。自底向上从输入串出发不断归约到开始符号代表是算符优先和 LR 系列。这里的 L 表示从左到右扫描输入第二个字母表示推导方向LL 是最左推导LR 是最右推导的逆过程。两者的差别用一张表说得最清楚对比项自顶向下LL自底向上LR分析方向从开始符号往下推从输入串往上归约核心数据结构分析栈 预测表状态栈 分析表能处理的文法范围文法受限较多不支持左递归范围大得多大部分程序设计语言都能覆盖错误定位相对直观能说出期望什么稍弱但错误恢复手段更丰富手写难度递归下降很好写手写几乎不现实通常靠工具生成典型工具ANTLR、早期 Pascal 编译器Yacc、Bison为什么实际工程里手写编译器偏爱递归下降因为它的代码结构和文法几乎一一对应可读性极佳加错误恢复和自定义语义动作都很灵活。像 GCC、Clang、Go 编译器早期版本都采用了手写递归下降的方案。LR 的优势在于理论上能处理的文法更广而且不需要消除左递归用生成工具时省心很多。3.3 递归下降的手写实践与优先级处理递归下降的核心思想是每个非终结符对应一个函数函数内部按产生式的右部顺序调用其他函数或者消费 Token。下面是个能算四则运算的完整例子可以直接跑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 (EOF, ) def eat(self, kind): tk self.peek() if tk[0] ! kind: raise SyntaxError(期望 %s实际 %s % (kind, tk)) self.pos 1 return tk[1] # expr - term ((|-) term)* def expr(self): val self.term() while self.peek()[0] in (PLUS, MINUS): op self.eat(self.peek()[0]) rhs self.term() val val rhs if op else val - rhs return val # term - factor ((*|/) factor)* def term(self): val self.factor() while self.peek()[0] in (STAR, SLASH): op self.eat(self.peek()[0]) rhs self.factor() val val * rhs if op * else val / rhs return val # factor - NUMBER | ( expr ) def factor(self): tk self.peek() if tk[0] NUMBER: return float(self.eat(NUMBER)) if tk[0] LPAREN: self.eat(LPAREN) val self.expr() self.eat(RPAREN) return val raise SyntaxError(非预期的记号 %s % (tk,))这段代码里藏着一个非常重要的设计用文法的层次来编码运算符优先级。expr处理加减term处理乘除factor处理括号和数字。因为乘除所在的term在调用链上更靠内层所以它天然先算1 2 * 3得到的自然是 7 而不是 9。这是手写解析器里处理优先级最省事的办法比在解析函数里塞一堆优先级判断清爽得多。如果要做右结合的运算符比如幂运算只需要把循环改成单次递归调用power - factor ^ power | factor。左结合用循环右结合用递归记住这一条就够应付绝大多数表达式场景。注意递归下降只适合手写小规模文法。当文法复杂到几十个非终结符时手写代码的维护成本会急剧上升这时候就该考虑用 ANTLR 或者 Bison 这类工具了。选型的判断标准很简单文法稳定、需要频繁改语义动作就手写文法复杂、改动少就用生成工具。4. 语义分析与中间表示让程序真正有意义4.1 符号表与作用域编译器里的户籍系统语法树建好只是把结构理清了接下来要回答这棵树在语义上是否成立。第一个要处理的是符号表它记录每个名字对应的信息是变量还是函数、什么类型、在哪一层作用域、占用多少空间。符号表最核心的机制是作用域嵌套实现上是给每个作用域建一张表再把这些表串成栈式结构或者链式结构。查找名字时从当前作用域往外找找到第一个匹配就停。这解释了很多语言里的变量遮蔽行为内层作用域里声明的同名变量会挡住外层的。为了保证离开作用域时能正确还原编译器通常要维护一个进入作用域就建表、退出作用域就销毁的对齐机制。数据结构的选择上主流做法有两类。一类是哈希表加链表每个作用域一张哈希表外层作用域通过指针链接查找时逐层往上另一类是一张全局哈希表加作用域栈表项里记录所属作用域查找时按作用域深度过滤。前者实现简单、隔离性好后者内存占用更小但实现稍复杂。实验规模用前者完全够。这里有个很实用的细节符号表要记录的位置信息不只是行号还包括它在栈帧里的偏移量。后端生成代码时需要知道每个局部变量相对于帧指针的偏移这个值是在语义分析阶段配合栈帧布局一起算出来的。很多同学在做实验时把这个信息漏掉到了代码生成阶段又得回头重做一遍符号表白白浪费一轮时间。4.2 类型检查编译器最实用的那道防线类型检查是语义分析阶段最核心的工作它要保证每个操作符作用在合法的操作数上每个函数调用和声明匹配。检查规则本身不难难的是处理类型等价性判断和隐式转换。类型等价有两种判定方式名字等价和结构等价。名字等价要求两个类型必须是同一个名字声明出来的结构等价只看两个类型的构造是否逐层一致。C 语言用的是结构等价所以两个用struct定义的相同结构体在 C 里被认为是兼容的而在某些语言里就不行。搞清楚你实现的这门课用的是哪种判定能避免大量实验里的争议。隐式转换的处理更讲究。通常的做法是在类型规则里维护一张类型转换表描述哪些类型能自动提升、提升方向是什么。比如整数和浮点数运算时整数会被提升为浮点数小范围整数参与运算时会被提升到机器字长。这张表的本质是给每种转换定义代价然后选择代价最小的方案。我在实验里踩过一个坑一开始把转换规则写成了双向的结果int float和float int分别走了两条不同路径生成了不一致的代码。后来把规则统一成向精度更高的一方靠拢才解决问题。4.3 中间表示三地址码与 SSA 的实际价值中间代码生成是前端和后端之间的桥梁。最常见的形式是三地址码每条指令最多包含三个操作数形如t1 b * 60、t2 a t1。它的好处是结构极其规整每条指令做一件事后端处理起来非常方便。三地址码有几种常见的存储形式四元式用四个字段存操作符、两个操作数和一个结果直观好懂三元式省掉结果字段用指令编号来引用中间结果间接三元式在三元式基础上加一张执行顺序表方便做指令重排。做实验时推荐直接用四元式因为你在代码生成阶段需要频繁引用中间结果四元式的写法最省心。再往上一层是控制流图CFG注意和上下文无关文法同名但不是一回事和SSA 形式。控制流图把代码切分成基本块块内顺序执行、块尾跳转整个程序就是一张有向图。SSA 则要求每个变量只被赋值一次多次赋值就生成新版本编号比如x1 1、x2 x1 1。SSA 的最大好处是让这个变量的值从哪里来变成了一个明确的定义-使用链问题大部分数据流分析在 SSA 上都能显著简化。现代编译器基本都是 SSA 加控制流图的组合。你在实验里如果只用四元式线性列表也能完成基本功能但做优化的时候会很吃力因为找不到清晰的块边界。我的建议是如果实验有优化环节提前花点时间把基本块划分和块间跳转关系做出来后面会轻松很多。5. 代码优化与目标代码生成5.1 常见的优化手段及其收益优化是在中间表示上做等价变换目标是让程序跑得更快、占内存更少。需要先明确一点优化不能改变程序的可观察行为这是铁律。所有变换都必须保证语义等价这个前提决定了优化不是随便改而是有严格条件的形式化重写。按作用范围分优化大致有这么几类。局部优化在单个基本块内做最典型的是常量折叠把3 * 4直接算成 12、常量传播把已知是常量的变量替换成常量值、公共子表达式消除t1 b * 60和t2 b * 60合并成一个、死代码消除删掉计算结果从未被使用的语句。全局优化跨基本块进行典型的是全局常量传播、全局公共子表达式消除需要做数据流分析才能保证正确性。循环优化是收益最大的一类因为程序大部分时间都消耗在循环里常见手段是循环不变量外提把循环体内不随迭代变化的计算挪出来、强度削弱把乘法替换成加法、循环展开减少循环控制开销。用直白的话说优化的本质是用编译时间换运行时间。这也是为什么开发时通常用-O0编译发布时用-O2或-O3开发要的是编译快、调试信息准发布要的是跑得快。理解了这层权衡你就明白为什么-O3有时候反而会让程序变慢——优化本身也会引入额外开销甚至会因为指令缓存的原因导致性能下降。另外一个必须知道的现实是优化级别越高调试越难。变量被优化掉、代码被重排、断点跳到莫名其妙的位置这些都不是编译器的 bug而是优化的正常代价。遇到这种情况就临时降级到-O0重新编译问题通常就消失了。5.2 目标代码生成与寄存器分配后端最后要做的两件事是指令选择和寄存器分配。指令选择把中间表示映射到目标机器的指令集这个过程可以用树覆盖算法来做把中间代码看成树用一组指令模板去覆盖它选择代价最小的覆盖方案。寄存器分配是后端最经典也最难的问题。CPU 访问寄存器比访问内存快一到两个数量级但寄存器数量极其有限所以要把尽可能多的变量塞进寄存器同时还得保证不冲突。主流算法是图着色把变量看成图的节点两个变量如果同时活跃就连一条边然后给这张图着色每种颜色对应一个寄存器颜色数就是寄存器数量。如果图需要的颜色数超过可用寄存器数就把部分变量溢出到栈上等用到的时候再加载回来。活跃变量分析的原理说起来简单一个变量在某点活跃指的是它的值在这一点之后还会被用到。这个判断需要做后向数据流分析从程序末尾往前推。实际做实验的时候通常会先做一个简化版本把所有变量都放在栈上一步一加载一步一存储跑通之后再引入寄存器分配。这种先求正确再求高效的策略在后端实现里非常有效因为后端的错误往往很难调试先把功能跑通能省掉大量的排查时间。6. 编译原理常见面试题与选择题速查6.1 高频面试问答题面试里关于编译原理的问题基本集中在这几个方向我按被问到的频率排了个序每个都给出一句话的核心答案。编译和解释有什么区别编译是一次性把源程序翻译成目标代码执行时不依赖源程序和编译器解释是逐条读取源程序并立即执行不生成独立的目标代码。现代语言的分界已经模糊了Java 先编译成字节码再由虚拟机解释执行热点代码会被即时编译成本地代码属于混合模式。词法分析和语法分析为什么要分开因为二者的任务性质不同分开能做到关注点分离。词法分析处理的是正则语言可以用有限自动机高效实现时间线性语法分析处理的是上下文无关语言需要更强的算法。合在一起做不仅实现复杂效率也会下降。为什么自顶向下分析不能有左递归因为自顶向下是从开始符号出发尝试推导遇到A - Aα这样的产生式时会在没有消费任何输入的情况下反复展开 A直接死循环。解决办法是消除左递归改成右递归形式。LL(1) 和 LR(1) 的区别是什么LL(1) 每次只看一个输入符号就能确定用哪个产生式是自顶向下分析LR(1) 在归约时看一个符号能处理更大范围的文法是自底向上分析。LR(1) 的分析能力强于 LL(1)但状态数通常更多。什么是二义性文法同一个句子能被文法推导出两棵不同的语法树。二义性会导致语义不确定必须通过改写文法或者指定优先级和结合性来消除。中间代码有什么作用它把前端和后端解耦让 m 种语言和 n 种目标机器的组合从 m×n 降为 mn同时为优化提供了一个与具体硬件无关的稳定平台。6.2 选择题易错点速查表考试里的选择题陷阱相对固定我把最常见的几个整理成表考前扫一遍能救回不少分。易混知识点正确结论常见错误选项正规式与自动机正规式、NFA、DFA 三者表达能力等价认为 DFA 比正规式能力更强NFA 与 DFA 的转换子集构造法把 NFA 转成 DFA认为 NFA 不能被确定化左递归直接左递归和间接左递归都要消除只关注直接左递归算符优先文法只适用于算符优先文法不含 ε 产生式认为对所有文法都适用语法树的叶子叶子节点从左到右构成句型的推导结果把内部节点也算进句柄句柄最右句型的可归约串是规范归约的关键和短语、直接短语混为一谈属性文法综合属性自下而上求值继承属性自上而下求值混淆两类属性的求值方向逆波兰式后缀表达式运算符在操作数之后把中缀和后缀搞反关于属性文法那一条值得多说两句。综合属性的值由子节点的属性算出比如表达式的类型由左右操作数的类型推出这种属性在语法树上自下而上传播继承属性的值由父节点或兄弟节点算出比如变量声明处的类型要传给使用处的标识符这种属性自上而下或者从左边传向右边。判断方法是问一句这个属性的值依赖谁依赖子节点的就是综合属性依赖父节点或左兄弟的就是继承属性。S 属性文法只有综合属性可以在自底向上的分析过程中边归约边求值L 属性文法允许继承属性但限制它只能依赖父节点和左兄弟这保证了可以在自顶向下的分析中一趟算完。7. 学习路线与实验建议课程资料怎么用才不浪费7.1 教材、课件与讲义怎么搭配市面上的教材和课程资料很多直接上手容易挑花眼。我自己的搭配思路是一本主教材负责建立体系一套课件负责抓住考点一份实验指导书负责把理论落到代码上。主教材建议选讲解风格偏工程实践的那类先把整本书快速翻一遍不要在细节上停留目标是画出自己的知识地图词法、语法、语义、中间代码、优化、目标代码这六大块各包含哪些核心概念。第二遍再逐章精读边读边做笔记重点是把每个算法的手算过程走一遍比如子集构造法、FIRST 和 FOLLOW 集的求解、LR 分析表的构造。这些是考试和实验的基本功不亲手算几遍很难真正掌握。至于课程课件和讲义它们最大的价值是范围筛选。不同学校的课程侧重不一样有的偏重形式语言和自动机理论有的偏重编译器的工程实现。找这些资料的时候先看目录结构对比自己课程的考纲把重叠度高的部分重点看重叠度低的部分略过。课件里通常会有大量例题和往年题型这些比教材上的习题更贴近考试思路值得拿出来做透。关于课后的习题解答类资料用法上要克制。直接抄答案几乎学不到东西更好的用法是做完之后对照思路重点看自己错在哪一步是概念没理解还是计算过程出了纰漏。特别是分析表构造这类题目错误往往发生在某个很细微的地方对照答案能找到盲区。7.2 实验怎么做才算真的做进去了编译原理实验通常是一条流水线词法分析、语法分析、语义分析、中间代码生成、目标代码生成。我的建议是不要等到所有理论都学完再动手而是学一章做一章哪怕前面的实现很粗糙。具体执行上有个很有效的策略叫先跑通再优化。第一步先实现一个只支持四则运算和变量赋值的极简编译器从输入表达式到输出能跑的结果哪怕中间代码生成得非常笨拙。跑通之后你会发现整条链路的接口关系一下子就清楚了后面扩展功能只是在既有骨架上加东西。反之如果一开始就想做完整的功能很容易卡在某个环节好几天最后连整体流程都没走通。调试手段上最有用的是给每个阶段都做可视化输出。词法分析阶段打印 Token 序列语法分析阶段把语法树画成缩进文本中间代码阶段打印四元式列表目标代码阶段打印汇编指令。有了这些输出定位错误就变成了看哪一层的输出开始不对效率比盲目打断点高得多。语法树的打印函数特别值得早点写它能让你肉眼看出文法处理得对不对。测试用例的设计也有讲究。除了常规的正确输入一定要准备几类异常输入未闭合的括号、未声明的变量、类型不匹配的运算、非法的字符。异常处理做得好不好往往是实验评分的分水岭。我当年就是因为只测了正确路径结果老师随便输入一个未声明的变量程序直接崩了扣了不少分。提示实验代码一定要用版本管理工具管起来。编译器实验的特点是模块多、耦合强改一处很容易影响另一处有版本记录才能放心大胆地重构。每完成一个可运行的阶段就打一个标记后面出问题能快速回滚。7.3 后续可以往外延伸的方向如果实验做完了还有余力有这么几个方向可以继续挖。一是给自己的小语言加类型系统从只有整数扩展到支持浮点、布尔、字符串顺便把类型检查和隐式转换做完整。二是引入优化环节在四元式上实现常量折叠和常量传播看看跑出来的结果有什么变化。三是换一个目标平台把原本生成三地址码的部分改成生成某种虚拟机的指令体会一下前后端分离带来的便利。再往深走可以试着用现成的解析工具重做一遍实验内容比如用 ANTLR 生成词法和语法分析器对比手写版本在代码量和可维护性上的差异。这个对比做下来你对什么场景该手写、什么场景该用工具会有非常具体的判断这种判断力在实际项目里比会背几个算法更值钱。我个人在带新人时最常说的一句话是编译原理这门课的价值不在于你记住了多少算法而在于你建立了一种把复杂转换拆成可验证的多个阶段的思维方式。词法分析器和语法分析器写完之后你看正则表达式、看 JSON 解析、看模板引擎的感受会完全不一样那才是这门课真正留下的东西。实验做不出来不用焦虑把每个阶段的输入输出关系想清楚一层一层往上搭慢一点也没关系最后能跑通的那一刻收获是实打实的。
返回列表