ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

编译原理实验:类C语言编译器工程从词法分析到中间代码实现

编译原理实验:类C语言编译器工程从词法分析到中间代码实现 简介电子科技大学编译原理实验代码适合计算机专业本科生、考研复习者及编译原理自学者参考。资源围绕词法分析器与语法分析器两个核心主题完整呈现了一个小型编译器前端的实现过程。压缩包共21个文件主要包含C源文件与Pascal源文件同时伴有工程配置文件、运行说明文档和可直接运行的可执行程序整体仅203KB下载与查阅都相当便捷。目前已有2111人参与学习可见其参考价值。代码结构上覆盖了输入处理、词法分析、语法分析等关键模块可清晰观察如何通过正则表达式或有限状态自动机识别Token并借助LL或LR分析方法构建抽象语法树。作为电子科技大学满分实验代码其模块划分、注释与实验设计思路对理解编译原理核心流程、完成课程设计或实验报告均有很好的借鉴意义。1. 编译原理实验代码先搞清楚这套成电风格的编译器工程能做什么编译原理实验是计算机专业里少数几个能把人逼到连续熬夜的课——不是因为理论难懂而是因为你手里往往只有一套半成品代码词法分析要自己填、语法错误要自己定位、符号表还得自己设计。电子科技大学这套编译原理实验代码对应的是一门完整的类 C 语言编译器工程词法分析、语法分析、语义检查、中间代码生成四遍结构齐全每一遍都有独立的 C 文件和头文件不是零散的算法演示而是能直接 make 出可执行文件的完整工程。它适合两类人一类是正在做编译原理课程实验、想参考一份能跑通的多遍实现的学生另一类是课程设计题目恰好是“类 C 语言编译器”或者 C-Minus 编译器、需要快速理解整体链条的开发者。这份资源最大的价值在于每一步都能对照代码看到编译器的四个阶段是怎么衔接起来的出了问题也知道去哪里打断点、看哪里。2. 工程结构与构建拿到代码第一步是让它一次性跑起来很多同学拿到代码后的第一个动作是直接打开某个 .c 文件开始看结果看了二十分钟还没搞明白入口在哪。我的建议是先不看代码先把整个工程的目录结构和构建方式摸清楚生成一个可执行文件再回头去读代码这样脑子里的地图是完整的。2.1 目录结构与主流程先看懂这几个文件这套工程通常包含下面这些文件虽然不同版本命名略有差异但职责几乎一样文件职责token.htoken 类型定义、token 结构体、关键字表声明lexer.h / lexer.c词法分析器把源文件字符流切分成 token 流parser.h / parser.c递归下降语法分析器生成语法树或直接输出四元式symtab.h / symtab.c符号表管理处理变量声明、作用域和类型检查intercode.h / intercode.c中间代码生成输出三地址码main.c驱动流程打开文件 → 词法分析 → 语法分析 → 输出结果我一般会先看 main.c因为主函数里能看到这个版本的编译器选择了什么架构——是每得到一个 token 就立刻送给语法分析器还是先把整个文件切分成 token 数组再统一处理。前一种叫流式驱动内存占用小适合大文件后一种叫分批驱动容易调试因为你可以先单独验证词法分析的结果再交给语法分析器。int main(int argc, char *argv[]) { FILE *fp; if (argc 2) { fprintf(stderr, 用法: %s 源文件.c\n, argv[0]); return 1; } fp fopen(argv[1], r); if (!fp) { fprintf(stderr, 无法打开文件 %s\n, argv[1]); return 1; } init_lexer(fp); init_symtab(); parse_program(); fclose(fp); return 0; }这段代码的逻辑就是典型的多遍编译器驱动先初始化词法分析器再初始化符号表然后进入语法分析入口。argc 2的判断在实验环境下很实用直接运行不带参数会给出提示而不是段错误这种防御性写法在课程实验里值得保留。2.2 构建脚本与第一个样例用 make 跑通默认用例接下来是把工程编译成可执行文件。这份资源里一般自带 Makefile如果没有我建议按下面的方式补一个这样后面改任何文件都只需要重新 makeCC gcc CFLAGS -Wall -g -stdc99 OBJS lexer.o parser.o symtab.o intercode.o main.o compiler: $(OBJS) $(CC) -o compiler $(OBJS) lexer.o: lexer.c lexer.h token.h parser.o: parser.c lexer.h parser.h token.h symtab.h symtab.o: symtab.c symtab.h token.h intercode.o: intercode.c symtab.h token.h clean: rm -f *.o compiler-g选项一定要留后面语法分析出现段错误时你用 gdb 跑一下 bt 就能直接看到挂在哪一个函数里这在实验阶段能省下大量排查时间。-stdc99是因为代码里可能用了//注释或 for 循环内声明变量如果你的编译器版本较老换成-stdc99才会不报 warning。跑通默认用例之后先别急着改代码。打开 lexer.c 和 parser.c 各完整读一遍把 token 类型和中途输出的调试信息对应起来。这样到写报告时你能说清楚每一个模块的数据流是什么样的而不是笼统地写“实现了词法分析和语法分析”。3. 词法分析器实现从正规式到 DFA 的 C 语言落地词法分析是编译原理实验里最直观、也是最好拿分的一遍。它的任务说白了就是把源代码字符串拆成一个个有类型有值的 token。教科书上讲的是从正规式构造 NFA、再确定化为 DFA但实验代码里通常不会真的去写一个子集构造算法而是手写状态机或者直接用分支判断。因为课程实验的源语言比较简单token 种类有限手写状态机的可读性和可控性反而更好。3.1 状态机设计为什么用逐字符扫描而不是正则库有的同学偷懒想直接用正则库把整个文件切分比如用regex.h一段段 match。这在实验里是能跑但问题在于一是正则库对中文报错信息不友好二是你没有办法在出错时精确给出行列号。手写逐字符扫描就不同每读一个字符都可以维护 line 和 col报错时直接给出精确位置这个能力在验收演示时特别加分。#include stdio.h #include ctype.h #include string.h #include token.h static int line 1; static int col 1; int next_token(FILE *fp, Token *tok) { int c fgetc(fp); while (c || c \t || c \n || c \r) { if (c \n) { line; col 1; } else { col; } c fgetc(fp); } if (c EOF) { tok-type TOK_EOF; return 0; } if (isalpha(c) || c _) { int len 0; tok-line line; tok-col col; while (isalnum(c) || c _) { if (len 127) tok-text[len] (char)c; c fgetc(fp); col; } if (c ! EOF) ungetc(c, fp); if (len 127) len 127; tok-text[len] \0; tok-type lookup_keyword(tok-text); return 1; } if (isdigit(c)) { int len 0; tok-line line; tok-col col; while (isdigit(c)) { if (len 127) tok-text[len] (char)c; c fgetc(fp); col; } if (c ! EOF) ungetc(c, fp); tok-text[len] \0; tok-type TOK_INT; return 1; } tok-line line; tok-col col; switch (c) { case : tok-type TOK_PLUS; break; case -: tok-type TOK_MINUS; break; case *: tok-type TOK_STAR; break; case /: tok-type TOK_SLASH; break; case ;: tok-type TOK_SEMI; break; case : c fgetc(fp); if (c ) { tok-type TOK_EQ; } else { ungetc(c, fp); tok-type TOK_ASSIGN; } break; default: tok-type TOK_UNKNOWN; return 0; } return 1; }这段代码里面有两个细节值得注意。第一个是ungetc(c, fp)的回退处理——当标识符扫描到第一个不属于字母或数字的字符时这个字符是下一个 token 的开头必须放回流中否则a1会被切成a和1当场翻车。第二个是lookup_keyword(tok-text)的设计它先按标识符处理再查关键字表这样if、while这些保留字能正确区分出 TOK_IF、TOK_WHILE而ifx这类名字仍然是普通标识符。3.2 参数说明与常见误用为什么 text 要限制长度tok-text[128]这个设计是给实验代码用的折中方案。如果直接用char *动态字符串内存管理会分散注意力而且一旦漏 free后面符号表和中间代码全跟着崩。128 字节对课程实验的标识符长度完全足够代价是超出部分会被截断你可以在标识符处理时给个报错提示。真正的编译器会用可变长缓冲但实验阶段不需要较真这个。还有一个小坑空字符\0的位置。上面代码里用len 127限制写入最后强制tok-text[len] \0这一步防的是极端情况——整个文件都是字母导致 len 无限增长。很多同学忽略这个边界条件遇到长标识符时会直接踩到缓冲区溢出表现为运行到一半出现乱码或奇怪字符实际上就是越界写坏了别的变量。3.3 扩展支持 // 与 /* */ 注释大部分实验代码初始版本不支持注释但报告里如果能加注释支持验收时是明显的加分项。实现起来是在/的分支里多判断一层case /: c fgetc(fp); if (c /) { while (c ! \n c ! EOF) c fgetc(fp); line; col 1; return next_token(fp, tok); } else if (c *) { while (1) { c fgetc(fp); if (c EOF) break; if (c \n) { line; col 1; } else col; c fgetc(fp); if (c * fgetc(fp) /) break; } return next_token(fp, tok); } else { ungetc(c, fp); tok-type TOK_SLASH; break; }这段代码里的核心技巧是注释跳过之后return next_token(fp, tok)递归调用自己也就是说注释不会产生任何 token继续向下扫描。我故意保留了/单独出现时的ungetc分支因为a / b和a // b在这种逐字符状态下就差这一个字符的回退写错就会把除号吞掉。4. 递归下降语法分析FIRST/FOLLOW 集如何变成可调试的代码词法分析做完token 流已经有了下一步是语法分析。这个实验工程里用的是递归下降法不是 yacc 之类的生成器。原因很实际课程要求你在报告里把每个函数的职责写清楚而递归下降的每个函数和文法里的每个非终结符一一对应你能直接对着代码讲文法答辩时不用支支吾吾。4.1 文法改造左递归消除与优先级分层教科书的表达式文法一般是E - E T | T但这直接写进递归函数就是死循环——parse_expr第一行调用parse_expr永远不能前进。所以实验代码里用的是消除左递归之后的文法expr - term expr expr - term expr | - term expr | ε term - factor term term - * factor term | / factor term | ε factor - ( expr ) | num | ident这套文法的好处是它天然把优先级分层了加减在 expr 层乘除在 term 层括号和操作数在 factor 层。1 2 * 3会被构造成1 (2 * 3)不会算出 9。每次调用parse_term进入更深一层实际上就是优先级降低一层。4.2 表达式解析的代码骨架void parse_expr() { parse_term(); while (tok.type TOK_PLUS || tok.type TOK_MINUS) { Token op tok; advance(); parse_term(); printf(四元式: (%c, %s, %s, t%d)\n, op.type TOK_PLUS ? : -, op.text, op.text, next_tmp()); } } void parse_term() { parse_factor(); while (tok.type TOK_STAR || tok.type TOK_SLASH) { Token op tok; advance(); parse_factor(); printf(四元式: (%c, %s, %s, t%d)\n, op.type TOK_STAR ? * : /, op.text, op.text, next_tmp()); } } void parse_factor() { if (tok.type TOK_INT || tok.type TOK_IDENT) { printf(四元式: (:, %s, _, t%d)\n, tok.text, next_tmp()); advance(); } else if (tok.type TOK_LP) { advance(); parse_expr(); if (tok.type TOK_RP) advance(); else error(缺少右括号); } else { error(非法的表达式起始 token); } }这个版本的要点是while循环代替了教科书里的expr - ε递归。因为expr的ε产生式就是“什么都不做”用循环反复判断当前 token 是加减号等于把expr展开成了迭代形式代码量更小运行栈也更安全。next_tmp()是中间代码里给临时变量编号用的每次生成一个新名字 t1、t2、t3后续语义分析或代码生成会用这个编号引用。4.3 语法错误处理报错信息要能定位行列号这是实验代码里最常见的能力缺口——很多同学的 parser 遇到第一个语法错误就崩了而且崩之前打印的信息只有“syntax error”五个字完全不知道错在哪。我习惯在每个error()调用里带上 token 的行列号void error(const char *msg) { fprintf(stderr, 第 %d 行 第 %d 列: %s\n, tok.line, tok.col, msg); exit(1); }tok.line和tok.col在词法分析时就已经填充好了语法分析阶段直接拿来用就行。这里一个小技巧是报错信息里把当前 token 的文本也打出来比如“第 3 行 第 10 列: 期望标识符实际是”。这样就算报告只贴了一个出错截图老师也能一眼看出你的错误处理是认真的。有些同学的实现做了错误恢复——报完错继续往下解析这个对实验来说不是必须的但如果时间充裕可以加一个同步 token 集合的概念遇到分号或右括号时重新进入正常状态。5. 避坑清单六个最常翻车的点与排查思路这一章写的是我实际调试这套实验代码时遇到过的、以及帮别人看代码时见到最多的六个问题。每一条都是先给现象再说原因最后给解决方式碰到类似问题可以照着排查。5.1 必踩的坑坑位一一运行就段错误gdb 定位不出原因。现象是make成功但跑样例直接 segmentation fault。原因是词法分析器返回了NULL指针或者 ungetc 回退时对同一个字符做了两次回退导致流状态混乱。解决方式是先在next_token()的入口 fprintf 打印当前读到的字符和调用次数确认扫描有没有推进。实践证明绝大多数段错误不是逻辑复杂而是某次fgetc返回EOF之后又继续访问了tok-text。坑位二输入的12*3算成了 9。现象是四元式输出顺序是先加法再乘法。原因非常典型——parse_expr里不小心调用了parse_expr()而不是parse_term()等于把优先级结构写成了完全左结合。解决方式是检查parse_expr的循环体第一行是不是parse_term()这两个函数名称只差一个字母却是乘法优先级能不能正确体现的分界。坑位三符号表查不到变量明明之前声明过。现象是在函数体内部声明的局部变量在表达式中使用时报“未定义变量”。原因大概率是作用域链没有在离开函数时弹出或者lookup时只看了当前层符号表没沿作用域链向上逐层查找。解决方式是给符号表加 scope 字段查找时从当前层开始逐层回退直到找到或返回空。坑位四fgets 读文件时遇到空行就乱套。现象是代码在 Windows 环境下写了一个空行后词法分析时报出奇怪的字符。原因是fgets保留了 CRLF 的\r而词法分析器只跳过\n和\t\r就成了未知字符。解决方式有二选一要么在读取后把结尾的\r手动清掉要么在词法分析的空白判断里加上c \r。坑位五数组越界导致中间代码胡乱覆盖。现象是代码里定义了很长的标识符比如int this_is_a_very_long_variable_name_test;之后程序行为变得诡异有时打印乱码、有时段错误。原因是 token 结构体里text[128]溢出。解决方式是我在第 3 章写的那个len 127限制但更底层的办法是让 lexer 在发现标识符长度超过上限时直接报错而不是截断后继续运行。坑位六语法分析器报错信息指向错误位置。现象是明明第 5 行报错但实际错误在第 2 行就开始了。原因通常是报错时机滞后——解析器在lookahead一个 token 之后才发现不匹配而错误的起点应该回溯到上一次读取 token 的位置。解决方式是保存一个“最近一次读取 token 的位置”缓存在错误发生时回退这个位置再打点。多数实验代码不太在意这个精度但答辩时老师会手动输入一个错位代码来测试这个细节直接加分。5.2 排查工具与验证方法如果拿到代码后不知道从哪里下手我建议先跑一个小脚本把输入文件换成只有三行内容的极简样例逐个打印 token 类型和内容确认词法正常后再交由语法分析。这一步只需要在 main.c 里临时加一个循环调用next_token打印所有结果不需要写完整语法分析。用 gdb 跑bt指令看调用栈在parser.c的advance()函数设置断点单步跟几轮就能看出解析卡在哪一个函数。另外建议准备一组边界测试文件空文件、只有注释的文件、只有分号的文件、表达式缺右括号的文件。这些边界文件的价值在于提前暴露问题而不是验收时被老师当场试出来。6. 验收技巧用一组样例把整条链路验证到答辩状态到验收前最后一个晚上你需要的不再是加功能而是把整条链路从头到尾稳定跑通。我建议准备三个层级的测试样例按从简到难的顺序验收。第一级是一个声明加一条赋值语句的极简程序int a; void main(void) { a 1 2 * 3; }这个用例验证的是最核心的表达式优先级和符号表登记。期望输出里2 * 3的四元式必须出现在1 ...之前顺序反了就是优先级错。第二级加入条件分支和循环重点是作用域处理。比如在 if 分支里声明一个变量确认离开分支后这个变量不可见在 while 循环里让循环体执行多次确认变量多次赋值不会重复登记。第三级放进注释和多行字符串保证词法分析不会被注释里的特殊字符影响。这三个样例全部跑通答辩演示时直接按顺序执行比临时写测试代码稳妥得多。我在这个工程上踩过的最大的坑是答辩前一晚改了一个算法逻辑但是忘了把之前的可执行文件重新编译第二天演示用的是旧版本功能不完整被老师当场指出来。从那以后我每次上机前都会强制走一遍make clean make然后重新执行三个测试样例的完整流程确认输出和预期一致才上机。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进