简介:面向计算机专业编译原理课程设计与实验教学的一套完整资料,覆盖词法分析、LL(1)语法分析、LR(0)与SLR(1)语法分析、四元式生成及汇编代码生成等核心环节,可用于课程实验复现、课程设计参考与小型编译器实现思路梳理。压缩包共14个文件,约557KB,包含4个cpp、3个c与3个头文件,对应分析器与小型编译器源码;另有3个txt和1份doc,便于查看文法、输入样例及课程设计报告。已有2350人学习下载。读者可据此理解从词法扫描到语法分析、中间代码与目标代码生成的完整流程,对照LL(1)和SLR(1)文法开展实验,并借助报告整理设计目标、模块划分与实现要点,适合需要完成同类课程设计或复习编译前端技术的学习者。
1. 从一段报错日志切进去:词法分析器为什么总在注释边界翻车
编译原理课程设计里,最容易被低估的不是文法推导,而是词法分析器在注释边界上的行为。一个未闭合的块注释、一个//后面的换行、一个<=被拆成<和=,都会让语法分析器收到一串看似合法却完全错位的 token,最后报错位置漂到几十行之外。这个课程设计资源包围绕词法分析、语法分析、小型编译器和实验报告给出一套能跑通的骨架:手写扫描器、递归下降/LL(1) 解析、符号表与三地址码,再加测试用例和报告结构。适合正在从零搭前端流水线的人,也适合已经写完但卡在错误恢复和验收问答的人。拿到后先别急着改文法,先让 token 序列稳定下来,后面两层才有意义。
2. 词法分析:手写扫描器与正则表怎么选,先定这 4 个参数
词法分析是整条编译前端流水线的第一道闸门,它输出的 token 序列一旦错位,语法分析器再优雅的错误恢复都救不回来。课程设计里常见两种做法:手写扫描器和正则表驱动。前者适合需要在实验报告里讲清楚状态转移、最长匹配和错误恢复的场景;后者适合规则多、想快速把词法跑通再把精力留给语法和语义的场景。选型不用纠结“哪个更高级”,先看导师要求的交付物:要画 DFA 状态图、要讲清前瞻字符、要证明关键字和标识符的区分方式,手写扫描器更稳;只要求 token 流正确、后面重点做语法分析,正则表更省时间。
2.1 词法单元的边界:关键字、标识符、注释和换行
课程设计语言一般不会太大,但边界问题一个不少。关键字和标识符共用字母开头,必须先把整段标识符读满,再查关键字表,不能看到i就猜if;注释和除号共用/,必须看第二个字符才能决定是走行注释、块注释还是除法;换行既是被跳过的空白,也是错误定位的行号来源,不能简单用split()把源码切碎。很多翻车现场不是文法写错,而是词法阶段把<=拆成<和=,语法分析器在表达式层报“期望操作数”,真正的错误位置已经偏了好几列。
token 设计建议至少保留kind、value、line、col四个字段。kind用于语法分析器做分支,value保留原始字面量,line和col用于报错。不要只存一个字符串再在语法层重新判断类型,那样每层都要写一遍isdigit(),联调时很难定位到底是词法错还是语法错。下面这份手写扫描器把注释、关键字、标识符、数字、双字符运算符和单字符运算符分开处理,重点看_peek(k)的前瞻和_advance()的行列维护。
from dataclasses import dataclass from typing import List KEYWORDS = {"if", "else", "while", "int", "return", "print"} SINGLE = set("+-*/(){};=<>!,") @dataclass class Token: kind: str value: str line: int col: int class LexerError(Exception): pass class Lexer: def __init__(self, src: str): self.src = src self.i = 0 self.line = 1 self.col = 1 def _peek(self, k: int = 0) -> str: j = self.i + k return self.src[j] if j < len(self.src) else "\0" def _advance(self) -> str: ch = self.src[self.i] self.i += 1 if ch == "\n": self.line += 1 self.col = 1 else: self.col += 1 return ch def tokenize(self) -> List[Token]: out: List[Token] = [] while self.i < len(self.src): ch = self._peek() if ch in " \t\r\n": self._advance() continue if ch == "/" and self._peek(1) == "/": while self.i < len(self.src) and self._peek() != "\n": self._advance() continue if ch == "/" and self._peek(1) == "*": start_line, start_col = self.line, self.col self._advance() self._advance() while self.i < len(self.src) and not (self._peek() == "*" and self._peek(1) == "/"): self._advance() if self.i >= len(self.src): raise LexerError(f"未闭合块注释,起始位置 {start_line}:{start_col}") self._advance() self._advance() continue if ch.isalpha() or ch == "_": start_line, start_col = self.line, self.col buf = [] while self._peek().isalnum() or self._peek() == "_": buf.append(self._advance()) word = "".join(buf) kind = "KW" if word in KEYWORDS else "ID" out.append(Token(kind, word, start_line, start_col)) continue if ch.isdigit(): start_line, start_col = self.line, self.col buf = [] while self._peek().isdigit(): buf.append(self._advance()) out.append(Token("NUM", "".join(buf), start_line, start_col)) continue two = ch + self._peek(1) if two in {"==", "!=", "<=", ">=", "&&", "||"}: start_line, start_col = self.line, self.col self._advance() self._advance() out.append(Token("OP", two, start_line, start_col)) continue if ch in SINGLE: start_line, start_col = self.line, self.col self._advance() out.append(Token("OP", ch, start_line, start_col)) continue raise LexerError(f"非法字符 {ch!r},位置 {self.line}:{self.col}") out.append(Token("EOF", "", self.line, self.col)) return out这段代码的逻辑是“先看当前字符属于哪一类,再决定消费多少字符”。空白直接跳过;//消费到换行前;/*消费到*/,没闭合就带起始位置抛错;字母开头走标识符,读满后查关键字表;数字开头读满连续数字;双字符运算符优先于单字符运算符;最后把单字符运算符收掉,未知字符立即报错。参数上,_peek(k)的k是向前看几个字符,课程设计里k=1足够覆盖<=、==、&&这类双字符运算符;self.i是字节偏移,line和col只用于报错,不要混用。EOFtoken 也要带行列,语法分析器在文件末尾报错时才有位置可指。
跑一个最小输入,可以直接观察 token 序列。不要一上来就塞完整程序,先用带注释、带多字符运算符的片段验证边界:
src = """ // 计算阶乘 int fact(int n) { if (n <= 1) { return 1; } return n * fact(n - 1); } """ for t in Lexer(src).tokenize(): print(t.kind, repr(t.value), f"{t.line}:{t.col}")这段脚本不是装饰,它相当于词法层的“黑匣子”快照。逻辑上,先验证//注释是否被跳过、<=是否作为整体输出、-是否单独成 token、EOF是否落在最后一行。参数上,输入里刻意放了行注释、块级作用域、函数定义和递归调用,基本覆盖了课程设计常见 token 类型。如果这里打印出来的<=变成两个OP,后面语法分析器报什么错都不用看,直接回词法层修。
提示:块注释的错误信息一定要带起始行列,否则联调时只能靠猜。未闭合块注释是课程设计里最典型的“一个错误带崩整个文件”。
2.2 正则表与 DFA:规则多的时候怎么不把自己绕进去
手写扫描器的优点是可控,缺点是规则一多,if/elif会变成一堵墙。课程设计如果要求支持字符串、浮点数、单行注释、块注释、多字符运算符,手写分支会迅速膨胀。这时可以用正则规则表,把“匹配哪条规则”和“匹配之后做什么”分开。规则表的顺序就是优先级:注释必须排在运算符前面,关键字必须排在标识符前面,否则if会先被ID吃掉。下面这份规则表用re.compile预编译,循环里用pattern.match(src, pos)从当前位置尝试匹配。
import re RULES = [ ("COMMENT", re.compile(r"//[^\n]*|/\*[\s\S]*?\*/")), ("KW", re.compile(r"\b(?:if|else|while|int|return|print)\b")), ("ID", re.compile(r"[A-Za-z_][A-Za-z0-9_]*")), ("NUM", re.compile(r"\d+")), ("OP", re.compile(r"==|!=|<=|>=|&&|\|\||[+\-*/(){};=<>!,]")), ("WS", re.compile(r"[ \t\r\n]+")), ] def tokenize_re(src: str): pos = 0 out = [] while pos < len(src): for kind, pat in RULES: m = pat.match(src, pos) if m: text = m.group(0) if kind not in {"WS", "COMMENT"}: out.append((kind, text, pos)) pos = m.end() break else: raise SyntaxError(f"非法字符 {src[pos]!r} at offset {pos}") return out逻辑上,每一轮从pos开始按规则顺序尝试,第一条匹配成功的规则决定 token 类型,然后推进pos。参数上,RULES的顺序就是词法优先级,COMMENT放最前是因为//和/*都以/开头,如果先走OP,注释会被拆成除号和星号;KW放在ID前,并用\b保证ifx不会被误判为关键字;WS放最后但必须存在,否则空格会触发非法字符。这个版本没有维护行列号,只保留了偏移量,适合先跑通 token 流,后面再把pos映射回行列。
如果实验报告要求画 DFA,可以把规则表翻译成状态表,不用把整个正则引擎画出来。课程设计语言常见状态可以压缩成下面几类:
| 状态 | 输入 | 动作 | 去向 |
|---|---|---|---|
| 0 | 字母或下划线 | 开始标识符 | 1 |
| 0 | 数字 | 开始数字 | 2 |
| 0 | / | 预看下一个字符 | 3 |
| 1 | 字母或数字 | 继续标识符 | 1 |
| 1 | 其他 | 查关键字表,产出 ID/KW | 0 |
| 2 | 数字 | 继续数字 | 2 |
| 2 | 其他 | 产出 NUM | 0 |
| 3 | / | 行注释 | 4 |
| 3 | * | 块注释 | 5 |
| 3 | 其他 | 产出除号 | 0 |
| 5 | *后接/ | 结束块注释 | 0 |
这张表的价值不在“好看”,而在答辩时能解释清楚为什么<=不会被拆、为什么注释不会吞掉下一行代码。手写扫描器和正则表不是互斥的,常见做法是先用正则表把 token 流跑通,再把手写扫描器作为报告里的主实现,两边用同一组测试用例对齐输出。对比维度可以按下面这张表写进实验报告:
| 维度 | 手写扫描器 | 正则表/DFA |
|---|---|---|
| 代码量 | 分支多,但每段短 | 规则集中,引擎代码少 |
| 可调试性 | 断点直接停在分支上 | 需要打印规则命中顺序 |
| 报告画图 | 状态转移图自然 | 需要把正则翻译成状态表 |
| 错误恢复 | 可精确控制跳过范围 | 默认遇错即停,需额外包装 |
| 性能 | 一次扫描,常数小 | 规则多时有回溯风险 |
2.3 参数怎么定:前瞻字符、关键字表、错误 token 和测试快照
词法分析的可调参数不多,但每个都影响后面两层。前瞻字符k一般取 1 就够,因为课程设计语言的双字符运算符最多两个字符;如果语言里有>>=、...这类三字符符号,才需要k=2。关键字表建议用set而不是长串if,查找是 O(1),也方便在报告里列成表格。错误 token 策略要提前定:是遇到非法字符立即抛异常,还是收集错误后继续扫描。课程设计验收通常更看重“报错位置准确”和“不无限递归”,立即抛异常更容易保证这两点;如果报告里要写错误恢复,可以在注释未闭合、非法字符两类错误上做局部跳过,但不要吞掉整行。
测试快照是词法层最值得保留的习惯。把每个测试用例的 token 序列输出成文本,和源码放在一起,改扫描器后先 diff 快照,再跑语法分析。常见做法是用--dump-tokens参数把kind value line:col逐行打印,测试脚本对比期望文件。这样做的成本很低,但能挡住大部分“改一个分支崩一片”的回归。参数上,快照里要包含EOF,否则文件末尾少一个 token 的 bug 很难发现;行号列号也要纳入对比,不然错误定位会悄悄退化。
3. 语法分析:递归下降、LL(1) 和 LR(1) 的课程设计取舍
词法层稳定后
本文还有配套的精品资源,点击获取