多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

东南大学编译课设:LLVM前端实践闭环与错误恢复实现

东南大学编译课设:LLVM前端实践闭环与错误恢复实现 简介本资源是东南大学网络安全学院《编译方法》课程的配套实践包面向计算机专业本科生及编译原理初学者聚焦词法分析、语法解析、语义处理与代码生成等核心环节的动手实现。资源共260个文件以55份Markdown实验文档为学习主线辅以67个GraphML格式的语法树/控制流图可视化文件、73个GIF动态演示含AST构建、错误处理流程等以及55个C/C/Java源码文件如lexical_analyzer.cpp、syntax_parser.cpp、多个.c测试用例和8个.dot/.svg图示脚本完整覆盖编译器前端开发全流程压缩包大小19.61MB结构清晰、模块对应明确。已有137人学习下载提供从源码编译、运行调试到典型错误分析的全链路支持特别包含多组带注释的测试用例如2.4.1.x.c系列和全局变量管理、主控逻辑等关键模块实现助力读者将抽象理论转化为可运行、可验证的工程能力。1. 这不是一份“交作业式”课程设计东南大学网安学院《编译方法》课设压缩包实为一套可跑通、可调试、可延展的LLVM前端实践闭环你打开这个名为东南大学-网安学院-编译方法课程设计-内含源码和运行说明.zip的压缩包第一眼看到lexer.py、parser.y、ast.py、codegen.cpp和README.md可能会下意识觉得“哦又一个用 Python 写词法分析、Bison 写语法分析、最后生成 LLVM IR 的教学模板”。但真正把它在本地解压、装依赖、跑make test、再用llc看汇编、用clang链接成可执行文件——你会意识到这不是填空题答案而是一套带完整错误恢复、支持作用域检查、能生成带调试信息的 LLVM IR、且所有中间表示Token → AST → IR都可逐层打印验证的轻量级编译器前端工程。它不追求支持 C 全语法但把int a 1 2 * 3; if (a 5) { return a; } else { a 0; }这类真实教学场景中的语义边界、类型推导、控制流图构建全做实了。适合刚学完 Dragon Book 第2–6章、正卡在“理论懂了但写不出可运行代码”阶段的本科生也适合想快速搭建一个可控实验平台来验证自己对符号表、CFG、SSA 形式理解的研究生。它不替代工业级编译器但它让你第一次亲手把“文法→分析树→三地址码→IR→汇编”这条链路从黑匣子变成白盒。2. 从解压到第一条可执行指令环境准备与最小可运行路径这门课设的落地门槛其实不高但必须严格遵循其隐含的工具链版本约束。它不是用最新版 LLVM 18 或 Python 3.12 写的而是基于LLVM 14.0.0非系统包管理器默认版本、Python 3.9、GNU Bison 3.8.2、Flex 2.6.4构建的。我见过太多人直接pip install llvmlite或apt install llvm后发现codegen.cpp编译失败、parser.y报 shift/reduce 冲突——问题不在代码而在工具链错位。下面这条路径是我在线下带学生复现时验证过 17 次的“零失败”流程。2.1 解压与目录结构认知先看清骨架再动手解压后你会看到如下核心目录结构删减无关文档├── src/ │ ├── lexer/ # 词法分析器flex 生成 │ │ ├── lexer.l # flex 规则文件 │ │ └── Makefile # 生成 lexer.cpp │ ├── parser/ # 语法分析器bison 生成 │ │ ├── parser.y # bison 语法规则 语义动作 │ │ └── Makefile # 生成 parser.cpp parser.hpp │ ├── ast/ # 抽象语法树定义与遍历 │ │ ├── ast.h │ │ └── ast.cpp │ └── codegen/ # LLVM IR 生成器C │ ├── codegen.h │ └── codegen.cpp ├── tests/ # 测试用例.mini 后缀自定义语言 │ ├── hello.mini │ ├── fib.mini │ └── scope.mini ├── build/ # 编译产物输出目录初始为空 ├── README.md # 关键含明确的 clang 版本要求与链接参数 └── Makefile # 主构建入口串联 lex → parse → ast → codegen → link提示README.md里那句 “Please use clang-14 to compile codegen.cpp, not g” 不是客套话。LLVM 14 的 C API 对 ABI 兼容性极敏感用g-11编译会触发undefined reference to llvm::IRBuilderBase::CreateAlloca类似链接错误——这是血泪经验。2.2 工具链精准安装绕过系统包管理器的“安全区”Ubuntu/Debian 用户请不要执行sudo apt install llvm。系统仓库的llvm包通常不含llvm-dev头文件且版本杂乱。正确做法是# 1. 下载 LLVM 14.0.0 官方预编译二进制Linux x86_64 wget https://github.com/llvm/llvm-project/releases/download/llvmorg-14.0.0/clangllvm-14.0.0-x86_64-linux-gnu-ubuntu-20.04.tar.xz tar -xf clangllvm-14.0.0-x86_64-linux-gnu-ubuntu-20.04.tar.xz sudo mv clangllvm-14.0.0-x86_64-linux-gnu-ubuntu-20.04 /opt/llvm-14 # 2. 设置环境变量写入 ~/.bashrc export LLVM_HOME/opt/llvm-14 export PATH$LLVM_HOME/bin:$PATH export LD_LIBRARY_PATH$LLVM_HOME/lib:$LD_LIBRARY_PATH # 3. 验证必须同时看到 clang 和 llvm-config clang --version # 输出应含 clang version 14.0.0 llvm-config --version # 输出 14.0.0Python 与 Bison/Flex 则用系统包管理器安装即可确保版本匹配# Ubuntu 22.04 可直接满足若旧系统请升级 sudo apt update sudo apt install python3.9 python3.9-venv flex bison build-essential # 创建隔离环境关键避免 pip 包污染 python3.9 -m venv venv source venv/bin/activate pip install llvmlite0.39.1 # 注意llvmlite 0.39.1 是唯一兼容 LLVM 14.0.0 的版本注意llvmlite0.39.1是硬性要求。pip install llvmlite默认装最新版如 0.42.x会因 LLVM C API 变更导致codegen.cpp中llvm::Type::getInt32Ty()等调用编译失败。这是第一个必须卡死的版本点。2.3 三步跑通第一个测试hello.mini的端到端验证进入项目根目录执行以下三步命令每步失败都意味着前序环节有误# 步骤1生成词法/语法分析器需先 cd src/lexer makecd ../parser make make gen-parser # 此命令在根 Makefile 中自动进入 lexer/parser 目录执行 make # 步骤2编译整个编译器前端C 部分 make compiler # 调用 clang-14 编译 codegen.cpp生成 ./build/compiler # 步骤3编译并运行第一个测试 make test-hello # 等价于./build/compiler tests/hello.mini ./a.out成功时终端将输出[INFO] Parsing tests/hello.mini... [INFO] Generating IR... [INFO] Writing IR to ./build/hello.ll [INFO] Compiling IR to object... [INFO] Linking executable... Hello, World!此时./build/下会多出hello.llLLVM IR 文本、hello.o目标文件、a.out可执行文件。你可以用cat ./build/hello.ll查看生成的 IR 是否含.str private unnamed_addr constant [14 x i8] cHello, World!\00和call i32 puts—— 这证明词法、语法、AST、IR 生成四层全部贯通。3. 深度拆解lexer.py 与 parser.y 如何协同实现“可恢复”的错误诊断课程设计中lexer.pyPython 实现和parser.yBison 实现的分工并非简单的“lexer 分词、parser 组句”而是构建了一套面向教学调试的错误传播通道。lexer.py不仅产出 Token还记录每个 Token 的行号、列号、原始文本parser.y则利用这些位置信息在语法错误时精准定位到.mini源码的某一行某一列并给出类似error: expected ; at line 5, column 12的提示。这种能力在真实编译器开发中价值极高但初学者常忽略其底层协作机制。3.1 lexer.py不只是字符串切片而是 Token 流的元数据容器src/lexer/lexer.py的核心不是正则匹配而是Token类的设计# src/lexer/lexer.py class Token: def __init__(self, type_: str, value: str, line: int, column: int): self.type type_ # INT, IDENTIFIER, PLUS self.value value # 123, a, self.line line # 行号从1开始 self.column column # 列号从1开始 def tokenize(code: str) - List[Token]: tokens [] lines code.split(\n) for line_num, line in enumerate(lines, start1): pos 0 while pos len(line): # ... 正则匹配逻辑略 # 关键每次匹配成功都 new 一个带 line_num 和 pos 的 Token tokens.append(Token(tok_type, matched_str, line_num, pos 1)) pos len(matched_str) return tokens逻辑说明column字段不是简单计数而是pos 1因为人类习惯列号从1起。这保证了当parser.y报错时line:column能精确对应编辑器光标位置。参数说明type_: 固定枚举值与parser.y中%token INT IDENTIFIER PLUS严格一致value: 原始字面量供后续语义分析如数字字面量转int值line/column: 仅用于错误报告不影响语法分析逻辑但极大提升调试效率。3.2 parser.y用%error-verbose和yyerror实现上下文感知报错parser.y的错误处理不是靠yyerror简单打印而是深度绑定 lexer 的位置信息// src/parser/parser.y %{ #include stdio.h #include ast.h extern int yylex(); // 声明 lexer 函数 extern int yylineno; // Bison 内置当前行号需 lexer 同步更新 extern char *yytext; // 当前匹配文本 %} %error-verbose // 启用详细错误消息如 syntax error, unexpected IDENTIFIER %% program: /* empty */ | program stmt { /* AST 构建 */ } ; stmt: declaration ; | expression ; | error ; { /* 错误恢复跳过直到 ; */ } ; %% // 自定义错误处理函数 void yyerror(const char *s) { // 关键使用 yylinenoBison 维护和 lexer 提供的列信息需额外传入 fprintf(stderr, error: %s at line %d, column %d\n, s, yylineno, get_current_column()); }逻辑说明%error-verbose让 Bison 自动生成更具体的错误描述如unexpected if而非笼统syntax errorstmt: error ;规则则是错误恢复的关键——当解析if语句出错时parser 不直接退出而是跳过后续字符直到遇到;继续尝试解析下一条语句。这使得一个文件中多个语法错误能被一次性报告而非“修一个错、报下一个错”。参数说明yylineno: Bison 内部变量lexer 必须在每次换行时递增它lexer.l中有yylineno;get_current_column(): 需在lexer.l中维护一个全局column变量并在yyerror中暴露其值error ;: 这是 Bison 的标准错误恢复语法error是预定义 token代表任意非法 token。3.3 验证错误诊断手动制造一个错看它如何定位修改tests/hello.mini在print(Hello, World!);前插入一行非法内容x 1 ; // 缺少右操作数 print(Hello, World!);运行make test-hello输出应为error: syntax error, unexpected ;, expecting IDENTIFIER or NUMBER at line 1, column 8column 8精准指向;的位置x 1 共7个字符后空格占1列;在第8列。这证明 lexer 的列计数与 parser 的错误报告已完全对齐——这是该课设区别于网上大多数“能跑就行”模板的核心价值它把编译器的“用户友好性”作为教学目标之一而非仅关注后端生成。4. AST 与 CodeGen从语法树到 LLVM IR 的语义落地细节ast/和codegen/目录是整套课设的“心脏”。很多初学者以为 AST 就是Node类的嵌套CodeGen 就是visit_*方法打印字符串。但东南大学这个实现把作用域管理、类型检查、内存布局、调试信息注入全揉进了这两个模块且每一处都有清晰注释和可验证的测试用例。我们以scope.mini为例拆解它是如何让int a 1; { int a 2; print(a); } print(a);输出2\n1的。4.1 ast.h/cpp作用域链与符号表的轻量实现ast.h中Scope类不是哈希表而是链表式作用域链// src/ast/ast.h struct Scope { std::mapstd::string, Symbol symbols; // 当前作用域符号 Scope* parent; // 指向外层作用域 Scope(Scope* p nullptr) : parent(p) {} Symbol* lookup(const std::string name) { // 从当前作用域向上查找模拟 C 语言作用域规则 for (Scope* s this; s ! nullptr; s s-parent) { auto it s-symbols.find(name); if (it ! s-symbols.end()) return it-second; } return nullptr; } };Symbol结构体包含类型、内存地址偏移、是否为函数等元数据struct Symbol { Type type; // INT, VOID, FUNCTION int offset; // 相对于栈帧基址的偏移CodeGen 时计算 Function* func; // 若为函数指针则指向 Function AST 节点 };逻辑说明lookup()的链表遍历实现了“内层遮蔽外层”的语义。当解析{ int a 2; }时会创建新Scope并设parent为外层Scopelookup(a)自动返回内层Symbol。这比用std::stackstd::map更易调试且内存开销可控。参数说明offset: 在CodeGen阶段由AllocaInst分配栈空间时确定int a通常为-432位int b为-8func: 用于函数调用节点CallExpr的类型检查确保f()的f确实是函数类型。4.2 codegen.cpp如何让 LLVM IR 带上调试信息DWARFcodegen.cpp最惊艳之处在于它用 LLVM C API 注入了完整的 DWARF 调试信息使lldb ./a.out可以单步调试.mini源码。关键代码在CodeGenVisitor::visit(VarDecl* node)中// src/codegen/codegen.cpp void CodeGenVisitor::visit(VarDecl* node) { // 1. 为变量分配栈空间 AllocaInst* alloca Builder.CreateAlloca( getLLVMType(node-type), nullptr, node-name.c_str() ); // 2. 创建调试信息DILocalVariable DIFile* unit DIBuilder-createFile( test.mini, /home/user/project/tests/ // 文件名与路径 ); DIScope* scope DIBuilder-createFunction( unit, node-name, , unit, 1, // 行号1 DIBuilder-createSubroutineType(DIBuilder-getOrCreateTypeArray({})), 0, 0, 0, DINode::FlagPrototyped, false ); DILocalVariable* var DIBuilder-createAutoVariable( scope, node-name, unit, 1, // 行号1 DIBuilder-createBasicType(int, 32, dwarf::DW_ATE_signed), true ); DIBuilder-insertDeclare( alloca, var, DIBuilder-createExpression(), DebugLoc::get(1, 0, scope), // 行号1列0 Builder.GetInsertBlock() ); }逻辑说明DIBuilder-createAutoVariable()创建变量调试描述DIBuilder-insertDeclare()将其绑定到alloca指令。最终生成的hello.ll中会出现类似!2 !DILocalVariable(name: a, scope: !3, file: !1, line: 1, type: !4) !3 distinct !DISubprogram(name: main, scope: !1, file: !1, line: 1, ...) !4 !DIBasicType(name: int, size: 32, encoding: DW_ATE_signed)参数说明line: 1: 此处硬编码为1实际应从 AST 节点获取node-line课设中为简化未实现但框架已预留接口DIBuilder-createExpression(): 空表达式表示变量值直接存于alloca地址DebugLoc::get(...): 必须与Builder的插入位置同步否则调试器无法定位。提示若跳过DIBuilder初始化生成的可执行文件仍可运行但lldb会显示(no debug info)。课设的Makefile中LDFLAGS -g和CXXFLAGS -g就是为了让链接器保留这些调试节。4.3 手动验证 IR用llvm-dis和opt看优化效果生成hello.ll后可进一步用 LLVM 工具链验证其质量# 1. 反汇编二进制 .o 文件确认 IR 无损 llvm-dis ./build/hello.bc -o ./build/hello.dis.ll # 2. 应用 -O2 优化观察常量折叠 opt -O2 ./build/hello.bc -o ./build/hello.opt.bc llvm-dis ./build/hello.opt.bc -o ./build/hello.opt.ll # 3. 对比原始 vs 优化后原始含 call printf优化后可能内联为 puts diff ./build/hello.ll ./build/hello.opt.ll你会发现1 2 * 3被优化为7if (true)分支被消除——这证明生成的 IR 符合 LLVM 的优化契约不是“玩具 IR”。5. 避坑指南那些让 80% 学生卡住的 5 个具体问题与解法这个课设的文档README.md写得简洁但实际落地时存在若干“文档没写、搜索引擎不顶用、错误信息极晦涩”的硬坑。以下是我在指导 32 名本科生完成课设过程中高频出现的 5 个问题按现象→原因→解决三步给出可立即执行的方案。5.1 现象make gen-parser报错bison: invalid option -- W原因Makefile中bison -Werror参数要求 Bison 3.7但 Ubuntu 20.04 默认bison --version输出3.5.1不支持-W选项。解决升级 Bison。下载源码编译官方推荐wget https://ftp.gnu.org/gnu/bison/bison-3.8.2.tar.xz tar -xf bison-3.8.2.tar.xz cd bison-3.8.2 ./configure --prefix/usr/local make sudo make install sudo ldconfig bison --version # 确认输出 3.8.25.2 现象make compiler报错undefined reference to llvm::IRBuilderBase::CreateAlloca原因clang-14未正确链接 LLVM 库或llvmlite版本不匹配见 2.2 节。解决强制指定 LLVM 库路径。修改Makefile中LDFLAGS行# 原行可能失效 # LDFLAGS llvm-config --ldflags --libs core native # 改为绝对路径适配你的 LLVM_HOME LDFLAGS -L/opt/llvm-14/lib -lLLVMCore -lLLVMSupport -lLLVMTarget -lLLVMCodeGen然后make clean make compiler。5.3 现象运行./build/compiler tests/scope.mini时 Segmentation Fault原因AST节点析构时Scope* parent指针悬空未初始化为nullptr导致lookup()遍历时访问非法内存。解决在ast.h中Scope构造函数添加显式初始化Scope(Scope* p nullptr) : parent(p) {} // 原代码已有但需确认所有 new Scope() 调用都传参检查ast.cpp中Program::accept()确保global_scope new Scope(nullptr);—— 若漏掉nullptrparent为随机值。5.4 现象make test-hello成功但./a.out报错./a.out: error while loading shared libraries: libLLVM-14.so: cannot open shared object file原因LD_LIBRARY_PATH未在make子 shell 中继承./a.out运行时找不到 LLVM 动态库。解决在Makefile的test-hello规则中显式设置环境变量test-hello: LD_LIBRARY_PATH$(LLVM_HOME)/lib $(BUILD_DIR)/compiler tests/hello.mini \ LD_LIBRARY_PATH$(LLVM_HOME)/lib ./a.out5.5 现象lldb ./a.out启动后bt显示(no debug info)无法list源码原因codegen.cpp中DIBuilder初始化缺失或DIBuilder-finalize()未被调用。解决检查CodeGenVisitor构造函数确保有// src/codegen/codegen.cpp CodeGenVisitor::CodeGenVisitor() { // ... 其他初始化 DIBuilder new DIBuilder(*TheModule); DICompileUnit DIBuilder-createCompileUnit( dwarf::DW_LANG_C, DIBuilder-createFile(unknown, .), 东南大学网安学院, false, , 0, , 0, , , 0, , 0 ); }并在CodeGenVisitor::finalize()课设中已定义末尾添加DIBuilder-finalize(); // 关键否则调试信息不写入模块6. 进阶技巧如何用此课设框架快速验证你的编译原理猜想这套课设的价值远不止于“完成作业”。它的模块化设计lexer/parser/ast/codegen 完全解耦、清晰的错误注入点、以及 LLVM IR 的标准性让它成为绝佳的编译原理实验沙盒。我常用它在 20 分钟内验证一个新想法比如“如果我把if语句的条件求值改成短路求值会对 CFG 产生什么影响”——下面是我的标准操作流。6.1 修改 AST 节点给IfExpr添加short_circuit标志首先在ast.h中扩展IfExpr结构struct IfExpr : public Expr { Expr* cond; Expr* then_expr; Expr* else_expr; bool short_circuit; // 新增字段默认 false IfExpr(Expr* c, Expr* t, Expr* e, bool sc false) : cond(c), then_expr(t), else_expr(e), short_circuit(sc) {} };然后在parser.y的if_stmt规则中允许用户通过语法糖启用短路if_stmt: IF ( expression ) { stmt_list } | IF SHORT_CIRCUIT ( expression ) { stmt_list } { $$ new IfExpr($3, $6, nullptr, true); // SHORT_CIRCUIT 是新 token } ;6.2 在 CodeGen 中实现短路用CreateCondBr替代CreateBr修改codegen.cpp的visit(IfExpr* node)void CodeGenVisitor::visit(IfExpr* node) { if (node-short_circuit) { // 短路版本生成条件分支不计算 else_expr Value* cond_val node-cond-accept(this); BasicBlock* then_bb BasicBlock::Create(Context, then, TheFunction); BasicBlock* merge_bb BasicBlock::Create(Context, merge, TheFunction); Builder.CreateCondBr(cond_val, then_bb, merge_bb); Builder.SetInsertPoint(then_bb); Value* then_val node-then_expr-accept(this); Builder.CreateBr(merge_bb); Builder.SetInsertPoint(merge_bb); // 注意此处不再 visit else_expr // 短路语义else_expr 根本不执行 } else { // 原有非短路版本计算 cond, then, else 三个表达式 } }6.3 一键验证用llvm-cov看分支覆盖率写一个测试short.miniint a 1; int b 0; if short_circuit (a 0) { b 2; } // b 应为 2 if (a 0) { b 3; } // b 仍为 2因为 a0 为 false但非短路版仍会计算 b3 print(b);编译时加覆盖率标记make compiler COVERAGE_FLAGS-fprofile-instr-generate -fcoverage-mapping ./build/compiler tests/short.mini LLVM_PROFILE_FILEshort.profraw ./a.out llvm-profdata merge -sparse short.profraw -o short.profdata llvm-cov show ./a.out -instr-profileshort.profdata输出会高亮显示then_bb被执行、else分支未覆盖——这直接证明短路逻辑生效。整个过程从改代码到出证据不超过 25 分钟。我的习惯是每当在 Dragon Book 里读到一个新概念比如“活跃变量分析”就立刻在这个框架里加一个Pass类用llvm::FunctionPass注册遍历TheModule的每个函数打印每个 BasicBlock 的 live-in/live-out 集合。它不生产工业代码但它让抽象概念瞬间具象。希望帮到你。本文还有配套的精品资源点击获取
返回列表