☰
编译原理课程设计:从词法分析到小型编译器的C++实现
2026/10/29 2:52:05 网站建设 项目流程

简介:这是一套面向计算机专业编译原理课程设计与实验环节的完整资料,适合正在完成词法分析、语法分析及小型编译器实现的学生参考。内容围绕编译流程的关键阶段展开,包含词法分析器、LL(1)方法对简单语句(如 i+i*i)的语法分析、LR(0)与SLR(1)分析、四元式生成以及汇编代码生成,并提供可参考的LL(1)文法和SLR(1)文法示例,便于对照理解不同分析方法的构造与实现差异。压缩包共14个文件,以cpp、c、h源码和txt说明为主,另附doc课程设计报告,整体约557KB,结构紧凑,适合按实验模块查阅与复用。已有2350人学习下载,说明其在课程设计场景中具有一定参考价值。读者可从中获得各实验环节的实现思路、分析表构造与代码组织方式,并借助报告梳理小型编译器的设计流程与结果验证。

1. 编译原理课程设计:从一份能跑通的词法分析到小型编译器

期末周拿到编译原理课程设计的题目,要求交一个能跑的小型编译器:词法分析、语法分析、中间代码生成一条链,外加一份实验报告。很多人翻开龙书看到 NFA 转 DFA 那节就停住了,其实真正要落进代码的东西没想象中多——一个手写扫描器、一套递归下降的语法分析、再加一个几十行的栈式虚拟机,两千行 C++ 就能把 int 变量、四则运算、if/while、函数调用跑起来。这份资源适合正在赶课程设计、想直接拿到可编译工程再改成自己文法的人,也适合带课设的助教拿去当参考实现。它不是拿正则表达式糊出来的玩具,Token 带行列号、语法错误能定位到具体位置、生成的三地址码真的能执行出结果。

2. 整体架构:四段式流水线怎么拆目录、怎么定接口

2.1 工程目录与各模块职责

课程设计最容易翻车的地方不是算法难,而是所有逻辑都塞进一个main.cpp,写到第三天自己都不敢改。我一般会按「前端三段 + 后端两段」切:词法、语法、语义各自一个类,代码生成和虚拟机独立出去,头文件和实现分离。这样实验报告里的模块图也直接照着目录画,不用二次整理。

mini-compiler/ ├── CMakeLists.txt ├── include/ │ ├── token.h # Token 枚举与结构体 │ ├── lexer.h # 词法分析器声明 │ ├── ast.h # AST 节点定义 │ ├── parser.h # 递归下降语法分析器 │ ├── symtab.h # 符号表与作用域栈 │ ├── codegen.h # 三地址码生成 │ └── vm.h # 栈式虚拟机 ├── src/ │ ├── lexer.cpp │ ├── parser.cpp │ ├── symtab.cpp │ ├── codegen.cpp │ ├── vm.cpp │ └── main.cpp # 命令行入口,串起整条流水线 ├── tests/ │ ├── lexer/ # 只测 Token 流的用例 │ ├── parser/ # 只测 AST 结构的用例 │ └── e2e/ # 从源码一路跑到输出 └── docs/ └── report.md # 实验报告底稿,随代码一起改
模块输入输出出问题时先看哪
Lexer源码字符串Token 流行列号、非法字符
ParserToken 流AST报错位置的 Token 前后各一个
SymTab声明语句名字到槽位的映射作用域进出是否配对
CodeGenAST + 符号表三地址码指令数组标签是否重复、临时变量是否被覆盖
VM指令数组标准输出pc 是否越界、栈是否下溢

2.2 Token、AST、符号表三份数据的流转

整条链路只有三种中间产物在传:Token、Expr/Stmt构成的 AST、以及符号表里的Symbol。Token 流我倾向于按需拉取而不是一次性全量生成,Parser 里维护cur_和peek_两个 Token,advance()往前推一格。这样语法错误一出现就能立刻停,不必先把整个文件的词法错误都攒着——课程设计的输入通常只有几十行,内存不是问题,报错友好度才是。

AST 用基类加unique_ptr子节点,不搞std::variant,原因是实验报告里画类图更直观,答辩老师一眼能看懂继承关系。符号表则跟着 Parser 走:解析到int x = 1;时先declare再define,解析到块语句时enterScope/exitScope成对出现。CodeGen 只读 AST 和符号表,不回头改它们,这条边界守住,后面加语句类型时不会牵一发动全身。

2.3 构建与运行:一条命令跑通全流程

工程用 CMake 管理,C++17,不引第三方库,保证在机房的老版本 GCC 上也能编过。构建和运行就三条命令:

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j ./build/minicc tests/e2e/fib.mc

main.cpp里做三件事:读文件、按开关决定输出阶段、把结果打到 stdout。开关设计成--dump-tokens、--dump-ast、--dump-ir,默认全跑一遍并执行虚拟机。实测下来这套开关对写实验报告特别有用,报告里要贴的词法单元表、语法树、中间代码都能一条命令导出来,不用手动截图。

int main(int argc, char** argv) { if (argc < 2) { std::cerr << "usage: minicc <file.mc> [--dump-tokens|--dump-ast|--dump-ir]\n"; return 1; } std::ifstream in(argv[1], std::ios::binary); std::string src((std::istreambuf_iterator<char>(in)), {}); // 去掉 UTF-8 BOM,Windows 记事本存的文件必带 if (src.size() >= 3 && (unsigned char)src[0] == 0xEF) src.erase(0, 3); Lexer lexer(src); Parser parser(lexer); auto program = parser.parseProgram(); // 语法错误在这里抛 CodeGen gen; auto code = gen.run(program.get()); VM vm(code); vm.run(); // 结果直接 print 到标准输出 return 0; }

这段入口的关键点是 BOM 处理和异常出口。parser.parseProgram()内部遇到非法 Token 会抛ParseError,带上行号和列号;main里不吞异常,让它打到 stderr,退出码非零。这样tests/e2e下的脚本就能用退出码判断用例是否通过,不需要解析输出文本。

3. 词法分析器:用 DFA 把字符流切成带位置信息的 Token

3.1 Token 类型与词法单元的数据结构

Token 的设计决定了后面两个模块写起来顺不顺。我的做法是枚举只分「大类」,关键字单独占枚举值,因为语法分析里if和标识符的处理路径完全不同,提前分开能少写一层判断。文本内容统一存std::string,不搞 union,几十个 Token 的字符串开销在课设规模下可以忽略。

enum class TokKind { KwInt, KwIf, KwElse, KwWhile, KwReturn, KwPrint, // 关键字 Ident, Number, // 标识符、数字字面量 Plus, Minus, Star, Slash, Assign, // + - * / = Eq, Ne, Lt, Le, Gt, Ge, // == != < <= > >= LParen, RParen, LBrace, RBrace, Comma, Semi, // 界符 End, Invalid }; struct Token { TokKind kind = TokKind::Invalid; std::string text; // 原始拼写,报错时原样打出来 int line = 1; // 1-based,和编辑器一致 int col = 1; };

text保留原始拼写有个好处:报错信息可以直接拼成unexpected token 'while' at 7:13,不用再写一套枚举转字符串的映射表。数字字面量也先存字符串,等 Parser 需要值的时候再std::stoi,词法阶段不掺语义。

3.2 手写扫描器的主循环

核心就是一个next(),每次调用返回下一个 Token。内部先跳过空白和//行注释,再按首字符分派:字母下划线走标识符、数字走数字、其余走运算符和界符。两字符运算符(==、<=、>=、!=)要优先于单字符匹配,否则<=会被切成<和=,这种错误在语法分析阶段报出来时离现场已经很远了。

Token Lexer::next() { skipSpaceAndComment(); Token tok; tok.line = line_; tok.col = col_; if (atEnd()) { tok.kind = TokKind::End; return tok; } char c = peek(); if (std::isalpha((unsigned char)c) || c == '_') { size_t start = pos_; while (!atEnd() && (std::isalnum((unsigned char)peek()) || peek() == '_')) advance(); tok.text = src_.substr(start, pos_ - start); auto it = kKeywords.find(tok.text); tok.kind = (it != kKeywords.end()) ? it->second : TokKind::Ident; return tok; } if (std::isdigit((unsigned char)c)) { size_t start = pos_; while (!atEnd() && std::isdigit((unsigned char)peek())) advance(); tok.text = src_.substr(start, pos_ - start); tok.kind = TokKind::Number; return tok; } // 两字符运算符优先匹配 advance(); switch (c) { case '+': tok.kind = TokKind::Plus; break; case '-': tok.kind = TokKind::Minus; break; case '*': tok.kind = TokKind::Star; break; case '/': tok.kind = TokKind::Slash; break; case '=': if (peek() == '=') { advance(); tok.kind = TokKind::Eq; } else tok.kind = TokKind::Assign; break; case '<': if (peek() == '=') { advance(); tok.kind = TokKind::Le; } else tok.kind = TokKind::Lt; break; case '>': if (peek() == '=') { advance(); tok.kind = TokKind::Ge; } else tok.kind = TokKind::Gt; break; case '!': if (peek() == '=') { advance(); tok.kind = TokKind::Ne; } else { tok.kind = TokKind::Invalid; error("stray '!'"); } break; case '(': tok.kind = TokKind::LParen; break; case ')': tok.kind = TokKind::RParen; break; case '{': tok.kind = TokKind::LBrace; break; case '}': tok.kind = TokKind::RBrace; break; case ',': tok.kind = TokKind::Comma; break; case ';': tok.kind = TokKind::Semi; break; default: tok.kind = TokKind::Invalid; error("unexpected character '" + std::string(1, c) + "'"); } tok.text = std::string(1, c); return tok; }

advance()是唯一修改pos_、line_、col_的地方,所有字符消费都必须走它。这一点在 5.1 节的避坑里还会再提,行列号错位十有八九是某处直接++pos_绕过了它。error()负责抛LexError,带上当前line_/col_,不在这里做恢复。

3.3 关键字、标识符和数字的识别

关键字表就是一个静态unordered_map,初始化一次,查表 O(1)。注意 C++ 里static const std::unordered_map在头文件中定义要加inline(C++17)否则多 TU 会重复定义,这是我在课程设计里被链接错误教育过的地方。

inline const std::unordered_map<std::string, TokKind> kKeywords = { {"int", TokKind::KwInt}, {"if", TokKind::KwIf}, {"else", TokKind::KwElse}, {"while", TokKind::KwWhile}, {"return", TokKind::KwReturn}, {"print", TokKind::KwPrint}, };

数字目前只支持十进制整数,够课设用。如果题目要求支持十六进制或浮点,在next()的数字分支里加前缀判断即可:0x走十六进制扫描,遇到.走浮点,同时TokKind要拆成IntLit和FloatLit。标识符长度不设上限,但也不做截断,直接substr存全量,因为课程设计里没人会写 200 字符的变量名。

3.4 行列号维护与词法错误上报

void Lexer::advance() { if (atEnd()) return; if (src_[pos_] == '\n') { ++line_; col_ = 1; } else { ++col_; } ++pos_; } void Lexer::skipSpaceAndComment() { while (!atEnd()) { char c = peek(); if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { advance(); continue; } // 行注释:吃掉直到换行,不产生 Token if (c == '/' && pos_ + 1 < src_.size() && src_[pos_ + 1] == '/') { while (!atEnd() && peek() != '\n') advance(); continue; } break; } }

\r被当作空白跳过,\n触发换行。这样 CRLF 文件不会出现「每行多算一列」的问题,因为\r也走了advance(),col_加一后马上被\n重置成 1。注释在这里直接丢弃,不生成 Token,实验报告里的词法单元表注释一栏可以写「跳过,不产生输出」,答辩时被问到也答得上来。

错误上报要给出尽可能具体的上下文,我一般让LexError带上line、col和原始字符,输出格式统一成lex error at 3:7: unexpected character '#'。Parser 捕获后可以选择继续(跳过这个 Token)或终止,课程设计的输入量小,直接终止反而更清晰。

4. 语法分析与中间代码:递归下降建 AST,再落到三地址码

4.1 文法改写:消除左递归、提取左公因子

题目给的文法基本都长这样:expr -> expr '+' term | term,直接照着写递归下降会无限递归直到爆栈。处理办法有两种,教科书上的公式是引入新非终结符expr -> term expr'、expr' -> '+' term expr' | ε,但落到代码里更自然的写法是把左递归改成循环:解析完左操作数后while看下一个 Token 是不是运算符,是就继续吃右操作数。这样 AST 是左倾的,语义正好对应左结合。

表达式按优先级分层,从低到高排:赋值、逻辑或、逻辑与、相等、关系、加减、乘除、一元、基本表达式。每一层一个函数,上层调下层,乘除自然比加减先归约,不需要在代码里写任何优先级表。赋值是右结合,所以parseAssign里对右操作数递归调用自己,而不是while循环。

program -> declList decl -> varDecl | funcDecl | stmt varDecl -> 'int' Ident ('=' expr)? ';' funcDecl -> 'int' Ident '(' paramList ')' block stmt -> block | ifStmt | whileStmt | returnStmt | exprStmt | varDecl ifStmt -> 'if' '(' expr ')' stmt ('else' stmt)? whileStmt -> 'while' '(' expr ')' stmt expr -> assign assign -> Ident '=' assign | logicOr logicOr -> logicAnd ('||' logicAnd)* logicAnd -> equality ('&&' equality)* equality -> relational (('=='|'!=') relational)* relational -> additive (('<'|'<='|'>'|'>=') additive)* additive -> mul (('+'|'-') mul)* mul -> unary (('*'|'/') unary)* unary -> ('-'|'!') unary | primary primary -> Number | Ident | Ident '(' argList ')' | '(' expr ')'

4.2 AST 节点设计与递归下降骨架

AST 只保留后续阶段真正要用的字段:表达式节点存运算符和左右子节点,语句节点存条件、分支体、循环体。用继承加虚析构,unique_ptr管理生命周期,dynamic_cast在 CodeGen 里分发。有人担心dynamic_cast慢,课设规模下几千个节点完全无感,换来的是新增节点类型时不用改访问者基类。

struct Expr { virtual ~Expr() = default; }; struct NumberExpr : Expr { int value; explicit NumberExpr(int v) : value(v) {} }; struct VarExpr : Expr { std::string name; explicit VarExpr(std::string n) : name(std::move(n)) {} }; struct BinaryExpr : Expr { TokKind op; std::unique_ptr<Expr> lhs, rhs; BinaryExpr(TokKind o, std::unique_ptr<Expr> l, std::unique_ptr<Expr> r) : op(o), lhs(std::move(l)), rhs(std::move(r)) {} }; struct Stmt { virtual ~Stmt() = default; }; struct IfStmt : Stmt { std::unique_ptr<Expr> cond; std::unique_ptr<Stmt> then, els; // els 为空表示没有 else }; struct WhileStmt : Stmt { std::unique_ptr<Expr> cond; std::unique_ptr<Stmt> body; }; struct BlockStmt : Stmt { std::vector<std::unique_ptr<Stmt>> body; };

Parser 里维护cur_和peek_,所有匹配都走check/match/expect三个工具函数。expect失败时抛ParseError,消息里带上期望的 Token 类型和实际拿到的 Token 文本,比如expected ')' but got ';' at 5:9。这套写法看起来平淡,但调试体验比到处手写if (cur_.kind != ...)好太多。

std::unique_ptr<Stmt> Parser::parseStatement() { if (check(TokKind::LBrace)) return parseBlock(); if (match(TokKind::KwIf)) return parseIf(); if (match(TokKind::KwWhile)) return parseWhile(); if (match(TokKind::KwReturn)) { auto e = parseExpr(); expect(TokKind::Semi); return std::make_unique<ReturnStmt>(std::move(e)); } if (check(TokKind::KwInt)) return parseVarDecl(); return parseExprStmt(); }

if的悬挂else问题在递归下降里天然消解:parseIf()解析完then分支后立刻看当前 Token 是不是else,是就吃掉并解析else分支。由于内层if一定在外层parseIf返回前被完整解析,最近的else必然绑定到最近的if,和 C 语言的语义一致,不需要额外写规则。

4.3 表达式优先级的分层处理

加减和乘除这两层最典型,其它层结构一模一样,改个 Token 集合就行。while循环负责左结合,每次把已有lhs和新的rhs包成一个BinaryExpr,第二次循环时lhs是上一次的整棵树,自然形成左倾。

std::unique_ptr<Expr> Parser::parseAdditive() { auto lhs = parseMultiplicative(); while (check(TokKind::Plus) || check(TokKind::Minus)) { TokKind op = advance().kind; auto rhs = parseMultiplicative(); lhs = std::make_unique<BinaryExpr>(op, std::move(lhs), std::move(rhs)); } return lhs; } std::unique_ptr<Expr> Parser::parseMultiplicative() { auto lhs = parseUnary(); while (check(TokKind::Star) || check(TokKind::Slash)) { TokKind op = advance().kind; auto rhs = parseUnary(); lhs = std::make_unique<BinaryExpr>(op, std::move(lhs), std::move(rhs)); } return lhs; } // 赋值右结合:递归调用自身,而不是 while 循环 std::unique_ptr<Expr> Parser::parseAssign() { if (check(TokKind::Ident) && peek_.kind == TokKind::Assign) { std::string name = advance().text; // 吃掉标识符 advance(); // 吃掉 '=' auto value = parseAssign(); // 右递归 return std::make_unique<AssignExpr>(std::move(name), std::move(value)); } return parseLogicOr(); }

parseAssign里用了一个 Token 的前瞻(peek_)来判断是不是赋值,而不是先解析左表达式再回退。递归下降不做回溯,靠两个字符合并成一个AssignExpr就够了。如果文法允许(a) = 1这种左值,就得先解析primary再判断,那会复杂很多,课程设计一般不做要求。

4.4 符号表与作用域链

符号表用栈式结构,vector<unordered_map<string, Symbol>>,每次进块push一层,出块pop。查找从栈顶往下走,先命中内层,符合变量遮蔽(shadowing)的语义。Symbol里存名字、类型、栈槽位,函数额外存参数个数,方便调用时检查实参数量。

struct Symbol { std::string name; ValueType type = ValueType::Int; int slot = -1; // 在 VM 栈帧里的偏移 bool isFunc = false; int paramCount = 0; }; class ScopeStack { public: void enter() { scopes_.emplace_back(); } void exit() { scopes_.pop_back(); } bool declare(const Symbol& s) { auto& top = scopes_.back(); if (top.count(s.name)) return false; // 同层重定义 top.emplace(s.name, s); return true; } Symbol* lookup(const std::string& name) { for (auto it = scopes_.rbegin(); it != scopes_.rend(); ++it) { auto f = it->find(name); if (f != it->end()) return &f->second; } return nullptr; } private: std::vector<std::unordered_map<std::string, Symbol>> scopes_; };

槽位分配由 CodeGen 负责:每遇到一个varDecl就nextSlot++,把编号写回Symbol。块结束时槽位不回收,因为课程设计里没有寄存器分配需求,栈帧一次性开够最省事。declare返回false时要报「变量重复定义」并带位置,这个检查放在 Parser 阶段比放到 CodeGen 更早暴露问题。

4.5 从 AST 生成三地址码

指令集设计得越小越好维护,我留了这些:LoadI(立即数)、Load/Store(槽位读写)、Add/Sub/Mul/Div、比较跳转Lt/Gt/Eq、Jmp/Jz、Call/Ret、Print、Halt。每条指令三个操作数a/b/c,跳转类指令的目标标签放在label字段里。临时变量用全局计数器newTemp()生成t0/t1/...,标签用newLabel()生成L0/L1/...,两者共用一个命名空间也不会冲突,因为前缀不同。

struct Instr { OpCode op; int a = 0, b = 0, c = 0; // 槽位号 / 临时变量号 / 立即数 std::string label; // 仅 Jmp/Jz 使用 }; int CodeGen::genExpr(const Expr* e) { if (auto n = dynamic_cast<const NumberExpr*>(e)) { int t = newTemp(); emit(OpCode::LoadI, t, 0, n->value); return t; } if (auto v = dynamic_cast<const VarExpr*>(e)) { Symbol* s = scopes_.lookup(v->name); if (!s) throw ParseError("undefined variable '" + v->name + "'"); int t = newTemp(); emit(OpCode::Load, t, 0, s->slot); return t; } if (auto b = dynamic_cast<const BinaryExpr*>(e)) { int l = genExpr(b->lhs.get()); int r = genExpr(b->rhs.get()); int t = newTemp(); emit(binaryOp(b->op), t, l, r); // 加减乘除映射成对应 OpCode return t; } throw std::runtime_error("unsupported expr in codegen"); }

这段递归生成的好处是每条子表达式都保证落在一个独立临时变量里,父节点直接引用编号,不会出现「算完左操作数、算右操作数时把左操作数覆盖掉」的问题。代价是临时变量数量等于表达式节点数,课程设计里无所谓。if语句的标签回填稍麻烦一点:先算出条件临时变量,发一条Jz跳到else标签,然后生成then分支,最后补Jmp end和两个标签。

void CodeGen::genStmt(const Stmt* s) { if (auto i = dynamic_cast<const IfStmt*>(s)) { int cond = genExpr(i->cond.get()); std::string elseL = newLabel(); std::string endL = newLabel(); emitJump(OpCode::Jz, cond, elseL); // 条件为 0 跳到 else genStmt(i->then.get()); emitJump(OpCode::Jmp, 0, endL); emitLabel(elseL); if (i->els) genStmt(i->els.get()); emitLabel(endL); return; } // while、block、return 同构,略 }

虚拟机就是一个pc加一个vector<int>当栈,Load/Store直接按下标读写,跳转指令修改pc。跑fib.mc这类递归用例时栈会随调用深度增长,Call前把返回地址压栈、Ret弹栈,和真实调用约定一个思路,只是省掉了寄存器保存。

5. 避坑排查:五个把调试拖到凌晨的现场

5.1 位置信息类:行号差一、BOM 与 CRLF

现象:所有报错的行号都比编辑器里小 1,或者第一行第一个 Token 直接报「非法字符」。

原因:前者通常是构造函数里把line_初始化为 0,而 Token 的line/col是 1-based;后者是文件头带 UTF-8 BOM,0xEF 0xBB 0xBF三个字节被当成普通字符扫进了 Token。CRLF 文件如果只处理\n,\r会被算作列宽,导致同一行后面的 Token 列号偏大。

解决:line_和col_都从 1 开始;读文件后判断前三字节并erase(0,3);skipSpaceAndComment里把\r也当空白跳过。这三个改动加起来不到十行,但能省掉一晚上的猜谜。

5.2 文法类:左递归爆栈与优先级写反

现象:输入一个最简单的1+2;程序直接段错误;或者1+2*3算出来是 9 不是 7。

原因:前者是照抄教科书文法写成parseExpr() { parseExpr(); ... },没有改写左递归,栈帧无限增长;后者是加减层里调用了自己而不是乘除层,优先级层次被压平了。

解决:所有二元运算符层统一改成「先取左操作数、while循环吃运算符和右操作数」的结构;表达式从低到高逐层调用,加减只能调乘除,不能反过来。改完拿1+2*3、(1+2)*3、1-2-3三个用例一跑,优先级和结合性都能验证。

5.3 语义与代码生成类:作用域没弹栈、临时变量编号冲突

现象:块里定义的变量在块外还能访问;或者一个函数里两个不同表达式的计算结果互相污染,输出在两次运行间飘忽不定。

原因:parseBlock里只enter()忘了exit(),作用域链只增不减,后面的同名变量全部命中内层;临时变量如果按「表达式内从 0 开始计数」而不是全局计数,父子表达式会拿到同一个编号,后写的指令覆盖先写的值。

解决:给ScopeStack加一对 RAII 守卫,进块构造、出块析构,异常路径也能保证弹出;newTemp()用类成员计数器,只增不减,标签同理。改完跑一个嵌套块加同名变量的用例,输出对得上就说明作用域链和槽位分配都没问题。

5.4 运行时类:跳转标签重复与栈下溢

现象:带if的程序执行到一半跳到莫名其妙的位置,或者虚拟机直接越界崩溃。

原因:标签生成器在多个函数间重置,不同if生成了同名L1,emitLabel时后者覆盖前者;还有一种情况是Jz的目标标签在指令数组里不存在,pc被设成一个越界值。

解决:标签计数器和临时变量计数器一样放在CodeGen成员里,整个编译过程只初始化一次;虚拟机执行前先扫一遍指令数组,检查所有Jmp/Jz的目标标签都能在标签表里找到,找不到就提前报bad jump target,不要等到运行时才崩。这个前置校验加上去之后,定位跳转类问题基本一眼就能看出来。

5.5 工程类:头文件重复定义与构建产物混进源码

现象:改一行代码链接报multiple definition;或者git status里冒出一堆.o、build/目录。

原因:关键字表、OpCode 名字表这类静态数据直接写在头文件里没加inline,被多个.cpp包含后每个 TU 各生成一份;构建目录没有写进.gitignore,提交时把可执行文件也带上了。

解决:C++17 的头文件静态变量统一加inline,或者挪进.cpp只留extern声明;项目根目录放一个.gitignore,把build/、*.o、*.exe、compile_commands.json全部排除。课程设计要交源码,评审看到干净的仓库结构,印象分会高不少。

6. 二次开发:加语句、换后端、补类型系统的三条改造路线

拿到工程后最常改的方向有三个,按改动量从小到大排:加新语句、扩展类型系统、替换后端。加for循环最简单,文法上加一条forStmt -> 'for' '(' varDecl expr ';' expr ')' stmt,Parser 里照着while抄一个parseFor(),CodeGen 里把它糖化成「初始化 + while(条件) { body; 步进 }」,不需要动指令集。唯一要注意的是循环体内的continue语义,如果题目要求支持,步进语句得挪到循环体末尾而不是条件判断前。

改造方向需要动的文件工作量验证方式
加for/do-whileparser.cpp、ast.h、codegen.cpp半天与等价while程序输出比对
加float/ 数组token.h、symtab.h、codegen.cpp、vm.cpp两三天类型错误用例 + 数值精度用例
换 LLVM IR 后端新增 llvm_codegen.cpp,保留原 CodeGen一周同一批 e2e 用例输出一致
加常量折叠优化codegen.cpp 中genExpr入口两三小时对比优化前后 IR 指令条数

换后端这条路线值得单独说。原工程的 AST 和符号表是后端无关的,写一个LlvmCodeGen实现同样的genExpr/genStmt接口,把LoadI/Add换成builder.CreateAdd之类,虚拟机就可以整个删掉。验证方法是保留原来的tests/e2e,用同一个.mc文件分别跑两个后端,比对标准输出。这套差分测试跑通,说明前端没在改造中被改坏,比人工看 IR 靠谱得多。

补类型系统是最容易改崩的一条路。ValueType从只有Int扩成Int/Float/Bool/Array之后,每个二元运算都要做类型提升检查,Symbol的slot大小也要跟着变。我的习惯是先写测试用例再改代码:把int a = 1.5;、float b = 1 + 2.0;、a[0] = 1;这些该报错的、该隐式转换的先写成.mc文件,每改一处跑一遍,红了就回头找。这套流程听着笨,但比改完一大片再一起调试省时间。

从那以后我每次动文法之前,都强制先把新语句的三个用例(正常、边界、错误)写进tests/e2e,跑一遍确认全绿再开始改代码,改完再跑一遍。编译原理课设的调试成本全在「错误离现场太远」这一点上,把测试前置,等于给自己留了后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询