简介:这份资源是山东大学编译原理与技术课程新版实验一至三的配套代码包,面向正在学习编译器前端构建的高校学生与自学者,帮助解决词法分析与语法分析从理论到实现的落地问题。包内共15个文件,以8个C++头文件与5个cpp源文件为核心,辅以1个build.sh构建脚本和1个README说明文档,压缩包约30KB,涵盖词法分析器、语法分析器、抽象语法树生成及对象代码生成等模块,结构紧凑便于直接编译运行。实验内容从识别关键字、标识符、常量与运算符的词法规则入手,逐步过渡到基于上下文无关文法的语法结构构造,并涉及递归下降、LL与LR等分析技术的实践。已有57人学习下载,适合作为编译器前端实验的参考实现,读者可借此理解有限自动机状态转移、词素流处理与语法错误报告等关键环节,并对照完成自己的实验任务。
1. 编译原理实验的“第一道坎”:从山东大学新版实验一~三说起
如果你正在搜“山东大学编译原理与技术课程新版实验一~三”,大概率是刚拿到实验指导书,或者被 Lex/Yacc、递归下降、语法树这些词砸得有点懵。这套实验是山大编译原理课程的核心实践环节,实验一通常做词法分析器,实验二做语法分析器,实验三做语义分析或中间代码生成。它解决的不是“编译原理是什么”这种科普问题,而是让你亲手把正则表达式、上下文无关文法、LL(1) 分析表这些纸面知识变成能跑通的代码。适合谁?正在上这门课、需要交实验报告、或者想用 C/C++/Python 补一遍编译前端实操的从业者。我拆过这套实验的常见版本,下面按“能复现”的标准把关键路径和坑点讲透。
2. 实验一:词法分析器的状态机设计与正则匹配
2.1 为什么先做词法分析:从字符流到 Token 序列
词法分析是编译器的入口,任务是把源程序的字符流切分成有意义的 Token 序列,比如关键字、标识符、数字、运算符。实验一通常要求你实现一个能识别 C 语言子集的词法分析器。常见做法是手写状态机,或者用 Lex/Flex 自动生成。山大新版实验一般建议手写,因为能逼你理解 DFA 的构造过程。核心逻辑是:读入一个字符,根据当前状态和字符类型决定下一个状态,遇到接受状态就输出 Token 并回退多余字符。这里的关键参数是“最长匹配”原则——比如>=必须匹配成大于等于,不能拆成>和=。我一般会先画状态转移图,再写代码,否则边界条件容易漏。
2.2 手写词法分析器的代码骨架与参数说明
下面是一个能跑通的 Python 版词法分析器骨架,覆盖标识符、整数、运算符和关键字。代码里用pos记录当前扫描位置,peek()看下一个字符但不移动,advance()移动并返回字符。
import re class Lexer: def __init__(self, text): self.text = text self.pos = 0 self.current_char = self.text[self.pos] if self.text else None def advance(self): self.pos += 1 self.current_char = self.text[self.pos] if self.pos < len(self.text) else None def peek(self): peek_pos = self.pos + 1 return self.text[peek_pos] if peek_pos < len(self.text) else None def skip_whitespace(self): while self.current_char is not None and self.current_char.isspace(): self.advance() def number(self): result = '' while self.current_char is not None and self.current_char.isdigit(): result += self.current_char self.advance() return ('INTEGER', int(result)) def identifier(self): result = '' while self.current_char is not None and (self.current_char.isalnum() or self.current_char == '_'): result += self.current_char self.advance() # 关键字表,实验里通常要求识别 if/else/while/return 等 keywords = {'if', 'else', 'while', 'return', 'int', 'void'} if result in keywords: return ('KEYWORD', result) return ('IDENTIFIER', result) def get_next_token(self): while self.current_char is not None: if self.current_char.isspace(): self.skip_whitespace() continue if self.current_char.isdigit(): return self.number() if self.current_char.isalpha() or self.current_char == '_': return self.identifier() # 处理双字符运算符,比如 >= <= == != if self.current_char == '>' and self.peek() == '=': self.advance(); self.advance() return ('OPERATOR', '>=') if self.current_char == '<' and self.peek() == '=': self.advance(); self.advance() return ('OPERATOR', '<=') if self.current_char == '=' and self.peek() == '=': self.advance(); self.advance() return ('OPERATOR', '==') if self.current_char == '!' and self.peek() == '=': self.advance(); self.advance() return ('OPERATOR', '!=') # 单字符运算符 if self.current_char in '+-*/=<>(){};,': op = self.current_char self.advance() return ('OPERATOR', op) raise Exception(f'非法字符: {self.current_char}') return ('EOF', None)逻辑说明:get_next_token是主循环,每次跳过空白后判断字符类型。数字走number(),字母或下划线走identifier(),运算符先检查双字符组合再检查单字符。参数上,keywords集合决定哪些标识符被提升为关键字,实验里通常要求覆盖 C 子集。注意peek()只用于双字符运算符的前瞻,不要用它来移动位置。跑通后可以用while True: token = lexer.get_next_token(); if token[0] == 'EOF': break; print(token)验证。
2.3 用 Flex 快速生成词法分析器的替代方案
如果实验允许用工具,Flex 是更省事的路径。写一个.l文件,定义正则模式和对应动作,flex lexer.l生成lex.yy.c,再gcc lex.yy.c -lfl -o lexer编译。常见做法是:
# 安装 flex(Ubuntu/Debian) sudo apt-get install flex # 编写 lexer.l 后生成 C 代码 flex lexer.l gcc lex.yy.c -lfl -o lexer ./lexer < test.c参数说明:-lfl链接 Flex 库,< test.c把测试文件喂给标准输入。Flex 的规则是“最长匹配优先,先定义优先”,所以把>=写在>前面。坑在于 Flex 默认不处理嵌套注释,实验里如果要求支持/* */,得自己写状态或正则。
3. 实验二:语法分析器的递归下降与 LL(1) 分析表
3.1 递归下降 vs 预测分析表:选型理由与适用边界
实验二通常要求实现语法分析器,把 Token 序列变成语法树。两条主流路线:递归下降和 LL(1) 预测分析表。递归下降写起来直观,每个非终结符对应一个函数,适合文法没有左递归、提取了左公因子的情况。LL(1) 分析表更“自动化”,需要先算 FIRST 集、FOLLOW 集,再构造预测分析表,适合实验要求“展示分析过程”的场景。山大新版实验一般两种都接受,但递归下降更容易调试。我一般会先消除左递归,再写递归下降,因为左递归会导致无限递归,这是血泪经验。
3.2 递归下降分析器的实现与语法树构造
下面是一个针对简单表达式文法的递归下降分析器,文法为:
expr -> term (('+' | '-') term)* term -> factor (('*' | '/') factor)* factor -> INTEGER | '(' expr ')'代码用current_token保存当前 Token,eat()消费并前进。
class Parser: def __init__(self, lexer): self.lexer = lexer self.current_token = self.lexer.get_next_token() def eat(self, token_type): if self.current_token[0] == token_type: self.current_token = self.lexer.get_next_token() else: raise Exception(f'期望 {token_type},实际 {self.current_token[0]}') def factor(self): token = self.current_token if token[0] == 'INTEGER': self.eat('INTEGER') return ('NUM', token[1]) elif token[0] == 'OPERATOR' and token[1] == '(': self.eat('OPERATOR') node = self.expr() self.eat('OPERATOR') # 期望 ')' return node else: raise Exception('factor 解析错误') def term(self): node = self.factor() while self.current_token[0] == 'OPERATOR' and self.current_token[1] in ('*', '/'): op = self.current_token[1] self.eat('OPERATOR') right = self.factor() node = (op, node, right) return node def expr(self): node = self.term() while self.current_token[0] == 'OPERATOR' and self.current_token[1] in ('+', '-'): op = self.current_token[1] self.eat('OPERATOR') right = self.term() node = (op, node, right) return node逻辑说明:expr处理加减,term处理乘除,factor处理数字和括号。每个函数返回一个语法树节点,元组形式(op, left, right)或('NUM', value)。参数上,eat负责匹配并前进,如果 Token 类型不匹配就抛异常。注意括号匹配时eat('OPERATOR')期望的是),但代码里没检查具体字符,实验里最好加上token[1] == ')'的判断。跑通后可以用parser.expr()得到树,再写个简单的树打印函数验证。
3.3 LL(1) 分析表的构造步骤与 FIRST/FOLLOW 集计算
如果实验要求 LL(1) 分析表,步骤是:1)消除左递归和左公因子;2)对每个非终结符算 FIRST 集;3)算 FOLLOW 集;4)构造预测分析表,表项是产生式。常见做法是用 Python 字典存 FIRST 和 FOLLOW,然后遍历产生式填充。参数上,FIRST 集看产生式右部第一个符号,如果是终结符直接加入,非终结符则递归;FOLLOW 集看产生式右部非终结符后面的符号,如果后面是空或末尾,加入左部的 FOLLOW。坑在于空产生式ε的处理,容易漏。我一般会写个小测试,用id + id * id验证分析表能否正确推导。
4. 实验三:语义分析与中间代码生成的落地细节
4.1 语义分析要做什么:符号表、类型检查与作用域
实验三通常要求做语义分析,核心是符号表和类型检查。符号表用来记录变量名、类型、作用域层级。常见做法是用栈式符号表,进入作用域时压栈,退出时弹栈。类型检查要验证表达式两边类型一致,比如int + float要报错或隐式转换。参数上,作用域层级用整数表示,查找变量时从当前层级往上找。坑在于同名变量在不同作用域的处理,以及函数参数的作用域。我一般会先定义 AST 节点类型,再写 visitor 遍历。
4.2 三地址码生成:从语法树到四元式
中间代码生成常用三地址码,形式如t1 = a + b。四元式是(op, arg1, arg2, result)。下面是一个简单的三地址码生成器,遍历语法树,用临时变量计数器temp_count。
class ThreeAddressCode: def __init__(self): self.temp_count = 0 self.code = [] def new_temp(self): self.temp_count += 1 return f't{self.temp_count}' def generate(self, node): if node[0] == 'NUM': return str(node[1]) op, left, right = node left_val = self.generate(left) right_val = self.generate(right) temp = self.new_temp() self.code.append((op, left_val, right_val, temp)) return temp逻辑说明:generate递归处理左右子树,遇到数字返回字面量,遇到运算符生成新临时变量并追加四元式。参数上,temp_count保证临时变量唯一,code列表存四元式。跑通后可以用for quad in code: print(quad)输出。注意实验里可能要求优化,比如常量折叠,那是进阶内容。
4.3 符号表与类型检查的联动实现
符号表和类型检查要联动:在遍历 AST 时,遇到变量声明就插入符号表,遇到变量引用就查找并检查类型。常见做法是写一个SemanticAnalyzer类,维护scope_stack和symbol_table。参数上,每个符号表项存(name, type, scope_level)。坑在于函数调用时的参数类型匹配,以及数组下标类型检查。我一般会先实现单作用域,再扩展多作用域,避免一开始就复杂化。
5. 避坑与排查:实验一~三最常见的五个翻车点
5.1 现象:词法分析器把>=拆成>和=;原因:没有做最长匹配前瞻;解决:在单字符运算符判断前先检查双字符组合,用peek()看下一个字符。
5.2 现象:递归下降分析器无限递归;原因:文法存在左递归,比如expr -> expr + term;解决:消除左递归,改成expr -> term (('+' | '-') term)*。
5.3 现象:LL(1) 分析表出现多重入口;原因:文法不是 LL(1),FIRST 集有交集;解决:提取左公因子,或改用 LR 分析。实验里如果要求 LL(1),必须确保文法满足条件。
5.4 现象:语义分析时变量找不到;原因:符号表作用域没正确压栈弹栈;解决:进入{}时压栈,退出时弹栈,查找时从栈顶往下找。
5.5 现象:三地址码临时变量重复;原因:temp_count没有全局唯一;解决:用类成员变量或全局计数器,确保每次new_temp()都递增。
6. 进阶技巧:用测试用例驱动实验验收与自动化对比
实验做完不是终点,能验证才算落地。我一般会写一组测试用例,覆盖正常和边界情况,比如空输入、非法字符、嵌套括号、多行注释。然后写个脚本自动跑,对比输出是否符合预期。下面是一个简单的测试驱动脚本:
import subprocess test_cases = [ ('1 + 2 * 3', '7'), ('(1 + 2) * 3', '9'), ('10 / 2 - 3', '2'), ] for expr, expected in test_cases: # 假设你的编译器输出计算结果 result = subprocess.run(['python', 'compiler.py', expr], capture_output=True, text=True) actual = result.stdout.strip() if actual == expected: print(f'PASS: {expr} = {actual}') else: print(f'FAIL: {expr} 期望 {expected},实际 {actual}')参数说明:subprocess.run调用你的编译器脚本,capture_output=True捕获输出,text=True返回字符串。坑在于路径和编码,Windows 下可能需要encoding='utf-8'。另外,实验报告里最好附上测试用例和通过率,这是加分项。从那以后我每次做完实验都强制走一遍自动化测试,不然手工点容易漏。希望帮到你。
本文还有配套的精品资源,点击获取