很多朋友一听到“写编译器”三个字,第一反应就是“这是大佬做的事”。但我自己把这个项目走完之后最大的感受是:编译器不过是一条流水线,只要有人把每个环节拆开,你就能跟着走一遍。这篇博文记录了我从零实现一个C 子集编译器的完整过程,目标很明确:把一份看着像 C 的源码,变成能在自己设计的虚拟机上运行的字节码。适合正在学编译原理、想做点硬核项目却不知道怎么下手的同学,也适合报着“我想彻底搞懂程序本质”这个念头的非科班开发者。看完之后你会发现,编译器并没有你想的那么高不可攀。
1. 项目概述:从零写编译器的整体思路
1.1 为什么选择 C 子集作为目标
真正的 C 语言标准非常庞大,光是类型系统、结构体、指针、预处理、存储修饰符这些东西,就能让一个团队忙上几年。我给自己定了一个极简的目标语言:只保留 int 整数类型、函数定义与调用、全局/局部变量、if/else、while、return,再加上最基本的四则运算、比较运算和括号表达式。这些特性组合起来,已经能让编译器碰到所有核心难题。
为什么这样选?因为“麻雀虽小,五脏俱全”。只要支持函数调用,就必须处理调用栈、返回地址和参数传递;只要支持局部变量,就必须做作用域管理和符号查找;只要支持控制流,就必须解决跳转指令的生成和标签回填。可以说,把这一圈走完,编译器的骨架已经立起来了。而砍掉指针和结构体这类特性,又不会让工作量大到一个人扛不住。
我建议所有想体验编译器开发的人也从这种“可编程的最小语言”起步。不是 C 太小,而是我们不需要为了练手去对抗语言规范里的历史包袱。一个能跑斐波那契数列、能算最大公约数的语言,已经完全值得叫“编译器”了。
1.2 整体架构与编译流水线
编译器的经典四阶段架构在我这个项目里体现得很直白:词法分析、语法分析、语义分析、代码生成。每一步的输入输出都非常明确。
| 阶段 | 输入 | 输出 | 核心要解决的问题 |
|---|---|---|---|
| 词法分析 | 原始源码字符串 | Token 流 | 如何把字符流切成有意义的单词 |
| 语法分析 | Token 流 | 抽象语法树 AST | 如何把单词序列还原成语法结构 |
| 语义分析 | 抽象语法树 AST | 带符号表信息的标注树 | 如何保证变量、函数使用合法 |
| 代码生成 | 语义检查过的 AST | 三地址码/字节码 | 如何把结构化的树展开为线性指令 |
这里我个人最喜欢的一种理解方式:编译过程就是信息逐步降级的过程。源码里既有“什么是变量”又有“变量怎么使用”的信息,经过词法分析之后变成 Token,Token 仍然保留部分词法细节;到了 AST,语法层面的嵌套关系被彻底固定;再到三地址码,连嵌套都没了,变成一串线性指令。每一层都在丢弃无关信息、保留对下一层有用的信息。
用生活类比解释一下:这不像是翻译一篇文章,更像是把一座房子从设计蓝图缩小到结构骨架,再缩小到砖和钢筋的采购清单。每条指令就是一个零件,编译器的责任就是保证零件之间能严丝合缝地组装。
1.3 语言特性边界与示例程序
我的 C 子集正式名字就叫“MiniC”。支持的内容包括:
- 基本数据类型只保留 int,函数返回类型也只有 int 和 void
- 函数支持参数列表,支持递归调用
- 语句支持变量声明、赋值、表达式语句、if/else、while、return、花括号代码块
- 表达式支持 +、-、*、/、% 以及 <、<=、>、>=、==、!=,支持括号改变优先级
- 支持// 行注释和 /* 块注释 */
为了贯穿全篇讲解,我会反复用下面这个求斐波那契数列的程序作为示例。
int fib(int n) { if (n <= 1) { return n; } return fib(n - 1) + fib(n - 2); } int main() { int result; result = fib(10); return result; }2. 词法分析器:让源码变成 Token 流
2.1 Token 的类型划分与定义
词法分析是所有后续阶段的源头,不管多漂亮的语法树,都要从这段把字符切词的工作开始。我第一版犯过一个大错误:把 Token 结构设计得太简陋,只保存了类型和文本值,没保存行列号。结果后期报错时只能写出“语法错误”,写不出“第 5 行第 3 列附近发生了什么”,调试体验简直灾难。
Token 至少要包含这几项:类型、文本、所在行、所在列。类型上我做了一个枚举,总共就十几种,绝不贪多:
| Token 类型 | 说明 | 示例 |
|---|---|---|
| IDENT | 标识符 | fib、result、n |
| KEYWORD | 关键字 | int、if、else、while、return |
| INT_LITERAL | 整数常量 | 10、2、0 |
| OPERATOR | 运算符 | +、-、*、/、<=、== |
| PUNCT | 分隔符 | (、) 、{、}、;、, |
| EOF | 文件结束标记 | 无 |
这里的 KEYWORD 和 IDENT 在词法规则上是同一类东西:都是字母开头的字母数字串。区别只是查一下关键字表之后归类不同。我维护了一张哈希表把 int、if、else、while、return、void 放进去,看到标识符后先查这张表,查到就是 KEYWORD,查不到就是 IDENT。
在 C 语言里定义 Token 结构,大致长这样:
typedef struct Token { TokenType type; const char *text; int line; int column; } Token;代码生成阶段最终会用到文本,但词法分析阶段必须把它保留下来,否则后续根本无法知道变量的真实名字。
2.2 手写词法分析器的状态机实践
有没有必要使用 lex/flex 这类工具?有必要,但不适合学习阶段。自动生成器会掩盖太多细节,一旦生成的代码出了问题,你连排查入口都找不到。手写词法分析器其实一点都不难,本质就是一个字符级的状态机:读一个字符,判断这个字符属于数字、字母还是符号,再决定进入哪个状态。
我大致是这样组织的:外层循环不断调用 nextToken(),内部维护一个当前读取位置。遇到空白字符就跳过,但行号要跟着换行符累积。遇到数字就不断读取直到非数字字符,然后用 strtol 或者手写转换变成整数。遇到字母或下划线就不断读取直到非字母数字字符,然后查关键字表。遇到运算符时要做最长匹配,比如看到 + 之后还得再看下一个字符是不是 =,如果是,就得到 +=,在我的 MiniC 里不支持 +=,但至少要准备这个逻辑,否则以后扩展语法时会被卡住。
核心逻辑可以浓缩为这样一段代码:
Token nextToken() { skipWhitespaceAndComments(); if (*src == '\0') return makeToken(T_EOF, "", line, col); if (isdigit(*src)) return scanNumber(); if (isalpha(*src) || *src == '_') return scanIdentifierOrKeyword(); return scanOperator(); }词法分析器的代码量通常只有三四百行,但它值得你好好打磨。我后来把注释处理也放到这里,因为从语法角度看,注释就是空白。处理跨行注释时尤其要注意换行符,它会改变行号。我刚开始没处理,导致某个错误始终报错到错误的行,排查了很久。
2.3 词法分析阶段的调试技巧
写完词法分析器之后,我最先做的不是继续往下写,而是先写一个 dumpTokens 函数,把 Token 流一行一个地打出来。每种 Token 用不同前缀加文本显示,例如IDENT(fib)、INT_LITERAL(10)、OPERATOR(<=)。跑一遍示例程序,确认输出的每个 Token 都在正确顺序上,再往下推进。
这个习惯帮我规避了大量没必要的排查。很多所谓“语法分析出错”的情况,其实是词法阶段就把 Token 切错了。比如 10x 这种写法,严谨的编译器应该报错,我第一版却会把它切成 INT_LITERAL(10) 加上 IDENT(x),后面语法怎么分析都会莫名其妙。后来我加一个检查:数字后面紧跟字母就报词法错误。所以词法分析器也要验证,一个小小疏忽会在后级阶段被放大成诡异的问题。
3. 语法分析:从 Token 流到抽象语法树
3.1 为什么要选择递归下降法
语法分析的方法总体分两类:一类是自底向上的 LR/LALR,典型工具有 yacc/bison;另一类是自顶向下的递归下降。我选择的是递归下降,原因很简单:它和你书写的文法规则是一一对应的,几乎不需要理解状态机跳转,写起来就像在描述语法本身。
递归下降法最大的坑在于左递归。如果文法里写着expr -> expr + term,翻译成递归函数就会无限调用自身。解决办法有两个:一是在文法层面改写为循环,二是用 Pratt 解析法处理表达式。对于表达式层次,我用了 Pratt 解析,因为它处理优先级和结合性更优雅。
什么叫 Pratt 解析?你可以想象成每个运算符带一个“绑定力量值”,数字越大绑定越紧。解析表达式时,Parser 从左往右拿运算符,并和当前最大绑定力量比较,如果遇到更紧的运算符就递归解析右侧。这种方式替代了一层层嵌套函数调用,特别适合处理加减乘除、比较运算这类优先级明确的场景。
下面是一段简化核心:
int expr(int minPrecedence) { Node *left = parseUnary(); while (true) { Operator op = peekOperator(); if (op.precedence < minPrecedence) break; advance(); Node *right = expr(op.precedence + 1); left = makeBinOpNode(op, left, right); } return left; }3.2 表达式优先级与文法设计
MiniC 的表达式优先级从低到高通常是这样安排的:
| 优先级 | 运算符 | 结合性 |
|---|---|---|
| 最低 | = | 右结合 |
| 较低 | || | 左结合 |
| 低 | && | 左结合 |
| 中等 | == != | 左结合 |
| 略高 | < <= > >= | 左结合 |
| 高 | + - | 左结合 |
| 更高 | * / % | 左结合 |
| 最高 | ! -(一元) | 右结合 |
| 最高 | ( ) 函数调用 | 左结合 |
注意赋值是右结合,所以a = b = 10应当被理解为a = (b = 10)。在 Pratt 解析里,这只需要把赋值运算符的递归最小优先级设为当前优先级而不是加一即可。
我写出的文法简化如下:
program := function* function := type IDENT '(' param_list ')' block param_list := (type IDENT (',' type IDENT)*)? block := '{' statement* '}' statement := var_decl | assignment ';' | if '(' expr ')' statement (else statement)? | while '(' expr ')' statement | return expr? ';' | block var_decl := type IDENT ';' expr := assignment assignment := logical_or ('=' assignment)?这套文法把语句和表达式清晰地分开。我始终记住一个原则:语法分析器的职责是判断“结构是否符合语法”,而“变量是否声明过”这是语义分析阶段的事。两边别混在一起,不然 Parser 越写越乱,错误信息也不好报。
3.3 AST 节点设计与全流程串联
AST 是我整个项目里最重要的数据结构。每个节点都带有类型、行号、子节点指针。定义大致如下:
typedef enum { N_PROGRAM, N_FUNC_DEF, N_BLOCK, N_VAR_DECL, N_ASSIGN, N_IF, N_WHILE, N_RETURN, N_CALL, N_BINOP, N_UNOP, N_INT_CONST, N_IDENT } NodeType; typedef struct Node { NodeType type; int line; union { struct { char *name; Node *param_list; Node *body; } func; struct { Node *cond; Node *then; Node *else; } if_s; struct { char *name; Node *args; } call; struct { char *op; Node *left; Node *right; } binop; struct { int value; } int_val; struct { char *name; } ident; } data; struct Node **children; } Node;不同节点存储的内容差别很大,所以用 union 来节省空间是常见做法,但代价是访问时你得一直惦记着当前节点的类型。我为了调试方便,额外写了一个 printAST 函数,把 AST 以缩进树的形式打印出来。这个函数的作用在后面测试中远超预期,很多逻辑问题用肉眼看 AST 一眼就能定位。
例如result = fib(10) + 1;这行代码经过语法分析后,AST 尾部会呈现这种结构:
ASSIGN IDENT(result) BINOP(+) CALL(fib, ARGS[INT(10)]) INT(1)把 AST 打印出来再往下写,语法分析阶段的信心会瞬间提高很多。没人愿意靠猜去推进语义分析。
4. 语义分析与符号表:让程序变得有意义
4.1 符号表的结构与作用域管理
语法分析只保证“形状正确”,但像“变量 result 在声明之前就被使用”这种错误,语法分析根本发现不了,语义分析就是在这里登场。核心数据结构就一个字:表。我实现了一张符号表,每个符号包含名字、类型、作用域层级、以及后续代码生成需要的栈偏移。
作用域管理是语义分析的重点。MiniC 的作用域规则是这样的:最外层是全局作用域,每个函数体内部是一个局部作用域,函数参数也属于这个局部作用域。如果出现嵌套代码块,就再往里推一层。为了处理嵌套,我使用了链式作用域栈:
typedef struct Symbol { char *name; Type type; int scope_level; int stack_offset; struct Symbol *next; } Symbol; typedef struct Scope { struct Scope *parent; Symbol *symbols; } Scope;查找符号时,先查当前作用域,查不到就去 parent 继续向上查;声明新变量时则只插入当前作用域链表的头部。这样天然实现了变量的遮蔽:内层声明了一个同名变量,外层那个就被暂时盖住了。
我在第一次实现时犯了一个低级错误:符号表用单层哈希表,没有按作用域分层,导致全局变量和局部变量同名时互相覆盖。测试程序跑出来的结果匪夷所思,最后加了一个 printSymbolTable 函数,把每个作用域里的变量打出来,问题一目了然。
4.2 类型检查与常见语义错误
MiniC 只有一个 int 类型,所以类型检查工作看起来少,但依然有几个必须检查的点:
- 凡是出现在表达式里的标识符,必须能在符号表中查找到
- 同一作用域内,不允许重复声明同一个变量
- 函数调用时,实参个数必须和形参个数一致
- return 语句所在的函数类型如果是 void,则不能返回整数
- 赋值运算符左值必须是变量,不能是表达式
最开始我漏掉了实参个数检查。某次测试时调用 fib(10, 20) 竟然顺利编译通过,代码生成阶段把第一个参数放到栈上,第二个参数也放了,函数返回时栈帧弹出数量对不上,运行时直接崩溃。加了参数个数检查之后,这类问题直接变成编译期可诊断的错误。
语义分析阶段还要顺便做一件事:给每个局部变量分配栈偏移。变量声明顺序决定偏移量,符号表里记录好这个值,代码生成时就能直接生成“往相对栈顶第几个位置存”的指令。这一步如果在语法分析阶段做,会遇到函数嵌套时的顺序混乱,所以放在作用域创建之后统一处理更合理。
5. 代码生成与运行时:把 AST 变成可执行的东西
5.1 为什么选择三地址码作为中间表示
到这里,我们有了合法的 AST,接下来就是把它变成真正可运行的东西。这个环节选择很多:可以直接生成 x86-64 汇编,可以生成 C 语言再交给本机编译器,也可以生成自定义的字节码交给虚拟机执行。我选择了三地址码加大虚拟机。
直接生成 x86-64 汇编看上去最硬核,但说实话,汇编指令集里的寄存器分配、调用约定、栈对齐问题,每一样都能让人写崩溃。如果目标是理解编译器逻辑而不是修炼汇编内功,三地址码是性价比最高的选择。
三地址码的特点是一条指令最多只有一个运算操作和三个地址,比如t2 = t1 * 3。它很像汇编,但没有寄存器数量和指令约束的问题,用编号 0、1、2 表示临时变量,无限个数。之后再把它翻译成虚拟机字节码或者汇编,或者直接边解释边执行,都是可行的。
我最终的做法是:先生成三地址码指令列表,然后交给一个简单的栈式虚拟机执行。这个虚拟机会模拟函数调用栈,运行结束后返回结果。
5.2 从 AST 生成三地址码的规则
三地址码的指令集我总共定义了大约 20 种:
| 指令 | 含义 |
|---|---|
| LOAD_CONST result, value | 把常量加载到临时变量 |
| COPY result, source | 复制源变量 |
| ADD result, a, b | 结果 = a + b |
| SUB result, a, b | 结果 = a - b |
| MUL result, a, b | 结果 = a * b |
| DIV result, a, b | 结果 = a / b |
| LT/LE/GT/GE result, a, b | 比较指令,结果为 0 或 1 |
| GOTO label | 无条件跳转 |
| IFFALSE cond, label | 条件为假则跳转 |
| CALL result, func, argc | 函数调用,结果存入 result |
| PARAM value | 压入实参,供 CALL 使用 |
| RET value | 返回值并退出函数 |
从 AST 生成三地址码本质是对树做后序遍历。先递归生成左子树的指令,把结果放到临时变量 t1;再递归生成右子树的指令,结果放到 t2;最后生成一条加法指令ADD t3, t1, t2。这个规则简单到接近机械,但正是这种机械性保证了正确性。
表达式fib(n - 1) + fib(n - 2)可能生成类似如下的三地址码:
LOAD_CONST t1, 1 SUB t2, n, t1 PARAM t2 CALL t3, fib, 1 LOAD_CONST t4, 2 SUB t5, n, t4 PARAM t5 CALL t6, fib, 1 ADD t7, t3, t6控制流的翻译是最有意思的部分。if 和 while 都需要用到标签。比如 while 循环通常是这样翻译的:
L_begin: 条件求值到 t1 IFFALSE t1, L_end 循环体语句 GOTO L_begin L_end:因为跳转目标常常是后面才确定的,我需要用“标签回填”技术:先生成一个空跳转指令,等知道目标位置后,再把指令里的标签参数补齐。语法分析阶段的 AST 结构在这里起到了决定性作用,没有它你根本不知道什么时候该发 GOTO 回去。
5.3 函数调用的运行时链接与栈帧
函数调用是代码生成阶段最大的难点,没有第二个。每调用一次函数,就要为一个新的栈帧分配内存。我的虚拟机栈帧布局如下:
| 地址 | 内容 |
|---|---|
| 栈帧顶部 +8 | 返回地址(虚拟机程序计数器) |
| 栈帧顶部 +4 | 保存的调用者栈基址 |
| 栈帧顶部 0 | 第一个局部变量(按偏移递减) |
| ... | 局部变量区 |
| 栈帧底部 | 实参列表(从 CALL 前推入) |
调用前会先执行若干 PARAM 指令把实参压入栈,然后执行 CALL。CALL 指令的职责是:保存当前程序计数器作为返回地址,保存当前栈基址,然后建立新的栈帧,跳转到函数的入口。函数末尾执行 RET 时,取出返回值,恢复调用者的栈基址和程序计数器,完成返回。
这里的参数顺序要特别注意。MiniC 的调用约定是参数从左往右压栈,但栈是向下增长的,因此最后一个参数在最栈顶。函数体内访问参数时,需要以栈基址加上固定偏移。如果这里搞反,拿到的参数顺序会完全颠倒。
运行时还能做一层保护:检查栈指针是否超过预设的最大值并在报告溢出。递归函数很容易触发栈溢出,有了这个检查,出现死递归时至少报错信息清晰,不会整个虚拟机上崩掉。虚拟机本身我用 C 语言实现,代码量零零总总不到 500 行,但跑起斐波那契数列毫无压力。
6. 实测与调试中的那些坑
6.1 最常见的几个问题与排查方式
写编译器一定会经历大量奇怪的失败。我把自己掉进去的坑总结成一份速查表,后面遇到同类问题可以直接对照:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 所有 Token 行号都偏移 | 词法阶段跨行注释没更新行号 | 在注释处理循环里遇到 \n 就 line++ |
| 表达式运算结果完全错误 | 优先级处理不对 | 打印 AST,检查加减和乘除的嵌套关系 |
| 变量赋值没生效 | 语义阶段栈偏移没有记录 | 打印符号表里每个变量的偏移量 |
| 函数递归调用无限循环 | 进入函数体前栈帧没有分配 | 检查 CALL 指令是否创建新栈帧 |
| 条件判断体永远不执行 | IFFALSE 和 IFTRUE 写反 | 单步跟踪第一条控制流指令的跳转目标 |
| return 值拿不到 | RET 指令把返回值放到错误位置 | 检查栈帧恢复顺序,防止覆盖返回值 |
| 局部变量互相串值 | 两个变量分配到了相同偏移 | 确认符号表是否按作用域分层管理 |
这些问题里最难的其实是第一种和最后一种。词法阶段的错误会在后续阶段放大成特别诡异的症状,而作用域分层不够清晰的错误往往只在你跑多级函数调用时才暴露。所以我强烈建议在写代码生成之前,就先把符号表相关逻辑彻底验证一遍。
6.2 调试手段:多打印,分阶段验证
编译器开发者最趁手的调试工具不是 debugger,而是 print。我给每个阶段都加了一个可开关的调试打印函数,用环境变量控制开启关闭。词法阶段能打印 Token 流,语法阶段能打印 AST 缩进树,语义阶段能打印符号表,代码生成阶段能打印三地址码指令序列。要排查问题时,按需打开对应开关。
开发顺序上也有一条很稳的路线,第一步先实现整数常量和加减乘除表达式,不要碰变量、不要碰控制流,先确保表达式求值正确。第二步加变量声明和赋值,这个时候符号表和栈偏移都能被充分测试。第三步加 if 和 while 控制流,跳转逻辑都能在复杂语句结构下得到验证。最后一步才加函数调用。每完成一步就留一个测试用例,比如计算 (10 - 4) * 3 / 2 的结果、计算 1 到 100 的累加、递归求斐波那契。这样每一层都有明确验证基准,永远不会出现“代码全写完了但不知道问题在哪”的大爆炸式调试。
我第一次写的时候直接一口气全部写完,结果运行错误一片,排查了一个晚上还找不到根因。后来把测试用例拆开,一个阶段一个阶段验证,发现真正的问题就藏在函数调用的栈帧分配里。分阶段开发,长期看才是最快的路。
最后再分享一个我印象很深的小技巧:在语法分析器里遇到 unexpected token 时,报错信息一定要包含当前 Token 的行列号、期望看到的符号、实际看到的符号。比如“希望在 while 头部看到 '(',但实际看到数字常量 0,位于第 4 行第 9 列”。这种错误信息对人排查和自己排查都极其有价值,不仅项目调试时能少走很多弯路,以后回看代码维护时也会感激当初这个决定。
写编译器这件事,最大的收获不是“我会写语法分析器了”,而是把程序运行的每一个环节从文本到机器指令完整地看了一遍。以前写高级语言感觉像隔着雾看灯,写完这个项目之后,感觉自己亲手把雾拨开了。如果你现在正对着编译原理教材发怵,不妨试试我这个路线:挑一个最小的语言子集,把四阶段流水线走通,再回头看书,你会发现那些公式和名词突然都变得亲切起来。