☰
编译原理实验一到八通关指南:从词法分析到代码生成
2026/10/13 7:59:04 网站建设 项目流程

简介:这份资源是面向高校计算机专业学生的编译原理实验合集,覆盖从词法分析到代码生成的完整编译流程,适合正在修读编译原理课程或准备相关实验考核的学习者。压缩包共248个文件,约446.47MB,以xz、zst等压缩归档文件为主,同时包含c与h源码、makefile构建脚本、y与l文法文件、docx与pdf实验文档以及少量可执行文件与日志,构成一套可编译、可复现的实验工程。已有491人学习下载,说明该系列实验在同类课程资料中具有一定参考价值。内容按实验一到八递进展开:实验一梳理编译器基本组成模块,实验二至四分别实现词法分析器、递归下降解析器与语义分析及表达式求值,实验五至七聚焦冗余消除、常量折叠、无用代码删除与寄存器分配等优化议题,实验八则整合前端处理、错误检测与目标代码生成,完成一个简易编译器综合项目。读者可据此获得完整的实验实现思路、文法与源码参考以及构建脚本,在动手实践中理解程序从源码到目标代码的转换过程。

1. 编译原理实验一到八:从词法到代码生成的完整通关路径

很多人学编译原理,课上听懂了 LL(1)、LR(1)、语法制导翻译,一到动手写实验就卡住。这套「编译原理实验一到八」的经典序列,本质上是一条从源程序字符串一路走到目标代码的完整流水线:词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成,中间还夹着符号表管理和错误处理。它解决的不是某个孤立算法,而是让你亲手把「编译器」这个黑匣子拆开再装回去。适合正在上编译原理课、需要交实验的本科生,也适合想补编译基础、理解语言实现细节的后端和工具链开发者。八个实验串起来,你会真正明白一行a = b + c * d是怎么变成机器能跑的指令的。

2. 实验一到三:词法、语法、语义的前端三件套

前端三个实验是整个编译器的地基,也是最容易「看起来会、写起来崩」的部分。我一般建议先把输入输出格式定死,再写核心逻辑,否则后面调试会非常痛苦。

2.1 实验一:词法分析器的最小实现与 token 设计

词法分析的任务是把字符流切成 token 流。核心是两件事:正则匹配规则和最长匹配原则。常见做法是用手写状态机,而不是直接上 lex 工具,因为手写能让你看清每个字符是怎么被消费的。

下面是一个简化版词法分析器骨架,支持标识符、整数、运算符和界符:

# 简易词法分析器:输入源程序字符串,输出 token 列表 KEYWORDS = {"if", "else", "while", "int", "return"} def tokenize(src): tokens = [] i = 0 n = len(src) while i < n: ch = src[i] # 跳过空白字符 if ch.isspace(): i += 1 continue # 标识符或关键字:字母开头,后接字母数字 if ch.isalpha() or ch == '_': j = i while j < n and (src[j].isalnum() or src[j] == '_'): j += 1 word = src[i:j] # 关键字单独标记,便于语法分析区分 tokens.append(("KEYWORD" if word in KEYWORDS else "ID", word)) i = j continue # 整数常量:连续数字 if ch.isdigit(): j = i while j < n and src[j].isdigit(): j += 1 tokens.append(("NUM", src[i:j])) i = j continue # 双字符运算符优先匹配,避免被单字符截断 if i + 1 < n and src[i:i+2] in ("==", "!=", "<=", ">=", "&&", "||"): tokens.append(("OP", src[i:i+2])) i += 2 continue # 单字符运算符和界符 if ch in "+-*/=<>(){};,[]": tokens.append(("OP" if ch in "+-*/=<>" else "DELIM", ch)) i += 1 continue # 无法识别的字符,记录错误但继续扫描 tokens.append(("ERROR", ch)) i += 1 return tokens if __name__ == "__main__": code = "int a = 10; if (a >= 5) { a = a + 1; }" for t in tokenize(code): print(t)

逻辑说明:主循环每次只看当前字符,按类别分派。标识符和数字用内层 while 吃掉连续字符,这是最长匹配的体现。双字符运算符必须在单字符之前判断,否则>=会被拆成>和=。参数方面,KEYWORDS集合决定哪些标识符升级为关键字,实际实验中通常还会加float、for等。错误处理这里只是标记 ERROR,正式实验一般要求输出行号和列号,方便定位。

提示:token 的类型命名要和后续语法分析的终结符一一对应,否则语法分析阶段会反复改词法代码。

2.2 实验二:递归下降语法分析与 FIRST/FOLLOW 集

语法分析把 token 流组织成语法树。递归下降是最直观的方法:每个非终结符写一个函数,函数内部根据当前 token 决定走哪条产生式。前提是文法已经消除了左递归和回溯。

以表达式文法为例:

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

对应的递归下降代码:

class Parser: def __init__(self, tokens): self.tokens = tokens self.pos = 0 def peek(self): # 返回当前 token,越界返回 None return self.tokens[self.pos] if self.pos < len(self.tokens) else None def match(self, typ): # 匹配并消费一个指定类型的 token tok = self.peek() if tok and tok[0] == typ: self.pos += 1 return tok raise SyntaxError(f"期望 {typ},实际 {tok}") def parse_E(self): node = self.parse_T() return self.parse_E_prime(node) def parse_E_prime(self, left): tok = self.peek() if tok and tok[0] == "OP" and tok[1] == "+": self.match("OP") right = self.parse_T() return self.parse_E_prime(("+", left, right)) return left # ε 产生式,直接返回 def parse_T(self): node = self.parse_F() return self.parse_T_prime(node) def parse_T_prime(self, left): tok = self.peek() if tok and tok[0] == "OP" and tok[1] == "*": self.match("OP") right = self.parse_F() return self.parse_T_prime(("*", left, right)) return left def parse_F(self): tok = self.peek() if tok and tok[0] == "NUM": self.match("NUM") return ("num", tok[1]) if tok and tok[0] == "ID": self.match("ID") return ("id", tok[1]) if tok and tok[0] == "DELIM" and tok[1] == "(": self.match("DELIM") node = self.parse_E() self.match("DELIM") # 匹配右括号 return node raise SyntaxError(f"F 无法匹配 {tok}")

逻辑说明:每个parse_X函数对应一个非终结符。E'和T'通过递归调用自身实现右递归,避免了左递归导致的无限递归。peek不消费 token,match消费并校验。参数上,self.pos是唯一的游标状态,所有函数共享。实际实验中,语法树节点通常用类而不是元组,方便后续语义分析挂属性。

FIRST/FOLLOW 集的作用是判断什么时候可以走 ε 产生式。比如E'的 FOLLOW 集包含)和结束符,当当前 token 属于 FOLLOW 时,E'应该返回而不是继续匹配。手写递归下降时这个判断隐含在if条件里,但实验报告通常要求显式列出 FIRST/FOLLOW 表。

2.3 实验三:语义分析与符号表管理

语义分析要干的事:类型检查、作用域解析、变量声明与使用的一致性。符号表是核心数据结构,常见实现是栈式作用域:进入块压栈,离开块弹栈。

class SymbolTable: def __init__(self): # 栈中每个元素是一个 dict,代表一个作用域 self.scopes = [{}] def enter_scope(self): self.scopes.append({}) def exit_scope(self): self.scopes.pop() def declare(self, name, typ): # 同一作用域重复声明报错 if name in self.scopes[-1]: raise NameError(f"重复声明: {name}") self.scopes[-1][name] = typ def lookup(self, name): # 从内到外查找,实现作用域遮蔽 for scope in reversed(self.scopes): if name in scope: return scope[name] raise NameError(f"未声明: {name}")

逻辑说明:scopes列表的末尾是当前作用域。declare只查当前作用域,所以内层可以遮蔽外层同名变量。lookup从内向外找,符合大多数语言的词法作用域规则。参数上,typ可以扩展成包含类型、偏移量、是否常量的结构体。语义分析遍历语法树时,遇到声明语句调用declare,遇到表达式调用lookup并做类型检查。

注意:符号表的作用域边界要和语法树中的块节点严格对应,否则会出现变量提前可见或提前失效的玄学 bug。

3. 实验四到六:中间代码、优化与目标代码生成

前端产出语法树后,中后端开始接管。这部分实验的难度陡增,因为抽象层次从「语法」跳到了「执行语义」。

3.1 实验四:三地址码与四元式生成

中间代码的常见形式是三地址码,每条指令最多一个运算符。四元式是它的表格化表示:(op, arg1, arg2, result)。

遍历语法树生成四元式:

class QuadGenerator: def __init__(self): self.quads = [] self.temp_count = 0 def new_temp(self): # 生成新的临时变量名 t1, t2, ... self.temp_count += 1 return f"t{self.temp_count}" def gen(self, node): # node 形如 ("+", left, right) 或 ("num", "10") if node[0] == "num": return node[1] if node[0] == "id": return node[1] # 二元运算:先递归生成左右操作数,再发射四元式 op = node[0] left = self.gen(node[1]) right = self.gen(node[2]) temp = self.new_temp() self.quads.append((op, left, right, temp)) return temp

逻辑说明:gen返回当前子树的计算结果存放位置(变量名或临时变量)。后序遍历保证左右操作数先算完。new_temp用计数器生成唯一临时名,避免冲突。参数上,quads列表就是最终输出,每条四元式的result字段指向存放结果的临时变量或用户变量。实际实验中,赋值语句需要额外发射(=, value, _, var)形式的四元式。

3.2 实验五:基本块划分与局部优化

优化实验通常从基本块开始。基本块是连续执行的指令序列,只有入口和出口可以跳转。划分方法是:找跳转目标、跳转指令下一条、以及它们之间的边界。

常见局部优化手段:

优化名称作用示例
常量折叠编译期计算常量表达式t1 = 2 + 3→t1 = 5
公共子表达式消除重复计算只做一次t1 = a + b; t2 = a + b→ 复用 t1
死代码消除删除结果不被使用的指令t1 = a + b后 t1 从未被引用
复写传播用变量替换临时变量t1 = a; b = t1→b = a

实现常量折叠的片段:

def constant_fold(quads): result = [] for op, a1, a2, res in quads: # 两个操作数都是数字字面量时直接计算 if a1.isdigit() and a2.isdigit(): val = eval(f"{a1} {op} {a2}") # 仅演示,实际应显式分派 result.append(("=", str(val), "_", res)) else: result.append((op, a1, a2, res)) return result

逻辑说明:遍历四元式,遇到两个操作数都是常量的算术运算就当场求值,替换成赋值四元式。参数上,eval在生产环境不安全,实验里可以换成operator模块的映射表。优化必须保证语义等价,所以除法要处理除零,浮点要处理精度。

3.3 实验六:目标代码生成与寄存器分配

目标代码生成把四元式映射到汇编或类汇编指令。最简单的方法是「一条四元式对应几条指令」,寄存器不够就溢出到栈。

一个极简的栈式目标代码生成:

def gen_target(quads): lines = [] for op, a1, a2, res in quads: if op == "=": lines.append(f"MOV {res}, {a1}") elif op == "+": lines.append(f"MOV R0, {a1}") lines.append(f"ADD R0, {a2}") lines.append(f"MOV {res}, R0") elif op == "*": lines.append(f"MOV R0, {a1}") lines.append(f"MUL R0, {a2}") lines.append(f"MOV {res}, R0") # 其他运算符类似扩展 return lines

逻辑说明:每个四元式用固定寄存器 R0 做累加器,先加载左操作数,再运算右操作数,最后写回结果。参数上,R0是临时寄存器,实际实验可能要求实现简单的寄存器分配算法,比如线性扫描或图着色。溢出处理是当活跃变量超过寄存器数量时,把最久未使用的变量写回栈。

提示:目标代码生成实验的评分重点通常不是指令数量,而是正确性。先把所有四元式都能翻译对,再考虑优化寄存器使用。

4. 实验七到八:符号表进阶与错误处理

这两个实验往往被低估,但它们是区分「能跑」和「健壮」的关键。

4.1 实验七:跨过程符号表与类型系统

当语言支持函数时,符号表要处理参数、局部变量、返回类型。常见做法是给每个函数建一个独立的符号表,全局符号表存函数名和签名。

class FunctionTable: def __init__(self): self.functions = {} # 函数名 -> (返回类型, 参数类型列表) def declare_func(self, name, ret_type, param_types): if name in self.functions: raise NameError(f"函数重复定义: {name}") self.functions[name] = (ret_type, param_types) def check_call(self, name, arg_types): # 调用时校验参数个数和类型 if name not in self.functions: raise NameError(f"未定义函数: {name}") ret, params = self.functions[name] if len(params) != len(arg_types): raise TypeError(f"参数个数不匹配: {name}") for p, a in zip(params, arg_types): if p != a: raise TypeError(f"参数类型不匹配: {name}") return ret

逻辑说明:functions字典存函数签名。check_call在函数调用点做参数校验,返回返回类型供上层表达式继续类型推导。参数上,类型可以用字符串表示,也可以用枚举。实际实验中,还要处理函数重载、默认参数等,但基础版先把签名匹配做对。

4.2 实验八:错误恢复与诊断信息

错误处理的目标不是「不报错」,而是「报错后还能继续分析,一次性给出多个错误」。常见策略是同步 token 集合:遇到错误时,跳过 token 直到遇到;、}或关键字,然后继续。

SYNC_TOKENS = {";", "}", "int", "if", "while", "return"} def recover(parser): # 跳过 token 直到遇到同步集合中的元素 while parser.peek() is not None: tok = parser.peek() if tok[1] in SYNC_TOKENS: break parser.pos += 1 # 消费同步 token,避免死循环 if parser.peek() is not None: parser.pos += 1

逻辑说明:recover在语法分析抛出异常后被调用,把游标推进到下一个可能合法的位置。参数上,SYNC_TOKENS的选择直接影响恢复质量:选得太少会跳过太多代码,选得太多会卡在原地。实际实验中,错误信息要包含行号、列号、期望 token 和实际 token,方便定位。

5. 避坑与排查:八个实验里最容易翻车的五个点

5.1 词法分析:最长匹配被忽略导致 token 截断

现象:>=被识别成>和=,语法分析报莫名其妙的错。原因:单字符运算符的判断写在双字符之前。解决:把所有多字符运算符放在单字符之前判断,或者用正则的贪婪匹配。

5.2 语法分析:左递归导致栈溢出

现象:递归下降解析器一跑就RecursionError。原因:文法存在直接左递归,如E -> E + T。解决:消除左递归,改写成E -> T E',E' -> + T E' | ε。

5.3 语义分析:作用域退出时忘记弹栈

现象:内层块声明的变量在外层还能查到。原因:exit_scope没有调用或调用时机不对。解决:在语法树遍历的块节点退出时严格配对调用enter_scope和exit_scope,可以用 try/finally 保证异常时也能弹栈。

5.4 中间代码:临时变量命名冲突

现象:两个不同的表达式生成了同名临时变量,导致计算结果互相覆盖。原因:临时变量计数器在递归调用中被重置或作用域不对。解决:把计数器放在生成器实例上,全局唯一递增,不要用局部变量。

5.5 目标代码:寄存器分配后活跃变量分析错误

现象:优化后的代码结果不对,但关掉优化就正确。原因:活跃变量分析把某个变量误判为死变量,提前释放了寄存器。解决:先不做寄存器分配,用最简单的「每条指令都写回内存」策略跑通,再逐步加优化,每加一步都对比未优化版本的输出。

注意:编译实验的调试成本很高,建议每完成一个实验就保留一份可运行的快照,后面实验出问题时可以快速回退定位。

6. 把八个实验串成一条可验证的流水线

单独跑通每个实验只是第一步,真正有价值的是把它们串起来:词法输出喂给语法,语法树喂给语义,语义标注后的树喂给中间代码生成,四元式喂给优化和目标代码。我一般会写一个driver脚本,按顺序调用各模块,并用一组固定的测试用例做端到端验证。

def compile_pipeline(source): # 阶段一:词法分析 tokens = tokenize(source) # 阶段二:语法分析,产出语法树 parser = Parser(tokens) ast = parser.parse_E() # 阶段三:语义分析,构建符号表并检查 symtab = SymbolTable() # 此处省略遍历 ast 做语义检查的代码 # 阶段四:中间代码生成 gen = QuadGenerator() gen.gen(ast) # 阶段五:优化 optimized = constant_fold(gen.quads) # 阶段六:目标代码生成 target = gen_target(optimized) return target if __name__ == "__main__": src = "int a = 2 + 3; int b = a * 4;" for line in compile_pipeline(src): print(line)

验证方法上,我习惯准备三类用例:正常程序、含语法错误的程序、含语义错误的程序。正常程序要求输出稳定且可人工核对;错误程序要求报错信息包含行号和错误类型,且不会崩溃。每改一次代码,三类用例都跑一遍,对比输出差异。

一个具体技巧:在四元式生成阶段给每条指令加上源行号注释,这样目标代码出错时能直接映射回源码位置。这个习惯在调试优化 bug 时特别省时间,相当于给自己留了一颗后悔药。

最后说个血泪经验:编译原理实验最怕的不是不会写,而是写完了不知道对不对。我的做法是每完成一个阶段,就用一个极小的输入(比如a = 1 + 2)手动推演一遍预期输出,和程序输出逐行对比。这个笨办法帮我抓出了至少一半的隐蔽 bug。希望帮到你。

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

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

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

立即咨询