
简介东北大学秦皇岛分校2123121编译原理实验报告围绕词法分析程序的设计与实现展开适合正在学习编译原理或需完成课程设计的学生参考文档共1个doc文件压缩包总大小约230KB内容为非加密文字版报告便于复制与阅读。报告先从理论上梳理词法分析的基本概念与任务详细说明单词分为关键字、运算符、标识符、常数和界符五类并分别给出if、while、return加号、减号、等号以及分号、括号等典型实例帮助区分保留字与自定义标识符的差异。随后给出一个基于Java实现的词法分析实验源码展示使用Reader读取输入、用数组建立关键字表与界符表并设计alphaprocess、digitprocess、otherprocess三个方法分别处理字母开头、数字开头与其他字符最终按类别输出单词及其类型。实验部分还讨论了空格仅用于分隔单词以及如何过滤注释以缩小扫描范围使读者能完整理解从源代码到token识别再到打印输出的实现思路。已有206人学习该资料对想快速掌握词法分析器编写要点的同学会有直接帮助。1. 编译原理实验报告先分清词法分析和语法分析的边界很多人在写编译原理实验报告时第一反应是先把代码跑通最后补一份“实验目的、实验环境、实验结果”的流水账。结果答辩时被问一句“你的词法分析器怎么处理标识符和保留字”就卡住了。这事的根源在于没有把实验报告当成一份技术文档来写而是一份“交差说明”。真正有效的做法是以编译流程为主线把词法分析、语法分析各自要解决的问题、输入输出、错误处理讲清楚。哪怕代码只是几百行报告也能写得很厚实。这篇博文就以“东北大学秦皇岛分校-2123121编译原理实验报告”这类题目为对象拆解一份能拿得出手的实验报告该怎么组织以及背后的关键技术点。2. 词法分析实验从正则文法到可运行的分词代码2.1 词法分析的核心状态转换图怎么画词法分析的输入是源程序字符串输出是符号串Token。实验报告里最常见的问题是直接贴代码却不解释“为什么这么写”。其实词法分析器本质上是一个有限自动机而设计它的第一步是画状态转换图不是写代码。一个典型的状态转换图包含以下几个状态起始状态等待输入字符标识符状态接收字母或下划线开头后续可以是字母、数字、下划线数字状态接收数字支持整数和小数运算符状态处理 - * / !以及组合符号如 ! 注释状态处理//行注释和/* */块注释画完状态图后代码其实就是对这张图的直接翻译。比如标识符状态的逻辑可以写成if (isalpha(ch) || ch _) { while (isalnum(ch) || ch _) { append_to_buf(ch); ch getchar(); } // 查保留字表 if (is_keyword(buf)) return KEYWORD; else return IDENTIFIER; }这段代码的意图很清晰先收集一个完整的词素再判断它到底是保留字还是普通标识符。注意这里的append_to_buf是把字符追加到缓冲区getchar是读下一个字符。有一个细节当循环退出时ch已经是下一个字符了不能丢失它需要塞回输入流。实验报告里如果能写出这个回退处理老师一眼就能看出你理解超前搜索。2.2 一个最小C语言子集的词法分析器实现2.2.1 标识符与保留字的区分很多初学者会把保留字单独做成一个状态实际上没必要。更常见的做法是先按标识符规则识别出一个字符串然后查一张预先构造好的保留字表。这样做的好处是逻辑简单新增保留字只需要改表不需要改状态图。// 保留字表 const char *keywords[] {if, else, while, return, int, char}; TokenType classify(char *word) { for (int i 0; i sizeof(keywords)/sizeof(char*); i) { if (strcmp(word, keywords[i]) 0) return TOKEN_KEYWORD; } return TOKEN_IDENTIFIER; }这里的参数说明word是从输入中收集到的词素keywords是预定义的保留字数组。用线性查找法查表表短时性能不是问题。实验报告里可以提一句如果要求高性能可以把保留字表换成哈希表但实验通常不要求。另一个容易忽略的点是大小写。如果语言区分大小写If就不是保留字而是标识符。要在报告里写明你设计的语言的规则否则测试用例会暴露问题。2.2.2 数字常量和运算符的识别数字的识别要支持整数和浮点数。状态图里至少要有“小数点”状态。遇到数字后如果后面跟着.要再捕一位数字否则像1.2.3这样的输入应该在第二个点时报错。while (isdigit(ch)) { append_to_buf(ch); ch getchar(); } if (ch . isdigit(peek())) { append_to_buf(ch); ch getchar(); while (isdigit(ch)) { append_to_buf(ch); ch getchar(); } }注意peek()是指向前看一个字符但不消费它。这里的设计是只有当前是点且点的下一位是数字时才认为进入了小数状态。否则这个点应该是一个单独的运算符比如成员访问或者直接报错。实验报告里把这个边界条件写清楚比单纯贴代码更有说服力。运算符识别要注意贪婪匹配。比如遇到时不能立刻返回要看下一个字符是不是。如果是则是等于运算符如果不是则是赋值运算符。case : ch getchar(); if (ch ) return TOKEN_EQ; else { ungetc(ch, stdin); return TOKEN_ASSIGN; }这里用了ungetc把读多的字符退回输入流。如果没有这一句a b中的后面跟着空格问题不大但ab中第二个就会被丢掉。这个回退写入报告时可以附一句解释词法分析器必须维护一个或多个字符的前瞻缓冲。2.3 词法分析实验的测试用例设计实验报告里只贴几个成功用例很常见但真正体现工作量的是边界用例。我会在报告里列出这样一张表输入片段预期Token序列说明int a 10;KEYWORD(int) IDENTIFIER(a) ASSIGN NUMBER(10) SEMICOLON基本语句if (a10) return 1;KEYWORD(if) LPAREN IDENTIFIER(a) EQ NUMBER(10) RPAREN KEYWORD(return) NUMBER(1) SEMICOLON组合运算符1.2e3NUMBER(1200)指数形式是否支持需明确/* comment */ aIDENTIFIER(a)注释跳过abcERROR: 字符串未闭合错误恢复这张表的重点是每个用例都要对应一个具体的文法规则。比如1.2e3是否支持取决于你的词法规则怎么定义。如果支持就要在状态图里加入指数部分如果不支持报告里一定要写明“本实验不支持指数形式”。否则老师一问你的程序遇到1e3会怎样你可能会卡住。3. 语法分析实验递归下降与LL(1)文法的落地3.1 为什么实验里常选递归下降分析语法分析就是根据词法分析得到的Token序列构建语法树或判断是否符合文法。自制编译器实验通常有两种路线用yacc/bison这类生成器或者手写递归下降分析器。实验报告里我建议用手写递归下降。原因是递归下降代码结构清晰和文法一一对应报告里可以直接贴文法再贴代码评审很容易看懂。递归下降属于自顶向下分析法对应LL(1)文法。它的限制是文法不能有左递归也不能有公共左因子。很多同学的文法没有做这两个改造直接写代码就会陷入无限递归或回溯。3.2 从文法到代码消除左递归与提取左公因子3.2.1 表达式文法的改造过程假设我们要解析表达式一开始的文法可能是E - E T | T T - T * F | F F - ( E ) | id这个文法直接递归下降会死循环因为E - E T中E的产生式第一个符号还是E。常见的做法是改成右递归E - T E E - T E | ε T - F T T - * F T | ε F - ( E ) | id这里ε表示空串。改造后E和T用来处理运算符的嵌套。实验报告里要写清楚这一步不是简单的形式变换它影响了运算符的结合性和优先级。在E的产生式中以右递归方式出现所以abc会被解析成a(bc)这不符合左结合惯例。为了保持左结合递归下降代码中通常采用循环而不是递归来处理同一优先级运算符。这是实验报告里容易露怯的地方也是能拿分的地方。3.2.2 递归下降代码的框架一个标准递归下降分析器由一组函数组成每个非终结符对应一个函数。下面是E和E对应的实现// E - T E void parse_E() { parse_T(); parse_E_prime(); } // E - T E | ε void parse_E_prime() { if (lookahead TOKEN_PLUS) { match(TOKEN_PLUS); parse_T(); parse_E_prime(); } // 否则ε产生式直接返回 }这里的lookahead是当前Tokenmatch函数检查当前Token是否符合预期然后读取下一个Token。注意到parse_E_prime里如果当前Token不是就直接返回这对应着ε。这实际上是一个“预测”过程根据下一个Token决定走哪个产生式。这种写法最直观但正如前面所说它会把运算符变成右结合。如果要实现左结合可以将parse_E_prime改为循环形式void parse_E_prime() { while (lookahead TOKEN_PLUS) { match(TOKEN_PLUS); parse_T(); // 构建左结合语法树节点 } }两种写法在实验报告中都应该出现并解释差异。这才能体现你真理解了递归下降与文法变换的关系。3.3 出错处理实验报告里最容易被扣分的一点很多实验报告只写了“如果输入不符合文法程序输出error”。这太单薄了。编译器的错误处理有个基本原则一次解析尽量报告多个错误而不是遇到第一个错误就停止。在递归下降分析中一个简单有效的错误恢复策略是“恐慌模式”当发现不匹配时跳过若干个Token直到遇到一个同步标记如分号、右括号。示例如下void match(TokenType expected) { if (lookahead expected) { lookahead nextToken(); } else { fprintf(stderr, 行 %d: 期望 %s, 得到 %s\n, line, tokenName(expected), tokenName(lookahead)); // 跳到下一个同步标记 while (lookahead ! TOKEN_SEMI lookahead ! TOKEN_RBRACE lookahead ! TOKEN_EOF) { lookahead nextToken(); } } }这段代码的重点是报错信息包含了行号和期望Token类型。同步标记的选择是;和}因为语句级的错误可以在这两个位置收敛。实验报告里如果写到这个层面的设计已经超过大多数人。4. 实验报告的结果分析用表格和错误信息体现工作量4.1 错误定位信息的三个字段不少同学在提交的实验报告里放几张运行截图截图里只有红色报错文字没有结构化信息。我建议你在代码里把错误输出设计成三个字段错误类型、行号、期望内容与实际内容。例如[词法错误] 第 3 行: 无法识别的字符 [语法错误] 第 5 行: 期望 )实际是 ;这样的输出在报告里非常直观。对应代码中可以定义一个错误结构typedef struct { int line; ErrorType type; char message[128]; } CompileError;维护一个错误列表编译结束时统一输出。这比遇到错误就exit(1)高明得多。报告中可以论证真实的编译器会尽可能报告所有错误这样用户不用反复编译。4.2 测试样例结果表格怎么设计表格是实验报告中性价比最高的内容。不要只贴“测试结果与预期一致”这句话而是把每个测试用例的输入、预期输出、实际输出、是否通过列出来。下面是一个来自真实实验报告的表格示例编号输入代码片段预期动作实际结果说明01int x 5;声明变量x类型int生成符号表记录语法树节点成功构建通过02x 5.2;类型不匹配报错“不能将float赋值给int”通过03if(x0) return x;条件跳转指令生成BR指令和标签通过04while(x0) { xx1; }循环结构生成回边指令通过表格的每一行都要有“说明”列解释这个用例是为了验证哪个文法规则或哪个错误处理逻辑。这样报告的实验分析就不是流水账而是逐条对应需求。4.3 对比不同输入规模下的表现如果你的实验进度允许可以加一个简单的时间对比。比如构造一个包含100行、500行、1000行源文件的测试统计词法分析耗时、语法分析耗时。不要用太精确的时间只要展示趋势即可。源文件行数 词法分析耗时(ms) 语法分析耗时(ms) 100 1.2 2.1 500 5.8 10.3 1000 11.9 21.7这张小表能证明的不是程序多快而是你考虑了性能问题。在实验总结部分可以写一句由于词法分析采用逐字符扫描耗时随输入规模线性增长语法分析采用递归下降最坏情况下会退化为O(n^2)但实际测试基本接近线性。这种分析是实验报告里的加分项。5. 给实验报告加分的一个技巧符号表与作用域的简单实现5.1 符号表的数据结构选择符号表是用来记录变量、函数、类型等信息的结构。实验报告里最常见的实现是数组或链表。链表写起来快但查找较慢。更贴近真实项目的是哈希表。哈希函数可以用简单的字符串散列unsigned int hash(char *name, int table_size) { unsigned int h 0; while (*name) { h (h 5) - h *name; } return h % table_size; }这里h (h 5) - h相当于乘以31是经典的DJB2变体。参数table_size表大小的选择要在报告里说明太小会导致冲突频繁太大浪费空间。实验里取1024足够。符号表记录条目至少应该包含名字、类型、作用域层级、声明的行号。5.2 嵌套作用域的处理实验报告里如果能提到作用域嵌套就已经超出了基础要求。常见实现方式是维护一个作用域栈。进入一个块时压入一层作用域离开时弹出并删除该层内声明的所有符号。typedef struct Scope { SymbolEntry *table; struct Scope *parent; } Scope; SymbolEntry *lookup(Scope *current, char *name) { while (current ! NULL) { SymbolEntry *e find_scope(current-table, name); if (e ! NULL) return e; current current-parent; } return NULL; }这个lookup函数先查当前作用域查不到再向父作用域查。这就是词法作用域的实现。报告中可以放一个简单用例int a; void f() { int a; a 1; // 这里访问的是内层的a }如果符号表实现正确赋值语句应该找到最近声明的那个变量而不是全局a。你可以用打印地址或作用域ID来验证。在实验报告的“测试结果与分析”中写出这种用例能证明你不是只做了词法分析而是真的理解编译过程。5.3 怎么在报告里写符号表的测试最后落一个具体的可复现技巧在调试模式下编译程序打印出作用域栈和符号表内容。例如每次声明变量时输出[符号表] 在第4行声明变量 a类型 int作用域级别 1这些打印信息本身就是测试结果。报告里截取几段配合源代码片段读者就能明白程序的行为。注意不要贴大量日志选关键场景即可。这里有个小技巧把日志输出到文件而不是终端方便在报告里引用。命令行运行./compiler -debug test.c debug.log 21然后从debug.log中截取少量行放入报告。这样做的好处是老师会认为你做了完整的功能验证而不是只跑通了hello world。本文还有配套的精品资源点击获取