多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

手写SNL编译器:递归下降到栈式虚拟机的完整实现

手写SNL编译器:递归下降到栈式虚拟机的完整实现 简介吉林大学计算机学院2022年编译原理课程设计——SNL语言编译器项目面向正在学习编译原理、需要完成或参考课程设计的高年级本科生。项目使用C开发实现了词法分析、语法分析、语义分析的完整流程为绩点4.0的原创成果与主流Github资源重复度低具有较好的参考价值。压缩包共91个文件体积142.28MB以.cpp和.h源代码为核心涵盖词法分析器、LL1语法分析器、语义分析器等模块另有多个txt测试用例、输出文件及完整Visual Studio工程文件方便直接打开调试并查看可执行程序与构建中间文件。其中测试用例覆盖语法错误、语义错误及语义正确场景便于从异常与正常两个方向对照。目前已有1373人浏览学习。该资源可帮助读者理解SNL语言编译器的整体架构与各个分析阶段的衔接通过自带测试用例验证输出在课程设计、实验报告撰写或答辩准备时作为具体对照与补充材料。1. 把 SNL 语言编译器拆开看它到底让你做哪几件事第一次拿到吉林大学计算机学院编译原理课程设计题目的人多半会先愣一下SNL 不是一门工业语言它是 Simple Notation Language一门课程专用的教学语言规模和难度刚好卡在“能完整走通编译流程”这条线上。你要做的不是把某个现成编译器改个壳而是从词法分析一路做到目标代码执行中间经过语法分析、语义检查和中间代码生成把编译器的翻译本质亲手实现一遍。这台编译器做完词法、语法、符号表、运行栈这些东西就不再是黑匣子。这篇笔记按课程设计最常见的做法把手写递归下降、四元式回填和栈式虚拟机串成一条最小可跑的链路适合正在做这个课设、或者想把手写编译器这条路彻底走通的人。2. 先把整体架构定下来中间表示、目标机和模块边界2.1 动手前先定的四件事语言边界、中间表示、目标机和错误处理编译器不是编辑器。编辑器给你补全、高亮和重构提示编译器只负责把源程序从字符流翻译成目标机可执行的指令序列。SNL 语言虽然小但该有的类型和语句都有整数、实数、布尔、字符串加上数组和记录类型语句覆盖赋值、条件、循环、过程调用足够支撑一门课设的完整实践。我一般建议先按保守子集实现把功能做少做稳而不是一上来就照着教材附录把全部语法点铺开——四五个调试版之后你会发现少两个语句比多两句语法错误体验好得多。中间表示的选择决定后面所有代码怎么组织。常见做法是“语法分析期间直接生成三地址码/四元式”不单独建一棵完整 AST 再遍历。SNL 课设的语句种类有限这样做省代码、思路直接每当递归下降函数识别出一个表达式或语句结构就立刻调用 emit 往四元式表里追加一条指令。缺点是想做中端优化时结构不够舒展如果你的课程要求里明确写了优化部分那就先把 AST 建出来后面再做常量折叠和死代码删除都顺手。目标机也要提前定。这个课设有三种主流做法生成 MIPS 汇编、生成 C 代码再交给本机编译器、自己实现一个栈式虚拟机直接执行中间代码。MIPS 汇编最接近工业真实链路但需要额外熟悉寄存器分配和延迟槽生成 C 代码省事却把一部分运行细节推给了 C 编译器栈式虚拟机最可控指令集你能完全按四元式设计排查问题也直观。课程项目我一般选栈式虚拟机把精力集中在编译器前端和中间代码的正确性上。最后是错误处理。课设编译器最容易忽略的就是“一次实验报出尽量多的错”。我习惯维护一个全局 errors 计数语法错误时跳过当前语句找到同步 token语义错误只记录不中断编译最后 errors 非零就不进入执行阶段。这样你调试一个 100 行的 SNL 程序第一遍能同时看到五六处问题而不是改一处跑一次。编译阶段输入输出主要数据结构词法分析源文件字符流Token 序列DFA 状态机、保留字表语法分析Token 序列四元式序列递归下降函数集语义分析语法分析阶段的动作修正后的四元式、符号表作用域链符号表目标执行四元式序列运行结果栈式虚拟机、运行栈2.2 为什么选手写递归下降而不是 yacc/bison很多教材把 yacc/bison flex 作为默认工具链上课时也讲过 LALR 分析表生成。但放到这门课设里手写递归下降的优势非常明显。SNL 的文法规则一共几十条一个非终结符对应一个 C 函数代码结构和文法几乎一一对应别人读你的代码能直接对照着 EBNF 看。报错位置也天然精确因为每个函数都知道自己正在尝试匹配哪个语法单位当前 token 是什么直接拼到错误信息里就行。yacc/bison 这边生成 LALR 分析表很快但错误恢复要靠额外配置 error 产生式调试期你会面对“要么报错位置偏了十几行要么一次只报一个错”的尴尬。悬挂 else 的经典冲突在 yacc 里靠优先级声明处理声明错了整个行为就变在手写递归下降里else 的归属就是代码里一个“谁消费它”的控制问题没有任何黑魔法。还有一个更现实的原因用 yacc 很容易把语义动作写成一个装饰品最后交上去的编译器只能“解析成功”却不能“运行正确”。递归下降逼着你在每个产生式的函数体里把符号表查询、类型检查和 emit 全写出来因为你不写就没有别的地方能写。课程设计的目的本来就是搞清楚编译器各部分怎么协作而不是学会填模板。对比一下就很直观对比项手写递归下降yacc/bison报错定位精确到函数和当前 token需要额外配 error 恢复规则悬挂 else代码里控制 token 消费即可靠优先级或冲突说明处理语义动作和语法代码写在一起要在动作里手工传属性值环境依赖一个 C 编译器即可需要 bison/flex版本差异还不少2.3 最小可运行骨架main 函数、命令行参数与编译流水线开始写各个分析器之前我建议先把主流程搭出来哪怕词法分析器还是空的也要让编译能跑通。这个骨架定下三件事命令行怎么解析、编译失败后怎么退出、调试输出走哪个开关。/* snlcc.c —— 最小可运行的 SNL 编译器主流程 */ #include stdio.h #include string.h int errors 0; /* 全局错误计数任一阶段出错都累加 */ int nquad 0; /* 已生成的四元式条数 */ int main(int argc, char *argv[]) { const char *src_file test.snl; int dump_ir 0; /* -d 时打印四元式便于调试 */ for (int i 1; i argc; i) { if (strcmp(argv[i], -d) 0) { dump_ir 1; /* 只影响调试输出 */ } else if (argv[i][0] -) { fprintf(stderr, usage: snlcc [-d] file.snl\n); return 2; } else { src_file argv[i]; /* 最后一个非选项参数作为源文件 */ } } init_lex(src_file); /* 打开源文件初始化保留字表 */ parse_program(); /* 语法分析入口驱动整个编译 */ if (errors 0 dump_ir) dump_quads(); /* 打印中间代码供人工核对 */ if (errors 0) run_vm(); /* 栈式虚拟机执行四元式 */ return errors ? 1 : 0; }这段骨架的逻辑顺序很重要parse_program 内部会循环调用 get_token词法分析器每取出一个 token语法分析器就消费一个语义动作穿插在递归下降函数里面执行。也就是说词法、语法、语义三个阶段不是三个独立 pass而是集中在一次语法分析扫描里完成的这是递归下降配合动作最自然的组织方式。命令行参数我做了最小处理-d 控制是否打印中间代码不带任何参数时输出 usage 并返回 2其他的参数都当源文件名。这里刻意把退出码分开0 表示编译并运行成功1 表示编译有错2 表示参数错误。后面在实验脚本里判断结果时这三个码一眼就能分清问题出在哪一层。唯一要提醒的是四元式表要在 emit 函数里做上界检查否则你写了一个无限循环的语法递归先爆掉的是数组。3. 词法分析与语法分析递归下降把字符流变四元式3.1 词法分析DFA 状态机与保留字表的组织词法分析是编译器的第一个黑匣子它的任务是把 char 流变成 token 流。SNL 课设里 token 类型大概有几十种标识符、整数、实数、字符串、运算符、分隔符和保留字。我习惯把保留字单独处理词法分析器先按标识符规则切出一个单词再查保留字表命中的就换成对应 token 类型没命中的才是普通标识符。这样新增保留字只需要改一张表不用动状态机。词法分析主体是一个 DFA 状态切换的循环。核心状态就几个START、ID、NUM、REAL、STR、DONE状态转移条件按字符类别判断。/* lex.c —— 词法分析核心循环的骨架按 DFA 状态切换 */ typedef enum { S_START, S_ID, S_NUM, S_REAL, S_STR, S_DONE } LexState; extern int cur_token; /* 当前 token 类型如 TK_IF、TK_ID */ extern char id_val[32]; /* 标识符/字符串的字面值 */ extern int int_val; /* 整型字面值 */ static void get_token(void) { LexState st S_START; int n 0; while (st ! S_DONE) { int c src_getc(); /* 从源文件读一个字符 */ switch (st) { case S_START: if (isalpha(c) || c _) { id_val[n] c; st S_ID; } else if (isdigit(c)) { int_val c - 0; st S_NUM; } else if (c {) { skip_comment(c); } /* 块注释 */ else if (c || c ) { handle_angle(c); } /* 见避坑 5.1 */ else if (c EOF) { cur_token TK_EOF; st S_DONE; } /* 其余单字符运算符直接定 token省略 */ break; case S_ID: if (isalnum(c) || c _) { id_val[n] c; } else { src_ungetc(c); id_val[n] 0; st S_DONE; } break; /* S_NUM、S_REAL、S_STR 同理按字符迁移后进入 S_DONE */ } } cur_token lookup_reserved(id_val); /* 先按标识符查保留字表 */ }这套写法里有两个关键点。第一是回溯读标识符时遇到分隔符比如右括号或分号必须用 src_ungetc 把一个字符退回流里否则下一个 token 的第一个字符就丢了。递归下降要求词法分析器每次精确取一个 token不能多吃。第二是注释处理block 注释要单独用 skip_comment 处理而且要维护嵌套深度读到 { 加一读到 } 减一深度归零才返回。SNL 课设里我见过最多的问题就是注释没闭合直接吞掉后面整个程序然后大量报“unexpected token”看着像语法错实际死在词法。标识符查保留字表用线性查找就行SNL 的保留字总共就二三十个二分查找带来的收益可以忽略。查表之前记得把 id_val 收尾置零否则字符串尾巴上残留上次的值那才是真正的玄学 bug。3.2 递归下降语法分析一个非终结符对应一个函数递归下降的代码组织很机械但机械不等于简单。规则只有一个每一个非终结符写一个函数函数体按产生式右侧的符号顺序去匹配。遇到终结符就检查当前 token 是否相等相等就 advance 取下一个遇到非终结符就调用对应函数。语句层是入口因为 SNL 的语句种类有限用当前 token 分派就能覆盖IF、WHILE、标识符开头赋值或过程调用其余就是错误。/* parse.c —— 语句与表达式 expression 的递归下降骨架 */ static void parse_stmt(void) { switch (cur_token) { case TK_IF: parse_if_stmt(); break; case TK_WHILE: parse_while_stmt(); break; case TK_ID: /* 赋值和过程调用都以标识符开头向前看一个 token 区分 */ parse_assign_or_call(); break; default: error_at(语句起始 token 非法, cur_token); break; } } /* expr - term { (|-) term } 的左结合写法 */ static int parse_expr(void) { int t1 parse_term(); /* 返回操作数的四元式槽位 */ while (cur_token TK_PLUS || cur_token TK_MINUS) { int op cur_token; advance(); int t2 parse_term(); int r new_temp(); /* 分配临时变量槽位 */ emit(op TK_PLUS ? add : sub, r, t1, t2); t1 r; /* 左结合结果继续参与下一轮 */ } return t1; }parse_expr 这段是递归下降里左结合的标准写法。像 expr - expr term 这种左递归文法如果直接翻译成递归调用会在第一个操作数处无限循环下去。正确做法是循环代替递归先解析一个 term然后看后面有没有加号减号有就再解析一个 term把结果通过四元式算出来再作为下一个运算的左操作数。这也是为什么返回值是 int——它返回的不是普通 C 数值而是四元式表里的槽位序号后续指令要用这个序号当操作数。还有一点很多人踩过赋值和过程调用都以标识符开头必须在 parse_assign_or_call 里向前看一个 token 才能区分。看下一个是赋值号还是左括号决定走赋值还是调用。递归下降里这种“LL(1) 冲突”经常出现解决办法就是向前看大不了看两个。3.3 悬挂 else 与左递归两处必须先处理的文法细节悬挂 else 是 if 语句文法最经典的冲突。问题描述很简单if a then if b then x else y 这个 else 到底归哪个 if高级语言教科书给出的语义是“就近匹配”但文法直接翻译成递归下降时内层 if 的函数会先看到 else如果它把这个 else 消费掉外层 if 就永远等不到自己的 else。手写递归下降里解决手法非常直接else 由最近的 if 消费内层语句函数根本不处理 else 这个 token。static void parse_if_stmt(void) { int jump_false, jump_over; advance(); /* 吃掉 if */ int cond parse_expr(); jump_false emit_jump_false(cond); /* 条件为假先跳过 then 分支 */ advance(); /* 吃掉 then */ parse_stmt(); /* 内层语句碰到 else 不会消费 */ if (cur_token TK_ELSE) { jump_over emit_jump(); /* 留一个无条件跳绕过 else */ backpatch(jump_false, nquad); /* 假分支跳到 else 起点 */ advance(); parse_stmt(); backpatch(jump_over, nquad); /* 真分支跳到整个 if 之后 */ } else { backpatch(jump_false, nquad); /* 没有 else 就跳到 if 之后 */ } }这段代码有两个值得细看的点。第一个是 nquad 的用法emit 返回当前四元式序号nquad 是全局计数backpatch 的 target 就是“当前四元式表的尾部”也就是下一步要生成的那条指令的位置。第二个是内层 parse_stmt 不会消费 else——因为内层函数的分派表里根本没有 else 这个 token它解析完自己的语句就返回了else 留在 token 流里等着外层 if 判定归属。左递归处理在 3.2 已经演示过循环代替递归并且天然保持左结合性。这里要补充的是SNL 的表达式如果不只包含加减还有乘除和取负那你的 parse_term 也要做成同样的循环结构只是第一层循环处理乘除第二层处理加减。每个优先级对应一个函数优先级越高函数越深这是递归下降处理表达式优先级的标准分层方式。你真的不需要像 LALR 那样声明 %left %right函数调用顺序本身就编码了优先级。4. 语义分析与中间代码生成符号表、类型检查和回填4.1 符号表与作用域块结构怎么压栈SNL 是块结构语言过程声明可以嵌套这就决定了符号表必须支持作用域。常见做法是作用域链每次进入一个过程push 一个新的 Scope本层的变量、常量、过程都挂到这个 Scope 的链表上查找时从当前 Scope 一层层向外找直到顶层。这个设计同时解决了嵌套可见性和同名遮蔽两个问题——内层声明和外层同名时查找从内往外先命中的就是内层的。/* symtab.c —— 作用域链符号表 */ typedef enum { SYM_VAR, SYM_CONST, SYM_PROC, SYM_ARRAY, SYM_RECORD } SymKind; typedef struct Symbol { char name[32]; SymKind kind; int type; /* TY_INT / TY_REAL / TY_BOOL / TY_STR */ int offset; /* 本作用域内的偏移进入过程时分配 */ struct Symbol *next; } Symbol; typedef struct Scope { Symbol *head; struct Scope *parent; } Scope; Scope *cur_scope NULL; Scope *scope_push(void) { Scope *s calloc(1, sizeof(Scope)); s-parent cur_scope; cur_scope s; return s; } void scope_pop(void) { Scope *s cur_scope; cur_scope s-parent; /* 释放 s-head 链上所有 Symbol省略 */ } Symbol *sym_lookup(const char *name) { for (Scope *s cur_scope; s; s s-parent) { for (Symbol *sy s-head; sy; sy sy-next) { if (strcmp(sy-name, name) 0) return sy; } } return NULL; }offset 这个字段值得多说一句。它不是在符号表里摆样子的而是在语义分析阶段就算好的运行栈偏移。进入一个过程时把本层所有变量和临时变量的占用空间累加起来得到一个总帧大小栈式虚拟机分配运行栈帧时按这个大小一次性压栈。SNL 没有指针变量寻址就是“帧基址 符号表里的 offset”所以这个值必须在编译期确定。另外过程声明本身也放在符号表里kind 取 SYM_PROC。过程名查找成功后语法分析拿到的只是一个符号表项真正调用时还需要靠它跳转到中间代码里对应的过程入口。所以符号表项里通常还会存一个 entry_label 字段指向该过程第一条四元式的序号——这个值在回填之前是未知的后面前向引用会再讲到。4.2 类型检查赋值兼容的一条规则加一个特例SNL 的类型系统不复杂但正因为不复杂类型检查往往被写成“直接比较两个 type 字段相等就放行”。真做起来你会发现最常出问题的是 int 和 real 的混合。SNL 的规则是赋值时目标类型和源类型必须兼容兼容的定义是两个类型相同或者目标是 real 且源是 int。反过来int 变量接收 real 值是不允许的。/* type.c —— 类型兼容检查返回 0 表示不兼容 */ int type_ok(int dst, int src) { if (dst src) return 1; if (dst TY_REAL src TY_INT) return 1; /* int 向 real 提升 */ return 0; } /* 数组下标必须是整型表达式 */ int check_index(int idx_type) { if (idx_type ! TY_INT) { error(数组下标类型不是整型); return 0; } return 1; }type_ok 里只允许 int 向 real 单向提升这一点和大部分教学编译器一致。实现时要记住提升不是嘴上说“兼容”就完了要在四元式层面真正插入一条转换指令比如把 int 临时变量转成 real 临时变量等运行时真的做类型转换。否则后面计算结果按 real 存储却拿了一个 int 的内存解释就是血泪现场。数组下标的检查是另一个高频考点。编译期只能检查下标表达式类型是否整型无法检查下标值是否越界——因为下标往往在运行时才算得出来。课设一般把越界检查放在虚拟机里执行访问数组的四元式时比一下当前栈上的值是否落在声明范围里越界就报运行时错误。很多同学把越界检查写进编译期遇到变量下标就没办法只能放弃整个检查这是不对的。正确的分工是编译期查类型运行期查边界。4.3 中间代码生成与回填跳转指令的账本三地址码的四元式格式是这门课设计的核心数据结构一条指令四个槽位op 加上一个目标操作数和两个源操作数。比如 add r, t1, t2 表示 r t1 t2。跳转指令稍微特殊一点目标操作数是跳转地址而跳转地址在生成阶段往往还不知道——它指向的代码可能还没生成出来。这时候就需要回填。/* quad.c —— 四元式表与回填链表 */ typedef struct { char op[8]; int a, b, c; /* a 为目标/跳转地址b、c 为操作数 */ } Quad; Quad quads[MAX_QUAD]; int nquad 0; int emit(const char *op, int a, int b, int c) { if (nquad MAX_QUAD) fatal(四元式表溢出); int idx nquad; snprintf(quads[idx].op, sizeof(quads[idx].op), %s, op); quads[idx].a a; quads[idx].b b; quads[idx].c c; return idx; } /* 回填把链表上的所有跳转指令的目标地址改成 target */ void backpatch(int list, int target) { int idx list; while (idx ! -1) { int next quads[idx].a; /* a 字段暂时当链表指针用 */ quads[idx].a target; idx next; } } int merge_lists(int l1, int l2) { int p; if (l1 -1) return l2; p l1; while (quads[p].a ! -1) p quads[p].a; quads[p].a l2; return l1; }回填链表的技巧在于复用 a 字段。一条跳转四元式生成时a 字段还空着回填链把这个空位利用起来存放下一条同类跳转指令的序号形成一个单向链表真正目标确定后backpatch 沿着链表把所有 a 字段统一改成真实目标。merge_lists 在布尔表达式短路求值里很有用a and b 在 a 为假时直接短路需要把两处假分支的跳转链合到同一个回填目标上merge 一次就能把两条链串成一条。布尔表达式的处理是回填的重头戏。SNL 的 and 和 or 如果按普通算术表达式那样先求值再判断就丢掉了短路语义——a and b 在 a 为假时根本不该计算 b。常见做法是直接用跳转代码翻译布尔表达式遇到 and 生成一个条件假跳遇到 or 生成一个条件真跳各自的跳转目标通过回填解决。这里链表的开销是值得的因为一个 if 条件里往往嵌着多个 and/or目标地址要等整个表达式生成完才知道。四元式序号统一从 0 开始打印时再 1 显示别两套标准混用。5. 避坑SNL 编译器最容易翻车的 5 个具体位置5.1 词法层注释没闭合、连续的小于号被当成错符号现象程序从某个 { 之后的代码全部消失解析器报出一大堆“unexpected token”看着完全是语法错但语法函数其实一句都没错。原因skip_comment 没有处理嵌套注释的深度。SNL 的块注释允许嵌套时读到第一个 } 就误以为注释结束了此时内部其实还有未闭合的注释后面的代码全部被吞。解决skip_comment 里维护 depth 计数见到 { 加一见到 } 减一depth 归零才返回词法主循环。另一种常见词法翻车是小写字母比较连写a b c 在词法层报了奇怪的 token 错。原因状态机把连续两个 拼成了左移运算符 但你的 SNL 子集文法里根本没有移位运算符语法层接到一个不认识的 token。解决在 handle_angle 里只消费一个 或 如果下一个字符还是同样的符号用 src_ungetc 把它退回 token 流让后面重新开始识别。记住一个原则文法没定义的运算符词法层就不要自作多情去合并。5.2 语法层悬挂 else 的归属反复横跳现象支持嵌套 if 之后else 总跟期望的 if 配不上。典型场景是 if a then if b then x : 1 else y : 1你预期 else 归外层实际却看到 y : 1 只在 b 为假时执行。原因内层 if 的 parse 函数把 else 当作自己的子句消费掉了外层 if 永远等不到。解决按 3.3 的方式else 由最近的 if 消费内层 parse_stmt 的分派表里不含 else 分支token 留着等外层 if 来决定。这里真正要提醒的是别为了“统一”把 else 消费逻辑写进 parse_stmt 里看起来是抽取公共逻辑实际是制造归属混乱。按语义规则else 的归属权本来就属于 if 语句不属于通用语句层。每次改这里都要跑一遍嵌套 if 的测试用例这个用例应该写进你的回归集里。5.3 语义层过程前向引用怎么处理现象过程 A 调用了文件后面才声明的过程 B编译到调用处直接报“未声明过程 B”。原因符号表按出现顺序登记进入过程 A 时 B 还没进表。这种前向引用在递归下降直接边分析边生成的模式下很普遍。解决先登记后分析。在编译主体之前扫描声明区的过程名把每个过程名预先插入符号表kind 标 SYM_PROC地址先填 -1等到真正分析到那个过程的函数体时再用当前 nquad 把入口序号补上。这个“先登记后分析”的顺序会影响符号表代码的调用时机scope_push 和 scope_pop 要能容忍一个尚未填完整信息的过程符号。另外注意参数列表也要在预登记时占好位因为调用点要检查实参个数和类型检查时参数类型必须已经存在于符号表里。5.4 中间代码层四元式序号 1 基 0 基混用现象虚拟机运行时说“跳到指令 -1”或者跳转到程序开头然后进入死循环。原因几乎都是 0 基和 1 基混用。打印四元式给人看时习惯从 1 开始编号但内部 nquad 和 backpatch 用的是 0 基序号。有人在回填时不注意直接把打印序号当内部序号传进去了目标地址全都偏了一位。更隐蔽的版本是为了对齐打印内部也改成 1 基但循环边界判断没改导致四元式表最后一个槽位永远访问不到。解决源代码里统一用 0 基打印函数里做一次 1 转换。全局变量命名上把两者分开比如 internal_index 和 display_index靠命名提醒自己别混。这个坑一旦踩了排查很痛苦因为四元式看起来每条都对就是跳转目标都差一位。5.5 目标代码层生成 C 代码交给 GCC 报“未包含 main 类型”现象把 SNL 编译器生成的 C 代码拿到 GCC 下编译报出一串跟 main 有关的错误比如入口函数名对不上、返回类型不符合要求。原因SNL 的 program 后面的名字被原样搬到了 C 代码里作为函数名而 C 语言要求标准入口必须是 main另外 SNL 的过程如果声明成无返回类型生成 C 函数时可能写成了非 int 返回编译器就不认它是合法入口。解决目标代码生成时做一个规范化映射program 主过程强制映射成 int main(void)其他过程统一加前缀比如 snl_ 前缀避免和 C 标准库函数撞名。这个坑的排查顺序也有讲究先看入口符号再看返回类型最后才看参数列表。因为报错信息第一条往往就是链接器找不到符号把入口先对齐后面几类问题通常会跟着暴露出来。养成这个顺序以后遇到任何“目标语言入口错误”类问题都能少走弯路。提示中间代码序号从 0 计打印给用户看时再 1两处命名统一叫 internal_index 和 display_index能直接避开 5.4 那类问题。6. 验证与进阶用 dump 追指令流再做一版常量折叠6.1 一个够用的验证方法-d 参数打印全四元式流编译器这东西黑盒跑一遍只输出“程序结果不对”是没法调试的。我习惯给编译器加一个 dump 开关把中间代码全部打印出来人眼对一遍指令流问题出在语法层还是语义层立刻能看出来。void dump_quads(void) { for (int i 0; i nquad; i) { printf(%4d: %-5s %-8s %-8s %-8s\n, i 1, quads[i].op, operand_str(quads[i].a), operand_str(quads[i].b), operand_str(quads[i].c)); } }operand_str 负责把整型槽位换成可读文本常量显示成 #3 这种形式临时变量显示成 $t1符号表变量显示成原名字。验证路径我一般从最小程序开始先跑一个只含赋值的 SNL 程序盯着前几条四元式是否符合预期再逐步加 if、while、过程调用。每加一个语法点就 dump 一次四元式对得上再继续往下做。这套方法比在虚拟机里设断点高效得多因为四元式本身就能反映语法分析和语义动作是否忠实。6.2 值得做的小优化常量折叠如果课程要求里写了优化最值得先做的是常量折叠。比如 x : 2 3 这种表达式编译期就能直接算出 5不用生成加法指令也省掉运行时一次栈操作。在 emit 调用处拦一道即可。int try_fold(const char *op, int b, int c, int *out) { if (quads[b].op[0] # quads[c].op[0] #) { int vb quads[b].a, vc quads[c].a; if (strcmp(op, add) 0) *out vb vc; else if (strcmp(op, sub) 0) *out vb - vc; else if (strcmp(op, mul) 0) *out vb * vc; else if (strcmp(op, div) 0) { if (vc 0) return 0; /* 除零不要折叠留给运行时报错 */ *out vb / vc; } else return 0; return 1; } return 0; }折叠的边界条件必须想清楚两个操作数都必须是常量操作符必须是能安全提前计算的算术运算。除零是最大的陷阱——如果你把 x : 5 div 0 在编译期折叠成一个常量错误就提前到了编译期被忽略而语义上应该运行时报“除零错误”。所以 try_fold 里遇到除零直接返回 0让这条除法指令留在中间代码里由虚拟机去报告运行时错误。我自己做这个课设时养成的习惯是每改动一个语法或语义点就重置测试用例从头跑一遍 dump直到四元式和预期完全一致才提交代码。编译器的坑不在于难而在于一环扣一环的隐藏依赖——词法吞错一个字符语法层报的错能让你困惑一晚上。所以“先把能跑的最小版本做出来再往小版本上加东西”是我最想让你带走的一句话。希望帮到你。本文还有配套的精品资源点击获取
返回列表