简介:面向编译原理课程设计与自学场景,这套基于 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 -std=c99 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 调 term,term 调 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_factor:
Node *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/1 |
| JZ | 目标地址 | 栈顶为 0 时跳转 |
| JMP | 目标地址 | 无条件跳转 |
| 无 | 弹出栈顶并打印 | |
| 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 流不对:明明是 a=1,语法分析却说缺少等号
现象:词法分析结果看起来正确,但语法分析器一运行就抛“缺少 =”。打印出的 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 优先级玄学:a+b*c 总被算成 (a+b)*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-check=full ./minic test.mini,看到definitely lost: 0 bytes才算干净。
6. 让编译程序真正可用:验证方法、错误恢复与下一步扩展
很多小型编译程序写完后没有测试,整个程序只有一条主路径。我个人的习惯是“先固化最小测试集,再动任何新功能”,否则你永远不知道哪一次修改把减法优先级改坏了。
6.1 用批处理脚本代替肉眼验证
把几个源程序放到 test 目录,每个配一个预期输出,用 shell 脚本批量做 diff:
for f in test/*.mini; do name=$(basename "$f" .mini) ./minic "$f" > "out/$name.txt" 2>&1 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 加一个程序计数器栈和一个局部符号表栈,不要让函数指令和你手写的静态数组冲突。
这个方向值不值得做?我的判断是值得。即使你最后没有生成真正的可执行文件,亲手写一遍词法、语法、代码生成和运行时,编译器在你眼里就不再是黑匣子。我自己的习惯是每次改一点,跑一次最小测试,始终让编译程序保持“能跑”的状态。做小编译程序最大的失败不是功能没写完,而是写完的代码自己不敢改。希望帮到你。
本文还有配套的精品资源,点击获取