
简介东南大学编译原理课程配套的词法分析器实验报告面向正在学习编译原理或需要动手完成词法分析实验的本专科学生。资源包内含1个docx文档大小仅415KB文字内容完整便于直接查阅和参考。已有761人学习过这份内容。报告围绕C词法分析器设计与实现展开在实验原理上采用NFA→DFA→DFA0的转换过程针对关键词、界符、运算符、空白符等分别构造自动机再统一连接详细给出DFA与DFA0状态图以及token序列、关键词表、界符表、运算符表、id表和数字表等核心数据结构的定义与作用算法层面重点讲解了addToken函数和lexical词法分析流程包含按字符读取、状态切换、错误跳过与继续等关键处理逻辑并配有相应C代码片段。通过学习这份报告读者能系统掌握词法分析器的整体设计思路、自动机建模方法以及常见错误处理策略为后续语法分析及编译原理综合实验打下基础。1. 词法分析器为什么写了两小时还在和过不去调试词法分析器时最磨人的不是看不懂状态转移图而是你明明把a[i] 1丢进去换回来的却是一堆叫不上名字的 Token再仔细一查和的识别逻辑互相打架连个语法错误都报不对位置。东南大学《编译原理》这门课喜欢在课程第一个大实验就布置「词法分析器」它听起来像一个热身动作实际却是后面语法分析、语义分析全部环节的地基你造出来的 Token 流要是脏的后面每一个实验都会给你颜色看。这篇笔记并不是要把某份神秘报告默写一遍而是想讲清楚一件事怎么从教材里的正则表达式和 DFA走到一份能真正处理一整个 .c 文件、不会在和0x1F这类边界上翻车的最小词法分析器。文章会包含可直接抄作业的 C 状态机骨架、五条血泪排错记录以及一套不必依赖图形 IDE 的验证思路。读完之后你会有一个能跑、能改、能解释清楚每一个分支的产物——不管是交实验报告还是拿去做更复杂的编译前端这份代码都能当底子用。2. 从正则到 DFA词法分析器到底在分析什么2.1 三个输出对象Token、属性值和动作词法分析器不是简单地「切词」。教材里通常会说它接收字符流、输出 Token 流但你在实现时很快会发现Token 流只是一个接口契约真正被后方语法分析器消费的是三个东西的组合Token 类别、Token 的属性值、以及每个 Token 对应的源码位置。Token 类别是编译期定义的整数常量常见做法是用一个枚举或者KIND_前缀的宏。属性值通常是个联合体或std::variant用来装标识符的字符串、整数的字面值、浮点数的 double 值。位置信息才是这份实验报告能不能拿高分的分水岭语法分析器报错时要能说出「第 10 行第 5 列附近出问题」这得靠词法阶段记录line和column。我一般会把 Token 结构体设计成:enum class TokenKind : uint8_t { T_IDENT, T_KEYWORD, T_INT_LIT, T_FLOAT_LIT, T_STRING_LIT, T_OP_ASSIGN, T_OP_PLUS, T_OP_MINUS, T_OP_STAR, T_OP_SLASH, T_OP_EQ, T_OP_NE, T_OP_LT, T_OP_LE, T_OP_GT, T_OP_GE, T_LPAREN, T_RPAREN, T_LBRACE, T_RBRACE, T_SEMI, T_EOF, T_ERROR }; struct Token { TokenKind kind; std::string text; // 原始切片 int line; int col; int value; // 整数或枚举值, 标识符时无用 // 字符串字面量、浮点字面量可再加字段, 或者用 union };这里有一个编码经验kind用uint8_t而不是 int是为了给以后的语法分析器省内存如果实验规模小、不追求这点优化直接用int也完全没问题别把简单事情复杂化。位置信息单独字段而不是放进text里人工去数是为了后续错误恢复时可以直接打印。还有一个容易被新手忽视的点text保存原始切片是为了调试不能靠枚举类型反推出「原字符是什么」数字和标识符尤其依赖它。2.2 为什么非得用 DFA而不是一个正则库就够了很多第一次做实验的人会想到用std::regex或者调一个第三方正则库把标识符、整数、浮点数分别写一条 pattern然后循环匹配。这种做法在小玩具上很好看但一旦源文件到了几千行你会遇到三个问题速度慢、最长匹配语义模糊、错误恢复困难。先讲最长匹配。正则库在大多数语言里默认返回「最先匹配的」而不是「最长的」例如输入如果你先写了的 pattern库会优先给一个把留成下一个 token。这在语法上是两个合法 token但语义完全变了if (a b)被拆成if (a b)语法分析器会报一个让你摸不着头脑的错误。DFA 天然处理最长匹配同一时刻从状态 0 出发能沿着走到一个状态再沿走到接受态如果后面没有更多输入就回退到最后的接受位置。错误恢复则是更现实的坑。正则库面对这种非法字符通常只返回「匹配失败」你很难告诉用户「第 5 行第 3 列出现非法符号 」。而手写 DFA 时你可以把非法字符也当作一个转移到错误态的信号记录位置继续扫描后面的内容实现一个能同时报告多个错误的健壮词法器。这也是为什么东南大学、燕山大学、山东科技大学这类学校的编译原理实验普遍要求学生手写识别器而不是套正则。实验目的不是让你当正则工程师而是让你把第二章里「正规式→NFA→DFA→最小化」的算法亲手从纸张变成代码。你可以先不用实现自动化的子集构造算法但 NFA 的每一个状态转移都要能对应到代码里的一步条件判断。3. 手写一个最小可用词法分析器状态转移表与扫描循环3.1 边界符号处理用字符分类避免一锅粥我见过不少实验报告把 main 函数写成两三百行、每个分支if (c a || c b || …)的乱麻。这不是 DFA这是面条。状态机的骨架不应该体现在长长的if-else if里而应当体现在一个稳定的「字符分类」基座上把字符先归到几个大类数字、字母、空白、运算符、界符、引号、反斜杠、EOF再由分类结果驱动状态转移。先看字符分类函数这段代码是整个分析器的地基// 返回字符的类别供状态转移函数做一级判断 enum CharClass { CC_DIGIT, CC_LETTER, CC_UNDERSCORE, CC_WHITESPACE, CC_OPERATOR, // - * / ! CC_DELIM, // ( ) { } [ ] ; , CC_QUOTE, // 单引号和双引号 CC_DOT, // 小数点单独分类以区分成员访问和浮点数 CC_OTHER }; CharClass classifyChar(char c) { if (c 0 c 9) return CC_DIGIT; if ((c a c z) || (c A c Z)) return CC_LETTER; if (c _) return CC_UNDERSCORE; if (c || c \t || c \n || c \r) return CC_WHITESPACE; if (c || c - || c * || c / || c || c || c || c !) return CC_OPERATOR; if (c ( || c ) || c [ || c ] || c { || c } || c ; || c ,) return CC_DELIM; if (c \ || c ) return CC_QUOTE; if (c .) return CC_DOT; return CC_OTHER; }这个设计的核心思想是状态转移的第一级判断不需要关心你是还是只需要知道你是「运算符」这一族具体是哪个运算符留到第二级精确比较。这样以后要扩展、|、%之类的运算符只需要在classifyChar里改一行而不需要动整个扫描循环。3.2 扫描主循环最长匹配、行号列号与前瞻字符确定 DFA 等价实现的另一个思路是逐字符消费。我前面常踩的坑是「贪心向前读」——先 peek 一个字符再决定是否 consume。这种方式在做这种双字符运算符时很顺手但在处理字符串字面量和注释时非常容易读过头。我采用的实现方式是每个 token 的识别都由独立的「状态机函数」负责这些函数从当前位置开始消费字符确定 token 的终点后返回。所有函数共享一个统一的 peek / advance 接口能同时维护行号列号。这样比一张巨大的转移表直观得多也更贴合教材里 DFA 的「状态 当前角色」。先写一个扫描器主类#include cctype #include string #include vector #include cstdint #include iostream class Lexer { public: explicit Lexer(const std::string src) : src_(src), pos_(0), line_(1), col_(1) {} // 逐 token 扫描直到 EOF。遇到非法字符也构造一个 T_ERROR token继续扫描。 std::vectorToken tokenize() { std::vectorToken tokens; for (;;) { skipWhitespaceAndComments(); if (pos_ src_.size()) { tokens.push_back(makeToken(TokenKind::T_EOF, )); break; } Token t scanOneToken(); tokens.push_back(t); } return tokens; } private: std::string src_; size_t pos_; int line_; int col_; char peek(size_t ahead 0) const { size_t idx pos_ ahead; return idx src_.size() ? src_[idx] : \0; } char advance() { if (pos_ src_.size()) return \0; char c src_[pos_]; if (c \n) { line_; col_ 1; } else { col_; } return c; } Token makeToken(TokenKind kind, const std::string text, int value 0) { return Token{kind, text, line_, col_, value}; } void skipWhitespaceAndComments() { // 空白字符直接丢弃 // 行注释 // 到换行为止 块注释 /* */ 要处理嵌套或至少非嵌套 bool progress true; while (progress) { progress false; while (pos_ src_.size()) { char c peek(); if (c || c \t || c \n || c \r) { advance(); progress true; } else break; } if (pos_ 1 src_.size() peek() / peek(1) /) { while (pos_ src_.size() peek() ! \n) advance(); progress true; } else if (pos_ 1 src_.size() peek() / peek(1) *) { advance(); advance(); // 吃掉 / 和 * while (pos_ src_.size()) { if (peek() * peek(1) /) { advance(); advance(); // 吃掉 * 和 / break; } advance(); } progress true; } } } Token scanOneToken() { char c peek(); if (classifyChar(c) CC_DIGIT || (c . classifyChar(peek(1)) CC_DIGIT)) return scanNumber(); if (classifyChar(c) CC_LETTER || c _) return scanIdentOrKeyword(); if (classifyChar(c) CC_OPERATOR) return scanOperator(); if (classifyChar(c) CC_DELIM) { advance(); TokenKind kind delimKind(c); return makeToken(kind, std::string(1, c)); } if (classifyChar(c) CC_QUOTE) return scanString(); // 未知字符报错但跳过继续扫后面的 advance(); return makeToken(TokenKind::T_ERROR, std::string(1, c)); }这里有两个相当关键的细节。第一skipWhitespaceAndComments里我用了progress标记来避免在空文件或只有注释的文件里死循环。注释结束后可能又有空白所以外层套一层 while。第二scanOneToken判断的开始条件里出现了c . classifyChar(peek(1)) CC_DIGIT这是为了支持.5这种浮点写法——如果不做这个判断.会被当成分隔符或成员访问符浮点数识别就少了一种合法形态。把这些细节写进实验报告评阅老师一眼就能看出来你真正跑过测试。3.3 各类 token 的识别函数从数字到字符串的完整状态机接下来是最能体现「状态机」本质的三组函数。数字识别要同时处理十进制、十六进制、八进制、浮点数而且要支持1.5e10这种带指数的写法。标识符识别相对简单但要注意关键字的回填——先按标识符完成整个片段扫描再查关键字表把 kind 换成T_KEYWORD同时保留text原值。Token scanIdentOrKeyword() { int startLine line_, startCol col_; std::string text; while (pos_ src_.size() (classifyChar(peek()) CC_LETTER || classifyChar(peek()) CC_DIGIT || peek() _)) { text advance(); } // 查关键字表。这里用静态 map, 避免每次都重建 static const std::unordered_mapstd::string, TokenKind keywordMap { {if, TokenKind::T_KEYWORD}, {else, TokenKind::T_KEYWORD}, {while, TokenKind::T_KEYWORD}, {return, TokenKind::T_KEYWORD}, {int, TokenKind::T_KEYWORD}, {float, TokenKind::T_KEYWORD}, // 想支持更多 C 关键字就在这里补 }; auto it keywordMap.find(text); if (it ! keywordMap.end()) { Token t makeToken(it-second, text); t.line startLine; t.col startCol; return t; } Token t makeToken(TokenKind::T_IDENT, text); t.line startLine; t.col startCol; return t; }关键字表用static const std::unordered_map而不是每次新建是因为词法阶段每遇到一个标识符都要查一次表重建容器的高额开销会让整个分析器白瞎。这里的另一个坑是我发现不少同学喜欢先把if当作一个可用字符序列来处理——在读到 i 和 f 后直接认成关键字结果变量名ifx被错误地拆成ifx。正确做法永远是把最大片段读完再查表。数字识别器代码稍微长一点需要分派的状态也最多Token scanNumber() { int startLine line_, startCol col_; std::string text; bool isFloat false; // 十六进制 0x 开头 if (peek() 0 (peek(1) x || peek(1) X)) { text advance(); text advance(); while (pos_ src_.size() (isxdigit(peek()))) { text advance(); } if (text.size() 2) { // 单独一个 0x 后面没有数字非法 return makeToken(TokenKind::T_ERROR, text); } Token t makeToken(TokenKind::T_INT_LIT, text); t.line startLine; t.col startCol; return t; } // 整数部分 while (std::isdigit(peek())) { text advance(); } // 小数点: 后面要么有数字, 要么是费马式的 .5 if (peek() . std::isdigit(peek(1))) { isFloat true; text advance(); // . while (std::isdigit(peek())) text advance(); } // 指数部分 e/E, 紧接着可选的正负号和数字 if (peek() e || peek() E) { isFloat true; text advance(); if (peek() || peek() -) text advance(); if (!std::isdigit(peek())) { // 指数后面必须跟数字 return makeToken(TokenKind::T_ERROR, text); } while (std::isdigit(peek())) text advance(); } Token t makeToken(isFloat ? TokenKind::T_FLOAT_LIT : TokenKind::T_INT_LIT, text); t.line startLine; t.col startCol; return t; }这段代码有一个隐蔽的边界条件值得解释判断是否进入小数部分时我写的是peek() . std::isdigit(peek(1))。为什么后面必须紧跟数字因为如果输入是a[0].size()这种表达式.是成员访问运算符而不是小数点。如果这里无条件吞掉.就会把a[0]和size搅在一起。同样的原则适用于指数部分——1e-3合法1e是残缺字面量我把这种残缺情况显式标记为T_ERROR而不是默默当成两个 token1和e后者会让语法分析器报错时把原因算到变量名头上去。运算符识别强调最长匹配三目运算? :也可以顺带实现Token scanOperator() { char c advance(); std::string text(1, c); TokenKind kind singleCharOpKind(c); // 映射 - * / ! if (pos_ src_.size()) { char next peek(); // 双字符运算符 if ((c next ) || (c next ) || (c next ) || (c ! next )) { text advance(); if (c ) kind TokenKind::T_OP_LE; else if (c ) kind TokenKind::T_OP_GE; else if (c ) kind TokenKind::T_OP_EQ; else kind TokenKind::T_OP_NE; } // 三字符运算符 和 如果不支持也可以先不加 } Token t makeToken(kind, text); return t; }字符串字面量的状态机要处理转义序列和未闭合引号。真正的字符串支持放在这里会让代码膨胀不少但实验里头很多评分用例里只包含简单字符串。为了不让这份骨架伤害你我先给一个能正确追踪行号、支持基础转义、遇到未闭合引号时能报错的实现Token scanString() { int startLine line_, startCol col_; char quote advance(); // 单引号或双引号 std::string text; bool closed false; while (pos_ src_.size()) { char c advance(); if (c quote) { closed true; break; } if (c \\) { // 转义序列 \n \t \\ \ \ if (pos_ src_.size()) break; char esc advance(); switch (esc) { case n: text \n; break; case t: text \t; break; case \\: text \\; break; case \: text \; break; case : text ; break; default: text esc; break; } } else { text c; } } Token t makeToken(closed ? TokenKind::T_STRING_LIT : TokenKind::T_ERROR, text); t.line startLine; t.col startCol; return t; }字符串处理里最容易犯错的其实是行末结束的\与\r\n的交互。如果你按\r和\n分开处理一个反斜杠加回车会被误认为是跨行拼接。实际在 C 源码里\紧跟换行确实是合法的续行符但我倾向在词法阶段不处理续行把它留给预处理器。这样既能满足大部分实验要求也免得引入无限循环的隐患。3.4 实验报告里怎么写清状态转移一张表胜过千行代码代码写完之后不少同学直接复制到 Word 里交差这是很亏的做法。评分者往往没有耐心读完你每一行代码他们要看到的是「DFA 的建模过程」。最实用的方法是一张四列的状态转移表当前状态、输入字符类别、下一状态、动作输出 token 或继续循环。表格样例状态名称输入字符类别下一状态动作START字母 / 下划线IN_IDENT记录起始行号列号IN_IDENT字母 / 数字 / 下划线IN_IDENT继续消费追加字符IN_IDENT其他START查关键字表回退一个字符输出 IDENT/KWSTART数字IN_NUM_INT开始收集数字IN_NUM_INT数字IN_NUM_INT继续IN_NUM_INT.IN_NUM_FRAC进入小数部分IN_NUM_FRAC数字IN_NUM_FRAC继续IN_NUM_FRACe/EIN_NUM_EXP进入指数部分IN_NUM_FRAC其他START输出 FLOAT_LIT 并回退IN_IDENT其他START查表输出这张表格能直接画成状态图放进实验报告附录。我见过最聪明的做法是从代码里用脚本提取 token 序列拿一个专门构造的.c文件跑一遍再把状态转移表连同实际输出截图一起放上去。那份报告为什么拿高分因为它把「代码正确」这件事变成了「文本可复核」的证据而不是自己嘴上说正确。4. 词法分析器避坑实录5 个让进度条停摆的坑与排错顺序4.1 忽略空白与注释时死循环peek后没有advance现象程序跑起来后 CPU 占用拉满终端里光标一直闪烁永远不输出。用调试器打断一看卡在skipWhitespaceAndComments里某一行出不来。原因skipWhitespaceAndComments里写了while (peek() )但函数体忘记调用advance()。或者是对\r和\n只做了判断没做消费。一旦某个字符未消费外层扫描主循环下次进来还是同一个字符形成空转。这类死循环比非法输入更隐蔽因为报错时系统根本不会给你一个 token。解决在跳过空白时每匹配一个字符必须同步调用 advance。也可以加一个计数器设置总步数上限比如 100 万步后强制退出调试阶段能帮你在几秒钟内发现问题位置。把「每次循环必然推进位置指针」当成一个不变量来写代码。4.2 关键字与标识符混淆ifx被拆成ifx现象输入合法的变量名ifx_2输出变成了if关键字后面跟一个单独标识符x_2语法分析阶段报「表达式不能以关键字 if 开头」。原因识别if、else这些关键字时不少同学使用了「逐字符匹配」的朴素方法。例如在读到i时往前读两位如果等于if就立刻输出关键字没有检查f后面是否还有字母或数字。变量名恰好以if开头时就发生了误判。解决永远把整个标识符作为整体消耗完再查关键字表。上面 3.3 的扫描代码已经保证了这一点。如果你仍然想用状态图表示IN_IDENT 状态只有在遇到非字母、非数字、非下划线时才结束之后才查表。写完后应当把if、ifx、if9、_if四个用例全部跑一遍。4.3 双字符三元符号成对收割总被拆成和现象报错信息永远指向号处。仔细回看 token 流变成了加两条记录。原因运算符识别只消耗了一个字符就立刻输出没有检查下一个字符是否和当前字符构成双字符运算符。更隐蔽的是!后面跟时如果单独输出!语法分析器会把它当成逻辑非运算符。解决scanOperator里使用「看一眼」策略——先消耗第一个字符立刻peek(1)检查能否拼成、、、!能拼上就同时消耗第二个字符。实验报告里如果写了 DFA必须在运算符状态旁标注「双字符优先」的迁移条件。这个坑在评分测试里几乎必现。4.4 十六进制数与孤零零的0x0x后面没有数字时怎么办现象输入0x后跟一个变量名y比如0xy输出的是一个十六进制数 0x0 加标识符y或者直接报错说 illegal character。看起来小概率但真实代码里不少人写过0xy这种式子意图往往是0 * xy结果就是一股脑吞掉。原因扫描十六进制数时遇到0x就直接进入十六进制分支然后无条件循环收集[0-9a-fA-F]。但当后面跟的是yy不是合法的十六进制位应当立即收尾问题在于如果整个片段只有0x低于最小编码长度你不报错后面的字符其实是下一个 token 的开头。解决在 3.3 的代码里已经体现了——检查text.size() 2就返回 T_ERROR并且advance()只消费了0和x两个字符后续y不受影响。这个做法保证了对报错位置的精确指向。顺便提一句数字后紧跟字母也是很多 C 语言新手测试用例里的「非法得具有迷惑性」的东西比如123abc不要把它截断成123和abc直接报错更符合很多实验文档的语义。4.5 文件尾最后一个 token 丢失EOF的字符被吞掉现象一个以a;结尾的文件输出的最后一个 token 是a和;但是 T_EOF 没出现。或者反过来最后一个 token 的内容比期望少一个字符比如abc变成了ab。原因主循环里用peek()判断文件是否结束但peek()在下标越界时返回了\0而某些分支比如 scanIdentOrKeyword 的 while 条件把\0当成了合法字符往前推进了pos_导致最后一个字符被吞。解决在peek()返回\0时所有需要调用peek(1)的函数都要有提前退出机制。scanIdentOrKeyword的条件里虽然classifyChar(\0)返回CC_OTHER但如果用的是isalnum()这类 C 标准库函数传入\0在某些库中会返回真值。我的习惯是peek()越界时不要返回\0而是定义一个kEOFChar 0x01这种绝对不会出现在源码文件中的特殊标记并保证classifyChar不会把它当作可用字符。写完代码后用空文件、只有空白字符的文件、以运算符结尾的文件三类边界输入做测试。5. 不靠玄学的验证方法把 Token 流变成计算结果词法分析器光看输出 token 序列不能完全证明正确更扎实的验证方式是让它驱动一个极简表达式求值器直接消费 Token 流并计算出数值。这能让你的 token 类别错误在输出结果中立刻暴露——比如把识别成-在 Token 序列里可能看不出来但求值结果会差之千里。我平时会在实验框架里加一个约 30 行的递归下降求值器覆盖 - * / ( )和整数字面量// 在词法分析器返回的 token 流基础上做表达式求值 (仅演示用) int parseExpr(const std::vectorToken tokens, size_t idx); int parseTerm(const std::vectorToken tokens, size_t idx); int parseFactor(const std::vectorToken tokens, size_t idx); bool expect(const std::vectorToken tokens, size_t idx, TokenKind kind) { if (idx tokens.size() tokens[idx].kind kind) { idx; return true; } return false; } int parseFactor(const std::vectorToken tokens, size_t idx) { if (expect(tokens, idx, TokenKind::T_INT_LIT)) { return std::stoi(tokens[idx - 1].text); } if (expect(tokens, idx, TokenKind::T_LPAREN)) { int v parseExpr(tokens, idx); if (!expect(tokens, idx, TokenKind::T_RPAREN)) { throw std::runtime_error(missing )); } return v; } throw std::runtime_error(unexpected token); } int parseTerm(const std::vectorToken tokens, size_t idx) { int v parseFactor(tokens, idx); while (idx tokens.size() (tokens[idx].kind TokenKind::T_OP_STAR || tokens[idx].kind TokenKind::T_OP_SLASH)) { TokenKind op tokens[idx].kind; idx; int rhs parseFactor(tokens, idx); if (op TokenKind::T_OP_STAR) v * rhs; else { if (rhs 0) throw std::runtime_error(divide by zero); v / rhs; } } return v; } int parseExpr(const std::vectorToken tokens, size_t idx) { int v parseTerm(tokens, idx); while (idx tokens.size() (tokens[idx].kind TokenKind::T_OP_PLUS || tokens[idx].kind TokenKind::T_OP_MINUS)) { TokenKind op tokens[idx].kind; idx; int rhs parseTerm(tokens, idx); if (op TokenKind::T_OP_PLUS) v rhs; else v - rhs; } return v; }这个 mini 求值器本身就是一份不错的实验附录材料它说明你的 Token 流不只是「打印出来好看的」而是能被下游语法和语义环节真实消费的。你可以准备以下边界测试把它们做成一个test_cases.txt文件自动读取12*3期望输出 7(12)*3期望 910/4期望 2整数除法3*(21期望报告缺右括号5/0期望报告除零。任何一条的输出和预期不一致都能追溯到词法阶段的某一种错误分类。有一个细节求值器里我把 Token 位置信息丢掉了吗没有报错时可以打印tokens[idx].line和tokens[idx].col。这能反过来检验词法器记录的行号列号是否正确——毕竟你的实验报告里如果声称具备行号定位能力最好真的能指出2 )这个式子中右括号在文件的第几行第几列。在我做过的几个编译器项目里词法分析器往往是最被低估却很值得花一周时间打磨的模块。等你做到后面发现语法分析阶段报错时行号出现偏移再回头改词法器的位置追踪代价比一开始就做对应表要大得多。所以我的习惯是写完词法器之后先花半小时跑一套覆盖十六进制、空注释、双字符运算符、字符串转义、跨行块注释的回归用例全绿再进语法阶段这个习惯能让我后半夜少掉很多根头发。希望这篇笔记能帮你在东南大学的编译原理实验里少走几条弯路也让你手上这份实验报告不只是应付评分而是真正敢拿去给下一个项目当引擎用。希望帮到你。本文还有配套的精品资源点击获取