很多程序员第一次听到“编译器”三个字,都会下意识把它归入“很厉害但我这辈子大概碰不到”的领域。我完全理解这种感受——两年前我也是这么想的。直到我认真花了一个月,从零开始手写了一个能解析并执行C语言子集的编译器,才切身体会到:编译器并不神秘,它只是一套有章法的程序,只要你懂得如何拆解问题,任何人都能亲手造出一个属于自己的版本。
这篇文章就用来完整复盘我的这次开发之旅。我会以“中间存在一个完整可运行的C子集教学编译器”为蓝本,讲清楚整个实现思路、每一步的设计理由、关键数据结构、我踩过的坑以及一些教科书里不会明说的实战经验。内容适合已经会写一点代码、但对编译原理处于“听说过、没碰过”阶段的人;只要你跟着思路走,哪怕暂时不写代码,也会对整个编译器的工作机制有非常清晰的认知。
1. 先拆解目标:这个“C 子集”到底要覆盖多少语法
动手写编译器之前,第一件事不是打开编辑器,而是想清楚“我到底要编译什么”。C 语言全量语法极其复杂,别说一个人,商业级编译器也需要整个团队持续维护多年。所以我们的选择是切一个足够小、但足够完整的子集,让它可以表达循环、分支、函数调用和递归,却没有把语法树的复杂度推高到失控的地步。
我最终划定的语法范围是这样:
| 能力项 | 覆盖内容 | 典型示例 |
|---|---|---|
| 数据类型 | 只保留 32 位整型,统一按 int 处理 | int a = 10; |
| 运算符 | 四则运算、取模、比较、逻辑与或非、括号 | if (a > 0 && b != 2) { ... } |
| 分支语句 | if / else,支持嵌套 | if (a) { } else { } |
| 循环语句 | while,支持 break 跳出 | while (i < 10) { i = i + 1; } |
| 函数 | 声明、定义、调用、单返回值、形参 | int add(int a, int b) { return a + b; } |
| 作用域 | 全局变量、函数体局部变量、形参 | int global = 3; |
| 输入输出 | 一个内置函数,直接输出整数 | print(a); |
这个子集故意做了一些取舍:没有数组、没有结构体、没有指针、没有浮点数,也没有函数声明和定义分离的语法。为什么这么切?因为我们要学习的核心机制是词法分析、语法分析、符号管理和求值执行,以上语法已经足以完整串联这些过程。数组和指针是后面可以自行扩展的部分,但如果一开始就把它们搬进来,递归下降解析和符号表设计会变得异常复杂,新手很难在两周内感受到“我居然真的写完了一个能跑程序的编译器”的成就感。
确定了范围之后,我还做了一个非常重要的事:先手工写了 10 个测试程序,涵盖简单表达式、嵌套 if、while 循环、递归函数、跨函数调用、全局变量遮蔽等场景。这个习惯强烈推荐——测试用例要先于实现存在,不然很容易写到一半就迷失方向,到时候想验证一个改动还要临时现写测试程序,效率非常低。
2. 词法分析器:把源代码字符串变成有身份的单词
编译器处理的源代码本质上就是一长串字符。词法分析器的职责,就是把这串字符切分成一个个有意义的“单词”——术语叫作 Token(记号)。可以把它理解成人类阅读句子时的分词过程:看到int a = 10;会知道int是类型关键字,a是标识符,=是赋值运算符,10是数字字面量,;是语句结束符。
2.1 定义 Token 结构
我使用的是类似 Python 的伪代码来搭建整体结构,因为可读性最强,方便展开思路。真正的实现用什么语言完全看你顺手,核心数据结构是共通的:
class Token: def __init__(self, kind, value, line): self.kind = kind # Token 类型,如 "INT", "IDENTIFIER", "NUMBER" self.value = value # 原始字符串值,如 "while", "a", "42" self.line = line # 行号,错误提示时用设计中我固定使用三组字段,其中最重要的是kind,它决定了解析器怎么看待这个单词。Token 的类型我规划为这么几类:关键字(int、if、else、while、return 等)、标识符(变量名和函数名)、数字字面量、运算符(+、-、*、/、%、&&、||、!、>、>=、<、<=、==、!=)、分隔符((、)、{、}、;、,、=)。其中=我单独拿出来,因为它是赋值动作而非表达式运算,解析时处理和==完全不同。
2.2 核心扫描逻辑
词法分析器的实现,本质上是一个带“指针”的单趟扫描循环:
def tokenize(source): tokens = [] i = 0 line_num = 1 length = len(source) while i < length: ch = source[i] if ch == '\n': line_num += 1 i += 1 continue if ch.isspace(): i += 1 continue if ch.isdigit(): j = i while j < length and source[j].isdigit(): j += 1 tokens.append(Token("NUMBER", source[i:j], line_num)) i = j continue if ch.isalpha() or ch == '_': j = i while j < length and (source[j].isalnum() or source[j] == '_'): j += 1 word = source[i:j] # 判断是不是关键字,如果是,kind 直接给关键字类型 kinds = ["int", "if", "else", "while", "return", "break"] tokens.append(Token(word if word in kinds else "IDENTIFIER", word, line_num)) i = j continue # 处理运算符和双字符比较符 if ch in "+-*/%(){};,": tokens.append(Token(ch, ch, line_num)) i += 1 continue # 这里处理 ==、!=、<=、>= 等双字符运算符,以及 = 与单个比较符 if source.startswith(("==", "!=", "<=", ">="), i): token = source[i:i+2] tokens.append(Token(token, token, line_num)) i += 2 continue # 最后处理单字符的 >、<、=、! if ch in "><=!": tokens.append(Token(ch, ch, line_num)) i += 1 continue raise Error(f"第 {line_num} 行无法识别的字符: {ch}") return tokens这里有一个很容易忽略但实际非常关键的细节:运算符的匹配顺序必须从长到短。必须先判断==再处理单个=,先判断<=再处理<,因为一旦先把单个字符当作 Token 吃进去,后面的字符就永远凑不成一对完整运算符了。我在第一版就犯过这个错误,导致所有a == b都被解析成了a = = b,当场报错。
2.3 为什么要单独区分关键字
有些初学者会在扫描阶段问:反正后面都是字符串,统一识别成标识符再判断是关键字不行吗?理论上可以,但在语法分析阶段,我们需要在读到if时立刻知道这里是一个分支语句的起始,而不是一个变量的名字。Token 类型的提前划分,相当于给每个单词提前贴好了分类标签,后面处理效率明显更高。另外,如果计划后续做语法高亮、代码格式化,一个带类型的 Token 流也会带来很大方便。
词法分析阶段还有两个很实用的工程细节:一是所有报错一定要带行号,这是之后再排查语法错误的重要线索;二是如果计划支持注释,扫描到两个特定字符时应该直接跳过一直到行尾。我做的这个子集没有加注释支持,但如果读者想加,思路很简单——识别到注释起始符号后,一直推进指针直到\n。
3. 递归下降解析器:用可读的代码构建语法树
词法分析完成之后,我们手里有一串 Token。接下来这一步,是整个编译器中最有“语文课”感觉的部分:语法分析。它的职责是把 Token 序列按照语法规则组合成一棵结构化的树,也就是 AST(抽象语法树)。所有后续阶段的输入都来自这棵树,所以这棵树设计得好不好,直接影响执阶段的心情。
3.1 为什么选递归下降而不是别的方法
实现语法分析的技术路线非常多:LR 分析器、LL 分析器、操作符优先级分析法等。作为新手开发,我强烈推荐递归下降,原因就一句话:它在代码形状上和语法本身几乎一一对应。你定义一个parseExpression函数,语法里就有一个“表达式”产生式;你定义一个parseStatement函数,语法里就一定有“语句”这个非终结符。这种直接对应关系让代码可读性极高,出错了也容易顺着函数调用栈一路查回源头。对小型教学型语言来说,它的性能完全够用,而且完全不需要类似 LR 状态机那样额外生成一套复杂表格。
3.2 表达式优先级:每一个优先级对应一层函数
表达式解析是最容易写乱的部分。我最初的想法是“从左到右处理所有运算符就完事”,写出来之后发现1 + 2 * 3会被算成 9,而不是期望的 7。这让我意识到,优先级不是可选项,而是表达式解析的地基。
递归下降里处理优先级的经典做法是:优先级越高的运算符,对应越内层的函数。我设计的层级从上到下的关系大致是这样的:
def parse_expression(): # 处理最低优先级的 ||,然后向下层转接 return parse_or() def parse_or(): node = parse_and() while check("||"): op = advance() node = BinaryOp(node, op, parse_and()) return node def parse_and(): node = parse_equality() # 循环处理 && ... def parse_equality(): node = parse_relational() # 循环处理 == != ... def parse_relational(): node = parse_additive() # 循环处理 > < >= <= ... def parse_additive(): node = parse_multiplicative() # 循环处理 + - ... def parse_multiplicative(): node = parse_unary() # 循环处理 * / % ...每一个函数都采用同样的模式:先调用下一层优先级的函数拿到左操作数,然后循环检查当前运算符是否属于本层。如果属于,就消耗掉并再次调用下一层函数取得右操作数,构造成一个二元运算节点,然后继续循环。这个模式没有太多神奇之处,但它天然地实现了“高优先级运算符先结合”的规则。比如1 + 2 * 3在解析到加号后会进入右侧的乘法层,于是2 * 3会先被构造成一个整体,最终执行时自然先算乘后算加。
为什么要单独对应||和&&各写一层,还把它们放在所有算术层之上?因为它们在整个表达式里优先级最低,而且需要实现短路求值——在求值引擎那一步你会看到这样设计的意义。如果把逻辑运算符和比较运算符放在同一层,a || b && c的括号规则就会乱套。
3.3 AST 节点模型
解析器每识别出一个语法结构,就要把它记录成一棵树。我的 AST 节点类型大致有这几种:
| 节点类型 | 含义 | 关键字段 |
|---|---|---|
| NumberExpr | 整数常量 | value |
| IdentifierExpr | 变量引用 | name |
| BinaryOpExpr | 二元运算 | left, op, right |
| UnaryOpExpr | 一元运算(! 和负号) | op, operand |
| AssignStmt | 赋值语句 | name, value |
| IfStmt | 分支语句 | condition, then_branch, else_branch |
| WhileStmt | 循环语句 | condition, body |
| BlockStmt | 语句块 | statements 列表 |
| ReturnStmt | 返回语句 | value |
| BreakStmt | 跳出循环 | 无 |
| FunctionDecl | 函数定义 | name, params, body |
| CallExpr | 函数调用表达式 | name, arguments |
在实际工程中,我并没有刻意求一套漂亮的抽象基类体系,而是用了一个相对灵活的结构:一个字典存储节点类型,字段直接挂在节点上。这样的好处是解析和求值阶段的代码足够直观,学习时不会被面向对象的过度设计绑架。
3.4 解析语句块与作用域的关系
解析器里parseBlock承担了一项额外职责:每次进入一对花括号时,它要为解释器创建一个新的作用域。这个设计如果等到求值阶段再做,往往会因为找不到变量归属而排查半天。我在实现时是在解析阶段就做好作用域边界的标识,为后续符号表管理铺路。这个决定在后面写求值引擎的时候帮我节省了大量时间,因为作用域的层级结构已经显露在 AST 的块节点里了。
另一个实用细节是匹配}的逻辑:很多新手解析器会在块结束时顺手消耗掉右花括号,然后外层语句继续;但是如果缺少右括号,解析器会直接崩溃。我在解析块时采用“如果当前 Token 不是右花括号就按语句处理,直到遇到右花括号或文件结尾”的写法,这样少一个括号时至少不会无限循环,而是能在尾部报一个比较清晰的“语句块未闭合”错误。
4. 符号表与作用域链:让每一个变量都有来处
一个人写完函数体之后,变量多起来,最头痛的就是“这个变量到底是哪里来的”。编译器在做语义分析和执行时同样需要知道答案,承担这个职责的就是符号表。
4.1 符号表的两种结构与我的选择
符号表有两种主流组织方式。一种是单一表加“作用域深度”字段,每次进入新作用域就把深度加一,离开再减一,查询时只找同一深度或更小深度的记录。另一种是真正的链式表:每个作用域维护一张独立的小表,同时持有父亲作用域的引用。我选择了后一种,因为它在语义上更贴合 C 语言的作用域规则——内层可以遮蔽外层同名变量,离开内层之后外层变量自动恢复。
数据结构如下:
class Scope: def __init__(self, parent=None): self.symbols = {} self.parent = parent def define(self, name, info): self.symbols[name] = info def lookup(self, name): scope = self while scope is not None: if name in scope.symbols: return scope.symbols[name], scope scope = scope.parent return None, Nonelookup从当前作用域开始一层层向外找,直到找到变量或者摸到全局作用域的尽头。这里有一个语义上的坑:赋值语句和声明语句的处理必须不一样。声明语句只应该写入当前作用域,即使外层有同名变量,也要在当前层建立新纪录;而赋值语句则要从当前层向外查找,找到哪个层级就修改哪个层级的变量。如果赋值时也采用局部写入逻辑,那么int a = 1;之后就再也无法给外层a赋值了。
4.2 全局符号表和函数表
全局变量和函数我分别用了两个表来管理。函数表的名字很直观——每个函数签名记录它的参数名字列表、返回类型和对应的 AST 节点。变量表则是作用域链顶端的那个全局作用域。
函数定义和全局变量声明存在一个先后顺序问题:C 语言中函数可以在定义之前被调用,只要最终有定义就行。为了支持这个特性,我的解析策略是:解析到函数定义节点时,立刻把函数的信息登记到全局函数表里,即便函数体还没有完整执行过。这样在求值另一个函数时,不管目标函数定义在前还是定义在后,都能正确找到。
4.3 重复定义的检测与红线
一个容易踩的坑是重复定义。比如同一个作用域里写了两次int a;,严格来说在 C 语言中是错误。如果我们不做任何检查,符号表里前面的记录就会被静默覆盖,后续排查语义问题时非常困难。我的实现中在define时先检查当前作用域是否已经存在同名记录,如果存在就立刻抛出一个带行号的语义错误。这个检查只针对当前作用域,不会去检查外层——因为内层遮蔽外层是合法行为,这个界限要分清。
写到这里顺便说一句:作用域链这个东西,在你以后写解释器、写静态分析工具、实现自己的脚本语言时都会反复出现。早点把它理解透,等于提前掌握了一个后续几年都会用到的核心抽象。
5. 求值引擎:用一棵树把程序真正跑起来
解析器输出 AST 之后,真正的“运行”还没有发生。从这一节开始,我们要让这棵树活起来。按照我最初的设计,为了让这个教学编译器真正做到“输入源码 → 输出结果”,我在完整生成汇编之前先实现了一个基于 AST 的解释式求值引擎。这相当于先把语义理解清楚,之后如果要换成真正的汇编生成,只需把求值步骤替换成代码发射步骤即可。
5.1 总体求值方式
我设计的求值函数分为表达式和语句两大类。表达式的求值返回一个整数,比如a + b * 2最终变成一个整数值;语句的求值不返回值,而是改变状态——修改变量、跳到分支、控制循环。整体的入口结构如下:
def execute_function(func_node, args, scope): call_scope = Scope(global_scope) for param_name, arg in zip(func_node.parameters, args): call_scope.define(param_name, arg) return execute_block(func_node.body, call_scope)每次调用函数都会为它新建一个作用域,并把实参数值绑定到形参上,然后执行函数体块。这个设计天然支持递归——每次递归调用都拥有完全独立的一份局部变量,互不干扰。
5.2 表达式求值:左右递归的二元运算
表达式求值的核心是递归。一个二元运算节点BinaryOpExpr的求值非常直接:
def eval_expr(node, scope): if node.type == "NumberExpr": return node.value if node.type == "IdentifierExpr": info, scope = scope.lookup(node.name) return info.value if node.type == "BinaryOpExpr": if node.op == "&&": left = eval_expr(node.left, scope) return False if not left else bool(eval_expr(node.right, scope)) if node.op == "||": left = eval_expr(node.left, scope) return True if left else bool(eval_expr(node.right, scope)) left = eval_expr(node.left, scope) right = eval_expr(node.right, scope) ... # 根据 node.op 做四则运算或比较逻辑与和逻辑或的短路求值在解析器那一步就埋下了伏笔。eval_expr对&&的实现是先算左边,如果左边为假,就不再计算右边直接返回假;||则相反。这个行为让0 && (1 / 0 == 1)这种代码不会触发除零错误——真实 C 语言的语义就是这样。
算术运算的细节有一个要注意的地方:整数除法在 C 里是截断的,两个正数是直接舍去小数,而负数参与除法时不同平台行为曾经有差异。我在这个教学编译器里规定为“先计算后取整”,保证与主流 C 编译器的行为对齐,省得测试时因边界行为不一致产生疑惑。
5.3 语句执行:分支、循环与跳出
语句执行的骨架是一个简单的 switch:
def execute_stmt(node, scope): if node.type == "AssignStmt": _, defining_scope = scope.lookup(node.name) defining_scope.symbols[node.name]["value"] = eval_expr(node.value, scope) elif node.type == "IfStmt": condition = eval_expr(node.condition, scope) if condition: execute_block(node.then_branch, scope) elif node.else_branch is not None: execute_block(node.else_branch, scope) elif node.type == "WhileStmt": while eval_expr(node.condition, scope): result = execute_block(node.body, scope) if result in ("break", "return"): return result ...WhileStmt里有一个很关键的返回值约定:execute_block一旦遇到break或return,就立刻向上传递一个控制信号。我在实现中使用了一个简单的字符串作为信号,然后在循环节点和函数体执行处检查信号,决定是跳出当前循环还是从函数中返回。对于教学编译器这个方案足够简单清晰,唯一的缺点是信号类型是字符串,调用时容易拼错;读者如果想在工作中实践,更优雅的做法是定义一个包含BREAK、RETURN、NORMAL三种状态的枚举类。
5.4 变量的初始值
C 语言中全局变量未初始化时会被置为 0,局部变量则是“未定义行为”。教学编译器为了稳定执行,我把所有变量初始值都设置成了 0。这样做的好处是测试程序不会因为未初始化变量出现不可复现的结果,推荐的工程习惯是:要么不给这类编译器定义模糊行为,要么给一个明确且稳定的兜底规则。
6. 函数调用与栈帧:递归、形参、返回值一气呵成
函数是整个 C 子集编译器中最接近“真实机器”的部分。一个编程语言支持函数和递归,意味着它的运行时环境必须具备“保存上下文、切换上下文、恢复上下文”的能力。这一节我会拆开讲解调用过程的每个细节,这也是这一整套代码里面最值得多琢磨的部分。
6.1 栈帧模型的简化实现
真实机器函数调用依赖硬件栈保存返回地址与局部变量,教学编译器里我用“调用栈”这个抽象来模拟:
call_stack = [] def call_function(func_name, args, caller_scope): func_node = functions[func_name] frame = Scope(caller_scope) # 新栈帧作为调用者作用域的孩子 for name, val in zip(func_node.parameters, args): frame.define(name, val) call_stack.append(frame) # 压栈记录帧 result = execute_block(func_node.body, frame) call_stack.pop() # 执行完弹栈 return result这里有一个初学者容易绕不出来的点:为什么新函数体不是直接在全局作用域下新建作用域,而是要作为调用者当前作用域的孩子?原因在于,如果要实现“函数体内可以访问全局变量”这一特性,那么作用域链从函数体的局部作用域向上延伸时必须经过调用者的作用域链,才能最终到达全局。如果你的语言严格规定函数只能访问全局变量和自身局部变量,那直接用全局作用域做父亲也没问题。但考虑到 C 语言实际允许在嵌套作用域里调用函数,采用调用者作用域作为父级,语义上更自然。
6.2 参数求值顺序
函数调用表达式CallExpr的求值逻辑是:先求值实参列表,再查函数表,然后调用。有一个实现细节值得注意——实参的求值顺序。C 标准对函数实参求值顺序并没有做严格的统一规定,不同编译器可能不同。我在实现里采用从左到右求值,因为这是最符合人类直觉的顺序,也基本不会引起奇怪的反常有感受。如果你自己扩展测试用例,要小心不要写出依赖求值顺序的程序,比如同一表达式里既修改一个变量又读取它,那是典型的未定义行为。
6.3 return 的实现与多层退出
return的难点在于它可以出现在任意深度的嵌套块内。如果函数体里有个循环,循环里有个 if,if 里写了return 42;,执行到这一行时需要立即终止整个函数体,而不仅仅是退出 if、退出循环。这正是我在语句执行里提到的控制信号方案发挥作用的地方:
elif node.type == "ReturnStmt": return ("return", eval_expr(node.value, scope))每个嵌套的块都会把这个返回值原样传出去,直到最外层execute_block看到return标记,就停止整个函数体的执行。这个机制虽然朴素,但它完整反映了“直接控制流”的核心思想。如果去掉这层信号传递,return 就无法可靠地穿越多层控制结构。
6.4 递归的验证
完成函数调用之后,我做的第一件验证就是计算阶乘:
int factorial(int n) { if (n <= 1) { return 1; } return n * factorial(n - 1); } print(factorial(10));这个测试看起来简单,实际上将栈帧隔离、参数绑定、作用域链、控制信号传递全部串联在了一起。当看到输出3628800的一瞬间,我整个人都松了一口气——这意味着函数调用的机制已经完整打通了。
7. 错误处理与人机接口:编译器读完源码也要学会拒绝
写编译器最容易忽视的就是错误处理。很多新手的实现“乐观地假设输入永远是正确的”,一旦碰到不符合预期的 Token 就崩溃或者陷入死循环。但真实用户写出来的测试程序,几乎不可能一次通过。一个优秀的教学编译器,不仅要在正确输入上能工作,还要在错误输入前表现得足够体面——给出明确的错误位置和原因,而不是留下一屏无法理解的堆栈。
7.1 三类错误的明确划分
我把整个过程中可能出现的错误分为三类:
| 错误类型 | 发生阶段 | 典型表现 |
|---|---|---|
| 词法错误 | 词法分析 | 非法字符无法形成任何 Token |
| 语法错误 | 语法分析 | Token 序列不符合语法,比如少了一个右括号 |
| 语义错误 | 符号表查询 | 变量未声明、函数未定义、重复定义 |
每一类错误都在对应阶段抛出,并携带行号。行号在词法分析时就已经记录在 Token 里,哪怕语法错误发生在很靠后的 Token,我们依然能准确告知用户是源码第几行出了问题。这是刚开始写编译器时最容易忽略、之后又最值得补上的能力。
7.2 错误恢复是教学编译器的加分项
很多真实编译器在遭遇一个语法错误后并不会立刻停止,而是会进行“错误恢复”,跳过一部分 Token 后再尝试解析后续内容,以此尽量收集更多错误。真正的编译器工程里这是一门很深的学问。我的教学编译器选择了一种轻量策略:当解析器遇到语法错误时,把错误信息记录下来,然后跳过当前语句末尾的分号或右花括号,继续解析下一条语句。这样至少可以让用户在一次性修改多个简单错误时不必反复重跑程序。
这里也提醒一句:错误恢复做过头也会导致大量“连坐错误”,因为跳过一个分号可能连带让后续好几条语句都解析失败。一个平衡的做法是只记录当前语句错误,跳过这一条语句,然后从头解析下一条。如果你不想在错误恢复上花太多时间,完全可以直接报错退出——对教学项目来说,先保证正确输入下稳定执行远比错误恢复重要。
7.3 打印中间状态:开发期最有用的调试手段
我在开发过程中最好的调试工具不是断点调试器,而是把 Token 流和 AST 结构打印出来。为 AST 写一个dump方法,格式化输出每个节点类型、子节点、行号,几十行代码就能带来极大的排错便捷。比如解析器突然对某个语句解析异常,我只要 dump 一下 AST,立刻能看出IfStmt的condition是否意外变成了空节点或者把条件语句体内的表达式误认作条件。
这个习惯延伸之后还有一个额外收益:对 AST 的输出格式稍作改变,就能实现一个非常基础的“语法树可视化”,别人学习这个项目的时候也能更直观地看到int a = 1 + 2背后的树形结构。
8. 从解释器到编译器:后续扩展与我的实战教训
严格来说,我前面实现的这套程序更像“解释器”——直接对 AST 求值并返回结果,而不是生成目标机器码再执行。从解释器走向真正的编译器,核心要增加的环节是代码生成。如果你读完前面的内容后想继续深入,我建议按下面的顺序进阶:先是生成栈式虚拟机指令,再向下生成汇编。
8.1 第一步:生成字节码而不是立刻蹦到汇编
直接从 AST 跳到汇编对新手很不友好,中间隔着指令分配、寄存器分配、栈布局等一大堆复杂问题。最平滑的路径是先设计一套极简的栈式虚拟机指令集,比如:
PUSH_NUMBER 10 # 将立即数 10 压栈 PUSH_VAR a # 将变量 a 的值压栈 ADD # 弹出两个值,求和,结果压栈 STORE_VAR b # 弹出栈顶值,存入变量 b JMP_IF_FALSE label # 弹出栈顶值,为假则跳转 CALL factorial # 调用函数 RET # 返回AST 求值阶段逻辑已经验证过你对语义的理解,这时候只是把每个节点的求值等效为一段字节码序列,难度要小很多。代码生成器跑通之后,再加一个微型虚拟机执行字节码,你就拥有了一条完整的“编译+执行”流水线。
8.2 第二步:再向汇编前进
字节码引擎稳定后,你就可以尝试把同样的指令序列转换成某一种汇编语言,比如 x86-64 或某个平台的简化汇编。这一步的工作重心会转移到寄存器分配、栈帧布局和调用约定上。这时候你会猛然发现,之前设计的作用域链、函数栈帧模型全部派上了用场——它们就是软件世界里对硬件机器行为的抽象模拟。
8.3 我踩过的几个值得分享的坑
最后分享几个我真实踩过、排查时间最长的坑:
第一个坑是把==误判为=。由于我在词法阶段先匹配了单字符赋值符,所有if (a == b)都被解析成了“给 a 赋值为 = b”,错误信息还极其隐蔽。修复后我规定:词法阶段永远优先匹配最长的运算符,这个规则后来帮我规避了所有双字符运算符引发的疑难问题。
第二个坑是函数作用域误用了全局作用域作为父亲。那段时间函数内只要访问全局变量就会报“未定义”。排查到最后,发现错误是我在创建函数帧时把Scope(global_scope)写成了Scope(None),等于把函数局部作用域和全局作用域彻底隔离开。这个例子足以说明作用域链在每个环节都极其重要。
第三个坑是while 循环里遇到 break 时,信号没有向外层传递。当时的执行逻辑在execute_block里看到了 break 信号,但没有让循环节点感知到,于是 break 只是退出了当前这一轮的块,while 的下一次检查又继续执行了。正确做法是把控制信号从块执行函数原样返回到循环节点。
8.4 关于学习路径的个人建议
如果你也想动手实现一个自己的编译器,我的建议是:不要追求一开始就做成完整的语言,哪怕只支持整数加法和变量赋值都值得动手。重要的是把整条链路从头到尾走通一次,感受“源码变成数据结构、数据结构驱动执行”的思维模式转变。
等我写完整个项目之后最大的感受是:编译器不是玄学,它从文本到结果一共就那几个大步骤,每一个步骤都可以拆成独立的小目标去攻克。而且一旦亲手完成了一版,市面上大多数脚本语言和 DSL 的设计思路,你都会看得比之前通透很多。这趟开发之旅的性价比,远比它看起来要高。