ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零手写小型编译器:词法分析、语法分析与栈式虚拟机实战

从零手写小型编译器:词法分析、语法分析与栈式虚拟机实战 简介面向编译原理课程设计与实验的完整资料包涵盖词法分析、LL(1)语法分析、LR(0)与SLR(1)语法分析、四元式生成及汇编代码生成等环节并附带小型编译器与课程设计报告适合正在完成课程设计或需要对照实验流程的本科学生。压缩包共14个文件以cpp/c源程序、h头文件、txt文法说明和doc报告为主整体仅557KB便于直接查看示例文法与分析过程。已有2347人学习下载资源热度较高。内容包含可运行的词法分析器、基于LL(1)的简单语句分析如ii*i、LR(0)/SLR(1)语法分析程序以及对应的四元式与汇编代码生成实现附带的实验报告可作为撰写课程设计文档的参考帮助理解从源程序到中间代码再到目标代码的完整编译路径。1. 为什么值得做一个真正能跑的小型编译器哪怕把编译原理清华大学出版社第三版第二章的课后题答案背得滚瓜烂熟到了课程设计验收现场老师让你在白板上写一个把a b 2 * 3变成中间代码的流程你还是会愣住。编译原理这门课最坑的地方在于理论考试和动手做完全是两码事而课程设计恰好卡在中间。你需要的不是一个“能讲原理”的PPT而是一个能输入源代码、输出结果、能在答辩现场跑给你看的小型编译器。这篇文章就是按这个目标拆的从词法分析到语法分析再到中间代码生成和栈式虚拟机执行最后落到实验报告怎么写、答辩怎么不翻车。2. 先把整体架构定下来词法分析、语法分析与代码生成的边界2.1 词法分析手写状态机还是用 flex 生成器很多人的第一个冲动是用 flex 生成词法分析器因为学校课件里就是这么教的。但我建议课程设计一律手写状态机理由有三个。第一flex 生成的代码是一张巨大的跳转表你很难在实验报告里讲清楚“这个表是怎么来的”老师一问yylex()里某个分支对应哪条正则你就只能背答案。第二手写一个词法分析器也就两百行左右覆盖标识符、数字、关键字、运算符和分号工作量完全可控。第三也是最重要的状态机是后面语法分析、甚至自控原理里都会反复出现的思想手写一遍比看十遍课件都管用。手写状态机的思路是维护一个全局位置指针每次调用getToken()就跳过空白然后看当前字符属于哪一类——字母开头走标识符分支数字开头走数字分支运算符走符号分支。每一类分支内部用“读一个字符、判断、再读下一个”的方式决定一个 Token 的终点这比一次性写出完整正则更不容易出错。如果你的验收环境是 Java也可以把同样的逻辑用 Java 重写一遍核心代码结构完全一致只是把数组和指针换成String和索引。很多学校对语言没有硬性要求重点看你能不能把 DFA 的思想讲出来。2.2 语法分析选递归下降文法的坑与优先级处理语法分析有两个主流方向自顶向下的递归下降和自底向上的 LR 分析。课程设计我无脑推荐递归下降因为它的代码结构跟文法一一对应写起来像在“翻译”文法规则调试的时候哪里不对一眼就能看出来。LR 分析虽然功能更强但要手写状态栈和 ACTION/GOTO 表那个复杂度对课程设计来说纯属给自己挖坑。递归下降对应 LL(1) 文法这里会遇到一个教科书上反复讲、但做的时候一定会踩的坑左递归。比如表达式文法最简单的写法是E - E T | T这个文法直接翻译成递归下降函数就是死循环因为parseE()一进来又调用parseE()。解决办法是把左递归改写成右递归或者更实用一点——用循环来表达“同层运算的重复”。我一般会在报告里先写出改造前的文法再写改造后的让老师看到你是真理解了这个过程而不是直接抄了最终结果。另一个大坑是运算符优先级。1 2 * 3如果按照一个扁平的结构去解析得到的 AST 就是错的。递归下降解决优先级不靠别的就靠“把优先级拆成不同的层”表达式层调用项层项层调用因子层因子层才是数字、标识符和括号。层数越多优先级越高这个设计要在一开始就定好不然后面改会很痛苦。2.3 中间代码加栈式虚拟机避开直接生成机器码的深坑很多课程设计要求“小型编译器”但没有明确说要生成目标平台机器码。我见过的绝大多数通过方案都是编译到三地址码或者类汇编的中间表示然后用一个栈式虚拟机解释执行。这样做的好处是中间代码的设计和生成不依赖具体 CPU 架构栈式虚拟机的执行逻辑又简单到可以用一百行代码写完正好覆盖“代码生成”和“运行环境”两个验收点。三地址码的典型形式是t1 b * c、t2 a t1、a t2每条指令最多三个操作数语义非常干净。栈式虚拟机则把指令变成PUSH b、PUSH c、MUL、PUSH a、ADD、POP a这种形式执行器维护一个操作数栈遇到MUL就弹出两个数、相乘、再压回去。这个模型跟 JVM 的字节码执行模型是同构的写在报告里也能体现你对编译原理后续内容的了解。这里要提醒一句中间代码和栈式指令的映射关系建议在架构设计阶段就画成一张表比如“加法节点 → PUSH 左操作数 → PUSH 右操作数 → ADD”。不要到写代码的时候临时想因为两者之间有一层顺序反转不提前想清楚很容易写反第 5 章会专门讲这个坑。3. 词法分析器实现从字符流到 Token 流的关键代码3.1 Token 类型定义与符号表接口词法分析器的输出是 Token 流输入是一个 C 字符串。第一步是把“有哪些 Token”定清楚Token 类型定义直接决定后面所有模块的代码怎么写。typedef enum { TOK_ID, // 标识符 TOK_NUM, // 整数常量 TOK_ASSIGN, // TOK_PLUS, // TOK_MINUS, // - TOK_MUL, // * TOK_DIV, // / TOK_LPAREN, // ( TOK_RPAREN, // ) TOK_SEMI, // ; TOK_IF, // if TOK_ELSE, // else TOK_WHILE, // while TOK_INT, // int TOK_EOF, // 输入结束 TOK_ERROR // 无法识别的字符 } TokenType; typedef struct { TokenType type; char lexeme[64]; int line; } Token;TokenType里把if、else、while、int这些都直接列出来这样做的好处是语法分析阶段判断“当前是不是一个关键字”只需要比较枚举值不需要做字符串比较。lexeme字段保存的是这个 Token 对应的原始字符串比如变量名sum或者数字42后面符号表去重、中间代码生成打印变量名都需要它。line字段是给报错用的没有行号的编译器调试起来会让人崩溃。符号表这里先不用做得很复杂用一个简单的数组就行typedef struct { char name[64]; int value; } Symbol; Symbol symtab[128]; int symtab_count 0; int find_symbol(const char *name) { for (int i 0; i symtab_count; i) if (strcmp(symtab[i].name, name) 0) return i; return -1; } int add_symbol(const char *name) { int idx find_symbol(name); if (idx 0) return idx; strcpy(symtab[symtab_count].name, name); symtab[symtab_count].value 0; return symtab_count; }find_symbol和add_symbol组合起来就是“查不到就插入”的典型语义对应编译原理课本里符号表管理的基本操作。课程设计阶段不用上哈希表线性表足够说明问题。3.2 状态机里识别标识符、数字与关键字词法分析的核心逻辑全在getToken这个函数里。它的结构是跳过空白然后根据首字符的类型分发到不同的状态分支。Token getToken(const char *src, int *pos, int *line) { Token tok; memset(tok, 0, sizeof(tok)); while (src[*pos] || src[*pos] \t || src[*pos] \n) { if (src[*pos] \n) (*line); (*pos); } // 标识符或关键字 if (isalpha(src[*pos]) || src[*pos] _) { int start *pos; while (isalnum(src[*pos]) || src[*pos] _) (*pos); int len *pos - start; memcpy(tok.lexeme, src start, len); tok.lexeme[len] \0; tok.line *line; if (strcmp(tok.lexeme, if) 0) tok.type TOK_IF; else if (strcmp(tok.lexeme, else) 0) tok.type TOK_ELSE; else if (strcmp(tok.lexeme, while) 0) tok.type TOK_WHILE; else if (strcmp(tok.lexeme, int) 0) tok.type TOK_INT; else tok.type TOK_ID; return tok; } // 数字 if (isdigit(src[*pos])) { int start *pos; while (isdigit(src[*pos])) (*pos); int len *pos - start; memcpy(tok.lexeme, src start, len); tok.lexeme[len] \0; tok.type TOK_NUM; tok.line *line; return tok; } // 运算符与界符 switch (src[*pos]) { case : tok.type TOK_ASSIGN; (*pos); break; case : tok.type TOK_PLUS; (*pos); break; case -: tok.type TOK_MINUS; (*pos); break; case *: tok.type TOK_MUL; (*pos); break; case /: tok.type TOK_DIV; (*pos); break; case (: tok.type TOK_LPAREN; (*pos); break; case ): tok.type TOK_RPAREN; (*pos); break; case ;: tok.type TOK_SEMI; (*pos); break; case \0: tok.type TOK_EOF; break; default: tok.type TOK_ERROR; (*pos); break; } tok.line *line; return tok; }关键点在于标识符分支先用isalpha或_确认进入标识符状态然后一直吞掉字母数字下划线直到遇到非字符为止。这里有个细节while (isalnum(...))用的是“当前字符仍然合法”作为循环条件而不是“下一个字符非法”这个写法可以避免数组越界。数字分支同理只认十进制整数不做科学计数法——课程设计里没必要扩展这个。运算符分支是逐一匹配单个字符所以、这类复合运算符在这里是识别不了的。如果你的语法需要它们可以加一层“读下一个字符再判断”的逻辑比如遇到后多看一个字符如果是就返回TOK_EQ。但小型编译器的课程设计通常只要单字符运算符就够用。3.3 词法错误与行号定位先保证报错位置准确词法阶段的错误处理策略是“发现非法字符报错但不崩溃跳过它继续”。Token tok; do { tok getToken(src, pos, line); if (tok.type TOK_ERROR) { printf(词法错误第 %d 行存在无法识别的字符%s\n, line, tok.lexeme); } } while (tok.type TOK_ERROR); // 到这里 tok 是一个合法 Token交给语法分析这里要注意getToken里遇到TOK_ERROR时已经把pos往后推了一个字符所以外层循环不会卡死在同一个位置。如果把错误字符也输出到lexeme报告里就能展示“哪个字符出了问题”比只报行号体验好很多。还有一个容易被忽略的细节行号计数必须在getToken里维护而不是在外部用strchr去数换行符。因为跳过空白和识别标识符是两个独立的状态如果行号在外面统一统计一旦标识符里混入换行理论上不会但调试时人会犯错行号就乱套了。我一般会在调试时打印每个 Token 的line字段先确认行号没问题再往下做语法分析。4. 语法分析与表达式处理递归下降实现优先级和错误恢复4.1 AST 节点设计与递归下降入口语法分析的任务是把 Token 流变成抽象语法树AST。AST 节点先定义成一套简洁的结构节点类型不需要很复杂但要能区分“变量”“数字”“二元运算”“赋值”“if”“while”。typedef enum { NODE_NUM, // 数字常量 NODE_VAR, // 变量引用 NODE_BINOP, // 二元运算 NODE_ASSIGN, // 赋值语句 NODE_IF, // if 语句 NODE_WHILE, // while 语句 NODE_BLOCK // 语句块 } NodeType; typedef struct ASTNode { NodeType type; char op[4]; // 运算类型或赋值 char name[32]; // 变量名 int value; // 数字值 struct ASTNode *left; struct ASTNode *right; struct ASTNode *cond; // if/while 使用 struct ASTNode *next; // 语句块中的下一条语句 } ASTNode;这个结构体用了一个折中方案不搞一堆子类而是用left、right和cond这几个指针组合出不同类型的树。赋值语句用left指向左边的变量节点right指向右边的表达式if 语句用cond存条件、left存 then 分支、right存 else 分支while 用cond存条件、left存循环体。这样定义写起来省事但也要求你在每个处理函数里清楚地知道“这个节点类型用到了哪些字段”评讲时也能说清这个取舍。递归下降的入口是整个语法分析器的最高层对应文法里的程序结构ASTNode *parseProgram() { ASTNode *root NULL; ASTNode **current root; while (peek().type ! TOK_EOF) { ASTNode *stmt parseStatement(); if (stmt) { *current stmt; current stmt-next; } } return root; }current用二级指针串起语句链表是 C 语言里构建链表的常用做法。peek()函数只查看当前 Token 不消费它这样parseStatement可以根据当前 Token 的类型决定走哪个分支比如看到TOK_INT走变量声明看到TOK_ID走赋值语句看到TOK_IF走 if 解析看到TOK_WHILE走 while 解析。4.2 表达式解析乘除优先于加减的分层写法表达式是整个语法分析里最经典的部分。优先级处理靠的是函数分层不是查表。// 表达式处理加减左结合通过循环实现 ASTNode *parseExpr() { ASTNode *node parseTerm(); while (peek().type TOK_PLUS || peek().type TOK_MINUS) { Token op consume(); ASTNode *right parseTerm(); ASTNode *binop new_node(NODE_BINOP); binop-op[0] op.type TOK_PLUS ? : -; binop-left node; binop-right right; node binop; } return node; } // 项处理乘除 ASTNode *parseTerm() { ASTNode *node parseFactor(); while (peek().type TOK_MUL || peek().type TOK_DIV) { Token op consume(); ASTNode *right parseFactor(); ASTNode *binop new_node(NODE_BINOP); binop-op[0] op.type TOK_MUL ? * : /; binop-left node; binop-right right; node binop; } return node; } // 因子数字、变量、括号表达式 ASTNode *parseFactor() { Token tok consume(); if (tok.type TOK_NUM) { ASTNode *node new_node(NODE_NUM); node-value atoi(tok.lexeme); return node; } if (tok.type TOK_ID) { ASTNode *node new_node(NODE_VAR); strcpy(node-name, tok.lexeme); return node; } if (tok.type TOK_LPAREN) { ASTNode *node parseExpr(); if (consume().type ! TOK_RPAREN) { printf(语法错误缺少右括号\n); exit(1); } return node; } printf(语法错误无法识别的表达式起始符 %s\n, tok.lexeme); exit(1); }parseExpr调用parseTermparseTerm调用parseFactorparseFactor遇到括号时递归调用parseExpr这就是优先级和嵌套的完整闭环。左结合通过while循环实现先解析出一棵左子树遇到运算符再解析右子树然后合成一棵更大的左子树。这样1 2 3会解析成(1 2) 3而不是1 (2 3)两者的后续求值结果在整数运算下相同但 AST 结构是对的。这里值得单独说一个常见的改错如果你把while改成if那么1 2 3只消费第一个第二个会留在 Token 流里导致整个程序解析失败。这个 bug 非常隐蔽因为你只测1 2 * 3的时候根本测不出来。4.3 if/while/赋值语句的语法树构建语句层的解析比表达式层简单但要注意消费 Token 的时机尤其是 if/else 这种带有可选部分的语句。ASTNode *parseStatement() { Token tok peek(); if (tok.type TOK_IF) { consume(); // 吃掉 if expect(TOK_LPAREN); ASTNode *cond parseExpr(); expect(TOK_RPAREN); ASTNode *then_branch parseStatement(); ASTNode *node new_node(NODE_IF); node-cond cond; node-left then_branch; if (peek().type TOK_ELSE) { consume(); // 吃掉 else node-right parseStatement(); } return node; } if (tok.type TOK_WHILE) { consume(); expect(TOK_LPAREN); ASTNode *cond parseExpr(); expect(TOK_RPAREN); ASTNode *body parseStatement(); ASTNode *node new_node(NODE_WHILE); node-cond cond; node-left body; return node; } if (tok.type TOK_ID) { ASTNode *var parseFactor(); expect(TOK_ASSIGN); ASTNode *expr parseExpr(); expect(TOK_SEMI); ASTNode *node new_node(NODE_ASSIGN); node-left var; node-right expr; return node; } // 其他情况表达式语句、空语句、块语句 printf(语法错误不支持的语句类型\n); exit(1); }expect函数的作用是“确认下一个 Token 是预期类型否则报错退出”。它是一个很短的函数但价值很大——所有语法错误都在一个地方处理错误信息能统一带上行号和预期 Token比每处都写if判断干净得多。if 语句里else是可选的部分所以peek().type TOK_ELSE的检查必须在then_branch解析完之后做而且不能提前消费else。这个顺序错一步就会导致else匹配到错误的 if。在 C 语言里else会匹配最近的if我们这里的递归下降天然实现了这个语义因为 else 只跟当前正在解析的这个 if 绑定。赋值语句里要特别注意expect(TOK_SEMI)一定不能省。很多同学忘了分号结果语法分析器读完了整个表达式之后发现后面还有个报错位置完全对不上。调试时建议在expect函数里打印“期望什么、实际是什么、在第几行”比纯退出后靠肉眼找问题快得多。5. 小型编译器常见的坑与排查五条血泪经验5.1 贪心匹配把关键字前缀吞进标识符现象源码里写了一个变量叫ifCount词法分析输出的却是两个 Tokenif和Count然后语法分析在if后面没看到(直接报语法错误。原因标识符分支在读取完整个连续字母数字序列之后才去查关键字表这是正确的错的往往是另一个版本——一边读一边匹配关键字读到if两个字母就立刻判定为TOK_IF没有继续往后看。解决先完整读入一个词素再查关键字表。也就是第 3.2 节代码里那种“先while循环读词素再用strcmp判断”的顺序。这个顺序的唯一性可以用一个记忆点来锚定一个词素要么是完整的关键字要么是完整的标识符不存在“一半关键字”这种情况。5.2 左递归文法让递归下降直接爆栈现象写parseExpr的时候照着课本文法E - E T | T翻译成函数函数一开头就调用parseExpr()运行直接段错误或者栈溢出。“编译原理第三版答案”里的标准文法本来就不是为递归下降准备的不能直接抄。原因左递归表示的运算结合顺序是先算左边递归下降遇到左递归会无限递归。所有自顶向下分析器都处理不了直接左递归。解决把文法改写成右递归加循环。最稳妥的写法是“先解析一个右操作数再用while循环拼接同层运算”也就是第 4.2 节代码里的模式。这里不需要背 Greibach 变换公式循环结构天然等价于左结合。5.3 AST 里丢掉了运算符优先级现象a 1 2 * 3求值结果是 9而不是 7。单独打印 AST 发现根节点是左子树是1右子树是整个2 * 3看着好像没错但生成代码时又按照“先右后左”算出来了 9。原因语法分析阶段优先级是对的但中间代码生成阶段没有按树的深度优先顺序来。常见做法是把中序遍历当成“先算左子树、再算右子树”求得的值存回父节点如果这里的求值顺序反了或者用了扁平结构丢了括号就会串味。解决AST 生成后写一个eval函数做后序遍历先递归求左子树再递归求右子树最后做运算。如果这一步的结果不对优先打印每个节点的op和左右子树的值定位是“树建错了”还是“遍历顺序错了”不要一上来就怀疑栈式虚拟机的代码。5.4 栈式虚拟机的取数顺序正好相反现象10 - 2执行结果是-8除法和减法全都反了。原因栈式指令的直觉是“先入栈左操作数再入栈右操作数然后弹两个数做运算”但栈顶取出来的是后入栈的右操作数。SUB指令如果直接把第一次pop的结果当左操作数就变成了2 - 10。解决在虚拟机执行SUB和DIV时先pop到右操作数再pop到左操作数。这个细节我在架构设计2.3 节里强调过要提前画映射表就是因为这里太容易想当然。写的时候加一行注释// 注意栈顶是右操作数先弹出来的必须先存到 right int right pop(); int left pop();5.5 实验报告只有代码没有测试证据现象报告里贴了一大段核心代码但没有任何输入输出的对照。老师在验收时打开终端随便敲了一个带错误的测试程序编译器报错信息不友好或者直接崩溃当场翻车。原因课程设计的分数很大一部分来自实验报告的可信度只贴代码意味着“这段代码是否真的跑过”完全无法验证。还有一个更隐蔽的问题很多人的测试只覆盖了正常路径没覆盖错误路径。解决报告里至少放三组测试正常表达式、嵌套 if/while、词法或语法错误程序。每组测试包含输入源码、编译输出、运行输出三个部分。这个习惯不仅能让你答辩时从容还能反过来帮你发现很多调试时没注意到的边界问题。6. 从能跑到能交实验报告的组织、测试用例与验收自查6.1 报告结构设计、实现、测试、总结四段式实验报告最忌写成“源码 注释拼接”。我推荐的模板是四段式设计、实现、测试、总结。设计段落放文法和架构图说明为什么选递归下降、为什么用栈式虚拟机实现段落放核心模块的接口和关键代码代码只贴关键的不要贴全文测试段落放上面的三组对照实验总结段落写遇到的两个问题及解决过程。如果你所在的学校对实验报告有固定模板比如山科大的编译原理课程设计最好先拿到模板再动笔格式分别丢。6.2 测试用例设计正常路径与错误路径都要覆盖测试用例至少要覆盖三组算术表达式优先级与括号组合比如a (1 2) * 3控制流语句的嵌套比如while (i 10) { if (i % 2 0) sum sum i; }错误程序比如缺右括号、未声明的变量、非法字符。每一组都要记录输入、期望输出、实际输出。前两组是证明功能第三组是证明健壮性两者缺一不可。6.3 答辩前自查清单检查项判定标准文法改造过程报告中同时出现左递归原版和改造后版本词法报错能准确输出错误行号和非法字符优先级1 2 * 3结果为 7栈式指令顺序10 - 2结果为 8错误路径输入错误程序不会崩溃答辩前把这五项从头到尾跑一遍每一项都能拿出对应截图。这比临场发挥靠谱得多因为我见过太多代码写得很完整、但验收时因为一个小测试没过就全盘否定的例子。这几年带课设最大的感悟是翻车的往往不是能力问题而是没人把验收当成测试用例来对待。整个实现走下来词法分析、语法分析、小型编译器这三个部分各解决一个明确问题实验报告把每个环节的决策理由写清楚——做到这一步你的编译原理课设就已经超过大多数人了。希望帮到你。本文还有配套的精品资源点击获取
返回列表