
简介基于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 个整数长度 nvoid 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_constint 溢出后折叠结果不一致多维数组声明与下标访问展平为一维 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_tFloatLit转成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_mapstd::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::vectorLoopCtx 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 目标平台与 ABIRISC-V 还是 x86以及完整的编译验证命令SysY 评测通常要求输出可执行文件目标平台可以选 RISC-V 32 或 x86-64。选型主要看评测环境提供哪个模拟器RISC-V 常见用 qemu-riscv32x86 直接跑本机。工程上最省事的是让 LLVM 的 Target 层负责生成汇编再调用 clang 或 gcc 完成汇编和链接。一个完整的最小构建命令序列如下# 第一步SysY 编译器生成 LLVM IR ./build/sysycc tests/foo.sy -emit-llvm -o build/foo.ll # 第二步LLVM 静态编译器把 IR 转成目标汇编 llc build/foo.ll -filetypeasm -mtripleriscv32 -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 而不是汇编-mtripleriscv32告诉 llc 用 RISC-V 32 位目标生成指令-filetypeasm输出人类可读汇编而不是目标文件。如果你用 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 和编译器产物一并交给 clangclang 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 溢出时回绕还是 UBLLVM 优化器默认按不溢出假设如果你希望得到回绕结果加上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 这个体量恰好适合亲手把整条工具链走完你留下的测试方法、边界判断和踩坑记录比编译器本身更能说明你对编译原理的理解程度。希望帮到你。本文还有配套的精品资源点击获取