简介:这是一份面向计算机专业学生与编译原理课程设计学习者的C语言编译器完整实现方案,围绕词法分析、语法分析、中间代码生成与优化、目标代码生成等核心环节展开,适合正在完成课程设计或希望深入理解编译流程的读者参考。压缩包共54个文件,约5.1MB,包含cpp与h源码、c与l词法文件、y语法文件、obj与pdb编译产物、asm汇编码、py脚本及txt说明文档等,覆盖从源码到可执行程序的完整工程结构。项目借助lex与yacc完成词法分析与语法分析并生成语法树,使用C++解析语法树、生成中间代码并实现错误检测与优化,再通过Python处理中间代码生成MIPS汇编码,最终可在PCSpim模拟器上运行。目前已有505人学习下载,读者可从中获取编译器各阶段的实现思路、模块划分方式与调试排错经验,适合作为课程设计参考与编译原理实践素材。
1. 从「基于C语言编译器」说起:一个被低估的硬核练手方向
很多人第一次看到「基于C语言编译器」这个说法,会下意识以为是要用 C 语言去写一个编译器,或者干脆把它和「C语言编译器」这个工具混为一谈——毕竟日常里我们说的「装个 C 语言编译器」,指的是 gcc、clang、MSVC 这类现成的工具链。但真正让这个方向有价值的,是另一层意思:把编译器当成一个可以拆开、可以自己动手重建的系统,用 C 语言作为实现语言,从词法、语法一路做到能跑出目标代码。这件事听起来离业务很远,可它恰恰是少数几个能同时训练数据结构、内存管理、递归下降、栈式机模型和工程调试的题目,做完一遍,你对「代码到底怎么变成机器能跑的东西」会从玄学变成可解释的流程。
这篇文章面向的是想真正动手写一个能跑的小型编译器、又不想一上来就被 LLVM 那种体量劝退的从业者。我会按「先立住理论、再动手复现」的顺序,把词法分析、语法分析、语义检查、三地址码生成、栈式虚拟机执行这条链路拆开讲,每一步都给可抄的代码骨架和参数说明。读完你应该能自己写出一个支持变量声明、算术表达式、if/while 和函数调用的最小编译器,并且知道每一步最容易在哪里翻车。
2. 编译器的四段流水线:为什么我坚持手写而不是直接上工具
2.1 词法、语法、语义、代码生成各自负责什么
一个能跑的最小编译器,本质上是把源字符串逐层降级成可执行结构。第一层是词法分析,把int a = 1 + 2;切成int、a、=、1、+、2、;这样的 token 序列,同时丢掉空格和注释。第二层是语法分析,把 token 序列按文法组织成抽象语法树(AST),比如把1 + 2变成一个BinaryExpr(+, 1, 2)节点。第三层是语义检查,确认变量先声明后使用、类型匹配、函数参数个数对得上。第四层是代码生成,把 AST 翻译成三地址码或栈式指令,交给虚拟机执行。
这四层之所以要分开,是因为每一层的输入输出边界清晰,调试时能单独定位。我见过太多人把词法和语法揉在一个函数里,结果遇到一个括号不匹配的报错,根本分不清是切词切错了还是文法写错了。分开之后,词法层只对字符负责,语法层只对 token 负责,语义层只对 AST 负责,出问题时逐层打印中间结果,定位速度差一个数量级。
选 C 语言来实现,理由也很实际:编译器本身要频繁操作指针、链表、动态数组和递归,C 能让你直接看到内存布局,写错了段错误会立刻教你做人。用高级语言写当然更快,但你会错过「AST 节点怎么分配、怎么释放、递归深度多大会爆栈」这些真正长本事的细节。
2.2 用 C 写词法分析器:状态机与 token 结构
词法分析器的核心是一个状态机,读一个字符、决定下一步状态、必要时回退。下面是一个能处理标识符、整数、运算符和分隔符的最小实现骨架。
#include <ctype.h> #include <stdio.h> #include <stdlib.h> #include <string.h> typedef enum { TOK_INT, TOK_IDENT, TOK_NUM, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_ASSIGN, TOK_SEMI, TOK_LPAREN, TOK_RPAREN, TOK_LBRACE, TOK_RBRACE, TOK_EOF, TOK_UNKNOWN } TokenType; typedef struct { TokenType type; char text[64]; // 标识符或数字的字面量 int value; // 数字 token 的整数值 int line; // 行号,报错时定位用 } Token; static const char *src; // 当前扫描位置 static int cur_line = 1; // 跳过空白,同时维护行号 static void skip_ws(void) { while (*src == ' ' || *src == '\t' || *src == '\n') { if (*src == '\n') cur_line++; src++; } } Token next_token(void) { skip_ws(); Token t = {0}; t.line = cur_line; if (*src == '\0') { t.type = TOK_EOF; return t; } if (isalpha(*src) || *src == '_') { int n = 0; while (isalnum(*src) || *src == '_') t.text[n++] = *src++; t.text[n] = '\0'; t.type = (strcmp(t.text, "int") == 0) ? TOK_INT : TOK_IDENT; return t; } if (isdigit(*src)) { int v = 0; while (isdigit(*src)) v = v * 10 + (*src++ - '0'); t.type = TOK_NUM; t.value = v; return t; } switch (*src++) { case '+': t.type = TOK_PLUS; break; case '-': t.type = TOK_MINUS; break; case '*': t.type = TOK_STAR; break; case '/': t.type = TOK_SLASH; break; case '=': t.type = TOK_ASSIGN; break; case ';': t.type = TOK_SEMI; break; case '(': t.type = TOK_LPAREN; break; case ')': t.type = TOK_RPAREN; break; case '{': t.type = TOK_LBRACE; break; case '}': t.type = TOK_RBRACE; break; default: t.type = TOK_UNKNOWN; break; } return t; }这段代码里几个参数值得说清楚。text[64]是标识符缓冲,实际项目里应该改成动态分配或加大到 256,否则遇到长变量名会截断。value只在数字 token 里有效,其他类型不读它。line是给报错用的,没有行号的编译器在调试时基本等于黑匣子。skip_ws里对\n单独计数,是因为后面语法报错要精确到行。
一个常见误区是把关键字识别放在语法层。关键字本质上是「长得像标识符但被保留的词」,在词法层用strcmp一次性判定,语法层就只需要处理TOK_INT这种明确类型,逻辑干净很多。另一个坑是数字溢出,上面用int累加,输入99999999999会静默溢出,生产级实现要加范围检查或改用long long。
2.3 递归下降语法分析:把 token 流变成 AST
语法分析我用递归下降,因为它的代码结构和文法几乎一一对应,新手最容易看懂。先定义 AST 节点类型。
typedef enum { NODE_NUM, NODE_VAR, NODE_BINOP, NODE_ASSIGN, NODE_IF, NODE_WHILE, NODE_BLOCK, NODE_DECL } NodeKind; typedef struct Node { NodeKind kind; int op; // 运算符:'+' '-' '*' '/' int value; // NODE_NUM 的数值 char name[64]; // NODE_VAR / NODE_DECL 的变量名 struct Node *left; struct Node *right; struct Node *cond; // if / while 的条件 struct Node *body; // if / while 的循环体 struct Node *next; // 语句链表的下一条 } Node; static Token cur; // 当前前瞻 token static void advance(void) { cur = next_token(); } static Node *new_node(NodeKind k) { Node *n = calloc(1, sizeof(Node)); n->kind = k; return n; } // 表达式:处理加减,左结合 static Node *parse_expr(void); static Node *parse_primary(void) { if (cur.type == TOK_NUM) { Node *n = new_node(NODE_NUM); n->value = cur.value; advance(); return n; } if (cur.type == TOK_IDENT) { Node *n = new_node(NODE_VAR); strcpy(n->name, cur.text); advance(); return n; } if (cur.type == TOK_LPAREN) { advance(); Node *n = parse_expr(); if (cur.type != TOK_RPAREN) { fprintf(stderr, "line %d: expected ')'\n", cur.line); exit(1); } advance(); return n; } fprintf(stderr, "line %d: unexpected token\n", cur.line); exit(1); } static Node *parse_term(void) { Node *left = parse_primary(); while (cur.type == TOK_STAR || cur.type == TOK_SLASH) { Node *n = new_node(NODE_BINOP); n->op = (cur.type == TOK_STAR) ? '*' : '/'; advance(); n->left = left; n->right = parse_primary(); left = n; } return left; } static Node *parse_expr(void) { Node *left = parse_term(); while (cur.type == TOK_PLUS || cur.type == TOK_MINUS) { Node *n = new_node(NODE_BINOP); n->op = (cur.type == TOK_PLUS) ? '+' : '-'; advance(); n->left = left; n->right = parse_term(); left = n; } return left; }这里的关键设计是parse_expr和parse_term分层,保证乘除优先级高于加减。cur是全局前瞻 token,advance每次向前读一个。parse_primary处理括号时递归调用parse_expr,这样(1+2)*3能正确解析成先加后乘。
参数上要注意op用字符表示运算符,简单直观,但如果以后要支持==、<=这种多字符运算符,就得改成枚举。name[64]同样有截断风险。递归下降最大的坑是左递归文法,比如把表达式写成expr -> expr + term,直接翻译成代码会无限递归,必须改写成循环形式,上面while循环就是标准解法。
3. 从 AST 到可执行:三地址码生成与栈式虚拟机
3.1 三地址码为什么比直接生成汇编更适合练手
AST 到机器码之间,我强烈建议插一层三地址码(TAC)。三地址码的形式是t1 = a + b,每条指令最多一个运算符、三个操作数。它的好处是:结构规整,方便做常量折叠、公共子表达式消除这类优化;和具体 CPU 解耦,你生成的 TAC 可以喂给任何后端;调试时打印出来一目了然,比看汇编舒服得多。
生成 TAC 的过程就是后序遍历 AST。遇到NODE_BINOP,先递归生成左右子树的 TAC,拿到两个临时变量名,再发一条新指令。临时变量用一个自增计数器命名,t0、t1、t2这样。
typedef struct { char op; // 运算符,0 表示赋值 char dst[16]; // 目标 char src1[16]; // 左操作数 char src2[16]; // 右操作数 } TAC; static TAC code[4096]; static int code_len = 0; static int tmp_cnt = 0; static void emit(char op, const char *dst, const char *s1, const char *s2) { TAC *t = &code[code_len++]; t->op = op; strncpy(t->dst, dst, 15); strncpy(t->src1, s1, 15); strncpy(t->src2, s2, 15); } // 返回该子树结果所在的临时变量名 static void gen_expr(Node *n, char *out) { if (n->kind == NODE_NUM) { sprintf(out, "%d", n->value); return; } if (n->kind == NODE_VAR) { strcpy(out, n->name); return; } if (n->kind == NODE_BINOP) { char l[16], r[16]; gen_expr(n->left, l); gen_expr(n->right, r); sprintf(out, "t%d", tmp_cnt++); emit(n->op, out, l, r); return; } }code[4096]是固定上限,练手够用,真实编译器要用动态数组。tmp_cnt全局自增保证临时变量不重名。gen_expr把结果写进out参数,调用方负责提供缓冲区,这种「输出参数」风格在 C 里很常见,但要小心缓冲区大小,sprintf写t%d最多几个字符,char[16]足够。
一个容易忽略的点是常量折叠。1 + 2其实可以在生成阶段直接算成3,省一条指令。做法是在NODE_BINOP里判断左右是否都是NODE_NUM,是就直接算。这个优化不加也能跑,但加上之后你能直观看到 TAC 条数减少,是理解「优化到底在优化什么」的最好入口。
3.2 栈式虚拟机:二十行代码跑起你的第一个程序
有了 TAC,执行引擎可以做得极简。栈式虚拟机的模型是:所有操作数从栈顶取,结果压回栈顶。变量存在一个数组里,用名字映射到下标。
#define STACK_MAX 1024 #define VAR_MAX 256 static int stack[STACK_MAX]; static int sp = 0; static int vars[VAR_MAX]; static char var_names[VAR_MAX][64]; static int var_cnt = 0; static int var_slot(const char *name) { for (int i = 0; i < var_cnt; i++) if (strcmp(var_names[i], name) == 0) return i; strcpy(var_names[var_cnt], name); return var_cnt++; } static int resolve(const char *s) { if (s[0] == 't' && isdigit(s[1])) return atoi(s + 1) + 1000; // 临时变量区 if (isdigit(s[0]) || (s[0] == '-' && isdigit(s[1]))) return atoi(s); return var_slot(s); } static void run(void) { for (int i = 0; i < code_len; i++) { TAC *t = &code[i]; int a = resolve(t->src1); int b = resolve(t->src2); int d = resolve(t->dst); int va = (a >= 1000) ? stack[a - 1000] : (a < 0 ? a : vars[a]); int vb = (b >= 1000) ? stack[b - 1000] : (b < 0 ? b : vars[b]); int r = 0; switch (t->op) { case '+': r = va + vb; break; case '-': r = va - vb; break; case '*': r = va * vb; break; case '/': r = vb ? va / vb : 0; break; case 0: r = va; break; // 赋值 } if (d >= 1000) stack[d - 1000] = r; else vars[d] = r; } }resolve把操作数字符串映射成存储位置:临时变量映射到stack的 1000 号以上区间,普通变量映射到vars数组,数字字面量直接返回负值表示立即数。run顺序执行每条 TAC,按op分派运算。
这里有个设计取舍:临时变量和普通变量分开存储,是为了避免临时变量污染变量表。真实实现里更常见的做法是统一用栈帧,函数调用时压栈、返回时弹栈。上面这个简化版不支持函数,但足够跑通int a = 1 + 2 * 3;这类程序。除零我直接返回 0,生产环境应该抛运行时错误。
4. 避坑与排查:手写编译器最容易翻车的五个地方
4.1 段错误:AST 节点没初始化就访问
现象:程序在解析阶段随机崩溃,gdb 回溯指向parse_primary或gen_expr。原因:new_node用了calloc还好,但如果换成malloc,left、right、next全是野指针,递归访问时直接段错误。解决:统一用calloc,或者malloc之后手动把指针字段置 NULL。我一般会在new_node里加一句memset,省得后面到处补。
4.2 无限递归:文法写成左递归
现象:解析1+2+3时栈溢出,程序卡死。原因:把表达式文法直接写成expr -> expr + term,递归下降遇到左递归会无限调用自己。解决:改写成循环,parse_expr里先解析一个term,然后while吃掉后续的+/-。这是递归下降的经典约束,写文法时就要避开左递归。
4.3 临时变量重名:计数器作用域搞错
现象:嵌套表达式的结果互相覆盖,(1+2)*(3+4)算出来是错的。原因:tmp_cnt如果是局部变量,每次递归都从 0 开始,生成的临时变量名重复。解决:tmp_cnt必须是全局或贯穿整个生成过程的上下文,保证每次sprintf出来的名字唯一。这个坑很隐蔽,因为单层表达式不会暴露,一嵌套就翻车。
4.4 变量未声明就使用:语义检查缺失
现象:程序里写a = 1;但没写int a;,虚拟机里var_slot自动创建了变量,程序照跑不误。原因:没有独立的语义检查阶段,变量表在运行时才建立。解决:在生成 TAC 之前遍历 AST,维护一个已声明变量集合,遇到NODE_VAR就查表,查不到直接报错并给出行号。语义检查越早做,运行时越干净。
4.5 缓冲区溢出:标识符和数字没做长度检查
现象:输入一个 100 字符的变量名,程序行为异常或崩溃。原因:text[64]、name[64]这些固定缓冲区在strcpy时溢出。解决:词法层扫描标识符时加长度上限,超过就报错;或者改用动态分配。练手阶段至少要把strcpy换成strncpy并手动补\0,这是血泪经验,别等线上崩了才想起来。
5. 进阶技巧:用差分测试验证你的编译器
写到能跑之后,怎么确认它是对的?我一般用差分测试:拿同一段源码,分别喂给你的编译器和 gcc,比较运行结果。具体做法是准备一批小程序,每个程序打印一个整数,比如int main(){ int a=3; int b=4; printf("%d", a*b+1); },你的编译器跑出结果,gcc 编译同一段代码也跑出结果,两者必须一致。
# 批量差分测试脚本骨架 for f in tests/*.c; do gcc "$f" -o /tmp/ref 2>/dev/null && ref=$(/tmp/ref) mine=$(./mycc "$f" 2>/dev/null) if [ "$ref" != "$mine" ]; then echo "MISMATCH: $f ref=$ref mine=$mine" fi done这个脚本遍历tests/下所有.c文件,gcc 编译后运行拿到参考输出,你的编译器也跑一遍,不一致就打印出来。参数上要注意:测试用例要覆盖边界,比如负数、除零、深层嵌套括号、多变量赋值。差分测试的价值在于,你不需要手动推导每个程序的正确结果,让 gcc 当裁判,你只需要盯着不一致的用例去查。
我自己的习惯是每加一个语法特性,就往tests/里丢五个用例,跑一遍差分。有一次加了while循环,差分测试立刻抓出一个循环条件求值顺序的 bug,手动测根本发现不了。写编译器这件事,后悔药就是测试用例,提前备好,比事后 debug 省太多时间。希望帮到你。
本文还有配套的精品资源,点击获取