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

文章详情

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

编译原理实验代码实战:手写词法分析与递归下降语法分析

编译原理实验代码实战:手写词法分析与递归下降语法分析 简介电子科技大学编译原理实验代码是一份面向高校本科生与自学者、围绕词法分析、语法分析及抽象语法树构建的完整实验工程尤其适合编译原理课程设计与实验报告参考。压缩包共21个文件约203KB其中C源代码与头文件实现分析算法和数据结构Word说明文档给出运行步骤与实验要求可执行程序便于直接验证效果工程配置文件则帮助搭建开发环境。已有2112人学习下载。通过运行程序并对照源码可以深入理解词法分析器如何基于有限状态自动机划分词法单元语法分析器如何采用递归下降或LR策略构造语法树还可学习错误处理、环境配置等实操细节。对于想要系统掌握编译器前端技术的学生这份满分实验代码提供了从理论到实践的清晰路径。1. 编译原理实验代码这门课拼的不是智商而是工程细节如果你在电子科技大学修过编译原理大概率见过这样的场景实验课上宣布的题目是“实现一个C语言子集的编译器前端”看起来只是词法分析加语法分析真动手才发现代码量轻松破千行而且第一次运行几乎必然翻车。编译原理实验代码不是背几个概念就能交差的它要求你在一学期内把正则表达式、有限自动机、上下文无关文法、递归下降和语法树这些抽象名词变成能对输入输出正确结果的程序。这套代码最值钱的地方在于它逼着你把“编译器前端”这条链路完整走一遍从读字符流开始到吐出语法树结束。适合谁来照着做两类人一类是正在赶实验的在校生另一类是想把编译原理从理论补成实战的从业者。下面这套方案是我自己跑通过的完整路径。2. 把实验拆成可交付的模块工程结构、选型与核心数据结构2.1 手写递归下降还是用 Flex/Bison选型决定了后面三周的体验电子科技大学的编译原理实验通常允许两种路线一是手写词法分析器和递归下降语法分析器二是用 Flex/Bison 这类生成器写.l和.y文件让工具替你生成 C 代码。前者代码量大但可控性极强实验报告里能写清楚每一个状态迁移后者上手快但原理遮蔽严重一旦生成的代码报错你基本只能对着parser.c里几千行状态表发愣。我的判断标准很简单如果你的目标是“把实验跑通并能在答辩时讲清原理”那就手写。原因有二。第一生成器生成的代码是不可读的答辩时老师问你“你的语法分析器怎么处理左递归”你很难对着工具生成的跳转表回答手写递归下降则每一步都是自己的逻辑。第二实验测试集的 bug 调试周期非常短手写代码出问题可以用 gdb 直接定位到具体函数而生成器代码会让断点打到一堆宏展开里。常见做法是手写词法分析器语法分析用递归下降加回溯这也是很多学校默认的参考实现。少部分追求挑战的同学会用 LR(1) 自动机手写但那个工作量在课程周期里不太现实。2.2 定义三张核心表Token 表、符号表与语法树节点不管你用什么语言实现整个工程的核心是三张数据结构。第一张是 Token 表它描述词法分析器的输出格式。我用的结构长这样typedef enum { TOKEN_ID, TOKEN_NUM, TOKEN_KEYWORD, TOKEN_OP, TOKEN_EOF } TokenType; typedef struct { TokenType type; char* lexeme; // 原始字符串 int line; // 行号报错用 int column; // 列号报错用 union { char* id_name; // 标识符 int int_val; // 整数值 double float_val; // 浮点值 } attr; } Token;这段代码的逻辑说明Token是词法分析器和语法分析器之间的唯一接口。type决定这个单词属于哪一类lexeme保留原始输入串line和column是调试的命根子——没有这两个字段语法报错时你只能看到“语法错误”四个字完全不知道错在哪一行。attr是一个联合体根据type的值选择读id_name还是int_val。你可以根据自己实验的要求增删字段比如加入十六进制数类型、字符串常量类型但line和column建议无论如何都要保留。第二张表是符号表语法分析阶段不需要它但如果你后续要做语义分析符号表必须从一开始就设计好。一个常见的简化设计是直接用链表存作用域typedef struct Symbol { char* name; int type; int scope_depth; struct Symbol* next; } Symbol;第三张是语法树节点。递归下降的每个函数最终要返回一个 AST 节点节点类型用enum定义比如NODE_ASSIGN、NODE_IF、NODE_WHILE、NODE_BINARY_EXPR等。2.3 搭出最小工程骨架一个能读完整输入文件的主循环先搭一个能跑通的最小骨架把文件读取和 Token 打印做出来再逐步填充逻辑。这是我常用的框架#include stdio.h #include stdlib.h #include string.h // 读取整个文件到内存 char* read_source(const char* path) { FILE* fp fopen(path, r); if (!fp) { perror(open failed); exit(1); } fseek(fp, 0, SEEK_END); long len ftell(fp); fseek(fp, 0, SEEK_SET); char* buf (char*)malloc(len 1); fread(buf, 1, len, fp); buf[len] \0; fclose(fp); return buf; } int main(int argc, char** argv) { if (argc 2) { fprintf(stderr, usage: %s source.c\n, argv[0]); return 1; } char* src read_source(argv[1]); // 这里后面替换成词法分析器 printf(read %zu chars\n, strlen(src)); free(src); return 0; }参数说明read_source一次性把整个文件读进内存因为编译器的输入通常不大省去流式读取的复杂度。实验代码和工业编译器不同优先保证逻辑简单、可调试。这个骨架验证了两个事编译环境能跑、文件路径传参没问题。接下来把printf那行替换成词法分析器的调用就可以开始第一步了。3. 词法分析器从正则到状态机的工程落地3.1 手工状态机是课程实验的正解但你要踩过这三个边界常见的误区是直接用正则表达式库Python 的re或 C 的 POSIX 正则去匹配单词。这么做代码短但有一批隐藏问题。第一个边界是最长匹配C 语言里必须被识别成一个运算符如果正则按单个字符匹配和会被拆成两个 Token语法分析器立刻就崩。第二个边界是关键字和标识符的区分int要变成TOKEN_KEYWORD而integer必须是TOKEN_ID这需要词法分析器在识别完整单词后回头查关键字表而不是在读到第一个字符时就断定。第三个边界是注释处理/*和//两种注释必须跳过但行号计算要在跳过过程中持续递增否则报错位置全部错位。手工状态机解决这三个边界的方式非常直接。你需要维护一个当前状态变量逐个字符读取根据状态迁移表决定下一个状态。用 C 写一个能识别标识符和整数的核心循环typedef enum { STATE_START, STATE_IN_ID, STATE_IN_NUM, STATE_IN_OP, STATE_DONE } State; Token next_token(Lexer* lex) { State state STATE_START; char buf[256]; int len 0; while (1) { char c peek(lex); // 只看不消费 switch (state) { case STATE_START: if (isalpha(c) || c _) { state STATE_IN_ID; buf[len] c; consume(lex); } else if (isdigit(c)) { state STATE_IN_NUM; buf[len] c; consume(lex); } else if (c || c || c ! || c ) { state STATE_IN_OP; buf[len] c; consume(lex); } else if (isspace(c)) { consume(lex); } else if (c \0) { /* EOF */ } else { /* 未知字符 */ } break; case STATE_IN_ID: if (isalnum(c) || c _) { buf[len] c; consume(lex); } else { buf[len] \0; return make_token(TOKEN_ID, buf, lex); } break; case STATE_IN_OP: // 处理 ! 等双字符运算符 if ((buf[0] c ) || (buf[0] c ) || (buf[0] ! c )) { buf[len] c; consume(lex); } buf[len] \0; return make_token(TOKEN_OP, buf, lex); break; } } }逻辑说明peek只返回当前字符但不推进指针consume才真正消费并更新行列号。状态机的核心思想是只有在当前状态确定能完整构成一个单词时才返回 Token 并退出循环。比如读到一个先按单字符运算符处理然后 peek 下一个字符如果是拼成否则返回单独的。这里有一个参数要特别注意buf长度固定为 256如果标识符超过这个长度会越界。你的实验里可能不会遇到超长标识符但养成写边界检查的习惯能省很多调试时间。另一个参数是make_token函数里要把line和column从Lexer结构里拷贝出来那才是报错定位的关键。3.2 关键字表和双重状态推进把“看起来对”变成“测试全过”有了一版能跑的词法分析器接下来就是踩测试集的阶段。你会发现如下问题if被识别成标识符而不是关键字3.14浮点数识别不出来字符串常量里的转义序列处理错误。解决关键字问题最容易维护一个哈希表或字符串数组在make_token里查表即可。但浮点数和字符串需要新增状态。浮点数状态机的迁移规则是从STATE_IN_NUM进入如果读到一个.进入小数状态继续读数字如果读到e或E进入指数状态此时可以接受一个可选的或-。字符串常量则是在读到时进入字符串状态一直读直到遇到闭合的期间处理\\n、\\t、\\等转义序列。这块写起来繁琐但没有技术难点只要按状态表一步步迁移就能通过。3.3 用“重放测试法”验证词法分析器你想象的正确和实际输出差距巨大写好词法分析器后不要急着写语法分析器。先用一个测试程序把所有 Token 打印出来对照手写的期望结果检查。我习惯的做法是写一个dump_tokens函数遍历源文件输出每一行的类型, 值, 行号, 列号然后拿实验给出的样例做对比。这个过程能提前暴露 80% 的词法 bug而这些 bug 如果留到语法分析阶段才暴露排错成本会直接爆炸。你会发现诸如int a1;被解析成int关键字、a标识符、赋值运算符、1数字、;分号这是对的但int a 1;这种非法输入也会被当成两段无误的词法流输出——词法分析器不管语法合法性只要单词切分正确就算过了。4. 语法分析递归下降、First/Follow 求与左递归消除4.1 为什么递归下降是课程实验的首选手写逻辑和文法一一对应递归下降语法分析器的核心思路是为文法中的每个非终结符写一个解析函数函数体里按产生式逐个匹配终结符或调用其他非终结符函数。它的最明显优势是代码结构等于文法结构调试时看到栈回溯就能定位到哪个产生式出了问题。对应地你需要先把实验给出的文法改造成 LL(1) 文法——消除左递归、提取左公因子。这一步在纸上完成代码只是把改造后的文法翻译成函数。一个典型的 C 语言子集表达式文法可能是这样的expr - term expr expr - term expr | - term expr | ε term - factor term term - * factor term | / factor term | ε factor - ( expr ) | NUM | ID这个文法是 LL(1) 的因为没有左递归且每个非终结符的 First 集两两不交。改造前的左递归形式expr - expr term不能用于递归下降因为解析函数会无限调用自身。你的实验步骤应该是先写出原始文法然后在报告里展示改造过程再写代码。4.2 First/Follow 集求法手算一次然后写个小程序替你算递归下降代码本身不需要显式用到 First/Follow 集但你需要用它们来证明你的文法确实是 LL(1) 的并且用来判断某个产生式在什么情况下该走哪个分支。实验报告里要求你写出求算过程手工求很容易出错我的经验是写一个 Python 脚本自动算from functools import lru_cache productions { expr: [[term, expr\]], expr\: [[, term, expr\], [-, term, expr\], []], # [] 表示 ε term: [[factor, term\]], term\: [[*, factor, term\], [/, factor, term\], []], factor: [[(, expr, )], [NUM], [ID]] } def first(nonterm): res set() for prod in productions[nonterm]: if not prod or prod[0] ε: res.add(ε) else: for sym in prod: if sym in productions: # 非终结符 f first(sym) res | (f - {ε}) if ε not in f: break else: res.add(sym) break return res lru_cache(maxsizeNone) def follow(nonterm): if nonterm expr: return {$} res set() for nt, prods in productions.items(): for prod in prods: for i, sym in enumerate(prod): if sym nonterm: rest prod[i1:] if not rest: res | follow(nt) else: for r in rest: f first(r) if r in productions else {r} res | (f - {ε}) if ε not in f: break return res for nt in productions: print(nt, first:, first(nt), follow:, follow(nt))逻辑说明first函数递归地求每个非终结符的 First 集遇到终结符直接加入结果遇到非终结符则递归展开如果该非终结符能推导出ε则继续看下一个符号。follow函数初始给开始符号expr加入$输入结束符然后遍历所有产生式找到目标非终结符后面的符号把它们的 First 集去掉ε加进来如果后面没有符号了则把产生式左部的 Follow 集继承下来。代码里的lru_cache是记忆化避免递归重复计算。你拿这个脚本对照手算结果发现问题基本都在手算这边。4.3 左递归消除和回溯处理两种最常见的翻车方式写递归下降代码时有两个高频翻车点。第一个是左递归漏处理。很多人把文法改写在报告里但代码还按原始文法写。比如在parse_expr里先调parse_expr再读运行就栈溢出。排错的方法是代码里每个函数如果无限递归gdb 的 backtrace 会显示同一函数名重复几十层立刻回头查文法。第二个是回溯错误。parse_factor遇到(时调parse_expr去匹配括号内的内容如果parse_expr在错误位置读了 token 但没有回退指针后面整个语法树就会错乱。所以你的词法分析器必须支持“保存位置”和“恢复位置”的操作常见做法是加一个lexer_mark()和lexer_restore()在可能失败的分支前打点失败时复位。回溯是递归下降的天然能力不丢人但每次都回溯的代码效率很低所以要用 First/Follow 集的判断来减少回溯次数。5. 编译实验最容易翻车的 5 个现场排错与避坑清单5.1 翻车现场循环里用getchar()导致 Token 错乱现象输入int a1;词法分析器输出int、、1后下一个 Token 变成乱码或直接 EOF。原因getchar()每次从标准输入读一个字符如果你在识别完int后没有正确处理缓冲区多余的空格或终止符被当作下一个单词的一部分。更常见的是peek和consume没有成对使用一个字符被消费了两次或零次。解决统一所有读取操作走peek/consume接口禁止在词法分析器里直接调用getchar()或fgetc()。把缓冲设计成“当前字符 前进指针”的形式确保每个字符只被处理一次。5.2 翻车现场文法二义性导致语法树阴晴不定现象同一个表达式ab*c在不同输入顺序下得到不同的语法树或者解析a-b-c时优先级反转。原因文法没有体现运算符优先级。如果你把expr - expr - expr写成二义性文法递归下降会产生左结合或右结核的随机选择。这通常发生在你跳过文法改造直接写代码时。解决把文法严格改成前面给出的层级结构每个优先级对应一个非终结符。a-b-c应该被解析成((a-b)-c)所以parse_expr要先解析左侧操作数然后循环处理-后面的内容形成左递归的等价循环。5.3 翻车现场报错位置永远指向文件最后一行的行号现象输入有明显错误的代码程序报错行号却是文件末尾。原因line和column在词法分析器的构造函数里被初始化为固定值从来没有随consume更新。解决确认consume函数里遇到\n时line、column 1其他字符column。然后写一个 10 行的测试文件在每行末尾故意放一个非法字符看报错位置是否精确。5.4 翻车现场实验平台判定死循环或超时现象代码在本地跑测试样例正常提交到在线评测平台却超时。原因本地测试文件小输入的 EOF 可能处理不干净。如果词法分析器在读到\0后没有正确返回TOKEN_EOF语法分析器就会一直等下一个 Token形成死循环。另一种可能是平台使用管道输入管道关闭后peek返回-1而不是\0你的代码没有处理这个分支。解决统一一切非可打印字符为 EOF并在next_token开头加一句如果peek(lex) EOF或peek(lex) \0直接返回TOKEN_EOF。平台评测的输入输出只比较最终结果不会管你是不是死循环消耗了 CPU。5.5 翻车现场语法分析器报“unexpected token”但词法分析器单测全过现象词法分析器打印的 Token 流看起来完全正确但语法分析器在某个位置卡住。原因Token 流的“看起来正确”和“实际 Token 类型正确”是两回事。比如3被词法分析器识别为TOKEN_ID而不是TOKEN_NUM打印时你看到的是3但语法分析器按TOKEN_NUM匹配时匹配失败。排查方法是打印时把 Token 类型也打印成字符串比如ID(3)还是NUM(3)一眼就能定位。解决在make_token里检查isalpha和isdigit的判断顺序。先判断数字后判断字母且3abc这种输入要么报错要么识别成非法 Token而不是默默变成ID。6. 跑稳最后一步用语法树打印验证整个实验的完整度写完整条链路后你怎么判断自己真的做完了我的验证方法很简单写一个递归打印语法树的函数把 AST 以缩进形式输出。输入a 1 2 * 3;如果输出的是ASSIGN(ID(a), BINARY_ADD(NUM(1), BINARY_MUL(NUM(2), NUM(3))))这样的结构说明词法、语法、优先级全部正确。这个打印函数不需要复杂一个print_ast(Node* node, int depth)递归调自己根据节点类型决定缩进和子节点遍历即可。我经历过的教训是很多同学把精力花在调试一个非常偏门的错误上比如指针数组越界导致内存破坏但语法树打印显示他的优先级处理成了(a1)*2*3——根本不是内存问题而是文法抽象层级错了。所以不要直接跳到最后跑编译生成的阶段先用语法树打印确认前端正确。实验代码的最终交付物除了跑通的程序和报告还有那份能清晰读出来的语法树。这个习惯我也是在被卡了两周后摸索出来的希望帮到你。本文还有配套的精品资源点击获取
返回列表