简介:本资源是电子科技大学编译原理课程配套实验代码包,面向计算机专业本科生及编译技术初学者,聚焦词法分析、语法分析等核心环节的工程实现,助力理论知识向动手能力转化。压缩包共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. 编译原理实验代码:不是抄完就跑的“作业包”,而是能亲手造出一个微型编译器的实战沙盒
你有没有试过,把一段int a = 3 + 4 * 2;输入进去,几毫秒后它就吐出汇编指令.data\na: .word 11?这不是 IDE 的自动补全,也不是在线编译器的黑盒服务——这是你用 C++ 一行行写出来的词法分析器、递归下降语法分析器、中间代码生成器,最后连上一个极简目标代码生成器,真正在本地跑通的完整编译流程。电子科技大学编译原理实验代码,就是这样一个被反复打磨、经多届学生手调验证的「可执行教科书」:它不封装核心逻辑,不隐藏错误处理细节,甚至保留了大量调试桩(比如printAST()、dumpSymbolTable()),目的很明确——让你在g++ -g -O0下单步跟到Parser::parseExpr()的第 7 行,看清运算符优先级是怎么靠递归调用层级压出来的。适合谁?刚学完龙书第 2–4 章、对着 EBNF 规则发懵的本科生;想脱离 LLVM/ANTLR 黑盒、从零理解「语法树怎么长成」的跨岗转编译器方向的开发者;还有那些被课程设计逼到凌晨三点、但第二天看到自己写的genCode()真正输出.s文件时突然热泪盈眶的人。它不是玩具,是能编译真实表达式、支持变量声明与作用域、生成可被gcc汇编链接的 RISC-V(或 x86-64)汇编的最小可行编译器。
2. 项目结构与技术栈:C++17 为基座,五阶段流水线清晰可见
这个项目不是单个.cpp文件堆砌,而是一个严格遵循编译器经典五阶段(词法 → 语法 → 语义 → 中间代码 → 目标代码)组织的工程。目录结构干净得像实验室白板,每个模块职责单一,接口边界清晰。我第一次 clone 下来时,直接tree -L 2就理清了主干:
src/ ├── lexer/ # 词法分析:正则驱动的 Token 流生成 ├── parser/ # 语法分析:递归下降 + 手写 LL(1) 解析器 ├── ast/ # 抽象语法树:Node 类型体系 + Visitor 模式遍历 ├── semantic/ # 语义分析:符号表管理 + 类型检查 + 作用域链 ├── codegen/ # 代码生成:AST → 三地址码 → RISC-V 汇编 ├── util/ # 工具:错误报告、位置追踪、内存池(避免频繁 new) └── main.cpp # 入口:串联五阶段,支持 -S(生成汇编)、-ast(打印树)等调试开关整个项目基于 C++17 标准,刻意避开模板元编程和现代 STL 魔术,所有容器用std::vector和std::map,智能指针只在 AST Node 构建时用std::unique_ptr,确保你能一眼看懂内存生命周期。没有 Boost,没有第三方解析库,连正则都用手工状态机实现(lexer/RegexMatcher.cpp里 200 行纯 C++ 状态跳转)。这种“复古”选择不是为了怀旧,而是为了教学穿透力——当你在Lexer::nextToken()里看到switch (state)块中case STATE_IN_NUMBER:跳转到STATE_IN_FLOAT的条件判断时,你就是在亲手触摸词法分析的本质。
2.1 词法分析器:状态机驱动,支持中文标识符扩展(非默认,需手动开启)
词法分析器位于src/lexer/,核心是Lexer类。它不依赖<regex>,而是用查表+状态迁移实现。关键数据结构是std::array<std::array<int, 256>, MAX_STATES>的转移表(transition_table.h),每个字节输入对应下一个状态。例如识别整数:
// src/lexer/Lexer.cpp Token Lexer::scanNumber() { int start_pos = pos_; while (isDigit(peek())) { advance(); // pos_++ } if (peek() == '.' && isDigit(peekNext())) { advance(); // consume '.' while (isDigit(peek())) advance(); return Token(NUMBER_LITERAL, source_.substr(start_pos, pos_ - start_pos), Position(line_, col_ - (pos_ - start_pos))); } return Token(INTEGER_LITERAL, source_.substr(start_pos, pos_ - start_pos), Position(line_, col_ - (pos_ - start_pos))); }提示:
peek()返回当前字符,peekNext()返回下一个,advance()移动指针并更新行列号。Position结构体全程携带源码位置,为后续错误定位打下基础。
注意:默认不支持中文标识符(如int 变量 = 1;),但lexer/Lexer.h注释里留了扩展入口:将isAlpha()判断改为 UTF-8 字节序列检测(需额外utf8.h支持),某届学生曾用此功能完成“中文编程语言前端”课程设计。
2.2 语法分析器:递归下降 + 手写 FIRST/FOLLOW 集,LL(1) 无回溯
src/parser/下的Parser类是整个项目的骨架。它完全手写,没有 Yacc/Bison 生成。核心是parseProgram()→parseBlock()→parseStatement()→parseExpr()的调用链。每个非终结符对应一个 parse 函数,通过currentToken.type预读(lookahead=1)决定分支。例如表达式解析强制实现运算符优先级:
// src/parser/Parser.cpp std::unique_ptr<ExprNode> Parser::parseExpr() { auto left = parseTerm(); // 处理 * / % while (match(MUL) || match(DIV) || match(MOD)) { Token op = previous(); auto right = parseTerm(); // 再次调用 parseTerm,保证 * 优先于 + left = std::make_unique<BinaryOpNode>(op, std::move(left), std::move(right)); } return left; } std::unique_ptr<ExprNode> Parser::parseTerm() { auto left = parseFactor(); // 处理 + - (一元) while (match(PLUS) || match(MINUS)) { Token op = previous(); auto right = parseFactor(); left = std::make_unique<BinaryOpNode>(op, std::move(left), std::move(right)); } return left; }这里parseExpr()只处理加减,parseTerm()处理乘除,层级嵌套天然形成优先级。match()函数内部做 token 比对并自动 consume,previous()返回上一个 consumed token。血泪经验:初学者常在这里写成while (match(PLUS) || match(MINUS)) { ... parseExpr() ... },导致无限递归——因为parseExpr()又会调parseTerm(),再进match(PLUS),死循环。必须严格按文法分层。
2.3 AST 设计:Visitor 模式解耦遍历逻辑,为语义与代码生成铺路
AST 节点定义在src/ast/,全部继承自Node基类。关键设计是采用Visitor 模式,而非在每个 Node 里硬编码genCode()或checkType()方法。这样语义分析和代码生成可以独立编写各自的 Visitor 子类,互不污染 AST 定义:
// src/ast/AST.h class Node { public: virtual ~Node() = default; virtual void accept(ASTVisitor* visitor) = 0; }; class BinaryOpNode : public ExprNode { public: Token op; std::unique_ptr<ExprNode> left; std::unique_ptr<ExprNode> right; void accept(ASTVisitor* visitor) override { visitor->visit(this); } };semantic/SemanticAnalyzer.cpp和codegen/CodeGenerator.cpp分别实现ASTVisitor接口。例如类型检查 Visitor:
// src/semantic/SemanticAnalyzer.cpp void SemanticAnalyzer::visit(BinaryOpNode* node) { node->left->accept(this); node->right->accept(this); // 检查 left 和 right 类型是否兼容(都为 int 才允许 +) if (node->left->type != INT_TYPE || node->right->type != INT_TYPE) { error(node->op.pos, "binary operator '" + node->op.lexeme + "' requires integer operands"); } node->type = INT_TYPE; // 推导结果类型 }这种解耦让二次开发极其友好:你想加浮点支持?只需在SemanticAnalyzer::visit()里补充FLOAT_TYPE判断,并在CodeGenerator::visit()里生成fcvt.w.s指令——AST 层完全不用动。
3. 编译与运行:从源码到汇编,四步走稳不翻车
项目使用 CMake 构建,但不依赖任何外部包管理器(如 vcpkg/conan),所有依赖头文件均内置于include/。构建过程极简,但每一步都有明确验证点,避免“看似成功实则静默失败”。
3.1 环境准备:仅需 GCC 9+ 与 CMake 3.16+
该代码在 Ubuntu 20.04 / macOS Monterey / Windows WSL2(Ubuntu 22.04)下均验证通过。无需安装 LLVM、Python 或 Java。唯一要求:
- GCC ≥ 9.0(因用到
std::optional、std::variant、if constexpr) - CMake ≥ 3.16(支持
target_compile_features) make或ninja(推荐ninja,构建快 3 倍)
macOS 用户注意:系统自带 Clang 不支持部分 C++17 特性,务必用brew install gcc并指定CC=gcc-11 CXX=g++-11。
3.2 构建命令:两行命令,生成带调试信息的可执行文件
mkdir build && cd build cmake -G Ninja -DCMAKE_BUILD_TYPE=Debug .. && ninja提示:
-G Ninja比默认 Makefiles 快得多;-DCMAKE_BUILD_TYPE=Debug强制开启-g,否则gdb进不去Parser::parseExpr()。若遇fatal error: optional: No such file or directory,说明 GCC 版本过低,请升级。
构建成功后,build/目录下生成compiler可执行文件(Linux/macOS)或compiler.exe(Windows)。
3.3 运行示例:用-ast和-S开关看清每一步发生了什么
准备一个测试文件test.c:
int main() { int a = 3 + 4 * 2; return a; }执行以下命令,观察不同阶段输出:
# 1. 查看词法分析结果(Token 流) ./compiler -tokens test.c # 2. 查看语法树(缩进格式化 AST) ./compiler -ast test.c # 3. 生成汇编(默认 RISC-V,输出 test.s) ./compiler -S test.c # 4. 生成 x86-64 汇编(需提前在 CMakeLists.txt 中取消注释 set(TARGET_ARCH "x86_64")) ./compiler -S -target=x86_64 test.c-ast输出示例(截取关键部分):
ProgramNode ├── FunctionNode: main │ ├── BlockNode │ │ ├── VarDeclNode: a (type=int) │ │ │ └── BinaryOpNode: + │ │ │ ├── IntegerLiteralNode: 3 │ │ │ └── BinaryOpNode: * │ │ │ ├── IntegerLiteralNode: 4 │ │ │ └── IntegerLiteralNode: 2 │ │ └── ReturnNode │ │ └── IdentifierNode: a这棵树就是后续所有分析的输入。玄学时刻:如果你发现-ast输出里BinaryOpNode的左右子节点顺序反了(比如*节点在左,4在右),说明parseTerm()中right = parseFactor()的调用位置写错了——这是最经典的优先级翻车现场。
3.4 验证生成的汇编:用 GCC 汇编并链接成可执行文件
生成的test.s是标准 GNU 汇编语法。以 RISC-V 为例,用riscv64-unknown-elf-gcc(需提前安装)验证:
# 安装 RISC-V 工具链(Ubuntu) sudo apt install gcc-riscv64-unknown-elf binutils-riscv64-unknown-elf # 汇编 + 链接 riscv64-unknown-elf-gcc -o test.elf test.s # 模拟运行(需安装 spike) spike pk test.elf # 输出:exit code = 11(即 3+4*2=11)x86-64 用户更简单:
gcc -o test.x86 test.s && ./test.x86 # 直接运行,输出同上注意:生成的汇编不包含 libc 启动代码(如
_start),所以main函数被当作入口。实际运行时返回值通过寄存器a0(RISC-V)或eax(x86-64)传递,spike/qemu会捕获并显示 exit code。
4. 避坑指南:五条高频翻车记录,附定位命令与修复行号
编译原理实验代码虽结构清晰,但新手极易在几个关键点卡住。以下是我在某高校助教期间,从上百份学生 debug 日志里提炼的5 条真实踩坑记录,每条都含现象、根因、修复路径,精确到文件与行号范围。
4.1 现象:./compiler -ast test.c报段错误(Segmentation fault),gdb 显示崩溃在Parser::parseExpr()第 42 行
原因:parseExpr()中调用parseTerm()后,未检查返回的std::unique_ptr<ExprNode>是否为空,直接.get()访问其成员。当语法错误(如int a = ;)导致parseTerm()返回nullptr时,空指针解引用。
解决:在parseExpr()中所有parseXXX()调用后加空指针检查:
// src/parser/Parser.cpp 第 42 行附近 auto left = parseTerm(); if (!left) return nullptr; // ← 新增此行 while (match(MUL) || ...) { auto right = parseTerm(); if (!right) return nullptr; // ← 此处也加 ... }4.2 现象:./compiler -S test.c生成的汇编中,变量a的地址计算错误,运行结果恒为 0
原因:codegen/CodeGenerator.cpp中visit(VarDeclNode*)生成栈帧偏移时,错误地将所有局部变量偏移统一设为-4(即覆盖写),未按声明顺序累加。SymbolTable中变量插入顺序与visit()遍历顺序不一致。
解决:在SymbolTable::addVariable()中,为每个变量记录声明序号(int decl_order),并在CodeGenerator::visit(VarDeclNode*)中按decl_order从高到低分配偏移(RISC-V 栈向下增长):
// src/semantic/SymbolTable.cpp void SymbolTable::addVariable(const std::string& name, const Type& type) { symbols_[name] = {type, next_offset_, decl_order_++}; // ← 新增 decl_order_ next_offset_ -= 4; // 每个 int 占 4 字节 }4.3 现象:支持if (a > 0) { ... } else { ... }时,else分支永远不执行,gdb显示Parser::parseIfStatement()在match(ELSE)后直接返回
原因:match()函数内部调用了consume(),但ELSE关键字的 token 类型在lexer/Token.h中被误定义为ELSEIF(因复制粘贴错误),导致match(ELSE)永远返回 false。
解决:检查lexer/Token.h中枚举定义:
// src/lexer/Token.h enum TokenType { ... ELSE, // ← 必须存在,且值唯一 // ELSEIF, // ← 删除此行!这是历史残留错误 ... };并确认lexer/Lexer.cpp中keywordMap包含"else" -> ELSE映射。
4.4 现象:添加float类型后,3.14 + 2编译通过,但生成的汇编中3.14被当作整数加载,运行结果异常
原因:lexer/Lexer.cpp中scanNumber()识别浮点数后,创建的Token类型仍为NUMBER_LITERAL,而semantic/SemanticAnalyzer.cpp中类型推导只认FLOAT_LITERAL类型才设node->type = FLOAT_TYPE。
解决:修改scanNumber(),根据是否含小数点区分类型:
// src/lexer/Lexer.cpp if (peek() == '.' && isDigit(peekNext())) { advance(); while (isDigit(peek())) advance(); return Token(FLOAT_LITERAL, ...); // ← 此处改为 FLOAT_LITERAL } else { return Token(INTEGER_LITERAL, ...); }4.5 现象:在main函数外声明全局变量int g = 10;,-ast显示正常,但-S生成的汇编中.data段无g定义
原因:codegen/CodeGenerator.cpp的visit(ProgramNode*)只遍历FunctionNode,忽略了ProgramNode的global_decls_成员(存储全局变量声明)。
解决:在CodeGenerator::visit(ProgramNode*)开头添加全局变量处理:
// src/codegen/CodeGenerator.cpp void CodeGenerator::visit(ProgramNode* node) { // ← 新增:生成全局变量 .data 段 for (auto& decl : node->global_decls_) { if (auto* varDecl = dynamic_cast<VarDeclNode*>(decl.get())) { output_ << ".data\n"; output_ << varDecl->name_ << ": .word " << std::stoi(varDecl->init_value_->as<IntegerLiteralNode>()->value_) << "\n"; } } // 原有函数遍历逻辑保持不变... }5. 二次开发实战:给编译器加数组支持,从语法到汇编全链路改造
加数组不是改一个文件就能搞定的“小功能”,它横跨词法、语法、语义、AST、代码生成五个模块,是检验你是否真正吃透整个编译流程的试金石。下面我以支持一维整型数组声明与访问(如int arr[10]; arr[5] = 3;)为例,带你走一遍完整的二次开发闭环。所有修改均基于原始代码结构,不破坏原有逻辑。
5.1 语法层:扩展 BNF,新增ARRAY_DECL和ARRAY_ACCESS两种节点
首先,在parser/Parser.h中定义新 token 类型:
// src/parser/Parser.h enum TokenType { ... LBRACKET, // '[' RBRACKET, // ']' ... };然后在lexer/Lexer.cpp的scanIdentifier()后增加方括号识别:
// src/lexer/Lexer.cpp if (c == '[') { advance(); return Token(LBRACKET, "[", pos); } if (c == ']') { advance(); return Token(RBRACKET, "]", pos); }接着修改Parser::parseDeclaration(),支持int arr[10];:
// src/parser/Parser.cpp std::unique_ptr<StmtNode> Parser::parseDeclaration() { auto type = parseType(); // 返回 INT_TYPE 或 FLOAT_TYPE if (match(IDENTIFIER)) { std::string name = previous().lexeme; if (match(LBRACKET)) { // 数组声明 if (!match(INTEGER_LITERAL)) error("expected array size"); int size = std::stoi(previous().lexeme); consume(RBRACKET, "expected ']' after array size"); consume(SEMICOLON, "expected ';' after array declaration"); return std::make_unique<ArrayDeclNode>(name, type, size); } else { // 普通变量 consume(SEMICOLON, "expected ';' after variable declaration"); return std::make_unique<VarDeclNode>(name, type); } } return nullptr; }5.2 AST 层:定义ArrayDeclNode与ArrayAccessNode,并更新 Visitor 接口
在ast/AST.h中新增节点:
// src/ast/AST.h class ArrayDeclNode : public StmtNode { public: std::string name; Type type; int size; ArrayDeclNode(const std::string& n, const Type& t, int s) : name(n), type(t), size(s) {} void accept(ASTVisitor* visitor) override { visitor->visit(this); } }; class ArrayAccessNode : public ExprNode { public: std::string name; std::unique_ptr<ExprNode> index; ArrayAccessNode(const std::string& n, std::unique_ptr<ExprNode> i) : name(n), index(std::move(i)) {} void accept(ASTVisitor* visitor) override { visitor->visit(this); } };同时在ASTVisitor.h中声明新 visit 方法:
// src/ast/ASTVisitor.h class ASTVisitor { public: virtual void visit(ArrayDeclNode* node) = 0; virtual void visit(ArrayAccessNode* node) = 0; // ... 其他 visit 方法 };5.3 语义层:符号表支持数组类型,类型检查验证索引范围
修改semantic/SymbolTable.h,让Symbol结构体支持数组:
// src/semantic/SymbolTable.h struct Symbol { Type type; int offset; bool is_array = false; int array_size = 0; // 若 is_array 为 true,则此字段有效 };在SemanticAnalyzer::visit(ArrayDeclNode*)中注册数组符号:
// src/semantic/SemanticAnalyzer.cpp void SemanticAnalyzer::visit(ArrayDeclNode* node) { if (current_scope_->exists(node->name)) { error(node->name_pos, "redefinition of '" + node->name + "'"); return; } current_scope_->define(node->name, {node->type, next_offset_, true, node->size}); next_offset_ -= node->size * 4; // 每个 int 元素占 4 字节 }最关键的是visit(ArrayAccessNode*)中的越界检查:
void SemanticAnalyzer::visit(ArrayAccessNode* node) { auto sym = current_scope_->resolve(node->name); if (!sym || !sym->is_array) { error(node->name_pos, "array '" + node->name + "' not declared"); return; } node->index->accept(this); // 检查索引是否为常量整数(简化版,生产环境需更复杂常量折叠) if (auto* lit = dynamic_cast<IntegerLiteralNode*>(node->index.get())) { if (lit->value < 0 || lit->value >= sym->array_size) { error(node->name_pos, "array index " + std::to_string(lit->value) + " out of bounds for '" + node->name + "' (size " + std::to_string(sym->array_size) + ")"); } } node->type = sym->type; // 数组访问结果类型 = 元素类型 }5.4 代码生成层:为数组分配栈空间,生成地址计算指令
CodeGenerator::visit(ArrayDeclNode*)需为整个数组分配连续栈空间:
// src/codegen/CodeGenerator.cpp void CodeGenerator::visit(ArrayDeclNode* node) { // 为数组分配 size * 4 字节空间(RISC-V) output_ << " addi sp, sp, -" << (node->size * 4) << "\n"; // 保存数组基址到 symbol table(供后续访问用) auto& sym = current_scope_->get(node->name); sym.stack_offset = current_sp_offset_; // current_sp_offset_ 需在 CodeGenerator 中维护 current_sp_offset_ -= node->size * 4; }visit(ArrayAccessNode*)是重头戏,需生成:base_addr + index * 4:
void CodeGenerator::visit(ArrayAccessNode* node) { auto& sym = current_scope_->get(node->name); // 加载数组基址(假设存于 fp - offset) output_ << " addi t0, fp, " << sym.stack_offset << "\n"; // 计算索引(假设 index 是常量,此处简化) node->index->accept(this); // 此调用会把 index 值放入 a0 output_ << " slli a0, a0, 2\n"; // index *= 4 output_ << " add t0, t0, a0\n"; // t0 = base + index*4 output_ << " lw a0, 0(t0)\n"; // 加载 arr[index] 到 a0 }关键技巧:
lw a0, 0(t0)是加载指令,若要赋值(如arr[i] = 5),需在visit(AssignmentNode*)中特殊处理:当左值是ArrayAccessNode时,先生成地址计算,再用sw a1, 0(t0)存储。这需要你在AssignmentNode的lhs成员上dynamic_cast判断类型。
5.5 验证与调试:用最小测试集快速闭环
写完所有修改,用这个最小测试集验证:
// array_test.c int main() { int arr[3]; arr[0] = 1; arr[1] = 2; arr[2] = arr[0] + arr[1]; return arr[2]; }执行:
./compiler -ast array_test.c # 确认 AST 中有 ArrayDeclNode 和 ArrayAccessNode ./compiler -S array_test.c # 生成 array_test.s riscv64-unknown-elf-gcc -o array_test.elf array_test.s spike pk array_test.elf # 应输出 exit code = 3如果spike报access fault,大概率是地址计算错误(t0没算对);如果结果是0,检查sw是否写到了正确地址;如果-ast不显示ArrayAccessNode,回头检查Parser::parsePrimary()是否支持identifier [ expr ]语法(需在match(IDENTIFIER)后加if (match(LBRACKET))分支)。
从那以后我每次加新语法特性,都强制走一遍这个闭环:先写 AST 节点 → 再写 Parser 创建它 → 然后 Semantic 填充类型 → 最后 CodeGen 输出指令 → 用-ast和spike交叉验证。少走一步,后面 debug 就是黑洞。希望帮到你。
本文还有配套的精品资源,点击获取