FEATURED · 精选文章

编译原理实验核心实战:词法分析、递归下降与中间代码生成

发布时间 / 2026/9/8 4:00:40
来源 / 创域科博编辑部
栏目 / 资讯中心
编译原理实验核心实战:词法分析、递归下降与中间代码生成 简介编译原理课程实验常需从零手写词法与语法分析器电子科技大学这份实验代码包完整覆盖了相关实现面向正在学习词法分析、语法分析及抽象语法树构建的本科生或需参考课设实现的开发者。代码主要由C与Pascal编写包含5个cpp源文件、4个头文件和2个pas文件分别对应词法分析器、语法分析器、文件读取等核心模块同时附有Visual Studio项目文件sln/vcxproj和Qt项目文件pro可跨环境快速编译运行。docx运行说明详细介绍了执行步骤与环境要求exe可执行程序支持直接演示。整个资源共21个文件压缩包仅203KB结构紧凑、无冗余素材代码注释与模块划分便于快速定位关键逻辑。已有2110人学习下载适合希望深入理解FSM识别token、LL/LR解析策略以及中间代码生成过程的学习者可作为实验报告的配套参考或调试排错依据。 编译原理实验这门课我在电子科技大学读本科那会儿它属于计算机学院公认“最硬核”的课之一。课程要求亲手写一个能跑通的编译器前端从词法分析、语法分析到语义分析和中间代码生成每一步都是实打实的代码量。我把实验代码整理重构后在社区分享过几版陆陆续续收到不少邮件和私信问题集中在“实验怎么写”“用Flex还是手写”“段错误怎么排查”这几个点上。这篇东西就把我的完整思路、实现细节和踩过的坑一次性讲清楚适合正在为编译原理实验头秃的本科生也适合想靠一个完整项目把编译原理知识串起来的自学者。1. 实验整体设计与思路拆解1.1 先搞清楚你面对的实验体系大多数高校的编译原理实验会拆成3到4个递进环节词法分析器、语法分析器、语义分析与中间代码生成部分学校还会加第四个实验做目标代码生成或基础优化。电子科技大学的课程设计也不例外实验语言一般选一个C语言的子集各家叫法不同有的叫C-Minus有的叫SysY总之就是“缺胳膊少腿”的C没有结构体、没有指针运算、没有构造函数核心语法只有变量声明、赋值、表达式、if-else、while、return外加几个基础数据类型。别小看这个精简子集它恰好覆盖了编译原理课上的全部核心知识。变量和表达式对应词法分析、语法分析、类型检查if-else和while覆盖了控制流语句的中间代码生成与回填技术函数的参数和返回值则强迫你实现一套完整的函数符号表和活动记录模型。整个实验做完你对“高级语言怎么变成中间表示”这件事会有远超听课的体感。1.2 工具链选择FlexBison还是手写递归下降这是实验开始前最容易纠结的问题。我见过同年级同学用不同方案交作业有人全程FlexBison有人全程手写也有像我一样混合的。三条路线各有出处但我个人的结论很明确——词法用Flex生成语法用手写递归下降语义和中间代码生成在语法分析过程中边做边生成。这个组合在课程场景里最稳。为什么词法用Flex因为词法分析本质上是正则表达式匹配手写DFA不难但手写一个能完整覆盖所有边界情况的DFA其实很费劲十六进制数、转义字符、注释嵌套、错误恢复每一项都会把人磨疯。Flex把这些规则编译成一张确定有限自动机规则写得规范结果几乎不会出问题。为什么语法要手写递归下降因为Flex能帮你生成词法分析器但Bison生成的LALR(1)分析器一旦涉及冲突和错误恢复调试体验非常痛苦。而手写递归下降代码量可控每层函数对应一个非终结符遇到错误可以精确到“期望什么、实际得到什么”对学生来说更容易定位。哪怕放到工业界GCC、Clang、Rustc这些编译器也在用递归下降加优先级爬升的方案手写这个选择一点都不丢人。顺便说一句如果你做实验用的是Java而不是C/C工具链会变成ANTLR或JavaCC思想还是一样的只是生成器语法不同。清华大学和哈尔滨工业大学陈鄞老师的编译原理课程在B站都能找到虽然是C语言视角但对“工具选择的底层逻辑”讲得很透哪怕你是Java路线也值得看。1.3 整体架构与数据流整个项目的代码结构我建议这样划分lex.lFlex规则文件负责把源代码切成token流syntax.c / syntax.h递归下降语法分析器同时调用符号表和中间代码生成模块symtab.c / symtab.h符号表实现哈希表或线性表均可intercode.c / intercode.h中间代码生成维护四元式队列main.c驱动的初始化、文件读取、错误计数和退出码。数据流非常经典源代码 → 词法分析输出token流 → 语法分析 → 语义分析与中间代码生成。我做的版本没有构建显式抽象语法树AST而是采用“语法制导翻译”的方案归约到一个产生式时直接生成对应的四元式。这样省代码也足够完成课程要求但如果你的实验要求输出语法树那就在解析时增加一个构建AST节点的步骤。2. 核心实验解析与实操要点2.1 词法分析正则规则与Flex的关键坑点词法分析实验的第一道坎是设计token类型。建议提前定义好枚举typedef enum { T_NUM, T_ID, T_IF, T_ELSE, T_WHILE, T_RETURN, T_INT, T_VOID, T_EQ, T_NEQ, T_LT, T_GT, T_LEQ, T_GEQ, T_ASSIGN, T_PLUS, T_MINUS, T_MUL, T_DIV, T_LPAREN, T_RPAREN, T_LBRACE, T_RBRACE, T_SEMI, T_COMMA, T_EOF } TokenType;这里有个非常关键的细节关键字if、else、while、return、int、void和标识符怎么区分。多数课程要求的语义是“关键字优先”如果一个单词是if就返回T_IF而不是T_ID。实现上有两种流派一是在Flex规则里把每个关键字单独写一条规则排在标识符规则前面二是所有标识符统一识别成T_ID然后在代码里查关键字表命中就改返回关键字类型。我更推荐第二种因为关键字表可以做成静态哈希表扩展时不用改Flex规则。另一个坑是注释。C-Minus通常要求支持/* ... */形式的注释。正则写法是\/\*([^*]|\*[^*/])*\*\/这个表达式我调了好几次才写对最关键的坑是Flex默认“最长匹配”原则如果偷懒写成\/\*.*\*\/遇到一行里有两个注释时会吃掉过多内容。写完建议用几个样例测一下单行注释、多行注释、注释内容含星号、注释尾紧跟着代码。错误处理也不可缺。我把非法字符统一return一个T_ERROR token同时在全局维护行号和错误计数语法分析阶段遇到T_ERROR就跳过并继续尝试恢复这样一次能测出多个错误课程评分时很加分。2.2 语法分析递归下降中的经典实现递归下降解析器的核心是一组函数每个非终结符对应一个函数。C-Minus的核心文法可以压缩成这样program → decl_list decl_list → decl decl_list | ε decl → var_decl | fun_decl var_decl → type ID [ [ NUM ] ] ; fun_decl → type ID ( params ) compound_stmt stmt → if ( expr ) stmt [ else stmt ] | while ( expr ) stmt | return [ expr ] ; | expr ; | compound_stmt写代码之前强烈建议在草稿纸上做一次消除左递归。比如表达式就是典型的左递归文法expr → expr term | term直接按这个写递归下降会无限递归必须转成右递归或循环实现。我用的是循环版每个优先级写一个函数void parse_expression() { parse_term(); while (lookahead T_PLUS || lookahead T_MINUS) { Token op lookahead; match(lookahead); parse_term(); gen_binary(op); // 生成四元式 } } void parse_term() { parse_factor(); while (lookahead T_MUL || lookahead T_DIV) { Token op lookahead; match(lookahead); parse_factor(); gen_binary(op); } }这个pattern其实就是在用代码模拟运算符优先级乘除的函数parse_term先处理“更高优先级”的factor加减的外层parse_expression再处理乘除的结果。任何复杂表达式最终都被拆成若干个二元运算。悬吊else是一个经典文法问题递归下降天然不会踩坑——因为if解析时只有当lookahead不是ELSE才跳过else分支否则就解析else子句。如果非要用Bison则会遇到shift/reduce冲突还得额外写优先级和结合性声明来解决这也是我说手写递归下降更省心的原因。2.3 语义分析与中间代码生成符号表和四元式这一块是整个实验真正拉开差距的地方。先从符号表说起。我把符号表设计成两层全局符号表加一个局部作用域栈。全局符号表存全局变量和函数名每次进入一个函数体或复合语句块就压入一个新的局部作用域。查找符号时从栈顶往下查天然实现了“内层变量遮蔽外层变量”的C语义。typedef struct Symbol { char *name; int type; // INT / VOID int is_array; int size; // 数组长度普通变量为1 int kind; // GLOBAL / LOCAL / PARAM int offset; // 相对栈帧偏移量代码生成时用 int is_func; struct Symbol *params; // 函数参数链表 struct Symbol *next; } Symbol;类型检查通常不复杂int和void的赋值、函数返回值类型、数组下标运算的前提条件。一个常被忽略的点是每生成一个中间代码四元式都要记录operand的类型因为最终代码生成阶段要区分load指令是读int还是读数组元素。中间代码我统一用四元式typedef struct { int op; // 运算类型如 OP_ADD, OP_SUB, OP_MUL, OP_DIV int arg1; // 操作数ID索引或临时变量编号 int arg2; int result; // 目标位置ID索引或临时变量编号 } Quad;对于临时变量我维护一个全局计数器temp_count每次newtemp()返回一个负数编号最终在输出中间代码时再统一映射成t0、t1、t2这样的名字。有个细节实验报告里如果要求输出类汇编格式负编号要统一格式化别直接暴露-1、-2这种内部表示会白扣分。if-else和while是回填backpatching的集中应用场景。以while循环为例while (expr) stmt我在parse_while阶段会记录三个位置条件表达式入口、循环体入口、循环体出口。生成四元式时条件表达式翻译出的是“为真跳转循环体为假跳转出口”这需要先等条件expr生成完再回填跳转目标。实现方式很直白把四元式队列当前长度存下来当作待回填地址记录后续再回头写入。这就是编译原理课上讲的backpatching在递归下降里落地非常简单。3. 实操过程与核心环节实现3.1 环境搭建与项目组织实验环境用Ubuntu就能搞定三件套gcc、flex、bison。语法分析虽然用手写不用bison但建议保底装一个想验证某个文法是否有冲突时可以用bison临时试一下。安装就是标准apt命令sudo apt install gcc flex bison make。项目目录我建议这样分层lab-compiler/ ├── lex.l ├── lib/ │ ├── syntax.h │ ├── syntax.c │ ├── symtab.h │ ├── symtab.c │ ├── intercode.h │ └── intercode.c ├── main.c ├── Makefile └── tests/ ├── test1.cm ├── test2.cm └── ...Makefile里最关键的是Flex生成规则lex.yy.c: lex.l flex lex.l syntax.o: syntax.c lex.yy.c gcc -c syntax.c注意lex.yy.c里会被其他模块包含头文件所以编译顺序必须先把flex跑一遍。如果直接gcc main.c lex.l会直接报错这种“没跑flex就编译”的错误我见过不少同学踩到。3.2 Flex文件的核心结构完整lex.l骨架长这样%{ #include stdio.h #include stdlib.h #include token.h #include syntax.h int line_no 1; %} %option noyywrap %% [ \t\r] { /* 跳过空白 */ } \/\*([^*]|\*[^*/])*\*\/ { /* 注释 */ } [0-9] { yylval.val atoi(yytext); return T_NUM; } [a-zA-Z_][a-zA-Z0-9_]* { if (is_keyword(yytext)) return is_keyword(yytext); yylval.name strdup(yytext); return T_ID; } { return T_EQ; } ! { return T_NEQ; } { return T_LEQ; } { return T_GEQ; } { return T_LT; } { return T_GT; } { return T_ASSIGN; } { return T_PLUS; } - { return T_MINUS; } * { return T_MUL; } / { return T_DIV; } ( { return T_LPAREN; } ) { return T_RPAREN; } { { return T_LBRACE; } } { return T_RBRACE; } ; { return T_SEMI; } , { return T_COMMA; } \n { line_no; } . { printf(line %d: illegal char %s\n, line_no, yytext); } %%这里最需要留意的两个坑%option noyywrap一定要写否则链接时会报找不到yywrap另一个是正则\/\*([^*]|\*[^*/])*\*\/处理注释时的细节它允许注释内容里出现单个星号但不允许出现*/序列否则会提前终止注释。补充一个实用建议如果实验要求支持负数字面量比如-3不要在词法层处理。负号由语法分析层作为一元运算符处理词法只负责返回T_MINUS token这样词法职责干净优先级在语法层统一搞定。3.3 递归下降解析与语义动作的融合以函数声明为例看一下“语法分析符号表中间代码生成”怎么融合在一个parse函数里void parse_fun_decl() { // 第一个token是类型 int 或 void TokenType type lookahead; if (lookahead T_INT || lookahead T_VOID) match(lookahead); else parse_error(expected type in function decl); char *funname strdup(yylval.name); match(T_ID); match(T_LPAREN); // 先把函数放进全局符号表避免递归调用时找不到 Symbol *func add_symbol(funname, type, IS_FUNC); push_scope(); while (lookahead ! T_RPAREN) { TokenType ptype lookahead; if (lookahead T_INT || lookahead T_VOID) match(lookahead); else parse_error(expected param type); char *pname strdup(yylval.name); match(T_ID); add_param(func, pname, ptype); if (lookahead T_COMMA) match(T_COMMA); } match(T_RPAREN); emit(OP_FUNC_LABEL, 0, 0, (int)funname); parse_compound_stmt(); pop_scope(); }这个函数的执行顺序暗合了C语言的作用域规则先把函数放进全局符号表再压局部作用域最后在函数体结束时弹出作用域。这个顺序如果反了函数体内的变量查找就找不到自己函数了这是很多同学第一版代码的典型bug。中间代码生成的时机我是在每个“解析完成”的位置直接emit。比如表达式解析到运算符时void gen_binary(Token op) { // 从临时变量栈弹出两个操作数 int arg2 pop_temp(); int arg1 pop_temp(); int dst new_temp(); emit(op_map[op], arg1, arg2, dst); push_temp(dst); }临时变量栈和四元式队列的配合是这套实现的精髓操作数在解析时入栈四元式生成后目的变量继续入栈作为下一层运算的操作数。只要压栈和出栈的顺序不出错整个表达式翻译就自然正确。3.4 测试用例设计实验做完不是跑通一个hello就完事。建议准备下面几类测试用例表达式优先级a 1 2 * 3;期望生成的中间代码先算乘法再算加法变量遮蔽全局int x和函数内局部int x验证函数内部引用x时用的是局部符号数组访问arr[i] 0;验证数组下标和赋值左值处理完整if-else悬吊嵌套if的else归属关系长循环while循环内嵌套多个if和return验证回填标签正确错误用例缺少分号、变量未声明、类型不匹配。这些测试在提交前都跑一遍基本能覆盖八成以上的扣分点。4. 常见问题与排查技巧实录4.1 高频问题速查现象根因排查思路编译不过报yywrap未定义漏了%option noyywrap在Flex定义区加上词法错乱把关键字识别成了标识符关键字表没匹配或规则顺序不对打印token流审查识别逻辑解析表达式崩溃递归下降缺少对下一优先级函数的调用检查parse_expression到parse_factor的调用链段错误符号表查找返回NULL在strcmp前判空插入前查重四元式操作数顺序反了左右操作数压栈顺序不对打印四元式人工模拟一遍中间代码死循环while条件回填标签遗漏检查回填的出口标签是否完整变量值在函数调用后丢失参数没有登记进局部作用域在parse_fun_decl里先push_scope再登记参数数组下标被当成普通变量数组pos运算缺失处理数组时生成OP_INDEX四元式4.2 调试方法论分段打印定位法一个我用得很顺手的调试思路把整个流程拆成三段分别打印。第一段是词法层加一个debug开关#ifdef LEX_DEBUG extern char *token_names[]; printf(token: %s, lexeme: %s\n, token_names[token], yytext); #endif编译时gcc -DLEX_DEBUG就能看到token流是否符合预期。这一步能过滤掉词法层九成的问题。第二段是语法层在递归函数入口打印正在解析的非终结符名配合一个缩进计数器输出出来就是一棵“语法分析树”哪里递归卡住、哪里错误恢复一目了然。第三段是中间代码层每emit一条四元式就打印一行配合测试用例人工模拟执行一遍检查四元式序列的语义是否符合预期。这个分段打印法几乎覆盖了整个实验的调试需求比gdb单步跟踪更直观。gdb不是不能用而是对这种状态分散在多个函数、结构联动频繁的程序单步太费时间不如直接打日志看流程。4.3 独家避坑心得最后写几个纯经验向的心得不一定每个学校都一样但适用性很广。第一不要把四元式队列设计成动态扩容的链表。用数组加预分配就足够课程实验的代码规模撑死几千行四元式数量不会超过几万条预分配100万条的四元式数组几乎不可能溢出却省掉了所有内存管理的麻烦。第二错误恢复要做好但不追求完美。评分老师常常会故意放几个语法错误来测试容错能力合理策略是语法错误出现时打印错误信息、跳过当前statement、继续解析下一个statement。别让一个错误把半个文件都拖崩。第三养成每次改动后回归测试的习惯。我建了一个tests目录里面十几个.cm文件覆盖各种边界条件每次改动核心逻辑后直接make test一次跑完一分钟内能知道有没有改崩之前的用例。这个习惯帮我避免了好几次“修好一个bug、带崩两个case”的尴尬。第四也是最重要的不要照搬网上的现成代码。网上流传的版本五花八门很多还是针对C99以前的老接口写的直接搬过来大概率编译不过就算编译过了一句一行读不懂面试或者答辩时一问就露馅。老老实实把每一步都吃透比交一份跑不通的作业要值得多。我在实际把整套编译原理实验做完并且消化之后最大的感受是这门课其实不看天赋看的是能不能坚持把细节抠完。词法、语法、语义和中间代码这四个环节每个单拎出来都不难但连成一个流水线后任何一个环节的小差错都可能让最终代码行为完全不可预测。这门实验的成就感也恰恰在此——当看到自己写的小编译器把一段C-Minus源码翻译成一行行四元式而且逻辑全对的时候那种从无到有构建一个系统的爽感是很多其他课程给不了的。最后再分享一个小技巧如果你做完基础实验觉得不过瘾可以试着给你的编译器加一个小优化——常量折叠比如12直接在语义分析阶段折叠成3。这个改动量不大但“编译优化”四个字立刻从PPT上的概念变成了你手里的代码。做完这一版再回头看哈工大陈鄞老师课程里讲的那套前端流程你会发现自己已经站在了和之前完全不同的理解层次上。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻