简介:这份资源是北京交通大学编译原理课程六个核心实验模块的完整源码集合,面向计算机科学与技术专业学生及需要系统练习编译器前端开发的开发者。内容覆盖词法分析、递归下降语法分析、LL(1)文法分析、算符优先文法分析、基于SLR(1)分析法的语法制导翻译以及中间代码生成,从字符序列到记号流、抽象语法树、语法检查直至语义动作触发与中间代码产出,构成一条完整的编译前端技术链路,适合课程实验对照、期末复习与自学编译器构造。压缩包共94个文件,以33个cpp源文件与29个头文件为主体,辅以21个txt测试用例与文法文件、6个makefile构建脚本及少量c文件,整体约66KB,按Lab01至Lab06分目录组织,结构清晰便于逐模块查阅。目前已有92人学习。读者可据此复现各实验的分析流程,理解移进-规约冲突处理与语法制导翻译的落地方式,并借助现成测试数据快速验证与排错。
1. 编译原理实验从零跑通:这套北交大源码到底能帮你省多少时间
如果你正在上编译原理这门课,大概率会遇到一个很现实的问题:课本上的 LL(1) 分析表、SLR(1) 项目集规范族、语法制导翻译这些概念,看一遍好像懂了,真让你从零写一个词法分析器或者递归下降语法分析器,又不知道从哪里下手。这套北京交通大学编译原理课程实验的完整源码集合,覆盖了词法分析、递归下降语法分析、LL(1) 文法分析、算符优先文法分析、基于 SLR(1) 分析法的语法制导翻译及中间代码生成、编译器前端实现六个核心实验模块,正好对应大多数高校编译原理课程实验的完整链路。适合两类人:一是正在做课程实验、需要一份能跑通的参考实现来对照调试的本科生;二是想快速回顾编译前端各阶段衔接关系、不想从零造轮子的开发者。每个模块独立成文件,可以单独编译运行,也可以串起来理解从源程序到中间代码的完整流程。
2. 词法分析与递归下降语法分析:从字符流到语法树的落地路径
2.1 词法分析器的核心逻辑与实现方式
词法分析是整个编译前端的第一步,任务是把源程序的字符流切分成有意义的单词符号(Token),同时识别关键字、标识符、常数和界符。这套源码里的词法分析模块采用的是经典的有限自动机思路,逐字符扫描,遇到字母开头就继续读直到非字母数字,然后查关键字表决定是关键字还是普通标识符;遇到数字就继续读数字和小数点,识别整数或浮点数;遇到运算符和界符则直接匹配。
我一般会先看它的 Token 结构定义,因为后面语法分析器要直接消费这个结构。常见做法是用一个结构体或类保存 token 类型和值,比如type字段区分关键字、标识符、常数、运算符,value字段存原始字符串。这样语法分析阶段只需要判断type和value就能做推导决策。
# 词法分析核心扫描循环(示意) KEYWORDS = {'if', 'else', 'while', 'int', 'float', 'return'} def tokenize(source): tokens = [] i = 0 while i < len(source): ch = source[i] 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() or source[j] == '.'): j += 1 tokens.append(('num', source[i:j])) i = j elif ch in '+-*/=<>(){};,': tokens.append(('op', ch)) i += 1 else: i += 1 # 跳过空白和换行 return tokens这段代码的关键在于扫描指针i的推进逻辑:识别完一个 token 后必须把i跳到 token 末尾的下一个位置,否则会死循环。参数方面,KEYWORDS集合决定了哪些标识符被提升为关键字,实际实验里通常还会加void、for、do等。容易翻车的地方是浮点数识别——如果源程序里出现1.2.3这种非法输入,上面的简化逻辑会把它当成一个 num token 吞掉,正规做法是在读到第二个小数点时报词法错误。
2.2 递归下降语法分析的推导过程与代码结构
递归下降分析法是最直观的自顶向下语法分析方法,核心思想是给每个非终结符写一个函数,函数体内按照产生式右部依次调用其他非终结符对应的函数或匹配终结符。这套源码里的递归下降模块针对的是算术表达式和简单语句,通常包含parse_expr、parse_term、parse_factor三层,用来处理运算符优先级。
# 递归下降分析算术表达式(示意) 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 match(self, expected): tok = self.peek() if tok[1] == expected: self.pos += 1 return tok raise SyntaxError(f"期望 {expected},实际 {tok}") def parse_expr(self): node = self.parse_term() while self.peek()[1] in ('+', '-'): op = self.peek()[1] self.pos += 1 right = self.parse_term() node = (op, node, right) return node def parse_term(self): node = self.parse_factor() while self.peek()[1] in ('*', '/'): op = self.peek()[1] self.pos += 1 right = self.parse_factor() node = (op, node, right) return node def parse_factor(self): tok = self.peek() if tok[0] == 'num' or tok[0] == 'id': self.pos += 1 return tok elif tok[1] == '(': self.pos += 1 node = self.parse_expr() self.match(')') return node raise SyntaxError(f"意外的 token: {tok}")这段代码里parse_expr处理加减、parse_term处理乘除、parse_factor处理括号和原子操作数,三层嵌套天然实现了优先级。self.pos是全局扫描位置,peek只看不前进,match匹配成功才前进。递归下降的优点是代码结构和文法产生式几乎一一对应,写起来直观;缺点是遇到左递归文法必须改写成右递归或消除左递归,否则会无限递归。实验里常见的坑是忘记在parse_factor里处理括号后调用match(')'),导致括号不匹配时错误定位不准。
3. LL(1) 文法分析与算符优先文法:两张分析表怎么建、怎么用
3.1 LL(1) 分析表的构造与预测分析流程
LL(1) 分析法的核心是构造一张预测分析表,表的行是非终结符,列是终结符,单元格里填产生式。构造过程分三步:先求 FIRST 集,再求 FOLLOW 集,最后根据FIRST(α)和FOLLOW(A)填表。这套源码里的 LL(1) 模块通常会读入一个文法文件,自动计算 FIRST 和 FOLLOW,然后生成分析表并驱动一个栈式分析器。
# 计算 FIRST 集(示意) def compute_first(grammar): first = {nt: set() for nt in grammar} changed = True while changed: changed = False for nt, prods in grammar.items(): for prod in prods: if prod[0] not in grammar: # 终结符开头 if prod[0] not in first[nt]: first[nt].add(prod[0]) changed = True else: # 非终结符开头 for sym in prod: if sym in grammar: before = len(first[nt]) first[nt] |= (first[sym] - {'ε'}) if 'ε' not in first[sym]: break if len(first[nt]) == before: break else: first[nt].add(sym) break return first这段代码用迭代法求 FIRST 集,changed标记控制循环直到不再变化。参数grammar是一个字典,键是非终结符,值是产生式列表,每个产生式用字符串或列表表示。注意ε的处理:如果某个非终结符能推出空串,求 FIRST 时要继续看下一个符号。实际实验里最容易翻车的是 FOLLOW 集计算,尤其是当产生式右部末尾是非终结符时,要把左部的 FOLLOW 加进去,很多人漏掉这一步导致分析表填不全。
3.2 算符优先分析法的优先关系表与归约过程
算符优先分析法利用运算符之间的优先关系来指导归约,核心是构造一张优先关系表,表里记录>、<、=三种关系。这套源码里的算符优先模块通常先计算 FIRSTVT 和 LASTVT 集,然后根据产生式填表,最后用一个栈做移进-归约。
| 步骤 | 栈内容 | 当前输入 | 动作 |
|---|---|---|---|
| 1 | # | id + id * id # | 移进 |
| 2 | # id | + id * id # | 归约 id |
| 3 | # E | + id * id # | 移进 |
| 4 | # E + | id * id # | 移进 |
| 5 | # E + id | * id # | 归约 id |
| 6 | # E + E | * id # | 比较 + 和 *,+ < *,移进 |
| 7 | # E + E * | id # | 移进 |
| 8 | # E + E * id | # | 归约 id |
| 9 | # E + E * E | # | 归约 * |
| 10 | # E + E | # | 归约 + |
| 11 | # E | # | 接受 |
这张表展示了算符优先分析器处理id + id * id的完整过程。关键点在第 6 步:栈顶的+和输入串当前的*比较优先关系,因为*优先级高于+,所以选择移进而不是归约。算符优先的优点是实现简单、适合表达式分析;缺点是只适用于算符优先文法,对if-else这种需要上下文判断的结构无能为力。实验里常见的坑是 FIRSTVT 和 LASTVT 集算错,导致优先关系表出现空白或冲突,分析器遇到某些输入直接卡死。
4. SLR(1) 语法制导翻译与中间代码生成:从分析栈到四元式
4.1 SLR(1) 项目集规范族的构造与冲突消解
SLR(1) 是 LR 分析法的一种简化版本,核心是构造项目集规范族,然后根据 FOLLOW 集解决归约-归约冲突和移进-归约冲突。这套源码里的 SLR(1) 模块通常会先定义文法,然后自动生成 LR(0) 项目集、计算 ACTION 表和 GOTO 表,最后驱动分析器。
# 构造 LR(0) 项目集闭包(示意) def closure(items, grammar): result = set(items) changed = True while changed: changed = False for lhs, rhs, dot in list(result): if dot < len(rhs) and rhs[dot] in grammar: nt = rhs[dot] for prod in grammar[nt]: new_item = (nt, tuple(prod), 0) if new_item not in result: result.add(new_item) changed = True return result这段代码里items是项目集合,每个项目用(左部, 右部, 点位置)表示。closure函数不断展开点后面是非终结符的项目,直到不再新增。参数grammar是文法字典。SLR(1) 和 LR(0) 的区别在于归约动作只对 FOLLOW 集里的终结符生效,这样能消解一部分冲突。实验里最容易翻车的是项目集编号和 GOTO 表的对应关系,如果状态编号错位,分析器会在某个输入下走进错误状态,报出莫名其妙的语法错误。
4.2 语法制导翻译与四元式生成
语法制导翻译的核心思想是在语法分析过程中同步执行语义动作,生成中间代码。这套源码里的 SLR(1) 模块通常会在归约时执行语义动作,把表达式翻译成四元式。四元式格式是(op, arg1, arg2, result),比如a = b + c会生成(+, b, c, t1)和(=, t1, _, a)。
# 归约时生成四元式(示意) quadruples = [] temp_count = 0 def new_temp(): global temp_count temp_count += 1 return f"t{temp_count}" def gen(op, arg1, arg2, result): quadruples.append((op, arg1, arg2, result)) # 在归约 E -> E + T 时执行 def reduce_add(left, right): temp = new_temp() gen('+', left, right, temp) return temp这段代码里new_temp负责生成临时变量名,gen把四元式追加到列表。实际实验里,语义动作通常和归约动作绑定,比如在 SLR(1) 分析器的归约分支里调用对应的语义函数。参数方面,arg1和arg2是操作数,result是存放结果的变量或临时变量。常见的坑是临时变量命名冲突——如果多个表达式共用同一个临时变量名,生成的中间代码会互相覆盖,导致最终结果错误。正规做法是用一个全局计数器保证临时变量唯一。
5. 编译器前端实现与常见问题排查:六个模块怎么串起来
5.1 六个实验模块的衔接关系与数据流
这套源码的六个模块不是孤立的,它们对应编译前端的完整流水线:词法分析输出 Token 流,递归下降或 LL(1) 或算符优先或 SLR(1) 消费 Token 流做语法分析,语法制导翻译在语法分析过程中生成中间代码,编译器前端实现则把前面几个阶段串起来形成一个完整的可执行流程。我一般会先跑词法分析,确认 Token 流正确;再把 Token 流喂给语法分析器,确认语法树或分析过程正确;最后打开语义动作,看四元式生成是否符合预期。
| 模块 | 输入 | 输出 | 依赖 |
|---|---|---|---|
| 词法分析 | 源程序字符串 | Token 序列 | 无 |
| 递归下降语法分析 | Token 序列 | 语法树/求值结果 | 词法分析 |
| LL(1) 文法分析 | 文法定义 + Token 序列 | 分析过程 | 词法分析 |
| 算符优先文法分析 | 文法定义 + Token 序列 | 归约过程 | 词法分析 |
| SLR(1) 语法制导翻译 | 文法定义 + Token 序列 | 四元式序列 | 词法分析 |
| 编译器前端实现 | 源程序字符串 | 四元式序列 | 全部 |
这张表说明了每个模块的输入输出和依赖关系。实际调试时,如果最终四元式不对,可以逐级回溯:先看 Token 流有没有错,再看语法分析有没有走错分支,最后看语义动作有没有漏执行。
5.2 避坑与常见问题排查
现象一:词法分析器把关键字识别成标识符。原因通常是关键字表没有包含该关键字,或者查表逻辑写在了标识符识别之后但没做替换。解决方法是把关键字集合补全,并在识别出标识符后立即查表,命中则改类型为关键字。
现象二:递归下降分析器遇到左递归文法无限递归。原因是文法里存在E -> E + T这样的左递归产生式,递归下降会一直调用自己。解决方法是在写文法时消除左递归,改成E -> T E'、E' -> + T E' | ε的形式。
现象三:LL(1) 分析表出现多重入口。原因是文法不是 LL(1) 文法,某个单元格里填了多条产生式。解决方法是提取左公因子或消除左递归,如果改完还有冲突,说明该文法不适合 LL(1),需要换 SLR(1) 或更强大的分析方法。
现象四:SLR(1) 分析器在某个输入下报语法错误但文法明明是对的。原因通常是 FOLLOW 集算错,导致归约动作没有在正确的终结符上触发。解决方法是打印 FOLLOW 集和 ACTION 表,逐项核对,重点检查产生式右部末尾是非终结符的情况。
现象五:四元式生成时临时变量互相覆盖。原因是临时变量命名用了固定字符串或者计数器没有全局唯一。解决方法是把临时变量计数器设为全局变量,每次生成新临时变量时递增,确保名字不重复。
提示:调试编译原理实验时,建议先把每个模块的中间输出打印出来,Token 流、分析栈变化、四元式序列都看一眼,比直接看最终结果更容易定位问题。
6. 进阶用法:用这套源码做自动化测试与文法覆盖验证
这套源码除了用来做课程实验,还有一个很实用的进阶用法:把它当成一个编译前端测试平台,验证不同文法在不同输入下的行为。我一般会写一个批量测试脚本,把多组源程序喂给词法分析器和语法分析器,自动比对输出是否符合预期。
# 批量测试词法分析器(示意) import subprocess test_cases = [ ("int a = 1;", ["keyword:int", "id:a", "op:=", "num:1", "op:;"]), ("float b = 3.14;", ["keyword:float", "id:b", "op:=", "num:3.14", "op:;"]), ("if (a > 0) return a;", ["keyword:if", "op:(", "id:a", "op:>", "num:0", "op:)", "keyword:return", "id:a", "op:;"]), ] for source, expected in test_cases: result = subprocess.run( ["python", "lexer.py", source], capture_output=True, text=True ) actual = result.stdout.strip().split("\n") assert actual == expected, f"失败: {source}\n期望: {expected}\n实际: {actual}" print(f"通过: {source}")这段脚本用subprocess调用词法分析器,把输出按行拆分后和预期比对。参数test_cases是测试用例列表,每个元素是源程序和期望的 Token 序列。实际使用时可以把lexer.py换成语法分析器或 SLR(1) 分析器,验证不同阶段的输出。这种自动化测试的好处是,当你修改文法或调整分析表时,能快速发现哪些输入的行为变了。
另一个进阶用法是文法覆盖验证:构造一组能覆盖所有产生式的输入,跑一遍分析器,看是否每条产生式都被触发过。如果某条产生式从未被触发,说明测试用例覆盖不全,或者文法里有死代码。我一般会在分析器里加一个计数器,每次归约时给对应产生式加一,最后打印统计结果。
# 产生式覆盖统计(示意) production_hits = {} def record_reduction(lhs, rhs): key = f"{lhs} -> {' '.join(rhs)}" production_hits[key] = production_hits.get(key, 0) + 1 # 分析结束后打印 for prod, count in sorted(production_hits.items()): print(f"{prod}: {count} 次")这段代码在每次归约时记录产生式命中次数,分析结束后打印。如果某条产生式次数为 0,说明测试输入没有覆盖到它。参数lhs和rhs分别是产生式左部和右部。实际实验里,这种覆盖统计能帮你快速判断文法是否有冗余产生式,或者测试用例是否需要补充。
从那以后我每次拿到一套编译原理实验源码,都强制先跑一遍批量测试和覆盖统计,确认每个模块的输入输出边界都摸清楚了再动手改代码。希望帮到你。
本文还有配套的精品资源,点击获取