ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

编译原理课程设计实战:从词法分析到目标代码生成的完整实现

编译原理课程设计实战:从词法分析到目标代码生成的完整实现

简介:这份资源是南京航空航天大学编译原理课程设计的完整实现包,面向计算机专业正在学习编译原理、需要完成课程设计的学生。内容覆盖词法分析与语法分析两大模块,包含可运行的源代码、头文件、工程文件以及课程设计报告和答辩演示文稿,能够帮助读者对照理解从字符扫描、记号识别到语法解析的完整流程,并参考报告中的设计思路与实现细节。压缩包共32个文件,以txt源码与说明、exe可执行程序、obj与pdb等编译中间文件、doc报告及ppt答辩材料为主,整体约961KB,体积轻便、结构清晰,便于按词法、语法两部分分模块查阅。资源经作者检验程序无BUG,已有750人学习下载,适合需要参考完整课设实现、对照调试与撰写报告的读者使用。

1. 编译原理课程设计:从词法分析到目标代码生成的完整链路

很多人对编译原理课程设计的印象停留在“画状态机、写正则、背 LL(1) 表”,真到动手时才发现,一个能跑通的最小编译器要同时处理词法切分、语法分析、语义检查、中间代码和目标代码生成五道关卡,任何一环的边界没想清楚,后面全是连锁翻车。这份课程设计资源围绕“编译原理 课程设计 计算机”这条主线,给出一套从源程序输入到汇编风格目标代码输出的完整实现骨架,适合正在做课程设计、需要一份可对照复现的参考实现,或者想补上“理论学过但没真正写过一个编译器”这块短板的人。它解决的不是“考试怎么算 FIRST 集”,而是“怎么把一套文法真正变成能跑的程序”。

2. 词法与语法分析:把源程序拆成可处理的记号流

2.1 词法分析器的状态机设计与关键字识别

词法分析是整个编译链路的第一道闸门,它的输出质量直接决定后续语法分析会不会收到一堆脏数据。这份资源里的词法分析器采用手写状态机而不是正则引擎,原因很实际:课程设计场景下,手写状态机便于在报告里画状态转换图,也便于在出错时定位到具体字符位置。核心思路是维护一个当前状态和已读字符缓冲区,遇到空白、界符、运算符时做一次“提交”,把缓冲区内容归类成标识符、关键字、常量或运算符。

下面是一个简化但可直接套用的词法扫描主循环,语言用 Python,方便对照调试:

# 词法扫描主循环:逐字符推进状态机 KEYWORDS = {"if", "else", "while", "int", "return", "void"} DELIMITERS = set("(){};,[]") OPERATORS = {"+", "-", "*", "/", "=", "==", "<", ">", "<=", ">=", "!="} def tokenize(src): tokens = [] i, n = 0, len(src) while i < n: ch = src[i] if ch.isspace(): i += 1 continue # 标识符或关键字:字母开头,后接字母数字下划线 if ch.isalpha() or ch == "_": start = i while i < n and (src[i].isalnum() or src[i] == "_"): i += 1 word = src[start:i] kind = "KEYWORD" if word in KEYWORDS else "IDENT" tokens.append((kind, word, start)) continue # 数字常量:这里只处理整数,浮点留作扩展 if ch.isdigit(): start = i while i < n and src[i].isdigit(): i += 1 tokens.append(("NUMBER", src[start:i], start)) continue # 双字符运算符优先匹配,避免把 == 拆成两个 = two = src[i:i+2] if two in OPERATORS: tokens.append(("OP", two, i)) i += 2 continue if ch in OPERATORS: tokens.append(("OP", ch, i)) i += 1 continue if ch in DELIMITERS: tokens.append(("DELIM", ch, i)) i += 1 continue raise SyntaxError(f"非法字符 {ch!r} 位于偏移 {i}") return tokens

这段代码的关键点有三个。第一,双字符运算符必须在单字符之前判断,否则==会被切成两个=,这是词法阶段最常见的翻车点。第二,每个 token 都带上起始偏移start,后面语法分析报错时能直接指回源码位置,报告里也好写。第三,关键字识别放在标识符分支内部完成,而不是单独开一条分支,这样状态机结构更紧凑。参数上,KEYWORDS集合要和文法里定义的保留字严格一致,多一个少一个都会导致后续语法分析对不上号。

2.2 递归下降语法分析与 FIRST/FOLLOW 集落地

语法分析部分资源采用的是递归下降而不是 LL(1) 预测分析表,这个选型值得说清楚。LL(1) 表在纸面上很优雅,但课程设计里一旦文法稍微复杂,构造表的代码量和调试成本会迅速超过手写递归下降。递归下降的每个非终结符对应一个函数,函数内部按产生式顺序尝试匹配,配合一个peek()看当前 token,逻辑直观,出错时调用栈本身就是一条推导路径,排查起来省事。

下面是非终结符stmt的一个递归下降实现片段,覆盖赋值语句和 if 语句两种产生式:

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", "", -1) def match(self, kind, value=None): tok = self.peek() if tok[0] != kind or (value is not None and tok[1] != value): raise SyntaxError(f"期望 {kind} {value},实际 {tok}") self.pos += 1 return tok def parse_stmt(self): tok = self.peek() # 赋值语句:IDENT = expr ; if tok[0] == "IDENT": name = self.match("IDENT")[1] self.match("OP", "=") expr = self.parse_expr() self.match("DELIM", ";") return ("assign", name, expr) # if 语句:if ( expr ) stmt [else stmt] if tok[0] == "KEYWORD" and tok[1] == "if": self.match("KEYWORD", "if") self.match("DELIM", "(") cond = self.parse_expr() self.match("DELIM", ")") then = self.parse_stmt() other = None nxt = self.peek() if nxt[0] == "KEYWORD" and nxt[1] == "else": self.match("KEYWORD", "else") other = self.parse_stmt() return ("if", cond, then, other) raise SyntaxError(f"无法识别的语句起始 {tok}")

peek()返回 EOF 标记这个细节别省,很多同学在文件末尾少写一个分号时,解析器直接抛 IndexError,报告里写“程序崩溃”很难看。match()把“期望什么、实际是什么”一起报出来,比只报“语法错误”有用得多。递归下降的边界在于左递归文法不能直接写,遇到expr -> expr + term这种必须先消除左递归,资源里的表达式解析就是按消除左递归后的形式组织的,这一点在改文法时务必先确认。

3. 语义分析与中间代码:让程序“讲得通”再“跑得动”

3.1 符号表组织与作用域检查

语法树建起来只说明结构合法,语义分析要回答的是“这个变量声明过吗、类型对得上吗、函数调用参数个数对吗”。资源里的符号表采用栈式作用域结构,每进入一个块压一层,离开时弹一层,查找时从栈顶往下扫。这种结构对课程设计足够用,也便于在报告里画作用域嵌套图。

class SymbolTable: def __init__(self): # 栈中每个元素是一个 dict,代表一层作用域 self.scopes = [{}] def enter_scope(self): self.scopes.append({}) def exit_scope(self): if len(self.scopes) == 1: raise RuntimeError("不能退出全局作用域") self.scopes.pop() def declare(self, name, typ): scope = self.scopes[-1] if name in scope: raise NameError(f"重复声明 {name}") scope[name] = typ def lookup(self, name): # 从最内层向外层查找,命中即返回 for scope in reversed(self.scopes): if name in scope: return scope[name] raise NameError(f"未声明标识符 {name}")

declare只在当前层查重,lookup才跨层查找,这个区分是作用域语义的核心。常见错误是把查重也写成跨层,结果内层块里声明一个和外层同名的变量被误判为重复声明。参数上,typ可以先用字符串表示(如"int"、"void"),等类型系统复杂了再换成类。语义分析阶段还要做类型相容检查,比如赋值号两边类型是否一致、条件表达式是否为整型,这些检查挂在语法树遍历的对应节点上,报错信息带上行号或偏移,和词法阶段保留的start对得上。

3.2 三地址码生成与临时变量管理

中间代码选三地址码是课程设计的稳妥选择,形式统一、便于后续优化、也便于在报告里展示。资源里的生成策略是后序遍历语法树,每个表达式节点返回一个“存放结果的位置”,可能是变量名,也可能是新分配的临时变量。临时变量用递增计数器管理,命名成t1、t2这种,避免和用户标识符冲突。

class TACGen: def __init__(self): self.code = [] # 三地址码指令列表 self.temp_count = 0 def new_temp(self): self.temp_count += 1 return f"t{self.temp_count}" def gen_expr(self, node): # 叶子节点直接返回名字或常量 if node[0] == "num": return str(node[1]) if node[0] == "var": return node[1] # 二元运算:先递归生成左右操作数,再发一条指令 if node[0] == "binop": left = self.gen_expr(node[2]) right = self.gen_expr(node[3]) tmp = self.new_temp() self.code.append(f"{tmp} = {left} {node[1]} {right}") return tmp raise ValueError(f"未知表达式节点 {node}") def gen_stmt(self, node): if node[0] == "assign": val = self.gen_expr(node[2]) self.code.append(f"{node[1]} = {val}") elif node[0] == "if": cond = self.gen_expr(node[1]) # 这里用标号占位,回填在完整实现里做 self.code.append(f"ifFalse {cond} goto L_else") self.gen_stmt(node[2]) self.code.append("goto L_end") self.code.append("L_else:") if node[3]: self.gen_stmt(node[3]) self.code.append("L_end:")

new_temp()的计数器只增不减,这是有意的:临时变量复用属于优化阶段的事,课程设计阶段先保证正确性。gen_expr返回的是“结果所在位置”而不是值本身,这个约定让嵌套表达式能自然拼接。标号回填是这里最容易出问题的地方,ifFalse后面的L_else在生成时还不知道具体位置,完整实现里要用一个待回填列表,等对应语句生成完再补上真实标号,资源里这部分用列表加二次遍历完成,别图省事直接写死字符串。

4. 目标代码生成与运行验证:从三地址码到可执行结果

4.1 寄存器分配简化策略与指令选择

目标代码生成阶段,资源没有做复杂的图着色寄存器分配,而是采用“每个临时变量分配一个栈槽、运算时加载到固定寄存器”的简化策略。这个选择对课程设计是合理的:图着色实现成本高、调试困难,而简化策略生成的代码虽然冗余,但正确性容易保证,报告里也能说清楚取舍。指令选择上,三地址码的每条二元运算映射成“加载左操作数、加载右操作数、运算、存回结果”四步。

def gen_target(tac_code): lines = [] # 简化策略:所有变量和临时量都在栈上,用 fp 偏移寻址 slot = {} next_slot = 0 def get_slot(name): nonlocal next_slot if name not in slot: slot[name] = next_slot next_slot += 4 # 假设每个槽 4 字节 return slot[name] for ins in tac_code: parts = ins.replace("=", " = ").split() # 形如 t1 = a + b if len(parts) == 5 and parts[3] in {"+", "-", "*", "/"}: dst, _, l, op, r = parts lines.append(f" lw t0, {get_slot(l)}(fp)") lines.append(f" lw t1, {get_slot(r)}(fp)") op_map = {"+": "add", "-": "sub", "*": "mul", "/": "div"} lines.append(f" {op_map[op]} t2, t0, t1") lines.append(f" sw t2, {get_slot(dst)}(fp)") # 形如 x = t1(简单赋值) elif len(parts) == 3 and parts[1] == "=": dst, _, src = parts lines.append(f" lw t0, {get_slot(src)}(fp)") lines.append(f" sw t0, {get_slot(dst)}(fp)") return lines

get_slot用字典做名字到偏移的映射,第一次遇到就分配新槽,这样变量和临时量统一处理,不用区分。op_map把三地址码的运算符映射到目标指令助记符,换目标架构时只改这一张表。注意这里假设了 4 字节槽宽,如果目标机字长不同要同步改,否则偏移会错位。生成的汇编风格代码可以直接对照模拟器或手工推演验证,资源里附带的验证方式是拿几个典型源程序跑一遍,比对三地址码和最终结果是否一致。

4.2 端到端验证:用三个测试程序卡住回归

验证环节别只跑一个a = 1 + 2就收工,资源建议至少准备三个层次的测试程序:纯赋值与算术、带 if-else 分支、带嵌套作用域和函数调用。每个程序记录期望的三地址码和期望输出,改任何一阶段代码后全量重跑。下面是一个带分支的测试输入示例:

# test_if.src int main() { int a = 3; int b = 5; if (a < b) { a = a + b; } else { b = b - a; } return a; }

跑通后重点看两处:ifFalse的跳转目标是否落在else分支起始,L_end是否在所有分支之后。这两处标号错位是目标代码生成阶段最高频的 bug,表现是程序不崩溃但结果莫名其妙,属于典型的玄学现象,靠单步对照三地址码才能揪出来。测试程序覆盖到位,后面改优化、改寄存器策略才有回归底气。

5. 避坑与排查:课程设计里最容易翻车的五件事

5.1 词法阶段把双字符运算符拆错

现象:a == b被解析成a、=、=、b,语法分析报“意外的 =”。原因:单字符运算符判断写在双字符之前,或者双字符匹配时没做长度优先。解决:扫描时先取src[i:i+2]查双字符表,命中就前进两位,未命中再查单字符表,顺序不能反。

5.2 递归下降遇到左递归直接栈溢出

现象:解析表达式时程序卡死或抛 RecursionError。原因:文法里写了expr -> expr + term这种左递归产生式,递归下降会无限自我调用。解决:先做左递归消除,改成expr -> term expr_tail、expr_tail -> + term expr_tail | ε的形式,再写对应函数。改文法前先拿纸推一遍 FIRST 集,别边写边试。

5.3 符号表作用域进出不配对

现象:内层块结束后,外层同名变量查不到,或者内层变量泄漏到外层。原因:enter_scope和exit_scope调用次数不匹配,常见于 if 分支里提前 return 或异常路径漏了退出。解决:把作用域进出包在 try/finally 里,或者用上下文管理器,保证异常路径也能弹栈。写完拿嵌套三层的测试程序验证查找结果。

5.4 临时变量命名和用户标识符撞车

现象:用户程序里声明了t1,生成的三地址码里临时变量也叫t1,结果互相覆盖。原因:临时变量命名没做隔离。解决:临时变量加不可能出现在源语言标识符里的前缀,比如%t1或_tmp1,或者在符号表里单独维护临时量命名空间,生成时统一走new_temp()。

5.5 标号回填漏掉分支导致跳转错位

现象:带 if-else 的程序结果对不上,但单步看每条指令都没错。原因:ifFalse的目标标号在生成时是占位符,回填阶段漏了某个分支,跳转落到了错误位置。解决:维护一个待回填列表,每条需要回填的指令记录“指令索引 + 标号名”,所有语句生成完后统一遍历回填,回填完再断言所有占位符都已被替换。

6. 进阶技巧:把课程设计从“能跑”推到“能讲清楚”

课程设计评分不只看程序能不能跑,还看你能不能把设计决策讲明白。这里有一个我踩过坑之后固定下来的习惯:每完成一个阶段,就在代码旁边留一段“决策记录”,写清楚为什么选这个方案、放弃了什么、边界在哪。比如词法阶段记一句“手写状态机而非正则引擎,因为需要保留字符偏移用于报错定位”;中间代码阶段记一句“三地址码而非语法树直接生成目标码,因为便于后续做常量折叠”。这些记录最后直接变成报告里的设计取舍章节,比事后回忆靠谱得多。

再往上一层,可以给编译器加一个--dump-tokens、--dump-tac的调试开关,每个阶段把中间结果打到标准输出。下面是一个简单的开关实现:

import sys def compile_source(src, dump_stage=None): tokens = tokenize(src) if dump_stage == "tokens": for t in tokens: print(t) parser = Parser(tokens) ast = parser.parse_program() gen = TACGen() gen.gen_stmt(ast) if dump_stage == "tac": for line in gen.code: print(line) return gen_target(gen.code) if __name__ == "__main__": src = open(sys.argv[1]).read() stage = sys.argv[2] if len(sys.argv) > 2 else None for asm in compile_source(src, stage): print(asm)

这个开关的价值在于,出问题时不用改代码加 print,直接python main.py test.src tac就能看到三地址码,对照期望输出定位是哪一阶段偏了。参数上dump_stage只认tokens和tac两个值,扩展时加分支即可,别做成万能字符串匹配,容易误触发。

还有一个容易被忽略的点:把三个测试程序的期望输出固化成文件,写一个对比脚本,每次改完跑一遍全量 diff。我一般会在提交前强制走一遍这个流程,因为编译器这种多阶段串联的东西,改词法可能影响语法,改语义可能影响代码生成,手工点几个用例根本盖不住。从那以后我每次动任何一阶段代码,都先把回归脚本跑绿再往下写,省下来的返工时间远比写脚本多。希望这份资源和你自己的实现能对得上,帮到你。

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

RELATED READING

延伸阅读

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