语言这个东西,是人写的,也是机器读的。日常工作里我们每天都在用别人设计好的编程语言,但有多少人想过,自己敲下的那行代码,是怎么从一串字符变成CPU里一条条指令的?我自己的经历是:工作三四年后卡在了性能分析和框架源码解读上,回头看才发现,真正缺的其实是编译原理这套底层思维。后来我在弹幕视频网站上找到了某国防类高校公开课《编译原理》,一整遍跟下来,刷视频和动手实验加起来花了一个多月,收获远超预期。这篇就聊聊这门课的内容拆解、学习路径和实操经验,以及怎么把课上学的"翻译过程"真正迁移到日常工程里。
先说结论:这门公开课围绕"从源代码到可执行程序"这条主线展开,把词法分析、语法分析、语义分析、中间代码生成、代码优化和目标代码生成完整串了起来,配套的实验包含手写词法分析器、递归下降解析器、类型检查器和中间代码生成器。适合三类人:正在上编译原理课但觉得教材晦涩的在校生,转编程方向想补齐计算机基本功的新人,以及像我一样工作后回头看理论、想用工程视角重新理解的从业者。
1. 这套课到底讲什么:课程主线与知识拆解
1.1 从输入字符到可执行程序的完整链路
很多人对编译原理的第一印象是"一堆状态机、文法推导,离实际太远了",其实恰恰相反。你用的每一个框架、每一门语言,底层都在做同一件事:把结构化文本转换成机器能理解的结构,再生成可执行的东西。这门课把这件事拆成了几个标准阶段。
第一阶段是词法分析。输入是源代码字符串,输出是Token流。比如一行int a = 10;,会被切分成关键字int、标识符a、运算符=、数字常量10、分号;。课程从正则表达式讲起,解释如何把正则转成NFA(非确定有限自动机),再通过子集构造法转成DFA(确定有限自动机),最后用这个DFA去扫描字符流。这一阶段看起来简单,但它奠定了后面所有阶段的基础——你写的每个词法规则,最终都会映射成一个状态机。
第二阶段是语法分析。Token流进来,按照文法规则组装成抽象语法树AST。课程重点讲了上下文无关文法、推导与规约、LL(1)与LR(1)分析算法、递归下降分析法。这块是整套课的硬核部分,也是面试和考试的高频出题点。课程没有一上来就丢给你LALR表怎么构造,而是先带你手写递归下降解析器,处理表达式优先级、左递归这些问题,等你有体感了再引入工具化的Yacc/Bison。
第三阶段是语义分析与中间代码生成。AST已经给出了"句子结构",但还需要检查类型是否匹配、变量有没有声明、作用域对不对,这就是语义分析。课程把符号表的设计讲得很细:作用域如何嵌套、名字如何查找、类型信息如何挂在AST节点上。随后进入中间代码生成,常见形式是三地址码,每条指令形如t1 = a + b。有了中间代码,前端和后端就能解耦。
第四阶段是代码优化与目标代码生成。课程介绍了常量折叠、公共子表达式消除、寄存器分配、指令选择这些常见优化手段。这个阶段虽然不如前面那么深入,但把局部优化和全局优化的基本模型讲清楚了,足以帮你理解编译器"-O2"后面发生了什么。
1.2 课程内容和传统编译原理教材的对应关系
如果你手边有编译原理经典教材,对照着看会发现这门课的结构非常接近"龙书"的经典编排顺序,但做了大幅度的口语化简化。第一章对应龙书的前三章,第二章讲文法和语法分析对应龙书第四章,第三章语义分析对应第五章,后面的中间代码和优化对应第七章到第八章。
但课程有个传统教材不太具备的优点:它配了不少可运行的代码片段和实验指导。比如讲递归下降时直接给你一个表达式解析器的框架代码,讲符号表时给出C语言风格的符号表实现思路。每个阶段结束都安排了一个能独立运行的实验,从词法分析器到最终能输出三地址码的简化编译器。这种"讲完一个阶段就做一个阶段"的节奏,比闷头读理论有效率得多。
我的建议是:不要把它当成一门完结的课"刷完",而是当成一本配合动手实践的教材用。每看完一个阶段,停下来写代码、跑测试,再继续下一阶段。课程本身时长不算长,但如果你真按这个节奏走,一两个月是正常的。
2. 为什么值得专门跟着它学:选型对比与课程特点
2.1 三大经典学习路径的差异
学编译原理,市面上的主流资源大致分三类。第一类是经典大部头教材,理论最完整,推导严谨,但自学容易陷入数学细节爬不出来。第二类是某开源项目做的"用X语言写一个编译器"教程,上手快,但往往只覆盖某个具体实现,缺少通用的理论框架,换个场景就不知道怎么迁移。第三类是高校公开课,有完整的教学体系,有作业实验,讲师会用自己的语言把教材的逻辑重新组织一遍,理解门槛低很多。
这门某国防类高校的公开课属于第三类,但比很多同类公开课更贴近工程实践。它不回避数学,比如正则到DFA的子集构造、FIRST与FOLLOW集合计算、LR项目集族的构造,该有的推导过程都有;但它的呈现方式始终围绕着"这个过程到底是在解决什么问题"。以FIRST集合为例,课程先抛出一个问题:递归下降分析时,看到当前Token,怎么决定用哪个产生式展开?然后把计算FIRST集合变成了一种"一眼就能看出来"的直觉,再过渡到算法。这种从问题出发的组织方式,能避免"学完一堆概念但不知道能干嘛"的空洞感。
2.2 视频学习独有节奏:案例驱动而不是概念先行
这套课设计上有个非常关键的点:大量使用"从一个简单例子出发"的讲法。比如讲NFA转DFA,它不会直接列子集构造算法的五步流程,而是先拿一个只有两三个状态的小NFA,带你把状态集合并、转移关系画出来,看着看着你就发现这就是BFS加集合操作,最后才总结算法。
这种节奏对自学者特别友好。看视频的时候你是在跟着老师的思路"跑"一个具体例子,获得了直觉,之后再去看教材里的严格定义和证明,就不会觉得那么劝退。我有几次卡在理论推导上,比如LR(1)和LALR的区别,就回去把视频里那个案例重新推一遍,马上能明白差距到底体现在哪里。
另外,这门课对实验部分的引导非常务实。它不会要求你一上来就写一个完整编译器,而是每个阶段给你一个最小可用的任务:第一版只需要能识别数字和加减乘除,第二版才处理变量声明,第三版才开始生成中间代码。这种渐进式的实验安排,很符合"先跑通,再完善"的工程习惯。
3. 实操路径:把这套课学出工程价值
3.1 基础准备与学习环境搭建
动手之前需要确认三块基础。第一是数据结构:栈、树、哈希表必须熟悉,尤其是树的各种遍历,因为整个编译过程都是在处理树形结构。第二是离散数学里的集合和关系,子集构造法本质就是集合运算,FIRST/FOLLOW集合也是集合,不要求你会证明,但要能写出并集交集闭包。第三是一门趁手的编程语言,我推荐C++或Python,前者更贴近课程里讲内存布局和符号表的设计,后者写起来更快但有些底层细节看不到。
环境方面不需要什么特殊的IDE,一个文本编辑器加一个编译器就够。我自己用的最简单组合:VS Code + GCC,所有实验代码都放在一个目录下,配合CMake管理多目标。如果想可视化状态机,可以装一个Graphviz,把DFA的状态转移画出来,调试词法分析阶段会省很多力气。
这里有个实操建议:不要一开始就追求"完美架构"。我第一次写词法分析器时,上来就设计了一大堆接口、抽象类,结果花了太多时间在工程结构上,反而把正则到DFA的核心逻辑忽略了。后来改成先用最简单的方式把功能实现,再重构,发现效率高很多。课程里的实验代码也是这个思路,先写能跑通的版本,再逐渐完善。
3.2 三阶段动手实验方案
我把课程配套实验整理成三个阶段,每个阶段都有明确的验收标准。
阶段一:词法分析器。任务是从零实现一个能识别标识符、数字、关键字、运算符和括号的词法分析器。有两种路线:手写一个简单的状态机,或者用Flex自动生成。作为新手,我强烈推荐先手写,因为这样才能真正理解"状态"和"转移"的概念。实现上可以先把每个Token定义成枚举,再用一个状态转移表驱动扫描循环。这个阶段的验收标准很简单:给一段包含各种合法和非法字符的源码,能正确输出Token序列并报出词法错误。
阶段二:语法分析器。任务实现一个支持算术表达式、变量赋值、条件语句和循环语句的递归下降解析器。这个阶段最大的坑是处理左递归和运算符优先级。课程里给了处理表达式优先级的经典方法:把表达式语法分层,add_expr->mul_expr->primary,每一层对应一个优先级,然后为每层写一个解析函数。验收标准是能解析一段符合语法规则的代码,生成AST,并把AST用缩进方式打印出来。打印AST这一步非常关键,后面所有调试都靠它。
阶段三:语义分析与三地址码生成。任务是在AST上做符号表构建和类型检查,然后生成三地址码。符号表可以用一个栈式结构模拟作用域,当前作用域插入声明,离开时弹出。类型检查从叶子节点向上做,赋值时检查左右两边类型是否匹配,算术运算检查操作数是否都是数字。三地址码生成本质就是一遍AST的递归遍历,每种节点对应一种指令模式。比如二元运算节点生成t = a op b,赋值节点生成a = b,条件语句生成跳转指令。验收标准是给定一段输入代码,输出可读的三地址码序列,并保证临时变量编号不冲突。
3.3 完整演练:从1+2*3到三地址码
拿一个具体例子走一遍,能让你对整个链路有很直观的感受。假设输入代码是:
int a; a = 1 + 2 * 3;词法分析阶段,第一行生成Token序列:int、a、;,第二行生成:a、=、1、+、2、*、3、;,同时每个Token后面跟着行号和列号,后续报错能直接定位。
语法分析阶段,1 + 2 * 3会按照优先级生成AST,乘法节点成为加法的右子节点,AST大致长这样:
AssignStmt (a) └── BinOp (+) ├── Const (1) └── BinOp (*) ├── Const (2) └── Const (3)AST长对了,优先级问题就解决了。很多人会在这一步出错,比如把1+2*3解析成(1+2)*3,就是因为在语法分层的哪个函数里调错了下一个函数。
语义分析阶段,检查到变量a已声明,常量1、2、3都是数字类型,int a = int + int * int类型全部匹配,通过。
三地址码生成阶段,遍历AST输出:
t1 = 2 * 3 t2 = 1 + t1 a = t2这个输出很关键,它表明编译器确实理解了"先乘后加"和"赋值"的语义。到这里,"能跑的编译器"的最小闭环就完成了。后面想加函数调用、数组、结构体,都是在树上插入新的节点类型和对应的指令模式。
4. 学习中的典型问题与避坑指南
4.1 理论推导卡壳的破解办法
第一个高频卡点是正则转NFA再转DFA。子集构造法本身不难,但手算容易错,尤其状态一多就晕。我的办法是写代码:NFA用状态集合加字符映射表示,DFA的状态就是NFA状态集合的子集,转移用预计算好的follower集合做BFS生成。代码量不大,大概七八十行,但写完之后你再也不怕这类题目。调试时用Graphviz把状态图画出来,非常直观。
第二个卡点是左递归消除和提取左因子。很多教材直接给你公式,看着挺晕。实际操作时记住一个套路:A -> Aα | β这种左递归,改成A -> βA'和A' -> αA' | ε就行。提取左因子则是把公共前缀提出来,A -> αβ | αγ改成A -> αA',A' -> β | γ。课程里对这两个变形有详细的例子,建议照着手推三遍,直到能闭着眼写出结果。
第三个卡点是LR分析表的构造。这块内容确实繁琐,但不要被吓到。我的经验是:先搞清楚SLR和LR(1)的项目集是怎么算的,再理解ACTION表和GOTO表各自存什么,最后才是LALR的合并压缩。课堂上讲这个环节花了不少时间,但实际手写编译器时可以用Yacc自动生成分析表,你要掌握的是理解和使用它,而不是手工推每张表。考试除外,考试还是得老老实实算。
4.2 实验代码里的高频Bug与调试方法
代码写多了会发现编译器的Bug集中在几个点。
第一个是递归下降解析器的无限递归。最常见的原因是左递归没消除,比如表达式文法里直接写了expr -> expr + term,然后parse_expr()第一行就调用parse_expr(),栈直接爆掉。排查方法很简单:语法分析函数入口处打印当前正在解析的Token和函数名,看到循环重复同一个函数名,基本就是左递归了。
第二个是AST结构不对。1+2*3解析成(1+2)*3,这种优先级错乱很难靠眼神看出来。建议写一个AST打印函数,用缩进和括号输出树的结构,每次解析完直接看打印结果。我发现很多问题一眼就能发现问题,比用调试器打断点快得多。
第三个是符号表作用域泄漏。局部变量在离开作用域之后还能被访问,或者同名变量在两个作用域里互相覆盖。解决办法是在作用域压栈时生成一个新的map,作用域结束时弹出,查找名字时从栈顶往栈底找。每次进入和离开作用域都打日志,问题就能定位。
第四个是三地址码的临时变量编号冲突,导致一条指令把另一条指令的结果覆盖了。我当时把临时变量编号设成全局递增,结果还行,但如果是嵌套表达式,最好让每个子表达式的编号从局部起点开始,具体做法可以给每个函数维护一个计数器。
4.3 实验写不出来时的应对策略
遇到写不出来的情况,先别急着看完整答案。我的做法是:先看20分钟参考实现,理解整体思路,然后关掉参考代码,凭记忆自己重写一遍。如果卡住了,再回头看具体那一小段,而不是从头到尾抄一遍。这个过程很痛苦,但记忆最深。我学语法分析时用这个办法,到写语义分析阶段时明显觉得更有信心了,因为模式已经被我内化。
另外,不要追求一次写出完美的代码。我第一版词法分析器只支持数字和标识符,第二版才慢慢加上字符串、注释、各种运算符。每次加一点,测试一点,比憋大招可靠得多。合适的节奏是:每个实验分三到四次提交,每次提交都保证能编译通过、能跑通当前用例。
5. 学完怎么用:从课堂理论走向真实工程
5.1 编译原理能直接落地的工程场景
很多人觉得编译原理是学院派课程,和自己的工作八竿子打不着。实际上,这些年我几乎每天都在用编译原理里的思想。
最明显的是DSL设计。比如规则引擎、配置中心、表单描述语言,本质就是定义一种小语言再解释执行。这时候你需要自己设计词法规则和语法规则,写解释器而不是编译器,但思路惊人一致:先分词,再建结构化表示,最后求值。
模板引擎和SQL解析器也是典型的编译器应用。模板里的{{ variable }}和{% if %}就是特殊Token,模板的嵌套结构就是一棵AST,渲染模板就是对AST做一遍遍历求值;SQL解析器更不用说了,一个最简单的SELECT解析器,词法分析只管识别关键字、表名、字段和运算符,语法分析负责把查询结构组装成语法树,后面优化器和执行器才能运作。
序列化框架的反序列化部分也可以从编译原理视角理解:JSON或二进制流是字节输入,字段定义是文法规则,反序列化过程就是在做带类型信息的语法分析。理解了这一点,你就能明白为什么有些反序列化框架要求目标类的字段顺序和类型保持一致,本质上就是文法不匹配。
静态检查工具和代码补全也是的。Lint工具需要解析源码AST,然后在树上做规则匹配;编辑器里的语法高亮,简化版本就是词法分析器理解了整个文件的Token类型。做了编译器实验之后,你会觉得这些工具的幕后逻辑不再是个黑盒。
5.2 课程结束后继续进阶的路线
如果你沿着这门课学完并完成了实验,接下来要看自己更偏理论还是偏工程。偏理论,就翻开经典编译原理教材,重点补LR分析与代码优化两章。你已经有实践基础,再看那些数学推导会觉得亲切很多。偏工程,我推荐两条路:一是基于LLVM学习后端,了解IR中间表示、指令选择、寄存器分配是怎么工业化实现的;二是研究一门开源解释器的源码,比如Lua解释器或Ruby的解析器,看真实世界里一个完整语言是怎么被实现的。
我个人学完这门课后的体会是:编译原理不是一门孤立的课程,它把数据结构、算法、形式语言、操作系统和体系结构全部串起来了。每个知识点都能在真实的系统里找到对应的影子。如果你也在学或者准备学这门课,我的最大建议就是:不要只刷视频,每个实验都亲手做一遍,哪怕做出来的编译器只能处理十行代码,它带给你的理解也远超看十遍视频。
最后分享一个小技巧:给自己定一个"最终目标"——不是"学完编译原理",而是"实现一个能将1+2*3正确计算的小编译器"。你会发现所有枯燥的理论都有了明确的落点,学习和做实验的动力完全不一样。这门公开课值得花一两个月慢慢啃,啃完之后再看那些框架源码,很多东西都会豁然开朗。