1. 项目概述:为什么我们要“手搓”一个编译器?
如果你是一名程序员,每天敲着if、for、int这些关键字,看着代码在屏幕上运行,有没有那么一刻好奇过,你写的这些文本,机器到底是怎么“看懂”并执行的呢?编译器,就是这个将人类可读的“高级语言”翻译成机器可执行的“低级语言”的神秘黑盒。网上总有人讨论“手搓编译器”,听起来高深莫测,仿佛是大神们的专属领域。但今天我想告诉你,从零构建一个能处理简单算术表达式的编译器,其核心思想远比想象中直观。这不仅是计算机科学的一次深刻实践,更是理解编程语言本质、提升代码抽象能力的绝佳路径。无论你是想夯实基础的学生,还是希望深入理解工具链的开发者,这个项目都能让你对“代码如何变成程序”有一个清晰、透彻的认识。
我们这次的目标很明确:不依赖任何庞大的编译器框架(如LLVM、GCC),纯粹用你熟悉的编程语言(比如Python、Java或C++),实现一个能处理类似(3 + 5) * 2这样表达式的微型编译器。最终,它能将这段文本转换成某种形式的低级代码(比如汇编指令或虚拟机字节码)。整个过程我们将拆解为三个经典阶段:词法分析、语法分析和代码生成。别被术语吓到,接下来我会用最“说人话”的方式,带你一步步走完这个奇妙的旅程。
2. 核心思路与整体设计:编译器的“流水线”模型
一个典型的编译器工作流程,很像一条工厂流水线。原材料(源代码文本)从一端进入,经过多个车间的加工,最终成品(目标代码)从另一端出来。我们的微型编译器将模拟这条流水线的核心部分。
2.1 编译器核心阶段拆解
词法分析(Lexical Analysis):这是流水线的第一个车间,任务是把一长串字符流“切碎”成有意义的单词(Token)。想象一下读英文句子,你会自然地把“I love programming”分成“I”、“love”、“programming”三个单词。编译器也一样,它会把
(3+5)*2切分成:左括号、数字3、加号、数字5、右括号、乘号、数字2。每个“单词”都会被打上标签,比如NUMBER、PLUS、MULTIPLY、LPAREN等。这个阶段不关心单词之间的关系,只负责识别和分类。语法分析(Syntax Analysis):第二个车间接收来自词法分析的一串Token,然后检查它们是否符合预定的语法规则,并构建出一棵“语法树”。这就像检查“I programming love”是否符合英语的语法结构。对于表达式
(3+5)*2,语法分析器会识别出这是一个乘法运算,乘法的左操作数是一个括号内的加法表达式(3+5),右操作数是数字2。最终形成的树状结构(抽象语法树,AST)清晰地表达了运算的优先级和结合性。代码生成(Code Generation):最后一个车间遍历语法树,为每一个树节点生成对应的低级指令。比如,遇到加法节点,就生成一条加法指令;遇到乘法节点,就生成一条乘法指令。同时,它还需要处理运算顺序、临时变量的分配等问题。最终输出可能是一段简单的汇编代码,或者是一种自定义的栈式虚拟机指令。
2.2 我们的技术选型与设计考量
为什么选择实现一个算术表达式编译器作为起点?
- 目标聚焦:算术表达式涵盖了编译器处理的核心问题:运算符优先级(乘除高于加减)、结合性(左结合)、括号改变优先级。解决了它,就理解了编译器处理复杂结构的基础。
- 闭环验证:输入是字符串,输出是可计算的结果或低级代码,整个流程可以形成一个完整的、可验证的闭环,成就感强。
- 易于扩展:在此框架上,后续可以相对容易地加入变量赋值、控制流(if/while)、函数调用等特性。
关于实现语言的选择,我推荐使用Python。原因如下:
- 开发效率高:Python强大的字符串处理能力和清晰的数据结构(列表、字典、类)能让快速原型开发。
- 聚焦算法逻辑:无需过多关注内存管理等底层细节,可以把精力完全集中在编译原理的核心算法上。
- 可视化调试方便:可以轻松打印和查看Token流、语法树的结构,便于理解和调试。
当然,你用C++或Java来实现也完全可以,这能让你对性能和控制有更深体会。但首次实践,我强烈建议用Python来降低入门门槛,先跑通整个流程,建立信心。
注意:这个项目的目的不是打造一个产品级的编译器,而是教学和深刻理解。我们会刻意简化很多工业级编译器需要考虑的复杂问题(如类型系统、优化、错误恢复等),直击最核心的骨架。
3. 第一阶段实战:词法分析器(Lexer)的实现
词法分析器,我们常亲切地称之为“分词器”。它的任务就是读入源代码字符串,输出一个标记(Token)序列。
3.1 Token的设计与定义
首先,我们需要定义我们的“单词”有哪些类型。对于算术表达式,我们至少需要以下几种:
# 定义Token类型 TT_INT = 'INT' # 整数,如 3, 42 TT_FLOAT = 'FLOAT' # 浮点数,如 3.14 (我们本次简化,只处理整数) TT_PLUS = 'PLUS' # 加号 + TT_MINUS = 'MINUS' # 减号 - TT_MUL = 'MUL' # 乘号 * TT_DIV = 'DIV' # 除号 / TT_LPAREN = 'LPAREN' # 左括号 ( TT_RPAREN = 'RPAREN' # 右括号 ) TT_EOF = 'EOF' # 文件结束符,表示输入已处理完毕 class Token: def __init__(self, type_, value=None): self.type = type_ # Token类型,如 TT_INT self.value = value # Token的值,如对于数字Token,value就是具体的数字3 def __repr__(self): return f'Token({self.type}, {self.value})' if self.value else f'Token({self.type})'3.2 构建Lexer:逐字符扫描的艺术
Lexer的核心是一个指针(pos),它从头到尾扫描输入字符串,根据当前字符决定生成什么Token。
class Lexer: def __init__(self, text): self.text = text # 源代码字符串 self.pos = 0 # 当前字符索引位置 self.current_char = self.text[self.pos] if self.text else None # 当前字符 def error(self): raise Exception('Invalid character') def advance(self): """移动指针到下一个字符""" self.pos += 1 if self.pos < len(self.text): self.current_char = self.text[self.pos] else: self.current_char = None def skip_whitespace(self): """跳过所有空白字符(空格、制表符、换行)""" while self.current_char is not None and self.current_char.isspace(): self.advance() def number(self): """提取一个完整的数字(多位数)""" result = '' while self.current_char is not None and self.current_char.isdigit(): result += self.current_char self.advance() # 目前只处理整数,返回TT_INT类型的Token return Token(TT_INT, int(result)) def get_next_token(self): """词法分析的核心方法,每次调用返回下一个Token""" while self.current_char is not None: # 跳过空白 if self.current_char.isspace(): self.skip_whitespace() continue # 识别数字 if self.current_char.isdigit(): return self.number() # 识别运算符和括号 if self.current_char == '+': self.advance() return Token(TT_PLUS) if self.current_char == '-': self.advance() return Token(TT_MINUS) if self.current_char == '*': self.advance() return Token(TT_MUL) if self.current_char == '/': self.advance() return Token(TT_DIV) if self.current_char == '(': self.advance() return Token(TT_LPAREN) if self.current_char == ')': self.advance() return Token(TT_RPAREN) # 如果遇到无法识别的字符,报错 self.error() # 所有字符处理完毕,返回EOF Token return Token(TT_EOF)实操心得与避坑指南:
- 指针管理是关键:
advance()方法一定要在适当的时候调用,确保current_char始终指向待处理的字符。忘记advance()是新手最常见的导致死循环的原因。 - 空白字符处理:必须在主循环开始时就处理空白,否则会遇到
' '字符无法识别而报错。 - 数字的贪婪匹配:
number()方法要用while循环收集连续的数字字符,直到遇到非数字为止。这确保了123被识别为一个TokenINT(123),而不是三个独立的INT(1),INT(2),INT(3)。 - EOF必不可少:
EOFToken 是语法分析器知道输入何时结束的信号,没有它,语法分析器可能会试图读取不存在的Token而导致错误。
你可以写个简单的测试来验证你的Lexer:
def test_lexer(): lexer = Lexer('(3 + 5) * 2') tokens = [] while True: token = lexer.get_next_token() tokens.append(token) if token.type == TT_EOF: break print(tokens) # 期望输出: [Token(LPAREN, None), Token(INT, 3), Token(PLUS, None), Token(INT, 5), Token(RPAREN, None), Token(MUL, None), Token(INT, 2), Token(EOF, None)]4. 第二阶段实战:语法分析器(Parser)与抽象语法树(AST)
拿到Token流后,语法分析器要判断它是否符合我们定义的语法规则,并构建出树形结构。我们采用递归下降的解析方法,这是最直观、最适合手写解析器的方法。
4.1 定义语法与AST节点
首先,我们用巴科斯范式(BNF)描述一下简单表达式的语法:
expr : term ((PLUS | MINUS) term)* term : factor ((MUL | DIV) factor)* factor : INT | LPAREN expr RPARENexpr(表达式)由term(项)和可选的加减运算组成。term(项)由factor(因子)和可选的乘除运算组成。factor(因子)可以是一个整数,或者一个括号括起来的expr。 这种分层定义天然地赋予了乘除比加减更高的优先级。
对应的,我们需要定义AST的节点类:
class BinOpNode: """二元操作节点,如 3 + 4, 5 * 2""" def __init__(self, left_node, op_token, right_node): self.left_node = left_node self.op_token = op_token # 操作符Token,如 PLUS, MUL self.right_node = right_node class NumNode: """数字节点""" def __init__(self, token): self.token = token self.value = token.value4.2 实现递归下降语法分析器
Parser会持有Lexer实例,并按照语法规则递归地调用对应的方法。
class Parser: def __init__(self, lexer): self.lexer = lexer self.current_token = self.lexer.get_next_token() # 初始化当前Token def error(self): raise Exception('Invalid syntax') def eat(self, token_type): """“消耗”当前Token,如果类型匹配则获取下一个Token""" if self.current_token.type == token_type: self.current_token = self.lexer.get_next_token() else: self.error() def factor(self): """解析因子: INT 或 ( expr )""" token = self.current_token if token.type == TT_INT: self.eat(TT_INT) return NumNode(token) elif token.type == TT_LPAREN: self.eat(TT_LPAREN) node = self.expr() # 递归解析括号内的表达式 self.eat(TT_RPAREN) return node else: self.error() def term(self): """解析项: factor ((MUL | DIV) factor)*""" node = self.factor() # 先解析第一个因子 # 循环处理连续的乘除 while self.current_token.type in (TT_MUL, TT_DIV): op_token = self.current_token if op_token.type == TT_MUL: self.eat(TT_MUL) elif op_token.type == TT_DIV: self.eat(TT_DIV) right_node = self.factor() # 解析右边的因子 node = BinOpNode(left_node=node, op_token=op_token, right_node=right_node) return node def expr(self): """解析表达式: term ((PLUS | MINUS) term)*""" node = self.term() # 先解析第一个项 # 循环处理连续的加减 while self.current_token.type in (TT_PLUS, TT_MINUS): op_token = self.current_token if op_token.type == TT_PLUS: self.eat(TT_PLUS) elif op_token.type == TT_MINUS: self.eat(TT_MINUS) right_node = self.term() # 解析右边的项 node = BinOpNode(left_node=node, op_token=op_token, right_node=right_node) return node def parse(self): """解析的入口,返回整个表达式的AST根节点""" return self.expr()核心逻辑解析与避坑点:
- 递归下降的精髓:每个语法规则(
expr,term,factor)对应一个函数。函数内部按照规则调用其他函数或处理Token。factor中遇到括号时递归调用expr,完美体现了“递归”和“下降”(从高层规则向底层规则解析)。 - 优先级处理:注意
expr调用term,term调用factor。这意味着在解析3 + 5 * 2时,expr()先调用term()去解析5 * 2(得到一个乘法节点),然后再用这个节点作为右节点,与左边的3组成加法节点。乘法在更深的调用层级中被解析,从而获得了更高的优先级。 - 左结合性的实现:
while循环是关键。node = BinOpNode(left_node=node, ...)这行代码将当前运算结果不断作为新运算的左节点,实现了左结合。对于10 - 2 - 1,它会生成((10 - 2) - 1)的AST,而不是(10 - (2 - 1))。 eat()方法的重要性:它不仅是消耗Token,更是语法检查。如果当前Token不符合预期,直接报错,比如在期望数字的地方遇到了加号。
测试一下Parser和AST:
def test_parser(): lexer = Lexer('(3 + 5) * 2') parser = Parser(lexer) ast = parser.parse() # 可以写一个简单的打印函数来可视化AST print_ast(ast) # 期望的AST结构大致是: # MUL # / \ # PLUS 2 # / \ # 3 55. 第三阶段实战:代码生成器(Code Generator)
有了结构清晰的AST,代码生成就变成了按特定顺序遍历这棵树,并为每个节点“做点事情”。我们将生成一种非常简单的、基于栈的指令集,模拟一个微型虚拟机的执行过程。
5.1 设计我们的“汇编指令”
为了简单,我们设计三条指令:
PUSH:将一个整数压入栈顶。ADD:弹出栈顶两个元素,相加,结果压回栈顶。MUL:弹出栈顶两个元素,相乘,结果压回栈顶。
那么,表达式(3 + 5) * 2对应的指令序列应该是:
PUSH 3 PUSH 5 ADD # 执行后栈顶为 8 PUSH 2 MUL # 执行后栈顶为 16执行完毕后,栈顶的值就是计算结果。
5.2 遍历AST生成指令
我们采用后序遍历(左子树 -> 右子树 -> 根节点)的方式来生成代码。这是因为对于二元运算,你需要先计算左操作数和右操作数的值(它们各自可能又是复杂的表达式),然后再进行运算。
class CodeGenerator: def __init__(self): self.instructions = [] # 存储生成的指令列表 def generate(self, node): """根据AST节点生成代码""" if isinstance(node, NumNode): # 数字节点:生成PUSH指令 self.instructions.append(f'PUSH {node.value}') elif isinstance(node, BinOpNode): # 二元操作节点:先递归生成左、右子树的代码,再生成操作指令 self.generate(node.left_node) self.generate(node.right_node) # 根据操作符类型生成对应指令 if node.op_token.type == TT_PLUS: self.instructions.append('ADD') elif node.op_token.type == TT_MINUS: self.instructions.append('SUB') # 假设我们也实现了SUB指令 elif node.op_token.type == TT_MUL: self.instructions.append('MUL') elif node.op_token.type == TT_DIV: self.instructions.append('DIV') # 假设我们也实现了DIV指令 else: raise Exception('Invalid operator') else: raise Exception('Invalid AST node') def get_code(self): """返回生成的指令列表""" return self.instructions5.3 实现一个简单的解释器来执行指令
生成指令后,我们还需要一个能执行这些指令的“虚拟机”。
class Interpreter: def __init__(self, instructions): self.instructions = instructions self.stack = [] def run(self): for instr in self.instructions: parts = instr.split() op = parts[0] if op == 'PUSH': value = int(parts[1]) self.stack.append(value) elif op == 'ADD': b = self.stack.pop() a = self.stack.pop() self.stack.append(a + b) elif op == 'MUL': b = self.stack.pop() a = self.stack.pop() self.stack.append(a * b) # 可以类似地实现 SUB, DIV # 执行完毕后,栈顶应只剩下结果 if len(self.stack) == 1: return self.stack[0] else: raise Exception('Execution error, stack is not clean.')代码生成的关键细节与经验:
- 遍历顺序决定一切:后序遍历保证了操作数在操作符之前被计算和压栈,这是栈式计算的核心。如果你用前序或中序遍历,生成的指令顺序将是错误的。
- 指令集的设计:我们这里用了非常简单的零地址指令(操作隐含从栈中取)。在实际编译器中,可能会生成x86、ARM等真实汇编,或者LLVM IR、JVM字节码等中间表示,原理相通,但细节复杂得多。
- 临时结果的处理:栈机自动处理了中间结果。在更复杂的场景或生成真实寄存器代码时,你需要一个“寄存器分配器”来管理有限的硬件寄存器,这又是一个深水区。
现在,让我们把三个阶段串联起来,完成整个编译流程:
def compile_and_run(source_code): # 1. 词法分析 lexer = Lexer(source_code) # 2. 语法分析 parser = Parser(lexer) ast = parser.parse() # 3. 代码生成 generator = CodeGenerator() generator.generate(ast) code = generator.get_code() print(f"生成的指令: {code}") # 4. 解释执行 interpreter = Interpreter(code) result = interpreter.run() print(f"计算结果: {result}") return result # 测试! compile_and_run('(3 + 5) * 2') # 输出:生成的指令: ['PUSH 3', 'PUSH 5', 'ADD', 'PUSH 2', 'MUL'], 计算结果: 16 compile_and_run('10 - 2 - 1') # 需要扩展支持减法6. 常见问题、扩展方向与深度思考
走到这一步,一个微型编译器已经在你手中运行起来了。但真实的编译器远比这复杂。下面是一些你可能会遇到的问题和可以继续探索的方向。
6.1 开发与调试中的常见陷阱
无限递归或栈溢出:
- 原因:语法规则定义存在左递归(例如
expr: expr PLUS term),在递归下降解析器中会无限调用自身。 - 解决:必须将文法改写为等价的非左递归形式(如我们使用的
expr: term ((PLUS | MINUS) term)*)。这是手写递归下降解析器必须掌握的第一步。
- 原因:语法规则定义存在左递归(例如
优先级错乱:
- 现象:
3 + 5 * 2被计算成16而不是13。 - 检查:回顾你的语法规则层级。确保乘除(
term)在加减(expr)的下一层被调用。expr -> term -> factor这个调用链是优先级正确的保证。
- 现象:
结合性错误:
- 现象:
10 - 2 - 1被计算成9(即10 - (2 - 1)) 而不是7。 - 解决:确保在
expr()和term()函数中,用while循环和node = BinOpNode(left_node=node, ...)这种模式来构造AST,这天然实现了左结合。
- 现象:
错误处理过于简陋:
- 现状:我们的
error()方法直接抛出异常,然后程序崩溃。 - 改进:工业级编译器会收集错误、尝试恢复并继续解析,以报告更多错误。你可以尝试实现一个错误列表,在遇到未知字符或语法错误时,记录错误信息,跳过当前Token或进行一些“应急”处理,然后继续解析。
- 现状:我们的
6.2 如何扩展这个微型编译器?
这个项目是一个完美的起点,你可以像搭积木一样为它添加新功能:
支持变量:
- 词法分析:增加识别标识符(字母开头的字符串)的Token类型
TT_ID。 - 语法分析:在
factor中增加对标识符的解析。增加赋值语句的规则,如assignment : ID EQUALS expr。 - 代码生成:需要维护一个“符号表”来记录变量名和它的值(或存储位置)。生成
LOAD和STORE指令。
- 词法分析:增加识别标识符(字母开头的字符串)的Token类型
支持控制流(if/while):
- 词法分析:增加关键字Token,如
IF,ELSE,WHILE,TRUE,FALSE,以及比较运算符==,>,<等。 - 语法分析:增加条件表达式和语句块的规则。控制流的核心是跳转指令。
- 代码生成:需要生成条件跳转(
JMP_IF_FALSE)和无条件跳转(JMP)指令。生成代码时需要解决“回填”问题——即跳转的目标地址在生成跳转指令时可能还不知道。
- 词法分析:增加关键字Token,如
支持函数:
- 这是质的飞跃。需要处理参数传递、调用栈、返回地址、返回值。你会深入理解栈帧、活动记录等概念。
生成真实汇编:
- 将我们的栈式指令替换为x86或RISC-V汇编。你需要学习目标平台的指令集和调用约定。例如,
PUSH变成mov和push指令的组合,运算在寄存器中进行。
- 将我们的栈式指令替换为x86或RISC-V汇编。你需要学习目标平台的指令集和调用约定。例如,
6.3 从“玩具”到“工具”的思考
完成这个项目后,你再去看GCC、Clang这些工业编译器,或者解释型语言的解释器(如Python的CPython),就会有一种豁然开朗的感觉。它们庞大的代码库,无非是在我们这个小骨架的基础上,填充了无数细节:
- 前端:更复杂的词法语法分析(处理整个C++/Rust语言)、语义分析(类型检查、作用域分析)。
- 中端:多种中间表示(IR)、大量的优化遍(Pass),如常量传播、死代码消除、循环优化。
- 后端:指令选择、指令调度、寄存器分配、窥孔优化,针对不同CPU架构(x86, ARM, RISC-V)生成最优代码。
“手搓编译器”的过程,强迫你去思考语言设计的本质,理解代码从抽象到具体的完整映射。这种系统性的思维训练,对你设计复杂软件、调试深层Bug、理解任何新语言或框架,都有着不可估量的价值。下次当你再遇到编译错误或神秘的运行时问题时,你看到的将不再是一行冰冷的报错信息,而是一整套正在运转的翻译和执行机制中的某个具体环节。这,就是知其所以然的力量。