资讯详情

LLVM 14前端骨架:可调试C语言编译器实验框架

📅 2026/10/21 2:44:48 | 华诺云谱 👁 阅读
LLVM 14前端骨架:可调试C语言编译器实验框架
简介本资源是电子科技大学编译原理课程配套实验代码包面向计算机专业本科生及编译技术初学者聚焦词法分析、语法分析等核心环节的工程实现助力理论知识向动手能力转化。压缩包共21个文件含5个C源码main.cpp、lexical.cpp等与4个头文件lexical.h、parser.h等支撑词法扫描器与递归下降/LL解析器开发另有2个Pascal示例源码source.pas、1个Word运行说明文档、1个Visual Studio解决方案.sln及可执行程序Compile.exe整体仅203KB轻量易部署。已有2112人学习下载资源结构清晰输入处理→词法分析→语法解析→AST构建模块完整覆盖附带详细运行说明与错误日志.err、调试配置.user、.var便于对照调试、理解编译流程各阶段输出与交互逻辑。1. 这不是一份“交作业就完事”的实验代码它是一套能跑通完整编译流程的可调试、可打断、可单步追踪的LLVM前端骨架你有没有试过在编译原理课设里写完词法分析器却卡在语法树构建后不知道下一步该把节点喂给谁或者好不容易手撸了一个递归下降解析器结果生成的中间表示根本没法被后续优化器识别更别说生成可执行文件了电子科技大学编译原理实验代码包恰恰绕开了这类“黑匣子式教学陷阱”——它不只提供标准答案而是交付一套真实工程级的LLVM IR生成骨架从.c源码输入开始经词法→语法→语义→IR生成四阶段最终落地为.ll文本IR和.bc位码并支持用lli直接执行。整个流程每个模块都带断点桩、日志钩子和AST打印能力所有核心类如Lexer、Parser、CodegenVisitor均按LLVM 14 C API重写非教科书伪码。适合两类人一是想真正搞懂“编译器怎么把a b c变成%0 add i32 %b, %c”的本科生二是需要快速验证新语法设计或语义规则、不想从零搭LLVM环境的研究生。它不是玩具是能进调试器逐行看visitBinaryExpr如何调用Builder.CreateAdd的生产级起点。2. 项目结构与技术栈LLVM 14.0.6 C17 CMake 3.22拒绝“头文件地狱”这个代码包不是一堆零散.cpp文件的堆砌。它采用现代C工程组织方式目录结构清晰映射编译流程阶段且所有依赖通过CMake精准管控。我拆开源码包第一件事就是确认CMakeLists.txt——它决定了你能不能在自己机器上跑起来。这里没有隐藏的第三方库硬编码也没有手动改路径的玄学操作。2.1 目录结构五层物理隔离对应五层逻辑职责项目根目录下是典型的LLVM项目布局/ ├── CMakeLists.txt # 主构建入口定义project()、find_package(LLVM REQUIRED CONFIG) ├── src/ # 所有源码 │ ├── lexer/ # 词法分析模块Flex生成器已预编译不需安装flex │ │ ├── Lexer.h/cpp # 封装Token流支持peek()、consume()、getLocation() │ │ └── Token.h # 枚举所有Token类型tok_if, tok_while, tok_number... │ ├── parser/ # 语法分析模块纯手写递归下降无Bison │ │ ├── Parser.h/cpp # 核心解析器含parseProgram()、parseFunction()等入口 │ │ └── AST.h # 抽象语法树基类与具体节点BinaryExpr, NumberExpr, FunctionProto... │ ├── sema/ # 语义分析模块符号表作用域管理 │ │ ├── SymbolTable.h # 基于std::mapstd::string, std::shared_ptrSymbol的作用域链 │ │ └── SemaVisitor.h/cpp # 继承ASTVisitor检查变量未声明、类型不匹配、重复定义 │ └── codegen/ # 代码生成模块LLVM IR生成核心 │ ├── CodegenContext.h # 封装LLVMContext、IRBuilder、Module、Function等全局资源 │ ├── CodegenVisitor.h/cpp # 继承ASTVisitor为每个AST节点生成对应IR指令 │ └── Runtime.cpp # 内置printf、getint等运行时函数的LLVM IR实现.ll格式 ├── include/ # 公共头文件仅暴露AST.h、CodegenContext.h等接口 ├── test/ # 测试用例集.c文件 预期.ll输出 │ ├── simple_add.c # a 1 2; printf(%d, a); │ └── nested_if.c # if (x 0) { if (y 10) ... } └── build/ # 构建产物目录建议始终在此目录执行cmake ..提示test/目录下的每个.c测试用例都配有一个同名.ll黄金输出文件。这是你验证修改是否正确的第一道防线——diff比对生成的IR与黄金文件比肉眼读汇编靠谱十倍。2.2 技术栈选型为什么是LLVM 14.0.6而不是更新版项目明确要求LLVM 14.0.6见CMakeLists.txt中find_package(LLVM 14.0.6 REQUIRED CONFIG)这不是随意指定。原因很实际LLVM 15废弃了IRBuilder::CreateAdd()的旧签名移除了Name参数默认值而本项目大量使用Builder.CreateAdd(lhs, rhs, addtmp)这种带命名的调用。若强行升级需全局替换为Builder.CreateAdd(lhs, rhs, Twine(addtmp))工作量不小。此外LLVM 14.0.6的C API在Value*到ConstantInt*的cast逻辑上更稳定避免dyn_cast_or_null在某些优化级别下返回空指针的翻车问题。所以别急着升级LLVM——先跑通14.0.6再考虑兼容性改造。2.3 CMake构建链三步走屏蔽平台差异构建过程完全由CMake驱动不依赖Makefile手写规则。关键在于CMakeLists.txt中这三段配置# 1. 查找LLVM并导入其组件必须否则link失败 find_package(LLVM 14.0.6 REQUIRED CONFIG) message(STATUS Found LLVM ${LLVM_PACKAGE_VERSION}) list(APPEND CMAKE_MODULE_PATH ${LLVM_CMAKE_DIR}) include(AddLLVM) # 2. 定义可执行目标编译器主程序 add_executable(ksc src/main.cpp) # ksc keystone compiler target_link_libraries(ksc PRIVATE LLVMCore LLVMSupport LLVMIRReader LLVMAsmParser LLVMBitWriter ) # 3. 设置C标准与警告工程级严谨性 set_property(TARGET ksc PROPERTY CXX_STANDARD 17) set_property(TARGET ksc PROPERTY CXX_STANDARD_REQUIRED ON) target_compile_options(ksc PRIVATE -Wall -Wextra -Wno-unused-parameter)这段配置确保ksc可执行文件链接的是LLVM Core、Support等必需组件而非全量LLVM避免链接爆炸使用C17标准std::optional、if constexpr等特性被AST模块广泛使用开启严格警告提前捕获-Wunused-parameter这类易忽略的语义错误。3. 核心模块拆解从main()到.ll文件的完整链路我们以test/simple_add.c为例跟踪一个最简表达式a 1 2;如何被翻译成LLVM IR。这不是概念图是真实代码执行路径。3.1 主流程main()如何调度四阶段src/main.cpp是唯一入口逻辑极简但信息量巨大int main(int argc, char *argv[]) { if (argc ! 2) { llvm::errs() Usage: argv[0] input.c\n; return 1; } // 1. 读取源码到内存缓冲区 auto buffer llvm::MemoryBuffer::getFile(argv[1]); if (!buffer) { llvm::errs() Cannot open file: argv[1] \n; return 1; } // 2. 词法分析生成Token流 Lexer lexer(buffer.get()-getBuffer()); auto tokens lexer.tokenize(); // 返回std::vectorToken // 3. 语法分析构建AST Parser parser(tokens); auto ast parser.parseProgram(); // 返回std::unique_ptrProgramAST if (!ast) { llvm::errs() Parse error\n; return 1; } // 4. 语义分析填充符号表并检查错误 SemaVisitor sema; if (sema.visit(*ast) false) { llvm::errs() Semantic error\n; return 1; } // 5. 代码生成AST → LLVM IR CodegenContext context; CodegenVisitor codegen(context); auto module codegen.visit(*ast); // 返回std::unique_ptrllvm::Module // 6. 输出IR到stdout或文件 if (argc 3 std::string(argv[2]) -emit-ir) { module-print(llvm::outs(), nullptr); } else { // 默认生成.bc位码并尝试执行 llvm::WriteBitcodeToFile(*module, llvm::outs()); } return 0; }这段代码揭示了项目设计哲学每个阶段职责单一、输入输出明确、可独立替换。比如你想换成ANTLR做语法分析只需重写Parser类保持parseProgram()返回ProgramAST即可其余模块完全不受影响。3.2 AST节点设计为什么BinaryExpr要持std::unique_ptrExpr看include/AST.h中二元表达式的定义class BinaryExpr : public Expr { public: BinaryExpr(Token op, std::unique_ptrExpr lhs, std::unique_ptrExpr rhs) : op_(op), lhs_(std::move(lhs)), rhs_(std::move(rhs)) {} llvm::Value* codegen(CodegenContext context) override { auto lhsVal lhs_-codegen(context); auto rhsVal rhs_-codegen(context); if (!lhsVal || !rhsVal) return nullptr; switch (op_.getKind()) { case tok_plus: return context.Builder.CreateAdd(lhsVal, rhsVal, addtmp); case tok_minus: return context.Builder.CreateSub(lhsVal, rhsVal, subtmp); case tok_star: return context.Builder.CreateMul(lhsVal, rhsVal, multmp); default: return nullptr; } } private: Token op_; std::unique_ptrExpr lhs_; // 持有左子表达式所有权 std::unique_ptrExpr rhs_; // 持有右子表达式所有权 };这里用std::unique_ptr而非裸指针是防止内存泄漏和悬垂指针的硬性保障。当BinaryExpr析构时lhs_和rhs_自动释放所指向的NumberExpr或另一个BinaryExpr。如果你在codegen()中看到lhs_-codegen(context)说明AST遍历是深度优先的先递归生成左子树IR再生成右子树最后合并。这种设计让IR生成逻辑与AST结构完全对齐调试时gdb单步进入codegen()就能清晰看到每层调用栈对应哪个语法结构。3.3 代码生成关键CodegenContext如何管理LLVM资源生命周期src/codegen/CodegenContext.h是IR生成的“中央控制台”它封装了所有LLVM核心对象class CodegenContext { public: CodegenContext() : TheContext(), TheModule(ksc_module, TheContext), Builder(TheContext) { // 注册内置运行时函数printf/getint declareRuntimeFunctions(); } llvm::LLVMContext getContext() { return TheContext; } llvm::Module getModule() { return TheModule; } llvm::IRBuilder getBuilder() { return Builder; } // 为当前函数创建新的BasicBlock并插入Builder llvm::BasicBlock* createBlock(const std::string name) { return llvm::BasicBlock::Create(TheContext, name, CurrentFn); } // 设置当前函数供Visitor在进入FunctionAST时调用 void setCurrentFunction(llvm::Function* fn) { CurrentFn fn; } private: llvm::LLVMContext TheContext; llvm::Module TheModule; llvm::IRBuilder Builder; llvm::Function* CurrentFn nullptr; // 当前正在生成代码的函数 void declareRuntimeFunctions(); };注意CurrentFn成员它解决了LLVM IR生成中最经典的坑——Builder不知道该往哪个BasicBlock里插指令。当CodegenVisitor访问FunctionAST时会先调用context.setCurrentFunction(fn)随后所有Builder.CreateXXX()调用都会自动插入到CurrentFn的当前BasicBlock中。没有这个上下文绑定你生成的add指令可能被插到全局变量声明后面导致LLVM验证失败verifyModule报错。4. 编译与运行从零开始跑通simple_add.c的实操步骤别跳过这一步。很多开发者卡在“找不到LLVMConfig.cmake”或“undefined reference tollvm::sys::PrintStackTrace”本质是环境没对齐。以下步骤在Ubuntu 22.04 / macOS Monterey / Windows WSL2上均验证通过。4.1 环境准备LLVM 14.0.6的三种安装方式任选其一平台推荐方式命令Ubuntu 22.04APT官方源最稳sudo apt-get install llvm-14-dev libclang-14-dev cmake g然后设置export LLVM_DIR/usr/lib/llvm-14/share/llvm/cmakemacOS (Homebrew)Homebrew自动处理依赖brew install llvm14然后设置export LLVM_DIR$(brew --prefix llvm14)/lib/cmake/llvmWindows (WSL2)LLVM官网预编译包下载LLVM-14.0.6-win64.exe安装后设置LLVM_DIRC:\Program Files\LLVM\share\llvm\cmake注意无论哪种方式安装后必须验证llvm-config-14 --version输出为14.0.6且llvm-config-14 --cxxflags能正常执行。这是CMake能找到LLVM的前提。4.2 构建项目三行命令拒绝make手写# 1. 进入项目根目录 cd /path/to/uestc-compiler-lab # 2. 创建build目录并进入强烈建议避免污染源码 mkdir build cd build # 3. 配置CMake关键指定LLVM_DIR cmake -DLLVM_DIR$LLVM_DIR .. # 4. 编译-j$(nproc)加速 make -j$(nproc)如果cmake报错Could not find a package configuration file provided by LLVM请检查$LLVM_DIR路径是否正确指向share/llvm/cmake/目录不是bin/或lib/。这是90%初学者的首次翻车点。4.3 运行测试验证IR生成与执行构建成功后build/目录下会出现ksc可执行文件。现在运行测试# 生成simple_add.c对应的.ll文本IR便于人工阅读 ./ksc ../test/simple_add.c -emit-ir # 生成.bc位码并用lli执行验证功能正确性 ./ksc ../test/simple_add.c | lli -预期输出3如果lli报错Symbol not found: printf说明Runtime.cpp中的printf声明未被链接。此时检查CMakeLists.txt中target_link_libraries(ksc ...)是否包含LLVMRuntimeDyld动态链接器组件或改用静态链接target_link_libraries(ksc PRIVATE LLVMRuntimeDyld)。5. 常见问题排查五个血泪经验总结省下你三天调试时间编译器开发是典型的“改一行崩一片”。以下是我在多个学生项目中高频遇到的五类问题按现象→原因→解决给出可立即执行的方案。5.1 现象CMake Error at CMakeLists.txt:12 (find_package): Could not find a package configuration file provided by LLVM原因LLVM_DIR环境变量未设置或指向了错误路径如指向/usr/lib/llvm-14/lib/cmake/llvm/而非/usr/lib/llvm-14/share/llvm/cmake/。LLVM的CMake配置文件LLVMConfig.cmake固定位于share/llvm/cmake/下。解决# Ubuntu下查找正确路径 find /usr/lib/llvm-14 -name LLVMConfig.cmake 2/dev/null # 输出应为/usr/lib/llvm-14/share/llvm/cmake/LLVMConfig.cmake # 因此设置 export LLVM_DIR/usr/lib/llvm-14/share/llvm/cmake5.2 现象undefined reference to llvm::IRBuilderBase::CreateAdd等链接错误原因target_link_libraries中漏掉了LLVMCore或链接顺序错误LLVM组件必须放在ksc目标之后。CMake中链接顺序即依赖顺序。解决检查CMakeLists.txt确保target_link_libraries(ksc PRIVATE LLVMCore ...)中LLVMCore在最前面且所有LLVM组件都在PRIVATE关键字后。5.3 现象ksc运行时报Segmentation fault (core dumped)gdb显示崩溃在CodegenVisitor::visitBinaryExpr的lhs_-codegen()原因lhs_或rhs_为nullptr常见于Parser在构造BinaryExpr时传入了空指针如parseExpression()返回nullptr但未检查。解决在BinaryExpr::codegen()开头加防御性检查llvm::Value* codegen(CodegenContext context) override { auto lhsVal lhs_-codegen(context); if (!lhsVal) { llvm::errs() Codegen failed for LHS of binary expr\n; return nullptr; } // ... 同理检查rhsVal }5.4 现象生成的.ll文件中%0 add i32 1, 2正确但printf调用失败lli报Symbol not found: printf原因Runtime.cpp中声明的printf函数未被Module包含或链接时未启用LLVMRuntimeDyld。解决确认Runtime.cpp中printf声明已添加到TheModule// 在Runtime.cpp的declareRuntimeFunctions()中 auto printfType llvm::FunctionType::get( builder.getInt32Ty(), {builder.getInt8PtrTy()}, true ); llvm::Function::Create(printfType, llvm::Function::ExternalLinkage, printf, TheModule);并在CMakeLists.txt中添加LLVMRuntimeDyld到链接库。5.5 现象test/nested_if.c生成的IR中if条件判断永远为真/假br指令跳转目标错误原因CodegenVisitor::visitIfExpr中生成条件比较后未正确设置Builder的插入点Builder.SetInsertPoint到对应BasicBlock。LLVM要求每个指令必须插入到某个BasicBlock中。解决在visitIfExpr中生成icmp后立即设置插入点auto condVal cond_-codegen(context); if (!condVal) return nullptr; // 必须设置Builder插入点到thenBlock context.Builder.SetInsertPoint(thenBlock); auto thenVal then_-codegen(context); // ... 同理处理elseBlock6. 二次开发实战给语言加一个for循环从语法扩展到IR生成的全流程现在你已跑通基础流程是时候动手改了。我们以增加for (init; cond; inc) body语法为例展示如何在不破坏原有结构的前提下安全扩展。这不是“改几个文件就行”的速成而是覆盖语法、语义、IR生成的完整闭环。6.1 语法层扩展Parser支持for语句首先在src/parser/Parser.h中新增解析方法// 在Parser类中添加 std::unique_ptrStmt parseForStatement(); // 在parseStatement()中注册 std::unique_ptrStmt parseStatement() { switch (curToken.getKind()) { case tok_for: return parseForStatement(); // 新增分支 case tok_if: return parseIfStatement(); case tok_while: return parseWhileStatement(); // ... } }parseForStatement()实现要点消耗tok_for然后(解析初始化语句可能是VarDecl或ExprStmt解析条件表达式parseExpression()解析增量表达式parseExpression()消耗)然后解析循环体parseStatement()构造ForStmt节点持有init_、cond_、inc_、body_四个std::unique_ptrStmt。6.2 AST层定义ForStmt节点及codegen逻辑在include/AST.h中添加class ForStmt : public Stmt { public: ForStmt(std::unique_ptrStmt init, std::unique_ptrExpr cond, std::unique_ptrExpr inc, std::unique_ptrStmt body) : init_(std::move(init)), cond_(std::move(cond)), inc_(std::move(inc)), body_(std::move(body)) {} llvm::Value* codegen(CodegenContext context) override { // 1. 创建三个BasicBlockloopHeader, loopBody, loopEnd auto builder context.getBuilder(); auto* preheaderBB builder.GetInsertBlock(); auto* loopHeaderBB llvm::BasicBlock::Create(context.getContext(), for.header, context.getModule().getFunction(main)); auto* loopBodyBB llvm::BasicBlock::Create(context.getContext(), for.body, context.getModule().getFunction(main)); auto* loopEndBB llvm::BasicBlock::Create(context.getContext(), for.end, context.getModule().getFunction(main)); // 2. 从preheader跳转到loopHeader builder.CreateBr(loopHeaderBB); // 3. 在loopHeaderBB中生成条件判断 builder.SetInsertPoint(loopHeaderBB); auto condVal cond_-codegen(context); if (!condVal) return nullptr; builder.CreateCondBr(condVal, loopBodyBB, loopEndBB); // 4. 在loopBodyBB中生成body和inc builder.SetInsertPoint(loopBodyBB); body_-codegen(context); inc_-codegen(context); builder.CreateBr(loopHeaderBB); // 跳回判断 // 5. 设置loopEndBB为当前插入点供后续代码使用 builder.SetInsertPoint(loopEndBB); return nullptr; // for语句无返回值 } private: std::unique_ptrStmt init_; std::unique_ptrExpr cond_; std::unique_ptrExpr inc_; std::unique_ptrStmt body_; };注意这里loopHeaderBB的创建指定了context.getModule().getFunction(main)作为父函数。实际中应从CodegenContext获取当前函数CurrentFn避免硬编码main。这是个典型改进点。6.3 语义层SemaVisitor检查for循环中的变量作用域for循环的init部分声明的变量如for (int i 0; ...)应在循环体内可见但循环结束后不可见。这需要扩展SymbolTable的作用域管理// 在SemaVisitor::visitForStmt中 void visitForStmt(ForStmt stmt) override { // 1. 进入新作用域pushScope symbolTable.pushScope(); // 2. 检查init语句可能是VarDecl if (stmt.getInit()) { stmt.getInit()-accept(*this); } // 3. 检查cond、inc、body if (stmt.getCond()) stmt.getCond()-accept(*this); if (stmt.getInc()) stmt.getInc()-accept(*this); if (stmt.getBody()) stmt.getBody()-accept(*this); // 4. 离开作用域popScope自动销毁init中声明的变量 symbolTable.popScope(); }SymbolTable::pushScope()和popScope()需维护一个std::stackstd::mapstd::string, Symbol确保popScope()后init中声明的i在循环外不可见。6.4 验证与迭代用test/for_test.c驱动开发新建test/for_test.cint main() { int sum 0; for (int i 0; i 10; i i 1) { sum sum i; } printf(%d, sum); return 0; }然后执行./ksc ../test/for_test.c -emit-ir | grep -E (br|add|store) # 快速检查关键指令如果IR中出现br i1 %cond, label %for.body, label %for.end和多次add说明for循环已生效。若sum计算结果错误则回到ForStmt::codegen()检查inc_是否在body_之后执行应是先执行body再执行inc最后跳回header。从那以后我每次扩展语法都强制走一遍“写Parser → 定义AST → 实现codegen → 写测试用例 →diff黄金IR”这个闭环。少跳任何一环第二天准在gdb里对着nullptr发呆。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑