简介:基于SysY2022语言规范实现的完整编译器项目,面向编译原理课程学习者、课程设计学生及编译器后端研究者。项目涵盖从词法分析、语法分析、语义分析到中间代码生成、代码优化和目标代码生成的完整流程,输出可与LLVM IR衔接,便于深入理解编译器各阶段衔接和优化策略。资源包共39个文件,约1.1MB,主要包括12个SysY源程序测试用例、8个头文件与6个C++实现文件,以及Markdown笔记、PDF规范文档、构建脚本和说明文档,目录划分清晰,可直接编译运行。已有60人学习/下载。配套的说明文件、Flex词法分析实验指导PDF和测试用例,既能支撑课程实验验证,也可作为二次开发和优化研究的基础平台。项目严格遵循SysY2022语言定义,代码结构规范,注释清晰,适合作为毕业设计或开源项目参考。
1. SysY2022 编译器项目到底在做什么:一门教学语言为什么值得亲手写完整个工具链
我第一次把 SysY 编译出的程序跑出正确结果,印象最深的不是某个优化起效,而是“原来从字符串到机器指令之间隔着这么多层”。SysY2022 是编译器竞赛和编译课程里常见的 C 语言子集规范:保留 int、float、数组、控制流和函数调用,砍掉指针、结构体、预处理等会让前端复杂的部分。这个标题里的项目,就是把 SysY 源程序完整走一遍“词法分析 → 语法分析 → 语义分析 → 中间表示 → 目标代码生成”的编译器实现,最终产物可以是机器码,也可以是 LLVM 中间表示。
这类项目适合三类人:准备编译系统竞赛的队伍,想系统驾驭 LLVM 工具链的工程师,以及想把编译原理课本和实际代码对应的学生。SysY 的价值在于足够小,小到你能在一周内把整根链路改明白,而不是面对 gcc 那样的大黑匣子只敢用不敢动。下面按“规范 → 前端 → 后端 → 避坑 → 验收”展开,每个阶段都有可以直接照抄的命令和核心代码。
2. 读懂 SysY2022 语言定义:动手前先画出特性边界和可执行语义
2.1 SysY 是 C 的哪个子集:int/float、数组与控制流
SysY2022 从外表看像一个“去掉高级特性的 C”:基本类型只有 int、float 和 void;变量可以用 const 修饰;数组支持一维到多维,可以出现在全局或局部;语句层面保留 if-else、while、for、break、continue、return;函数支持带参数定义和调用,也允许递归。
先看一段最典型的 SysY 代码,理解了它,整条编译链路要处理什么就清楚了:
const int N = 10; int a[N]; int add(int x, int y) { return x + y; } int main() { int i = 0; int s = 0; while (i < N) { a[i] = add(i, 1); s = s + a[i]; i = i + 1; } putint(s); return 0; }这段代码覆盖了 SysY 的大多数核心特性:const 全局常量、全局数组、函数定义与调用、循环与赋值、运行时库函数 putint。注意它没有指针、没有结构体、没有 switch,也没有printf那种可变参数函数,所有输出都走 SysY 规定好的库函数。这直接决定了解析器不需要处理声明符的复杂嵌套,运行时库也只要实现固定几个符号。
被砍掉的部分比保留的部分更值得琢磨。没有指针意味着数组访问只能靠下标,不需要取地址和间接跳转;没有结构体意味着类型系统只需要关心 int、float、void 和数组;没有预处理意味着编译器前端不需要维护宏展开表。这些限制让一个手写的递归下降解析器就能完全拿下语法分析,不需要引入 flex/bison 这类大体积生成器,调试门槛和依赖成本都低很多。
不过“子集”这个说法有一个隐蔽的坑:SysY2022 的规范文本和不同竞赛环境之间存在细微口径差异。例如有些版本允许位运算符和三目运算符,有些版本明确排除;有些评测器对递归深度不设限制,有些会把进程栈限得很浅。动手前最好把规范原文里的语法定义一节逐条过一遍,按你实际要面对的评测集做裁剪,否则前面前端写得越深,返工成本越高。
2.2 规范里没明说的约束:常量表达式、递归深度与运行时库
SysY2022 规范正文很短,它不会逐字告诉你的往往是评测和运行环境的事。我总结有三个高频约束,每个都能让一个看似正确的编译器在验收时翻车。
第一个是常量表达式。数组维度、全局变量初始化这些位置基本都要求编译期可求值,比如int a[2 * N + 1]里2 * N + 1必须在编译期算出来。这意味着前端必须有一个能在编译期求 int 算术的常量折叠器。我建议在语义分析阶段就单独实现一个evaluate_const(ASTNode*),而不是等到生成 IR 时用llvm::Constant现算,因为你在处理数组声明时就要拿到维度值去排内存布局。
第二个是递归深度与栈空间。SysY 语法允许函数递归,实测评测数据里递归深度可能到几万。如果后端是自写汇编,只做固定栈帧而不处理动态栈增长,深递归会直接段错误。就算用 LLVM 后端,也要确认生成的栈帧对齐方式符合目标 ABI,测评时给进程栈留足够余量。
第三个是运行时库,这是新手最晚注意到的地方。SysY 代码里的 getint、getch、putint、putch、getarray、putarray 并不是 C 标准库函数,而是配套定义一套 SysY 库接口。常见签名如下:
| 函数 | 作用 | 返回 |
|---|---|---|
| int getint() | 读一个十进制整数 | 读到的值 |
| int getch() | 读一个字符 | 字符的 ASCII 值 |
| int getarray(int a[]) | 先读数组长度 n,再读 n 个整数 | 长度 n |
| void putint(int n) | 输出整数 | 无 |
| void putch(int c) | 输出一个字符 | 无 |
| void putarray(int n, int a[]) | 输出长度和数组元素 | 无 |
编译器本身可以不实现这些函数,但链接阶段必须把这些符号补上。很多人的编译器本地用 gcc 链接能跑,放到评测环境就报undefined reference to getint,原因就是把 sylib 当成“系统自带”了。
2.3 把规范翻译成特性矩阵:从哪里开始实现最稳
拿到规范后,我习惯先画一张特性矩阵再动手。这张表能帮你在开发中途停下来时不迷路:
| 特性范围 | 常见要求 | 实现建议 | 主要风险 |
|---|---|---|---|
| int/float 算术与关系运算 | 加减乘除、取模、比较 | 映射到 LLVM add/fadd 系列 | 有符号除法和取模的边界语义 |
| const 与常量折叠 | 数组维度、全局初始化 | 语义层写 evaluate_const | int 溢出后折叠结果不一致 |
| 多维数组 | 声明与下标访问 | 展平为一维 + GEP 偏移 | 维度顺序和步长算反 |
| if-else / while / for | 完整控制流 | 递归下降 + 基本块跳转 | 悬空 else 和 break 作用域 |
| 函数调用与递归 | 支持调用,允许递归 | 按目标 ABI 生成 call | 传参超过寄存器数量的栈上传递 |
| 运行时库 | getint/putint/getarray | 单独编译 sylib.c | 与 libc 缓冲区冲突 |
优先级排序上,我建议按“int 常量表达式 + putint → 变量与赋值 → 分支和循环 → 函数调用 → 数组 → 全局变量 → 浮点 → 优化”推进。每一步都留一个 golden test,保证新增特性不破坏旧用例。哪怕评测前只做到函数调用这一步,交出去的也已经是一个能通过大量基础用例的编译器。
注意:特性矩阵不是一次画完就不变的。当你开始写后端,遇到“数组维度是常量但常量折叠器算错了”这类问题,要回头改矩阵并在对应行做标记,保证整个项目对规范的理解始终一致。
3. 前端到中间表示:从 SysY 源码到 LLVM IR 的完整流水线
3.1 词法与语法分析:手写递归下降还是用生成器
SysY 的 token 种类比 C 少很多,词法分析器几百行就能写完。常见做法是手写词法器加递归下降解析器,而不是引入 flex/bison,原因有两个:SysY 语法规模小,手写维护成本低;生成器产出的报错信息往往不如手写可控,而 SysY 评测对编译错误提示也有格式要求。
词法层的 token 集合可以这样组织:
// lexer.h —— SysY 的 token 集合一览 enum class Tok { Int, Float, Void, Const, Ident, IntLit, FloatLit, If, Else, While, For, Break, Continue, Return, Assign, // = Add, Sub, Mul, Div, Mod, // + - * / % Eq, Ne, Lt, Le, Gt, Ge, // == != < <= > >= AndAnd, OrOr, Not, // && || ! LParen, RParen, LBrace, RBrace, LBracket, RBracket, Semi, Comma, Eof };词法器的工作就是按字符流切出这些 token,并把IntLit的十进制字符串转成int64_t,FloatLit转成double。SysY 没有十六进制、没有字符字面量、没有字符串字面量,所以这块几乎不存在边界争议。
语法层采用递归下降,表达式解析用优先级攀升法。SysY 的运算符优先级从低到高是||、&&、== !=、< <= > >=、+ -、* / %、一元、后缀。写成代码大概是:
// parser.cpp —— 按优先级层实现二元表达式解析 Expr *Parser::parseExpr() { return parseAssign(); } Expr *Parser::parseBinary(int minPrec) { Expr *lhs = parseUnary(); // 先解析一元层 while (true) { int prec = binary_prec(curTok); // 查当前运算符优先级 if (prec < minPrec) break; Tok op = curTok; next(); Expr *rhs = parseBinary(prec + 1); // 左结合,递归下一层 lhs = new BinaryExpr(op, lhs, rhs); } return lhs; }binary_prec返回 -1 表示当前 token 不是二元运算符。这里用parseBinary(prec + 1)而不是parseBinary(prec),是为了保证1 - 2 - 3解析成(1-2)-3而不是1-(2-3)。
函数定义、变量声明、语句块这些边界用独立的 parse 函数处理。SysY 没有指针声明符,所以变量声明的解析不需要处理*、[]的复杂组合,只要区分当前是类型关键字还是普通标识符即可。
3.2 语义检查与作用域管理:未声明变量、类型不匹配、常量维度
语法分析得到 AST 之后,下一步是语义分析。SysY 需要检查的点集中在这几处:变量必须先声明后使用,函数调用参数个数与类型要匹配,数组维度必须是常量表达式,return 语句的类型要和函数返回类型一致。
作用域管理是语义分析的地基。SysY 作用域规则和 C 一致:块内可以遮蔽外层同名变量,函数参数只在函数体内可见。实现上用一层一层的符号表最直观:
// symbol.h —— 作用域链式符号表 class SymbolTable { struct Scope { std::unordered_map<std::string, Symbol> symbols; Scope *parent; }; Scope *cur; public: void pushScope() { cur = new Scope{ {}, cur }; } void popScope() { Scope *old = cur; cur = cur->parent; delete old; } void define(const std::string &name, const Symbol &sym) { cur->symbols[name] = sym; // 允许遮蔽,不做重名检查 } Symbol *find(const std::string &name) { for (Scope *s = cur; s; s = s->parent) { auto it = s->symbols.find(name); if (it != s->symbols.end()) return &it->second; } return nullptr; } };这个表在遍历 AST 时维护:进入 Block 节点pushScope,离开时popScope;遇到变量声明调用define,遇到标识符引用调用find。
这里有一个非常关键的实现顺序问题:处理函数体之前,要先扫描一遍全局声明和函数原型,把所有函数签名记录到符号表,再逐个分析函数体。否则 A 函数调用在它后面定义的 B 函数时,会误报“未声明标识符”。SysY 允许函数相互调用,这个两遍处理避免了一大批假阳性错误。
数组维度的常量求值也在语义层做。比如:
// 处理 int a[exp]; 时,先把 exp 求值成常量 int64_t dim = evaluate_const(exp); if (dim < 0) error("array dimension must be non-negative");evaluate_const递归计算 AST 中的字面量、const 变量和算术运算。遇到非常量表达式要报错,并给出具体位置,方便评测时定位是哪一行出了问题。
3.3 用 LLVM IR 生成可验证的三地址码:一个 if 和 while 的最小例子
语义分析通过后,进入中间表示生成。SysY 编译器最常见的选型是直接生成 LLVM IR,因为后续优化和后端代码生成可以全部交给 LLVM,编译器本身只负责语义正确的前端。
LLVM IR 生成的核心套路是“所有局部变量统一用 alloca 分配,访问时 load/store”。不要一开始就尝试生成严格 SSA 形式的 phi 节点,那会把数据流分析问题提前引进来。alloca 方式的 IR 虽然看起来“低效”,但语义直观,而且 LLVM 的mem2reg优化 pass 会自动把它提升成 SSA 形式。
生成 if-else 的代码骨架如下:
// irgen.cpp —— 用 IRBuilder 生成 if-else 控制流 void IRGenerator::genIf(IfStmt *stmt) { Function *fn = builder.GetInsertBlock()->getParent(); BasicBlock *thenBB = BasicBlock::Create(ctx, "if.then", fn); BasicBlock *elseBB = BasicBlock::Create(ctx, "if.else", fn); BasicBlock *contBB = BasicBlock::Create(ctx, "if.end", fn); builder.CreateCondBr(genExpr(stmt->cond), thenBB, elseBB); builder.SetInsertPoint(thenBB); genStmt(stmt->thenBody); if (!builder.GetInsertBlock()->getTerminator()) builder.CreateBr(contBB); // then 里没有 return 才跳 cont builder.SetInsertPoint(elseBB); if (stmt->elseBody) genStmt(stmt->elseBody); if (!builder.GetInsertBlock()->getTerminator()) builder.CreateBr(contBB); builder.SetInsertPoint(contBB); }这里的两个getTerminator()判断值得强调。SysY 允许if (x) return 1;,此时 then 分支已经以 ret 结束,不能再补 br 指令,否则 LLVM 会报“basic block 已有终结指令”。这也是初学者最容易在生成 IR 时崩掉的地方。
while 循环的生成类似,关键是 break 和 continue 跳转目标的维护。常见做法是用一个栈保存每层循环的 break 目标和 continue 目标:
// irgen.cpp —— 维护循环跳转目标栈 struct LoopCtx { BasicBlock *breakBB; BasicBlock *contBB; }; std::vector<LoopCtx> loopStack; void IRGenerator::genWhile(WhileStmt *stmt) { BasicBlock *condBB = BasicBlock::Create(ctx, "while.cond", fn); BasicBlock *bodyBB = BasicBlock::Create(ctx, "while.body", fn); BasicBlock *endBB = BasicBlock::Create(ctx, "while.end", fn); builder.CreateBr(condBB); builder.SetInsertPoint(condBB); builder.CreateCondBr(genExpr(stmt->cond), bodyBB, endBB); loopStack.push_back({ endBB, condBB }); builder.SetInsertPoint(bodyBB); genStmt(stmt->body); if (!builder.GetInsertBlock()->getTerminator()) builder.CreateBr(condBB); loopStack.pop_back(); builder.SetInsertPoint(endBB); }break 语句直接生成CreateBr(loopStack.back().breakBB),continue 则是CreateBr(loopStack.back().contBB)。这样嵌套循环的跳转关系不会错。
函数参数的传递也要在入口处处理:IRBuilder 创建函数后,函数参数是 SSA 值,不能直接 store。所以要为每个参数创建 alloca,然后 store 参数值。这一步和局部变量统一步调,后面还要把参数名绑定到对应 alloca 地址上,供函数体内 load 引用。
生成 IR 后,建议立刻用一个最小可用工具链验证:自己生成的 .ll 文件能被llvm-as接受,能被clang -O0直接编译运行。这样能在“前端正确性”和“后端正确性”之间划一道清晰分界线,后续排查时可以快速判断问题到底出在 IR 生成还是目标代码生成。
4. 从 LLVM IR 到机器码:后端选择、汇编、链接与 SysY 运行时
4.1 接入 LLVM 后端还是自写机器码生成:两种路线的成本对比
标题里同时写了“机器码”和“LLVM 中间表示”,说明这个项目的预期路径是利用 LLVM IR 作为中间层,再交给 LLVM 后端生成目标机器码。这也是目前 SysY 编译器最稳妥、产出质量最高的路线。
自写后端和用 LLVM 后端的差别非常大,动手前先想清楚这几点:
| 对比维度 | 用 LLVM 后端 | 自写汇编生成器 |
|---|---|---|
| 开发量 | 小,前端生成 IR 即可 | 大,需要指令选择、寄存器分配、栈帧管理 |
| 调试难度 | 低,IR 可读性强 | 高,一个寄存器分配 bug 会让所有用例崩掉 |
| 指令质量 | 高,LLVM 自带优化 | 取决于你的实现深度,通常较差 |
| 可控性 | 受 LLVM 版本约束 | 完全可控 |
| 适合场景 | 标准完整实现、快速交付 | 竞赛极限优化、教学演示后端原理 |
SysY 是一个教学和竞赛性质的语言,与其把大量时间投入写一个不如 LLVM 的 x86 后端,不如把精力放在前端语义正确性和测试覆盖上。自写后端适合你已经有一个能工作的完整编译器之后,再按需换掉某一段。常见做法是先让 LLVM 后端把整条链路走通,之后想深入研究再尝试对特定 IR 模式做手写指令选择。
4.2 目标平台与 ABI:RISC-V 还是 x86,以及完整的编译验证命令
SysY 评测通常要求输出可执行文件,目标平台可以选 RISC-V 32 或 x86-64。选型主要看评测环境提供哪个模拟器:RISC-V 常见用 qemu-riscv32,x86 直接跑本机。工程上最省事的是让 LLVM 的 Target 层负责生成汇编,再调用 clang 或 gcc 完成汇编和链接。
一个完整的最小构建命令序列如下:
# 第一步:SysY 编译器生成 LLVM IR ./build/sysycc tests/foo.sy -emit-llvm -o build/foo.ll # 第二步:LLVM 静态编译器把 IR 转成目标汇编 llc build/foo.ll -filetype=asm -mtriple=riscv32 -o build/foo.s # 第三步:用 clang 汇编并链接运行时库 clang build/foo.s runtime/sylib.c -o build/foo # 第四步:跑起来并捕获输出 ./build/foo < tests/foo.in # 第四步换成真实机器码视角,反汇编看自己的程序长什么样 objdump -d build/foo | head -80每个参数都有实际意义:-emit-llvm表示编译器输出 IR 而不是汇编,-mtriple=riscv32告诉 llc 用 RISC-V 32 位目标生成指令,-filetype=asm输出人类可读汇编而不是目标文件。如果你用 x86-64,把-mtriple改成x86_64-unknown-linux-gnu即可。
这里要注意,生成机器码这件事并不完全属于编译器的职责范围。编译器到汇编就结束了,剩下的“汇编”和“链接”由 clang 和系统汇编器完成。你在评测环境里看到的可执行文件,实际上是“编译器输出汇编 + 汇编器生成目标文件 + 链接器合并运行时库”的产物。提前理解这条链路,遇到undefined reference或relocation truncated时就不会无头绪。
如果评测目标是 RISC-V,本地没有 qemu 的话必须在交叉环境验证。常见做法是装qemu-user并用-L指定 sysroot,或者干脆在评测服务器上跑。开发期我会优先用 x86-64 作为主目标,保证功能正确后再切换到 RISC-V 检查 ABI 细节。
4.3 运行时库函数和链接:getint、putint 这些符号从哪里来
SysY 源程序自身不定义 getint、putint,这些符号需要编译器项目额外携带一个运行时库。常见做法是提供一个 sylib.c,编译时和用户的汇编一起链接。
一个最精简的实现可以直接基于 POSIX read/write 系统调用,避免和 libc 的 stdio 缓冲区打架:
// sylib.c —— 提供 SysY 规定的运行时符号 #include <unistd.h> int getch(void) { char ch = 0; if (read(0, &ch, 1) == 1) return (int)ch; return -1; } void putch(int c) { char ch = (char)c; (void)write(1, &ch, 1); } int getint(void) { int n = 0, sign = 1, ch = getch(); while (ch == ' ' || ch == '\n' || ch == '\r' || ch == '\t') ch = getch(); if (ch == '-') { sign = -1; ch = getch(); } for (; ch >= '0' && ch <= '9'; ch = getch()) n = n * 10 + (ch - '0'); return sign * n; } void putint(int n) { if (n == 0) { putch('0'); return; } if (n < 0) { putch('-'); // INT_MIN 边界用 long 兜住,避免 -n 溢出 long m = -(long)n; putint_abs(m); return; } putint_abs((long)n); }这里刻意不用getchar、printf,而是直接read/write系统调用。原因在于 SysY 的 getint 和 getch 会被交替调用,如果标准库 stdio 做了缓冲,你无法预知下一个字符是被缓冲在上层还是留在内核,很容易出现“getarray 读到错误数字”的诡异问题。直接用系统调用,行为就是确定性的:读一个字节就是一个字节。
putint_abs是一个处理无符号绝对值的内部函数,递归或循环把数字逐位拆出再 putch。这样避开INT_MIN取负溢出的经典坑。
链接顺序也有讲究。正确做法是把 sylib.c 和编译器产物一并交给 clang:
clang build/foo.s runtime/sylib.c -o build/foo不要把 sylib 编译成 .o 之后再链接到一半,避免用户代码里定义同名函数时出现多重定义。评测环境如果允许自定义运行时,还可以追加计时函数 starttime/endtime,但核心符号链路上,上面这份 sylib 已经覆盖绝大多数 SysY 程序。
5. SysY 编译器实现避坑指南:5 个最常见的翻车点和排查思路
5.1 悬空 else 和运算符优先级:语法阶段的表现和修复
现象:if (a) if (b) c = 1; else c = 2;被错误解析成if (a) { if (b) c = 1; } else c = 2;,评测用例里明明不该执行的赋值被执行了。另一类是1 + 2 * 3被算成 9,优先级表顺序配错。
原因:SysY 语法和 C 一样规定 else 匹配最近的未配对 if,但递归下降解析器如果写成“先解析 then,再回头判断有没有 else”,很容易在嵌套时做出错误归并。运算符优先级则是||、&&、按位、关系、移位、加乘这些层级的次序搞混。
解决:解析 if 时不主动去找 else,只记录当前 if 节点,等外层解析到 else token 时再做匹配。也就是把“else 归谁”的判断延迟到 token 流自然推进的那一刻。优先级攀升法里,parseBinary(minPrec)的层数顺序必须和规范表严格一致,每次只需要检查“当前 token 优先级是否大于等于最低优先级”,用prec < minPrec刹住,其余交给递归展开。排查时可以用-ast-dump之类的可视化选项打印 AST,一眼就能看出 else 挂在了哪个节点下。
5.2 数组维度与全局变量初始化:内存布局别想当然
现象:int a[3][4]; a[1][2] = 5;运行后把a[0][6]或者相邻变量覆盖了;全局int b[100];在没有初始化赋值时,执行结果里出现历史垃圾值。
原因:多维数组在内存里按行优先存储,a[i][j]的实际偏移是i * 列数 + j。如果生成 IR 时把维度和步长搞反,下标访问就会落到错误位置。全局变量如果生成到.bss段,初始化前应该全零,但如果你选择生成到.data段且没有给默认零初始化器,就会读到一个不确定值。
解决:前端的数组声明阶段就把维度表存到符号表里,生成 GEP 时用常量维度计算线性偏移。一个稳定写法是统一用i64下标做乘法累加:
// 多维下标展开:a[i][j] -> i * dims[1] + j llvm::Value *offset = builder.getInt64(0); for (int d = 0; d < ndim; d++) { offset = builder.CreateMul(offset, builder.getInt64(dims[d])); offset = builder.CreateAdd(offset, genExpr(idx[d])); }全局变量则坚持“不提供初始化器就生成零初始化”的规则,用ConstantAggregateZero或者直接声明后再补一个 store 零,别依赖目标平台的内存默认状态。你要知道,评测环境里进程地址空间哪怕新映射的页本身是零,也不该把正确性建立在“刚好是零”上。
5.3 编译期的“堆空间不足”和栈溢出:递归下降解析器的深度极限
现象:编译器处理深度嵌套表达式时崩溃,常见报错是segmentation fault,或者直接打印内存分配失败。有的评测脚本把这类错误归为“编译器的堆空间不足”。
原因:递归下降解析器在解析深度嵌套的表达式时会建立同样深的调用栈。SysY 测试用例虽然正常代码不会写几十层括号,但评测脚本可能做自动化生成,一排连续一元运算符或者一连串三元表达式都会把递归层数推到几十万。AST 节点也要占用编译器进程的堆内存。
解决:三层防护。第一层,解析一元运算符时用循环累积,而不是递归调用一次处理一个负号:遇到连续! ! ! !x时,先进数组再统一建节点。第二层,给解析器增加显式深度计数,超过阈值就报“表达式过深”,避免进程被系统终止。第三层,把 AST 节点的分配统一走std::unique_ptr或内存池,析构时一次释放,减少递归析构导致的额外栈消耗。排查时先复现表达式,再二分定位是哪一层递归爆掉,大多数情况下都是 unary 链或赋值链造成的。
5.4 未定义行为让评测结果和 gcc 不一致:除法取模与有符号溢出
现象:同一个 SysY 程序,用你的编译器输出和用 gcc 直接跑 C 版本,结果在极端输入下不一致。常见于-2147483648 / -1、有符号整数溢出、负数取模这些边界。
原因:SysY 规范对未定义行为的态度是“参照 C”,但 C 标准对这类情况本身就不作规定。LLVM 的 sdiv 指令在溢出时产生的结果和 gcc 在 x86 上也可能不同;你的常量折叠器如果按数学规则算出结果而运行时按机器指令算,自然就分叉了。
解决:在语义分析阶段就锁死口径。除法与取模都映射到 LLVM 有符号指令sdiv/srem,常量折叠时也要模拟同样的截断行为。不要尝试“修正”溢出,SysY 评测数据通常回避这类输入,但你要保证你的编译器和运行时始终采用同一套约定。另一个常见分歧是i + 1这类赋值在 int 溢出时回绕还是 UB,LLVM 优化器默认按不溢出假设,如果你希望得到回绕结果,加上nsw标志前先想清楚。
5.5 链接阶段找不到 main 或运行时符号:入口点和 sylib 缺失
现象:链接器报类似“undefined reference to main”或“undefined reference to getint”的错误。有的环境还会直接提示“编译器未包含 main 类型”这样的诡异信息,实际是链接脚本找错了入口目标。
原因:SysY 源文件里明明写了int main(),但你的编译产物里 main 符号没有正确导出。常见于两个位置:前端解析函数声明时把 main 当成普通函数处理,但生成 IR 时函数没有 internal linkage 冲突;另一个是汇编器输出符号没有加下划线前缀的 ABI 要求。getint 这类符号则是 sylib 没参与链接,或者 sylib.c 编译出的目标文件没放在链接命令里。
解决:用llvm-nm build/foo.o查看编译器产物中的符号表,确认main是外部可见的全局符号。SysY 程序入口固定是main,不需要像嵌入式开发那样提供_start。运行时符号的修复就是检查链接命令行,确保 sysycc 的输出汇编、sylib.c、系统 libc 三者在同一条 clang 命令里。推荐做一个link.sh脚本把这些固定下来,每次编译流程都走同一个入口,不要在 Makefile 里写三套不同的链接参数。
6. 让编译器经得起评测:回归测试、优化开关与交付验收技巧
6.1 建立最小 golden 回归集
评测前最后悔的事,永远是“改了一个 bug,坏了三个旧用例”。SysY 编译器规模不大,但前后端联动性强,一处语义分析变更可能影响所有数组访问代码生成。我的做法是维护一个tests/目录,每个用例包含.sy源文件、.in输入、.std标准输出,用脚本批量回归:
#!/bin/bash # regression.sh —— 批量回归 SysY 编译器 set -euo pipefail for src in tests/case/*.sy; do name=$(basename "$src" .sy) ./build/sysycc "$src" -emit-llvm -o "build/$name.ll" clang "build/$name.ll" runtime/sylib.c -o "build/$name" "./build/$name" < "tests/case/$name.in" > "build/$name.out" || true if diff -u "tests/case/$name.std" "build/$name.out" > /dev/null; then echo "PASS $name" else echo "FAIL $name (see build/$name.out)" fi done这个脚本里最关键的是把源程序、输入、标准输出三者绑定在一起。SysY 评测程序大多通过标准输入喂数据,标准输出比对,因此这套结构可以直接对接评测环境。我习惯每个 feature 提交时至少新增一个用例,比如新增 float 支持,就放一个float_arith.sy,避免后续改动把浮点路径改坏。
6.2 优化开关:从 -O0 到 mem2reg 验证
SysY 编译器能跑通后,下一步就是在 LLVM IR 上做验证性优化。最安全的优化开关组合是-mem2reg -instcombine,分别处理 alloca 提升和常量折叠。用命令行验证优化效果:
opt -mem2reg -instcombine build/foo.ll -S -o build/foo_opt.llmem2reg会把局部变量的 alloca/load/store 提升成 SSA 值,instcombine做简单的代数化简。只要你前端的名字解析正确,这两个 pass 不会改变程序语义,但能把 IR 变得可读很多。评测指标如果卡性能,再往上看-O2,但先确认-O0下所有用例通过,再去追逐优化。我见过不少编译器最后不是挂在语义而是挂在优化 pass 引入的边界问题上。
6.3 交付前的内容结构
评测或交付前,检查项目压缩包里是否包含这几样:源码目录、构建脚本、测试用例、运行时库。一个 SysY 编译器项目的完整度,按“能生成 IR、能链接运行、能批量回归、能说明设计取舍”四级来判断。我在交付前会单独写一段 README,说明目标平台、支持的 SysY 特性范围、构建依赖和已知限制。这比堆代码更能让评测方快速定位你这个编译器做到了哪一步。
这些年做编译链路,我最怕的不是写不出 IR,而是改一处后端导致十几个用例静默飘红。现在的习惯是每次修改前先跑回归基线,修改后再跑对比,凡是有行为变化的都逐条看 diff,绝不带着“应该差不多”的心态过夜。SysY2022 这个体量恰好适合亲手把整条工具链走完,你留下的测试方法、边界判断和踩坑记录,比编译器本身更能说明你对编译原理的理解程度。希望帮到你。
本文还有配套的精品资源,点击获取