从零构建编译器:手写词法分析、语法解析与代码生成实践
1. 项目概述:为什么我们要亲手造一个编译器?
如果你是一个写过几年代码的程序员,大概率已经用过无数编译器了。从gcc编译 C 语言,到javac处理 Java 文件,再到rustc构建 Rust 项目,编译器就像一个沉默的翻译官,把我们用高级语言写出的、充满逻辑和美感的代码,翻译成机器能直接执行的、冰冷的 0 和 1。但你是否好奇过,这个翻译官内部到底是如何工作的?它怎么知道int a = 10;是一个变量声明,而if (a > 5)是一个条件判断?它又是如何确保你写的代码在语法和语义上都是正确的?
这次,我们不满足于仅仅使用编译器,而是要亲手从零开始构建一个简单的编译器。这听起来像是一个庞大得吓人的工程,但别担心,我们不会去实现一个像 GCC 那样支持全特性 C++ 的工业级怪物。我们的目标是构建一个“玩具”编译器,它能处理一个极其简化的、我们自己定义的小语言。这个过程的重点不在于功能的完备性,而在于彻底搞懂编译器核心流程的每一个环节:词法分析、语法分析和代码生成。通过这个项目,你将不再对编译过程感到神秘。你会明白,所谓的“编译”,本质上就是一套严谨的、将一种结构化文本(源代码)转换为另一种结构化文本(目标代码)的规则引擎。
理解编译器技术,价值远超“造轮子”本身。它能让你在调试时,一眼看穿“语法错误在第几行”背后的原理;能让你在优化代码时,理解编译器可能会做哪些转换;更能为你打开一扇门,未来当你需要为特定领域(比如游戏脚本、配置文件、自动化规则)设计一门小巧的领域特定语言(DSL)时,你知道从哪里开始。本文,我将带你走一遍我实现这个简单编译器的完整路径,分享其中关键的决策、踩过的坑,以及最终看到自己定义的语言成功运行时的成就感。我们会聚焦于三个核心阶段:用词法分析器把字符流变成有意义的单词(Token),用语法分析器(这里采用递归下降法)把这些单词组装成树形结构(AST),最后遍历这棵树,生成可执行的目标代码。
2. 核心流程拆解:编译器的“流水线”作业
一个典型的编译器,可以看作一条精密的工业流水线。源代码从一端输入,经过一系列工序的加工,最终从另一端输出目标代码。每一道工序都有其明确的职责和产出物,并且工序之间通过标准化的“半成品”进行交接。理解这条流水线的分工,是构建编译器的第一步。
2.1 经典编译流程全景
虽然现代编译器(如 LLVM、GCC)的内部结构异常复杂,加入了大量优化和多阶段处理,但其主干流程依然遵循着数十年前确立的经典模型。这个模型通常包括以下六个主要阶段:
词法分析:这是流水线的第一站。它的任务最简单也最基础:读入源代码的字符流(就是一长串字符),然后根据预定义的规则(比如哪些字符组合在一起是关键字,哪些是标识符,哪些是数字),将其切割成一个个有意义的“单词”,在编译原理中称为Token。例如,对于代码
sum = a + 100;,词法分析器会产出类似这样的 Token 序列:[标识符“sum”], [赋值符“=”], [标识符“a”], [加号“+”], [整数“100”], [分号“;”]。它不关心这些 Token 的顺序是否合理,那是下一道工序的事。语法分析:流水线的第二站,也是核心中的核心。它接收词法分析产出的 Token 流,并根据我们为编程语言定义的语法规则(通常用上下文无关文法,如 BNF 或 EBNF 来描述),检查这个 Token 序列是否符合语法结构,并同时构建出一棵抽象语法树。AST 是源代码逻辑结构的树形表示,它抛弃了像空格、注释、分号这样的细节,只保留程序的结构骨架。例如,对于
sum = a + 100;,语法分析器会构建出一棵以赋值语句为根,左边是变量“sum”,右边是一个加法表达式(其左右子节点分别是变量“a”和常量“100”)的树。语义分析:这一站是“逻辑检察官”。它遍历 AST,进行上下文相关的检查。语法分析只确保“句子”结构正确,而语义分析确保“句子”意思合理。主要工作包括:类型检查(比如不能把一个字符串赋值给整型变量)、变量声明检查(使用变量前是否已经声明)、函数调用匹配(实参和形参的个数、类型是否匹配)等。对于我们的简单编译器,语义分析可能会大大简化,可能只检查变量是否先声明后使用。
中间代码生成与优化:经过语义分析的 AST 是高级的、与机器无关的。为了最终生成机器码,一个常见的步骤是将其转换为一种更简单、更接近机器指令的中间表示,比如三地址码或 LLVM IR。这样做的好处是,可以将与机器无关的优化(如常量折叠、公共子表达式消除)集中在此处进行,使得编译器的前端(语言相关部分)和后端(机器相关部分)得以解耦。我们的玩具编译器为了简化,可能会跳过独立的中间代码表示,直接从 AST 生成目标代码。
目标代码生成:流水线的最后一站。它将优化后的中间代码(或直接是 AST)映射到特定目标机器(如 x86、ARM)的指令集上,生成最终的汇编代码或机器码。这需要处理复杂的细节,如寄存器分配、指令选择、栈帧管理等。
汇编与链接:严格来说,这已不属于“编译器核心”,而是编译过程的后续阶段。代码生成器产出的是汇编代码,需要汇编器将其转换为目标文件(机器码片段),再由链接器将多个目标文件以及库文件合并成一个完整的可执行文件。
对于我们“从零开始”的目标,我们将重点关注并实现前两个阶段(词法、语法)和最后一个阶段(代码生成),搭建一个最小可行模型。语义分析和优化,我们会在力所能及的范围内以简化形式融入。
2.2 我们的简化实现策略
面对完整的编译流程,我们需要制定一个切实可行的简化策略,确保项目能在有限时间内完成,同时又能触及核心原理。
- 语言设计:我们不会去编译 C 或 Python 的子集。相反,我们将自己定义一门超级简单的语言。例如,它可能只支持整数类型的变量声明、赋值、加减乘除四则运算、
print输出语句,以及简单的if条件判断。这极大地降低了语法和语义的复杂度。定义语言的过程,其实就是定义词法规则和语法规则的过程,这本身就是一个极好的学习体验。 - 语法分析算法选择:语法分析的算法有很多,如 LL(1)、LR(1)、LALR 等。递归下降分析法因其直观、易于手工实现、特别适合 LL(1) 文法的特点,成为我们入门的不二之选。它本质上就是为语言的每个语法结构(如表达式、语句)编写一个对应的递归函数,函数体通过调用其他函数或匹配 Token 来反映语法规则。
- 目标代码选择:生成真实的机器码(如 x86)过于复杂,涉及指令集和系统调用。一个更友好的选择是生成一种栈式虚拟机的字节码,或者干脆生成另一门高级语言(如 C 或 Python)的代码。后者实现起来最简单:我们的编译器变成一个“源代码到源代码”的转换器。例如,将我们自定义语言的代码转换成等价的 Python 代码,然后用 Python 解释器去执行。这让我们可以绕过复杂的底层细节,专注于核心的翻译逻辑。本文将采用生成 Python 代码作为目标输出的方案。
- 工具链:为了聚焦原理,我们不使用 Lex/Yacc 或 ANTLR 这类编译器生成工具。我们将用纯手写代码的方式实现词法分析器和递归下降语法分析器。这能让你对每一个细节都有掌控感。
注意:选择手写和生成高级语言代码,是学习阶段的“捷径”。它牺牲了性能和教育意义(如寄存器分配),但换来了更快的反馈闭环和更清晰的核心逻辑展示。当你掌握了这些核心后,可以尝试挑战生成汇编代码或实现一个简单的栈式虚拟机。
3. 阶段一:词法分析器——从字符到单词
词法分析器,也叫扫描器,是编译器的“眼睛”。它的工作模式很像我们阅读:我们不会一个字母一个字母地读,而是自动将连续的字符组合成有意义的单词。词法分析器就是模拟这个过程。
3.1 定义语言的词法规则
在写代码之前,我们必须先定义好我们的“微型语言”有哪些合法的“单词”。这包括:
- 关键字:语言中具有特殊含义的保留字。例如:
let(用于变量声明),if,else,print。 - 标识符:用于命名变量、函数等。通常以字母或下划线开头,后跟字母、数字或下划线。如
myVar,count1。 - 字面量:直接表示值的符号。
- 整数:如
0,123,-45。 - 字符串(如果支持):如
"hello"。
- 整数:如
- 运算符:用于运算或操作。
- 算术运算符:
+,-,*,/,% - 比较运算符:
==,!=,>,<,>=,<= - 赋值运算符:
=
- 算术运算符:
- 分隔符:用于分隔不同语法单元。
- 括号:
(,),{,} - 语句结束:
; - 逗号:
,(用于参数列表)
- 括号:
我们需要为每一类 Token 定义一个类型(一个枚举值),并记录其具体的文本值(lexeme)和它在源代码中的位置(行号、列号),以便后续报错。
3.2 手写词法分析器的核心逻辑
词法分析器通常是一个函数或一个类,其核心是一个循环,每次调用它,它就从源代码字符流中读取并返回下一个 Token。
核心流程如下:
- 跳过空白字符:循环读取字符,忽略空格、制表符、换行符。换行符需要记录行号增加。
- 查看当前字符:根据当前字符决定如何识别下一个 Token。
- 识别数字:如果当前字符是数字,则持续读取后续的数字字符,直到遇到非数字字符,然后将这段字符转换为整数,生成一个
INTEGER类型的 Token。 - 识别标识符和关键字:如果当前字符是字母或下划线,则持续读取后续的字母、数字或下划线。读取完成后,得到的字符串去关键字表中查找。如果找到,则生成对应的关键字 Token(如
LET);否则,生成IDENTIFIERToken。 - 识别运算符和分隔符:有些运算符是单个字符(如
+,-),有些是双字符(如==,!=,>=)。需要“向前看”一个字符来判定。例如,读到=,再看下一个字符是不是=,如果是,则生成EQToken,否则生成ASSIGNToken。 - 处理注释(可选):如果支持单行注释(如
//),在遇到两个连续的/时,需要一直读取字符直到行尾,然后回到步骤1。 - 处理字符串字面量(可选):如果遇到引号,则读取直到下一个匹配的引号,中间的内容作为字符串值。
- 处理文件结束:当读到源代码末尾时,返回一个特殊的
EOFToken。
实操心得与避坑指南:
- “向前看”缓冲:识别双字符运算符时,我们“偷看”了下一个字符。但识别完成后,这个字符已经被从输入流中消耗了。为了不丢失它,一种常见的做法是实现一个
peek()方法,它返回下一个字符但不移动读取位置。或者,在消耗了字符后,将其暂存起来,如果组合不成立再“放回去”(回退)。 - 位置信息至关重要:务必在 Token 中保存行号和列号(起始位置)。当语法或语义分析阶段发现错误时,你需要能准确地告诉用户“错误发生在第X行第Y列附近”。我最初忽略了列号,当一行中有多个错误时,报错信息非常模糊。
- 关键字表的优化:关键字可以硬编码在识别标识符的逻辑中。更优雅的做法是使用一个
HashMap或Set,将关键字字符串映射到对应的 Token 类型。这样判断一个标识符是否为关键字就是一次高效的哈希查找。 - 错误恢复:如果遇到无法识别的字符(比如
@),词法分析器不应该直接崩溃。合理的做法是生成一个ERRORToken 或跳过该字符并记录一个错误,然后尝试继续分析后面的代码,以便在一次编译中收集多个词法错误。
下面是一个极度简化的伪代码示例,展示了词法分析器的核心循环结构:
class Lexer: def __init__(self, source_code): self.source = source_code self.pos = 0 # 当前位置索引 self.line = 1 # 当前行号 self.column = 1 # 当前列号 self.keywords = {'let': TokenType.LET, 'if': TokenType.IF, 'print': TokenType.PRINT} def next_token(self): self.skip_whitespace() if self.pos >= len(self.source): return Token(TokenType.EOF, "", self.line, self.column) current_char = self.source[self.pos] # 识别数字 if current_char.isdigit(): return self.read_number() # 识别标识符或关键字 elif current_char.isalpha() or current_char == '_': return self.read_identifier() # 识别双字符运算符 (如 ==, !=) elif current_char == '=': if self.peek() == '=': self.advance() return self.make_token(TokenType.EQ, "==") else: return self.make_token(TokenType.ASSIGN, "=") # 识别其他单字符Token elif current_char == '+': return self.make_token(TokenType.PLUS, "+") elif current_char == ';': return self.make_token(TokenType.SEMICOLON, ";") # ... 处理其他字符 else: # 无法识别的字符 error_token = self.make_token(TokenType.ERROR, current_char) self.advance() # 跳过这个错误字符,尝试继续 return error_token def read_number(self): start_pos = self.pos while self.pos < len(self.source) and self.source[self.pos].isdigit(): self.advance() lexeme = self.source[start_pos:self.pos] return Token(TokenType.INTEGER, int(lexeme), self.line, self.column) def read_identifier(self): start_pos = self.pos while self.pos < len(self.source) and (self.source[self.pos].isalnum() or self.source[self.pos] == '_'): self.advance() lexeme = self.source[start_pos:self.pos] token_type = self.keywords.get(lexeme, TokenType.IDENTIFIER) return Token(token_type, lexeme, self.line, self.column) def advance(self): # 移动位置,更新行列号 if self.source[self.pos] == '\n': self.line += 1 self.column = 1 else: self.column += 1 self.pos += 1 def peek(self): # 查看下一个字符,不移动位置 if self.pos + 1 < len(self.source): return self.source[self.pos + 1] return '\0'4. 阶段二:语法分析与AST构建——从单词到树
词法分析器给了我们一袋零散的“积木”(Token),语法分析器的任务就是按照“图纸”(语法规则),把这些积木搭建成一个结构化的模型——抽象语法树。AST 是后续所有阶段(语义分析、代码生成)的基础。
4.1 定义语言的语法规则
我们需要用形式化的方法描述语言的结构。最常用的是巴科斯-诺尔范式或其扩展形式 EBNF。它用递归的方式定义了语言的构成。
例如,我们为微型语言定义部分语法:
program : statement* statement : let_statement | if_statement | print_statement | expression_statement let_statement : 'let' IDENTIFIER '=' expression ';' if_statement : 'if' '(' expression ')' block ('else' block)? print_statement: 'print' expression ';' expression_statement: expression ';' block : '{' statement* '}' expression : comparison comparison : addition (('==' | '!=' | '>' | '<' | '>=' | '<=') addition)* addition : multiplication (('+' | '-') multiplication)* multiplication : primary (('*' | '/') primary)* primary : INTEGER | IDENTIFIER | '(' expression ')'这些规则是递归下降解析器实现的直接蓝图。它们定义了语言的层次结构:一个程序由多个语句构成;语句可以是声明、if条件、打印或表达式;表达式则按照运算符优先级(比较 -> 加减 -> 乘除 -> 基础元素)层层分解。
4.2 递归下降解析器实现详解
递归下降法的核心思想是:为语法规则中的每一个非终结符(如program,statement,expression)编写一个对应的解析函数。每个函数负责从当前 Token 流的位置开始,尝试匹配它所代表的语法结构。如果匹配成功,就返回对应的 AST 节点;如果失败,则报告错误。
关键解析函数的设计:
parse_program():入口函数。循环调用parse_statement(),直到遇到EOFToken,将所有语句节点收集到一个列表,作为程序的根节点。parse_statement():根据当前 Token 的类型决定调用哪个具体的语句解析函数。例如,看到LET就调用parse_let_statement()。parse_let_statement():期望匹配模式LET IDENTIFIER ASSIGN expression SEMICOLON。它会消耗掉let关键字,然后解析一个标识符作为变量名,消耗掉=,再递归调用parse_expression()来解析赋值表达式,最后消耗掉;。最终生成一个LetStatementAST 节点,包含变量名和表达式子节点。parse_expression()与运算符优先级:这是最精妙的部分。直接按照语法规则递归调用即可,但要注意运算符优先级。上述文法通过多层规则(expression -> comparison -> addition -> multiplication -> primary)隐式地定义了优先级。parse_expression()直接调用parse_comparison()。在parse_comparison()中,它先调用parse_addition()解析左边的运算数,然后循环查看当前 Token 是否是比较运算符,如果是,就解析运算符,再解析右边的运算数,构建一个BinaryOp节点。parse_addition和parse_multiplication同理,这样就自然地实现了*/优先级高于+-,高于==>等比较运算符。parse_primary():处理最基本的表达式元素。如果是整数 Token,就创建一个IntegerLiteral节点;如果是标识符,就创建Identifier节点;如果是左括号,则消耗掉它,递归调用parse_expression()解析括号内的表达式,然后期望一个右括号并消耗掉。
构建AST节点:每个语法结构都对应一个 AST 节点类。这些类通常组织成一个继承体系。例如:
class ASTNode: pass class Statement(ASTNode): pass class Expression(ASTNode): pass class LetStatement(Statement): def __init__(self, name, value): self.name = name # 标识符节点 self.value = value # 表达式节点 class BinaryOp(Expression): def __init__(self, left, op, right): self.left = left self.op = op # Token类型,如 PLUS, MINUS, EQ self.right = right class IntegerLiteral(Expression): def __init__(self, value): self.value = value class Identifier(Expression): def __init__(self, name): self.name = name实操心得与避坑指南:
- 错误处理与同步恢复:当解析器遇到意外的 Token 时(例如在期望表达式的地方遇到了
}),它应该报告一个友好的错误信息(包含位置),并尝试从错误中恢复,继续解析后续代码。一种简单的“恐慌模式”恢复策略是:一直丢弃 Token,直到遇到一个已知的语句开始符(如let,if,print或})。这能防止一个错误导致整个解析过程崩溃。 - 左递归陷阱:如果你定义的文法存在直接或间接的左递归(例如
expression: expression '+' term),递归下降解析器会陷入无限递归。我们的文法通过将左递归改写为右递归(使用*循环)来避免这个问题(addition: multiplication (('+' | '-') multiplication)*)。这是手写递归下降解析器时必须处理的一个关键点。 - AST vs 具体语法树:AST 是“抽象”的,它省略了像分号、括号(除非用于改变优先级)这类不承载核心语义的标点符号。在构建节点时,确保只保留必要的信息。
- 调试可视化:为 AST 节点实现一个
__repr__或to_string方法,可以以缩进或树形格式打印 AST,这对于调试解析器是否正确工作至关重要。我经常在开发初期,每解析完一个函数就打印一下当前的 AST 片段,能快速定位问题。
5. 阶段三:代码生成——从树到可执行代码
有了结构良好的 AST,我们就有了一个完整的内存中的程序模型。代码生成器的工作就是遍历这棵树,并根据每个节点的类型,“翻译”成等价的、用目标语言(这里我们选择 Python)编写的代码。
5.1 设计代码生成策略
我们选择生成 Python 代码,这本质上是一个“源代码到源代码”的翻译。我们需要为每一种 AST 节点类型定义一个“生成”方法。
核心映射关系:
LetStatement-> Python 的变量赋值语句。例如,let x = 5+3;生成x = (5 + 3)。注意,我们简单的语言可能没有变量类型声明,而 Python 是动态类型,所以直接赋值即可。BinaryOp-> Python 的二元运算符表达式。大部分运算符(+,-,*,/,==,>)在 Python 中有直接对应。需要确保优先级,但我们的 AST 结构已经反映了优先级,所以按顺序生成子表达式即可,必要时添加括号。IntegerLiteral-> Python 的整数。Identifier-> Python 的变量名。PrintStatement-> Python 的print()函数调用。例如,print x;生成print(x)。IfStatement-> Python 的if-else语句。需要生成条件表达式、then分支的代码块和可选的else分支代码块。Block-> Python 的缩进代码块。我们需要在生成时管理缩进级别。
5.2 实现AST遍历与代码生成
通常采用访问者模式来实现代码生成器。为每个 AST 节点类定义一个accept(visitor)方法,然后编写一个CodeGenerator访问者类,该类为每种节点类型实现对应的visit_NodeType(node)方法。
一个更简单直接的方式是写一个递归函数,根据节点类型做分发:
class CodeGenerator: def __init__(self): self.output = [] self.indent_level = 0 def generate(self, node): if isinstance(node, Program): for stmt in node.statements: self.generate(stmt) return '\n'.join(self.output) elif isinstance(node, LetStatement): # 生成赋值语句 var_name = node.name.name # 递归生成右侧表达式代码 expr_code = self._generate_expression(node.value) line = f"{var_name} = {expr_code}" self._add_line(line) elif isinstance(node, PrintStatement): expr_code = self._generate_expression(node.expression) line = f"print({expr_code})" self._add_line(line) elif isinstance(node, IfStatement): cond_code = self._generate_expression(node.condition) self._add_line(f"if {cond_code}:") self.indent_level += 1 self.generate(node.then_branch) # 生成then块 self.indent_level -= 1 if node.else_branch: self._add_line("else:") self.indent_level += 1 self.generate(node.else_branch) # 生成else块 self.indent_level -= 1 # ... 处理其他节点类型 def _generate_expression(self, node): if isinstance(node, IntegerLiteral): return str(node.value) elif isinstance(node, Identifier): return node.name elif isinstance(node, BinaryOp): left = self._generate_expression(node.left) right = self._generate_expression(node.right) op_map = {TokenType.PLUS: '+', TokenType.MINUS: '-', TokenType.MULTIPLY: '*', TokenType.DIVIDE: '/', TokenType.EQ: '==', TokenType.GT: '>'} op = op_map.get(node.op, node.op.value) # 使用Token值或映射 # 为了安全,给非基础元素的子表达式加括号(可优化) return f"({left} {op} {right})" # ... 处理其他表达式节点 else: raise Exception(f"未知的表达式节点: {type(node)}") def _add_line(self, line): indent = " " * self.indent_level self.output.append(indent + line)生成代码示例:假设我们的源程序是:
let x = 10; let y = x * 2; if (y > 15) { print y; } else { print x; }经过我们的编译器,生成的 Python 代码将是:
x = 10 y = (x * 2) if (y > 15): print(y) else: print(x)5.3 语义检查的融入
一个完整的编译器在代码生成前会有独立的语义分析阶段。在我们的简化版中,我们可以将一些基本的语义检查嵌入到代码生成或解析过程中。
- 变量声明检查:在解析
let语句时,可以将变量名加入当前作用域的符号表。在解析标识符表达式时,去符号表中查找该名称是否已声明。如果未声明就报错。对于微型语言,一个全局的符号表(集合)可能就足够了。 - 类型检查(简化):如果我们的语言只支持整数,那么类型检查可以简化为确保运算符两边的表达式都是整数类型(在我们的 AST 里,就是
IntegerLiteral或标识符指向的整数变量)。这可以在_generate_expression遇到BinaryOp时进行简单判断,或者通过一个独立的TypeChecker访问者遍历 AST 来实现。
注意:将语义检查与代码生成混合,虽然简单,但不利于关注点分离和未来扩展。更好的架构是设计独立的语义分析阶段,在生成代码前先对 AST 进行一遍或多遍遍历,完成所有检查并丰富 AST 节点的信息(如将标识符节点链接到其声明节点)。
6. 集成测试与问题排查
将词法分析、语法分析和代码生成三个模块串联起来,就构成了我们编译器的核心管道。编写测试用例是确保每个环节正确工作的关键。
6.1 构建端到端测试
测试应该从最简单的案例开始,逐步增加复杂度:
- 单个表达式:
print 1+2*3;测试运算符优先级。 - 变量声明与使用:
let a = 5; print a; - 条件语句:
if (1>0) { print “yes”; } - 嵌套表达式和语句:
let b = (10-2)/4; if (b == 2) { let c = b * 10; print c; }
对于每个测试用例,你需要:
- 运行词法分析器,检查输出的 Token 序列是否正确。
- 运行语法分析器,检查生成的 AST 结构是否正确(可以通过打印树来可视化)。
- 运行完整的编译器(词法->语法->代码生成),检查生成的 Python 代码是否符合预期。
- 最后,手动或自动执行生成的 Python 代码,验证其运行结果与预期一致。
6.2 常见问题与调试技巧
在开发过程中,我遇到了不少典型问题,以下是排查思路:
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 词法分析器将关键字识别为标识符 | 关键字表未正确初始化或查找逻辑有误。 | 检查keywords字典的键值是否正确。在read_identifier函数末尾打印识别出的lexeme和最终判定的token_type。 |
| 解析表达式时报告“意外的Token” | 1. 运算符优先级处理错误,导致解析函数在错误的位置停止。 2. 文法规则与 Token 流不匹配,例如缺少必要的括号或分号。 | 1. 在递归下降的各个parse_xxx函数入口打印当前 Token,跟踪解析路径。2. 检查源代码是否符合你定义的文法。使用一个更简单的输入测试。 |
| 生成的Python代码运行时出错(如变量未定义) | 1. 代码生成器为标识符生成的名称错误。 2. 变量作用域处理问题(如果支持作用域)。 3.语义检查缺失:使用了未声明的变量。 | 1. 打印 AST,确认Identifier节点中的name字段是否正确。2. 检查代码生成器在生成标识符时是否直接使用了 node.name。3.实现一个简单的符号表,在解析阶段进行声明检查。 |
生成的表达式优先级错误,如1+2*3被生成为(1+2)*3 | AST 构建错误。在parse_addition和parse_multiplication中,左右子节点的组合顺序不对。 | 回顾你的文法规则和解析函数。parse_addition应该先解析一个multiplication作为左节点,然后循环处理后续的+/-和multiplication。确保在构建BinaryOp节点时,左节点是之前解析的结果,右节点是新解析的。可视化 AST 检查树的结构。 |
if语句生成后,else分支缩进不对 | 代码生成器中缩进级别管理错误。在进入和退出block时,indent_level的增加和减少不匹配。 | 在_add_line方法中打印当前的缩进级别和生成的代码行。确保每个block的生成都成对地修改indent_level。 |
调试心得:
- 分阶段验证:不要试图一次性写完整个编译器再测试。先让词法分析器对一小段代码输出正确的 Token。然后测试语法分析器能否根据这些 Token 构建出正确的 AST。最后再测试代码生成器。每一步都稳扎稳打。
- 可视化工具是你的朋友:为 Token 和 AST 实现美观的打印格式。对比你手绘的预期 AST 和程序输出的 AST,差异一目了然。
- 编写小型、独立的单元测试:为
Lexer.next_token(),Parser.parse_expression()等关键函数编写测试,输入特定字符串,断言输出是否符合预期。这能极大提升开发效率和代码质量。
7. 总结与扩展思考
当你看到自己定义的let x = 10; print x*2;这样的代码,经过你的编译器处理后,成功地输出了20,那种成就感是无与伦比的。你亲手搭建的这条流水线——字符流 -> Token 流 -> AST -> 目标代码——完整地运行了起来。
这个简单的编译器项目,就像一张地图,为你标出了编译技术这片广阔大陆上的几个核心地标。通过它,你不再对SyntaxError或Undefined variable这样的错误信息感到陌生,因为你知道了它们是在流水线的哪个环节、由哪个模块产生的。
当然,我们的玩具编译器距离实用还差得很远。但它是一个完美的起点。如果你有兴趣继续深入,这里有几个明确的扩展方向:
- 增强语言特性:添加
while循环、函数定义与调用、数组、简单的类型系统(如区分整数和布尔值)。每增加一个特性,你都需要思考:它的词法规则、语法规则、AST节点、语义规则以及代码生成策略是什么? - 实现真正的语义分析:将符号表管理、类型检查从代码生成器中剥离出来,建立一个独立的语义分析阶段,对 AST 进行多次遍历,完成所有上下文相关的检查。
- 生成更低级的代码:挑战生成栈式虚拟机(如 JVM、Python 虚拟机风格)的字节码,或者生成真正的汇编语言(如 x86-64 或 RISC-V)。这会让你直面寄存器分配、调用约定、栈帧管理等真正“硬核”的问题。
- 集成优化:在 AST 或中间代码层面实现一些经典优化,如常量折叠(将
2*3直接计算为6)、死代码消除等。
编译器工程是一个深不见底的领域,但每一步探索都会让你对计算机如何执行程序有更深刻的理解。这个亲手从零构建的过程,最大的收获不是那个能处理print 1+1;的小程序,而是你脑中建立起来的、关于“语言如何被翻译和执行”的清晰心智模型。下次当你再使用任何编程语言时,你看到的将不仅仅是代码,而是其背后那套精妙运转的规则系统。
