13、编译器 II:代码生成

编译器 II:代码生成

在编程领域,将高级程序转换为二进制代码的能力犹如魔法一般。本文聚焦于为 Jack 语言构建编译器,详细阐述从语法分析器到完整编译器的转变过程,深入探讨代码生成的各个方面。

1. 代码生成概述

高级程序员借助变量、表达式、语句、子程序、对象和数组等抽象构建模块描述程序功能,而编译器的任务是将这些语义转换为目标计算机能理解的语言。在本文中,目标计算机是之前章节构建的虚拟机,因此需将各种程序元素系统地转换为基于栈的 VM 命令序列。

2. 变量处理

编译器的基本任务之一是将高级程序中声明的变量映射到目标平台的主机 RAM 上。在 Jack 语言中,所有基本类型(int、char 和 boolean)均为 16 位宽,与 Hack RAM 的地址和字宽一致,每个 Jack 变量都可映射到内存中的一个字。

不同类型的变量具有不同的生命周期:
- 类级静态变量在类的所有子程序中全局共享,程序执行期间需保持单个副本存活。
- 实例级字段变量每个对象都有私有副本,对象不再使用时需释放内存。
- 子程序级局部和参数变量在子程序启动时创建,结束时释放。

在两层编译器架构中,内存分配和释放委托给 VM 层,编译器只需将 Jack 变量映射到相应的虚拟内存段,如静态变量映射到 static 0、static 1 等,字段变量映射到 this 0、this 1 等。这些映射可通过符号表方便地管理。

符号表在变量处理中起着关键作用,它记录变量的名称、类型、种类和索引等信息。Jack 编译器使用两个符号表,分别用于类级和子程序级,以实现不同的作用域。

处理变量声明时,编译器根据变量类型将其信息添加到相应的符号表中。处理语句中的变量时,编译器先在子程序级符号表中查找,若未找到则在类级符号表中查找,找到后完成语句的翻译。

3. 表达式编译

简单表达式是由项和运算符组成的序列,在 Jack 中表达式使用中缀表示法,而目标 VM 语言使用后缀表示法。因此,需要一个算法将中缀表达式解析并转换为后缀代码。

图 11.4 展示了一个表达式的 VM 代码生成算法,该算法从左到右处理输入表达式,生成 VM 代码。编译 Jack 表达式的任务由 compileExpression 例程完成,开发者需扩展该算法以处理 Jack 语法中规定的各种可能性。

4. 字符串编译

字符串在计算机程序中广泛使用,在 Jack 中,字符串常量通过调用 String 类的构造函数创建新的 String 对象,并通过调用 appendChar 方法初始化对象的字符。

然而,这种实现方式可能导致内存泄漏,现代语言通常采用运行时垃圾回收和优化技术来提高字符串对象的使用效率。Jack 编译器可使用操作系统服务,如调用 OS API 中的函数。

5. 语句编译

Jack 编程语言有五种语句:let、do、return、if 和 while。编译器需生成相应的 VM 代码来处理这些语句的语义。
- return 语句 :先调用 compileExpression 例程计算表达式的值并将其压入栈顶,然后生成 return 命令。
- let 语句 :先记录变量名,调用 compileExpression 计算表达式的值并压入栈顶,最后生成 pop 命令将栈顶值弹出到变量对应的虚拟内存段。
- do 语句 :将其视为 do expression 语句进行编译,然后通过 pop temp 0 命令移除栈顶元素。
- if 和 while 语句 :高级语言的控制流语句需通过条件跳转和无条件跳转原语来实现。编译器检测到 if 关键字时,先计算表达式的值,取反后根据结果进行条件跳转,然后编译相应的语句块。

6. 对象处理

在面向对象语言中,对象是通过构造函数创建的。编译器在编译构造函数调用时,需确保对象的正确创建和初始化。

编译构造函数时,首先确定所需内存块的大小,调用 OS 函数 Memory.alloc 分配内存,并将返回的基地址设置到 THIS 指针,使 this 段与新对象对齐。构造函数最后需返回新对象的基地址。

方法调用时,编译器将对象引用压入栈顶,然后调用相应的方法。编译器通过将 THIS 指针设置为调用对象的基地址,确保方法中的 this 段与目标对象对齐。

7. 数组编译

在 Jack 中,数组是 Array 类的实例,与对象的声明、实现和存储方式相同。数组元素可通过索引访问,编译数组访问语句时,需计算数组元素的物理地址并将其存储在 THAT 指针中。

基本的编译策略在某些情况下可能会出现问题,可通过改进策略来正确编译各种数组赋值语句。Jack 数组的编译相对简单,因为数组元素无类型限制,且所有基本数据类型和地址均为 16 位宽。

8. 编译器规范

Jack 编译器接收单个命令行参数,输入可以是 .jack 文件或包含多个 .jack 文件的文件夹。对于每个输入文件,编译器将生成对应的 .vm 文件,并将 VM 命令写入其中。

9. 编译器实现
9.1 标准映射
  • 文件和函数命名 :Jack 类文件 Xxx.jack 编译为 VM 类文件 Xxx.vm,Jack 子程序 yyy 编译为 VM 函数 Xxx.yyy。
  • 变量映射 :静态变量、字段变量、局部变量和参数变量分别映射到相应的虚拟内存段。
  • 对象字段映射 :通过 push argument 0, pop pointer 0 命令将 this 段与调用对象对齐。
  • 数组元素映射 :通过设置 pointer 1 并访问 that 0 来编译数组引用。
  • 常量映射 :null 和 false 映射为 push constant 0,true 映射为 push constant 1, neg,this 映射为 push pointer 0。
9.2 实现指南
  • 标识符处理 :使用符号表管理变量标识符,未在符号表中找到的标识符可能是子程序名或类名。
  • 表达式编译 :compileExpression 例程实现图 11.4 中的算法,并扩展以处理 Jack 语法中的所有项。表达式语法具有递归性,处理子表达式时需递归调用 compileExpression。
  • 字符串编译 :每个字符串常量通过调用 String.new 构造函数和 appendChar 方法进行处理。
  • 函数和构造函数调用编译 :调用具有 n 个参数的函数或构造函数时,先调用 compileExpressionList 计算参数值,然后进行调用。
  • 方法调用编译 :调用方法时,先将调用对象的引用压入栈顶,再调用 compileExpressionList 计算参数值,最后进行调用。
  • do 语句编译 :将 do 语句视为 do expression 语句编译,然后移除栈顶元素。
  • 类编译 :开始编译类时,创建类级和子程序级符号表,不生成代码。
  • 子程序编译 :初始化子程序符号表,若为方法则添加 this 映射,添加参数和局部变量信息,开始生成代码。
  • 构造函数编译 :除了常规子程序编译步骤外,还需分配内存并将 this 段与新对象对齐,最后返回新对象的基地址。
  • 空方法和空函数编译 :生成的代码以 push constant 0, return 结尾。
  • 数组编译 :使用特定技术编译数组赋值语句,处理数组时通常只需使用 that 0 索引。
  • 操作系统 :编译过程中可调用操作系统服务,如 Math.multiply 和 Math.sqrt 等函数。Nand to Tetris 提供原生和模拟两种操作系统实现。
9.3 软件架构

编译器架构基于之前构建的语法分析器,由以下模块组成:
- JackCompiler :驱动编译过程,为每个输入文件创建 JackTokenizer、输出文件,并使用 CompilationEngine、SymbolTable 和 VMWriter 进行解析和代码生成。
- JackTokenizer :与项目 10 中构建的分词器相同。
- SymbolTable :提供符号表的构建、填充和使用服务,在编译 Jack 类文件时使用两个实例。
- VMWriter :用于将 VM 命令写入输出文件。
- CompilationEngine :递归的自上而下编译引擎,根据 Jack 语言构造调用相应的编译例程,生成 VM 代码。

10. 项目实践

项目目标是将之前的语法分析器扩展为完整的 Jack 编译器,并通过一系列测试程序进行验证。

10.1 实现阶段
  • 阶段 1:符号表 :构建编译器的 SymbolTable 模块,扩展语法分析器以输出标识符的详细信息,包括名称、类别、索引和使用情况。通过测试 Jack 程序验证新功能,若输出正确则可开始生成 VM 代码。
  • 阶段 2:代码生成 :使用六个应用程序逐步测试编译器的代码生成能力。按照给定顺序开发和测试编译器,每个测试程序针对不同的语言特性进行测试。
10.2 测试程序
  • Seven :测试编译器对包含整数常量、do 语句和 return 语句的简单程序的处理能力。
  • ConvertToBin :测试编译器对 Jack 语言的过程式元素的处理能力,如表达式、函数和各种语句。
  • Square :测试编译器对 Jack 语言的面向对象特性的处理能力,如构造函数、方法、字段和方法调用。
  • Average :测试编译器对数组和字符串的处理能力。
  • Pong :全面测试编译器对面向对象应用程序的处理能力,包括对象和静态变量。
  • ComplexArrays :测试编译器对复杂数组引用和表达式的处理能力。
11. 总结与展望

Jack 语言是一种相对简单的面向对象编程语言,其设计简化了编译过程。例如,所有数据类型均为 16 位宽,不支持继承和区分私有与公共类成员,这些特点使得类的编译可以独立进行。

然而,大多数编程语言具有更丰富的类型系统和特性,对编译器提出了更高的要求。同时,本文的代码生成策略未考虑优化,工业级编译器通常会投入大量精力来确保生成的代码在时间和空间上高效。在 Nand to Tetris 中,操作系统是效率的重要关注点,后续章节将详细阐述 Jack OS 的高效算法和优化数据结构。

通过本文的学习,我们深入了解了编译器的构建过程,从变量处理到代码生成,每个环节都需要精心设计和实现。希望这些知识能帮助开发者更好地理解和掌握编译器的原理和技术。

以下是一个简单的 mermaid 流程图,展示了 Jack 编译器的主要流程:

graph TD;
    A[输入 Jack 程序] --> B[JackTokenizer 分词];
    B --> C[CompilationEngine 解析];
    C --> D[SymbolTable 管理变量];
    C --> E[VMWriter 生成 VM 代码];
    E --> F[输出 VM 程序];

同时,为了更清晰地展示变量映射关系,我们列出以下表格:
| 变量类型 | 虚拟内存段映射 |
| ---- | ---- |
| 静态变量 | static 0, static 1, … |
| 字段变量 | this 0, this 1, … |
| 局部变量 | local 0, local 1, … |
| 参数变量(函数/构造函数) | argument 0, argument 1, … |
| 参数变量(方法) | argument 1, argument 2, … |

通过这些表格和流程图,我们可以更直观地理解 Jack 编译器的工作原理和结构。

在实际应用中,我们可以按照以下步骤使用 Jack 编译器:
1. 准备 Jack 源文件,可以是单个文件或包含多个 .jack 文件的文件夹。
2. 运行 JackCompiler 程序,指定源文件或文件夹路径。
3. 编译器将为每个 .jack 文件生成对应的 .vm 文件。
4. 使用 VM 模拟器加载生成的 .vm 文件并运行,验证程序的正确性。

通过以上步骤,我们可以完成从 Jack 程序到可执行 VM 代码的转换,并进行测试和验证。希望这些信息对您有所帮助,让您在编译器开发的道路上更加顺利。

编译器 II:代码生成

在上半部分中,我们已经对 Jack 编译器的代码生成过程有了较为全面的了解,包括变量处理、表达式编译、语句编译等关键内容。接下来,我们将进一步深入探讨一些细节,并通过更多的示例和说明来加深理解。

12. 代码生成细节深入分析
12.1 表达式编译的递归特性

表达式编译的 compileExpression 例程具有递归特性,这是因为 Jack 语言的表达式语法允许嵌套。当遇到左括号时, compileExpression 会递归调用自身来处理括号内的子表达式。这种递归下降的方式确保了子表达式能够先被计算。例如,对于表达式 (x + y) * z ,编译器会先递归计算 x + y 的值,然后再与 z 进行乘法运算。

graph TD;
    A[表达式 (x + y) * z] --> B[遇到左括号];
    B --> C[递归调用 compileExpression 处理 x + y];
    C --> D[计算 x + y 的值];
    D --> E[返回结果到外层];
    E --> F[继续处理 * z];
    F --> G[完成表达式计算];
12.2 字符串编译的内存管理问题

虽然 Jack 语言的字符串编译方式通过调用 String 类的构造函数和 appendChar 方法实现,但这种方式可能导致内存泄漏。例如,在 Output.printString("Loading … please wait") 语句中,每次执行都会创建一个新的 String 对象,并且该对象会一直占用内存直到程序结束。现代语言通常采用垃圾回收机制来解决这个问题,但 Jack 语言目前没有相关的优化。

为了更清晰地展示字符串编译的过程,我们列出以下步骤:
1. 遇到字符串常量,如 "Hello"
2. 计算字符串长度,将长度压入栈顶。
3. 调用 String.new 构造函数创建新的 String 对象。
4. 依次将字符串中的每个字符的代码压入栈顶,并调用 appendChar 方法将字符添加到对象中。

12.3 语句编译的条件跳转实现

if while 语句的编译需要通过条件跳转和无条件跳转原语来实现。以 if 语句为例,编译器的处理步骤如下:
1. 检测到 if 关键字,调用 compileExpression 计算条件表达式的值并压入栈顶。
2. 生成 not 命令对条件表达式的值取反。
3. 创建一个唯一的标签,如 L1 ,并生成 if-goto L1 命令。
4. 如果条件不满足(即跳转),跳过 if 语句块;否则,继续编译 if 语句块中的语句。
5. 生成无条件跳转命令,跳过 else 语句块(如果有)。
6. 编译 else 语句块(如果有)。

graph TD;
    A[检测到 if 关键字] --> B[计算条件表达式的值];
    B --> C[取反];
    C --> D[生成 if-goto L1 命令];
    D --> E{条件是否满足};
    E -- 是 --> F[编译 if 语句块];
    E -- 否 --> G[跳过 if 语句块];
    F --> H[生成无条件跳转命令];
    H --> I[跳过 else 语句块];
    G --> J[编译 else 语句块];
13. 编译器实现的注意事项
13.1 符号表的使用

符号表在编译器中起着至关重要的作用,它用于记录变量的各种属性。在编译过程中,编译器会根据变量的作用域在不同的符号表中查找变量信息。例如,当遇到一个变量时,先在子程序级符号表中查找,如果未找到则在类级符号表中查找。

为了更好地管理符号表,编译器在编译类时会创建类级符号表和子程序级符号表。在编译子程序时,会初始化子程序级符号表,并根据需要添加 this 映射、参数和局部变量信息。

13.2 操作系统服务的调用

编译器在编译过程中可以调用操作系统服务,如 Math.multiply Math.sqrt 等函数。这些函数的调用通过生成相应的 VM 命令来实现。例如,对于表达式 x * y ,编译器会生成 push x, push y, call Math.multiply 2 命令。

Nand to Tetris 提供了原生和模拟两种操作系统实现。原生实现需要在项目 12 中开发 OS 类库并编译成 .vm 文件;模拟实现则由 VM 模拟器提供,当 VM 代码调用 OS 函数时,模拟器会根据情况执行相应的函数。

14. 项目实践的深入探讨
14.1 测试程序的作用

项目中提供的六个测试程序分别针对不同的语言特性进行测试,它们的作用如下:
| 测试程序 | 测试特性 |
| ---- | ---- |
| Seven | 简单算术表达式、 do 语句和 return 语句 |
| ConvertToBin | 过程式元素,如表达式、函数和各种语句 |
| Square | 面向对象特性,如构造函数、方法、字段和方法调用 |
| Average | 数组和字符串的处理 |
| Pong | 面向对象应用程序,包括对象和静态变量 |
| ComplexArrays | 复杂数组引用和表达式 |

通过依次测试这些程序,可以逐步验证编译器的功能是否正确。例如,在测试 ConvertToBin 程序时,需要手动在 RAM[8000] 中输入一个十进制值,然后运行程序,检查 RAM[8001…8016] 中的结果是否正确。

14.2 编译器优化的可能性

目前的代码生成策略没有考虑优化,例如对于简单的 c++ 语句,编译器可能会生成一系列复杂的 VM 操作。而优化后的编译器可以直接将其转换为更高效的机器指令。在实际开发中,可以考虑以下优化方向:
1. 常量折叠:在编译时计算常量表达式的值,减少运行时的计算量。
2. 指令合并:将多个相关的指令合并为一个更高效的指令。
3. 寄存器分配:合理分配寄存器,减少内存访问次数。

15. 总结

通过对 Jack 编译器代码生成过程的详细分析,我们了解了从高级程序到 VM 代码的转换过程。从变量处理到表达式编译,再到语句编译和对象处理,每个环节都有其独特的实现方式和注意事项。

符号表的使用确保了变量信息的有效管理,表达式编译的递归特性和语句编译的条件跳转实现是编译器的核心机制之一。同时,项目实践中的测试程序为验证编译器的正确性提供了重要的手段。

虽然 Jack 语言的设计简化了编译过程,但也存在一些不足之处,如字符串编译的内存管理问题和缺乏类型系统等。在实际开发中,可以根据这些问题进行改进和优化,以提高编译器的性能和功能。

希望通过本文的介绍,读者能够对编译器的代码生成过程有更深入的理解,并在实际开发中运用这些知识。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值