编译器 2: 代码生成
背景知识
程序本质上就是一系列操纵数据的操作。因此,将高级程序编译成低级语言主要涉及两个主要的问题:数据的翻译和命令的翻译。
数据翻译
程序能操纵很多变量类型(types),包括整型和布尔型等简单类型,以及数组和对象等复杂类型。另一个需要讨论的重要内容是变量的生命周期和作用域(scope)--即局部变量、全局变量、参数、对象成员字段等等。
对于程序中遇到的每个变量,编译器必须将其映射到目标平台中适合描述其类型的等价表示上。此外,编译器必须管理变量的生命周期和作用域,这与它的类型相关。因此这里就需要用到符号表。
符号表(Symbol Table) 高级程序引入并操纵很多标识符(identifiers)。当编译器遇到标识符 xxx时,它需要知道 xxx代表什么。它是变量名、类名称,还是函数名?如果它是变量,那么 xxx 是某个对象的成员字段,还是某个函数的参数?变量的种类又是什么,整数、布尔数、字符或某种数据类型?在编译器能用目标语言表达xxx 的语义之前,必须解决这些问题。另外,每当在源代码中遇到 xxx 时,所有以上这些问题都必须被回答(用于代码生成)。
显然有必要了解程序引入的所有标识符,并且记录每个标识符在源程序中代表的内容及其被映射到目标语言中的何种结构上。大多数编译器通过使用符号表(symbol table)抽象来保存这些信息。在源代码中第一次遇到一个新标识符时(比如在符号声明中),编译器将它的描述添加到表中。在代码的其他地方遇到标识符时,编译器在符号表中查找该标识符,然后得到关于它的所有必要信息。
变量处理:编译器所面对的基本挑战之一是,如何将源程序中声明的各种变量类型映射到目标平台的内存中去。首先,不同的变量类型需要不同大小的内存块,因此这种映射不是一对一的。其次,不同的变量有不同的生命周期。例如,应该在程序的整个运行期中保持每个静态变量的单一副本。相比之下,对于对象的所有实例变量(成员字段),类的每个对象实例都应该保存有其各自的副本,在清除对象时,应该收回其内存。同样,每次调用子程序时,必须为其局部变量和参数变量创建新的副本。
上面说的算是坏消息。好消息是,我们已经解决了所有这些困难。在双层编译器架构中,变量的内存分配任务交给了 VM后端程序。前面构建的虚拟机包含了用于提供标准类型变量的存储机制。这些标准变量类型包括:静态变量、局部变量、参数变量以及对象的成员字段,它们都是大多数高级语言所需要的。通过使用全局堆栈和虚拟内存段,这些变量的所有分配和去配细节已经在VM 层被处理了。
这种功能并不是很轻易就实现的。实际上,必须费很大的劲儿去构建 VM的实现,它将全局堆栈和虚拟内存段映射到最终的硬件平台上。然而付出的努力是值得的:对于任何给定的语言L,任何L-VM编译器现在都已经完全从复杂的底层内存管理中解脱出来了。编译器所需要做的唯一的事情就是将源程序中的变量映射到虚拟内存段上,然后用VM命令来表达操控这些变量的高级命令,这就变成相对简单的任务了。
命令翻译
现在来描述高级命令是如何被翻译成目标语言的。因为已经讨论了变量、对象和数组的处理,所以现在只有两个问题需要讨论:表达式求值 (expression eoaluation)和程序流程控制 (control flow).
规范详述
虚拟机平台之上的标准映射
编译器将每个.jack 文件翻译成对应的.vm 文件,对于.jack 文件中的每个构造函数、函数、方法,该.vm 文件包含了相对应的VM 函数。如此一来,每个 Jack 编译器必须遵循下面的代码生成约定。文件命名和函数命名 每个.jack 文件被编译成独立的.vm 文件。Jack 子程序(函数,方法,和构造函数)被编译成下列的 VM函数:
- 在Jack 程序中,类Yyy 中的Jack 子程序 xxx()被编译成名为 Yyy.xxx 的VM 函数。
- 拥有k个参数的Jack 函数或构造函数被编译成对k个参数进行操作的 VM 函数。
- 拥有k 个参数的 Jack方法被编译成对k+1个参数进行操作的VM 函数。第一个参数(参数0)总是指针 this(用来引用当前对象)。
内存分配和访问
- Jack 子程序的局部变量被分配到 local 虚拟段,并通过该段对其进行访问。
- Jack 子程序的参数变量被分配到 argument 虚拟段,并通过该段对其进行访问。
- jack类文件的静态变量被分配到相应的.vm 文件的static 虚拟段,并通过该段对其进行访问。
- 在Jack 方法或构造函数相应的VM 函数中,要想访问this 对象的字段,首先通过使用pointer0来将以 this 指针指向的虚拟内存段(this 虚拟段)映射为存储当前对象的内存段上(实际就是使this指针指向存储当前对象内存段的基地址),然后通过this index 来访问对象中的各个成员,这里index 是非负数。
- 在VM 函数中,要想访问数组的数据项,同访问对象的方法相似,首先通过使用 pointer1 使得that 指针指向数组的第一个元素,然后通过 that 指针来访问数组中的数据项
子程序调用
- 在调用VM函数之前,调用者(本身也是VM 函数)必须将函数的参数压入堆栈。如果被调用的 VM 函数是Jack 方法,那么首先压入的参数必须是该方法操作对象的引用(reference)。
- 将Jack 方法编译成 VM 函数时,编译器必须插入适当的 VM 代码来设置this 虚拟段的基地址。同样,在编译 Jack 构造函数时,编译器也必须插入 VM 代码,该代码为新对象分配一个内存段然后将this 指向这个内存段的基地址。
从返回类型为 void 的方法及函数返回 高级 void 子程序并不返回值。这个抽象按如下方式来处理:
- 返回类型为void 的方法和函数所对应的VM 函数必须返回常数0作为其返回值。
- 编译 do sub 语句时(sub 是返回类型void 的方法或函数),对应的调用者必须弹出(并忽略)该返回值(该值总是常数O)。
常数
null 和 false 被映射到常数 0。true 被映射到常数 -1(该常数可以通过 pushconstant 1,再对其进行 neg操作来得到)。
实现
现在要为整个编译器打造软件架构。该架构建立于第10 章所描述的语法分析器之基础上。事实上,当前架构的基础正是逐步将语法分析器发展成完整编译器的过程。因此整个编译器由5个模块构成:
- JackCompiler:建立和调用其他模块的顶层驱动模块;
- JackTokenizer : 字元转换器;
- SymbolTable : 符号表;
- Vwriter:生成 VM 代码的输出模块;
- CompilationEngine : 自顶向下的递归式编译引擎。