第2章 C♭和cbc
本章将对本书制作的编译器及其实现的概要进行说明。
2.1 C♭语言的概要
本书制作的编译器可将C♭这种语言编译为机器语言。本节首先对C♭语言的概要进行简单的说明。
C♭的Hello, World!
C♭是C语言的简化版,省略了C语言中琐碎的部分以及难以实现、容易混淆的功能,实现起来条理更加清晰。虽然如此,C♭仍保留了包括指针等在内的C语言的重要部分。因此,理解了C♭的编译过程,也就相当于理解了C程序的编译过程。
让我们再来看一下用C♭语言编写的Hello, World!程序,如代码清单2.1所示。
代码清单2.1 用C♭语言编写的Hello, World!程序
import stdio; int main(int argc, char **argv) { printf("Hello, World! \n"); return 0; }
可见该程序和C语言几乎没有差别,不同之处只是用import替代了#include,仅此而已。
本书的目的是让读者理解“在现有的OS上,现有的程序是如何编译及运行的”。那些有着诸多不切实际的限制,仅能作为书中示例的“玩具”语言,对其进行编译丝毫没有意义。从这个角度来说,C语言作为编程语言是非常具有现实意义的,而C♭则十分接近于C语言。因此,理解了C♭,对于现实的程序就会有更深刻的认识。
C♭中删减的功能
为了使编译器的处理简明扼要,下面这些C语言的功能不会出现在C♭中。
●预处理器
●K&R语法
●浮点数
●enum
●结构体(struct)的位域(bit field)
●结构体和联合体(union)的赋值
●结构体和联合体的返回值
●逗号表达式
●const
●volatile
●auto
●register
简单地说一下删除上述功能的原因。
首先,C♭同C语言最大的差异在于C♭没有预处理器。认真地制作C语言的预处理器会花费过多的时间和精力,进而无法专注于本书的主题——编译器。
但是,因为省略了预处理器,所以C♭无法使用#define和#include。特别是不能使用#include,将无法导入类型定义和函数原型,这是有问题的。为了解决该问题,C♭使用了与Java类似的import关键字。import关键字的用法将稍后说明。
数据类型方面也做了一些变化。
首先,删除了和浮点数相关的所有功能。浮点数的计算是比较重要的功能,笔者也想对此进行实现,但由于本书页数的限制,最后也只能放弃。
其次,由于C语言的enum和生成名称连续的int型变量的功能本质上无太大区别,因此为了降低编译器实现的复杂度,这里将其删除。至于结构体和联合体,主要也是考虑到编译器的复杂度,才删除了类似的使用频率不高或非核心的功能。
volatile和const还是比较常用的,但因为cbc几乎不进行优化,所以volatile本身并没有太大意义。const可以有条件地用数字字面量和字符串字面量来实现。
最后,auto和register不仅使用频率低,而且并非必要,所以将其也删除了。
import关键字
下面对C♭中新增的import关键字进行说明。
C♭在语法上和C语言稍有差异,而且没有预处理器,所以不能直接使用C语言的头文件。为了能够从外部程序库导入定义,C♭提供了import关键字。import的语法如下所示。
import导入文件ID;
下面是具体的示例。
import stdio; import sys.params;
导入文件类似于C语言中的头文件,记载了其他程序库中的函数、变量以及类型的定义。cbc中有stdio.hb、stdlib.hb、sys/params.hb等导入文件,当然也可以自己编写导入文件。
导入文件的ID是去掉文件名后的“.hb”,并用“.”取代路径标识中的“\”后得到的。例如导入文件stdio.hb的ID为stdio,导入文件sys/params.hb的ID为sys.params。
导入文件的规范
下面让我们看一个导入文件的例子,cbc中的stdio.hb的内容如代码清单2.2所示。
代码清单2.2 导入文件stdio.hb
// stdio.hb import stddef; // for NULL and size_t import stdarg; typedef unsigned long FILE; // dummy extern FILE* stdin; extern FILE* stdout; extern FILE* stderr; extern FILE* fopen(char* path, char* mode); extern FILE* fdopen(int fd, char* mode); extern FILE* freopen(char* path, char* mode, FILE* stream); extern int fclose(FILE* stream); : :
只有下面这些声明能够记述在导入文件中。
●函数声明
●变量声明(不可包含初始值的定义)
●常量定义(这里必须有初始值)
●结构体定义
●联合体定义
●typedef
函数及变量的声明必须添加关键字extern。并且在C♭中,函数返回值的类型、参数的类型、参数名均不能省略。
2.2 C♭编译器cbc的构成
阅读有一定数量的代码时,首先要做的就是把握代码目录以及文件的构成。这一节将对本书制作的C♭编译器cbc的代码构成进行说明。
cbc的代码树
cbc采用Java标准的目录结构,即将作者的域名倒序,将倒序后的域名作为包(package)名的前缀,按层次排列。比如,笔者的个人主页的域名是loveruby.net,则包名以net. loveruby开头,接着是程序的名称cflat,其下面排列着cbc所用的包。代码的目录结构如图2-1所示。
图2-1 cbc中包的层次
从asm到utils的11个目录,各自对应着同名的包。也就是说,cbc有11个包,所有cbc的类都属于这11个包中的某一个。cbc不直接在net.loveruby和net.loveruby. cflat下面放置类。
cbc的包
cbc的包的内容如表2-1所示(省略了包名的前缀net.loveruby.cflat)。
表2-1 cbc中的包
在这些包之中,asm、ast、entity、ir、type这5个包可以归结为数据相关(被操作)的类。另一方面,compiler、parser、sysdep、sysdep.x86这4个包可以归结为处理相关(进行操作的一方)的类。
把握代码整体结构时最重要的包是compiler包,其中基本收录了cbc编译器前端的所有内容。例如,编译器程序的入口函数main就定义在compiler包的Compiler类中。
compiler包中的类群
我们先来看一下compiler包中的类。compiler包中主要的类如表2-2所示。
表2-2 compiler包中主要的类
Compiler类是统管cbc的整体处理的类。编译器的入口函数main也在Compiler类中定义。
从Visitor类到TypeResolver类都是语义分析相关的类。关于这些类的作用将在第9章详细说明。
最后,IRGenerator是将抽象语法树转化为中间代码的类,详情请参考第11章。
main函数的实现
在本章最后,我们一起来大概地看一下Compiler类的代码。Compiler类中main函数的代码如代码清单2.3所示。
代码清单2.3 Compiler#main(compiler/Compiler.java)
static final public String ProgramName = "cbc"; static final public String Version = "1.0.0"; static public void main(String[] args){ new Compiler(ProgramName).commandMain(args); } private final ErrorHandler errorHandler; public Compiler(String programName){ this.errorHandler = new ErrorHandler(programName); }
main函数中,通过new Compiler(ProgramName)生成Compiler对象,将命令行参数args传递给commandMain函数并执行。ProgramName是字符串常量"cbc"。
Compiler类的构造函数中,新建ErrorHandler对象并将其设为Compiler的成员。之后,在输出错误或警告消息时使用该对象。
commandMain函数的实现
接着来看一下负责cbc主要处理的commandMain函数(代码清单2.4)。原本的代码中包含较多异常处理的内容,比较繁琐,因此这里只列举主要部分。
代码清单2.4 Compiler#commandMain的主要部分(compiler/Compiler.java)
public void commandMain(String[] args){ Options opts = Options.parse(args); List<SourceFile> srcs = opts.sourceFiles( ); build(srcs, opts); }
commandMain函数中,首先用Options类的parse函数来解析命令行参数args,并取得SourceFile对象的列表(list)。一个SourceFile对象对应一份源代码。实际的build部分,是由build函数来完成的。
Options对象中的成员如表2-3所示。
表2-3 Options对象的成员
Options对象中还定义有其他成员和函数,因为只和代码生成器、汇编器、链接器相关,所以等介绍上述模块时再进行说明。
Java5泛型
可能有些读者对List<SourceFile>这样的表达式还比较陌生,所以这里解释一下。
List<SourceFile>表示“成员的类型为SourceFile的列表”,简单地说就是“SourceFile对象的列表”。到J2SE 1.4为止,还不可以指定List、Set等集合中元素对象的类型。从Java 5开始,才可以通过集合类名<成员类名>来指定元素成员的类型。
通过采用这种写法,Java编译器就知道元素的类型,在取出元素对象时就不需要进行类型转换了。
这种能够对任意类型进行共通处理的功能称为泛型。在Java5新增的功能中,泛型使用起来尤其方便,是不可缺少的一项功能。
build函数的实现
我们继续看负责build代码的build函数,其代码大概如代码清单2.5所示。
代码清单2.5 Compiler#build的主要部分(compiler/Compiler.java)
public void build(List<SourceFile> srcs, Options opts) throws CompileException { for(SourceFile src : srcs){ compile(src.path( ), opts.asmFileNameOf(src), opts); assemble(src.path( ), opts.objFileNameOf(src), opts); } link(opts); }
首先,用foreach语句(稍候讲解)将SourceFile对象逐个取出,并交由compile函数进行编译。compile函数是对单个C♭文件进行编译,并生成汇编文件的函数。
接着,调用assemble函数来运行汇编器,将汇编文件转换为目标文件。
最后,使用link函数将所有的对象文件和程序库链接。
可见上述代码和第1章中叙述的build的过程是完全对应的。
Java 5的foreach语句
这里介绍一下Java 5中新增的foreach语句。foreach语句,在写代码时也可以写成“for...”,但通常叫作foreach语句。
foreach语句是反复使用Iterator对象的语句的省略形式。例如,在build函数中有如下foreach语句。
for(SourceFile src : srcs){ compile(src.path( ), opts.asmFileNameOf(src), opts); assemble(src.path( ), opts.objFileNameOf(src), opts); }
这个foreach语句等同于下面的代码。
Iterator<SourceFile> it = srcs.iterator( ); while(it.hasNext( )){ SourceFile src = it.next( ); compile(src.path( ), opts.asmFileNameOf(src), opts); assemble(src.path( ), opts.objFileNameOf(src), opts); }
通过使用foreach语句,遍历列表等容器的代码会变得非常简洁,因此本书中将尽量使用foreach语句。
compile函数的实现
最后我们来看一下负责编译的compiler函数的代码。剩余的assemble函数和link函数将在本书的第4部分进行说明。
compiler函数中也有用于在各阶段处理结束后停止处理的代码等,多余的部分比较多,所以这里将处理的主要部分提取出来,如代码清单2.6所示。
代码清单2.6 Compiler#compiler的主要部分(compiler/Compiler.java)
public void compile(String srcPath, String destPath, Options opts)throws CompileException { AST ast = parseFile(srcPath, opts); TypeTable types = opts.typeTable( ); AST sem = semanticAnalyze(ast, types, opts); IR ir = new IRGenerator(errorHandler).generate(sem, types); String asm = generateAssembly(ir, opts); writeFile(destPath, asm); }
首先,调用parseFile函数对代码进行解析,得到的返回值为AST对象(抽象语法树)。再调用semanticAnalyze函数对AST对象进行语义分析,完成抽象语法树的生成。接着,调用IRGenerator类的generate函数生成IR对象(中间代码)。至此就是编译器前端处理的代码。
之后,调用generateAssembly函数生成汇编语言的代码,并通过writteFile函数写入文件。这样汇编代码的文件就生成了。
从下一章开始,我们将进入语法分析的环节。