ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

LL(1)语法分析实战:IF-ELSE语句到四元式的编译器前端实现

LL(1)语法分析实战:IF-ELSE语句到四元式的编译器前端实现

简介:这份资源是编译原理课程中IF-ELSE条件语句翻译程序设计的完整实现包,面向正在学习语法分析与中间代码生成的高校学生及自学者。它围绕LL(1)预测分析法展开,演示如何对条件语句进行词法识别、语法解析,并输出四元式中间代码,帮助理解编译器前端从源码到语义表示的关键流程。压缩包共17个文件,约417KB,包含cpp与h源码、vcproj与sln工程文件、rc资源脚本、manifest清单及Debug目录下的obj、pdb、idb等编译产物,另有txt说明与htm文档,构成可直接打开编译的Visual Studio工程。资源中附带的Compare模块涉及条件比较与跳转四元式的生成实例,便于对照分析嵌套IF-ELSE、空语句与复合语句的处理方式。目前已有540人学习下载,适合作为课程实验参考或编译器前端入门练手项目。

1. 从一段 if-else 说起:这个编译器前端项目到底在干什么

你可能写过无数个 if-else,但有没有想过,编译器看到if (a > b) { ... } else { ... }这串字符时,它脑子里到底在算什么?这个项目就是干这件事的:用 LL(1) 分析法,把一段 IF-ELSE 条件语句翻译成四元式。四元式是编译原理里经典的中间代码表示,格式是(操作符, 操作数1, 操作数2, 结果),比如(>, a, b, t1)就表示把 a 和 b 比较的结果存到临时变量 t1 里。整个项目覆盖了词法分析、LL(1) 语法分析、语义动作、四元式生成这条完整链路,适合正在学编译原理、需要交课程设计或者想亲手摸一遍编译器前端流程的开发者。代码量不大,但五脏俱全,跑通之后你对“语法分析器怎么把字符变成中间代码”这件事会有完全不一样的理解。

2. 技术选型与整体架构:为什么用 LL(1) 而不是 LR

2.1 LL(1) 的适用边界与项目定位

LL(1) 是自顶向下分析法里最基础的一种,核心要求是文法无左递归、无回溯,且每个非终结符的 FIRST 集和 FOLLOW 集不能有冲突。IF-ELSE 语句的文法天然适合用 LL(1) 处理,因为它的结构是线性的:先看到if,然后读条件表达式,再读then分支,最后可选地读else分支。用递归下降或者预测分析表都能实现,这个项目大概率用的是预测分析表驱动的方式,因为这样更贴近教材里 LL(1) 的标准流程。

选 LL(1) 而不是 LR 的原因很实际:LL(1) 的分析表构造逻辑清晰,手写代码量少,调试的时候能直接看到栈里压了什么、当前读到的 token 是什么。LR 虽然能处理更多文法,但状态机构造和规约动作的调试成本对初学者不友好。这个项目的目标不是做一个工业级编译器,而是把“语法分析驱动语义动作”这条链路讲清楚,LL(1) 是性价比最高的选择。

2.2 模块划分与数据流

整个项目按编译阶段拆成四个模块,数据流是单向的:

源代码字符串 ↓ [词法分析器] → token 序列 ↓ [LL(1) 语法分析器] → 语法树 / 规约序列 ↓ [语义动作] → 四元式列表 ↓ [输出模块] → 格式化打印

词法分析器负责把if、(、a、>、b、)这些切成 token,每个 token 带类型和值。语法分析器用预测分析表驱动一个栈,遇到非终结符就查表展开,遇到终结符就匹配并触发语义动作。语义动作是嵌在文法产生式里的,比如读到if的时候生成一个条件跳转的四元式,读到else的时候回填跳转地址。输出模块把四元式列表按序号打印出来,方便对照检查。

2.3 预测分析表的构造逻辑

预测分析表是 LL(1) 的核心数据结构,行是非终结符,列是终结符,单元格里放产生式编号。构造步骤分三步:先算 FIRST 集,再算 FOLLOW 集,最后根据A → α把 α 的 FIRST 集填进去,如果 α 能推导出空串,再把 FOLLOW(A) 填进去。

# 构造预测分析表的简化逻辑 def build_parsing_table(grammar, first_sets, follow_sets): table = {} # key: (非终结符, 终结符), value: 产生式 for production in grammar: lhs = production.left rhs = production.right first_rhs = compute_first_of_sequence(rhs, first_sets) for terminal in first_rhs: if terminal != 'ε': table[(lhs, terminal)] = production if 'ε' in first_rhs: for terminal in follow_sets[lhs]: table[(lhs, terminal)] = production return table

这段代码里compute_first_of_sequence要处理符号串的 FIRST 集,如果某个符号能推出空串就继续看下一个符号。follow_sets是提前算好的 FOLLOW 集。填表的时候如果发现同一个单元格被填了两次,说明文法有冲突,不是 LL(1) 文法,需要改写。实际调试的时候我一般会在填表前打印 FIRST 和 FOLLOW 集,确认没算错再往下走,不然表填错了后面分析器跑起来全是玄学错误。

3. 核心模块拆解:词法分析、语法分析与四元式生成

3.1 词法分析器:把字符流切成 token

词法分析器的任务很单纯:从左到右扫描源代码,识别出关键字、标识符、运算符、界符和常量。IF-ELSE 语句里涉及的关键字就if和else两个,运算符有>、<、==这些关系运算符,界符是(、)、{、}。

# 词法分析器核心逻辑 KEYWORDS = {'if', 'else'} OPERATORS = {'>', '<', '>=', '<=', '==', '!='} DELIMITERS = {'(', ')', '{', '}', ';'} def tokenize(source): tokens = [] i = 0 while i < len(source): ch = source[i] if ch.isspace(): i += 1 continue if ch.isalpha(): # 标识符或关键字 j = i while j < len(source) and (source[j].isalnum() or source[j] == '_'): j += 1 word = source[i:j] if word in KEYWORDS: tokens.append(('KEYWORD', word)) else: tokens.append(('ID', word)) i = j elif ch.isdigit(): # 数字常量 j = i while j < len(source) and source[j].isdigit(): j += 1 tokens.append(('NUM', source[i:j])) i = j elif ch in OPERATORS or ch in DELIMITERS: # 处理双字符运算符 if i + 1 < len(source) and source[i:i+2] in OPERATORS: tokens.append(('OP', source[i:i+2])) i += 2 else: tokens.append(('OP' if ch in OPERATORS else 'DELIM', ch)) i += 1 else: raise SyntaxError(f"非法字符: {ch} 位置: {i}") return tokens

这段代码里tokenize函数逐字符扫描,遇到字母就往后读直到非字母数字下划线,判断是关键字还是标识符。遇到数字就读完整数字串。运算符那里有个细节:要先检查双字符运算符,比如>=不能被切成>和=。最后如果遇到不认识的字符直接抛异常,方便定位问题。实际跑的时候建议在 tokenize 之后打印一遍 token 列表,确认切分正确再喂给语法分析器,不然语法分析报错你都不知道是词法切错了还是文法写错了。

3.2 LL(1) 语法分析器:栈驱动的推导过程

语法分析器维护一个栈,初始时压入开始符号。每一步看栈顶符号和当前输入 token:如果栈顶是终结符且和当前 token 匹配,就弹出栈并读下一个 token;如果栈顶是非终结符,就查预测分析表,把对应产生式的右部逆序压栈;如果栈顶是空串就弹出。匹配过程中触发语义动作。

# LL(1) 语法分析器主循环 def parse(tokens, parsing_table, start_symbol): stack = ['$', start_symbol] # $ 是结束符 input_tokens = tokens + [('EOF', '$')] ip = 0 # 输入指针 quadruples = [] # 四元式列表 temp_count = 0 # 临时变量计数器 while stack: top = stack[-1] current_token = input_tokens[ip] if top == '$' and current_token[1] == '$': break # 分析成功 if top == current_token[1]: # 终结符匹配 stack.pop() ip += 1 elif (top, current_token[1]) in parsing_table: production = parsing_table[(top, current_token[1])] stack.pop() # 逆序压栈 for symbol in reversed(production.right): if symbol != 'ε': stack.append(symbol) # 触发语义动作 action = production.semantic_action if action: temp_count = action(quadruples, temp_count, current_token) else: raise SyntaxError(f"语法错误: 栈顶 {top}, 当前 token {current_token}") return quadruples

主循环里stack初始压入$和开始符号,$是栈底标记。每次循环看栈顶和当前 token,匹配就弹栈读下一个,不匹配就查表展开。展开的时候产生式右部要逆序压栈,因为栈是后进先出。语义动作在展开的时候触发,比如if对应的产生式展开时生成条件跳转四元式。temp_count用来生成t1、t2这样的临时变量名,每次需要新临时变量就加一。这里有个容易翻车的地方:语义动作的触发时机要和产生式展开严格对应,不能提前也不能延后,否则四元式顺序会乱。

3.3 四元式生成:回填技术与控制流

IF-ELSE 的四元式生成难点在跳转地址的回填。因为 LL(1) 是自顶向下分析,读到if的时候还不知道else分支的代码有多长,所以只能先生成一个空地址的跳转指令,等分析到对应位置再回填。

# 语义动作:处理 if 条件为假时的跳转 def gen_if_false(quadruples, temp_count, condition_result): # 生成条件跳转四元式,跳转地址暂时为空 index = len(quadruples) quadruples.append(('jf', condition_result, '_', None)) # jf = jump if false return index # 回填函数 def backpatch(quadruples, index, target): op, arg1, arg2, _ = quadruples[index] quadruples[index] = (op, arg1, arg2, target)

gen_if_false在条件表达式求值之后调用,生成一个jf四元式,第四个参数先留空。backpatch在知道跳转目标之后调用,把空地址填上。比如if (a > b) { s1 } else { s2 }的四元式序列大概是:

(>, a, b, t1) (jf, t1, _, 5) # 条件为假跳到第5条 (+, c, d, t2) # then 分支 (=, t2, _, x) (j, _, _, 8) # then 分支结束跳到第8条 (*, e, f, t3) # else 分支 (=, t3, _, y)

第 2 条的跳转地址 5 和第 5 条的跳转地址 8 都是回填进去的。这里的关键是维护一个待回填列表,每次生成跳转指令就把索引存进去,等分析到目标位置再统一回填。我一般会在回填之后打印完整四元式列表,逐条检查跳转地址对不对,这个步骤能省掉大量调试时间。

4. 避坑与常见问题:语法分析器跑不通时先查这几处

4.1 文法左递归导致栈溢出

现象:分析器一启动就无限循环,栈越压越深,最后报递归深度超限或者内存溢出。

原因:文法里存在左递归,比如E → E + T,LL(1) 遇到这种情况会一直展开 E 而不消耗输入 token。

解决:把左递归改写成右递归,E → T E',E' → + T E' | ε。改写之后 FIRST 和 FOLLOW 集要重新算,预测分析表也要重建。改完记得验证新文法还是不是 LL(1),有时候改写会引入新的冲突。

4.2 FIRST 集和 FOLLOW 集算错导致查表失败

现象:分析器跑到某个 token 时报“查表失败”,但文法看起来没问题。

原因:FIRST 集或 FOLLOW 集计算有误,预测分析表里该填的单元格没填上。常见错误是忘了处理 ε 产生式,或者 FOLLOW 集没有正确传播。

解决:单独写一个测试函数打印每个非终结符的 FIRST 和 FOLLOW 集,和手工计算的结果对照。重点检查含 ε 的产生式,以及 FOLLOW 集的传递规则:如果A → αBβ,那么 FIRST(β) 去掉 ε 要加到 FOLLOW(B);如果 β 能推出 ε,FOLLOW(A) 要加到 FOLLOW(B)。

4.3 语义动作触发时机错位

现象:四元式顺序混乱,跳转地址对不上,或者临时变量重复使用。

原因:语义动作嵌在产生式里,但触发时机和产生式展开的时机没对齐。比如在匹配终结符的时候触发了本该在展开非终结符时触发的动作。

解决:在产生式对象里明确标记语义动作,展开时统一触发。建议在每次触发语义动作时打印当前产生式和生成的四元式,方便追踪。临时变量计数器要全局维护,不能每个动作自己维护一份。

4.4 回填地址偏移量计算错误

现象:四元式列表打印出来跳转地址差一位或者差几条。

原因:回填的时候目标地址算错了,常见的是把当前四元式索引和下一个索引搞混,或者忘了四元式列表是从 0 开始还是从 1 开始。

解决:统一约定四元式编号从 0 开始,回填的时候用len(quadruples)作为下一个待生成四元式的索引。每次回填后打印目标索引和实际跳转位置,对照检查。如果跳转地址是相对地址,还要注意基址问题。

4.5 词法分析器把双字符运算符切错

现象:>=被切成>和=,语法分析器报错。

原因:词法分析器逐字符扫描时没有优先匹配最长运算符。

解决:在识别运算符时先检查双字符组合,再检查单字符。可以用一个有序列表按长度降序排列运算符,匹配时从长到短尝试。这个坑很隐蔽,因为单字符运算符单独看没问题,只有组合出现时才暴露。

5. 二次开发与改造思路:从课程设计到可扩展的编译器前端

这个项目跑通之后,最自然的改造方向是扩展支持的语句类型。当前只处理 IF-ELSE,你可以加 WHILE 循环、FOR 循环、变量声明和赋值语句。改造的核心思路是:每加一种语句,就往文法里加对应的产生式,然后写对应的语义动作生成四元式。WHILE 循环的四元式生成和 IF-ELSE 类似,也需要回填,但多了一个往回跳的指令。

# WHILE 循环的四元式生成骨架 def gen_while(quadruples, temp_count, condition_result): loop_start = len(quadruples) # 循环开始位置 jf_index = len(quadruples) quadruples.append(('jf', condition_result, '_', None)) # 条件为假跳出 return loop_start, jf_index def end_while(quadruples, loop_start, jf_index): quadruples.append(('j', '_', '_', loop_start)) # 跳回循环开始 backpatch(quadruples, jf_index, len(quadruples)) # 回填跳出地址

gen_while在循环条件求值后调用,记录循环开始位置并生成条件跳转。end_while在循环体分析完后调用,生成跳回指令并回填跳出地址。改造的时候要注意嵌套结构,比如 WHILE 里面套 IF-ELSE,回填列表要按嵌套层级管理,不然内层跳转可能回填到外层地址上。

另一个改造方向是加错误恢复机制。当前遇到语法错误直接抛异常,实际编译器会跳过一些 token 继续分析,尽量多报几个错误。可以在语法分析器里加一个错误恢复模式:遇到查表失败时,弹栈直到找到一个能匹配当前 token 的状态,或者跳过当前 token 继续。这个改造对理解编译器的容错设计很有帮助。

验证改造是否正确的办法是写一组测试用例,覆盖各种嵌套和边界情况,然后人工检查四元式序列。我一般会准备一个对照表,左边写源代码,右边写期望的四元式,跑完自动对比。这个习惯是从一次翻车经历来的:当时改完 WHILE 循环觉得没问题,结果嵌套 IF-ELSE 的时候跳转地址全乱了,因为没有自动化对比,手工检查漏掉了一个回填点。从那以后我每次改文法或者语义动作,都强制走一遍全量测试用例,确认四元式序列和预期完全一致才继续。希望这个项目能帮到你,跑通之后你对编译器前端的理解会扎实很多。

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

RELATED READING

延伸阅读

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