自制编译器
上QQ阅读APP看书,第一时间看更新

第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函数写入文件。这样汇编代码的文件就生成了。

从下一章开始,我们将进入语法分析的环节。