☰
北邮编译原理课程设计:从词法分析到目标代码生成的完整实现指南
2026/10/13 1:37:37 网站建设 项目流程

简介:这份资源是北京邮电大学编译原理课程设计的完整实践项目,面向正在学习编译原理、需要动手实现编译器的计算机专业学生。项目以Pascal语言为源语言,覆盖词法分析、语法分析、语义分析、代码生成及符号表管理、错误处理等核心环节,帮助读者把课堂理论落到可运行的编译器代码上。压缩包共83个文件,约123KB,以21个h头文件与19个cpp源文件为主体,另有8个pas测试用例、8个asm汇编文件、8个bin可执行文件及rez、rc等资源文件,并包含dsw、dsp、vcxproj等工程配置,结构完整、便于直接编译调试。目前已有1193人学习下载。读者可从中获得一套可参考的编译器实现框架、Pascal示例程序与汇编输出对照,以及递归下降解析、三地址码生成等关键环节的代码组织思路,适合作为课程设计参考或编译器入门练手项目。

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

北邮编译原理课程设计在计算机专业里算得上一道分水岭。很多人学完龙书、做完课后习题,真到要自己写一个能跑通源程序到汇编输出的编译器时,才发现理论跟工程之间隔着一条不小的沟。这个课程设计通常要求实现一个完整编译器的前端加部分后端,覆盖词法分析、语法分析、语义分析、中间代码生成和目标代码生成几个阶段,源语言一般是简化版 Pascal 或 C 的子集。它解决的核心问题是:让你亲手把正则表达式、上下文无关文法、语法制导翻译这些抽象概念变成能编译、能运行、能输出正确结果的代码。适合已经学过编译原理课程、需要完成课程设计或想通过一个完整项目巩固编译技术的人。下面按实际开发顺序,把每个阶段的实现路径、参数设置和踩坑点讲清楚。

2. 词法分析与语法分析:从正则到语法树的落地实现

2.1 词法分析器的状态机设计与实现

词法分析是编译器的第一道关卡,任务是把源程序的字符流切分成有意义的记号(Token)序列。常见做法是手写一个确定有限自动机(DFA),或者用词法分析生成器。北邮课程设计一般要求手写,因为这样才能真正理解正则表达式到 DFA 的转换过程。

我一般会先定义 Token 类型枚举,然后写一个nextToken()函数,每次从输入缓冲区读取字符,根据当前状态和输入字符决定下一步动作。核心逻辑是一个大的while循环加switch状态机。

# 词法分析器核心状态机(简化版) TOKEN_TYPES = ['ID', 'NUM', 'PLUS', 'MINUS', 'STAR', 'SLASH', 'LPAREN', 'RPAREN', 'ASSIGN', 'SEMI', 'EOF'] def next_token(self): # 跳过空白字符 while self.pos < len(self.src) and self.src[self.pos].isspace(): self.pos += 1 if self.pos >= len(self.src): return ('EOF', None) ch = self.src[self.pos] # 标识符或关键字:以字母开头,后跟字母或数字 if ch.isalpha(): start = self.pos while self.pos < len(self.src) and (self.src[self.pos].isalnum()): self.pos += 1 word = self.src[start:self.pos] if word in KEYWORDS: return (word.upper(), word) return ('ID', word) # 数字常量:连续数字 if ch.isdigit(): start = self.pos while self.pos < len(self.src) and self.src[self.pos].isdigit(): self.pos += 1 return ('NUM', int(self.src[start:self.pos])) # 运算符和分隔符:单字符匹配 single = {'+': 'PLUS', '-': 'MINUS', '*': 'STAR', '/': 'SLASH', '(': 'LPAREN', ')': 'RPAREN', ';': 'SEMI'} if ch in single: self.pos += 1 return (single[ch], ch) # 赋值符号 := 需要向前看一个字符 if ch == ':': if self.pos + 1 < len(self.src) and self.src[self.pos+1] == '=': self.pos += 2 return ('ASSIGN', ':=') raise LexError(f"非法字符 ':' 在位置 {self.pos}") raise LexError(f"无法识别的字符 '{ch}' 在位置 {self.pos}")

这段代码的关键点在于:标识符识别时用isalnum()而不是只判断字母,因为变量名可以包含数字;赋值符号:=需要向前看一个字符,这是最长匹配原则的体现;错误处理要给出位置信息,方便后续调试。参数方面,self.pos是当前读取位置,self.src是源程序字符串。实际项目中输入可能来自文件,需要把文件内容读成字符串再传入。

注意:关键字表要在初始化时建立,常见做法是用 Python 的set或哈希表,查找复杂度 O(1)。不要用列表做线性查找,否则关键字一多性能会明显下降。

2.2 递归下降语法分析与 AST 构建

语法分析阶段要把 Token 序列转换成抽象语法树(AST)。递归下降是最适合课程设计的方案,因为代码结构直接对应文法产生式,调试起来直观。北邮课程设计的文法通常包含表达式、赋值语句、if 语句、while 语句和复合语句。

先定义 AST 节点类型。我一般用类继承体系,每个节点一个类,或者用字典加type字段。类继承更清晰,但代码量大;字典方式更紧凑,适合快速原型。

# AST 节点定义(字典方式,紧凑) def make_node(node_type, **kwargs): return {'type': node_type, **kwargs} # 递归下降解析器核心片段 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', None) def consume(self, expected=None): tok = self.tokens[self.pos] if expected and tok[0] != expected: raise SyntaxError(f"期望 {expected},实际 {tok[0]} 在位置 {self.pos}") self.pos += 1 return tok def parse_expr(self): # 处理加减法,左结合 node = self.parse_term() while self.peek()[0] in ('PLUS', 'MINUS'): op = self.consume()[0] right = self.parse_term() node = make_node('BinOp', op=op, left=node, right=right) return node def parse_term(self): # 处理乘除法,优先级高于加减 node = self.parse_factor() while self.peek()[0] in ('STAR', 'SLASH'): op = self.consume()[0] right = self.parse_factor() node = make_node('BinOp', op=op, left=node, right=right) return node def parse_factor(self): tok = self.peek() if tok[0] == 'NUM': self.consume() return make_node('Num', value=tok[1]) elif tok[0] == 'ID': self.consume() return make_node('Var', name=tok[1]) elif tok[0] == 'LPAREN': self.consume('LPAREN') node = self.parse_expr() self.consume('RPAREN') return node raise SyntaxError(f"意外的记号 {tok} 在表达式位置")

递归下降的核心是每个非终结符对应一个函数,函数内部按照产生式右部依次调用。表达式处理要体现优先级:parse_expr处理加减,parse_term处理乘除,parse_factor处理括号和原子。左结合通过while循环实现,每次把已解析的节点作为左子树。参数方面,self.pos是当前 Token 索引,peek()不消耗 Token,consume()消耗并返回。错误信息要包含位置,方便定位。

提示:递归下降对左递归文法不直接支持,需要先消除左递归。课程设计里的表达式文法通常已经消除了左递归,如果遇到问题,检查文法产生式是否有A -> A α形式。

2.3 符号表的设计与作用域管理

符号表是语义分析的基石,用来记录变量、函数、类型的信息。北邮课程设计一般要求支持嵌套作用域,比如复合语句内部声明的变量只在该语句块内可见。

常见做法是用栈式符号表:进入作用域时压入一个新表,退出时弹出。每个表用字典实现,键是变量名,值是类型、偏移量等信息。

class SymbolTable: def __init__(self): # 栈式结构,每个元素是一个作用域字典 self.scopes = [{}] def enter_scope(self): self.scopes.append({}) def exit_scope(self): if len(self.scopes) > 1: self.scopes.pop() def declare(self, name, sym_type, extra=None): # 在当前作用域声明变量,重复声明报错 current = self.scopes[-1] if name in current: raise SemanticError(f"变量 '{name}' 重复声明") current[name] = {'type': sym_type, 'extra': extra or {}} def lookup(self, name): # 从内到外查找,实现作用域链 for scope in reversed(self.scopes): if name in scope: return scope[name] raise SemanticError(f"未声明的变量 '{name}'")

enter_scope和exit_scope在解析复合语句时成对调用。declare只在当前作用域检查重复,lookup从最内层往外找,这样内层可以遮蔽外层同名变量。参数sym_type可以是'int'、'bool'等,extra用来存偏移量、数组维度等附加信息。

注意:退出作用域时不要清空全局符号表,否则后续代码无法查找全局变量。栈底始终保留全局作用域。

2.4 语义检查的常见规则与实现

语义分析要在 AST 上做类型检查、变量声明检查、控制流检查。常见规则包括:赋值左右类型匹配、条件表达式必须是布尔类型、变量使用前必须声明、函数调用参数个数和类型匹配。

实现方式是在 AST 上做一次遍历,每个节点类型对应一个检查函数。比如BinOp节点检查左右子树类型是否一致,Assign节点检查左值是否可赋值、右值类型是否匹配。

def check_expr(node, symtab): if node['type'] == 'Num': return 'int' elif node['type'] == 'Var': sym = symtab.lookup(node['name']) return sym['type'] elif node['type'] == 'BinOp': left_type = check_expr(node['left'], symtab) right_type = check_expr(node['right'], symtab) if left_type != right_type: raise SemanticError(f"运算符 {node['op']} 两侧类型不一致") return left_type raise SemanticError(f"未知表达式类型 {node['type']}")

这段代码递归地推断表达式类型,遇到类型不匹配就报错。参数symtab是当前符号表,遍历过程中可能进入新作用域,需要在语句级别处理。

提示:语义错误要尽量给出具体位置和原因,不要只报「类型错误」。课程设计评分很看重错误信息的质量。

3. 中间代码与目标代码生成:三地址码到汇编的映射

3.1 三地址码的生成策略

中间代码是编译器前端和后端的桥梁。北邮课程设计通常要求生成三地址码,形式如x = y op z或x = y。生成方式是在 AST 遍历过程中,为每个表达式节点分配临时变量,把运算结果存进去。

我一般用一个TempGenerator管理临时变量编号,一个CodeList收集生成的三地址码指令。

class TempGenerator: def __init__(self): self.count = 0 def new_temp(self): self.count += 1 return f"t{self.count}" def gen_expr(node, symtab, temps, code): if node['type'] == 'Num': return str(node['value']) elif node['type'] == 'Var': return node['name'] elif node['type'] == 'BinOp': left = gen_expr(node['left'], symtab, temps, code) right = gen_expr(node['right'], symtab, temps, code) temp = temps.new_temp() op_map = {'PLUS': '+', 'MINUS': '-', 'STAR': '*', 'SLASH': '/'} code.append(f"{temp} = {left} {op_map[node['op']]} {right}") return temp

gen_expr返回表达式的「结果位置」——可能是常量、变量名或临时变量。BinOp节点先递归生成左右子树,再分配新临时变量存放运算结果,最后把指令追加到code列表。参数temps是临时变量生成器,code是输出指令列表。

注意:临时变量编号要全局唯一,不要在每个函数里重新计数,否则不同函数的临时变量会冲突。

3.2 从三地址码到 x86 汇编的翻译

目标代码生成把三地址码映射到具体汇编指令。课程设计一般要求生成 x86 或 MIPS 汇编。x86 更常见,因为可以在本机直接汇编运行。

核心思路是为每个临时变量和用户变量分配栈帧偏移,然后用mov、add、sub、imul、idiv等指令实现运算。

def gen_asm(code_list): asm = [] asm.append(".section .text") asm.append(".globl main") asm.append("main:") asm.append(" push %rbp") asm.append(" mov %rsp, %rbp") # 为变量分配栈空间,假设最多 100 个变量 asm.append(" sub $800, %rsp") offset = {} # 变量名到栈偏移的映射 next_offset = -8 def get_offset(name): nonlocal next_offset if name not in offset: offset[name] = next_offset next_offset -= 8 return offset[name] for instr in code_list: parts = instr.split() if len(parts) == 5 and parts[3] == '=': # 形式:t1 = a + b dest, _, left, op, right = parts d_off = get_offset(dest) l_off = get_offset(left) r_off = get_offset(right) asm.append(f" mov {l_off}(%rbp), %rax") if op == '+': asm.append(f" add {r_off}(%rbp), %rax") elif op == '-': asm.append(f" sub {r_off}(%rbp), %rax") elif op == '*': asm.append(f" imul {r_off}(%rbp), %rax") elif op == '/': asm.append(" cqto") asm.append(f" idivq {r_off}(%rbp)") asm.append(f" mov %rax, {d_off}(%rbp)") asm.append(" mov $0, %rax") asm.append(" leave") asm.append(" ret") return "\n".join(asm)

这段代码为每个变量分配 8 字节栈空间,用%rbp相对寻址。get_offset惰性分配偏移,第一次遇到变量时分配新槽位。加减乘直接对应指令,除法需要cqto扩展符号位再idivq。参数code_list是三地址码列表,输出是汇编文本。

提示:栈对齐在 x86-64 上很重要,sub $800, %rsp保证 16 字节对齐。如果变量超过 100 个,需要动态计算栈大小。

3.3 寄存器分配的基本思路

寄存器分配是目标代码生成的进阶话题。课程设计一般不做完整图着色,但可以做一个简单版本:把频繁使用的变量优先分配到寄存器,其余放栈上。

常见做法是统计每个变量在三地址码中的使用次数,按次数排序,前 N 个分配到%rbx、%r12、%r13等被调用者保存寄存器。其余变量仍用栈。

def count_usage(code_list): usage = {} for instr in code_list: for token in instr.replace('=', ' ').split(): if token and not token.isdigit() and token not in ('+', '-', '*', '/'): usage[token] = usage.get(token, 0) + 1 return usage def allocate_registers(usage, num_regs=3): sorted_vars = sorted(usage.items(), key=lambda x: -x[1]) regs = ['%rbx', '%r12', '%r13'] alloc = {} for i, (var, _) in enumerate(sorted_vars[:num_regs]): alloc[var] = regs[i] return alloc

count_usage统计每个标识符出现次数,allocate_registers取前 N 个分配到寄存器。实际生成汇编时,如果变量在alloc里就用寄存器名,否则用栈偏移。

注意:寄存器分配后要处理寄存器冲突,比如两个变量分到同一个寄存器。简单做法是只分配不冲突的变量,或者在使用前把寄存器值写回栈。

3.4 目标代码生成的验证方法

生成汇编后必须验证正确性。我一般分三步:先用gcc汇编链接,再运行可执行文件,最后对比预期输出。

# 假设生成的汇编保存在 output.s gcc -o output output.s ./output echo "退出码: $?"

如果程序输出不对,用gdb单步调试:

gdb ./output # 在 gdb 里: # break main # run # stepi # info registers # x/10x $rsp

stepi单步执行汇编指令,info registers查看寄存器值,x/10x $rsp查看栈内存。对比三地址码和汇编的每一步,能快速定位是哪个阶段出了问题。

提示:验证时先用最简单的程序,比如a := 1; b := 2; c := a + b;,确认基本路径正确后再测复杂控制流。

4. 避坑与排查:课程设计里最容易翻车的五个地方

4.1 词法分析器把关键字识别成标识符

现象:源程序里的if、while被当成普通变量名,语法分析时报「意外的 ID」。

原因:关键字表没有在标识符识别后做二次判断,或者关键字表初始化遗漏了某些关键字。

解决:在next_token里识别出字母序列后,立即查关键字表。关键字表用set初始化,包含所有保留字。测试时专门写一个只含关键字的源文件,确认每个关键字都返回正确 Token 类型。

4.2 递归下降解析器遇到左递归直接栈溢出

现象:解析表达式时程序崩溃,报RecursionError或栈溢出。

原因:文法产生式存在左递归,比如E -> E + T | T,递归下降会无限调用parse_expr。

解决:消除左递归,改成E -> T E',E' -> + T E' | ε。对应代码里用while循环处理+ T部分,而不是递归调用自身。

4.3 符号表作用域退出时误删全局变量

现象:复合语句结束后,全局变量查找失败,报「未声明」。

原因:exit_scope直接清空了整个符号表,而不是只弹出最内层。

解决:用栈式结构,exit_scope只pop最后一个元素,栈底全局作用域始终保留。测试时写一个嵌套复合语句,内层声明同名变量,退出后确认外层变量仍可访问。

4.4 三地址码临时变量编号冲突

现象:不同函数的临时变量都叫t1、t2,生成汇编时栈偏移冲突,运行结果错误。

原因:每个函数单独创建TempGenerator,编号从 0 开始。

解决:全局共享一个TempGenerator实例,或者给临时变量加函数前缀,比如func1_t1。最简单的是全局计数器,所有函数共用。

4.5 汇编栈对齐导致段错误

现象:生成的可执行文件运行时Segmentation fault。

原因:x86-64 调用函数时要求%rsp16 字节对齐,如果sub的栈空间不是 16 的倍数,调用printf等库函数会崩溃。

解决:分配栈空间时向上取整到 16 的倍数。比如需要 800 字节,实际分配 800(已经是 16 的倍数);如果需要 804 字节,分配 816。用(size + 15) & ~15计算对齐后的大小。

5. 进阶技巧:用测试用例驱动编译器开发

课程设计最怕写到一半发现前面阶段有 bug,回头改又影响后面。我的习惯是测试用例驱动:每个阶段先写测试,再写实现,确保当前阶段正确后再进入下一阶段。

具体做法是建一个tests/目录,每个阶段一个子目录。词法分析阶段放.src源文件和对应的.tokens期望输出;语法分析阶段放.src和.ast期望输出;代码生成阶段放.src和.expected期望运行结果。

# 测试驱动脚本(简化版) import subprocess, os def run_test(stage, src_file, expected_file): # 调用编译器对应阶段 result = subprocess.run( ['python', 'compiler.py', '--stage', stage, src_file], capture_output=True, text=True ) with open(expected_file) as f: expected = f.read().strip() actual = result.stdout.strip() if actual == expected: print(f"[PASS] {src_file}") else: print(f"[FAIL] {src_file}") print(f"期望:\n{expected}") print(f"实际:\n{actual}") # 遍历所有测试用例 for src in os.listdir('tests/lexer'): if src.endswith('.src'): base = src[:-4] run_test('lexer', f'tests/lexer/{src}', f'tests/lexer/{base}.tokens')

这个脚本用subprocess调用编译器,对比输出和期望。--stage参数控制只运行到某个阶段,方便隔离问题。测试用例要覆盖边界情况:空输入、只有注释、超长标识符、嵌套括号、除零错误等。

我一般会先写 10 个词法测试、10 个语法测试、5 个代码生成测试,全部通过后再写更复杂的集成测试。集成测试用完整程序,比如计算斐波那契数列、求最大公约数,验证端到端正确性。

提示:测试用例的期望输出要手工确认,不要用编译器自己的输出当期望,否则 bug 会被固化。

另一个实用技巧是给编译器加--dump-tokens、--dump-ast、--dump-ir开关,每个阶段输出中间结果。调试时逐阶段检查,比直接看最终汇编快得多。

python compiler.py --dump-tokens test.src python compiler.py --dump-ast test.src python compiler.py --dump-ir test.src python compiler.py test.src -o test.s

这些开关在开发期非常有用,交付时保留也不影响正常编译。我习惯在main函数里用argparse解析这些选项,每个阶段函数返回结果后根据开关决定是否打印。

最后说一个血泪经验:课程设计答辩时老师会问「如果源程序有语法错误,你的编译器怎么处理」。一定要在递归下降解析器里做好错误恢复,至少不要一报错就崩溃。常见做法是遇到错误时跳过 Token 直到遇到分号或右花括号,然后继续解析,这样能一次报告多个错误。我当年就是没做错误恢复,答辩时被问住了。希望帮到你。

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

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

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

立即咨询