简介:这份资源是南开大学软件学院编译原理课程的作业成果,包含一个可运行的简单C语言编译器完整源码与配套文档,面向计算机、人工智能、通信工程等专业的在校学生及教师,也适合作为课程设计、毕业设计或自学编译原理的参考范例。压缩包共50个文件,约54KB,以18个h头文件、16个cpp源文件、6个c文件为核心,辅以lexer.l与grammar.y词法语法定义、Makefile构建脚本、md说明文档及测试用例,覆盖词法分析、语法分析、符号表、语法树等编译器关键模块。已有215人学习关注。项目代码均经测试运行成功,答辩评审平均分达96分,读者可据此理解编译器整体架构与实现细节,也可在现有代码基础上修改扩展,完成自己的课设或作业任务。
1. 从一份编译原理作业说起:简单C语言编译器到底能跑通什么
很多人搜「南开大学软件学院编译原理作业:简单C语言编译器+源代码+文档说明」,真正想知道的不是作业长什么样,而是这份东西能不能在自己机器上跑起来、能不能看懂、能不能改成自己的课程设计。我当年带学弟做编译原理实验时,最常见的翻车现场就是:源码拖下来,make一敲满屏红字,文档里写的「支持简单C语言子集」到底支持到什么程度,没人说得清。所以这篇笔记不聊虚的,就按一线做法把这份作业拆开:它通常包含词法分析、语法分析、语义检查、中间代码生成这几段,配套的源代码和文档说明负责把「怎么编译、怎么测、怎么改」讲清楚。适合正在做编译原理实验的本科生、要交课程设计的人,以及想拿一个能跑通的最小C编译器当练手底座的开发者。下面按「先立住原理、再动手复现、最后避坑」的顺序推。
2. 先看清这份作业的技术底座:词法、语法、语义三段怎么分工
2.1 为什么简单C语言编译器大多走「手写递归下降 + 一遍扫描」
编译原理作业里的「简单C语言编译器」,绝大多数不是工业级编译器,而是教学用的子集编译器。它通常只支持int变量声明、赋值、加减乘除、比较、if、while、return和函数调用,不支持结构体、指针运算、预处理宏。选型上,常见做法是词法分析手写、语法分析用递归下降、语义分析边解析边做符号表检查,整体走一遍扫描。这样做的理由很实际:递归下降代码可读性高,和文法一一对应,助教改作业时一眼能看出你哪条产生式写错了;一遍扫描省掉了构造完整AST再遍历的复杂度,代码量能压到两三千行以内,适合一个人一周内写完。
对比另一条路——用 Lex/Yacc 或 Flex/Bison 自动生成——优点是文法改动快,缺点是调试时错误定位在生成代码里,新手容易懵。南开这份作业如果文档里明确写了「手写」,那基本就是递归下降路线。你要做的第一件事不是急着编译,而是打开源码目录,确认这几个文件是否存在:词法分析器(常见命名lexer.c/scanner.c)、语法分析器(parser.c)、符号表(symtab.c)、代码生成(codegen.c)和主入口(main.c)。如果文档说明里给了文件清单,先对照一遍,缺文件就别硬编,先找齐。
2.2 符号表和作用域:编译原理实验里最容易埋雷的地方
符号表是这份作业里最容易被低估的模块。简单C语言编译器通常用一个栈式符号表来管理作用域:进入{压一层,离开}弹一层,变量查找从栈顶往下找。常见实现是一个数组加一个scope_level计数器,或者链表数组。这里的关键参数是「作用域深度上限」和「符号名最大长度」,教学实现里一般写死成 64 或 128。你如果要做扩展,比如支持嵌套函数,就得先确认符号表是否支持按层查找,否则会出现内层变量覆盖外层却查不到的玄学问题。
语义检查阶段要做的三件事:变量重复声明报错、变量未声明就使用报错、赋值类型不匹配报错。这三条是编译原理实验评分点里出现频率最高的。文档说明里如果写了「语义分析」章节,重点看它有没有把这三类错误列出来。没有的话,你自己补一个check_semantics函数,在解析到标识符时先查符号表,查不到就报undeclared identifier,查到但类型不符就报type mismatch。这一步做完,你的编译器才算「能报错」,而不是一遇错就段错误。
2.3 中间代码生成:三地址码还是直接生成汇编
教学用简单C语言编译器在中间代码这块通常有两种选择:生成三地址码(四元式)或者直接生成 x86/伪汇编。三地址码的好处是平台无关、便于做优化实验,格式一般是(op, arg1, arg2, result),比如(+, a, b, t1)。直接生成汇编的好处是能真正跑起来看到结果,但绑定平台,换台机器可能就不行。南开这份作业如果文档里提到「目标代码」或「汇编输出」,那大概率是后者;如果只提到「中间代码」,那就是三地址码。
我一般建议先跑通三地址码版本,确认词法、语法、语义都对了,再考虑接一个后端把三地址码翻译成可执行指令。这样调试时你能把每一段源码对应的四元式打出来,哪一步错了看得清清楚楚。参数上,临时变量命名用t1, t2, t3递增,标签用L1, L2递增,这是教学实现里最通用的约定,文档说明里一般也会沿用。
3. 把源代码跑起来:从解压到第一个测试用例通过
3.1 环境准备与编译命令:先确认工具链再动手
拿到源代码压缩包后,第一步不是make,而是看文档说明里的「编译环境」章节。教学编译器常见依赖是gcc、make、flex、bison。如果源码是纯手写递归下降,那只需要gcc和make。先跑这三条命令确认环境:
gcc --version make --version flex --version 2>/dev/null || echo "no flex, maybe not needed"逻辑说明:第一条确认 C 编译器存在且版本不过老(建议 gcc 7 以上);第二条确认 make 可用;第三条探测 flex,如果文档说用了 Lex 但你没装,后面编译会报lex.yy.c找不到。参数上没有特殊要求,版本号只要不是十年前的就行。如果make报错说找不到yywrap,那是 flex 链接问题,在 Makefile 里加-lfl即可。
接下来进源码目录,先看 Makefile 里的目标名。常见的是make或make all,也有写成make compiler的。不要盲目敲make install,教学项目一般没有 install 目标,敲了只会报错。编译成功后通常会生成一个可执行文件,名字可能是compiler、cc或main。用ls -l确认一下,然后拿文档里给的示例程序试跑。
3.2 用最小测试用例验证词法、语法、语义三段
不要一上来就跑复杂程序。先写一个最小用例,只包含声明和赋值:
int main() { int a; a = 1 + 2; return a; }保存为test1.c,然后运行编译器。常见调用方式是./compiler test1.c或./compiler < test1.c,具体看文档说明里的用法。如果输出是三地址码,你应该看到类似:
(+, 1, 2, t1) (=, t1, _, a) (return, a, _, _)如果输出是汇编,你会看到mov、add之类的指令。这一步的目的是确认三段都通了:词法能切出int、a、=、1、+、2;语法能识别声明和赋值语句;语义能查到a已声明。任何一段挂了,输出都会不对。
然后逐步加复杂度:加if、加while、加函数调用。每加一个特性就跑一次,不要攒着一起测。我一般会准备五个测试文件,分别覆盖声明赋值、算术表达式、条件分支、循环、函数调用。这样出问题时能快速定位是哪一块没实现。文档说明里如果给了测试用例,优先用它的,因为评分标准可能和它的预期输出绑定。
3.3 参数怎么调:临时变量、标签和符号表容量的修改位置
教学编译器里几个常改的参数:临时变量起始编号、标签起始编号、符号表最大容量、标识符最大长度。这些通常在头文件里用#define定义,比如:
#define MAX_SYMBOLS 128 #define MAX_IDENT_LEN 64 #define TEMP_START 1 #define LABEL_START 1逻辑说明:MAX_SYMBOLS控制符号表数组大小,测试用例变量多的时候要调大,否则会报符号表溢出;MAX_IDENT_LEN控制标识符长度,一般 64 够用;TEMP_START和LABEL_START决定临时变量和标签从几开始编号,改成 0 或 1 都行,但要和代码生成里的格式化字符串一致。改完这些参数后必须重新make,否则不生效。
如果你要扩展支持更多语法,比如for循环,改的是语法分析里的statement函数,加一个分支判断for关键字,然后递归解析初始化、条件、步进和循环体。这一步没有固定参数,靠的是对照文法写代码。文档说明里如果有文法产生式列表,照着写基本不会错。
4. 避坑与排查:编译原理实验里最常见的五类翻车
4.1 现象:make报错undefined reference to 'yylex';原因:词法分析器没链接上;解决:检查 Makefile 是否包含lexer.o
这是 Flex/Bison 路线的经典问题。yylex是词法分析入口,如果 Makefile 里只编译了parser.o没编译lexer.o,链接阶段就会找不到。解决方法是打开 Makefile,确认OBJS变量里包含所有.o文件,或者直接make clean && make重新来一遍。如果是手写词法分析器,检查函数名是否和语法分析器里调用的名字一致,常见的是get_token和next_token混用。
4.2 现象:解析到if语句时段错误;原因:递归下降里条件表达式解析后没消费右括号;解决:在match(')')后加断言
递归下降最容易犯的错是「忘了吃掉某个 token」。比如解析if (a > b) { ... }时,解析完条件表达式后指针停在)上,如果没调用match(')')就继续解析语句块,后面全乱。段错误往往是因为指针越界或空指针解引用。解决方法是在每个match后加日志,打印当前 token,跑一遍就知道卡在哪。参数上,建议在match函数里加assert,失败时直接报「expected X but got Y」,比段错误好查得多。
4.3 现象:符号表查不到已声明变量;原因:作用域层级没对齐,声明和查找不在同一层;解决:统一用current_scope变量
符号表实现里常见两种写法:一种是声明时insert到当前层,查找时从当前层往下找;另一种是声明和查找都用全局层。如果混用,就会出现「明明声明了却查不到」。解决方法是定义一个全局current_scope,进入块时current_scope++,离开时current_scope--,insert和lookup都基于这个变量。参数上,current_scope初始为 0,最大深度一般不超过 16,教学用例够用。
4.4 现象:三地址码里临时变量编号重复;原因:临时变量计数器没在函数间重置或没递增;解决:用全局temp_count并在生成时temp_count++
临时变量编号重复会导致后续优化或翻译时变量冲突。常见原因是temp_count定义成了局部变量,每次进函数都从 1 开始。解决方法是把它定义成全局变量,或者放在编译器上下文结构体里,生成新临时变量时统一走new_temp()函数。参数上,temp_count初始值设为 1,每调用一次new_temp()就返回t{temp_count++}。标签同理,用new_label()统一管理。
4.5 现象:文档说明里的示例输出和实际输出不一致;原因:源码版本和文档版本不匹配;解决:以源码为准,文档只作参考
这是最让人头疼的一类。你按文档里的命令跑,输出却对不上,可能是文档写的是旧版行为,源码已经改了。解决方法是先读源码里的main函数和测试脚本,看它实际接受什么参数、输出什么格式。文档说明里的「预期输出」如果和源码不符,以源码为准,因为评分看的是源码行为。如果文档里给了测试脚本,直接跑脚本,脚本里的断言才是硬标准。
5. 进阶用法:把这份作业改成你自己的课程设计
5.1 扩展语法:加for循环和break的最小改法
如果你想让这份简单C语言编译器支持for循环,改动集中在语法分析和代码生成两处。语法分析里,在statement函数加一个分支:
if (match(TOKEN_FOR)) { match('('); parse_expr(); // 初始化 match(';'); parse_expr(); // 条件 match(';'); parse_expr(); // 步进 match(')'); parse_statement(); // 循环体 }逻辑说明:这段代码按for (init; cond; step) body的结构顺序解析,每个部分复用已有的表达式解析函数。参数上没有新增,但要注意for的初始化部分可能为空,比如for (; i < 10; ),所以parse_expr要能处理空表达式。代码生成阶段,把for翻译成标签加条件跳转:先执行初始化,然后打条件标签,条件不成立跳结束标签,执行循环体,执行步进,跳回条件标签,最后打结束标签。break的实现是在循环体解析时记录当前结束标签,遇到break就生成跳转到该标签的指令。
5.2 验证方法:用差分测试对比你的编译器和 gcc 的行为
改完编译器后,怎么确认它是对的?我一般用差分测试:写一批小程序,分别用你的编译器和gcc编译运行,比较输出。比如:
for f in tests/*.c; do ./compiler "$f" > out_mine.txt gcc "$f" -o /tmp/ref && /tmp/ref > out_ref.txt diff out_mine.txt out_ref.txt || echo "MISMATCH: $f" done逻辑说明:这段脚本遍历tests目录下所有.c文件,用你的编译器输出结果,用 gcc 编译运行得到参考结果,然后 diff。参数上,tests目录里放的是只用到你已实现子集的小程序,不要放结构体、指针这些没实现的东西。如果 diff 有差异,先看是输出格式不同还是计算结果不同。格式不同就统一输出格式,结果不同就是语义实现有 bug。
5.3 一个具体技巧:把符号表和四元式输出成 JSON 方便调试
教学编译器输出通常是纯文本,调试时不好用程序分析。我习惯加一个--dump-json选项,把符号表和四元式输出成 JSON:
void dump_symbols_json(FILE *fp) { fprintf(fp, "{\n \"symbols\": [\n"); for (int i = 0; i < sym_count; i++) { fprintf(fp, " {\"name\": \"%s\", \"type\": \"%s\", \"scope\": %d}%s\n", symbols[i].name, symbols[i].type, symbols[i].scope, i < sym_count - 1 ? "," : ""); } fprintf(fp, " ]\n}\n"); }逻辑说明:这个函数遍历符号表数组,把每个符号的名字、类型、作用域层级输出成 JSON 对象。参数上,sym_count是当前符号数量,symbols是符号数组。加了这个之后,你可以用jq或 Python 脚本分析符号表,比如统计每个作用域有多少变量、检查有没有重复声明。四元式同理,输出成[{"op":"+","arg1":"1","arg2":"2","result":"t1"}]这种格式,后续写优化 pass 时直接读 JSON 就行。
我自己的习惯是每改一个模块就先跑一遍 JSON dump,确认中间表示对了再往下走。编译原理实验最怕的就是「看起来能跑但结果不对」,JSON 输出能让你在每一步都有据可查。希望帮到你。
本文还有配套的精品资源,点击获取