编译原理课设完整编译链:词法分析、四元式回填到栈式虚拟机
简介面向编译原理课程学习与课程设计实践的北邮编译原理课程设计资料包适合完成 Pascal 编译器实验、需要对照实现思路的学生参考。内容围绕词法分析、语法分析、语义分析、代码生成与虚拟机执行展开涉及正则式词法规则、抽象语法树、符号表管理、错误处理及三地址码等中间代码优化并配有 Pascal 与汇编测试用例便于理解从源程序到目标代码的链路。压缩包共 83 个文件约 123KB以 h/cpp 源码为主体包含 pas 测试程序、asm 汇编样例、bin 目标文件及 rez 资源与工程配置结构覆盖编译器核心模块、汇编编辑器与虚拟机实现。已有 1196 人学习可通过阅读源码、运行测试用例和对照阶段产物查漏补缺建立可运行的课程设计框架并加深对编译流程与系统级软件实现的理解。1. 编译原理课程设计的第一道坎不是写不出代码是不知道哪一步错了交完课程设计那阵子我见过太多能跑通自己那三五个测试用例、换个源文件就崩的实现带嵌套注释的文件直接被吞掉半个文件if-else套三层之后四元式的跳转全指到同一个下标。某高校的这份编译原理课程设计资源价值不在参考答案而在它是一条真能从.c源文件跑到可执行结果的完整链路——词法、语法、语义、四元式、目标代码五个阶段各自独立成模块中间只靠四种数据结构串联。一份能跑通的编译器课设排查问题时最需要的恰恰是这种阶段可停可看的结构。适合两类人正卡在某一关跑不通的可以对照模块边界直接定位想弄清书上的自动机理论怎么落成几千行可调试代码的按目录顺序读下去就行。2. 工程结构与跑通五个阶段各自成模块接口只有四种数据先看结构。这份工程没有把所有逻辑塞进一个main.cpp而是按编译阶段切片每个.cpp只做一件事。这样做的好处很实际当输出不对时你能用--emit参数让流水线停在任意一站把中间结果打印出来和预期比对而不是面对一个黑匣子猜是哪一步算错了。2.1 目录结构与各阶段的边界compiler-lab/ ├── Makefile ├── include/ │ ├── token.h # Token 枚举、关键字表 │ ├── ast.h # AST 节点定义与 arena 分配器 │ ├── symbol.h # 符号、类型、作用域链 │ ├── ir.h # 四元式结构与指令枚举 │ └── error.h # 统一错误上报行号、列号、阶段 ├── src/ │ ├── lexer.cpp # 字符流 - Token 流 │ ├── parser.cpp # Token 流 - AST │ ├── sema.cpp # AST - 带类型标注的 AST 符号表 │ ├── irgen.cpp # AST - 四元式表 │ ├── codegen.cpp # 四元式 - 栈式虚拟机指令 │ ├── vm.cpp # 指令数组 - 执行结果 │ └── main.cpp # 驱动按 --emit 决定在哪一站停下 ├── tests/ │ ├── case01_basic.c │ ├── case01_basic.expect │ └── run_tests.sh └── docs/ └── grammar.md # 文法、优先级表、四元式指令集说明阶段之间的接口只有四种数据vectorToken、Expr*/Stmt*树根、Scope*符号表、vectorQuad四元式表。每个模块的头文件只暴露自己的输出类型不反向依赖上一层的实现细节。我一般会把docs/grammar.md当作唯一的文法真相来源——代码和文档对不上时先改文档再改代码否则两周后自己都说不清当初支持的是哪一套语法。这里要强调一个容易忽略的点error.h是独立模块不是每个阶段自己printf。词法错误、语法错误、语义错误的输出格式必须统一成文件名:行:列: 错误信息后面接测试脚本时才能直接拿diff比对。翻车经验是有人词法阶段打印lex error at line 5语义阶段打印第7行类型不匹配脚本一比对全是红叉。2.2 构建与运行一条命令跑通完整编译链工程用 C17 加一个最朴素的 Makefile没有 CMake 依赖make就能出二进制这对交作业场景很友好——评审老师的机器上未必装了新版 CMake。CXX : g CXXFLAGS : -stdc17 -Wall -Wextra -O2 -g SRCS : $(wildcard src/*.cpp) OBJS : $(SRCS:.cpp.o) TARGET : bin/cc all: $(TARGET) $(TARGET): $(OBJS) mkdir -p bin $(CXX) $(CXXFLAGS) -Iinclude $^ -o $ %.o: %.cpp $(CXX) $(CXXFLAGS) -Iinclude -c $ -o $ clean: rm -f $(OBJS) $(TARGET) test: $(TARGET) bash tests/run_tests.sh-Wall -Wextra不是摆设。递归下降分析器里最容易出现的 bug 是某个分支忘了return开了警告能直接抓出来-g则是为了配合gdb单步看 AST 构造过程比到处插printf干净。驱动层的--emit参数是整个工程调试体验的核心它把一条单向前进的流水线切成了可观测的几段make -j4 ./bin/cc tests/case01_basic.c --emittokens # 只跑到词法打印 Token 流 ./bin/cc tests/case01_basic.c --emitast # 跑到语法打印带缩进的语法树 ./bin/cc tests/case01_basic.c --emitir # 跑到中间代码打印四元式表 ./bin/cc tests/case01_basic.c --emitasm # 打印目标指令 ./bin/cc tests/case01_basic.c --run # 生成后立刻执行 ./bin/cc tests/case01_basic.c --emitir --dump-sym # 额外打印各作用域符号参数含义需要说清楚--emit的值决定流水线在哪个阶段结束后退出tokens/ast/ir/asm/run是从左到右的五个停靠点--dump-sym是附加开关配合--emitir使用把每个作用域退出时的符号表快照打出来。有人问为什么不直接全部打印答案是输出量——一个两百行的源文件四元式就有上千条混着符号表一起看根本没法比对。提示--emit的每一个停靠点都必须能独立产出人类可读的文本。做不到这一点后面的排查章节基本无从谈起。2.3 测试用例的组织与回归脚本tests/目录下每个用例是一对文件caseNN_xxx.c源码加caseNN_xxx.expect预期四元式文本。预期文件用的是--emitir的输出不是最终执行结果原因是四元式层能同时暴露语法结构、类型推导和跳转标号三方面的错误而执行结果只能告诉你数不对。#!/usr/bin/env bash # 逐个用例跑中间代码输出与 .expect 做行级 diff set -u PASS0; FAIL0 for src in tests/*.c; do base${src%.c} [ -f ${base}.expect ] || continue out$(./bin/cc $src --emitir 21) if diff -u ${base}.expect (printf %s\n $out) /tmp/diff.txt; then PASS$((PASS1)) else FAIL$((FAIL1)) printf FAIL: %s\n $src head -20 /tmp/diff.txt fi done printf pass%d fail%d\n $PASS $FAIL脚本里的21是必须的语义错误走的是 stderr不合并就会漏掉。(...)进程替换省掉了临时文件ubuntu 和 macOS 的 bash 都支持。预期文件有个硬性要求不能包含绝对路径、时间戳、临时变量编号这类每次运行都变的内容。临时变量如果按t1、t2、t3顺序编号还好如果按内存地址生成就会天天不通过。我一般会在irgen里用一个从 0 开始的计数器专门管临时变量名保证同一份输入永远产生同一份输出——这是整个回归测试能立住的地基。3. 前端拆解从字符流到带位置的 AST 节点前端两个模块词法和语法是课设里改动最频繁的部分。原因很直接新加一个语法特性这两处都得动。把它们的边界划清楚改起来才不会牵连到后面的语义分析和代码生成。3.1 词法分析关键字表 最长匹配Token 的定义决定了后面所有模块能拿到什么信息。行号和列号必须从一开始就带上不能等出错时再回头算——后补行号的成本高到离谱。// include/token.h enum class Tok { Ident, Number, KwInt, KwIf, KwElse, KwWhile, KwReturn, KwVoid, // 关键字 Plus, Minus, Star, Slash, Percent, Assign, Eq, Ne, Lt, Le, Gt, Ge, And, Or, Not, LParen, RParen, LBrace, RBrace, Semi, Comma, Eof }; struct Token { Tok kind; std::string lexeme; // 原始文本报错时原样回显给用户 int line, col; long value 0; // 仅 Number 有效 };关键字不单独建 DFA 状态而是先按标识符规则扫完整串再查表判定Token Lexer::next() { // 跳过空白与两类注释循环处理连续的注释块 for (;;) { skipWhitespace(); if (peek(0) / peek(1) /) { skipLineComment(); continue; } if (peek(0) / peek(1) *) { skipBlockComment(); continue; } break; } const int line curLine_, col curCol_; const char c peek(0); if (std::isalpha(c) || c _) { std::string s; while (std::isalnum(peek(0)) || peek(0) _) s take(); auto it kKeywords.find(s); // 查表判定关键字 Tok k (it ! kKeywords.end()) ? it-second : Tok::Ident; return {k, s, line, col}; } if (std::isdigit(c)) { std::string s; while (std::isdigit(peek(0))) s take(); return {Tok::Number, s, line, col, std::stol(s)}; } // 双字符运算符优先避免 被拆成 和 static const std::pairconst char*, Tok kTwo[] { {, Tok::Eq}, {!, Tok::Ne}, {, Tok::Le}, {, Tok::Ge}, {, Tok::And}, {||, Tok::Or}, }; for (auto [lit, kind] : kTwo) { if (peek(0) lit[0] peek(1) lit[1]) { take(); take(); return {kind, lit, line, col}; } } const char ch take(); switch (ch) { case : return {Tok::Plus, , line, col}; case -: return {Tok::Minus, -, line, col}; case : return {Tok::Lt, , line, col}; // ... 其余单字符运算符 default: throw LexError(line, col, std::string(非法字符 ) ch ); } }逻辑上有三处值得说。第一空白和注释的跳过放在外层for(;;)里保证空白 注释 空白 注释这种组合能正确消化掉而不是只处理一层。第二双字符运算符必须比单字符先匹配这是最长匹配原则的最小实现把拆成和是新手最常见的翻车点症状是a b报语法错误而a b反而不报。第三非法字符直接抛LexError而不是返回一个UnknownToken 交给语法分析处理错误信息里带上行列号用户一眼能看到位置。参数说明peek(k)返回从当前位置往后第 k 个字符越界统一返回\0省掉每个调用点的边界判断take()消费一个字符并推进行列计数遇到换行时curLine_、curCol_归零。这两个原语覆盖了整个扫描器不需要别的位置追踪逻辑。skipBlockComment里有一个容易漏的细节注释内部要逐字符take()而不是直接跳到*/否则跨行的块注释不会更新行号后面所有报错的行号全部偏移。3.2 递归下降语法分析用优先级分层代替左递归消除表达式文法是课设里绕不开的坎。教科书上的E - E T | T T - T * F | F直接写递归下降会无限左递归。教材给的解法是消除左递归改写成E - T E但我在实际项目里更倾向另一种写法按优先级分层每层一个函数天然左结合也不需要引入E这种中间非终结符。expr - assign assign - or ( assign)? // 右结合 or - and (|| and)* and - eq ( eq)* eq - rel ((|!) rel)* rel - add ((|||) add)* add - mul ((|-) mul)* mul - unary ((*|/|%) unary)* unary - (-|!) unary | primary primary - Number | Ident (( args ))? | ( expr )层数多但可以用一张表驱动减少重复代码// 每层的运算符集合索引即优先级层级0 为最低 static const std::vectorstd::unordered_setTok kLevelOps { {Tok::Or}, {Tok::And}, {Tok::Eq, Tok::Ne}, {Tok::Lt, Tok::Le, Tok::Gt, Tok::Ge}, {Tok::Plus, Tok::Minus}, {Tok::Star, Tok::Slash, Tok::Percent}, }; Expr* Parser::parseBinary(int level) { if (level (int)kLevelOps.size()) return parseUnary(); Expr* lhs parseBinary(level 1); // 先吃更高优先级的子表达式 while (kLevelOps[level].count(peek().kind)) { Token op advance(); Expr* rhs parseBinary(level 1); // 同层循环 左结合 lhs arena_.makeBinaryExpr(op.kind, lhs, rhs, op.line, op.col); } return lhs; }关键在parseBinary(level 1)出现两次第一次是拿左边的操作数第二次是拿右边的操作数。因为右边又重新从更高优先级开始解析a b * c会被解析成a (b * c)而外层while循环保证了a b c变成(a b) c左结合成立。赋值运算符是右结合单独处理解析完左边后如果看到递归调用parseAssign()而不是parseBinary这样a b c会得到a (b c)。参数说明level是从 0 开始的优先级层级数值越大优先级越高kLevelOps的长度决定了有多少层加一种新运算符只需要在对应集合里加一个枚举值函数体一行不用改。这是我比较推荐的做法——把优先级这个信息集中在数据里而不是散落在函数调用链中。括号的处理藏在parseUnary - parsePrimary里遇到(就递归回parseBinary(0)解析完整表达式后必须吃到一个)否则报括号未闭合并且错误位置指向开括号所在行——这样用户能定位到具体哪个括号没配对。3.3 AST 节点设计arena 分配与访问者模式节点定义决定了后面语义分析和代码生成怎么写用继承加 arena 是编译期场景里比较稳的组合// include/ast.h struct Expr { int line 0, col 0; virtual ~Expr() default; }; struct NumberExpr : Expr { long value 0; }; struct VarExpr : Expr { std::string name; }; struct BinaryExpr : Expr { Tok op; Expr* lhs; Expr* rhs; }; struct UnaryExpr : Expr { Tok op; Expr* operand; }; struct AssignExpr : Expr { std::string name; Expr* value; }; struct CallExpr : Expr { std::string name; std::vectorExpr* args; }; struct Stmt { virtual ~Stmt() default; }; struct BlockStmt : Stmt { std::vectorStmt* body; }; struct IfStmt : Stmt { Expr* cond; Stmt* then_; Stmt* else_ nullptr; }; struct WhileStmt : Stmt { Expr* cond; Stmt* body; }; struct ReturnStmt : Stmt { Expr* value nullptr; }; struct ExprStmt : Stmt { Expr* expr; }; // 一次性释放整棵树避免逐个 delete 造成的开销和遗漏 class Arena { std::vectorstd::unique_ptrchar[] blocks_; public: template class T, class... Args T* make(Args... args) { void* p ::operator new(sizeof(T)); return new (p) T(std::forwardArgs(args)...); } ~Arena() default; // 节点生命期与编译进程相同进程退出即释放 };这里刻意用了裸指针加 arena而不是shared_ptr。原因不是性能玄学而是 AST 是一棵严格的树没有共享子节点shared_ptr的引用计数维护纯粹是浪费更重要的是 arena 里用 placement new 构造对象后析构函数不需要逐个调用——编译结束后整个进程就退出了。如果你打算把编译器做成常驻服务反复编译那就得改成真正的析构调用这是二次开发时要注意的点。line/col放在基类上所有节点都带位置信息。语义分析报变量未声明时直接取expr-line就行不用在每个节点里重复定义这两个字段。4. 中端与后端符号表、四元式回填到栈式虚拟机的完整链路后三个模块是拉开差距的地方。词法语法大家都能写出来语义检查和中间代码生成的质量才决定这个编译器能跑多复杂的程序。4.1 符号表与作用域链作用域用父子链表实现每个块一个Scope退出时自然回退// include/symbol.h enum class TypeKind { Int, Void, Func }; struct Type { TypeKind kind TypeKind::Int; Type* ret nullptr; // Func 类型的返回值类型 std::vectorType* params; }; struct Symbol { std::string name; Type type; int slot -1; // 栈帧偏移由后端分配 bool isFunction false; int paramCount 0; }; class Scope { Scope* parent_ nullptr; std::unordered_mapstd::string, Symbol table_; public: explicit Scope(Scope* p) : parent_(p) {} // 只查本层用于判定重复定义 Symbol* lookupLocal(const std::string n) { auto it table_.find(n); return it table_.end() ? nullptr : it-second; } // 沿作用域链向外查用于名字解析 Symbol* lookup(const std::string n) { for (Scope* s this; s; s s-parent_) { auto it s-table_.find(n); if (it ! s-table_.end()) return it-second; } return nullptr; } bool define(Symbol sym) { if (lookupLocal(sym.name)) return false; // 同层重名 table_.emplace(sym.name, std::move(sym)); return true; } };lookupLocal和lookup分开是必要的。声明语句int a a 1;里左边的a定义之前右边不该看到自己但也不该看到外层的a——正确行为是报使用了未声明的变量。如果在定义前用lookup会穿透到外层去查就可能意外命中外面的同名变量把一个应该报错的情况悄悄放过。进入函数体、进入{}块都要push一层新作用域退出时pop。这个进入/退出必须配对的约束最好用 RAII 守卫强制别靠人肉在函数末尾补一句。4.2 语义检查清单语义阶段要拒绝哪些输入最好提前列成表写代码时逐条对照比事后补 bug 靠谱检查项触发条件报错信息要点重复定义define时lookupLocal命中指出首次定义的行号未声明使用lookup返回空打印标识符原文和当前行赋值类型不符左值类型与右值类型不等两个类型名都打出来条件非整型if/while条件类型不是 Int报实际类型函数调用参数不匹配参数个数或类型与声明不符报期望 N 个实际 M 个return位置在函数外使用return报不在函数体内break位置循环嵌套深度为 0报不在循环内void 函数返回值void函数里return expr;报void 函数不能返回值循环深度用一个整数计数器维护进while加一、出while减一比用作用域链去猜要直接。函数返回值这一项要做严格版比较麻烦——要判断所有控制流路径是否都有return简单做法是布尔的函数体内是否出现过 return严格做法要在 AST 上做可达性分析。课设场景一般够用但如果你要拿去跑更严谨的测试集这块是会暴露问题的。4.3 四元式生成与跳转回填四元式是前端和后端的契约格式一定要统一// include/ir.h enum class Op { Add, Sub, Mul, Div, Mod, Neg, Not, Assign, Eq, Ne, Lt, Le, Gt, Ge, Jmp, JmpIf, JmpIfNot, Param, Call, Ret, Print }; struct Quad { Op op; std::string a1, a2, res; int target -1; // 仅跳转类指令有效 };if语句的跳转目标在生成时还不知道用回填解决void IRGen::genIf(IfStmt* s) { std::string cond genExpr(s-cond); // 条件结果落到临时变量 int jf emit(Op::JmpIfNot, cond, , ); // 占位条件为假跳走 genStmt(s-then_); if (s-else_) { int jend emit(Op::Jmp, , , ); // then 结束跳到整个 if 之后 patch(jf, (int)quads.size()); // 假分支入口 else 起点 genStmt(s-else_); patch(jend, (int)quads.size()); } else { patch(jf, (int)quads.size()); } }emit返回新四元式的下标patch(idx, target)把第idx条指令的目标改成target。这里的关键在于patch的时机必须在被跳向的那段代码已经生成之后否则拿到的下标是错的。参数说明quads.size()作为patch的第二个参数含义是当前已经生成了多少条指令也就是下一条将要生成的指令下标正好是跳转应该落到的位置。这个用法在整个后端会反复出现while的循环头、if的汇合点都靠它。while稍微特殊一点因为它是向后跳void IRGen::genWhile(WhileStmt* s) { int loopStart (int)quads.size(); // 循环条件的位置 std::string cond genExpr(s-cond); int jexit emit(Op::JmpIfNot, cond, , ); genStmt(s-body); emit(Op::Jmp, std::to_string(loopStart), , ); // 无条件跳回条件处 patch(jexit, (int)quads.size()); }向后跳的目标在生成时是已知的所以不用回填直接写进a1。向前跳的目标未知才需要patch。这个区别值得在代码注释里写清楚否则后来接手的人容易搞混。4.4 目标代码生成与栈式虚拟机后端把四元式翻译成栈式虚拟机指令指令集刻意做小方便验证// include/ir.h续 enum class InstrOp { Push, // 立即数入栈 Load, // 局部变量入栈imm 为槽位号 Store, // 栈顶存回局部变量 Add, Sub, Mul, Div, Mod, Jmp, Jz, // 跳转 / 栈顶为 0 则跳 Call, Ret, Print, Halt }; struct Instr { InstrOp op; long imm 0; // Push 的立即数、Load/Store 的槽位号、Jmp/Jz 的目标下标 };执行循环就是一个标准的解释器long VM::run(const std::vectorInstr code, std::vectorlong locals) { std::vectorlong st; // 求值栈 size_t pc 0; while (pc code.size()) { const Instr in code[pc]; switch (in.op) { case InstrOp::Push: st.push_back(in.imm); break; case InstrOp::Load: st.push_back(locals[in.imm]); break; case InstrOp::Store: locals[in.imm] st.back(); st.pop_back(); break; case InstrOp::Add: { long b st.back(); st.pop_back(); long a st.back(); st.pop_back(); st.push_back(a b); break; } case InstrOp::Jz: if (st.back() 0) { st.pop_back(); pc in.imm; } else st.pop_back(); break; case InstrOp::Jmp: pc in.imm; break; case InstrOp::Print: std::cout st.back() \n; st.pop_back(); break; case InstrOp::Halt: return st.empty() ? 0 : st.back(); default: break; } } return 0; }参数说明locals是当前函数的局部变量槽位数组编译器在codegen里按函数作用域分配好槽位号Load/Store的imm就是这个数组的下标。求值栈st的生命期在run内部函数返回时清空——如果支持函数调用栈帧需要重新设计这是二次开发要面对的第一个问题。这个后端是对了一目了然错了一眼能看出的设计。它不能生成真实机器码但对理解指令选择、栈式求值、跳转修正这三件事已经足够。如果作业要求生成 MIPS 或 RISC-V 汇编只需要把Instr到汇编文本的映射重写一遍四元式那一层完全不用动。5. 常见问题排查六个会让课设直接翻车的细节下面这些坑前四个是几乎每届都会有人踩的后两个是输出比对和测试环节才暴露的。5.1 跨行块注释把后续代码全吞掉现象源文件里写了一段跨三行的/* ... */后面所有语句都报文件意外结束或者报错位置全部偏了几行。原因skipBlockComment里用一个固定长度的前瞻去找*/但没有逐字符调用take()导致curLine_没有跟着推进。另一个变体是遇到第一个*/就退出如果语言支持嵌套注释就会提前结束。解决块注释内部逐字符take()把行号更新交给同一个原语用深度计数器处理嵌套void Lexer::skipBlockComment() { take(); take(); // 吃掉 /* int depth 1; while (depth 0) { if (peek(0) \0) throw LexError(curLine_, curCol_, 块注释未闭合); if (peek(0) / peek(1) *) { take(); take(); depth; } else if (peek(0) * peek(1) /) { take(); take(); --depth; } else take(); // 逐字符推进行号才会同步 } }5.2 表达式一长段就段错误现象测试用例里写a b c d e f程序直接崩溃或栈溢出短的表达式没问题。原因照抄含左递归的文法写递归下降parseExpr里第一行就调用parseExpr无限递归。解决改成第 3.2 节的分层优先级写法或者按教材做左递归消除E - T E。判断方法是给递归函数加一层深度计数超过比如 200 就报表达式嵌套过深——这不是修复但能让你尽快确认问题在递归而不是内存。5.3 块内变量在块外可见现象{ int x 1; } x 2;这段代码没报错被当成合法程序通过。原因语义分析遍历块时只push了新作用域遍历结束没有pop或者pop写在了return之后异常路径上跳过了。解决用 RAII 守卫强制配对别靠手写struct ScopeGuard { Semantic s; explicit ScopeGuard(Semantic s_) : s(s_) { s.pushScope(); } ~ScopeGuard() { s.popScope(); } }; // 使用进入块时构造离开块时自动析构异常路径也安全5.4 嵌套 if 的回填目标串到外层现象if (a) { if (b) { ... } }里内层的假跳转跳到了外层else的位置执行流跑飞。原因用一个全局变量记录当前待回填的跳转下标生成内层结构时把它覆盖了回到外层继续patch时用的是内层的值。解决每次进入子结构前保存当前待回填指针结束后恢复并合并struct PatchSave { std::vectorint saved; PatchSave(std::vectorint cur) : saved(std::move(cur)) { cur.clear(); } ~PatchSave() { /* 退出时由调用方决定 merge 还是丢弃 */ } };更简单的做法是genIf内部的所有跳转全部用局部变量记录只在最外层统一patch中间不依赖任何成员状态。这个改法改动量小我一般优先用。5.5 四元式输出明明对diff 却全红现象肉眼比对--emitir的输出和.expect文件内容一致但脚本判定失败。原因临时变量名带自增计数但每次运行从不同起点开始或者输出里混进了绝对路径、指针地址、std::endl造成的多余空行。解决随机化源头逐个排查。临时变量改用t0、t1...的固定编号从 0 起步输出里不打印任何路径信息把生成逻辑和打印逻辑分离打印函数只接受四元式表不接触文件名。测试时的经验是只要输出里有一项无法预测整个回归测试就废了。注意--emitir的输出格式一旦定下来就不要随手改改了就得同步改所有.expect文件工作量比想象中大。5.6 函数参数和局部变量地址互相覆盖现象两个参数的函数能跑对三个参数就开始互相串值。原因codegen里槽位计数器是全局的第一个函数分配了 02第二个函数继续从 3 开始但虚拟机的locals数组是按函数重置的于是槽位号越界或错位。解决槽位计数器必须挂在函数作用域上进函数时清零函数内局部变量从 0 开始编号参数占 0N-1局部变量从 N 开始。分配完一个函数再把整个函数的槽位数记到Symbol里调用时按这个数扩栈。6. 二次开发在四元式层动手比改前面三个模块都划算这份工程最大的改造价值在中间表示那一层。词法和语法是对外契约改了会影响所有测试用例四元式是内部格式只要a1/a2/res/target的语义不变加优化、换后端都不用碰前端。第一个方向是局部优化。常量折叠是最容易见效的在irgen完成后跑一遍 pass// 局部常量折叠只处理两个操作数都是立即数的二元运算 bool foldOnce(std::vectorQuad q) { bool changed false; for (auto in : q) { if (!isBinary(in.op)) continue; if (!isNumber(in.a1) || !isNumber(in.a2)) continue; long v evalBinary(in.op, std::stol(in.a1), std::stol(in.a2)); in {Op::Assign, std::to_string(v), , in.res}; // 折叠成赋值立即数 changed true; } return changed; } // 必须迭代到不动点因为折叠后可能产生新的可折叠组合 void foldConstants(std::vectorQuad q) { while (foldOnce(q)) {} }这段代码有个前提条件必须在跳转回填全部完成、所有target都是最终下标之后跑否则你在中间插删指令会让后面的跳转目标全部错位。我在自己那份实现上就因为先跑优化后回填导致所有while循环跳到了错误的地址调了整整一个下午。第二个方向是加数组和指针。类型系统要扩一个Array种类四元式要加两条Index取下标和Addr取地址。语义分析在遇到a[i]时检查a是数组、i是整型然后生成Index a, i - t1后端把它翻译成基址加偏移。这一步做完你的编译器就能跑通排序、矩阵乘法这类典型测试用例比只能跑if/while的原版说服力高一个档次。第三个方向是换后端。现在四元式到虚拟机指令的映射是一条对一条中间没有做任何寄存器分配。如果想生成真实的汇编可以在四元式之后加一层线性扫描寄存器分配先做活跃变量分析再按区间分配寄存器溢出部分落栈。这一步的工作量不小但接口很干净——std::vectorQuad进std::vectorInstr出替换中间任何实现都不影响前后端。第四个方向是错误恢复。现在的实现遇到第一个语法错误就抛异常退出一次只能报一个错。改成 panic mode报错后跳到同步集合比如;、}从那里继续解析一次运行能报出多个错误。用户体验差别很大而这个改动只局限在parser.cpp里风险可控。最后讲个血泪教训。我第一次改优化 pass 的时候图省事直接在irgen边生成边折叠结果跳转回填用到的下标全乱套测试用例对了三个、错了十几个花了很久才发现是 pass 顺序的问题。从那以后我每次加 pass 都强制走一遍生成完整四元式 → 回填全部完成 → 再跑优化 → 输出的固定顺序并且在docs/grammar.md里写明这个顺序谁改谁看。希望这份结构上的取舍能帮你少走一段弯路。本文还有配套的精品资源点击获取