ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

编译原理课程设计实战:拆解北航小型编译器代码包

编译原理课程设计实战:拆解北航小型编译器代码包 简介北航2022年编译技术课程设计的完整代码包面向学习编译原理、需要完成类似课设的高校学生与自学者。压缩包共2000个文件、约10.64MB其中以1437个Java源文件为主辅以235个class编译产物、149个Markdown笔记和111个txt文档另有XML配置、JSON数据及少量Python/C脚本基本覆盖了从源码到中间表示、目标代码生成的典型实现路径。内容预览中出现的IR指令构建、MIPS指令生成、基本块与寄存器分配等类文件提示代码已具备较完整的编译器后端框架。已有135人学习过该资源。系统梳理这些代码与文档可以帮助理解词法分析、语法分析、语义分析及代码生成各阶段如何衔接也可作为设计报告、模块划分和测试用例的参考模板对完成类似编译课程设计或入门编译器开发很有价值。1. 北航编译技术课程设计代码2022.zip先拆包再定方案这门课到底在考什么拿到标题里这个zip包的时候我第一反应不是“里面有代码”而是先问一句它能不能在我这台机器上原样构建起来。北航编译技术课程设计做的是一个人写完一个小型编译器从词法分析、语法分析、语义检查到代码生成一个都不能少。这个zip的价值在于把整条编译流水线的源码、测试用例和构建脚本一次性交到你手里省去四处拼凑参考代码的时间。它适合两类人被课程大作业卡住想找参考的学生以及在工作中要碰解释器、DSL、静态分析工具的工程师。解压、构建、跑通样例是接下来一切讨论的前提。2. 编译器课程设计题目的真实形态从词法分析到代码生成中间隔了多少细节2.1 课程设计到底在做什么不是写玩具是写一个能交差的玩具编译器这门课的课程设计题目年年微调骨架基本固定给一门类C的子集语言写编译器输入是一个.c文件输出可以是x86汇编、MIPS汇编或者一种自定义的栈式虚拟机指令。2022年这个包大概率走的是“前端手写或lex/bison 后端栈式虚拟机”的路子。为什么这么猜因为课程设计一周一题时间有限栈式虚拟机后端最容易验证正确性也最容易写自动化评分脚本。你不信可以解压后翻包里的构建脚本找找有没有target叫test或regress。选型理由上手写词法分析器适合练原理但产品化慢lex/flex生成器快但生成的代码读起来像天书。我的建议是如果只想快速跑通用flexbison如果想在答辩时讲清楚每个状态手写递归下降更稳。两类都能过关键是你对哪部分有把握。另一个容易被忽视的点是评测方式老师大概率不做手工检查直接跑一批测试用例比对输出这意味着你不光要写好编译器还得保证命令行接口和评分脚本期待的一致。常见做法是支持类似./compiler input.c output.vm的调用多一个交互式命令行反而是给自己挖坑。2.2 词法分析与语法分析手写递归下降还是lex/yacc怎么选词法分析这层输出是token流。常见做法是定义token类型枚举IDENT、INTEGER、KEYWORD、OPERATOR外加一个全局的yytext缓冲区。用flex的话规则文件里把保留字和标识符分开匹配因为类C语言里“if”这种保留字不能在用户代码里当变量名。/* lexer.l 关键片段 */ %{ #include token.h extern char yytext[]; %} %% if { return TOKEN_IF; } int { return TOKEN_INT; } [a-zA-Z_][a-zA-Z0-9_]* { strcpy(yylval.name, yytext); return TOKEN_IDENT; } [0-9] { yylval.ival atoi(yytext); return TOKEN_INTEGER; } { return TOKEN_GE; } .这段规则里保留字匹配必须写在标识符匹配之前否则flex按最长匹配原则会把“if”认成标识符。而这种两字符操作符要放在前面不然读到就提前返回了。我曾因为把写在后面导致ab被解析成a b翻车一整晚。语法分析这层lex/yacc只能告诉你“输入符合文法”真正干活的是每个产生式后面的动作代码。递归下降则是把每个非终结符写成函数靠手写返回值和错误码推进。对课程设计而言我一般推荐用bison因为表达式优先级可以直接用%left声明不用自己实现算符优先。2.3 语义分析与中间代码符号表怎么管三地址码怎么发语义分析的核心是符号表。符号表不只是存“变量名→类型”还要存作用域嵌套深度、是否初始化、地址偏移。课程设计最常见的写法是链表加哈希表混合每层作用域开一个链表查找时从内往外逐层找。好处是实现简单坏处是嵌套深了慢但课程设计不讲究性能够用就行。入口处一般有一个init_symbol_table()函数进出时分别调用push_scope()和pop_scope()这两句话没配对后面查变量名就会串作用域。中间代码最常见的形态是三地址码每条指令至多三个操作数例如t1 a b、t2 t1 * 2、x t2。这种形态的好处是跟具体机器无关后面做寄存器分配和目标代码生成时只需把t1、t2映射到栈帧里的slot。三地址码的临时变量命名规则值得留意常见的是t序号每次新生成临时变量序号加一。别小看这个序号它决定栈帧大小。如果一份代码里临时变量编号从0和局部变量编号从0混着来生成结果大概率局部变量会被临时变量踩掉。2.4 目标代码生成输出汇编还是虚拟机指令目标代码生成是课程设计最能拉开差距的地方。输出x86汇编要处理寄存器分配、栈帧布局、调用约定坑很多输出虚拟机指令则简单得多每条三地址码指令都能一一映射到一条虚拟机指令解释器再读指令跑。我见过不少课程设计把main函数写成解释器循环读指令、按opcode分发、操作模拟栈。选这条路线你可以把精力集中在前端和指令集设计上而不是耗在x86的调用约定里。无论哪种后端变量地址偏移的计算都一样每个栈帧先分配参数区再分配局部变量区临时变量区放最后。之前见过一份代码局部变量偏移从0开始算结果参数和局部变量重叠函数调两个参数就丢一个。这种bug最隐蔽因为它只在特定调用序列下触发单测全过一联调就崩。另有一个值得注意的点是返回值怎么约定走虚拟机路线时函数返回值通常约定写在一个固定寄存器或栈顶位置这个约定没写进文档后改代码的人很容易在“取返回值”和“弹栈”之间弄错顺序。3. 把代码包变成可运行工程解压、目录核对与一键构建3.1 解压前的核对文件类型、压缩方式与zip伪加密的快速判断拿到zip先别急着双击。Linux下我用file命令看真实类型因为经常有包名是zip实际是7z或者rarWindows下右键看属性如果显示0字节但解压报错大概率是上传损坏。用unzip -t测试压缩包完整性这一步能省掉后面一半的排查时间。file 编译器设计代码_2022.zip unzip -t 编译器设计代码_2022.zip | tail -20 unzip -Z -v 编译器设计代码_2022.zip | head -30file返回Zip archive data说明格式没问题unzip -t输出No errors detected才能继续。unzip -Z -v能看到每个文件的压缩方法和加密标志如果出现Encrypted: yes而作者没说设过密码就要怀疑zip伪加密了。zip伪加密不是真加密只是把文件头里的通用标志位改成了1解压软件看到这个标志就要求输入密码。判断方法是看压缩数据段前是否真的有12字节加密头用hexdump遍历几个文件条目伪加密一般只有标志位数据段是明文。zip伪加密在CTF的misc题里常见课程设计包里出现多半是打包工具异常不用慌。zip密码忘记了的正确解法是先确认是不是伪加密是则修复标志位真加密就只能找作者要密码别浪费时间在暴力破解上。3.2 目录结构长什么样从顶层文件推断工程怎么组织解压后先看顶层文件而不是直接进src。一份规范的课程设计包通常有README、Makefile、src目录、test目录。如果只有一堆.c文件躺在根目录也没关系代码整理这一步本来就要做。我习惯先列目录树找到构建入口。unzip 编译器设计代码_2022.zip -d compiler2022 cd compiler2022 find . -maxdepth 2 -type f | sort cat Makefilefind结果里Makefile、lexer.l、parser.y、symbol.h、codegen.c这五个文件如果在同一层基本就是flexbison的单目录工程如果有CMakeLists.txt说明工程跨了子目录。看Makefile的目标名all、build、test、clean能看出作者设计流程的思路。很多课程设计包里的Makefile是临时拼的编译选项里可能带着-I/home/xxx/...这种绝对路径那种包在别的机器上必挂需要先做路径清理再谈构建。3.3 用Makefile/CMake完成构建依赖flex/bison的最小配置最常见的依赖是flex和bison。系统没装的话Debian/Ubuntu用apt装macOS用brew装。这里的重点是别装到一半去搜别的版本flex对bison的版本匹配不敏感但bison 3.x生成的头文件名是parser.tab.h老教程里写的y.tab.h已经过时。构建时如果报y.tab.h: No such file or directory多半是版本和教程对不上不是你的代码写错了。# Makefile 片段 CC gcc CFLAGS -Wall -g -stdc11 LEX flex YACC bison -d -v all: compiler lexer.c: lexer.l $(LEX) $ parser.c: parser.y $(YACC) $ compiler: lexer.c parser.c symbol.c codegen.c main.c $(CC) $(CFLAGS) -o $ $^这里的-d参数让bison生成parser.tab.h-v参数让bison生成parser.output里面是所有状态和冲突的详细说明。调试语法冲突时parser.output比任何调试器都管用。如果你把flex生成的lexer.c和bison生成的parser.c一起编译顺序上lexer.c要在parser.c前面因为lexer里会引用parser.tab.h里的token枚举。用CMake的话核心配置就三句话find_package(BISON)、find_package(FLEX)、BISON_TARGET配合FLEX_TARGET其余交给链接规则。CMake的好处是跨平台路径不用手管坏处是如果包里没有现成CMakeLists你得自己补比改写Makefile还费劲。3.4 跑通第一个样例从c文件到目标码的命令序列构建完成就该跑样例了。test目录下一般有若干.c文件常见调用方式是./compiler 输入文件 输出文件也有设计成./compiler test.c的。先跑最简单的./compiler test/hello.c out.vm ./vm out.vm echo $?如果compiler不接文件名而是接stdin用重定向喂进去。跑vm时要确认可执行权限权限不足用chmod x vm修正。退出码0说明解释器正常退出非0退出码要配合vm源码里的错误处理看比如opcode越界、栈溢出这些错误信息能直接指向代码生成模块的bug。这一步有个隐藏坑如果编译器输出的是汇编而不是虚拟机指令那./vm out.vm根本跑不起来你得改用gcc把.s文件汇编成可执行文件再运行。所以先看一眼readme别假设测试命令。提示课程设计包里的test文件经常和编译器实现版本错位包里有10个测试编译器只支持其中8个。先跑readme里自带的那一个别上来就跑完整目录不然错误信息满天飞分不清是编译器问题还是样例本身超出支持范围。4. 读懂一遍编译主流程符号表、语法树与代码生成的参数调法4.1 token定义与关键字表为什么类C子集要先管好保留字看编译器代码第一站永远是token定义。token枚举的顺序有讲究关键词从字符串到枚举值一一映射标识符放在所有关键字之后。很多代码里有一张关键字表用二分查找或者直接strcmp循环把输入的字符串转成token这种实现让你增加一个新关键字时只需改表不用改词法规则。参数上常见两个一是表是否排序二是比较是否区分大小写。/* token.h 片段 */ typedef enum { TOKEN_EOF 0, TOKEN_INT, TOKEN_CHAR, TOKEN_IF, TOKEN_ELSE, TOKEN_WHILE, TOKEN_RETURN, TOKEN_IDENT, TOKEN_INTEGER, TOKEN_STRING, TOKEN_PLUS, TOKEN_MINUS, TOKEN_STAR, TOKEN_SLASH, TOKEN_ASSIGN, TOKEN_EQ, TOKEN_NE, TOKEN_LT, TOKEN_LE, TOKEN_GT, TOKEN_GE, TOKEN_LPAREN, TOKEN_RPAREN, TOKEN_LBRACE, TOKEN_RBRACE, TOKEN_SEMI, TOKEN_COMMA } TokenType;这里把TOKEN_IF放在等值比较的边界位置是为了查表时的二分边界条件。如果哪天你要给语言加一个运算记住要同时改关键字表、lexer规则、parser产生式三层漏一层就会出现“词法认了语法不认”的怪问题。词法分析里另一个参数是最大标识符长度很多实现用一个定长数组存yytext例如char yytext[128]超长标识符会被截断两个超长变量名可能变成同一个token这种bug查起来相当痛苦。4.2 语法树怎么建bison动作代码与示例代码讲解接下来看parser.y。值得关注的点是每个产生式的动作里做了什么。最朴素的做法是边分析边生成代码不建抽象语法树稍好一点的做法是建AST等语义检查完再遍历生成代码。课程设计如果是边分析边生成省内存但后续想做优化就没戏。如果代码里建AST头文件里会有类似struct ASTNode的定义下面这段示例代码讲解一下核心结构/* ast.h 示意 */ typedef struct ASTNode { int op; /* 操作类型OP_ASSIGN/OP_ADD/OP_CALL */ struct ASTNode *l, *r; /* 左右子树 */ char *name; /* 变量名或函数名 */ int ival; /* 整型字面量 */ int type; /* 类型信息 */ } ASTNode;这个结构体里op字段是核心决定遍历时的分支逻辑。ival和type在字面量和类型检查时用。看懂AST遍历顺序就懂了代码生成顺序遍历是中序、先序还是后序直接决定“先算右操作数还是先算左操作数”。很多bug就藏在遍历顺序里比如表达式a - b - c如果按右结合遍历会生成a - (b - c)结果完全不对。bison的%left声明能处理运算符结合性但如果AST遍历时自己又写了一遍展开逻辑两处结合性不一致就会翻车。4.3 符号表与作用域两个必调参数符号表实现里有两个参数值得调哈希桶数量和链表插入方式。课本上讲散列课程设计里用顺序栈也能过但想跑深一点递归测试作用域弹出不及时就会串味。常见实现是符号表栈进入函数时压栈退出时弹栈查找时从栈顶向下。/* symbol.h 示意 */ #define SYM_HASH_SIZE 211 typedef struct SymEntry { char *name; int type; int offset; /* 栈帧偏移 */ struct SymEntry *next; } SymEntry; SymEntry *sym_table[SYM_HASH_SIZE];SYM_HASH_SIZE选211这种质数是为了让字符串hash分布均匀。offset字段是代码生成的关键局部变量在栈帧里的位置全靠它。如果你在符号表里看到offset按char大小递增而目标机器是4字节对齐那生成的代码可能在多字节变量上错位。课程设计语言一般只有int和char但如果支持指针或数组align规则要单独处理。另一个参数是查找时遇到重复定义怎么处理常见做法是“本层允许覆盖外层”插入时头插到当前链上这样内层同名变量会遮蔽外层弹栈后自动恢复。4.4 代码生成的可调参数栈帧大小、临时变量编号代码生成阶段有两个参数几乎必调栈帧大小和临时变量编号起点。栈帧大小等于参数区加局部变量区加临时变量区后两者由符号表offset和临时变量个数决定。临时变量编号从某个数开始目的是避免和局部变量编号冲突。/* codegen.c 片段 */ static int temp_count 0; static int frame_size 0; int new_temp(void) { return frame_size; } int emit_expr(ASTNode *node) { if (node-op OP_ADD) { int t1 emit_expr(node-l); int t2 emit_expr(node-r); int dst new_temp(); printf(add t%d, t%d, t%d\n, dst, t1, t2); return dst; } /* 其他操作类似 */ }这段代码里frame_size既是临时变量偏移计数器又是最终栈帧大小的来源。new_temp每调用一次frame_size就加一跑完一个函数后frame_size就是该函数的栈帧总大小赋值给指令里的frame字段。如果new_temp在表达式两侧都调用了注意别让右操作数的临时变量覆盖左操作数还没用完的值这就是“临时变量活性”问题课程设计里最容易写出看似正确、实际递归调用时崩掉的代码生成器。调试时可以在new_temp里加一行fprintf(stderr, alloc temp %d\n, dst)把分配日志打出来对着输出数比单步调试高效得多。5. 避坑zip伪加密、中文路径与bison冲突的五个高频排查记录5.1 zip伪加密解压时突然要密码代码明明是课程设计包现象从某处下载zip后双击解压弹窗要求输入密码但来源说明里根本没提密码。用unzip解压直接报password required。 原因zip文件条目里有个通用标志位被置为1表示“本条目加密”但实际压缩数据并没有经过加密算法。压缩工具版本混用、或者有人故意用伪加密来防盗都会出现这种情况。zip伪加密在CTF里是各类misc题的常客。 解决先判断是否真加密。用十六进制编辑器打开zip定位到报错文件对应的local file header看偏移0x06处的通用标志字段。只改标志位而数据段没有加密头的是伪加密把对应bit清零保存再解压即可。如果数据段确实有12字节加密头那是真加密密码忘了就只能回溯当时的打包工具或者找作者要商用解压工具的“移除密码”功能对这种场景也帮不上忙。顺手把解压工具换成7-Zip再试一次有时候7-Zip能识别伪加密并绕过。5.2 vscode写c没有代码提示读编译源码像看天书现象用vscode打开编译器源码所有结构和函数都是白色没有智能提示跳转定义也失效。 原因默认配置没导入项目头文件路径IntelliSense找不到include目录或者代码是flex/bison生成的lexer.c和parser.c是生成物编辑器跟不上。 解决在项目根目录建.vscode/c_cpp_properties.json把includePath指到源码目录和bison生成头文件的目录。生成文件不参与编辑直接在settings里排除它们省得报一堆语法错误干扰判断。这一步看着小实际能救命——在几千行生成代码里手找token枚举没有代码诊断插件辅助纯属自我折磨。{ configurations: [ { name: Linux, includePath: [ ${workspaceFolder}/src, ${workspaceFolder}/generated ], defines: [], compilerPath: /usr/bin/gcc, cStandard: c11 } ], version: 4 }配置完重载窗口跳转和补全就回来了。compilerPath如果写错vscode会用内置编译器分析报一堆“恒成立的条件判断”警告那种也算一种代码诊断但属于误导。5.3 中文路径导致构建产物异常现象zip解压到“桌面/课程设计(2022)/”目录下make成功但运行时读不了测试文件有时flex生成的词法分析器把文件名里的中文当成了非法字符。 原因老版本flex生成的扫描器在文本模式下对非ASCII字节分段处理中文路径进入token流后乱码更常见的是Makefile里写死的路径带空格或中文shell传参时被拆开。 解决把工程放到纯英文、无空格的路径下。这个习惯在我处理过几十份课程设计代码后已成了条件反射。如果非要在中文目录下跑至少给文件路径加双引号并且不要在文件名里用空格。相对路径在编译器代码里也要注意很多实现用fopen读文件路径分隔符写死成/Windows下要兼容就得改写路径拼接。5.4 bison报shift/reduce冲突代码能跑表达式求值全错现象编译parser.y时输出1 shift/reduce conflict你没在意。结果编译a b * c时生成的目标码做的是(a b) * c乘法的优先级没了。 原因文法里没声明%left和%right优先级或者声明了但写反了。bison的冲突报告会写明冲突发生在第几行parser.output文件里还能看到具体状态机看一眼就知道是哪个产生式在打架。 解决在parser.y顶部加优先级声明乘除高于加减右结合运算符用%right。重新生成parser.c之前一定要删掉旧的parser.tab.h否则bison版本跨代时头文件结构不一致编译报错会让你误以为是代码本身的问题。这个坑我踩过后来养成了每次修改parser.y后先make clean再make的习惯。还有一个容易踩的%prec用在unary minus上如果漏了-a*b会被解析成-(a*b)还是(-a)*b就完全看默认文法的结合性结果经常是出乎意料的。5.5 可执行文件跑不起来msvcp140.dll找不到现象课程设计包里带了Windows编译好的exe一运行就弹“由于找不到msvcp140.dll无法继续执行代码”退出码一堆乱码。 原因exe依赖Visual C 2015-2022运行库目标机器没装。很多课程设计包是作者在实验室机器上编的实验室装了运行库换台电脑就露馅。这个报错典型得不能再典型但它有个特点不是你的代码问题是环境问题。 解决装Visual C Redistributable或者不用现成exe改用源码在本地重新构建。我强烈推荐后者——课程设计代码必须能自己构建才算真正掌握。如果一定要用现成exe在虚拟机里开一个装了完整编译环境的Windows至少别在主力机上乱装运行库。另外排查这类dll缺失问题时的通用心法是先查PATH里有没有混入另一个版本的msvcp140.dll版本冲突比缺失更隐蔽错误提示还一样。6. 进阶验证用快速排序代码当回归测试集把生成的目标码跑起来6.1 测试集设计别只用hello world很多课程设计测试集里只有hello world骗得过人骗不过栈。快速排序代码是很好的测试它有数组、递归调用、比较运算、循环能一次性覆盖词法、语法、符号表作用域和代码生成四层。把这样一段快排写成类C源码过一遍编译器生成目标码再用配套虚拟机执行输出排序结果和标准答案比对。/* test/quicksort.c 测试样例 */ int arr[10]; int quick(int lo, int hi) { int i, j, pivot, tmp; if (lo hi) return 0; i lo; j hi; pivot arr[(lo hi) / 2]; while (i j) { while (arr[i] pivot) i i 1; while (arr[j] pivot) j j - 1; if (i j) { tmp arr[i]; arr[i] arr[j]; arr[j] tmp; i i 1; j j - 1; } } quick(lo, j); quick(i, hi); return 0; }这段代码用到的数组下标、全局变量、递归调用正是课程设计里三处最脆弱的环节。任何一个出问题输出要么排序错误要么栈溢出崩溃。6.2 用diff和退出码做回归跑编译器的习惯每次改完代码生成模块先把固定测试集跑一遍再把新输出和上次的输出diff。输出不一致时用退出码筛掉crash再人工看diff。配合一个命令脚本把编译、执行、比对串起来./compiler test/quicksort.c out.vm ./vm out.vm result.txt diff result.txt golden/quicksort.txt echo PASSdiff退出码0表示通过非0表示失败。golden目录里存的是当时人工验证过的标准输出这就是最简单的回归测试。比任何“我看着没问题”都实在。6.3 我的习惯与教训说句血泪经验课程设计翻车基本不在主流程而在边界。数组下标越界不报错、递归爆栈不提示、临时变量编号被覆盖这些问题都是固定测试集跑不出来的。我的做法是在编译器里加一个--dump-tokens参数把词法分析结果打出来怀疑哪个阶段出错就分段验证。把编译器拆成“词法→语法→代码生成”三段黑匣子每段一个调试开关比对着最终输出猜问题快得多。另一个教训是给自己写的代码留点距离。写完放两天再改会发现当时觉得“一眼就能懂”的命名其实全是谜语。给代码做点整理把#define换成enum把全局变量集中到struct里不是说不这么做不行是为了两周后打开这个包还能想起自己当时为什么这么写。这些习惯也延续到我后来做解释器和DSL的日子。每次拿到新项目先跑通再拆开最后写测试顺序永远别颠倒。给这个课程设计代码包留一份干净的金字产出就是你给未来的自己留的后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表