ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用C语言手写小型编译器:词法分析、递归下降与栈机代码生成实战

用C语言手写小型编译器:词法分析、递归下降与栈机代码生成实战 简介面向编译原理课程设计与自学场景这套基于 C 语言实现的小型编译程序源码包适合高校计算机专业学生、开发者及对编译器实现感兴趣者用作参考模板。项目以 C 与 C 混合源码完整覆盖词法分析、语法分析、语义检查与四元式中间代码生成等核心阶段代码结构清晰、注释到位并配有 README 讲解设计与运行要点能够帮助读者理解从源码到中间表示的编译流程为后续优化与代码生成奠定基础。压缩包共 4 个文件包含 .c 与 .cpp 源文件、说明文档及许可证整体大小仅 14KB体量小巧但覆盖完整。资源已获 287 人浏览学习具备直接借鉴价值。读者可借助随包文档快速定位关键函数参考四元式输出示例进行验证并在此基础上继续扩展优化与目标代码生成是完成课设或巩固编译原理知识的实用素材。1. 基于 C 语言的小型编译程序到底要拆成哪几块如果你拿到一个“基于 C 语言实现一个小型编译程序”的课题第一反应通常是把它当一个黑匣子源文件从左边进去结果从右边出来中间那句“编译”藏在教材第 300 页里。其实把这个项目拆到底它只做了两件事把文本变成树再把树变成指令。做这样一个小型编译程序最直接的价值在于把 C 语言里最磨人的三样东西——指针、结构体、内存管理——全部用一遍而且每一处都能对应到一个真实环节。下面是按词法分析、递归下降语法分析、栈机代码生成与运行时的顺序展开的完整落地过程。适合正在做编译原理课程设计或者学完 C 语言和数据结构后想找一块“能跑起来”的综合性练手题的人。2. 用 C 语言做词法分析Token结构、逐行读取与工程骨架编译程序的第一步永远是词法分析。它负责把源文件里的字符流切成一串有意义的 Token并丢掉空白和注释。这一步做不好后面语法分析报的错会特别难追所以必须先把工程结构和 Token 的定义钉死。2.1 先定一份足够小的源语言支持什么、不理会什么我一般会把目标语言定成“整型变量的赋值、算术表达式、条件分支和循环”不加函数、数组、字符串。小型编译程序的工程量不是功能堆料而是把每一条错误路径做干净。作为例子程序最终要能编译下面这段文本begin a 3; b a 5 * 2; if (b 10) print(1); while (a 4) a a 1; end这里有四个语句类型赋值、if、while、print。变量名只允许字母开头数字只支持十进制整数。为什么不用完整 C 语言语法因为目标是编译程序不是再造一个 GCC范围收得越小词法、语法和后端越容易看清边界。运算符优先级先按经典四级排优先级运算符说明最高* /乘除左结合第二 -加减左结合第三 !关系比较左结合最低赋值右结合右结合其实不用特别实现因为赋值语句的语法规则是从右边开始解析的。真正需要做左结合处理的是加减乘除具体做法在第三章的递归下降函数里会看到。2.2 五个 C 文件和一个 Makefile把一条流水线拆开编译器虽然小也不能写单个 main.c。常见做法是拆成五个源文件对应流水线的一个阶段CC gcc CFLAGS -Wall -g -stdc99 OBJS lexer.o parser.o codegen.o vm.o main.o minic: $(OBJS) $(CC) -o minic $(OBJS) lexer.o: lexer.c lexer.h token.h parser.o: parser.c parser.h ast.h lexer.h token.h codegen.o: codegen.c codegen.h ast.h vm.o: vm.c vm.h codegen.h clean: rm -f *.o minic逻辑说明lexer.o依赖词法分析的头文件parser.o同时依赖 Token 和 AST 定义因为语法分析要把 Token 变成 AST。codegen.o负责把 AST 变成指令数组vm.o负责执行指令数组。这样分文件编译的好处是改词法时不至于重新编译全部对于课设级别足够了。2.3 Token 结构体给 C 语言文本一个中间状态词法分析的核心产出是 Token。Token 类型用枚举定义后续语法分析用switch判断起来很方便typedef enum { TK_EOF, TK_BEGIN, TK_END, TK_IF, TK_ELSE, TK_WHILE, TK_PRINT, TK_INT, TK_IDENT, TK_ASSIGN, TK_PLUS, TK_MINUS, TK_STAR, TK_SLASH, TK_EQ, TK_NE, TK_LT, TK_GT, TK_LPAREN, TK_RPAREN, TK_SEMI } TokenType; typedef struct { TokenType type; int val; /* TK_INT 类型的整型字面量 */ char name[64]; /* TK_IDENT 类型的标识符原样 */ int line; /* 报错时定位行号 */ } Token;参数说明val只对整数常量有意义name只对标识符和关键字有意义line在错误提示里非常关键。语法分析器从next_token()拿到这个结构后基本不看原始字符了。2.4 用 fread 一次性读取源文件少踩换行处理的坑很多 C 语言初学者用fgets逐行扫描处理换行、注释和跨行字符串时会非常痛苦。我推荐直接把整个源文件读到内存再用游标扫描char *source; int src_len; int pos 0; int line 1; char *read_source(const char *path) { FILE *fp fopen(path, rb); if (!fp) { perror(path); exit(1); } fseek(fp, 0, SEEK_END); long len ftell(fp); fseek(fp, 0, SEEK_SET); char *buf (char *)malloc(len 2); if (!buf) { perror(malloc); exit(1); } fread(buf, 1, len, fp); fclose(fp); buf[len] \0; src_len (int)len; return buf; } int next_char(void) { while (pos src_len) { char c source[pos]; if (c \n) line; /* 处理 // 行注释 */ if (c / pos src_len source[pos] /) { while (pos src_len source[pos] ! \n) pos; continue; } return c; } return EOF; }逻辑说明read_source把整个文件放进堆内存最后补一个\0防止字符串函数越界。next_char每次返回一个有效字符遇到//就跳到行尾但不把\n消耗掉下一次调用会返回\n由外部isspace跳过同时line已经加过一次。这样行号统计最稳定比逐 fgets 处理\r\n省心得多。代码里的pos就是 C 语言指针式的游标只不过用下标写。如果整个文件很大这种一次读取的方式会多占内存但对小型编译程序完全不是瓶颈。2.5 next_token 完整实现关键字、数字、运算符一网打尽有了字符源词法分析器的主函数就顺了Token next_token(void) { Token t {0}; t.line line; int c; /* 跳过空白字符和注释 */ do { c next_char(); } while (c ! EOF isspace((unsigned char)c)); if (c EOF) { t.type TK_EOF; return t; } /* 标识符 / 关键字 */ if (isalpha((unsigned char)c)) { int len 0; t.name[len] (char)c; while (pos src_len (isalnum((unsigned char)source[pos]) || source[pos] _)) { t.name[len] source[pos]; } t.name[len] \0; if (strcmp(t.name, begin) 0) t.type TK_BEGIN; else if (strcmp(t.name, end) 0) t.type TK_END; else if (strcmp(t.name, if) 0) t.type TK_IF; else if (strcmp(t.name, else) 0) t.type TK_ELSE; else if (strcmp(t.name, while) 0) t.type TK_WHILE; else if (strcmp(t.name, print) 0) t.type TK_PRINT; else t.type TK_IDENT; return t; } /* 数字字面量 */ if (isdigit((unsigned char)c)) { int v 0; while (pos src_len isdigit((unsigned char)source[pos])) { v v * 10 (source[pos] - 0); pos; } t.val v; t.type TK_INT; return t; } /* 运算符与符号 */ switch (c) { case : t.type TK_PLUS; break; case -: t.type TK_MINUS; break; case *: t.type TK_STAR; break; case /: t.type TK_SLASH; break; case (: t.type TK_LPAREN; break; case ): t.type TK_RPAREN; break; case ;: t.type TK_SEMI; break; case : if (pos src_len source[pos] ) { pos; t.type TK_EQ; } else t.type TK_ASSIGN; break; case !: if (pos src_len source[pos] ) { pos; t.type TK_NE; } else error(第%d行! 后面必须跟 , line); break; case : t.type TK_LT; break; case : t.type TK_GT; break; default: error(第%d行无法识别字符 %c, line, (char)c); break; } return t; }逻辑说明next_token先跳过空白再根据首字符决定分支。标识符循环里isalnum允许 C 语言风格的字母、数字、下划线关键字不能放到别处判断否则begin也会变成普通标识符。数字累加时没有做溢出检查课设里默认输入都是合法的小整数如果要加v超过INT_MAX/10时就该报错。双字符运算符和!利用了pos已经越过首字符的位置直接看source[pos]是不是第二字符。这段代码里最隐蔽的坑是!单独出现我在该分支特意加了error避免后面语法分析报出一个莫名其妙的“期待等号”。error的实现用可变参数打印到 stderr 后直接退出对小项目足够#include stdarg.h void error(const char *fmt, ...) { va_list ap; va_start(ap, fmt); fprintf(stderr, 错误: ); vfprintf(stderr, fmt, ap); va_end(ap); fprintf(stderr, \n); exit(1); }到这里词法分析器已经可以输出一长串 Token。调试阶段我会加一个dump_tokens()把每个 Token 的 type、name、val、line 全部打印出来。这一步能过滤掉至少一半的玄学问题。3. 递归下降语法分析用 C 语言结构体把语句变成 AST词法分析完成后语法分析接手。常见做法是递归下降每个语法规则对应一个 C 函数返回值是 AST 节点指针。这一章的关键不只是“会解析”而是让你看到优先级和控制流是怎么嵌进树里的。3.1 AST 节点结构用 C 语言结构体表达一棵语法树语法分析不能只判断“合法不合法”还得保留结构供后端使用。我定义如下节点typedef enum { ND_INT, ND_IDENT, ND_ASSIGN, ND_ADD, ND_SUB, ND_MUL, ND_DIV, ND_LT, ND_GT, ND_EQ, ND_NE, ND_IF, ND_WHILE, ND_PRINT } NodeKind; typedef struct Node { NodeKind kind; int val; /* ND_INT 的值 */ int slot; /* 变量在符号表中的槽位 */ char name[64]; /* 调试用变量名、运算名 */ struct Node *lhs, *rhs; /* 算术 / 比较节点 */ struct Node *cond; /* if / while 条件 */ struct Node *then_body; struct Node *else_body; struct Node *next; /* 语句链表 */ } Node;逻辑说明kind是节点类型slot在第四章会被指令直接引用。把变量映射成整数槽位而不是字符名可以让代码生成阶段少做字符串比较。name是为调试打印 AST 时保留的冗余字段如果你嫌占内存也可以去掉。所有 AST 节点都用calloc分配避免手滑漏初始化Node *new_node(NodeKind k) { Node *n (Node *)calloc(1, sizeof(Node)); if (!n) { perror(calloc); exit(1); } n-kind k; return n; }用calloc而不是malloc是 C 语言指针内存管理里一个重要习惯新节点全部是 0 值之后只需要给非零字段赋值。否则一个忘了初始化的cond指针会在代码生成时让你半夜找空指针。3.2 EBNF 与解析函数的一一对应优先级靠调用层级保证小型编译程序的语法可以用下面几条规则描述program : begin statement* end statement : assign | if | while | print assign : ident expr ; if : if ( expr ) statement (else statement)? while : while ( expr ) statement print : print ( expr ) ; expr : term (( | -) term)* term : factor ((* | /) factor)* factor : int | ident | ( expr )递归下降最关键的地方是每一条规则在 C 代码里就是同名的函数。expr 调 termterm 调 factor所以乘除永远比加减先完成求值。只把这个调用顺序写反整棵树的优先级就反了后面第五章会详细讲。expr的实现Node *parse_expr(void) { Node *node parse_term(); while (tok.type TK_PLUS || tok.type TK_MINUS) { NodeKind k (tok.type TK_PLUS) ? ND_ADD : ND_SUB; next_token(); Node *rhs parse_term(); Node *n new_node(k); n-lhs node; n-rhs rhs; n-name (k ND_ADD) ? : -; node n; } return node; }逻辑说明这里没有用左递归expr : expr term因为 C 语言递归下降无法直接处理左递归会无限调用自己。用while循环收集同一级运算符生成左结合树是消除左递归的最简做法。加号减号的树高度由迭代次数决定不会爆栈。parse_term和parse_expr结构完全一致只是把循环换成了TK_STAR和TK_SLASH。真正有分支的是parse_factorNode *parse_factor(void) { if (tok.type TK_INT) { Node *n new_node(ND_INT); n-val tok.val; next_token(); return n; } if (tok.type TK_IDENT) { Node *n new_node(ND_IDENT); n-slot find_or_add_slot(tok.name); strncpy(n-name, tok.name, 63); next_token(); return n; } if (tok.type TK_LPAREN) { next_token(); Node *n parse_expr(); if (tok.type ! TK_RPAREN) error(第%d行缺少右括号, tok.line); next_token(); return n; } error(第%d行表达式开头不合法, tok.line); return NULL; }参数说明整数走ND_INT标识符走ND_IDENT括号则递归进入parse_expr。find_or_add_slot负责变量名到槽位的映射这个名字之后的代码生成和指令执行都会用到。3.3 语句解析if/while 如何构造条件分支树语句部分用parse_statement做总入口Node *parse_statement(void) { switch (tok.type) { case TK_IDENT: return parse_assign(); case TK_IF: return parse_if(); case TK_WHILE: return parse_while(); case TK_PRINT: return parse_print(); default: error(第%d行语句开头不合法, tok.line); return NULL; } } Node *parse_assign(void) { Node *n new_node(ND_ASSIGN); n-slot find_or_add_slot(tok.name); strncpy(n-name, tok.name, 63); next_token(); /* 读赋值号 */ if (tok.type ! TK_ASSIGN) error(第%d行赋值语句缺少 , tok.line); next_token(); n-rhs parse_expr(); if (tok.type ! TK_SEMI) error(第%d行赋值语句缺少分号, tok.line); next_token(); return n; }逻辑说明parse_assign先把左手变量登记成槽位再解析右边表达式最后强制吃掉分号。这里的find_or_add_slot有自己的内部符号表如果变量之前出现过就返回旧槽位没出现过就分配新槽位。这样符合同一个变量在整个程序里共享存储的预期。parse_if和parse_while更有意思Node *parse_if(void) { next_token(); /* 吃掉 if */ if (tok.type ! TK_LPAREN) error(if 后面缺少左括号); next_token(); Node *n new_node(ND_IF); n-cond parse_expr(); if (tok.type ! TK_RPAREN) error(if 条件缺少右括号); next_token(); n-then_body parse_statement(); if (tok.type TK_ELSE) { next_token(); n-else_body parse_statement(); } return n; } Node *parse_while(void) { next_token(); /* 吃掉 while */ if (tok.type ! TK_LPAREN) error(while 后面缺少左括号); next_token(); Node *n new_node(ND_WHILE); n-cond parse_expr(); if (tok.type ! TK_RPAREN) error(while 条件缺少右括号); next_token(); n-then_body parse_statement(); return n; }这里没有强制while后面带分号因为while的循环体本身是一个完整语句如果循环体是赋值语句parse_statement会吃掉它自己的分号。这种“语句嵌套语句”的写法正好体现递归下降的结构化特点。3.4 符号表把 C 语言变量名变成栈机内存槽前面反复出现的find_or_add_slot需要一个符号表实现。最朴素的做法是单向链表typedef struct Sym { char name[64]; int slot; struct Sym *next; } Sym; Sym *sym_list NULL; int next_slot 0; int find_or_add_slot(const char *name) { for (Sym *s sym_list; s; s s-next) { if (strcmp(s-name, name) 0) return s-slot; } Sym *s (Sym *)calloc(1, sizeof(Sym)); strncpy(s-name, name, 63); s-slot next_slot; s-next sym_list; sym_list s; return s-slot; }逻辑说明查找顺序是从链表头部开始比较找到就返回旧 slot否则新建节点插入头部。插入头部比尾部简单而且变量访问顺序不影响语义。缺点是没有作用域隔离变量无论在哪一层 if 里声明都是全局可见。课设阶段可接受等以后要加{}块作用域时再把Sym *换成Sym **scope_stack压栈出栈即可。还可以补一句话获取变量名调试时打印 AST 会方便。符号表不负责类型检查小型编译程序通常默认变量全是整型所以没有类型字段。4. 栈机指令生成与运行时AST 到可执行代码的最短路径语法分析已经得到一棵 AST下一步是“代码生成”。很多人一听到生成代码就想到 x86 汇编、寄存器分配这其实是真正的坑。小型编译程序最可靠的落地路径是生成一套栈式虚拟机指令再用自己的 C 语言解释器跑起来。4.1 为什么是栈机而不是 x86 汇编栈机指令的最大优点是生成和运行都极其简单运算指令只从操作数栈顶上取两个值运算结果再压回去不需要操心寄存器冲突。条件跳转处理起来也比汇编容易因为无需处理实际硬件上的标志位。整个小型目标机只需要 12 条指令指令操作数作用PUSH_INT立即数压入整数常量PUSH_VAR槽位从变量槽压入值STORE槽位弹出栈顶写入变量槽ADD / SUB / MUL / DIV无弹出两值计算后压回LT / GT / EQ / NE无弹出两值比较后压回 0/1JZ目标地址栈顶为 0 时跳转JMP目标地址无条件跳转PRINT无弹出栈顶并打印HALT无停止执行指令结构体只需要两个字段typedef struct { int op; /* OP_ 开头的常量 */ int operand; /* 立即数 / 槽位 / 跳转地址 */ } Instr; Instr code[MAX_CODE]; int code_len 0; void emit(int op, int operand) { if (code_len MAX_CODE) error(代码段溢出); code[code_len].op op; code[code_len].operand operand; code_len; }MAX_CODE可以设成 4096小型编译程序的中间代码不会超过这个量。4.2 从 AST 生成栈机代码gen_ast 的递归翻译代码生成的核心函数是对 AST 做后序遍历先处理子节点再把结果组装成指令。算术表达式的翻译规则非常直观void gen_ast(Node *n) { if (!n) return; switch (n-kind) { case ND_INT: emit(OP_PUSH_INT, n-val); break; case ND_IDENT: emit(OP_PUSH_VAR, n-slot); break; case ND_ADD: gen_ast(n-lhs); gen_ast(n-rhs); emit(OP_ADD, 0); break; case ND_SUB: gen_ast(n-lhs); gen_ast(n-rhs); emit(OP_SUB, 0); break; case ND_MUL: gen_ast(n-lhs); gen_ast(n-rhs); emit(OP_MUL, 0); break; case ND_DIV: gen_ast(n-lhs); gen_ast(n-rhs); emit(OP_DIV, 0); break; case ND_ASSIGN: gen_ast(n-rhs); emit(OP_STORE, n-slot); break; default: /* 控制流节点单独处理 */ break; } }逻辑说明以a b * 2为例AST 是a (b * 2)。gen_ast先递归生成左边a再递归生成右边b * 2此时指令序列是 PUSH_VAR a、PUSH_VAR b、PUSH_INT 2、MUL最后才 emit ADD。加法执行时栈里正好先弹出乘法的结果再弹出 a不会出错。STORE指令把栈顶值弹出写入槽位完成赋值。这段代码里常量节点直接压栈变量节点压槽位值运算节点先子后父是理解栈机代码生成的最佳套路。4.3 控制流回填跳转指令的“后悔药”if 和 while 是代码生成里最容易错的一处。难点在于条件成立时跳哪儿不成立时跳哪儿当时还不知道目标指令的地址。常见做法是先 emit 一个占位跳转等后续指令生成完再把地址回填进去这就是编译器里的“后悔药”技巧。void gen_if(Node *n) { gen_ast(n-cond); int jz_pos emit(OP_JZ, 0); /* 先填 0 */ gen_ast(n-then_body); if (n-else_body) { int jmp_pos emit(OP_JMP, 0); code[jz_pos].operand code_len; /* 条件不成立跳过 then进入 else */ gen_ast(n-else_body); code[jmp_pos].operand code_len; /* then 结束后跳过 else */ } else { code[jz_pos].operand code_len; /* 条件不成立直接到末尾 */ } } void gen_while(Node *n) { int loop_start code_len; /* 保存循环条件起点 */ gen_ast(n-cond); int jz_pos emit(OP_JZ, 0); gen_ast(n-then_body); emit(OP_JMP, loop_start); /* 回到条件重新判断 */ code[jz_pos].operand code_len; /* 条件为假跳出循环 */ }参数说明code_len永远指向“下一条指令的位置”所以code[jz_pos].operand code_len就是在给 JZ 指令写跳转目标。loop_start在生成循环体前后不变JMP 可以准确跳回。回填是理解编译器的分水岭第一次做时你会在第五章的 while 死循环里深刻领会它。4.4 虚拟机运行循环C 语言数组模拟一颗 CPU生成完的指令数组交给一个简洁的 VM 解释执行。#define STACK_DEPTH 1024 #define MAX_SLOTS 256 void vm_run(void) { int pc 0; int sp 0; int stack[STACK_DEPTH] {0}; int slots[MAX_SLOTS] {0}; for (;;) { Instr *ir code[pc]; int a, b; switch (ir-op) { case OP_PUSH_INT: stack[sp] ir-operand; pc; break; case OP_PUSH_VAR: stack[sp] slots[ir-operand]; pc; break; case OP_STORE: slots[ir-operand] stack[sp--]; pc; break; case OP_ADD: b stack[sp--]; a stack[sp--]; stack[sp] a b; pc; break; case OP_MUL: b stack[sp--]; a stack[sp--]; stack[sp] a * b; pc; break; case OP_PRINT: printf(%d\n, stack[sp--]); pc; break; case OP_JMP: pc ir-operand; break; case OP_JZ: a stack[sp--]; pc (a 0) ? ir-operand : pc 1; break; case OP_HALT: return; default: error(未知指令 %d, ir-op); } } }逻辑说明pc是指令指针sp是栈顶索引slots是一段连续的变量内存。每条指令执行完要么pc要么跳到operand。除法没有除零保护课设可以加一句if (b 0) error(除零)。sp也缺上界检查如果程序生成了一个无限压栈的表达式会直接越界加一个if (sp STACK_DEPTH) error(栈溢出)能少很多调试时间。VM 跑完HALT后返回整个过程不需要真实汇编、不需要链接器但在“编译程序”这个命题下该有的编译管线全都有了。5. 常见问题与排查小型编译程序最容易翻车的五个点下面五个问题基本是每个 C 语言版编译程序都会撞上的坑。每一条按现象、原因、解决展开照着检查比从头看代码更快。5.1 Token 流不对明明是 a1语法分析却说缺少等号现象词法分析结果看起来正确但语法分析器一运行就抛“缺少 ”。打印出的 Token 序列里a后面的变成了TK_EQ。原因next_token里对的双字符判断写错位置。刚才的 switch 先看source[pos] 如果这里误用了source[pos-1]或者忘记pos单赋值号会被识别成比较相等。解决在 parser 启动前加一个dump_tokens()void dump_tokens(void) { Token t; while ((t next_token()).type ! TK_EOF) { printf(line %d: type%d name%s val%d\n, t.line, t.type, t.name, t.val); } /* 重置 pos 和 line再交给语法分析器 */ pos 0; line 1; next_token(); }这是最直接的血泪经验永远不要直接对 Parser 猜问题先看词法输出。5.2 优先级玄学ab*c 总被算成 (ab)*c现象表达式a b * c输出错误明显是先做了加法再做乘法。原因parse_expr里调了parse_term但parse_term内部不小心调用了parse_expr或者把parse_factor的层级写反了树的根节点变成了加法而不是乘法。解决写一个 AST 打印函数按缩进输出节点类型void dump_ast(Node *n, int depth) { if (!n) return; for (int i 0; i depth; i) printf( ); printf(%s, node_name(n-kind)); if (n-kind ND_INT) printf( %d, n-val); if (n-kind ND_IDENT) printf( %s, n-name); printf(\n); dump_ast(n-lhs, depth 1); dump_ast(n-rhs, depth 1); dump_ast(n-cond, depth 1); dump_ast(n-then_body, depth 1); dump_ast(n-else_body, depth 1); dump_ast(n-next, depth); }调用它看一眼树结构问题立刻现形。只要根节点是*乘法就在树里更低处参与运算如果成了根肯定函数层级错了。5.3 一执行就 Segfault或者 while 循环死转现象编译程序语法分析通过但vm_run一跑直接崩或者while永远不结束。原因Segment fault 多半是 AST 节点有未初始化指针gen_ast递归到空不处理calloc清零能挡住一大部分。while 死循环则是回填地址出错比如JZ的目标写成了0每轮都重新执行条件永远到不了HALT。解决先用 gdb 看崩溃点再用下面这段打印指令流void dump_code(void) { for (int i 0; i code_len; i) { printf(%d: %s %d\n, i, op_name(code[i].op), code[i].operand); } }对照预期跳转位置核对每个 JZ/JMP。回填代码里有一个很容易被忽略的点emit之后code_len会变所以存放跳转位置的下标要单独用一个变量保存不能每次都取code_len-1。我用int jz_pos emit(OP_JZ, 0);正是这个原因。5.4 分号处理不一致if 后面的赋值语句总是吞掉分号现象if (b 10) print(1);能过换成if (b 10) a 1;就在下一个语句处报错。原因if的循环体由parse_statement处理而赋值语句自己在结尾吃分号。如果你的parse_if在解析完then_body后又额外调用expect(TK_SEMI)遇到 print 这类本身没有分号的语句就会出错遇到赋值语句则多吞一个下一个语句的分号。解决记住一个原则每个语句的分号由该语句自身的解析函数负责控制流的else跳到下一个 statement 之前不要自行消费分号。判断语句有没有分号看parse_assign尾部而不是parse_if尾部。5.5 内存泄漏每个 AST 节点都是 malloc但没人回收现象在循环里反复调用编译程序内存占用持续上涨用valgrind一查全是new_node泄漏。原因new_node里 calloc代码生成后没有任何函数释放 AST。课设一次性退出操作系统会回收但如果你把编译程序做成一个被调用的函数泄漏就不可忽视了。解决写一个释放函数按递归结构回收void free_ast(Node *n) { if (!n) return; free_ast(n-lhs); free_ast(n-rhs); free_ast(n-cond); free_ast(n-then_body); free_ast(n-else_body); free_ast(n-next); free(n); }符号表链表也要释放。释放顺序从叶到根否则先释放根就找不到子节点了。验证方法是valgrind --leak-checkfull ./minic test.mini看到definitely lost: 0 bytes才算干净。6. 让编译程序真正可用验证方法、错误恢复与下一步扩展很多小型编译程序写完后没有测试整个程序只有一条主路径。我个人的习惯是“先固化最小测试集再动任何新功能”否则你永远不知道哪一次修改把减法优先级改坏了。6.1 用批处理脚本代替肉眼验证把几个源程序放到 test 目录每个配一个预期输出用 shell 脚本批量做 difffor f in test/*.mini; do name$(basename $f .mini) ./minic $f out/$name.txt 21 if diff -q out/$name.txt test/$name.expect /dev/null; then echo $name: PASS else echo $name: FAIL diff out/$name.txt test/$name.expect fi done这条命令的价值在于每次改动后跑一次能立刻发现回归。测试用例从最小开始单个常量输出、单个变量赋值、加减乘除、括号嵌套、if 的 then/else、while 循环、循环内赋值。我自己的项目里最常翻车的是“while 循环体里变量自增后打印”这类用例必须保留。6.2 错误恢复别一碰错就退出error函数现在直接exit(1)这在课设演示没问题但足以让新手失去主动权。进阶做法是把错误收集到一个数组里让语法分析器尽量恢复int err_count 0; void record_error(int line, const char *msg) { printf(第%d行: %s\n, line, msg); err_count; }在expect失败时可以跳过当前语句到最近的分号继续解析下一条。这样能一次报出多个错误而不是只报第一个。注意不要让错误恢复改变next_token的状态宁可报错多也不要误报导致连锁崩溃。6.3 下一步功能函数调用需要一张调用栈如果你已经能跑通上述例子最自然的扩展是加函数。函数会在某两个小地方推翻现有设计第一符号表需要分离局部变量和全局变量第二栈机需要为函数调用保存调用方的pc和变量槽现场。常见做法是另加CALL、RET指令把slots改成一个二维矩阵按函数帧索引。我不想把这篇笔记写成编译原理教材只提醒一句加函数前先给现有 VM 加一个程序计数器栈和一个局部符号表栈不要让函数指令和你手写的静态数组冲突。这个方向值不值得做我的判断是值得。即使你最后没有生成真正的可执行文件亲手写一遍词法、语法、代码生成和运行时编译器在你眼里就不再是黑匣子。我自己的习惯是每次改一点跑一次最小测试始终让编译程序保持“能跑”的状态。做小编译程序最大的失败不是功能没写完而是写完的代码自己不敢改。希望帮到你。本文还有配套的精品资源点击获取
返回列表