简介:一套基于 SysY2022 语言规范实现的完整编译器项目,面向编译原理课程设计与实践,目标是让 SysY 源程序经过完整编译流程输出可执行机器码或 LLVM 中间表示,便于教学演示与二次研究。项目内部按词法分析、语法分析、语义分析、中间代码生成、代码优化与目标代码生成等阶段组织,源代码与头文件分工清晰,并配套多项辅助文档。资源包共 39 个文件,约 1.1MB,除 12 个 SysY 测试程序外,以 C++ 源文件、头文件、Markdown 文档和 PDF 指南为主,另含 Flex 词法文件、PowerShell 测试脚本及工程配置文件,便于构建、验证和阅读实现细节。已有 60 人学习下载。严格遵循 SysY2022 定义,该项目对学习编译原理有重要参考价值,读者可从中获取完整可运行实现、SysY2022 语言定义 PDF、Flex 工具指导以及 README 与说明文件,快速上手并扩展优化,适合希望深入理解编译前后端的学生和研究者。
1. 这不是玩具编译器:SysY2022 源码到 LLVM IR 的完整链路
如果只靠教材学编译原理,很容易落入"一听就懂、一写就废"的尴尬。SysY 语言就是专门用来打破这个局面的:它是面向编译原理教学设计的类 C 语言,SysY2022 规范定义了一套精简但完整的语法子集。这个项目严格按 SysY2022 实现了一个完整编译器,从词法分析、语法分析、语义分析,到中间代码生成、代码优化和目标代码生成,六个阶段一个不少,最终输出可执行的机器码或 LLVM 中间表示(IR)。对把课程设计当工程做的学生来说,最值钱的不是"它能跑",而是源码里有完整的 Lexer、Parser、语义分析器和 IR 生成器,每个阶段都能独立打断点调试。适合三类人:正在做编译原理课程设计的学生、想研究 LLVM 后端优化的人,以及想借真实项目练 CMake 和工程化习惯的开发者。编译器和编辑器的区别,恰好在这个项目里能一次看明白:编辑器只帮你写文本,编译器才负责把文本变成机器能跑的东西。
2. 工程结构拆解:六个核心模块、CMake 构建与测试组织
拿到压缩包先别急着 build,花十分钟把目录结构过一遍,后面排查问题会省很多事。SysY-main 下的组织方式很典型:include/放所有头文件,src/放实现,tests/下按 work1、work2、work3 划分测试工作区,顶层还有一个CMakeLists.txt和一份 SysY2022 语言定义 PDF 文档。这个布局是标准的 C++ 工程结构,头文件和实现分离、测试独立成目录,对课程设计而言已经算规范了。
2.1 从 include 到 src:核心模块与文件职责对照
整个项目最核心的文件就是我前面说的那八头文件加五个源文件。先看头文件这侧:Lexer.h声明词法分析器接口,token.h定义 Token 种类,Parser.h和ast.h负责语法分析阶段;ast_visitor.h是访问者模式基类,semantic_analyzer.h做语义检查,symbol_table.h管符号表,ir.h定义中间代码的数据结构。src/下的实现文件与之一一对应,scanner.l是 Flex 词法规则文件,lexer.cpp是词法分析器实现,parser.cpp和ast.cpp构建语法树,symbol_table.cpp与semantic_analyzer.cpp完成语义阶段。main.cpp是编译器入口,负责把整个流程串起来。
| 文件 | 职责 | 对应编译阶段 |
|---|---|---|
| token.h / Lexer.h / scanner.l / lexer.cpp | Token 定义与词法扫描 | 词法分析 |
| Parser.h / parser.cpp / ast.h / ast.cpp | 递归下降解析与 AST 构建 | 语法分析 |
| symbol_table.h / semantic_analyzer.h | 符号管理与语义规则检查 | 语义分析 |
| ast_visitor.h / ir.h | AST 遍历与 IR 结构定义 | 中间代码生成 |
| main.cpp | 参数解析与阶段调度 | 入口调度 |
提示:
tests/下按 work1、work2、work3 划分,对应三组不同复杂度的 SysY 测试程序。word1.md 到 work4.md 文档里应该有各组测试点的说明,遇到"某阶段行为不符合预期"时,先翻对应文档,别一头扎进源码。
2.2 用 CMake 构建:命令、参数与产物说明
项目顶层CMakeLists.txt已经把源文件组织好了。我一般习惯在项目根目录建一个build/目录做 out-of-source 构建,避免编译产物污染源码目录。 VS Code 常见的一个困惑是"安装了但没法编译就报找不到编译器",核心原因就是 VS Code 本身只是个编辑器,真正的 gcc/clang 工具链需要单独配,而这个项目的 CMake 构建正好帮你走了完完整整的一遍。
mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Debug cmake --build . -j4第一个命令创建独立构建目录。第二个命令cmake ..会读取根目录的CMakeLists.txt,-DCMAKE_BUILD_TYPE=Debug告诉 CMake 生成带调试信息的 Makefile,这样后面用 GDB 调试 Lexer 或 Parser 时能直接看到源码行号;如果只关心最终产物,可以改成Release,优化级别默认会开到 O2 甚至 O3。第三个命令cmake --build . -j4里的-j4表示用四个并行任务编译,CPU 核多于四个可以调成-j8,但注意并行任务越多,内存占用越高——如果你跑在内存紧张的机器上,这个参数反而是翻车点,编译中途 OOM 的话先把并行数降下来。
构建完成后会在build/下生成编译器可执行文件。如果 README 或说明文件.txt里没有明确写产物名,优先去CMakeLists.txt里搜add_executable那行,产物名就在第一个参数位置。
提示:Linux 上需要 gcc 或 clang 作为宿主编译器,Windows 上建议用 MinGW 或者 Visual Studio 的 MSVC 工具链。CLion 和 VS Code 都能直接识别这个 CMake 工程,导入根目录即可。
2.3 Flex 文件 scanner.l:为什么项目里同时有 lexer.cpp
这个项目里同时出现scanner.l和lexer.cpp,对新人有迷惑性。常见做法有两种:一是纯手写词法分析器,lexer.cpp里一个字符一个字符地扫;二是用 Flex 这类工具写正则规则,自动生成词法分析代码。这个项目属于第二种:scanner.l是 Flex 的规则文件,里面定义了 SysY2022 的各类词法模式,lexer.cpp是 Flex 根据scanner.l生成的 C++ 实现。
flex --header-file=lexer.h scanner.l这条命令是 Flex 生成代码的常用姿势。--header-file=lexer.h指定生成头文件的名字,这样parser.cpp包含lexer.h时就能拿到yylex()函数声明。如果漏了这个参数,Flex 默认生成的头文件跟你手写的Lexer.h容易产生声明冲突,明明是同一份词法逻辑,编译却报"函数未声明"或"重定义"。用 Flex 而不是手写的好处很实际:SysY 的 Token 规则像整型字面量、浮点字面量、标识符这些正则写起来清晰,Flex 自动转成状态机代码,比自己维护手写扫描器少了大量容易看漏的边界字符判断。坏处是生成的代码可读性差,调试时看lexer.cpp不容易定位到具体规则,所以scanner.l里每个规则后面的{ }动作代码建议写得越短越好,只做 Token 创建,不做复杂逻辑。
3. 词法与语法分析:Token 流、递归下降与 AST 的构建细节
词法和语法两个阶段在 SysY 这类教学语言里可以共用同一个驱动:yylex()每调一次返回一个 Token,Parser 循环消费 Token 构建 AST。这两章是理解整个编译器的主干——后端再复杂,前提都是 AST 建得对不对。我建议你调试时从这里入手,因为词法语法错误最容易观察,tests/work1_test里那些简单程序就是拿来跑通这条链路的。
3.1 Token 设计与 Lexer:从字符流到词法单元
SysY2022 的 Token 种类基本覆盖了 C 语言的子集:关键字(int、float、if、else、while、return等)、标识符、整型和浮点字面量、运算符(+、-、*、/等)以及分隔符(括号、花括号、分号)。token.h里定义 Token 的枚举种类,常见做法是给每个 Token 附带行号和列号,Parser 报错时能直接给出源码位置。
enum class TokenKind { TK_IDENT, TK_INT_LIT, TK_FLOAT_LIT, TK_KW_INT, TK_KW_FLOAT, TK_KW_RETURN, TK_KW_IF, TK_KW_ELSE, TK_KW_WHILE, TK_PLUS, TK_MINUS, TK_STAR, TK_SLASH, TK_LPAREN, TK_RPAREN, TK_LBRACE, TK_RBRACE, TK_SEMICOLON, TK_EOF };这里每个枚举值就是一个 Token 类别。用enum class而不是裸enum的好处是类型安全,switch分发时不会不小心跟整数类型比较。实际 Token 对象还会带value字段,存放字面量字符串或者常量值,line和col字段记录位置,Lexer.h里一般会定义一个含这些字段的Token结构体。Flex 规则里每匹配一个模式,就构造一个这样的 Token 返回给 Parser。
看一下 scanner.l 里最典型的一条整数字面量规则:
[0-9]+ { yylval.intVal = atoi(yytext); return TK_INT_LIT; }[0-9]+是正则,匹配一个或多个数字;yylval.intVal把字符串转成整数存入语义值;返回TK_INT_LIT让 Parser 知道这是一个整型字面量。千万别在动作里做太复杂的事,比如判断这个整数是否越界、是否该转浮点,这些都留到语义分析阶段,词法只负责"认出是什么 Token"。
3.2 递归下降 Parser:AST 节点与 SysY2022 文法结构
parser.cpp用的是递归下降分析法,每个语法产生式对应一个解析函数。SysY2022 的文法足够简单,递归下降完全够用,而且调试直观:出错时直接看调用栈就知道卡在哪个产生式。AST 节点定义在ast.h,常见结构是基类StmtAST/ExprAST,派生类各表示一种语句或表达式。
std::unique_ptr<StmtAST> parseWhileStmt() { expect(TokenKind::TK_KW_WHILE); // 必吃 while 关键字,否则报错 consume(TokenKind::TK_LPAREN); // 不需要报错的符号直接吞掉 auto cond = parseExpr(); // 递归下降解析条件表达式 expect(TokenKind::TK_RPAREN); auto body = parseBlockStmt(); // 解析循环体 return std::make_unique<WhileStmtAST>(std::move(cond), std::move(body)); }expect和consume的差别是个常见的理解盲区:expect在 Token 不匹配时会产生语法错误并尝试跳过,方便报告具体位置;consume则假设 Token 一定存在,只用于(、)这种结构性符号。parseExpr()内部还会继续按优先级递归调parseAdditive、parseMultiplicative,形成经典的表达式解析链。WhileStmtAST用std::unique_ptr持有条件和循环体,所有权明确,析构时自动递归释放整棵子树,这对避免内存泄漏很重要。
AST 节点设计有个经验:节点字段越少越好。addExpr只要存左操作数、右操作数和运算符三个字段,多余信息留给后续阶段去推。新手常犯的错是把语义分析的结论(比如"这个表达式类型是 float")提前塞进 AST 节点,导致同一个字段在后续阶段出现歧义。
3.3 调试语法错误的常见手法:从报错信息反推产生式
编译器报的语法错误信息质量,直接决定你排查问题的速度。这个项目在parser.cpp里做了错误定位和恢复处理,常见做法是解析出错时打印 Token 位置、期望类型和实际类型三件套:
if (tok.kind != expectedKind) { std::cerr << "syntax error at line " << tok.line << ", expected " << tokenName(expectedKind) << ", got " << tokenName(tok.kind) << "\n"; skipToNextSemicolon(); // 跳到下一个分号,防止错误雪崩 }tokenName做两件事:把 TokenKind 枚举值转成可读字符串,方便在错误信息里直接看到"期望TK_SEMICOLON,实际是TK_RPAREN"。skipToNextSemicolon是错误恢复机制,跳过当前语义无法处理的 Token 序列直到分号,让 Parser 至少能继续解析后面的语句。加上这个恢复机制后,一个源文件包含五个语法错误也能一次全报出来,而不是卡在第一个错误上。调试时如果错误信息说"期望右括号,得到标识符",优先怀疑前一步的表达式解析提前返回了,用 GDB 在parseExpr()函数入口打断点是这个阶段最有效的定位手段。
4. 语义分析与中间代码生成:符号表、类型检查与 LLVM IR 输出
语法分析只保证"句子结构对",语义分析负责"意思对"。SysY2022 的语义规则落到工程上,核心是两个数据结构:符号表管名字与类型、语义分析器管检查规则。这两块通了,IR 生成就是顺着 AST 走一遍的事。
4.1 符号表与作用域:symbol_table.cpp 的实现思路
符号表是编译器里最容易被低估的模块。SysY 支持嵌套块{},不同作用域里可以声明同名变量,所以符号表必须分层。常见做法是用一个 vector 模拟栈式作用域,vector 里的每个元素是一层作用域的映射表。
class SymbolTable { std::vector<std::unordered_map<std::string, Symbol>> scopes; public: bool insert(const std::string &name, const Symbol &sym) { auto &top = scopes.back(); if (top.count(name)) return false; // 同一层的重复声明 top[name] = sym; return true; } Symbol* lookup(const std::string &name) { for (auto it = scopes.rbegin(); it != scopes.rend(); ++it) { auto found = it->find(name); if (found != it->end()) return &found->second; } return nullptr; } void enter() { scopes.emplace_back(); } void exit() { scopes.pop_back(); } };lookup从最内层作用域往外逐层找,符合"内层优先屏蔽外层"的语言规则。insert只看最顶层,保证同一作用域内不重复声明。enter/exit对应进入和退出一个块作用域,语法分析器在处理{时调enter,处理}时调exit。Symbol里至少存三样东西:类型种类(int/float/数组/指针)、是否为常量、数组维度信息。SysY 的数组维度在语义分析中是重点,符号表里不把维度存储下来,后面越界检查就是纸上谈兵。
4.2 语义检查规则:SysY2022 的类型约束与常量表达式
语义分析阶段检查的规则,围绕 SysY2022 的语言定义,典型规则可以把项目附带的SysY2022语言定义-V1.pdf当基准。这一类教学编译器最常检查的点按优先级排列大概是这样:
| 检查规则 | 违反时的典型报错 | 阶段 |
|---|---|---|
| 变量使用前必须声明 | "identifier 'x' not declared" | 符号表查找 |
函数返回类型与return表达式类型一致 | "type mismatch in return" | 类型检查 |
if/while条件必须是整数类型 | "condition must be integer" | 类型检查 |
| 数组下标必须是整数表达式 | "array index must be integer" | 下标检查 |
| 重复声明同一作用域变量 | "redeclaration of 'a'" | 符号表插入 |
类型不匹配是场景中最常见的报错源头。SysY2022 对int和float的处理比 C 语言严格得多,常见做法是不允许隐式互转——int x = 1.5;这类赋值要经过显式检查后判定非法,而float y = 1;如果规范也不允许隐式提升,编译器就会直接报类型错误。
semantic_analyzer.cpp里每到一个节点就调对应检查函数,比如visitBinaryExpr检查左右操作数类型是否一致、visitReturnStmt检查返回值类型与函数返回类型是否匹配。这类代码业务逻辑很直白,但错误信息的质量直接影响用户体验:报"line 12: type mismatch in return"比报"error: type mismatch"对使用者友好得多,建议错误信息都带上行号。
4.3 从 AST 到 LLVM IR:ast_visitor 和 ir.h 的分工
生成中间代码是这个项目最亮的部分——它能输出 LLVM IR。ast_visitor.h是标准的访问者模式基类,ir.h定义中间代码的数据结构,semantic_analyzer.cpp检查完后再由访问器类遍历 AST 生成 IR。访问者模式的核心是双分派:让 AST 节点决定调用哪个visit重载。
class ASTVisitor { public: virtual void visit(NumberExprAST *node) = 0; virtual void visit(BinaryExprAST *node) = 0; virtual void visit(FunctionDeclAST *node) = 0; virtual void visit(WhileStmtAST *node) = 0; virtual void visit(IfStmtAST *node) = 0; // 每个 AST 节点对应一个 visit 方法 };每个派生 AST 节点在自己的accept()方法里调visitor.visit(this),OOP 的基类指针类型决定调用哪个重载,这种模式让 IR 生成逻辑集中在访问器里,不用每个 AST 类都写生成代码。ir.h里的数据结构常见两条路线:直接封装 LLVM C++ API,或者自建一个轻量 IR 结构(自定义的Instruction、BasicBlock类)再转换成 LLVM IR 文本。前者上手快但有学习成本,后者结构透明适合教学。项目里同时有ir.h和ast_visitor.h,我倾向于认为它走的是封装 LLVM API 的路线,这在 SysY2022 编译器里是主流做法——毕竟可以直接复用 LLVM 的 IRBuilder,后面还能顺便做优化实验。写visitWhileStmt时绕不开 LLVM 的phi节点,处理方式在下一章展开细说。
5. 避坑与常见问题:我在跑通 SysY 编译器时踩过的五个坑
这个项目整体是"能一次构建成功"的工程,但真正跑起来时小问题不少。下面五个坑是我在复现过程中真实遇到过的,按从构建期到运行期的顺序排列,每个都按现象、原因、解决的链路写清楚。
5.1 CMake 链接报错 undefined reference:目标顺序与静默漏源文件
现象:cmake --build .编译没问题,链接时报一堆undefined reference to,指向语义分析器或符号表的函数。
原因:CMakeLists.txt里的add_executable少列了源文件,或者把目标链接顺序搞反了——静态链接时符号依赖是单向的,main.cpp引用了semantic_analyzer.cpp里的函数,链接命令里semantic_analyzer就得排在main后面(如果用的是add_subdirectory加target_link_libraries的方式,顺序由 CMake 自动处理)。
解决:先看CMakeLists.txt里add_executable一行是否覆盖了src/下全部.cpp;再检查target_link_libraries的依赖顺序。最笨但最有效的排查办法是把整个src/*.cpp都加进add_executable,构建过了再逐步精简。这个坑的本质是 CMake 不会自动收集源码文件,每新增一个.cpp都要手动登记。
5.2 "编译器未包含 main 类型":入口函数缺失的真实含义
现象:编译一个测试程序时,报错信息类似于"编译器未包含 main 类型"或 "undefined reference to `main'"。
原因:这个报错在 SysY 场景里几乎都是 SysY 源程序里没有定义返回int的main函数,或者main函数定义写成了void main。编译器只是忠实地把 AST 翻译成 LLVM IR,而链接器在找可执行文件入口时找不到符号main。这里的"编译器未包含 main 类型"不是指编译器本体缺了什么东西,而是"链接阶段找不到入口函数"。
解决:检查 SysY 源程序是否满足 SysY2022 的可执行程序要求:必须有int main()(参数列表为空),末尾必须return 0或等价语句。如果项目里有个测试用例专门验证"缺少 main 时报错",那就反过来利用这个用例,确认编译器的诊断信息是友好可读的。
5.3 Flex 生成的 scanner 与手写 lexer 的冲突:重定义和 include 路径
现象:scanner.l用 Flex 生成lexer.cpp后,编译报yylex重定义错误,或者 Parser 里调yylex()链接不上。
原因:最常见的情况是--header-file参数没配,Flex 生成了默认的lex.yy.c,你手写Lexer.h里又声明了同样名字的函数,两处定义打架;另一种情况是 CMake 里把scanner.l也当源文件参与了编译,Flex 生成一遍代码又被 gcc 单独编译一次。
解决:Flex 生成时显式指定--header-file=lexer.h,并且在 CMake 里要么将scanner.l交给 Flex 处理,要么只保留生成的lexer.cpp,不要两个一起编。我的习惯是:把生成的lexer.cpp和lexer.h都放在build/目录,不入源码树,避免手改生成的代码被下一次 Flex 覆盖。
5.4 中间代码生成阶段堆空间不足:深层递归 AST 与循环引用
现象:编译一个测试用例时进程崩溃,报"编译器的堆空间不足"(或bad_alloc/std::length_error),而同一个用例换个编译器(比如 clang)就没事。
原因:这类崩溃通常在语义分析或 IR 生成阶段,根源大概率是 Parser 或 AST 遍历的递归深度失控。SysY2022 文法本身不深,但表达式嵌套写成((((...))))时递归下降会一层层往下压,访问者模式遍历 AST 时每个节点都是一次递归调用。另一个隐蔽原因:符号表里存放了指向 AST 节点的裸指针,AST 析构后符号表还持有悬空指针,访问时触发未定义行为,表现之一就是堆被写坏。
解决:先在parser.cpp里加递归深度计数,比如if (depth > 200) throw std::runtime_error("expression too deeply nested");再用 GDB 在semantic_analyzer.cpp的visit函数里打断点,看调用栈是否异常深。如果确认是悬空指针,把符号表里的 AST 指针改成std::shared_ptr或统一所有权模型——要么 AST 整体持有符号表,要么符号表只保存值不保存指针。
5.5 PowerShell 测试脚本执行策略与编码问题
现象:test_runner.ps1在 Windows 上运行时报"禁止运行脚本"或中文乱码。
原因:Windows PowerShell 默认执行策略是Restricted,不允许直接跑.ps1脚本;乱码则是因为脚本文件保存成了 UTF-8 无 BOM,PowerShell 默认按 GBK 解读内容。
解决:以管理员身份在 PowerShell 里执行Set-ExecutionPolicy RemoteSigned -Scope CurrentUser放开本用户的脚本执行权限;然后用带 BOM 的 UTF-8 重新保存test_runner.ps1(VSCode 右下角编码选择"UTF-8 with BOM")。这两步都做完,脚本里的测试断言和中文日志就能正常输出了。如果仍然乱码,还可以在脚本开头加一句chcp 65001。
6. 验证与进阶:用 test_runner 回归和 LLVM 工具链确认编译器行为
编译器的价值全在"行为是否正确"上,而这个项目自带了两条验证路径:一是tests/下的三组 SysY 测试程序,二是生成的 LLVM IR 可以直接用 LLVM 工具链执行验证。
6.1 用 test_runner.ps1 一次跑完三组回归测试
powershell -ExecutionPolicy Bypass -File .\test_runner.ps1脚本会依次编译tests/work1_test、work2_test、work3_test下的用例,比较输出结果。建议先跑 work1 确认词法语法阶段正常,再跑 work2 验证语义分析,最后 work3 看数组和控制流的处理。每个用例失败时脚本会打印输入文件和期望输出,直接对照定位是哪个阶段出的问题。
6.2 用 lli 执行生成的 LLVM IR,验证行为符合预期
项目输出 LLVM IR 的好处是你可以不看汇编,直接拿 LLVM 的解释器执行 IR 查看运行结果:
compiler test.sysy -emit-llvm -o test.ll lli test.ll echo $?-emit-llvm让编译器只输出 LLVM IR 文件,lli直接解释执行这个 IR。退出码 0 且输出与预期一致,说明从语义分析到 IR 生成整条链路是对的。如果对生成的 IR 质量好奇,可以再跑一遍opt -O2 test.ll -S -o test_opt.ll对比优化前后的指令数,这正是拿这个项目做优化实验的入口。
6.3 值得动手扩展的两个方向
第一个方向是给 IR 生成增加控制流优化:SysY 的&&、||短路求值在 LLVM IR 里会用phi节点实现,你可以对比"每次短路都生成两个块"和"复用已有块"的差异。第二个方向是把语义分析的错误收集机制改成多错误报告——现在遇到第一个错误大概率就终止了,改成收集所有错误并一起报告会让这个编译器更像真实产品。
我从这个项目里学到的最大教训是:编译器这类项目,每一步都离不开"拿真实输入验证"。从那以后我每次改完语义分析或 IR 生成代码,都强制走一遍test_runner.ps1加lli的双重验证,再也不靠肉眼读代码判断"应该没问题"。希望帮到你。
本文还有配套的精品资源,点击获取