☰
编译原理实验源码合集:从词法分析到中间代码生成一次跑通
2026/10/10 1:08:32 网站建设 项目流程

简介:这套面向编译原理课程的完整实验源码集合,整合了北京交通大学六个核心实验模块,覆盖编译器前端从词法分析、递归下降语法分析、LL(1)文法分析、算符优先文法,到基于SLR(1)分析法的语法制导翻译及中间代码生成的全过程。词法分析实现字符流到记号序列的转换;递归下降与LL(1)分别通过递归子程序和预测分析表验证程序语法结构;算符优先模块处理复杂运算符表达式的归约;SLR(1)在自底向上分析的同时触发语义动作;中间代码生成模块则输出独立于机器的通用表示,为后端优化奠定基础。资源共94个文件,以33个cpp源码和29个h头文件为主体,辅以21个txt测试用例、6个makefile构建脚本及少量c文件和说明文档,整体仅66KB。已有92人浏览学习。各实验按Lab01至Lab06分目录组织,配有输入输出样例与语法定义文件,既可帮助北交大学生对照完成实验报告,也为计算机专业学习者理解前端原理、动手调试语法分析与翻译流程提供了完整可运行的参考。

1. 这一份编译原理课程实验源码集合,帮你把编译器前端一次跑通

打开这份源码集合前,先想清楚你拿它干什么。如果你的处境是:下周要交词法分析和语法分析实验,自己写的状态机一跑就死循环,或者 LL1 分析表构造到一半发现文法明明含左递归却没人提醒你——那这份集合就是给你兜底的。它覆盖了编译原理课程里最经典的六个核心模块:词法分析、递归下降语法分析、LL1 文法分析、算符优先文法分析、基于 SLR(1) 分析法的语法制导翻译,以及中间代码生成,最终汇成一个可运行的编译器前端。这套东西不是玩具:词法分析能处理标识符、关键字、无符号数和运算符,语法分析从递归下降到 LR 家族一路递进,翻译阶段还能输出四元式——这正是你在实验报告里最想写清楚的一条链路。适合两类人:一类是课程进度跟不上的本科在读学生,另一类是想快速攒出一套可复现实验环境的自学者。它解决的问题很具体:让你从零开始搭前端时,不必在文法的等价变换和分析表构造上反复翻车。

2. 词法分析模块:手写有限自动机与状态转换的核心实现

2.1 为什么课程设计几乎都用手写词法分析而不是 Lex

词法分析是整条前端链路的第一个环节,也是唯一一个可以完全脱离文法、只靠正则描述来完成的模块。课程实验里通常有两种做法:一是用 Lex/Flex 生成器,二是手写一个基于确定有限自动机(DFA)的扫描器。答案是你必须手写一遍才能拿分,因为许多课程明确把“手工构造词法分析程序”列为验收要求。但更现实的理由是:手写词法分析能让你理解“正则表达式到 DFA”的等价变换过程,而 Lex 生成的代码会把这个过程完全封装成黑匣子。实验报告里你写不出状态转移表的构造思路,答辩时会被追问到失语。

我一般会在设计词法分析模块时维护一个全局的字符指针,逐字符扫描,用状态变量记录当前所处的识别状态。核心数据结构是状态转移表,table[当前状态][输入字符类别] = 下一个状态。如果你处理的关键字不超过二十个,用一张静态二维表就足够,不必上哈希表。代码结构大致如下:

#define MAX_TOKEN_LEN 64 #define STATES 12 // 字符类别:0-字母, 1-数字, 2-运算符, 3-空白, 4-其他 int state_table[STATES][5] = { /* 0 初始态 */ {1, 2, 3, 0, -1}, /* 1 标识符态 */ {1, 1, 3, 0, -1}, /* 2 数字态 */ {-1, 2, 3, 0, -1}, /* 3 运算符态 */ {-1, -1, 3, 0, -1}, // ... 其余状态按需补充 }; int next_state(int current, char ch) { int cat; if (isalpha(ch)) cat = 0; else if (isdigit(ch)) cat = 1; else if (strchr("+-*/=<>", ch)) cat = 2; else if (isspace(ch)) cat = 3; else cat = 4; return state_table[current][cat]; }

这段实现的要点是:标识符和数字都在同一张表里流转,当next_state返回 -1 时说明当前字符不能延续当前词素,此时需要回退指针,交出已识别的 token。我踩过最大的坑是状态表里忘了把数字态的字母转移设为 -1,导致类似123abc这样的非法标识符被吞成了两个 token。你要记住的最关键参数是:数字态遇到字母必须报错或回退,否则后端语法分析阶段会出现莫名其妙的“缺分号”错误。

2.2 保留字与标识符的查表处理:边界条件最容易翻车

识别出标识符之后,下一步判断它是用户自定义名字还是语言保留字。最直接的做法是:先按标识符统一识别,再查一张预置的保留字表,命中就改 token 类别。这个顺序不能反——如果一开始就按关键字逐字匹配,代码会写成一堆strncmp的分支,后续加一个关键字就要改一遍主控逻辑。

typedef struct { char *lexeme; int token_type; } ReservedWord; ReservedWord reserved[] = { {"if", IF}, {"else", ELSE}, {"while", WHILE}, {"int", INT}, {"void", VOID}, {"return", RETURN}, {"main", MAIN} }; int classify_identifier(char *buf) { for (int i = 0; i < sizeof(reserved)/sizeof(ReservedWord); i++) { if (strcmp(buf, reserved[i].lexeme) == 0) return reserved[i].token_type; } return IDENTIFIER; }

查表逻辑本身没什么难度,真正的坑是大小写敏感。我见过不少学生的实验里把If和IF都当关键字放行,结果语义分析阶段变量名冲突检查全乱了。另一个隐蔽问题是标识符长度:很多语言规定前 8 位有效,但现代课程实现中一般不做截断,而是直接接受长名字。建议在读入缓冲区时检测MAX_TOKEN_LEN上限,超出就报错,并明确写出“标识符长度不得超过 64”这样的错误信息。否则缓冲区溢出会以极其诡异的方式破坏后面的语法分析。

词法分析模块完成后,你得到的不只是一堆 token,而是一个可供后端消费的 token 流。注意:token 流里必须保留行号和列号信息,至少保留行号。没有行号的编译错误提示是灾难——你后面调 LL1 或 SLR(1) 分析时,所有语法报错都会变成“第 0 行第 0 列”的废信息,排查难度直接翻倍。

3. LL1 与递归下降语法分析:消除左递归和分析表构造的完整方案

3.1 从文法到预测分析表:FIRST 集与 FOLLOW 集的闭包计算

递归下降分析是手写语法分析器的起点,LL1 是它的表驱动版本。两者的共同前提是:文法必须是无左递归、无公共左因子的 LL(1) 文法。课程实验里最常给的四则运算表达式文法通常长这样:E -> E + T | T,这显然含左递归,第一步就是等价变换。

// 消除左递归:E -> E + T | T 变为 E -> T E' // E' -> + T E' | ε // 注意:E' 的产生式里不能出现左递归,否则前功尽弃 // 变换后的终结符集合: // FIRST(E) = FIRST(T) = { (, id } // FIRST(E') = { +, ε } // FOLLOW(E) = { $, ) } // FOLLOW(E') = FOLLOW(E)

构造 LL1 分析表时,对每个产生式A -> α,把该产生式填入table[A][每个属于 FIRST(α) 的终结符];如果α能推出 ε,则再把产生式填入table[A][每个属于 FOLLOW(A) 的终结符]。这是整个表驱动分析的核心规则。

int predict_table[NUM_NONTERMINAL][NUM_TERMINAL]; // 初始化全为 -1,表示报错 // 填入产生式编号,每个非终结符的每条产生式按 FIRST/FOLLOW 规则填入 // 表驱动预测分析主循环 int analyze(char *token_stream[]) { int stack[STACK_SIZE]; push(END_MARKER); push(START_SYMBOL); while (stack not empty) { top = peek(stack); token = current_input_token; if (top == token) { pop(); advance_input(); } else if (is_terminal(top)) { error("非法输入"); return -1; } else { int prod = predict_table[top][token]; if (prod == -1) { error("分析表空项"); return -1; } pop(); push_production_rhs(prod); // 右部倒序入栈 } } return 0; }

这段代码是 LL1 分析的核心骨架。它的逻辑是:栈顶是终结符就匹配输入,是非终结符就查表选择产生式,查到空项就报语法错误。你最容易出错的地方是产生式右部入栈的顺序——必须右部倒序入栈,这样左部第一个符号才位于栈顶。如果你顺着序入栈,分析过程会从右端开始推导,直接导致误判合法输入。

3.2 递归下降分析中的回溯与错误定位:如何不写成一团乱麻

递归下降的构造比表驱动直观,每个非终结符对应一个函数,遇到选择分支就向前看一个 token 决定走哪条路。问题来了:LL1 文法保证每个选择分支的 FIRST 集不相交,你不需要回溯,但如果老师给的实验说明里夹带了一点非 LL1 的内容,比如stmt -> id = expr | id ( expr_list ),正文字符id同时出现在两个分支的 FIRST 里,递归下降就会陷入回溯。

// 非 LL1 场景:需要向前多看几个 token 或改写文法 void parse_stmt() { if (lookahead == ID) { match(ID); if (lookahead == ASSIGN) { match(ASSIGN); parse_expr(); } else if (lookahead == LPAREN) { match(LPAREN); parse_expr_list(); match(RPAREN); } else error("赋值或函数调用不完整"); } }

这段函数对应stmt -> id = expr | id ( expr_list ),它靠向前多看一个 token 区分两种情况。这本质上已经超出 LL1,属于 LL(2) 的范畴,但实际编译器里这种处理非常常见。递归下降的优势正在于此:它不强制你做严格的表驱动,你可以按需向前多看几个 token,代价是代码里多一些分支判断。

错误定位是递归下降最恼人的部分。我的建议是维护一个错误恢复机制:当match失败时,抛出异常或返回错误码,然后调用synch()函数跳过输入直到遇见 FOLLOW 集中的同步符号。不要在每个函数里都写一段错误处理,那是灾难。统一走一个错误恢复入口,记录当前行号、期望的 token 和实际读到的 token,然后同步,这个策略能让错误报告质量提升一个档次。

递归下降与 LL1 表驱动的关系是:两者分析的文法集合相同,但表驱动更适合在实验报告中展示“分析表构造”的过程,递归下降更适合展示“程序结构对应文法产生式”的思想。如果你的课程要求两选一,我建议表驱动——因为分析表的构造过程更容易写出篇幅,也更容易被验证对错。

4. 算符优先分析与 SLR(1) 分析法:自底向上语法分析的现代路线

4.1 算符优先表的构造思路与最左素短语的识别

算符优先分析是自底向上语法分析的入门版,它只关注终结符之间的优先关系,忽略非终结符的属性计算。这在实验里是个讨巧的方案:文法无需消除左递归,分析表小,但能处理的范围也窄——它要求文法必须满足算符文法条件,即产生式右部不能出现两个非终结符相邻。

// 算符优先表:FIRSTVT 与 LASTVT 集合的构造 // FIRSTVT(P):P 能推导出的第一个终结符,且该终结符后面可以跟非终结符 // LASTVT(P):P 能推导出的最后一个终结符,且该终结符前面可以跟非终结符 // 优先关系判定: // 对于产生式 ...a B...,有 a < FIRSTVT(B) // 对于产生式 ...B a...,有 LASTVT(B) > a // 对于产生式 ...ab... 或 ...a B b...,有 a = b

算符优先分析的核心是“最左素短语”的识别过程:从栈顶开始向下找第一个终结符,然后根据优先关系确定短语的边界。这个模块在实验中最常见的翻车点是你把优先关系表构造错了,导致分析过程进入死循环——不是报错,是无限循环。调试方法是在循环里加一个最大步数限制,超过 500 步直接报错退出,避免程序卡死。

但我必须坦白:算符优先分析在现代编译课程中的地位是过渡性的。它的计算能力比 LR 家族弱,能覆盖的文法集合也有限,很多有歧义的表达式文法在算符优先分析里得不到正确的优先级。你可以把算符优先实验当作理解“移进-归约”范式的跳板,不必在这个模块上投入过多精力优化。

4.2 SLR(1) 分析表构造:LR(0) 项目集规范族的闭包运算

SLR(1) 是 LR 家族里最容易手算实现的一个。它的构造步骤是:先构造 LR(0) 项目集规范族,再用 FOLLOW 集解决部分移进-归约冲突。比 LR(1) 少了对向前搜索符的计算,比 LALR(1) 少了合并同心项目的操作,是课程实验里性价比最高的选择。

// LR(0) 项目集 I 的闭包计算 Closure(I) { repeat: for (每个项目 A -> α . B β in I) for (每条产生式 B -> γ) if (B -> . γ 不在 I 中) 把 B -> . γ 加入 I; until I 不再变化; } // 状态转移:GOTO(I, X) = Closure(所有 A -> α X . β 的项目集) // 其中 X 是文法符号,终结符对应 action 表的移进,非终结符对应 goto 表

SLR(1) 分析表构造代码的核心是维护一个项目集集合和一个转移表。如果你用结构体数组存项目集,用二维整数数组存 goto 表,整个实现可以控制在三百行以内。不必引入复杂的数据结构,线性探测和动态数组足够。

分析表构造完成后,你要在 action 表里填入三类动作:s移进、r归约、acc接受。归约动作的依据是:如果项目集中有A -> α .且A != S',则对每个FOLLOW(A)中的终结符填入r 产生式编号。这里就是 SLR 和 LR(1) 的区分点——SLR 用全局 FOLLOW 集,LR(1) 用逐项目的搜索符集,后者更精确但手动构造近乎不可能。

SLR 实验里你最容易翻车的地方是:归约时弹出栈顶符号后,要用弹栈后的新栈顶状态和产生式左部的非终结符去查 goto 表,而不是直接用当前状态查。这个“归约后再转移”的逻辑,许多人第一次写都会漏掉,结果就是状态栈和符号栈错位,后续动作全乱。debug 的直观技巧是打印每一步的状态栈和符号栈,对照标准分析过程逐步核对。

5. 语法制导翻译与中间代码生成:从分析树到四元式的落地路径

5.1 语法制导定义:每个产生式对应一条语义动作

前面所有分析模块的终点在这里——语法分析的同时执行语义动作,输出中间代码。最经典的中间代码形式是四元式:(op, arg1, arg2, result)。语法制导翻译的核心是为每条产生式关联一个语义动作,这个动作在归约时触发。也就是在 SLR 分析器的归约动作里插入语义子程序,与移进动作本身无关。

// 四元式结构 typedef struct { char op[8]; char arg1[16]; char arg2[16]; char result[16]; } Quad; Quad quads[512]; // 四元式数组 int quad_count = 0; // 归约 E -> E + T 时调用的语义动作 void semantic_action_add(int e1_pos, int t_pos, int result_pos) { sprintf(quads[quad_count].op, "+"); strcpy(quads[quad_count].arg1, quads[e1_pos].result); strcpy(quads[quad_count].arg2, quads[t_pos].result); sprintf(quads[quad_count].result, "t%d", temp_var_count++); quad_count++; }

语义动作的参数来源是分析栈。你要在每个栈符号上额外维护一个属性字段——常见做法是定义struct attr { char name[16]; int type; },所有终结符和非终结符的语义值都挂在这个字段上。归约时,从栈顶向下若干位置的符号取属性,计算新值,然后压入新符号的属性字段。

5.2 中间代码生成中的临时变量管理:避免编号冲突的两条规则

中间代码生成的麻烦不在语义动作怎么写,而在临时变量的命名和复用。如果你用全局递增计数器来生成临时变量名,生成的代码体积会膨胀但正确性没问题。如果你试图复用临时变量以节省空间,就必须小心生命周期冲突。

// 临时变量生成规则: // 1. 每个二元运算产生一个新的临时变量 t{n},n 从 0 开始递增 // 2. 如果参与运算的两个操作数中有一个临时变量已被后续语句使用,则不能复用 // 3. 赋值语句的目标变量不是临时变量,不参与临时变量池的管理 int temp_var_count = 0; char temp_pool[MAX_TEMP][16]; // 已生成的临时变量名 char* new_temp() { sprintf(temp_pool[temp_var_count], "t%d", temp_var_count); return temp_pool[temp_var_count++]; }

我见过最多的问题是把临时变量和用户变量混淆,导致生成的中间代码里出现t3 = t1 + t2时,t1已经被后续语句改变。这就是生命周期分析没做。在课程实验层面,最简单的策略是:临时变量只增不减,不做复用。这样正确性有保证,实验报告里还可以写“由于实验规模和输入数据量有限,不进行临时变量的存活性分析”,老师一眼就能看出你懂这里的取舍。

中间代码生成之后,你得到的是一组线性四元式序列。它还不算完整的目标代码,但已经足够说明“语法制导翻译”的产物形态。如果你想更进一步,可以把这组四元式翻译成 MIPS 汇编或栈式机器码——但那是后端的工作,不属于本实验的验收范围。

语法制导翻译这个模块建议你单独写一个头文件semantic.h,把语义动作函数、临时变量管理和符号表操作封装在一起。这样 SLR 分析器主程序可以保持干净,语义动作的调试也不会拖累语法分析的代码。

6. 避坑排查:六个模块串跑与前后端接口的常见问题

6.1 词法模块的 Token 流与语法模块的输入不匹配

现象:语法分析器读入 token 流后,在第一个产生式就报错,无论输入什么程序都是同样的错误。

原因:词法分析器输出的 token 编码与语法分析器的终结符枚举不一致。比如词法层把INT编码为 0,语法层头文件里把INT定义为 5,两者完全没有对齐。

解决:抽一个公共头文件token.h,集中定义所有 token 类型枚举值,词法模块和语法模块同时 include 这个文件。不要在两个模块里各写一份枚举。

6.2 LL1 分析表构造正确但总在“分号”处报错

现象:表达式分析全部正确,但在语句结束的;处报“非法符号”。

原因:;没有进入任何非终结符的 FOLLOW 集,但你的文法里语句明显以;结尾。通常是 FOLLOW 集合的闭包计算中遗漏了stmt -> ... ; stmt这样的产生式。也可能是你最后一步把FOLLOW(stmt)初始化为空,而不是把$放进去。

解决:FOLLOW 集计算的初始化阶段,必须把$加入开始符号S的 FOLLOW 集。

6.3 SLR 分析表构造后遇到移进-归约冲突就放弃

现象:构造 SLR 分析表时,某个状态在同一个终结符上既有移进动作又有归约动作,于是以为文法不可能是 SLR。但老师明确说该文法是 SLR(1)。

原因:语法文法的产生式含左递归或二义性,没有做过等价变换。也有可能是 FOLLOW 集合算错,多算了某个终结符,虚假地制造了冲突。我排查时见过 FOLLOW 集少收敛一次导致冲突的案例——闭包算法必须在集合不再变化时才终止。

解决:先打印出冲突状态的全部 LR(0) 项目,人工判断是真实冲突还是 FOLLOW 集算错。真实冲突只能改写文法,后者只需重新计算集合。

6.4 生成的四元式顺序错误:赋值语句的结果跑到后面去

现象:输入a = b + c * d;,生成的四元式顺序变成了乘法,再加法,再赋值,但多了一步不必要的跳转或顺序颠倒的临时变量赋值。

原因:语法制导动作绑定在错误的产生式上。如果你把语义动作写在了E -> T而不只是E -> E + T上,就会在每次识别到简单的 T 时创建一次临时变量,额外产生非必要的四元式。

解决:归约动作只绑定在真正有运算的产生式上,识别单个标识符或数字时直接设置属性值,不做四元式生成。

6.5 程序能跑但内存泄漏:分析栈与符号表清理时机不对

现象:连续分析多个输入程序,程序运行速度越来越慢,最终崩溃。

原因:符号表和分析栈是全局静态数组,没有在下一个程序开始前重置。你的循环入口处把计数器和栈顶指针归零了,但动态分配的符号表条目没有 free。

解决:只用静态数组实现符号表和四元式表,不做运行时动态分配,整个程序分析完直接退出进程。课程实验的处理规模不需要这么动态。

7. 进阶串联:用同一套源码跑通全部六个模块的验证方法

六个模块分开调试时各有各的对错标准,串联起来才能证明你的源码集合真的可用。我建议按以下顺序做整合验证:先用词法分析处理一段完整的 C 语言子集程序,输出 token 序列;再把 token 序列喂给语法分析模块,确认能构建出完整的分析树或完成归约序列;最后在语法分析过程中触发语义动作,收集四元式输出。如果你拿到的这份集合里各模块是独立的而非集成的,整理时要注意打通它们之间的数据接口。这里给你一条我实践中验证过的整合链路,核心是让每个模块的输入和输出都对应到同一套中间表示上。

// 串联验证的主控逻辑 int main() { char *source = read_file("test.c"); Token tokens[MAX_TOKENS]; int token_count = lex_analyze(source, tokens); // 语法分析阶段的回调函数,用于在归约时触发语义动作 set_semantic_callback(semantic_action_dispatch); int result = slr_parse(tokens, token_count); if (result == 0) { dump_quads(quads, quad_count); } else { print_syntax_error(); // 输出行号+期望token+实际token } }

这种串联设计有一个好处:你不必在各模块之间转换中间表示,token 数组是唯一的传递枢纽。词法分析输出 token 数组,语法分析直接消费 token 数组,语义动作围绕分析栈上的属性字段操作,四元式数组从语义动作中积累。只要 token 结构的定义在三处保持一致,这条链路就不会出现数据结构层面的断层。

你要验证的第二个关键点是:语法分析器在分析过程中是不是正确地调用了语义动作。很多人调试时会跳过语义动作,只验证语法分析本身,结果交实验时发现四元式输出全是空的。调试技巧是:在语义动作入口加一个调试开关,打印归约用的产生式编号和当前属性值,逐步核对归约序列。如果归约序列正确但属性值错乱,问题出在属性字段的存取位置不对;如果归约序列本身就不符合文法,那问题出在语法表构造。

验证完成后,建议你把六个模块的输出分别存档:词法分析输出tokens.txt,LL1 分析输出predict_steps.txt,SLR(1) 分析输出slr_steps.txt,语法制导翻译输出quads.txt。这些中间产物是实验报告里最有力的过程证据,比任何描述性文字都能说明问题。

最后提一个我这几年调试编译器前端攒下的习惯:每次改完文法或语义动作,先跑一遍最小用例——比如a = 1;——再做回归。最小用例跑通之后才开始测试表达式嵌套和语句块,一层一层往外测。不要一上来就跑整段程序,那只会让你面对一堆错误提示,还不知道从哪一行开始找起。先用最小用例验证主链路,再逐步扩大输入规模,是编译原理实验里最高效的调试路径,也是我拿到任何一份编译器源码后必做的第一步。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询