ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

编译原理实验避坑指南:从词法分析到三地址码的完整实践路线

2026/10/3 2:46:29 拓冰建站 浏览量
编译原理实验避坑指南:从词法分析到三地址码的完整实践路线 简介面向NUAA南航计算机科学与技术及物联网工程专业学生的编译原理课程实验资料包聚焦编译器前端两大核心模块。资源共7个文件含2个C源码文件对应词法分析器与语法分析器实现、2个可直接运行的exe程序以及3个txt测试代码与说明文档压缩包整体约1.01MB结构精简、上手便捷。资料覆盖从字符流到标记Token的词法分解再到基于上下文无关文法CFG的语法检查与抽象语法树AST构建并配有覆盖正常与异常输入的测试用例便于对照验证、排查括号不匹配、非法字符、语法错误等典型问题。使用C实现既能帮助理解正则表达式、状态机、LL(1)/LR(0)/LALR(1)等核心概念也能同步锻炼编程与调试能力。已有602人学习下载适合南航相关专业本科生课后实验、考前复习或课程设计参考。1. NUAA编译原理实验先看清这门课到底要你交出什么南航计算机科学与技术专业和物联网工程专业的编译原理实验最终大多会沉淀成一份zip压缩包里面是一个能跑通教学样例的小型编译器配套一份实验报告和若干运行截图。很多人把精力全砸在“让代码跑通”上结果交上去才发现扣分最多的往往是错误处理、行列号输出、报告与代码对不上这类细节。编译原理实验的本质不是让你造一个能商用的编译器而是让你把词法、语法、语义三层从黑匣子变成白盒——这正好也是这门课在计算机科学与技术专业课程体系里不可替代的原因。下面按词法分析、语法分析、语义分析与中间代码的推进顺序讲一套能照着做、能躲开常见扣分点的落地路线。2. 词法分析实验手写状态机还是Flex决定你后面几周的节奏2.1 先拆评分点词法分析器到底在测什么词法分析是所有编译原理实验的第一站也是最好拿分的一站。常见的教学要求是从源文件里识别关键字、标识符、整型常量、浮点常量、运算符、分隔符、字符串字面量和注释遇到非法字符要报错并且报错信息里最好带上行号和列号。看起来要求不高但评分样例往往很刁钻——它们专门测你没想过的情况比如“数字后面紧跟字母该不该报错”“两个运算符连在一起怎么切分”“注释没闭合怎么办”。这些边界情况恰好就是词法分析实验的评分点。很多人的代码能识别正常输入一旦碰到非法字符就崩或者死循环分数直接从优秀掉到及格线。我的建议是第一周先别急着写代码把你们课程里给的token类别表列出来对照着给每种token设计两个测试用例一个正常路径一个异常路径。这样写实现的时候就不会漏分支。另外一个容易忽略的点是输出格式。教学平台一般会要求类似(类别, 词素, 行号, 列号)的固定格式或者要求每行一个token。格式不匹配会被判定为“输出错误”即使你的词法分析逻辑全对。所以动手前先确认样例输出的确切格式最好把一两个样例输出保存下来照着对齐。2.2 手写词法分析器一个能跑的最小C实现手写词法分析器最大的好处是可控代码全是自己的出问题很容易定位。这里给一个最小实现骨架能识别标识符、数字、单字符运算符和非法字符并带行号统计。它不追求功能完整但展示了词法分析最核心的套路读一个字符、判断归属、必要时回退ungetc。// lex_test.c —— 最小词法分析器骨架演示字符回退与行号统计 #include stdio.h #include ctype.h #include string.h typedef enum { TOK_ID, TOK_NUM, TOK_OP, TOK_ERR, TOK_EOF } TokenType; // 关键字表用线性查找即可实验规模下二分没有意义 static const char *keywords[] {int, float, if, else, while, return}; static int is_keyword(const char *s) { for (int i 0; i 6; i) { if (strcmp(s, keywords[i]) 0) return 1; } return 0; } // 主扫描函数每次调用解析出一个token // fp源文件指针lexeme存放词素的缓冲区line/col带回行列号 TokenType next_token(FILE *fp, char *lexeme, int *line, int *col) { int c fgetc(fp); // 跳过空白并维护行列号 while (c || c \t || c \n) { if (c \n) { (*line); *col 1; } else (*col); c fgetc(fp); } if (c EOF) return TOK_EOF; // 标识符或关键字字母或下划线开头后面跟字母/数字/下划线 if (isalpha(c) || c _) { int len 0; lexeme[len] (char)c; (*col); while (isalnum(c fgetc(fp)) || c _) { if (len 127) lexeme[len] (char)c; // 防止缓冲区溢出 (*col); } ungetc(c, fp); // 多读的那个字符不属于本token回退 (*col)--; // 回退后列号也要跟着回退 lexeme[len] \0; return is_keyword(lexeme) ? TOK_ID : TOK_ID; // 也可区分关键字类型 } // 数字只处理无符号整数浮点数留作扩展 if (isdigit(c)) { int len 0; while (isdigit(c fgetc(fp))) { if (len 127) lexeme[len] (char)c; (*col); } ungetc(c, fp); (*col)--; lexeme[len] \0; return TOK_NUM; } // 单字符运算符 if (strchr(-*/();,, c)) { lexeme[0] (char)c; lexeme[1] \0; (*col); return TOK_OP; } // 非法字符走上报分支不让主程序崩溃 lexeme[0] (char)c; lexeme[1] \0; (*col); return TOK_ERR; } int main(void) { FILE *fp fopen(test.c, r); if (!fp) { perror(open failed); return 1; } char lexeme[128]; int line 1, col 1; TokenType t; while ((t next_token(fp, lexeme, line, col)) ! TOK_EOF) { printf((%d,%d): %s\n, line, col, lexeme); } fclose(fp); return 0; }这段代码核心就两个操作fgetc读字符和ungetc回退。数字和标识符的扫描都用“多读一个再回退”的方式处理边界——比如读到abc123时扫描完abc后多读的1是数字用ungetc放回流里下一个token从1开始。很多新手翻车就翻在忘记回退或者回退后又fgetc了一次导致字符丢失。行列号的维护也有讲究跳过换行时行号加一、列号归零ungetc之后列号必须同步减一否则报错行列全偏。后面做语法分析时行列号是整个调错过程的救命稻草一开始就维护好后面省大量时间。2.3 Flex路线正则省事但Windows环境先过三关手写词法分析代码量约两三百行对想省时间的同学用Flexlex的GNU实现是另一种常见路线。Flex的输入是一个.l文件分三段定义段、规则段、用户代码段。规则段里每条规则是“正则表达式 动作代码”比如识别数字写[0-9] { return TOK_NUM; }。写起来确实快但第一次在Windows下用Flex的人通常会连续踩三个坑。第一个坑是unistd.h不存在。Flex生成的lex.yy.c会引用unistd.h这是Unix头文件Windows的MinGW里可能没有。解决方法是生成后手动加一行兼容宏// 在 lex.yy.c 的开头插入或在 .l 文件的 %{ %} 块里加 #ifdef _WIN32 #include io.h #define unistd unistd_h // 避免与系统头冲突 #include unistd.h #undef unistd #endif更省事的办法是用#include io.h替代或者直接把Flex生成代码里的isatty相关分支删掉——很多误报错其实来自isatty判断。第二个坑是编码。.l文件里的正则如果包含中文字符串匹配文件编码必须是UTF-8而Windows记事本默认存成GBKFlex解析规则段时直接报错。把编辑器默认编码改成UTF-8就能避开。第三个坑是Flex版本差异不同版本生成的代码结构不同网上抄来的补丁代码不一定适配。我一般建议如果你本来就不熟悉正则的贪婪匹配和优先级规则直接入手写实现反而更快Flex适合那种一年写一次、规则很多、手感熟练的人。3. 语法分析实验递归下降和LR选型关联你期末周的睡眠质量3.1 为什么课程实验的输入文法天生适合递归下降语法分析是编译原理实验里最劝退的一站。常见实现路线就两条递归下降Recursive Descent和LR分析用Bison/yacc。选型不是看哪个技术更高级而是看你的输入文法长什么样、实验周期有多少天。课程实验给的文法为了方便教学大部分会改写成LL(1)形式没有左递归、每步产生式最多有一个不确定选择。这种文法用递归下降分析器是顺理成章的——每个非终结符对应一个函数函数内部按产生式右侧的符号序列逐个匹配。代码结构和文法一一对应出错了直接看是哪个函数报的错调起来非常直观。而LR分析走的是状态机路线Bison生成的分析表是黑匣子冲突信息普通人根本读不懂比如shift/reduce conflict到底发生在哪个产生式、什么语境不查半天文档根本定位不了。一个现实因素是代码量。递归下降实现一个表达式文法只要一百多行而同样的文法用Bison写光读.output文件排查冲突就得一天。对物联网工程这类课时被压缩的专业递归下降几乎是唯一能在截止日前交上来的路线。我见过太多人选了Bison最后一周全耗在“为什么我的文法有8个shift/reduce冲突”上后悔药都没处买。3.2 递归下降分析器骨架表达式文法的完整实现下面是一个能处理加、减、乘、除和括号的表达式递归下降分析器核心。文法经过左递归消除后变成expr - term { (|-) term } term - factor { (*|/) factor } factor - number | ( expr )每个非终结符一个函数match用来消费token并推进词法分析器。// parser.c —— 递归下降分析器骨架配合上一章词法分析器使用 #include stdio.h #include stdlib.h // 词法token类型假设已经定义 typedef enum { TOK_NUM, TOK_ADD, TOK_SUB, TOK_MUL, TOK_DIV, TOK_LP, TOK_RP, TOK_EOF } Token; extern Token next_token(void); // 词法接口返回下一个token static Token lookahead; // 当前lookahead token // 消费一个token若类型不符则报语法错误 static void match(Token expected) { if (lookahead expected) { lookahead next_token(); } else { fprintf(stderr, syntax error: expected token %d, got %d\n, expected, lookahead); // 简易错误恢复跳过当前token避免无限循环 lookahead next_token(); } } // factor - number | ( expr ) static int factor(void) { if (lookahead TOK_NUM) { int val get_num_value(); // 取词法值 match(TOK_NUM); return val; } else if (lookahead TOK_LP) { match(TOK_LP); int val expr(); match(TOK_RP); return val; } else { fprintf(stderr, syntax error: unexpected token in factor\n); return 0; } } // term - factor { (*|/) factor } static int term(void) { int val factor(); while (lookahead TOK_MUL || lookahead TOK_DIV) { if (lookahead TOK_MUL) { match(TOK_MUL); val * factor(); } else { match(TOK_DIV); int divisor factor(); if (divisor 0) fprintf(stderr, runtime error: divide by zero\n); else val / divisor; } } return val; } // expr - term { (|-) term } static int expr(void) { int val term(); while (lookahead TOK_ADD || lookahead TOK_SUB) { if (lookahead TOK_ADD) { match(TOK_ADD); val term(); } else { match(TOK_SUB); val - term(); } } return val; }这段代码里最容易出错的是递归深度和错误恢复。表达式嵌套很深时expr和factor互相递归调用C的默认栈空间大约1MB嵌套上万层会栈溢出。实验级别的样例不会这么刁钻但如果你的测试脚本生成了超长表达式就得考虑把递归改成显式栈的循环版本。错误恢复这里用了最简单的“跳过当前token”策略——课程实验一般不需要像GCC那样能一次报出所有错误能报出第一个错误并正常退出就够及格了。另外要注意match里对错误token的处理。如果写成“出错后直接exit”那么一个错误就终止整个分析后续错误全部看不到如果用跳过策略虽然可能会连带报出几个假错误但至少能看到更多线索。我建议报告里写清楚你采用的是哪种策略、为什么很多老师会因为这里写得明白而给加分。3.3 如果你非要上Bison冲突排查的三条经验有些学校的实验明确要求必须用yacc/Bison生成语法分析器这没法绕开。如果你必须走LR路线记住三条实战经验。第一条把%left、%right、%nonassoc优先级声明写在规则之前这是解决表达式文法shift/reduce冲突的标准手段。不写优先级Bison会把12*3的两种归约都视为冲突生成的分析表会带一堆警告。第二条善用.output文件。加-v选项生成y.output里面详细列出了每个状态的分析表冲突发生在哪个状态下、涉及哪些item一目了然。第三条调试时用YYDEBUG宏#define YYDEBUG 1后设置全局变量yydebug 1Bison会打印每一步shift和reduce的动作序列——这是LR分析器唯一的可读调试图谱。如果你选了Bison路线我建议不要直接拿一整份完整文法去撞而是先从表达式子集做起来确认冲突清零后再逐步加入控制流。每加几个产生式就重新生成并跑一次样例冲突出现时能立刻定位到新加的部分。千万不要攒到最后一起编。4. 语义分析与中间代码生成编译原理实验的分数分水岭4.1 语义检查的四个必做点到了这一层词法和语法都通了实验要求的“编译器”才开始像个真正的东西。语义分析的核心任务是对语法分析器生成的语法树做静态检查最常见的四个必做点是变量未声明、变量重复声明、赋值类型不匹配、运算操作数类型不匹配。未声明变量的检查依赖符号表核心逻辑很简单标识符出现在表达式中时查符号表查不到就报错。重复声明则是在声明语句进入作用域时检查符号表内是否已存在同名条目。类型不匹配要看你定义的类型体系常见的是int、float和void至少要做“int和float能否直接加减乘除”这类检查。比如float int是否允许、若允许是否要插入隐式转换这是评分样例里一定会出现的题点。值得提前确认的是你们的实验说明里是不是要求“错误恢复之后继续分析”。如果是那么语义检查遇到错误不能直接退出要打印错误信息后继续遍历语法树。这就要求你的语义分析函数在发现错误后返回一个“安全值”避免空指针访问导致整个程序崩溃。这种继续分析的设计代码里要写成错误收集器error collector的形式把所有错误统一收集、最后一次性打印。4.2 符号表实现作用域栈的出入时机符号表是语义分析的地基。常见的课程实现是“作用域栈 每层一个哈希表”栈顶是当前作用域。进入一个代码块比如函数体或复合语句时压栈离开时弹栈。查找变量时从栈顶向下逐层找——这正好对应C语言的作用域遮蔽规则。// symtab.h —— 作用域栈式符号表每层用链表即可满足课程需求 #include string.h #include stdlib.h typedef struct Symbol { char *name; int type; // 0int, 1float, 2void int declared_line; // 声明所在行报错用 struct Symbol *next; } Symbol; typedef struct Scope { Symbol *head; // 本作用域的符号链表 struct Scope *parent; } Scope; static Scope *current_scope NULL; void scope_push(void) { Scope *s (Scope *)calloc(1, sizeof(Scope)); s-parent current_scope; current_scope s; } void scope_pop(void) { if (current_scope) { Scope *tmp current_scope; current_scope current_scope-parent; free(tmp); // 注意只释放作用域外壳不处理符号内存 } } // 只查当前作用域 Symbol *lookup_current(const char *name) { for (Symbol *s current_scope ? current_scope-head : NULL; s; s s-next) { if (strcmp(s-name, name) 0) return s; } return NULL; } // 按作用域链逐层查找 Symbol *lookup_all(const char *name) { for (Scope *s current_scope; s; s s-parent) { for (Symbol *sym s-head; sym; sym sym-next) { if (strcmp(sym-name, name) 0) return sym; } } return NULL; }作用域栈的进出时机要和语法分析的动作严格对应。递归下降分析器里进入复合语句的函数调用scope_push处理完所有语句后调用scope_pop。这里最容易踩的坑是错误恢复路径上如果直接return了就会漏掉scope_pop导致后续作用域全乱。比较稳妥的做法是进入作用域后记录状态用goto或统一在函数出口弹栈避免多个return分支各自维护弹栈。另外声明的行号必须存下来因为“变量在第几行重复声明”是评分样例里要输出的信息。4.3 三地址码生成把AST压成指令序列的最小思路如果你们的实验只做到语义分析就可以交差这节可以跳读。但多数学校会要求生成中间代码或目标代码三地址码Three Address Code简称TAC是最常见的中间表示。它的特点是每条指令至多三个操作数比如t1 t2 t3、if t1 goto L1。生成TAC的思想是从语法树自底向上或递归下降的同时发出指令。给每个表达式节点分配一个临时变量把计算结果存进去。以a b * c为例b * c生成t1 b * c整个表达式生成t2 a t1。临时变量编号用全局计数器递增保证唯一。关键点是把临时变量的声明和数组/链表存储方案定好// tac.h —— 三地址码指令结构与生成器最小实现 #include stdio.h #include stdlib.h #include string.h typedef enum { TAC_ADD, TAC_SUB, TAC_MUL, TAC_DIV, TAC_ASSIGN, TAC_LABEL, TAC_GOTO, TAC_IF } TacOp; typedef struct Tac { TacOp op; char arg1[32]; // 第一个操作数变量名或临时变量名 char arg2[32]; // 第二个操作数没有则置空 char result[32]; struct Tac *next; } Tac; static Tac *tac_head NULL, *tac_tail NULL; static int temp_count 0; // 临时变量编号计数器 static int label_count 0; // 标签编号计数器 // 生成一个新临时变量名例如 t1, t2, t3... char *new_temp(void) { char *name malloc(16); sprintf(name, t%d, temp_count); return name; } // 生成一个新标签名例如 L1, L2... char *new_label(void) { char *name malloc(16); sprintf(name, L%d, label_count); return name; } // 追加一条三地址码指令到链尾 void emit(TacOp op, const char *a1, const char *a2, const char *res) { Tac *inst (Tac *)calloc(1, sizeof(Tac)); inst-op op; strncpy(inst-arg1, a1 ? a1 : , 31); strncpy(inst-arg2, a2 ? a2 : , 31); strncpy(inst-result, res ? res : , 31); if (tac_tail) { tac_tail-next inst; tac_tail inst; } else { tac_head tac_tail inst; } } // 打印指令序列便于验证 void dump_tac(void) { const char *opname[] {ADD,SUB,MUL,DIV,ASSIGN,LABEL,GOTO,IF}; int label 1; for (Tac *p tac_head; p; p p-next) { if (p-op TAC_LABEL) { printf(L%d:\n, label); } else if (p-op TAC_ASSIGN) { printf( %s %s\n, p-result, p-arg1); } else { printf( %s %s %s %s\n, p-result, p-arg1, opname[p-op], p-arg2); } } }这里要留意的是操作数存储策略。用定长char[32]数组简单但容易截断用char*动态分配则要管理生命周期防止内存泄漏。对实验规模的程序数组够用但报告里最好给出长度上限的说明——比如“变量名最大31字符超出部分截断并给出警告”。临时变量编号从0开始递增标签编号要跟IF指令里的跳转目标对应这需要你在生成IF时先new_label拿到目标名再在稍后位置emit(TAC_LABEL...)输出。跳转指令的“先占坑、后填地址”是中间代码生成里最常见的逻辑想明白了这一条控制流的实现就顺了。5. 编译原理实验避坑清单五个真实翻车现场与修复顺序5.1 zip伪加密与解压失败交作业前先验证压缩包现象实验包用WinRAR打的zip在自己电脑上双击能开传到教学平台或同学的机器上就提示“文件损坏”或要求输入密码。原因很可能是zip伪加密——打包工具给本地文件头加了加密标志位但数据本身没加密WinRAR和系统自带解压工具对这种“伪加密”处理方式不同有的直接拒绝解压。类似问题也大量出现在从网上下载的实验zip资源里很多人下完发现解不开第一反应是找破解工具其实只是伪加密标志位在作怪。解决用7-Zip打开zip包如果能看到文件但双击时才要密码基本就是伪加密。最稳的办法是重新打包在7-Zip里把文件全选拖出来再手动压缩成标准zip。交作业前养成一个习惯换一台没有装WinRAR的电脑用Windows自带解压试试能不能正常打开。这一步能拦下九成的解压翻车。提示如果zip包真是自己加过密码的解压时用命令行7z x 包名.zip -P密码可以绕过部分图形界面的怪毛病。实验资源类zip一般不该加密加了密码会给评分老师添麻烦也可能被误判为异常文件。5.2 中文乱码编码不统一是报告扣分的隐形杀手现象源代码里的中文注释在Linux下编译显示乱码报告里的截图中文全是“”或者代码文件在VS Code里打开乱码。原因是你的源码是GBK编码而编译器和实验平台默认按UTF-8读。词法分析里如果写死了某个中文字符串匹配编码不一致直接导致匹配失败。解决所有源文件、.l/.y文件、报告统一存成UTF-8无BOM。Windows下VS Code默认UTF-8但记事本另存时要手动选编码如果不得不处理GBK旧文件在编译命令里加-finput-charsetGBK -fexec-charsetUTF-8转换。这个坑在实践课里特别隐蔽因为你的代码逻辑一点问题没有就是乱码导致输出异常白白扣了格式分。5.3 Flex生成的代码在Windows编译不过缺unistd.h的兼容写法现象用Flex生成lex.yy.c后在MinGW或Dev-Cpp里编译报错说找不到unistd.h。原因是Flex生成的代码包含Unix系统头Windows环境没有。这不是你的错也不是Flex版本的问题是跨平台的天然冲突。解决在.l文件的%{ %}定义段里直接加条件编译%{ #ifdef _WIN32 #include io.h #else #include unistd.h #endif %}如果还报isatty未定义就把yywrap相关代码改成#define yywrap() 1。这条改完基本就能在Windows下编过了。记住凡是Flex/Bison生成的C代码跨平台问题都能用条件编译兜底。5.4 本地能跑样例就崩内存问题的排查顺序现象自己写的测试程序全部通过一跑老师的评分样例就段错误Segmentation fault或者输出跟期望值差一个数字。原因大概率是缓冲区溢出、指针未初始化、或者词法分析器读到了字符串常量的末尾却没有处理结束符。解决按三个顺序排查。第一用AddressSanitizer重新编译gcc -fsanitizeaddress -g它能精确定位越界位置。第二检查所有ungetc是否成对——漏一个回退会导致所有token错位表面上看起来像逻辑错误。第三检查char lexeme[128]这类固定缓冲区在写入前是否有长度上限判断把strcpy全换成strncpy。这三步跑完九成段错误能定位。别急着怀疑算法先怀疑内存。5.5 流程图和代码对不上报告与实现的同步纪律现象实验报告里画的模块流程图是“先判断关键字再判断标识符”代码里实际是“先识别所有标识符再回表查关键字”——两者结果一样但流程图和代码逻辑顺序不一致。评阅老师按图读代码对不上就会认为你抄了别人的报告或者贴错图。解决代码定稿之后再画流程图不要先画图后写代码。改过算法就顺手更新对应图。还有一个小技巧报告里的代码片段不要手敲直接从编辑器复制这样至少不会出现“报告里的代码编译不过、交上去的代码却没问题”的乌龙。这种不一致在历届实验中扣分频率极高属于“完全可以避免的低级失误”。6. 让实验从「能跑」进化到「稳过」三个验证技巧和一组收尾习惯第一个验证技巧是差分测试。不要只跑老师给的样例自己写一个“预期输出生成器”——用Python写一个脚本生成随机表达式同时算出正确结果再拿你的编译器去跑同一输入对比结果。这能把隐藏的逻辑错误快速暴露出来。差分测试的投入产出比极高写一百行脚本能帮你省一整天的debug时间。第二个技巧是中间表示的可视化。如果你做了三地址码生成把dump_tac的输出保留到文件跑样例时顺便检查指令序列是否符合预期。比如a12*3的TAC应该是先算乘法再算加法如果你看到先加法后乘法说明语法树建立阶段就有问题。把中间表示打印出来等于给黑匣子开了扇窗。第三个技巧是提交前做一次干净的端到端回归把代码拷贝到新目录、用Makefile重新编译、跑全部样例、再退出重来一遍。重点检查有没有只靠当前目录遗留的.o文件才能编译通过的情况。一个干净的构建过程既能让评分老师顺利复现也是你自己对代码健康的最终确认。收尾的纪律我养成了三条第一实验报告里的运行截图要符合时间线——不许用之前的截图冒充最终版本截图里的可执行文件路径和代码要能对上。第二Makefile里写清楚make clean、make all、make run三个目标多数老师会直接跑make验证。第三zip包内目录层级宁浅勿深根目录直接放源码和报告别套三层文件夹评分脚本找文件时不会去翻深路径。这三条不能直接加分但能让老师省心省心即加分。希望帮到你。本文还有配套的精品资源点击获取