简介:面向计算机专业学生与编译原理学习者的实验参考代码,聚焦电子科技大学编译原理课程实验,可用于对照实现词法分析器与语法分析器,理解从源码分词、语法解析到抽象语法树构建的完整流程。压缩包共21个文件、约203KB,以5个cpp源文件与4个h头文件为核心,分别承载词法分析、语法分析与文件读取等模块,另含2个pas测试样例、运行说明docx、sln与vcxproj工程文件及可直接运行的exe程序,便于在本地复现实验环境。已有2139人学习下载。读者可据此了解分词规则设计、语法解析策略选择、模块划分与工程组织方式,并借助说明文档与可执行程序验证运行结果,为课程实验与编译器入门实践提供参考。
1. 拿到一份编译原理实验代码,先别急着跑通
期末周前后,「编译原理实验代码」这几个字的搜索量总会抬一截。场景都差不多:课程设计要交一个能走完词法、语法、语义、中间代码、目标代码的迷你编译器,手里这份代码包能编译通过,也能对着样例吐出四元式,但一打开源文件就不知道从哪儿看起,一改就报错,一答辩就被追问「你这个 FIRST 集怎么算的」。这份资源就是一套按实验阶段拆开的编译器前端实现,C 语言为主,手写词法扫描器和递归下降解析器,中间代码用四元式表示,最后落到一个伪汇编后端和一个解释执行器上,配套测试脚本和期望输出。适合两类人:一类是要在两三天内跑通并讲清楚原理的,另一类是已经跑通、想把实验代码改成自己能继续加特性的编译器骨架的。下面按工程结构、核心模块、避坑点、二次开发四层往下拆。
2. 工程结构拆解:六个实验阶段怎么串成一条编译流水线
一份零散的实验代码和一个能顺手改的项目,差别基本在目录组织上。零散版本通常是lex.c、parse.c、main.c三个文件堆在根目录,全局变量满天飞;稍好一点的会按编译阶段分目录,把接口头文件抽出来。这份资源属于后者,前端后端分得比较清楚,每个阶段可以单独make出来跑,也可以串成一条完整链路。
2.1 目录结构与编译阶段的对应关系
先把整棵树过一遍,看的时候对照编译原理课上那几张流程图,落位会快很多:
compiler-lab/ ├── Makefile # 顶层构建,支持分阶段 target ├── include/ │ ├── token.h # Token 类型枚举、词法单元结构 │ ├── ast.h # 抽象语法树节点定义 │ ├── symtab.h # 符号表与作用域链接口 │ ├── quad.h # 四元式、临时变量、回填链表 │ └── error.h # 统一错误上报(带行列号) ├── src/ │ ├── lexer/ │ │ ├── scanner.c # 手写扫描器主循环 │ │ └── keyword.c # 关键字哈希表与最长匹配辅助 │ ├── parser/ │ │ ├── parser.c # 递归下降 + 表达式优先级爬升 │ │ └── ast_build.c # AST 节点构造与释放 │ ├── sema/ │ │ ├── type_check.c # 类型兼容、左值校验 │ │ └── symtab.c # 作用域链的进入/退出 │ ├── ir/ │ │ ├── quad_gen.c # 语法树到四元式的翻译 │ │ └── backpatch.c # 布尔表达式短路回填 │ ├── codegen/ │ │ ├── mips_like.c # 伪汇编输出 │ │ └── interp.c # 四元式直接解释执行 │ └── main.c # 命令行入口,串起各阶段 ├── tests/ │ ├── case01.src # 正常用例 │ ├── case02.src # 嵌套作用域用例 │ ├── case03.src # 错误恢复用例 │ ├── expected/ # 与用例一一对应的期望输出 │ └── run_tests.sh # 回归脚本 └── docs/ └── grammar.md # 文法产生式,和 parser.c 一一对应这个结构里有两点值得注意。一是docs/grammar.md和parser.c是严格对应的,每个非终结符一个函数,函数名就是产生式左部的小写形式,改文法的时候两边一起改,不至于代码和文档打架。二是src/sema和src/ir是分开的,很多实验代码把语义动作直接写在语法分析函数里,前期图省事,后期想加类型检查就会发现根本插不进手。分层之后,parser.c只产出 AST,不做任何类型判断,语义检查统一在type_check.c里遍历一遍,职责干净。
我一般看陌生项目会先找入口,src/main.c里的处理顺序就是整条流水线:
// main.c 主流程:文件 -> Token 流 -> AST -> 四元式 -> 目标代码 int main(int argc, char **argv) { if (argc < 2) { fprintf(stderr, "usage: %s <source>\n", argv[0]); return 1; } const char *path = argv[1]; Scanner sc; scanner_init(&sc, path); // 打开文件并预读 Parser p; parser_init(&p, &sc); // 解析器持有扫描器引用 AstNode *root = parse_program(&p); // 语法分析,产出 AST 根节点 if (has_error()) return 2; // 词法/语法错误直接返回 SymTab *global = symtab_create(NULL); if (!type_check(root, global)) return 3; // 语义检查,失败不生成 IR QuadList *ql = quad_list_create(); gen_quad(root, ql); // 生成四元式 quad_print(ql); // 打印,实验要求的标准输出 if (g_opt_codegen) emit_asm(ql, stdout); // 可选:伪汇编后端 if (g_opt_interp) interp_run(ql); // 可选:解释执行 quad_list_free(ql); symtab_destroy(global); ast_free(root); return 0; }这段里g_opt_codegen和g_opt_interp是两个全局开关,靠命令行参数-S和-run打开,默认只打印四元式。参数上唯一要留意的是argv[1]必须是文件路径而不是标准输入,因为扫描器在scanner_init里做了fseek预取文件长度,管道输入会让长度算成 0,后面一个 Token 都吐不出来。这也是新手第一次跑最常见的翻车点之一,后面第 5 章会再提。
2.2 贯穿全流程的四个数据结构
编译器的骨架其实是数据结构,语法分析产出的树、语义检查用的符号表、中间代码的四元式,这三样定义清楚了,剩下的都是填空。这份资源里四个核心结构的定义分别在四个头文件里,字段不多但够用:
// token.h:词法单元,kind 是枚举,lexeme 是原始文本 typedef struct { TokenKind kind; // TK_ID / TK_NUM / TK_IF / TK_PLUS ... char *lexeme; // 指向扫描缓冲区,不单独 malloc int line; // 行号,报错用 int col; // 列号 long ival; // 整数常量值,仅 TK_NUM 有效 } Token; // ast.h:语法树节点,用 kind 区分叶子与内部节点 typedef struct AstNode { AstKind kind; // ND_BINOP / ND_IF / ND_WHILE / ND_ID ... struct AstNode *lhs, *rhs;// 二元运算或语句的两个子树 struct AstNode *body; // 语句块链表头 struct AstNode *next; // 同级语句的下一个节点 Token *tok; // 关联的 Token,保留行列号 struct Sym *sym; // 语义分析阶段回填的符号 } AstNode; // symtab.h:符号,带作用域深度和作用域链表 typedef struct Sym { char *name; Type type; // TY_INT / TY_BOOL / TY_VOID int scope; // 作用域深度,0 是全局 struct Sym *next_in_scope; // 同一作用域内的哈希冲突链 struct Sym *next_outer; // 外层作用域链,退出作用域时整条摘掉 } Sym; // quad.h:四元式,(op, arg1, arg2, result) typedef struct Quad { OpKind op; // Q_ADD / Q_ASSIGN / Q_JF / Q_LABEL ... char *arg1; char *arg2; char *result; // 临时变量 t0、t1 或用户变量名 int label; // 跳转目标,-1 表示不回填 } Quad;几个设计取舍值得说清楚。Token.lexeme指向扫描缓冲区而不是每次strdup,这是为了性能,代价是缓冲区必须在整个编译期有效,scanner_destroy之前谁都不能释放它,新手踩内存越界的坑多半出在这儿。AstNode里预留了sym指针,语义分析时把标识符节点和符号表条目绑定,后面生成四元式直接取node->sym->name,不用再查一次表。Sym用next_outer串成作用域链,退出一个{}块时把整条链表摘下来挂到临时栈上,比遍历删除干净得多。
2.3 构建与回归:让每次改动都有据可查
Makefile 里分了几个 target,最常用的是all、stage-lex、stage-parse、test、clean。分阶段 target 的好处是改词法的时候不用等整个工程编译,也不至于语法错误的输出把词法问题掩盖掉:
# 全量构建,产物在 build/ 下 make all # 只构建词法分析器,输出 Token 流,便于对拍 make stage-lex && ./build/lexer tests/case01.src # 全量构建 + 跑回归 make testrun_tests.sh的逻辑很朴素,遍历tests/*.src,跑一遍编译器,把 stdout 和tests/expected/下同名文件做 diff,不一致就把差异打到 stderr:
#!/usr/bin/env bash # 回归脚本:逐用例对拍,有差异就非零退出 set -u BIN=./build/compiler FAIL=0 for src in tests/*.src; do name=$(basename "$src" .src) expect="tests/expected/${name}.out" actual=$("$BIN" "$src" 2>&1) # 合并 stderr,错误信息也进对拍 if ! diff -u "$expect" <(echo "$actual") > /tmp/diff.$$ 2>&1; then echo "[FAIL] $name" cat /tmp/diff.$$ # 直接打印差异,省得再开文件 FAIL=1 else echo "[ OK ] $name" fi done rm -f /tmp/diff.$$ exit $FAIL这套脚本的价值在于它是「黑匣子对拍」——你不需要理解编译器内部怎么实现的,只要输出和期望一致就算过。改代码前先跑一遍确认全绿,改完再跑一遍,红了就立刻知道是哪一步被改坏了。有个细节:脚本里把 stderr 合并进了 stdout(2>&1),因为错误用例的期望输出本身就包含错误信息,不合并的话错误分支永远对不上。这块我见过不少人图省事只比 stdout,结果错误恢复的用例形同虚设,一直显示 OK 但其实什么也没测。
3. 词法与语法核心:扫描器、递归下降和作用域链
前端两大块,词法和语法,是实验里最容易写出「能跑但经不起追问」的部分。能跑指的是样例输入能吐出正确 Token 和 AST;经不起追问指的是换一个输入就崩,或者被问一句「你的最长匹配怎么保证的」就答不上来。拆两块看。
3.1 手写扫描器:状态机、最长匹配与关键字冲突
词法分析器的核心是一个while循环加一个switch,按首字符分流,然后尽量往后吞字符。这里的关键词是「最长匹配」:遇到<=必须吞两个字符再判断,不能看到<就立刻返回小于号。资源里的主循环长这样:
// scanner.c:主循环,按首字符分流,标识符和数字走最长匹配 Token next_token(Scanner *s) { while (s->pos < s->len) { char c = s->src[s->pos]; if (c == '\n') { s->line++; s->col = 1; s->pos++; continue; } // 空白跳过,注意不放过分号这种单字符分隔符 if (c == ' ' || c == '\t' || c == '\r') { s->pos++; s->col++; continue; } // 注释:// 到行尾,/* */ 到闭合,未闭合必须报错 if (c == '/' && s->pos + 1 < s->len) { if (s->src[s->pos + 1] == '/') { while (s->pos < s->len && s->src[s->pos] != '\n') s->pos++; continue; } if (s->src[s->pos + 1] == '*') { s->pos += 2; while (s->pos + 1 < s->len && !(s->src[s->pos] == '*' && s->src[s->pos + 1] == '/')) s->pos++; if (s->pos + 1 >= s->len) { error_at(s, "unterminated comment"); return tk_error(s); } s->pos += 2; continue; } } // 标识符 / 关键字:先按标识符吞,再查关键字表 if (isalpha((unsigned char)c) || c == '_') { int start = s->pos; while (s->pos < s->len && (isalnum((unsigned char)s->src[s->pos]) || s->src[s->pos] == '_')) s->pos++; int len = s->pos - start; TokenKind kw = keyword_lookup(s->src + start, len); // 命中则返回关键字 kind return make_token(s, kw == TK_ID ? TK_ID : kw, start, len); } // 数字:这里只支持十进制整数,十六进制在主循环外单独判断 if (isdigit((unsigned char)c)) { int start = s->pos; while (s->pos < s->len && isdigit((unsigned char)s->src[s->pos])) s->pos++; Token t = make_token(s, TK_NUM, start, s->pos - start); t.ival = strtol(s->src + start, NULL, 10); return t; } // 双字符运算符:先看两位,再看一位 if (s->pos + 1 < s->len) { TokenKind two = match_two(s->src[s->pos], s->src[s->pos + 1]); if (two != TK_UNKNOWN) { s->pos += 2; return make_token(s, two, s->pos - 2, 2); } } TokenKind one = match_one(c); if (one != TK_UNKNOWN) { s->pos++; return make_token(s, one, s->pos - 1, 1); } // 谁也不认识的字符,报错并吞掉一个,避免死循环 error_at(s, "unexpected character"); s->pos++; return tk_error(s); } return make_token(s, TK_EOF, s->pos, 0); // 文件尾必须有一个显式 EOF Token }逐段说明。空白和换行分支必须放在最前面,\n单独处理是因为行号统计依赖它,如果和空格一起吞,报错的行号就全错了。注释分支里/* */未闭合时直接报错返回,而不是继续往后扫——不报错的话后面的代码会被整段吞进注释,错误位置会漂移到文件末尾,调试时极其难找。标识符分支先按「字母开头 + 字母数字下划线」吞完整个词,再拿词去哈希表里查是不是关键字,这就是最长匹配的落地方式:iff不会被拆成if和f,while1也不会被识别成while。数字分支目前只认十进制,如果实验要求支持0x前缀,得在isdigit分支之前加一层判断,否则0x1f会被切成0和标识符x1f,语法分析阶段直接报「缺少分号」。
双字符运算符的判断放在单字符之前,这也是顺序敏感的地方:<=、>=、==、!=、&&、||这六个必须先匹配两位,失败再退回一位。match_two和match_one在keyword.c里,用两张静态表实现,表里的 kind 和token.h的枚举顺序一致,改的时候两边要同步。最后的兜底分支一定要s->pos++再返回错误,否则遇到不认识的字符主循环会原地打转,表现出来就是编译器卡死,这在实验现场是很典型的翻车。
3.2 语法分析:递归下降加优先级爬升
语法分析这块有两种常见写法,一种是表驱动的 LR(1)/SLR,一种是手写递归下降。实验代码里递归下降占多数,因为好调试、好加打印、出错了能直接在调用栈上看出来是哪条产生式。资源的做法是「递归下降加优先级爬升」:语句层用递归下降,每个非终结符一个函数;表达式层不按E -> E + T | T那样给每一级写一个函数,而是用一个parse_expr(min_prec)把优先级和结合性编码成整数参数。
// parser.c:表达式优先级爬升,min_prec 控制当前层能接受的最高优先级 AstNode *parse_expr(Parser *p, int min_prec) { AstNode *lhs = parse_unary(p); // 前缀、一元运算、括号、字面量 while (1) { Token *op = peek(p); int prec = binop_prec(op->kind); // 返回 0 表示不是二元运算符 if (prec == 0 || prec < min_prec) break; advance(p); // 吃掉运算符 // 左结合用 prec + 1,右结合用 prec,赋值运算通常是右结合 int next_min = is_right_assoc(op->kind) ? prec : prec + 1; AstNode *rhs = parse_expr(p, next_min); AstNode *bin = ast_new(ND_BINOP); bin->tok = op; bin->lhs = lhs; bin->rhs = rhs; lhs = bin; } return lhs; } // 二元运算符优先级表,数值越大结合越紧 int binop_prec(TokenKind k) { switch (k) { case TK_ASSIGN: return 1; // 右结合,单独处理 case TK_OR: return 2; case TK_AND: return 3; case TK_EQ: case TK_NE: return 4; case TK_LT: case TK_GT: case TK_LE: case TK_GE: return 5; case TK_PLUS: case TK_MINUS: return 6; case TK_STAR: case TK_SLASH: case TK_PERCENT: return 7; default: return 0; // 非运算符,交由调用方处理 } }min_prec的含义是「当前这一层能接受的最低优先级」。以a + b * c为例,进入parse_expr(0),读到a之后遇到+,prec是 6,不小于 0,吃进去,然后递归parse_expr(7)解析右操作数;在parse_expr(7)里读到b之后遇到*,prec是 7,不小于 7,吃进去,继续递归;等*的右手解析完返回,栈回到+那一层,*的优先级大于+的约束,所以b * c整体成为+的右子树。如果你把next_min写成prec而不是prec + 1,a - b - c会被解析成a - (b - c),减法变成右结合,测试用例里只要有一道连续减法就会暴露。
参数上还有一处要注意:is_right_assoc只对赋值和少数几个运算符返回真,实验里如果没实现赋值表达式作为值使用(也就是a = b = 1这种),可以简单地把赋值当语句处理,parse_expr里根本不会遇到TK_ASSIGN,那张表里留个占位就行,不用真实现。
语句层的递归下降更直白,parse_stmt按首 Token 分流,if、while、return、{、标识符,每个分支解析完自己的结构,然后统一吃掉分号。分号的处理是个高频错误点:很多实现写在每个分支里各吃一次,改的时候漏掉一处,就会出现「前面的语句都正常,后面所有语句全部报缺少分号」的连锁反应。
3.3 符号表与作用域链:进入块时建表,退出块时整条摘掉
符号表这块,最朴素的做法是全局一张表,遇到重名就覆盖。这个做法在单作用域程序上没问题,一旦测试用例里出现嵌套{}里的同名变量,行为就完全错了。资源里用的是作用域链:全局一个SymTab,每个{}块进入时压一个新的SymTab到栈上,退出时弹出。
// symtab.c:进入/退出作用域,插入/查找都沿链向上 void symtab_enter(SymTab *cur, const char *name) { SymTab *inner = symtab_create(cur); // parent 指向外层 cur->child = inner; // 实际调用点会保存返回的 inner 作为 current } Sym *symtab_insert(SymTab *cur, const char *name, Type ty) { // 先查当前作用域是否已有同名,有就直接返回,不重复插入 unsigned h = hash(name) % SYM_BUCKET; for (Sym *s = cur->buckets[h]; s; s = s->next_in_scope) { if (strcmp(s->name, name) == 0) return s; } Sym *s = calloc(1, sizeof(Sym)); s->name = strdup(name); s->type = ty; s->scope = cur->depth; s->next_in_scope = cur->buckets[h]; // 头插,O(1) cur->buckets[h] = s; cur->count++; return s; } Sym *symtab_lookup(SymTab *cur, const char *name) { for (SymTab *t = cur; t; t = t->parent) { // 沿作用域链向外找 unsigned h = hash(name) % SYM_BUCKET; for (Sym *s = t->buckets[h]; s; s = s->next_in_scope) { if (strcmp(s->name, name) == 0) return s; } } return NULL; // 找不到,语义分析阶段报未声明 }三处逻辑说明。symtab_insert里先查重是必须的,因为语义分析遍历 AST 的顺序和源码书写顺序不一定一致,同一个声明节点可能被访问两次,不查重就会插出两个同名符号,后面生成四元式时临时变量编号会跳号。哈希桶用头插,因为编译期符号数量不多,几十上百个撑死了,没必要维护有序。symtab_lookup沿parent一路往外找,这正是作用域「内层遮蔽外层」语义的直接映射——内层先命中就返回,外层同名的自然被遮住。
退出作用域时的释放要小心。我一般会把SymTab本身和里面所有Sym一次性free掉,但Sym.name是strdup出来的独立内存,得先遍历桶逐个free(s->name)再free(s),顺序反了就是 use-after-free。测试用例case02.src就是专门测这个的:三层嵌套块,每层都有同名变量,退出时打印剩余符号数量,数字对不上就说明释放逻辑有问题。
4. 语义分析与中间代码生成:类型检查怎么落到四元式上
前端跑通之后,语义检查和中间代码生成是拉开差距的地方。很多实验代码在这里偷懒:类型检查只判断「是不是同一个类型」,中间代码直接按 AST 后序遍历吐四元式,遇到布尔表达式短路、if-else嵌套跳转就全靠猜。资源里的做法是把这两件事分开,先做一遍带返回值的语义检查,再单独走一遍四元式生成。
4.1 类型检查:返回值、左值校验与隐式转换
语义分析函数统一返回Type,父节点根据子节点返回的类型做判断,这样类型信息自下而上传递,不需要额外维护一张类型表:
// type_check.c:二元运算的类型规则,返回结果类型,错误直接上报 Type check_binop(AstNode *n, SymTab *scope) { Type lt = type_check(n->lhs, scope); Type rt = type_check(n->rhs, scope); if (lt == TY_ERROR || rt == TY_ERROR) return TY_ERROR; // 错误已上报,向上传递 switch (n->tok->kind) { case TK_PLUS: case TK_MINUS: case TK_STAR: case TK_SLASH: if (lt != TY_INT || rt != TY_INT) { error_at_token(n->tok, "arithmetic operand must be int"); return TY_ERROR; } return TY_INT; case TK_LT: case TK_GT: case TK_LE: case TK_GE: case TK_EQ: case TK_NE: // 比较运算允许两边同为 int 或同为 bool,结果一律是 bool if (lt != rt) { error_at_token(n->tok, "comparison type mismatch"); return TY_ERROR; } return TY_BOOL; case TK_AND: case TK_OR: if (lt != TY_BOOL || rt != TY_BOOL) { error_at_token(n->tok, "logical operand must be bool"); return TY_ERROR; } return TY_BOOL; default: return TY_ERROR; } }TY_ERROR这个返回值是整个语义分析能继续往下走的关键。子节点出错之后不直接exit,而是返回一个「已出错」的哨兵类型,父节点看到它就不再重复报错,只把错误继续往上传递。这样一次编译能把源码里所有类型错误都报出来,而不是改一个报一个。实验答辩时这条经常被问到:「你的错误恢复怎么做的?」答案就是这个哨兵类型加has_error()全局标志,main里检查一次决定要不要继续生成中间代码。
左值校验放在赋值语句那条分支里,逻辑是n->lhs->kind必须是ND_ID且该符号不是常量,否则报「assignment to non-lvalue」。这条规则看着简单,实现在 AST 上的时候要注意a[0] = 1这种下标赋值,如果实验不要求数组,检查函数里直接不处理ND_INDEX分支,遇到时报「unsupported」比默默放过要好。
4.2 四元式生成:临时变量与布尔表达式的回填
四元式生成的核心是「后序遍历 AST,每个节点返回一个存放结果的字符串」,可能是一个用户变量名,也可能是新分配的临时变量t0、t1。算术表达式很直接,难点在控制流:if和while的条件跳转目标在生成的时候还不知道,需要先留空,等目标确定后再回填。
// quad_gen.c:二元算术生成,返回结果存放位置 char *gen_expr(AstNode *n, QuadList *ql) { if (n->kind == ND_NUM) { char buf[32]; snprintf(buf, sizeof(buf), "%ld", n->tok->ival); return strdup(buf); // 常量直接返回字面量文本 } if (n->kind == ND_ID) { return strdup(n->sym->name); // 语义分析已回填 sym } if (n->kind == ND_BINOP) { char *l = gen_expr(n->lhs, ql); char *r = gen_expr(n->rhs, ql); char *t = new_temp(ql); // t0, t1, t2 ... 顺序分配 emit_quad(ql, op_from_token(n->tok->kind), l, r, t); free(l); free(r); return t; // 释放左右临时串,t 交给上层 } return strdup("0"); } // backpatch.c:把待回填链上的四元式跳转目标统一改成 target void backpatch(QuadList *ql, IntList *list, int target) { for (IntList *p = list; p; p = p->next) { Quad *q = quad_at(ql, p->idx); q->label = target; // 链表里存的是四元式下标 } }第一段里,gen_expr返回的字符串所有权归调用方,所以生成完子表达式之后要free(l)和free(r),但t不能 free,要交给上层继续用。这个所有权约定必须写死,否则要么到处泄漏,要么 double free。临时变量用new_temp顺序分配,t0、t1、t2编号连续,答辩现场对着输出数临时变量个数,一眼就能看出有没有多余指令。
第二段backpatch是布尔表达式短路的实现基础。以a && b为例,语义是「a 为假就直接跳到整个表达式为假的位置」,但生成a的四元式时还不知道那个位置在哪,所以先发一条(JF, a, -, _),把这条指令的下标挂进一个链表,等b也生成完,整个&&表达式的出口确定之后,再拿链表统一回填。资源里用IntList存四元式下标而不是存指针,是因为四元式列表底层是realloc的动态数组,存放指针在扩容后会全部失效,这个坑我在别的项目上真踩过。
控制流的生成函数一般长这样:gen_if先分配else标签,生成条件表达式的真/假链,用backpatch把假链指向else,再生成 then 分支,发一条无条件跳转JMP到出口,把else标签落下来,生成 else 分支,最后把出口标签落下。顺序错一步,生成的跳转就会跳到别的分支体中间,解释执行的时候表现为「偶尔能跑对,换个条件就死循环」,这种 bug 靠读代码很难看出来,得把四元式打出来按标签逐条走一遍。
4.3 从四元式到可执行结果:解释执行比生成汇编更适合实验
后端有两个选项:生成伪汇编,或者直接解释四元式。实验里我更推荐先做解释器,因为它把「寄存器分配」「调用约定」这些和编译原理主线关系不大的东西绕开了,能让你把注意力放在四元式对不对上:
// interp.c:极简四元式解释器,只处理本实验用到的指令集 void interp_run(QuadList *ql) { long *temp = calloc(MAX_TEMP, sizeof(long)); // 临时变量值域 long *var = calloc(MAX_VAR, sizeof(long)); // 用户变量值域 int pc = 0; while (pc < ql->count) { Quad *q = &ql->quads[pc]; switch (q->op) { case Q_ASSIGN: set_slot(temp, var, q->result, get_slot(temp, var, q->arg1)); pc++; break; case Q_ADD: set_slot(temp, var, q->result, get_slot(temp, var, q->arg1) + get_slot(temp, var, q->arg2)); pc++; break; case Q_SUB: set_slot(temp, var, q->result, get_slot(temp, var, q->arg1) - get_slot(temp, var, q->arg2)); pc++; break; case Q_JF: // 条件为假跳转 if (get_slot(temp, var, q->arg1) == 0) pc = q->label; else pc++; break; case Q_JMP: pc = q->label; break; case Q_LABEL: pc++; break; // 标签本身不执行 case Q_PRINT: printf("%ld\n", get_slot(temp, var, q->arg1)); pc++; break; default: pc++; break; } } free(temp); free(var); }temp和var分成两个数组,是为了避免用户变量和临时变量重名——用户在源码里完全可以声明一个叫t0的变量,统一用一张表的话t0会被临时变量覆盖,这个 bug 在测试用例里加个同名变量就立刻现形。max_temp和max_var从QuadList上统计出来,不要硬编码成几千,实验用例撑不到那么大,浪费不说还容易越界。Q_LABEL分支什么都不做、只让pc++,是因为跳转目标在JF/JMP里已经直接写成四元式下标了,标签指令只是一个占位,这让回填逻辑简单很多:backpatch填的是下标,不用先查标签表再转下标。
解释器的价值在调试:把interp_run里加一句fprintf(stderr, "pc=%d op=%d\n", pc, q->op),跑一遍就能看到每一轮执行到了哪条指令,配合四元式打印,任何控制流错误都无处藏身。相比之下,伪汇编后端出错了只能看汇编列表,还要脑补栈帧,排查效率差一个量级。
5. 避坑与排查:编译实验里最耗时间的五个翻车点
实验代码跑不通,九成不是算法想错了,而是几个固定的边界问题。下面五条是我在带人和自己折腾时反复见到的,按「现象、原因、解决」写,遇到对应症状直接对号入座。
5.1 现象:所有 Token 的行号都是 1,错误定位全错
原因九成是扫描器初始化时把整个文件一次读进内存,但把行号计数器放在了next_token外面,或者\n是在注释分支里被吞掉的,主循环没机会s->line++。还有一种情况是先按\n切行、每行单独扫描,行号传参传丢了。
解决:行号只在主循环的\n分支里递增,其他任何地方都不要碰s->line。扫注释的时候把注释内部的\n也走一遍同样的递增逻辑,我一般是抽一个advance_char(s)函数,里面统一处理pos++和line++,主循环和注释分支都调它,就不可能出现两套计数器。
5.2 现象:a - b - c算出来的结果和手算差 2b
原因:表达式解析写成右结合了,parse_expr里递归调用传的是prec而不是prec + 1,a - b - c被解析成a - (b - c)。这个 bug 特别阴,因为a + b + c和a * b * c无论左右结合结果都对,只有减法和除法会露馅,很多用例根本覆盖不到。
解决:默认左结合,只有赋值和幂运算这类明确右结合的运算符才传prec。改完之后补一条连续减法的用例进tests/,期望输出写死,让它成为回归的一部分。
5.3 现象:嵌套块里的同名变量互相覆盖,退出块后外层变量值丢了
原因:符号表只有一张全局表,或者虽然有作用域链但退出块时没摘掉内层符号,导致内层声明永久污染外层。表现出来就是{ int x = 1; } print(x);不报未声明,反而打印 1。
解决:进入{}时新建一层SymTab并把当前指针压栈,退出时把当前层整层释放并弹栈,symtab_lookup只沿parent向上找。释放顺序是「先逐个free(s->name),再free(s),最后free(表本身)」,反过来就是 use-after-free。用-fsanitize=address编译跑一遍用例,泄漏和越界会直接打印调用栈。
5.4 现象:if-else嵌套三层以上,解释执行时跳到了错误分支
原因:回填链表里存的是指向Quad的指针,而四元式数组在生成过程中会realloc扩容,扩容后旧指针全部失效,backpatch就等于往一块已经释放的内存里写。症状是加了打印就正常、去掉打印就错,或者换个用例规模表现完全不同,典型的玄学。
解决:链表里存四元式下标,不存指针,回填的时候用quad_at(ql, idx)现取。另外,标签分配和回填必须在同一次生成过程里成对出现,不要先生成全部再统一回填,那样链表的生命周期容易和数组扩容交错,问题更隐蔽。
5.5 现象:测试脚本全绿,但老师换一个输入就崩
原因:run_tests.sh只比 stdout,错误用例的期望输出里包含了unterminated comment这类错误信息,实际输出到 stderr,一合并就全绿,但实际错误分支根本没被触发;或者用例太少,没覆盖错误恢复路径。
解决:脚本里强制2>&1,并且为每个错误用例单独准备期望文件。再补一类「压力用例」:超长标识符、深嵌套括号、连续 500 行赋值,这些不会出现在正式用例里,但能提前暴露缓冲区越界和递归层数过深的问题。递归下降在深嵌套表达式下会爆栈,如果实验要求处理很深的括号,考虑加个深度计数,超过阈值就报错而不是让程序崩溃。
6. 二次开发:把一次性实验代码改成能继续长东西的骨架
跑通之后真正动手改,第一步不是加功能,是把边界收紧。我的做法是先做三件小事,每件都能立刻验证。第一件,把Token.lexeme的strdup化,代价是内存多了,但换来的是扫描缓冲区可以提前释放,后面加宏展开、加#include处理时不会因为缓冲区生命周期扯皮。第二件,把new_temp和new_label的计数器从全局变量挪进QuadList,这样同一个进程里生成多棵函数的四元式时编号不会串,多函数支持是几乎所有二次开发的第一需求。第三件,给emit_quad加一个const char *file, int line参数,四元式打印时带上生成位置,出了控制流 bug 直接定位到quad_gen.c的哪一行。
下面这张表是我改这类实验代码时的优先顺序,按投入产出比排:
| 改造点 | 改动范围 | 收益 | 风险 |
|---|---|---|---|
| 临时变量/标签计数器归入 QuadList | ir/*.c 少量 | 支持多函数、多编译单元 | 低 |
| 符号表加类型字符串与作用域深度 | symtab.h/.c | 错误信息可读性大幅提升 | 低 |
| 表达式解析加一元负号与类型提升 | parser.c / type_check.c | 用例覆盖变广 | 中,注意优先级 |
| 四元式改三地址码带基本块 | ir/*.c | 为后续优化铺路 | 高,回填逻辑要重写 |
| 加常量折叠 | ir/quad_gen.c | 输出指令数下降,答辩加分 | 中,注意除零 |
重点说常量折叠,因为它是性价比最高、也最容易写错的一个。在gen_expr的ND_BINOP分支里,如果lhs和rhs都是ND_NUM,直接算出结果返回,不发四元式:
// 常量折叠:两个操作数都是字面量时直接求值,不生成四元式 if (n->lhs->kind == ND_NUM && n->rhs->kind == ND_NUM) { long a = n->lhs->tok->ival, b = n->rhs->tok->ival, r = 0; switch (n->tok->kind) { case TK_PLUS: r = a + b; break; case TK_MINUS: r = a - b; break; case TK_STAR: r = a * b; break; case TK_SLASH: if (b == 0) { error_at_token(n->tok, "division by zero"); return strdup("0"); } r = a / b; break; default: goto no_fold; // 比较运算不折,保留短路语义 } char buf[32]; snprintf(buf, sizeof(buf), "%ld", r); return strdup(buf); no_fold: ; }这里的坑有两个。除法必须先判零再除,判零之后返回"0"只是占位,真正的错误靠error_at_token上报、has_error()兜底,不然除以零会直接把编译器打崩。比较运算故意不折叠,因为a < b在短路上下文里会生成带跳转的真假链,折成常量之后链就断了,backpatch会拿到空链表,表现是if语句整段被跳过。验证方法很简单:拿case01.src跑一遍,对比折叠前后的四元式条数,再把解释执行结果和折叠前逐行 diff,一致才算过。
从那以后我每次接手一份实验代码,都强制先跑一遍make test确认全绿,再动第一行代码,改完再跑一遍,红了就先回退而不是硬调。这套笨办法救过我很多次,尤其是回填和控制流那部分,靠读代码是读不出指针失效的。希望帮到你。
本文还有配套的精品资源,点击获取