多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

编译原理课程设计:从词法分析到小型编译器实现指南

编译原理课程设计:从词法分析到小型编译器实现指南 简介面向编译原理课程设计与实验报告撰写的完整资料包适合计算机专业本科生完成词法分析、语法分析及小型编译器的课程实践也可用于自顶向下与自底向下分析方法的对比学习。资源以C/C实现为主覆盖词法分析器、基于LL(1)方法的简单语句分析如ii*i、LR(0)与SLR(1)语法分析并延伸至四元式生成和汇编代码生成同时包含可参考的课程设计报告。共14个文件含4个cpp、3个c、3个h源文件以及3个txt文本和1个doc文档便于对照代码、注释和报告梳理从输入串到目标代码的完整链路。压缩包仅557KB轻量易下载已有2347人学习。若需快速搭建课设框架、理解分析表构造或补齐实验报告这套资料能提供较清晰的示例和实现思路。1. 编译原理课程设计从词法分析到小型编译器这门课到底要你做什么很多同学把编译原理实验当成“写代码最多、原理最抽象”的一门课其实它的核心任务非常具体把一串源代码字符流变成一棵可执行的中间表示再变成目标代码。说得再直白一点课设题目里的“词法分析”和“语法分析”不是两个独立的大作业而是构成“小型编译器”的前两个阶段。你真正要交付的不只是两个分析器而是一条能跑通的编译流水线源文件 → Token 流 → 语法树 → 中间代码或直接解释执行 → 输出结果。适合谁去认真做将来要做数据库、做静态分析、做脚本引擎、甚至做前端工具链的开发者这门课的底层思维会一直跟着你。常见做法是用 Java 或 Python 手写一个支持整数表达式、变量声明、赋值和打印语句的迷你语言再配套一份像样的实验报告。它能解决的问题也很明确让你在 1000 到 2000 行代码里把“形式语言”课本里那些晦涩概念变成能跑的玩意儿。2. 设计取舍先行先定语言子集和架构再动手写词法与语法2.1 语言子集为什么小型编译器必须“小”才有交付可能课程设计最常见翻车现场是一上来就想支持函数、数组、循环、结构体结果写了三千行还没跑到语法分析。我一般会建议先定义“最小的有用语言”只支持整数类型、变量声明、赋值、加减乘除、括号表达式、打印语句。为什么这样定因为每个语法特性都会同时冲击词法规则、文法产生式、符号表结构和中间代码生成多一个特性就多一层组合爆炸。真实编译器的语法分析器动辄上万行是因为它在处理几十种语句和表达式你的课设只有两周到一个月目标应该锁定在“完整跑通一条链”而不是“覆盖多少语法糖”。定义语言子集时建议直接写一个 BNF 草稿哪怕你还没学透 EBNF 也没关系。比如program { statement } ; statement print_stmt | assign_stmt | decl_stmt ; decl_stmt int IDENT [ expr ] ; ; assign_stmt IDENT expr ; ; print_stmt print expr ; ; expr term { ( | -) term } ; term factor { (* | /) factor } ; factor NUMBER | IDENT | ( expr ) ;这个文法一旦固定词法分析要产生哪些 Token 类型也就定死了关键字 int / print标识符 IDENT数字 NUMBER运算符 - * / ( )分号 ;。把语言子集写进实验报告是让答辩老师快速认可你设计能力的第一张牌。它能证明你不是在抄代码而是先做了形式化设计再落到实现。2.2 架构怎么分词法、语法、符号表各干各的小型编译器的模块划分我倾向于四个源文件lexer词法、parser语法、symbol_table符号表、main驱动和错误输出。别做成一个文件两千行排错会想骂人。词法分析器只负责“切词”把读到的字符流变成(类型, 值, 行号)三元组语法分析器只负责“看 Token 流、按产生式归约或推导”符号表由语法分析器在遇到声明语句时调用负责记录变量名和类型并在赋值语句时查询变量是否存在。高频的坑是有人把变量拼接进 Token 的字符串值里然后语法分析器又拿着字符串反复比较这叫“词法语法职责混乱”调试起来非常痛苦。如果你用的是 Java 做编译原理实验还可以顺手把异常分类做成LexException和SyntaxException分别表示词法错误非法字符和语法错误缺分号、表达式不完整。这样答辩演示时输入一行错误代码报错信息能精确定位到“第 2 行第 3 列 缺少分号”而不是一串 Java 堆栈印象分会差很多。我习惯在main里先实例化词法器让它一口气把整个源文件切完输出 Token 流到内存再交给语法分析器。这样简化了接口词法错误和语法错误能被清晰地分阶段报告实验报告也可以截两张图分别说明两个阶段的错误处理。2.3 实现语言选型Java 版课设和 Python 版各有什么代价热词搜索里“java编译原理”一直很高频说明很多学校指定 Java。用 Java 的好处是强类型和面向对象结构能让符号表、Token、节点类的设计更清晰IDE 调试能力也强代价是样板代码多一个TokenType枚举就要写半天。用 Python 则开发效率高、代码量少尤其适合快速跑通整个流程但如果你答辩现场要展示设计模式或类型体系Python 的动态类型会让某些优点不明显。如果老师没限定语言我的建议是你更熟哪个用哪个但最好别选自己不熟的 C指针和内存管理容易让你的精力从编译原理上挪走。纯粹为了讲清楚编译流程Python 最合适后面所有示例都用 Python 写你改用 Java 时只需把每个 Token 类映射为一个枚举值即可。3. 手写词法分析器状态机或正则核心是上下文无关的切词逻辑3.1 一个最小可跑的 Lexer代码量 150 行左右下面给出一个精简但完整的词法分析器针对上面定义的语言子集。它不依赖第三方正则库而是用手写状态判断因为课设答辩时老师最爱问“你的关键字是怎么从标识符里区分出来的”。# lexer.py import re # 仅用于字符分类不用于主切词逻辑 class TokenType: INT INT PRINT PRINT IDENT IDENT NUMBER NUMBER PLUS PLUS MINUS MINUS MUL MUL DIV DIV ASSIGN ASSIGN LPAREN LPAREN RPAREN RPAREN SEMI SEMI EOF EOF class Token: def __init__(self, type_, value, line, col): self.type type_ self.value value self.line line self.col col def __repr__(self): return fToken({self.type}, {self.value!r}, line{self.line}, col{self.col}) class Lexer: def __init__(self, source): self.source source self.pos 0 self.line 1 self.col 1 self.keywords {int: TokenType.INT, print: TokenType.PRINT} def peek(self, offset0): if self.pos offset len(self.source): return return self.source[self.pos offset] def advance(self): ch self.source[self.pos] self.pos 1 if ch \n: self.line 1 self.col 1 else: self.col 1 return ch def skip_whitespace_and_comments(self): while True: while self.peek() and self.peek().isspace(): self.advance() if self.peek() / and self.peek(1) /: while self.peek() and self.peek() ! \n: self.advance() else: break def read_identifier(self): result [] while self.peek() and (self.peek().isalnum() or self.peek() _): result.append(self.advance()) word .join(result) # 先查关键字表命中就返回关键字类型否则是标识符 if word in self.keywords: return Token(self.keywords[word], word, self.line, self.col - len(word)) return Token(TokenType.IDENT, word, self.line, self.col - len(word)) def read_number(self): result [] while self.peek() and self.peek().isdigit(): result.append(self.advance()) num_str .join(result) return Token(TokenType.NUMBER, int(num_str), self.line, self.col - len(num_str)) def next_token(self): self.skip_whitespace_and_comments() if self.pos len(self.source): return Token(TokenType.EOF, , self.line, self.col) ch self.peek() if ch.isalpha() or ch _: return self.read_identifier() if ch.isdigit(): return self.read_number() single_map { : TokenType.PLUS, -: TokenType.MINUS, *: TokenType.MUL, /: TokenType.DIV, : TokenType.ASSIGN, (: TokenType.LPAREN, ): TokenType.RPAREN, ;: TokenType.SEMI, } if ch in single_map: self.advance() return Token(single_map[ch], ch, self.line, self.col - 1) raise ValueError(fLexError: 无法识别的字符 {ch} at line {self.line}, col {self.col}) def tokenize(self): tokens [] while True: tok self.next_token() tokens.append(tok) if tok.type TokenType.EOF: break return tokens逻辑分四块skip_whitespace_and_comments负责跳过空白和//行注释read_identifier先把字母数字下划线全部吞进来再查关键字表这一点很关键——如果你反过来先判断关键字再读标识符printx会被错误切成关键字print加上标识符xread_number只吞数字所以123abc会被切成数字123和标识符abc如果你想报错可以在这里加一个“数字后紧跟字母则抛异常”的规则next_token的顺序决定了多字符运算符如的扩展方式——先查单字符表再考虑双字符如果你将来要加必须在single_map查询前先判断peek(1)。3.2 为什么正则和状态机能切同一门语言的词法哪个更适合课设如果你上过编译原理理论课老师一定讲过从正则表达式到 NFA、再到 DFA 的最小化。但在课设里手写一个递归下降或循环状态判断的切词器往往比引入re库更可控。为什么正则库的匹配语义虽然强大但错误定位和位置追踪非常别扭——你很难从re.finditer的结果里精确还原 Token 的行号和列号而课设实验报告里恰恰要体现“错误定位能力”。我见过有的同学用一整条正则r\d|[a-zA-Z_]\w*|[\-*/();]做切词代码确实很短但一旦遇到非法字符比如正则直接跳过它报错信息只能写“出现未知字符”却指不出位置这在答辩时会很吃亏。当然如果课程项目本身指定了“用 flex 或 JLex 生成词法分析器”那你应该去写.l文件规则那属于另一个范畴。要不要做 DFA 最小化课设阶段一般不做除非报告里想多写一节。一个折中方案是正文展示确定性状态机的next_token实验报告的附录里画一个“标识符/数字/运算符/分隔符”的状态转换图这样既展示了理论功底也没增加太多代码量。3.3 切词阶段的符号表要不要建这里只需要单词的“身份”符号表在词法阶段可以先不管你只需要用TokenType.IDENT标记所有标识符和关键字关键字已通过查表被单独抽出。等语法分析器处理到int a;时再把变量名a及其类型INT存入符号表。如果你非要在词法阶段就把“a 是整型变量”记录下来会遇到前向引用问题int a;还没解析完你怎么知道a一定是个变量声明词法分析器是上下文无关的它看不到int关键字和分号之间的关系。把符号表推迟到语法分析阶段处理既符合编译原理教科书的分层设计也让错误恢复更简单声明语句里的类型错误如int 1a;其实在词法阶段就该报“非法标识符”但那属于字符层面的规则而重复声明变量、使用未声明变量必须等语法阶段才能查出来。4. 语法分析递归下降还是 LR(1)小型编译器我选递归下降4.1 为什么递归下降是课设的最优解语法分析器的常见选择有三个递归下降自顶向下、LR(1)自底向上、以及各种生成器Yacc、ANTLR、PLY。在“小型编译器 课程设计 实验报告”这个约束下我几乎无条件推荐手写递归下降。理由很直白它能让你每个非终结符对应一个 Python 函数代码结构与 BNF 一一对应答辩讲起来极其顺畅而 LR(1) 的分析表构建逻辑复杂手工构造容易错除非你要做一个能够处理二义性文法或复杂运算符优先级的工业级语法否则没必要。递归下降的经典问题有两个左递归和回溯。比如文法expr - expr term是左递归直接写成parse_expr会无限递归。解决办法是把左递归文法改写成右递归或迭代循环。对于expr - expr term | term可以改写为expr - term { ( | -) term }对应实现就是先parse_term再while看下一个 Token 是不是加号或减号。至于回溯小型语言里可以通过peek一两个 Token 来避免真正需要回溯的场景很少。常见的做法是让parse_statement根据当前 Token 类型直接分派int开头走声明解析IDENT开头走赋值解析print开头走打印解析分派逻辑就是一层if/elif。4.2 完整的递归下降 Parser从语句到表达式的优先级链这里给出和上面 Lexer 配套的 Parser。整个 parser 的核心思想是每个函数消费对应非终结符能产生的 Token并把结果构造成一棵简单的树。为了课设好展示我这里不做复杂的 AST 类层次而是用 Python 元组快速表示。# parser.py from lexer import TokenType class Parser: def __init__(self, tokens): self.tokens tokens self.pos 0 def peek(self, offset0): idx self.pos offset if idx len(self.tokens): return None return self.tokens[idx] def advance(self): tok self.tokens[self.pos] self.pos 1 return tok def expect(self, token_type): tok self.advance() if tok.type ! token_type: raise SyntaxError(fSyntaxError: 期望 {token_type}但得到 {tok.type}行 {tok.line}) return tok def parse(self): statements [] while self.peek().type ! TokenType.EOF: statements.append(self.parse_statement()) return (Program, statements) def parse_statement(self): tok self.peek() if tok.type TokenType.INT: return self.parse_declaration() elif tok.type TokenType.IDENT: return self.parse_assignment() elif tok.type TokenType.PRINT: return self.parse_print() else: raise SyntaxError(fSyntaxError: 语句以非法的 Token 开头 {tok.type}行 {tok.line}) def parse_declaration(self): self.expect(TokenType.INT) id_tok self.expect(TokenType.IDENT) # 这里要做的符号表登记会在第 4.3 节补上 if self.peek().type TokenType.ASSIGN: self.advance() expr self.parse_expr() else: expr None self.expect(TokenType.SEMI) return (DeclStmt, id_tok.value, expr) def parse_assignment(self): id_tok self.expect(TokenType.IDENT) self.expect(TokenType.ASSIGN) expr self.parse_expr() self.expect(TokenType.SEMI) return (AssignStmt, id_tok.value, expr) def parse_print(self): self.expect(TokenType.PRINT) expr self.parse_expr() self.expect(TokenType.SEMI) return (PrintStmt, expr) # 表达式优先级加/减 低于 乘/除 低于 括号和原子 def parse_expr(self): node self.parse_term() while self.peek().type in (TokenType.PLUS, TokenType.MINUS): op self.advance() right self.parse_term() node (BinOp, op.value, node, right) return node def parse_term(self): node self.parse_factor() while self.peek().type in (TokenType.MUL, TokenType.DIV): op self.advance() right self.parse_factor() node (BinOp, op.value, node, right) return node def parse_factor(self): tok self.peek() if tok.type TokenType.NUMBER: self.advance() return (Number, tok.value) if tok.type TokenType.IDENT: self.advance() return (Variable, tok.value) if tok.type TokenType.LPAREN: self.advance() node self.parse_expr() self.expect(TokenType.RPAREN) return (Paren, node) raise SyntaxError(fSyntaxError: 无法解析的因子 {tok.type}行 {tok.line})代码逻辑不复杂parse_expr和parse_term都采用“先解析一个子节点再循环判断后续运算符”的模式这就是左递归消除后对应的迭代实现。运算优先级靠的是函数嵌套顺序parse_expr调用parse_termparse_term调用parse_factor导致乘除法比加减法结合得更紧。如果你想扩展一元负号在parse_factor里加一个if tok.type TokenType.MINUS分支然后递归调用自身即可。展开成这样的元组结构而不是真正构造类对象是为了让你实验报告里的“结构打印函数”和“解释执行函数”都容易写如果嫌元组可读性差可以定义NumberNode、BinOpNode等类但要写好构造函数和__repr__。4.3 符号表与作用域课设里做好一个“全局变量表”就够了小型编译器通常只有全局作用域所以符号表不用做栈式多层作用域一个字典就能搞定{a: INT, b: INT}。但有两个细节必须处理否则解释执行阶段必炸第一声明语句里如果变量已经存在要报“重复声明”第二赋值语句和表达式里的变量必须先在符号表中查到否则报“未声明变量”。我在parse_declaration的注释里标注了登记位置实际实现可以这样补# 在声明解析中补上符号表登记 self.symbol_table {} ... if id_tok.value in self.symbol_table: raise SyntaxError(fSemanticError: 变量 {id_tok.value} 重复声明行 {id_tok.line}) self.symbol_table[id_tok.value] INT如果你希望报告里多一个亮点可以把符号表的 get 和 set 单独封装成类并记录每个变量的行号演示时报错信息就是“变量 x 未声明首次使用在第 3 行”这种更人性化的提示。作用域的关键点在于“声明时登记、使用时查询”这个动作要放在语法分析阶段而不是词法阶段。有些同学喜欢在parse_assignment里做自由发挥先查询符号表再解析右侧表达式这样能更快发现未声明变量。其实顺序无所谓只要保证在整条语句解析前后一致即可。5. 编译原理实验避坑手册解决 4 个让课设翻车的细节与排查方法编一个能跑通的小型编译器不难难的是跑挂之后不知道怎么查。这一章写四条最常见的“编译原理实验踩坑记录”都是实操中反复出现的。5.1 关键字被当成标识符为什么printx会被切开现象输入printx 5;词法分析器切出来两个 Tokenprint和x语法分析器误认为这是一个打印语句然后报错。原因切词时只要看到字母就打头读完整串再查关键字表printx整体查表不命中应该返回IDENT但一些同学把print识别放在read_identifier前面导致print先被摘走剩下x又被当标识符。解决把查关键字表放在读完完整标识符之后if word in key_words再返回关键字类型如果想让printx直接报错很多语言的词法规范要求标识符不能以关键字子串开头可以在read_identifier里判断“如果完整词前几个字符恰好是关键字但后面还有字母数字”则抛词法异常说“非法标识符”。两种策略都能接受但要写进报告的“错误处理”一节。5.2 空白和注释处理顺序//被当成除法现象输入a 5 // 2;词法分析器先切出5然后遇到/把/当成除号第二个/又报“未知字符”。原因skip_whitespace_and_comments只在每次next_token开头调用而单字符single_map也在next_token里两者顺序没协调好。解决切单字符运算符之前先执行skip_whitespace_and_comments并在其中判断peek() / and peek(1) /时继续跳过整行。切忌在single_map里单独处理/而不看第二个字符。调试时可以打印每个 Token 的前后位置一眼看出空白的消耗是否异常。5.3 表达式优先级写反2 3 * 4算成 20现象语法分析器把2 3 * 4分析成((2 3) * 4)。原因parse_expr直接循环用parse_expr递归解析右侧表达式而不是先解析 term。解决方案只有一条严格按“加减法函数调用乘除法函数乘除法函数调用因子函数”的层级写不要把乘除直接放到parse_expr的 while 循环里。如果你给每个 BinOp 节点打上op标记执行阶段的求值器只要按后序遍历累加乘除运算结果自然正确检查方法很简单跑一个测试样例再打印 AST 结构肉眼确认括号在 3 和 4 之间。5.4 不区分“未声明变量”和“语法错误”现象输入x 1;符号表里没有x语法分析器报错时抛出SyntaxError: 期望 ;或者干脆没有报错但解释执行阶段报“NameError”。原因解析赋值语句右侧表达式时你只验证了 Token 是否为 IDENT没有同时查符号表。解决在parse_factor里遇到 IDENT 时立即查一个self.symbol_table查不到就抛“SemanticError: 变量未声明”而不是等到执行阶段。这样错误能被报告得更早且实验报告可以写清楚“词法错误、语法错误、语义错误”三阶段分离的报错路径。6. 进阶技巧中间代码生成与实验报告撰写经验6.1 三地址码生成用最小代价扩大课设的完成度如果你已经跑通词法和语法还有两天时间最值得补的模块是“三地址码生成器”。它不复杂遍历语法树并生成类似t1 2 * 3、t2 t1 4的指令序列。代码量一百行以内但实验报告立刻多了“中间表示”这一节课程设计的深度马上不一样。如果你只想做解释执行也可以用另一个更讨巧的方案写一个eval_node(node)递归函数遇到Number返回整数遇到BinOp先递归求值左右子节点再计算遇到Variable从环境字典取值这样“直接解释执行”也算完成了小型编译器的语义部分。但答辩时老师大概率会问“你的中间代码在哪”所以三地址码最能打。下面给个简版def generate_ir(ast, symbol_table): ir [] tmp_ctr 0 def new_temp(): nonlocal tmp_ctr t ft{tmp_ctr} tmp_ctr 1 return t def emit_expr(node): if node[0] Number: return str(node[1]) if node[0] Variable: return node[1] if node[0] BinOp: left emit_expr(node[2]) right emit_expr(node[3]) t new_temp() ir.append(f{t} {left} {node[1]} {right}) return t for stmt in ast[1]: if stmt[0] PrintStmt: val emit_expr(stmt[1]) ir.append(fprint {val}) elif stmt[0] AssignStmt: val emit_expr(stmt[2]) ir.append(f{stmt[1]} {val}) elif stmt[0] DeclStmt: if stmt[2] is not None: val emit_expr(stmt[2]) ir.append(f{stmt[1]} {val}) return ir参数方面symbol_table在这里其实没有直接用到但保留它作为接口扩展点。如果你后续要做“类型检查”就在emit_expr里加入“取左值类型”的逻辑如果你学有余力还可以加一个简单的常量折叠当左右两个操作数都是Number时直接计算并返回结果字符串而不是生成一条临时指令。这样实验报告里又能多写一节“代码优化”。6.2 最后的诀窍测试样例怎么设计实验报告怎么写才能拿高分设计测试样例不要只给“三四个正常表达式”。要故意准备一个错误样例让词法分析器报“非法字符”再准备一个语法错误样例报告“缺少分号”再准备一个语义错误样例报告“变量未声明”然后分别截图。实验报告的主体结构建议分成七个部分需求分析、文法定义、系统设计含架构图和数据结构、核心代码说明、测试结果、问题与解决、总结。讲代码时不要粘贴全部源码只挑next_token、parse_expr和generate_ir三个函数分别说明它们对应课本上的有限状态自动机、递归下降知识、三地址码概念。报告里一定要附上“运行环境与命令行”python main.py test.src截图覆盖三条正常运行和三条错误运行。这样老师评价会瞬间上去。如果你想把课设做成“可以继续演进”的小项目可以在 README 里写清楚后续扩展计划加比较运算、加 if 语句、加 while 循环每个扩展对应改动哪个函数。我个人做这门课的血泪经验是能尽早把一条链跑通后面加特性才有底气最差的情况是某天半夜发现“词法和语法各写了一半、拼不起来”。所以我习惯从一个五行的源程序开始让print 3 4 * 2;先输出正确结果再一步步加变量和声明每加一个特性就回归一遍老测试用例。这个习惯帮我避免了很多次“大改引发旧功能崩坏”的翻车希望帮到你。本文还有配套的精品资源点击获取
返回列表