编译原理核心考点与实战:词法分析、语法分析到代码优化
2026/9/18 20:27:17 网站建设 项目流程

学编译原理这门课,很多人第一反应是“龙书太厚、理论太抽象、考试太玄”。但说句实在话,编译原理恰恰是计算机专业里最值得认真啃的一门课,它把形式语言、自动机、数据结构、算法、体系结构全部串在了一起。你以后写不写编译器另说,但只要你搞过一遍词法分析、语法分析、中间代码生成,再回头看你写的任何代码,视角都会完全不一样——你看到的不再是字符串,而是一棵语法树。

这篇总结我按“知识点+考点+实操”三个维度来写,覆盖了词法分析、语法分析、语义分析、中间代码、代码优化、运行时环境这些核心模块,同时把高频考点、容易踩的坑、实验常见问题、面试常问题目都一并整理了。无论你是期末突击、考研复试,还是准备面试,这份提纲都能帮你少走弯路。

1. 编译原理到底在学什么:先建一张全局考点地图

很多同学学编译原理觉得乱,是因为脑子里没有一张“编译过程全景图”。其实整门课就是沿着编译器的工作流程展开的,从源码到目标代码,一共六个阶段:词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成。再加上贯穿始终的符号表管理和出错处理,这就是全部框架。

1.1 六个阶段到底各自干什么

  • 词法分析:把源代码的字符流切分成Token序列。比如int a = 10;会被切成<kw,int> <id,a> <op,=> <num,10> <symbol,;>。这个阶段的核心工具是正则表达式、有限自动机。
  • 语法分析:把Token序列组织成语法树,判断“句子结构”是否符合文法。核心工具是上下文无关文法、LL分析、LR分析。
  • 语义分析:检查类型是否匹配、变量是否声明、表达式是否合法,并收集类型信息。核心是属性文法、语法制导翻译。
  • 中间代码生成:把语法树转换成一种与机器无关的中间表示,常见的有三地址码、四元式、语法树。这一步相当于“翻译成通用语”。
  • 代码优化:对中间代码或目标代码进行等价变换,让程序跑得更快、占空间更少。例如常量折叠、死代码删除、公共子表达式消除。
  • 目标代码生成:把中间代码映射到具体机器的指令集,涉及寄存器分配、指令选择、指令调度。

1.2 考试中的分数分布与复习优先级

根据我的经验,期末试卷里语法分析永远是重头戏,通常占30%到40%;词法分析占15%到20%;语义分析与中间代码占15%到20%;代码优化和运行时环境加起来10%到15%;剩余的是概念题、简答题和选择题。所以复习顺序建议是:词法分析 → 语法分析 → 中间代码 → 语义分析 → 优化与运行时。词法分析是基础,语法分析是拉分大项,中间代码和语义分析是区分“背过”和“真懂”的分水岭。

注意:别一上来就背概念。概念题只值5到10分,分析计算题才是大头。你得能亲手算出FIRST集、FOLLOW集,能画DFA,能填LR分析表,这才是拿分的关键。

1.3 这门课为什么难:真正的问题在哪

编译原理的难点不在于某个知识点特别高深,而在于它是一套环环相扣的系统。正则表达式没学好,DFA最小化就懵;FIRST集算不明白,LL(1)分析表就填不出来;分析表填不出来,后面的语法制导翻译根本无从下手。所以你要么按顺序啃,要么考前突击时至少把“词法 → 语法”这条主线打通,否则后期听课等于听天书。这也是为什么很多人吐槽“编译原理一听就懂,一做题就废”——你缺的不是理解,是动手算题的量。

2. 词法分析:从正则表达式到DFA的完整链路

词法分析是整门课的第一关,也是实验课最常见的题目。它的核心内容就三件事:把规则写成正则表达式,把正则表达式转成NFA,再把NFA转成DFA且最小化。三者关系像一个流水线:正则表达式是给人看的,NFA是给机器转换用的中间产物,DFA是真正能高效执行的识别器。

2.1 正则表达式、NFA、DFA三者的关系

  • 正则表达式:描述词法规则。例如标识符可以写作letter(letter|digit)*,无符号整数可以写作digit+
  • NFA:允许同一状态对同一输入有多条转换边,允许ε空边。识别起来需要回溯或并行跟踪,效率低但构造简单。
  • DFA:每个状态对每个输入至多一条转换边,识别过程确定、快速。实际词法分析器里都是用DFA做状态迁移。

考试中常考的题型是“给定正则表达式,画出NFA;再子集构造法转DFA;再最小化DFA”。每一步都有固定套路,属于“背模板、做例题”就能拿分的题,千万别丢。

2.2 子集构造法与DFA最小化:不仅会背还得会算

我拿一个简单的表达式a(b|c)*举例。你先画NFA:起始状态0,读a到状态1,状态1有ε边到状态2和状态3,状态2读b回到状态1,状态3读c回到状态1,状态1也是接受状态。然后做子集构造:

  • 初始状态集合是ε-closure({0}),得到{0}
  • 对输入a,得到ε-closure(move({0}, a))={1}
  • {1},输入b得到{2},输入c得到{3},再加上ε闭包还会回到{1},所以分别得到{1,2}{1,3}
  • 继续对{1,2}求b、c的转移,最终得到几个封闭的状态集合。

最小化DFA时,用“划分法”:先把状态分成接受状态和非接受状态两组,然后反复细分,直到每个组内的状态在任意输入下都落在同一个组里。比如上面例子中,接受状态包含最终状态1,非接受状态包含其它,通过b、c的迁移进一步区分,最后合并等价状态。

实操心得:很多同学画NFA喜欢省ε边,结果在子集构造时求闭包求错。我的建议是先老老实实把ε边画全,再求闭包;熟练之后再简化。实验和考试里,运算过程的步骤分也很重要,别跳步。

2.3 词法分析实验怎么做:手写派与工具派

词法分析实验有两种常见路线。手写派用C、C++或Python,直接读字符流,用自写状态机识别关键字、标识符、数字、运算符、界符。工具派用Flex/Lex,写.l文件,用正则表达式定义规则,自动生成词法分析器C代码。

如果你手写,核心结构是这样的:一个全局字符指针peek,一个getToken()函数,按状态迁移判断Token类型。伪代码如下:

Token getToken() { skipWhitespace(); if (isalpha(peek)) { // 读标识符/关键字 while (isalnum(peek)) append(); if (isKeyword(buf)) return makeToken(KEYWORD); else return makeToken(IDENTIFIER); } else if (isdigit(peek)) { // 读数字 while (isdigit(peek)) append(); return makeToken(NUMBER); } // 处理运算符、界符 }

如果你用Flex,.l文件大概长这样:

%{ #include "tokens.h" %} %% "if" { return IF; } "else" { return ELSE; } [a-zA-Z_][a-zA-Z0-9_]* { return IDENT; } [0-9]+ { return NUM; } ">="|"<="|"=="|"!=" { return RELOP; } [ \t\n]+ { /* skip whitespace */ } . { return UNKNOWN; } %%

两种路线我都建议试一遍:手写一遍能帮你理解状态机本质,用Flex一遍能让你见识“正则 → 自动机”这条流水线在工业界的实际应用。实验报告里如果能对比两种实现的优缺点,老师通常印象分会高不少。

3. 语法分析:从LL(1)到LR(1)的各类分析表构建套路

语法分析是编译原理的“主战场”。自顶向下分析的代表是LL(1),自底向上分析的代表是LR(1)家族,包括LR(0)、SLR(1)、LR(1)、LALR(1)。考试最爱考的大题有两类:一类是给文法求FIRST集、FOLLOW集、构建LL(1)分析表;另一类是给文法构造LR(0)项目集规范族、SLR(1)分析表,或者直接让你判断一个文法是不是LL(1)、是不是SLR(1)。

3.1 自顶向下分析:FIRST集、FOLLOW集与LL(1)分析表的计算

LL(1)分析的关键是预测分析表。要填表,先算FIRST集和FOLLOW集。

FIRST集的定义:一个符号串能推导出的所有终结符开头的集合。对每个非终结符A,看它的产生式右部第一个符号:如果是终结符,加入FIRST(A);如果是非终结符B,加入FIRST(B);如果B能推出空串,还要看下一个符号;如果整个右部都能推出空串,则空串也加入FIRST(A)。

FOLLOW集的定义:在推导过程中,可能紧跟在A后面的终结符集合。计算规则:初始把$加入FOLLOW(开始符号)。对形如A -> αBβ的产生式,把FIRST(β)(去掉ε)加入FOLLOW(B);如果β能推出ε(即 β ⇒* ε),则把FOLLOW(A)加入FOLLOW(B)。

我拿经典表达式文法举例:

E -> T E' E' -> + T E' | ε T -> F T' T' -> * F T' | ε F -> ( E ) | id

首先,FIRST(E)=FIRST(T)=FIRST(F)={ (, id }。FIRST(E')={ +, ε },FIRST(T')={ *, ε }。然后求FOLLOW:

  • FOLLOW(E)={ $, ) },因为E是开始符号,且F -> ( E ) 中E后面是 )。
  • FOLLOW(E')=FOLLOW(E)={ $, ) },因为E -> T E' 中E'是右部末尾。
  • FOLLOW(T)= { +, $, ) },因为E' -> + T E' 中T后面FIRST(E')有+,且E'能推ε,所以FOLLOW(E')并入;而FOLLOW(E')={ $, ) }。
  • FOLLOW(T')=FOLLOW(T)={ +, $, ) }。
  • FOLLOW(F)={, +, $, ) },因为T' -> * F T' 中F后面是,且T'能推ε,所以FOLLOW(T')并入。

有了FIRST和FOLLOW,就能填LL(1)分析表。对每个产生式A -> α,对每个a ∈ FIRST(α)a != ε,把A -> α填入M[A][a];若 α 能推ε,则对每个b ∈ FOLLOW(A)也填入。

实操心得:计算FOLLOW集时最容易漏的是“把FOLLOW(A)并入FOLLOW(B)”这一步。比如A -> αB这种产生式,只要B在末尾,FOLLOW(A)就必须并给FOLLOW(B)。检查时逐条规则核对,能避免计算遗漏。

3.2 自底向上分析:LR(0)项目集规范族、SLR(1)与LR(1)分析表的构建

自底向上分析考得最多的是LR分析。核心思路是:给文法每个位置加一个圆点,表示“当前分析到什么位置”,形成项目集合,然后通过GO函数构造项目集规范族。

S -> L = RS -> RL -> * RL -> idR -> L这个文法为例。先求每个非终结符的闭包:增广文法加S' -> S,初始项目集I0 = closure({S' -> .S})。对每个项目A -> α . X β,如果X是非终结符,就把所有X -> .γ加入闭包;如果X是终结符,则等待读入。

构造项目集规范族后,用所有终结符和非终结符分别求GO函数,得到状态转移。然后填ACTION表和GOTO表。ACTION表中,对项目A -> α . a βa为终结符,填移进;对项目A -> α .(即圆点在末尾),需要归约。SLR(1)使用FOLLOW(A)来决定归约的展望集合,而LR(1)使用更精确的展望符。

考试里常见的一个坑是“移进-归约冲突”。比如经典文法:

S -> L = R S -> R L -> * R L -> id R -> L

在某个状态中,同时存在L -> id .(归约)和R -> L .(归约),就需要用FOLLOW集判断冲突是否可解决。如果你求FOLLOW(R)和FOLLOW(L)后发现它们有交集,那这个文法就不是SLR(1)的。这种题说白了就是考你会不会用FOLLOW集做冲突消解。

3.3 那些年错过的语法分析大题模板

考试中的语法分析大题,总共就那么几个固定模板,练熟就能拿分。

  • 模板一:求FIRST/FOLLOW集。按规则逐条算,注意可空非终结符的传递。
  • 模板二:判断LL(1),构造预测分析表。先算FIRST/FOLLOW,再看是否有“同一非终结符的多个产生式右部FIRST集相交”的情况。
  • 模板三:构造LR(0)项目集规范族。画项目集图,注意闭包要算全。
  • 模板四:求SLR(1)/LR(1)分析表,并分析输入串的移进归约过程。这里要会写“步骤、状态栈、符号栈、输入串、动作”这种表格,是标准得分格式。
  • 模板五:判断LR(0)、SLR(1)、LR(1)、LALR(1)文法的包含关系。记住:LR(0)最严格,SLR(1)比LR(0)宽松但弱于LR(1),LALR(1)介于SLR(1)和LR(1)之间。

注意:LR(0)项目集里如果有“归约-归约冲突”或“移进-归约冲突”,而且不能用FOLLOW集消解,那就不是SLR(1)。很多同学在这里做错,是因为把“LR(0)有冲突”直接当成“不是LR(0)文法”——实际上LR(0)文法要求项目集里完全没有冲突,一旦有冲突,就必须用FOLLOW集或展望符来消解。

4. 语义分析、中间代码与运行时环境:容易被人忽略的拉分点

词法语法是“骨架”,语义分析、中间代码生成、运行时环境就是“血肉”。这部分在考试里常以简答、填空、翻译题的形式出现,分值不算最高,但概念密集,非常容易考出区分度。

4.1 语法制导翻译:属性文法、S属性与L属性

语法制导翻译的核心是“在语法分析的同时做语义动作”。你需要理解综合属性与继承属性:综合属性自下而上计算,继承属性自上而下传递。属性文法中,S属性文法只有综合属性,适合自底向上分析;L属性文法中,继承属性只沿语法树从左到右传递,适合自顶向下分析。

考试常见题型是给一段产生式,标注属性计算规则。比如:

E -> E1 + T { E.val = E1.val + T.val } E -> T { E.val = T.val } T -> id { T.val = id.lexval }

此时只需把每个产生式的语义规则写上去,最后计算整个表达式的值。这个考点本身不难,但你要注意区分“打印语句”放在哪、何时触发,比如“在归约时打印”和“在移进时打印”会得到完全不同的输出顺序。

4.2 中间代码生成:三地址码、四元式与常见语句翻译

中间代码的形式有三种常考:三地址码、四元式(op, arg1, arg2, result)和三元式。四元式最常考,因为实现简单且便于优化。写翻译题时,你只要会翻译这几类语句就够:

  • 赋值语句:a = b + c * d翻译成两条四元式:(*, c, d, t1)(+, b, t1, t2)(=, t2, _, a)
  • if语句:if (a > b) x = 1; else x = 2;需要生成条件跳转四元式,如(j>, a, b, ...)(j, _, _, ...)
  • while语句:while (a < b) a = a + 1;翻译时注意回填标签,通常用“回填技术”来处理跳转地址未知的问题。
  • 数组引用:a[i] = b[j] + 1要翻译出地址计算,比如(*, i, 4, t1)等。

这道题想拿满分,关键是要理解临时变量的复用和回填。建议考试时先在草稿纸上画好控制流图,再按“每个表达式一个临时变量”的原则一步步翻译,不要跳步。

4.3 符号表、类型检查与运行时环境:概念题高发区

符号表的作用是记录变量、函数、类型的属性和作用域。你需要理解两种建表时机:词法分析时建表、语义分析时填表。考试经常考“符号表应该包含哪些字段”:名字、类型、作用域、存储位置、维度信息、行号等。

类型检查要理解静态类型检查和动态类型检查的区别。静态类型检查在编译期进行,动态类型检查在运行期进行。表达式类型检查的规则是:int + int -> intfloat + int -> float,数组下标必须为整型,函数调用时实参和形参必须类型兼容。

运行时环境主要考活动记录和存储分配。活动记录通常包含:返回地址、静态链/动态链、参数、局部变量、临时变量。考试题常问“活动记录中每个字段的作用”以及“栈式分配与堆式分配的区别”。这块内容比较“背”,但概念清晰就能拿分。

实操心得:很多人把“静态链”和“动态链”搞混。记住一句话:静态链用于访问外层作用域的变量,指向定义该函数的静态外层函数的活动记录;动态链用于回收栈空间,指向调用者的活动记录。画图理解一次,比背十遍定义都管用。

5. 代码优化:从数据流分析到循环优化

代码优化是编译原理里“最接近工程实战”的部分,也是面试里经常被追问的环节。考试的难度通常集中在:能写出基本块、能画DAG、能判断哪些优化手段作用于哪个层次、以及简单的数据流分析。

5.1 优化的分类:局部优化、全局优化、循环优化

按作用范围,优化分三类。局部优化在基本块内进行,例如常量折叠(2*3直接算成6)、复写传播(x = y后用y替换x)、死代码删除。全局优化跨越基本块,例如公共子表达式消除、代码外提。循环优化是高频考点,包括:代码外提(把循环内不变量移到循环外)、强度削减(把乘法变加法)、删除归纳变量。

考试最容易出简答题的是“给出一个循环,问可以做哪些优化”。例如:

for (i = 0; i < n; i++) { x = y + z; a[i] = i * 4; }

优化思路:x = y + z是循环不变量,外提;i*4用归纳变量j = 0; j += 4替代,同时a[i]的地址可以每次加4而不是每次计算偏移。如果答题时能把这三条都写出来,基本就稳了。

5.2 数据流分析:到达定值与活跃变量

数据流分析是代码优化的理论工具。考试最常考的是“到达定值分析”和“活跃变量分析”。到达定值分析需要你写出每个基本块的 Gen 集和 Kill 集,然后迭代计算 In 和 Out:

In[B] = ∪ Out[P](P是B的前驱) Out[B] = Gen[B] ∪ (In[B] - Kill[B])

活跃变量分析则反向计算:

In[B] = Use[B] ∪ (Out[B] - Def[B]) Out[B] = ∪ In[S](S是B的后继)

这类题只要按照给定公式迭代两到三轮,通常就能收敛。考试时老师会要求你写出迭代过程,所以别直接写答案,要展示每一轮的In/Out变化。

注意:到达定值分析是“前向数据流”,活跃变量是“后向数据流”,两者的方程方向相反。不少同学考试时把方向记反了,导致后面分析全错。记法:定值从前往后传,活跃从后往前传。

5.3 基本块划分与DAG优化

基本块划分的规则:入口语句是基本块的第一个语句——程序第一条语句、跳转目标语句、跳转语句的下一条语句。然后每个入口语句到下一条入口语句之前构成一个基本块。这个考点常结合DAG图考优化:根据基本块中的运算构造DAG,合并公共子表达式、删除无用赋值。

DAG题其实不复杂,但要注意对数组元素、指针的保守处理:如果没有明确信息,默认同一个数组的不同下标可能指向同一个存储单元,所以不能随便合并。

6. 考前冲刺与避坑指南:三天复习路线、实验常见问题、面试速查

我知道很多读者看这篇文章的时候,离考试可能只剩三天了。别慌,这最后一部分就是给突击党准备的冲刺方案,顺便把实验和面试里高频踩坑点都列一遍。

6.1 三天冲刺路线图

  • 第一天:搞定词法分析和语法分析的计算题。上午练正则转NFA、子集构造、DFA最小化;下午练FIRST集、FOLLOW集、LL(1)分析表;晚上练LR(0)项目集和SLR(1)分析表。这一天过后,大题已经能拿一半以上的分。
  • 第二天:搞定语义分析、中间代码和运行时环境。上午专攻翻译题:赋值语句、if、while的三地址码;下午背符号表、类型检查、活动记录概念;晚上做两套完整真题,重点看失分点。
  • 第三天:主攻代码优化题和概念题。上午练基本块划分、DAG优化、循环优化简答;下午集中背概念、看错题、背常考简答;晚上快速过一遍所有公式和算法伪代码。

实操心得:突击阶段不建议从头啃教材,直接拿往届真题做,遇到不会的知识点再翻对应章节。编译原理的题型非常固定,刷三套真题比看三遍书管用得多。

6.2 实验常见问题与排查方法

词法分析实验常出的问题有这么几类:一是关键字和标识符判断顺序反了,比如把if识别成标识符;二是数字越界、浮点数支持要加小数点规则;三是注释和空白处理不当,导致Token错位;四是状态机处理中peek字符回退没做对。

语法分析实验,如果是递归下降分析,最常见的坑是左递归没有消除,导致无限递归。如果是LR分析实验,常见问题是分析表构建算法写错,或者冲突没有处理。这类实验调试思路其实就一招:先把输入串一步一步手动跑一遍分析过程,再用程序输出对比,很快就能定位。

我整理了一个速查表:

实验现象可能原因处理建议
关键字被识别成id查关键字表时机太晚先查关键字表,再判定标识符
数字越界没有限制数字长度加长度上限或大数处理
空白导致Token错位跳过空白的逻辑没覆盖换行在词法规则中显式跳过空格、制表符、换行
死循环状态机没有推进字符指针检查每个状态是否都调用advance()
递归下降栈溢出左递归文法未消除改写为右递归或EBNF形式
LR分析表报错ACTION/GOTO表构建有冲突打印项目集规范族逐步排查

6.3 面试高频题速查:编译原理常见考点

互联网公司面试里,编译原理一般不会考特别偏的题,但基础概念和大局观很重要。我整理了十个最高频的问题,背熟它们基本能应付大多数面试场景:

  1. 编译器分为哪几个阶段?每个阶段的作用是什么?
  2. 编译器和解释器的区别是什么?
  3. 正则表达式和上下文无关文法的区别是什么?
  4. LL(1)与LR(1)分析的区别与优缺点?LL适合手写,LR适合工具生成,LR文法比LL文法表达能力强。
  5. 什么是语法树?和语法分析树的区别是什么?
  6. 中间代码表示有哪几种?三地址码和四元式的区别?
  7. 什么是语法制导翻译?
  8. 什么是数据流分析?
  9. 静态类型检查和动态类型检查的区别?
  10. 如何生成一个自定义语言的词法分析器/语法分析器?你可以提Lex/Yacc、Flex/Bison、ANTLR等工具。

回答这十道题时,尽量用项目经验或课程实验来支撑。比如问到“如何设计词法分析器”,你说“我手写过状态机,也用Flex生成过”就比干巴巴背概念强得多。

写在最后

说句实在话,编译原理考前突击是能过的,但真要把它学成自己的本事,还是得动手写代码、动手算分析表。我记得自己当年学这门课时,第一次手写递归下降解析器,跑通一个带括号的四则运算计算器的那一刻,整个人的成就感比写完一个网页高多了。那种“我在教机器理解规则”的感觉,确实是计算机专业里少有的体验。

最后再分享一个小技巧:复习时遇到不懂的文法、不会算的FIRST集,别死磕书本,顺手写个小程序帮你算,写程序的过程本身就是最好的复习。这个价值观放在编译原理上,非常合适——编译器本来就是“用程序处理程序”的艺术,学它最有效的方式,就是用它造点东西出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询