ARTICLE DETAIL

资讯详情

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

从零手写SysY编译器:西工大编译原理试点班大作业全流程拆解

从零手写SysY编译器:西工大编译原理试点班大作业全流程拆解 简介这份资源是西北工业大学编译原理试点班的大作业完整交付物面向计算机、人工智能、通信工程等专业学生及需要课程设计参考的学习者核心解决SysY语法编译器从理论到可运行实现的落地问题。压缩包共168个文件约113KB以111个sy测试用例、22个in输入数据为主配合8个cpp与7个h源文件、3个c文件及2个py脚本另有ll中间表示、md说明文档、makefile构建脚本等覆盖词法分析、语法分析、AST构建、IR生成与SSA构建等编译全流程模块。已有249人学习下载。资源包含可正常运行的编译器源码、文档说明与实验报告代码经测试通过答辩评审平均分达96分适合作为课设、毕设或项目初期立项的参考模板也可在现有代码基础上修改扩展功能帮助读者理解编译器各阶段的实现思路与调试方法。1. 从零手写 SysY 编译器西工大编译原理试点班大作业到底在考什么如果你正在选西北工业大学的编译原理试点班或者已经拿到了那份“完成一个能够正常工作的 SysY 语法编译器”的大作业题目大概率第一反应是词法、语法、语义、中间代码、目标代码这一整套下来得写多少东西SysY 是 C 语言的一个精简子集去掉了指针、结构体、浮点等复杂特性只保留 int 类型、常量、变量、函数、if/else、while、break/continue 和基本运算。它的语法规则不多但“能跑通”和“能通过所有测试用例”之间隔着一条很深的沟。这份大作业真正考的不是你会不会写递归下降而是你能不能把一个完整的编译流水线串起来让一段 SysY 源码经过你的编译器后在 RISC-V 或 ARM 模拟器上跑出正确结果。适合谁做适合已经学过编译原理理论课、想拿一个真实项目把“龙书”里那些抽象概念砸实的人。接下来我会按实际动手顺序把词法分析、语法分析、语义检查、中间代码生成、目标代码生成和测试验证这条链路拆开讲清楚每一步都给可复现的命令和参数。2. 词法分析与语法分析用 Flex/Bison 还是手写递归下降2.1 先定工具链为什么我推荐手写而不是 Flex/BisonSysY 的语法规则在官方文档里给得很明确用 Flex 写词法、Bison 写语法是最省事的路径。但试点班大作业通常要求你“理解每一行代码为什么这么写”而且 Bison 的移进/归约冲突在 SysY 的表达式优先级处理上会频繁出现调起来非常痛苦。我一般会建议手写递归下降原因是SysY 的语法层级清晰表达式优先级用几个函数层层调用就能表达不需要引入额外的状态机。更重要的是手写之后你在语义分析和中间代码生成阶段可以自由地在 AST 节点里挂载符号表指针和类型信息不用跟 Bison 的语义动作较劲。先看词法分析的核心结构。SysY 的 token 类型包括关键字int、void、const、if、else、while、break、continue、return、标识符、整数字面量十进制、八进制、十六进制、运算符和分隔符。下面是一个最小可用的词法分析器骨架# lexer.py import re TOKEN_SPEC [ (COMMENT, r//[^\n]*|/\*[\s\S]*?\*/), (KEYWORD, r\b(int|void|const|if|else|while|break|continue|return)\b), (IDENT, r[A-Za-z_][A-Za-z0-9_]*), (HEX, r0[xX][0-9a-fA-F]), (OCT, r0[0-7]*), (DEC, r[1-9][0-9]*|0), (OP, r|!||||\|\||[-*/%!;,()\[\]{}]), (WS, r\s), ] def tokenize(src): tokens [] pos 0 while pos len(src): for name, pattern in TOKEN_SPEC: m re.match(pattern, src[pos:]) if m: text m.group(0) if name not in (WS, COMMENT): tokens.append((name, text, pos)) pos len(text) break else: raise SyntaxError(fUnexpected char at {pos}: {src[pos]}) return tokens这段代码的逻辑是按优先级顺序尝试匹配注释和空白直接跳过。参数上要注意十六进制和八进制必须放在十进制之前匹配否则0x10会被拆成0和x10。标识符的正则要放在关键字之后不然int会被当成普通标识符。实际跑的时候输入一段 SysY 源码输出 token 序列先确认没有漏字符、没有把数字切错。2.2 递归下降解析器表达式优先级怎么落到代码里语法分析阶段要把 token 流变成 AST。SysY 的表达式优先级从低到高是逻辑或、逻辑与、相等性、关系、加减、乘除模、一元、基本表达式。递归下降的写法就是每个优先级写一个函数低优先级函数调用高优先级函数。下面给出加减和乘除两层的实现# parser.py class Parser: def __init__(self, tokens): self.tokens tokens self.pos 0 def peek(self): return self.tokens[self.pos] if self.pos len(self.tokens) else (EOF, , -1) def eat(self, kindNone, textNone): tok self.peek() if kind and tok[0] ! kind: raise SyntaxError(fExpected {kind}, got {tok}) if text and tok[1] ! text: raise SyntaxError(fExpected {text}, got {tok}) self.pos 1 return tok def parse_mul(self): node self.parse_unary() while self.peek()[1] in (*, /, %): op self.eat()[1] right self.parse_unary() node (binop, op, node, right) return node def parse_add(self): node self.parse_mul() while self.peek()[1] in (, -): op self.eat()[1] right self.parse_mul() node (binop, op, node, right) return node逻辑说明parse_add先调parse_mul拿到一个乘除表达式然后只要遇到加减号就继续循环保证左结合。参数上eat函数负责消费 token 并做类型检查如果不符合预期就抛异常。这里没有处理一元负号实际写的时候parse_unary里要判断-和!。跑通的标准是给一段带括号和混合运算的表达式能正确还原出 AST 的嵌套结构。3. 语义分析与符号表变量作用域和类型检查怎么做才不漏3.1 符号表的数据结构选型栈式作用域链SysY 支持块级作用域{}里面定义的变量在外面不可见内层可以遮蔽外层同名变量。符号表最自然的实现是栈式作用域链进入一个块就压入一个新作用域退出就弹出。每个作用域是一个字典查找时从栈顶往下找。下面是一个可直接用的实现# symtab.py class SymbolTable: def __init__(self): self.scopes [{}] # 全局作用域 def enter_scope(self): self.scopes.append({}) def exit_scope(self): self.scopes.pop() def declare(self, name, info): if name in self.scopes[-1]: raise SemanticError(fRedefinition of {name}) self.scopes[-1][name] info def lookup(self, name): for scope in reversed(self.scopes): if name in scope: return scope[name] return None逻辑说明declare只检查当前作用域是否重名允许内层遮蔽外层。lookup从最内层往外找找到就返回。参数上info里至少存类型int/void、是否是常量、常量值如果是 const、是否是函数。实际跑的时候遇到变量引用就调lookup返回 None 就报“未声明标识符”。3.2 语义检查的四个必做项未声明、重定义、类型匹配、返回值语义分析阶段要遍历 AST做四类检查。第一变量引用前必须已声明函数调用前必须已定义或声明。第二同一作用域不能重复定义同名变量。第三赋值号左边必须是左值且类型要匹配SysY 只有 int 和 void所以void函数不能用在表达式里。第四非 void 函数的所有路径都必须有 returnvoid 函数不能有返回值。下面是一个遍历函数声明的检查片段def check_func(self, node): # node: (func, ret_type, name, params, body) ret_type, name, params, body node[1], node[2], node[3], node[4] self.symtab.declare(name, {type: ret_type, params: params}) self.symtab.enter_scope() for ptype, pname in params: self.symtab.declare(pname, {type: ptype}) self.check_block(body) self.symtab.exit_scope() if ret_type ! void and not self.has_return(body): raise SemanticError(fFunction {name} may not return a value)逻辑说明先声明函数名再进入新作用域声明参数然后检查函数体。has_return需要递归遍历所有分支确认每条路径都有 return。参数上params是(类型, 名字)的列表。跑通的标准是故意写一个缺 return 的函数编译器要报错写一个重定义变量也要报错。4. 中间代码生成从 AST 到四元式怎么保证顺序和临时变量不冲突4.1 四元式设计为什么不用三地址码的字符串拼接中间代码我一般用四元式(op, arg1, arg2, result)比直接拼三地址码字符串好调试。SysY 的中间代码需要支持算术运算、逻辑运算、比较、赋值、跳转、标签、函数调用、返回。下面是一个生成四元式的核心函数# irgen.py class IRGen: def __init__(self): self.quads [] self.temp_count 0 self.label_count 0 def new_temp(self): self.temp_count 1 return ft{self.temp_count} def new_label(self): self.label_count 1 return fL{self.label_count} def emit(self, op, arg1None, arg2None, resultNone): self.quads.append((op, arg1, arg2, result)) def gen_expr(self, node): if node[0] num: return str(node[1]) if node[0] binop: left self.gen_expr(node[2]) right self.gen_expr(node[3]) temp self.new_temp() self.emit(node[1], left, right, temp) return temp if node[0] var: return node[1]逻辑说明gen_expr递归处理表达式遇到二元运算就先算左右子表达式再生成一条四元式返回临时变量名。参数上op是运算符arg1和arg2是操作数result是存放结果的临时变量或变量名。跑通的标准是给一个a b c * d生成的四元式顺序应该是先算c * d到t1再算b t1到t2最后a t2。4.2 控制流生成if/else 和 while 的标签回填控制流是中间代码生成里最容易翻车的地方。if/else 需要生成条件跳转和标签while 需要回边跳转。下面是一个 if/else 的生成逻辑def gen_if(self, node): # node: (if, cond, then_body, else_body) cond self.gen_cond(node[1]) label_else self.new_label() label_end self.new_label() self.emit(if_false, cond, None, label_else) self.gen_block(node[2]) self.emit(goto, None, None, label_end) self.emit(label, None, None, label_else) if node[3]: self.gen_block(node[3]) self.emit(label, None, None, label_end)逻辑说明先算条件生成if_false跳转到 else 标签然后生成 then 块再无条件跳转到 end 标签接着放 else 标签和 else 块最后放 end 标签。参数上gen_cond负责把比较表达式转成条件跳转或布尔临时变量。跑通的标准是写一个带嵌套 if/else 的 SysY 程序生成的四元式标签不重复、跳转目标都存在。5. 目标代码生成与测试RISC-V 汇编怎么调测试用例怎么跑5.1 从四元式到 RISC-V寄存器分配和栈帧布局目标代码生成阶段要把四元式翻译成 RISC-V 汇编。SysY 编译器通常要求生成 RV32IM 汇编然后在模拟器上跑。寄存器分配最简单的做法是每个临时变量都分配一个栈槽运算时加载到t0、t1算完存回栈。函数调用要遵循 RISC-V 调用约定参数放a0-a7返回值放a0。下面是一个四元式到汇编的翻译片段def gen_riscv(quads): asm [] stack_offset {} current_offset 0 def get_offset(name): nonlocal current_offset if name not in stack_offset: current_offset 4 stack_offset[name] -current_offset return stack_offset[name] for op, arg1, arg2, result in quads: if op : off1 get_offset(arg1) off2 get_offset(arg2) offr get_offset(result) asm.append(flw t0, {off1}(sp)) asm.append(flw t1, {off2}(sp)) asm.append(add t2, t0, t1) asm.append(fsw t2, {offr}(sp)) elif op label: asm.append(f{result}:) elif op goto: asm.append(fj {result}) elif op if_false: off get_offset(arg1) asm.append(flw t0, {off}(sp)) asm.append(fbeqz t0, {result}) return asm逻辑说明每个变量和临时变量都分配一个栈偏移用sp做基址。加法翻译成两条lw、一条add、一条sw。参数上stack_offset字典记录每个名字的偏移current_offset每次减 4。跑通的标准是生成的汇编能在 RISC-V 模拟器如 QEMU 或 Venus上汇编通过并且跑出正确结果。5.2 测试用例怎么组织从官方样例到边界用例SysY 大作业通常会提供一批测试用例包括功能测试和性能测试。功能测试覆盖表达式、分支、循环、函数调用、全局变量、常量。性能测试会跑矩阵乘法、斐波那契等。我一般会先跑官方样例确认基本功能然后自己补边界用例空函数、只有 return 的函数、多层嵌套作用域、短路求值、负数除法、取模负数。下面是一个测试脚本的骨架#!/bin/bash # run_tests.sh for sysy_file in tests/*.sy; do base$(basename $sysy_file .sy) ./compiler $sysy_file out/$base.s if [ $? -ne 0 ]; then echo COMPILE FAIL: $base continue fi riscv64-linux-gnu-gcc -static out/$base.s -o out/$base qemu-riscv32 out/$base echo $base exit code: $? done逻辑说明遍历tests/下的.sy文件编译成汇编再用交叉编译器汇编链接最后用 QEMU 跑。参数上riscv64-linux-gnu-gcc需要提前装好qemu-riscv32用于执行。跑通的标准是所有测试用例的 exit code 和预期一致性能测试的运行时间在可接受范围内。6. 避坑与排查SysY 编译器最容易翻车的五个地方6.1 短路求值没做逻辑表达式结果不对现象if (a b)里即使a为假b的副作用还是被执行了。原因中间代码生成时把当成普通二元运算两边都算了。解决在gen_cond里对和||做短路处理a b翻译成if_false a goto L; if_false b goto L; ...||类似。6.2 全局变量初始化顺序错常量折叠出问题现象全局const int N 10; int a[N];报数组大小不是常量。原因符号表里全局常量的值没有在声明时立即求值或者求值顺序不对。解决在语义分析阶段遇到全局 const 声明时立刻计算常量表达式的值并存入符号表后续数组维度直接用这个值。6.3 函数调用参数超过 8 个栈上传参漏了现象调用有 10 个参数的函数第 9、10 个参数值不对。原因RISC-V 调用约定里超过 8 个的参数要放在栈上生成代码时只处理了a0-a7。解决在函数调用生成时前 8 个参数放寄存器后面的参数依次压栈被调用函数在栈帧里按偏移取。6.4 临时变量命名冲突嵌套表达式结果被覆盖现象a b c d算出来不对。原因new_temp生成的临时变量名在递归过程中被复用或者栈槽分配时同一个名字被分配了不同偏移。解决确保new_temp全局唯一递增栈槽分配用字典记录同一个名字只分配一次。6.5 性能测试超时没做基本块优化现象矩阵乘法跑了几十秒还没出结果。原因每个临时变量都走栈没有做寄存器分配和常量传播。解决至少做局部常量折叠和死代码消除把频繁使用的变量尽量留在寄存器里。如果时间紧先把-O0跑通再逐步加优化。7. 进阶技巧用差分测试和形式化验证思路把编译器逼到墙角差分测试是验证编译器正确性最有效的手段之一。思路很简单同一段 SysY 程序分别用你的编译器和 GCC把 SysY 当 C 的子集编译生成可执行文件跑同样的输入比较输出。如果输出不一致说明你的编译器在某处翻译错了。下面是一个差分测试的脚本片段#!/bin/bash # diff_test.sh for f in tests/*.sy; do base$(basename $f .sy) # 你的编译器 ./compiler $f out/$base.my.s riscv64-linux-gnu-gcc -static out/$base.my.s -o out/$base.my qemu-riscv32 out/$base.my out/$base.my.out # GCC 参考 cp $f out/$base.c gcc out/$base.c -o out/$base.gcc ./out/$base.gcc out/$base.gcc.out # 比较 if ! diff -q out/$base.my.out out/$base.gcc.out /dev/null; then echo DIFF FAIL: $base fi done逻辑说明把 SysY 源码分别喂给你的编译器和 GCC跑出结果后逐字节比较。参数上qemu-riscv32跑你的 RISC-V 二进制gcc直接编译 C 版本。注意 SysY 的putint、getint等内置函数在 GCC 版本里需要提供对应的 C 实现否则链接会失败。差分测试能抓出很多手工测试漏掉的问题尤其是表达式求值顺序、整数溢出行为、负数除法和取模。我自己的习惯是每加一个新特性先跑一遍差分测试确认没有回归再跑官方测试用例。另外如果时间允许可以给关键模块写不变量断言比如“每个临时变量在使用前一定被定义过”“每个标签一定被生成过”这些断言在调试阶段能省下大量翻车时间。最后说一个血泪教训不要等到所有模块写完再联调。词法分析写完就单独测 token 流语法分析写完就打印 AST 看结构语义分析写完就故意写错误程序看报错中间代码写完就手动模拟执行几条四元式。每层都验证过最后串起来的时候问题会少很多。希望帮到你。本文还有配套的精品资源点击获取
返回列表