编译原理实验:手写词法分析器与语法分析器的完整避坑指南

发布时间:2026/10/3 13:05:28
编译原理实验:手写词法分析器与语法分析器的完整避坑指南 简介这是电子科技大学编译原理课程设计/课程作业的完整资料包专注于词法分析器和语法分析器的设计与实现面向正在学习编译器构造、需要完成类似实验的大学生。压缩包共8个文件其中两个Python源码文件分别承载词法分析与语法分析的核心逻辑另有文法定义、分词结果、解析树文本、错误报告和变量符号表等辅助文件完整覆盖从源代码读入到中间表示生成的主要环节整个压缩包仅4KB体量虽小但结构清晰便于逐文件研读。目前已有176人学习下载。借助这份资料可以直观学习如何利用正则表达式切分关键字、标识符、运算符、常量等词法单元以及如何依据上下文无关文法采用LL(1)或LR(1)策略构建抽象语法树同时错误报告和符号表文件也提供了排查语法错误、管理符号信息的实际思路对理解编译器前端工作流程、独立完成课程设计或准备考试都很有帮助。1. 别急着解压先搞清楚这份资源里的词法分析器和语法分析器在替你干什么期末前从课程平台拉下来一个“UESTC-编译原理-词法分析器-语法分析器.zip”解压后一屏的 .c、.h、实验文档和测试样例第一反应往往是懵的——这不是一个完整编译器而是把编译前端拆成两个核心实验的课程包词法分析器负责把源代码字符串切成带类型的Token流语法分析器负责按文法把Token流组装成语法树或分析树。这份资源能解决的痛点很具体让你在一学期内不依赖工具链手写出一个能识别一门迷你语言的编译器前端并为后续语义分析和中间代码生成留出接口。适合正在赶编译原理实验报告的学生也适合想补一遍前端底子的从业者。先把“词法”和“语法”这两层漏斗的位置摆正后面的代码才不会写着写着变成黑匣子。2. 词法分析器从字符流到Token流的第一个漏斗2.1 词法分析器在分析什么字符流到Token流的映射词法分析器做的事情本质是把源代码的裸字符序列char source[]变成一个带类型的Token数组。每个Token至少包含三样东西类型标识符、关键字、数字字面量、运算符、界符、词素lexeme即原始文本片段、以及位置信息行号和列号。位置信息看起来不起眼但后面语法报错全靠它定位我一般在一开始就把行列号设计进Token结构体里省得后面返工。教科书里词法分析的标准路线是“正则表达式 → NFA → DFA → 最小化DFA”但在课程实验里绝大多数同学不会真去构造状态转移表而是用switch/case或if/else写一个简化状态机。这条路完全可行核心要点就三个最长匹配、关键字优先、回退字符的处理。理解这三个点比背一遍子集构造算法更能在实验里救命。2.2 手写一个最小状态机词法分析器C语言可直接跑的版本我见过很多版本的课程代码结构都大同小异。下面这个是我按实验常见需求整理的最小实现覆盖标识符、关键字、整数、浮点数、四则运算符和括号足够支撑一个TINY语言前端。#include ctype.h #include stdio.h #include string.h #define MAX_LEXEME 64 typedef enum { TK_IDENT, TK_IF, TK_ELSE, TK_WHILE, TK_RETURN, TK_INT_LIT, TK_FLOAT_LIT, TK_PLUS, TK_MINUS, TK_STAR, TK_SLASH, TK_LPAREN, TK_RPAREN, TK_LBRACE, TK_RBRACE, TK_SEMI, TK_EOF, TK_ERROR } TokenType; typedef struct { TokenType type; char lexeme[MAX_LEXEME]; int line; int col; } Token; static const char *type_names[] { IDENT, IF, ELSE, WHILE, RETURN, INT_LIT, FLOAT_LIT, PLUS, MINUS, STAR, SLASH, LPAREN, RPAREN, LBRACE, RBRACE, SEMI, EOF, ERROR }; Token get_token(FILE *fp, int *line, int *col) { int c; Token tok; memset(tok, 0, sizeof(tok)); tok.line *line; tok.col *col; // 跳过空白与单行注释 for (;;) { c fgetc(fp); if (c \n) { (*line); *col 1; } else if (isspace(c)) { (*col); } else if (c / (c fgetc(fp)) /) { while (c ! \n c ! EOF) c fgetc(fp); if (c \n) { (*line); *col 1; } } else { ungetc(c, fp); break; } if (c EOF) break; } c fgetc(fp); if (c EOF) { tok.type TK_EOF; strcpy(tok.lexeme, EOF); return tok; } (*col); // 标识符与关键字先完整读入再查关键字表 if (isalpha(c) || c _) { int i 0; while ((isalnum(c) || c _) i MAX_LEXEME - 1) { tok.lexeme[i] (char)c; c fgetc(fp); (*col); } ungetc(c, fp); (*col)--; tok.lexeme[i] \0; if (strcmp(tok.lexeme, if) 0) tok.type TK_IF; else if (strcmp(tok.lexeme, else) 0) tok.type TK_ELSE; else if (strcmp(tok.lexeme, while) 0) tok.type TK_WHILE; else if (strcmp(tok.lexeme, return) 0) tok.type TK_RETURN; else tok.type TK_IDENT; return tok; } // 数字字面量整数与浮点数合并识别 if (isdigit(c)) { int i 0, is_float 0; while (isdigit(c) i MAX_LEXEME - 1) { tok.lexeme[i] (char)c; c fgetc(fp); (*col); } if (c .) { is_float 1; tok.lexeme[i] (char)c; c fgetc(fp); (*col); while (isdigit(c) i MAX_LEXEME - 1) { tok.lexeme[i] (char)c; c fgetc(fp); (*col); } } ungetc(c, fp); (*col)--; tok.lexeme[i] \0; tok.type is_float ? TK_FLOAT_LIT : TK_INT_LIT; return tok; } // 单字符运算符与界符 switch (c) { case : tok.type TK_PLUS; strcpy(tok.lexeme, ); break; case -: tok.type TK_MINUS; strcpy(tok.lexeme, -); break; case *: tok.type TK_STAR; strcpy(tok.lexeme, *); break; case /: tok.type TK_SLASH; strcpy(tok.lexeme, /); break; case (: tok.type TK_LPAREN; strcpy(tok.lexeme, (); break; case ): tok.type TK_RPAREN; strcpy(tok.lexeme, )); break; case {: tok.type TK_LBRACE; strcpy(tok.lexeme, {); break; case }: tok.type TK_RBRACE; strcpy(tok.lexeme, }); break; case ;: tok.type TK_SEMI; strcpy(tok.lexeme, ;); break; default: tok.type TK_ERROR; tok.lexeme[0] (char)c; tok.lexeme[1] \0; break; } return tok; }这段代码的逻辑重心有两个。第一是“先完整读入再查关键字”进入字母分支后循环把整个标识符吞完再统一用strcmp比较关键字表。这样的顺序保证了ifVar被识别成一个标识符而不是被拆成关键字if加标识符Var。第二是ungetc(c, fp)回退当数字后面跟着的不是数字或小数点时把多读的那个字符放回流中让下一次get_token重新处理。回退时同时要(*col)--否则列号会越报越偏。MAX_LEXEME设成64是针对课程实验里没有超长标识符的保守值。如果实验题规定标识符最长32可以直接改小但注意循环条件是i MAX_LEXEME - 1留了一个位置给结尾的\0这是新手最容易踩的缓冲区溢出点。另一个细节是浮点识别这里只处理了123.456这种形态没有处理.5这种省略整数部分的写法实验要求如果明确要支持需要在isdigit(c)分支前面再加一个c .的判断。2.3 用Flex生成词法分析器另一种常见做法手写状态机虽然直观但遇到需要识别几十种正则模式时代码会膨胀得很厉害。课程实验里很多同学会改用Flex实验包通常也会允许这种提交方式。一个典型的lex.l片段长这样%{ #include tokens.h %} %option noyywrap %% if { return IF; } else { return ELSE; } while { return WHILE; } return { return RETURN; } [a-zA-Z_][a-zA-Z0-9_]* { return IDENT; } [0-9](.[0-9]*)? { return NUM; } { return PLUS; } - { return MINUS; } /* { /* 处理多行注释 */ } //.* ; /* 单行注释直接丢弃 */ [ \t\n] ; /* 空白直接丢弃 */ . { fprintf(stderr, lex error at line %d\n, yylineno); } %%Flex的好处是规则和动作分离调试时改一条规则比改一整个switch省事。代价是你得额外搞清楚yylval、yylineno和头文件生成这些Flex特有的机制环境配置本身就要花掉半天。我的建议是如果实验报告里要求画出状态转换图就手写状态机方便对照如果只验收可执行程序和测试通过Flex更快。两条路在最终产物上没有本质区别Token接口设计成一样的就好。3. 语法分析器把Token流钉成语法树3.1 三种语法分析路线课程实验选哪种不翻车语法分析器的输入是词法分析器吐出的Token流输出是语法树。严格说语法树要表达“哪个产生式匹配了哪个输入串”常见实现有三种递归下降、LL(1)预测分析表、LR系列分析表。课程实验里最常见的是递归下降其次是LL(1)。递归下降的本质是为文法里每个非终结符写一个函数函数体里按产生式右侧逐个匹配终结符或调用其他非终结符函数。它的优点是代码结构和文法一一对应出错时在哪一行报错一目了然而且支持任意复杂的语义动作。缺点是左递归文法必须手工消除优先级和结合性要靠函数调用层级来表达。LL(1)预测分析表则把“下一个非终结符面对当前输入Token该选哪条产生式”预先算好运行时用一张二维表加一个符号栈驱动。它的优点是逻辑更接近课本但你需要手动求FIRST集和FOLLOW集表一旦算错运行时错误极其诡异调试起来比递归下降痛苦得多。LR系列的表达能力最强但课程设计课时的强度下不太现实状态簇、ACTION表、GOTO表一个实验报告就能写四十页。除非题目明确要求做SLR(1)分析表的生成器否则我不建议把这个列为第一目标。3.2 递归下降实现表达式文法消除左递归后的C代码表达式文法必须先消除左递归。以加减乘除为例标准写法是把左递归拆成循环expr - term expr_tail expr_tail - term expr_tail | - term expr_tail | ε term - factor term_tail term_tail - * factor term_tail | / factor term_tail | ε factor - ( expr ) | NUM | IDENT对应的C实现如下为了演示输出效果这里在归约时打印后缀式指令方便肉眼验证语法树结构。typedef struct { Token lookahead; int eof_flag; } Parser; void advance(Parser *p) { if (!p-eof_flag) { *p get_token(...); if (p-lookahead.type TK_EOF) p-eof_flag 1; } } int accept(Parser *p, TokenType t) { if (p-lookahead.type t) { advance(p); return 1; } return 0; } int expect(Parser *p, TokenType t) { if (accept(p, t)) return 0; fprintf(stderr, line %d col %d: expected %s, got %s\n, p-lookahead.line, p-lookahead.col, type_names[t], type_names[p-lookahead.type]); return -1; } void parse_expr(Parser *p) { parse_term(p); while (p-lookahead.type TK_PLUS || p-lookahead.type TK_MINUS) { int op p-lookahead.type; advance(p); parse_term(p); printf(op TK_PLUS ? ADD\n : SUB\n); } } void parse_term(Parser *p) { parse_factor(p); while (p-lookahead.type TK_STAR || p-lookahead.type TK_SLASH) { int op p-lookahead.type; advance(p); parse_factor(p); printf(op TK_STAR ? MUL\n : DIV\n); } } void parse_factor(Parser *p) { if (accept(p, TK_INT_LIT) || accept(p, TK_FLOAT_LIT)) { printf(PUSH %s\n, p-lookahead.lexeme); } else if (accept(p, TK_IDENT)) { printf(PUSH IDENT %s\n, p-lookahead.lexeme); } else if (accept(p, TK_LPAREN)) { parse_expr(p); expect(p, TK_RPAREN); } else { fprintf(stderr, line %d: unexpected token %s\n, p-lookahead.line, type_names[p-lookahead.type]); } }代码里最关键的两个函数是accept和expect。accept负责“看当前Token是否匹配”匹配就消费掉并前移不匹配就返回0而不消费expect在accept失败时打印带行列号的错误信息并返回-1。所有语法错误都通过expect这一条路出来错误信息格式就统一了——这是比在每一层函数里各写一套报错更省心的做法。parse_expr里用while而不是if正是对应消除左递归后expr_tail的“零次或多次循环”。加减是左结合所以每次匹配到运算符后立即递归下降右侧的term并输出指令这个输出顺序天然就是左结合的后缀式。如果把while改成if同级的和-只能识别一个第二个运算符会直接触发错误分支。3.3 如果非要走LL(1)表格路线FIRST、FOLLOW集和预测分析表有些实验题直接给定文法要求“构造LL(1)预测分析表”。这时候上面的递归下降代码不能直接用得先把FIRST和FOLLOW求对。对上面那个表达式文法两个集合长这样非终结符FIRSTFOLLOWexpr( NUM IDENT$ )expr_tail - ε$ )term( NUM IDENT - $ )term_tail* / ε - $ )factor( NUM IDENT* / - $ )预测分析表按“非终结符 × 终结符 → 产生式”填充。比如expr_tail遇到时选 term expr_tail遇到)或$时选ε。运行时维护符号栈栈顶是终结符就匹配输入栈顶是非终结符就查表。这个方案一旦表算错错误表现为“期望某Token但栈顶不匹配”排查时只能一行行对照FIRST和FOLLOW的推导过程非常消磨耐心。我的建议是除非题目明确要求构造预测分析表否则用递归下降完成语法分析FIRST与FOLLOW集合只作为理论分析写在报告里就够了。4. 词法到语法联动接口定义清楚联调才不玄学4.1 接口设计让词法分析器成为语法分析器的数据源很多同学的词法分析器单独跑没问题语法分析器单独测试也没问题一联调就崩。根源是两边各写各的Token结构不统一或者语法分析器内部自己又封装了一遍词法逻辑。正确做法是先定接口再写实现词法分析器只暴露一个函数get_token每次调用返回下一个Token语法分析器只依赖这个函数不关心Token是从文件读的还是从字符串读的。typedef Token (*TokenProvider)(void *ctx);让语法分析器持有这个函数指针联调时词法器可以把文件句柄塞进ctx测试时也可以换成从内存字符串取Token的假实现。这样单元测试不用写临时文件直接喂字符串就能验证语法逻辑。4.2 主程序联调读文件、跑词法、跑语法一条龙下面这个主程序把前面两段代码串起来。typedef struct { FILE *fp; int line; int col; } LexerCtx; Token next_token(void *ctx) { LexerCtx *lc (LexerCtx *)ctx; return get_token(lc-fp, lc-line, lc-col); } void parse_program(Parser *p) { while (!p-eof_flag) { if (p-lookahead.type TK_ERROR) { fprintf(stderr, lex error: %s at line %d col %d\n, p-lookahead.lexeme, p-lookahead.line, p-lookahead.col); advance(p); continue; } if (p-lookahead.type TK_SEMI) { advance(p); continue; } parse_expr(p); if (expect(p, TK_SEMI) ! 0) { // 错误恢复跳到下一个分号再继续 while (!p-eof_flag p-lookahead.type ! TK_SEMI) advance(p); if (p-lookahead.type TK_SEMI) advance(p); } } } int main(int argc, char **argv) { if (argc 2) { fprintf(stderr, usage: %s source.txt\n, argv[0]); return 1; } FILE *fp fopen(argv[1], r); if (!fp) { perror(open failed); return 1; } LexerCtx lc {fp, 1, 1}; Parser p; memset(p, 0, sizeof(p)); p.lookahead next_token(lc); parse_program(p); fclose(fp); return 0; }这段代码示范了三个联调要点。第一LexerCtx把文件指针、行号、列号打包成一个上下文next_token把它转成TokenProvider接口语法分析器完全不需要知道底层是文件还是字符串。第二parse_program里对TK_ERROR做了容错词法错误打印后消费掉Token继续跑避免一错就停导致剩下全部是误报。第三语法错误后的恢复策略是“跳到下一个分号”这对应语句边界的同步是编译器教科书里说的panic mode恢复的简版用在课程实验里足够了。4.3 错误处理的收益行列号准确排错才不靠猜联调阶段最怕的是“错误信息给了但行列号对不上”你会花大量时间在代码里加打印来追踪Token到底读到了哪个位置。要避免这个问题词法阶段就要把line和col维护准确尤其是ungetc回退时列号要跟着回退注释跳过时换行要加行号。语法阶段报错统一走expect把“预期的Token类型”和“实际收到的Token类型”都打出来这两个信息组合起来基本能定位到文法哪条产生式写错了。很多课程实验的测试脚本是按输出文本比对判分的错误信息格式可能不算分但人眼排查依赖它。我在联调时习惯先写一个只有一条语句的最小文件比如a 1;跑通后再逐步加长。这个习惯看起来笨但能直接把词法层和语法层的问题隔离比一上来跑整个官方样例再回头猜哪里错了快得多。5. 编译原理实验避坑5个让分析器翻车的细节5.1 第一行就词法报错列号还跳了十几位现象输入文件第一行是if (a) return 1;词法分析器输出的错误却说unexpected character at line 1 col 14而且后面的Token全乱了。原因ungetc回退和列号不同步。常见写法是读完数字后ungetc(c, fp)但忘了(*col)--导致接下来每个Token的列号都比实际偏大几位。另一个更隐蔽的原因是空白跳过循环里isspace(c)和c \n两个分支同时命中时\n被isspace先吃掉列号没有重置为1。解决把isspace判断放到c \n之后并确保每个分支里line和col的维护都同步。建议写一个小函数advance_char统一处理列号增减而不是在每个分支里手动维护。5.2 关键字全变成IDENT或者ifVar被拆成if和Var现象输入里明明写了whileToken类型却是IDENT反过来变量名ifCount被报成关键字。原因两类问题其实是一个方向相反的bug。前者是词法循环在读标识符时遇到非字母数字就停但比较关键字时用的是截断后的字符串后者是边读边逐字符匹配关键字读ifCount时先撞上if就直接返回了关键字Token。解决统一“先完整读入整个标识符再整体查关键字表”的顺序。循环条件用isalnum(c) || c _吞完所有字符存进lexeme然后再用strcmp从头到尾比较。这样ifCount永远是一个IDENT而单独出现的if才会被识别成关键字。网上流传的清华大学出版社编译原理第三版第二章习题答案可以用来验证这些概念题但实验代码千万别照抄网上片段查重和逻辑陷阱都是坑。5.3 单行注释跳过之后下一行第一个字符神秘消失现象文件第一行是// comment\nint a;跳过注释后int前面的i字符丢了词法输出变成nt。原因注释判断的写法通常是c / (c fgetc(fp)) /这个表达式在遇到/后已经读走了下一个字符。如果下一个字符也是/则进入注释循环但如果下一个字符不是/比如是*或普通字符这个字符被读走之后没有ungetc回退就永远丢了。解决遇到/后不要立即吞掉下一个字符而是先c fgetc(fp)判断后再决定回退还是进入注释。同时注释循环里遇到\n要(*line)、*col 1否则注释后的代码行号全部错位。5.4 字符串里的分号、括号和//被当成普通代码处理现象样例里有一行print(hello; // world);词法分析器在分号处截断后面的//被当成注释导致整个字符串后面的代码全被吞了。原因状态机里没有字符串状态。所有字符一视同仁地走标识符、数字、符号分支字符串内部的字符自然就流到了错误的分支里。解决在词法主循环之前增加字符串分支。读到时进入in_string状态循环读取直到下一个未转义的为止中间遇到\n可以报“未闭合字符串”错误。字符串内容整体作为一个TK_STRINGToken返回内部的分号、注释符号、括号都不再参与词法分析。如果课程实验的语言不支持字符串这一步可以跳过但很多实验的print语句是带字符串的提前做了能少掉一大半测试错误。5.5 语法分析器死循环控制台一直没有输出现象输入一个语法错误文件后程序不退出不报错CPU占用率拉满。原因语法错误触发后accept返回0但没有消费Token而parse_expr的while循环又不会主动跳过当前Token。比如当前Token是TK_RPARENparse_factor里三个分支都匹配不上但也没有advance于是parse_expr的while在同一个Token上反复调用parse_term形成死循环。解决在每个“不匹配”分支里先打印错误再强制advance跳过当前Token。更稳妥的做法是4.2里演示过的错误恢复语法错误发生时循环跳过Token直到遇到分号或右大括号这类语句边界再恢复解析。这个机制实现起来不到二十行但能让分析器在任何输入下都保证终止这是实验判分时一个很重要的隐性指标。6. 验证三件套从复现官方样例到构造自己的测试用例拿到这份zip后先别急着改代码。我的验证习惯是准备三组文件第一组是官方给的合法样例用来确认基本流程通不通第二组是我自己构造的语法错误样例比如a ;、if (a { }、return 1缺分号用来验证报错信息和错误恢复第三组是边界样例包括空文件、只有注释的文件、超长标识符、连续多个运算符。三组都过再谈提交。一个很实用的验证技巧是给词法分析器加一个--dump-tokens命令行选项跑完把每个Token的类型、词素、行列号打印成表格。肉眼扫一遍Token序列比单步调试快得多。语法树的验证则用3.2里那种后缀式输出输入1 2 * 3输出应当是PUSH 1、PUSH 2、PUSH 3、MUL、ADD顺序不对就意味着优先级或左结合没实现对。最后再提一个我自己的习惯每次改完词法或语法代码先用同一个坏文件跑一遍确认报错行为没有退化。这个习惯不止一次帮我抓到了“改好一个分支、弄坏另一个分支”的回归问题。整个实验做完你会发现词法分析器和语法分析器看着是两栋楼地基却是同一套接口设计和错误处理思路。希望这些踩坑记录能帮你在ddl之前少走几段弯路把这份UESTC实验包真正变成自己的东西。本文还有配套的精品资源点击获取