C1编译器的实现
总览
C1语言编译器及流程 |
C1 语言是一个类 C 的语言。语言的特征为:
- 包含 int、float 和 bool 简单类型以及以这些类型为基本类型的多维数组类型。
- 一个 C1 程序包含多个函数、全局变量声明和常量声明,其中必须有一个 void main(void)主函数。
- 函数可以带参数,也可以不带参数,参数的类型是简单类型。
- 函数的返回类型可以是void,或者是某简单类型。
- 函数体中可以有常量定义、变量声明和函数声明,包含表达式语句、条件语句、循环语句、函数调用语句、复合语句和空语句。
本文实现的C1编译器,其编译流程由词法语法分析、语义检查和代码生成三个阶段组成。其主要的特点是:
- 多目标:对C1源代码,可以生成MIPS汇编码、EIR二进制码和C代码;
- 强大的类型系统:可以识别C语言语法的类型定义,输出其类型表达式;
- 实现绝大部分C1语言特征;
- 带有扩展语法:如continue、for等;
- 较详细的错误报告;
下面根据编译器的阶段,逐一介绍其实现细节。
词法、语法分析
分析方案
本阶段的分析是把字符串流转换为抽象语法树。
词法、语法分析分别使用Flex和Bison构造。
分析时,只对语句建立树结构。对于符号的定义(变量定义、函数定义等),并不对其语法成分建树,而是顺着分析流程建立符号表,并把符号放在符号表中。
这样,就可以 避免语法树中出现大量的字符串,使得树的结构、结点的类型得到了简化。缺点是 造成复杂的类型分析比较困难,将类型系统的设计大大复杂化了。
翻译完成后,得到的总入口为全局符号表,从此符号表开始检索,可以得到程序的所有信息。
词法
与C的词法类似,其主要区别为:
- read和write是保留字,用于在C1中进行输入输出;
- bool、true和false是保留字,用于实现布尔类型;
其余还有一些区别,如sizeof不是单词等,但并不重要。
语法
本实现的语法与C1的语法基本相同,其主要区别是:
- 没有逗号表达式;
- 包含for语句;
- 函数的参数可以是数组类型(值传递语义);
- 变量初始化语法只能有一层括号,且不能有多余逗号。
- 下列不是运算符
++、--、+=、-=
符号表
符号表实现在src/sym_tab.c中。采用多层结构,图示如下:
+->指向上一层sym_tab +------|---->+---------+<-----------+ | | | sym_tab | | | | +---------+ | | +-----| uplink | | | +---------+ | | <-->| order |<--> | | +---------+ | | +--------->|entry[i] |<-----------|----+ | | +---------+ | | | | +---------+ +---------+ | | | | |sym_entry| |sym_entry| | | | | +---------+ +---------+ | | | +->| list |<--->| list |<->.|..<-+ | +---------+ +---------+ | +--->| tab | | tab |----+ +---------+ +---------+
上图中,sym_tab是符号表,sym_entry是表项。
表项串接在符号表中,有list和order两个线索。表项的list链条是哈希链,order链条为顺序链。
查找符号时,先在本层的符号表查找。若找不到,则顺着uplink向上一层再查,直到找到或到达顶层。
符号表记录符号的名字和类型。根据不同的类型有不同的记录,如函数有函数局部符号表地址、函数语句AST指针、函数地址、函数类型等信息。
类型系统
表示
类型系统实现在src/type.c中,其基本结构类似符号表,也是一个哈希链条将所有类型串起来。
每个类型的定义如下:
struct type { struct list_head list; enum { TYPE_VOID = 0, TYPE_INT, TYPE_FLOAT, TYPE_BOOL, TYPE_ARRAY, TYPE_FUNC, TYPE_LABEL, TYPE_TYPE, } type; int n; int is_const; union { struct sym_entry *e; struct type *t1; }; struct type *t2; };
有上述定义可见,这个类型的定义是树状的,因而可以表达非常复杂的结构,如函数数组,数组函数等。
名字
上面类型都是匿名的,当需要给类型取名(包括内置类型和用户自定义类型)时,可以构造一个TYPE_TPYE类型的类型。其中上述结构体的e指向符号表,给出类型名字,t2指向真实的类型。
在编译器初始化时,默认给内置类型命名:
symtab_enter_t(symtab, "int", get_type(TYPE_INT, 0, 0, NULL, NULL)); symtab_enter_t(symtab, "float", get_type(TYPE_FLOAT, 0, 0, NULL, NULL)); symtab_enter_t(symtab, "bool", get_type(TYPE_BOOL, 0, 0, NULL, NULL)); symtab_enter_t(symtab, "void", get_type(TYPE_VOID, 0, 0, NULL, NULL));
当用户用typedef定义新类型时,可以类似上述方法,在符号表中记录相应类型。
等价
类型等价可以按结构和按名字。
从类型的表示可见,当类型需要按名字等价时,只要比类型指针就可以了。若指针不等,则不是同一类型(匿名的类型总是不等的):
static inline int type_is_equal_byname(struct type *t1, struct type *t2) { return t1 == t2; }
当按结构等价时,则需要递归地比较两个类型树的所有属性:
static inline int type_is_equal_bystru(struct type *ty1, struct type *ty2) { ..... if(ty1->type == TYPE_FUNC) return type_is_equal_bystru(ty1->t1, ty2->t1) && type_is_equal_bystru(ty1->t2, ty2->t2); ..... }
解析
C语言中的类型定义 并非是书写类型表达式,而是声明其用法。这造成了这一部分实现的极端复杂。
如类型表达是为int->array(10,int)的类型用C语法写出为:
int type(int a)[10];
为了分析这种类型,在rule/c1.y中有两个函数来处理之。
AST
AST实现在include/ast_node.h中。
由于语义的要求,树结点的分叉数是不一样的的,故采用链表 将儿子和兄弟组成一个双向链表(从Linux内核取出,而非bison-example),增强通用性。
定义如下:
struct ast_node { unsigned short type; unsigned short id; struct list_head sibling; int first_line; int first_column; union { void *pval; int ival; float fval; struct list_head chlds; }; };
各个域含义为:
- type:结点类型(exp、block等,详见node_type.h)
- id: 结点子类型('+'、'-'等)
- sibling:兄弟组成的链表
- first_:位置追踪信息
- chlds: 儿子组成的链表
- val: 结点属性值
图示如下:
+--------------------------------------+ | +---------+ +---------+ | | | types | | types | | | +---------+ +---------+ | +->| sibling |<--->| sibling |<->....<-+ +---------+ +---------+ +->| chlds |<-+ | val | | +---------+ | +---------+ | | | +---------+ | | | types | +----+ | +---------+ | +->| sibling |<->..<-+ +---------+ ..->| chlds |<--.. +---------+
基本操作只有三种: ast_node_new 新建 ast_node_delete 删除 ast_node_add_chld 增加儿子
其余遍历兄弟和儿子的操作使用list.h中的list_for_each_entry实现。
语义检查
此遍较简单,主要要做的检查为:
- 类型检查和提升
- continue、break在while或for中
- 变量不能是void
- const变量不能被赋值
EIR代码生成器
EIR指令模拟的是一种栈式机器,指令类型和意义可见eir/interp_dbg.c。
此指令集的特点是: 已经将所有的策略定好,因此指令生成并没有太多灵活的空间,只要对树进行一次遍历,就可以生成代码。
值得一提的是短路运算的翻译方案。如and的翻译如下:
geni(lit, 0, 0); gen_exp(l); cj1 = cx; geni(jpc, 0, 0); gen_exp(r); cj2 = cx; geni(jpc, 0, 0); geno(opr, 0, notnot); code[cj1].v.i = cx; code[cj2].v.i = cx;
这个翻译方案的特点是:
- 若两个表达式有一个为假,最终栈顶留下数字0
- 若第一个表达式为真,第二个表达式不求值
- 两个表达式均真时,执行notnot操作,将栈顶翻转为1
因此这个方案是and操作的合法方案。这个方案 用较少的指令达到了准确的翻译,且翻译只需要局部的信息。缺点是条件较复杂时可能要连续经过多次跳转才能到达目标。
or的翻译类似可得。
MIPS代码生成器
寄存器分配
MIPS是基于寄存器的机器,因此相对于栈式机器,需要进行寄存器分配。
为了简单起见,本生成器基于基本块来分配。
寄存器分配器为每个寄存器维护如下的结构:
struct reg_struct { int dirty; int loaded; struct sym_entry *sym; struct list_head list; struct list_head avail_list; };
由此可知,这里一个寄存器仅仅可以关联一个符号sym。符号表中同时有一项指向寄存器结构,表示当前此符号被关联到了哪个寄存器上。
当产生对sym对应寄存器修改的指令时,dirty位置1。
当到达基本块出口时,调用reg_wb_all函数产生指令将dirty为1的寄存器写回内存。同时将原来所有关联取消,以便下一个基本块分配。
分配函数的核心为get_reg函数。生成器将要使用的符号传递给get_reg。
get_reg函数首先查看是否符号已经关联,若是则直接返回寄存器号。否则,从avail_list链中取出一个可用寄存器,将符号关联到此。若avail_list为空,则产生溢出,将list上面的一个变量写回内存,在将符号关联到此。
体系结构相关特性优化
延迟槽的利用
由于这个生成器还十分简单,获取的全局信息也不够,因此 对一般生成的指令,延迟槽内仅仅填写空操作。但是 对于函数框架模板、短路翻译方案等地方,手工做了优化。
叶子函数
叶子函数是指此函数体内没有进一步函数调用。根据MIPS体系结构特点,不需要将返回地址放入内存。
我们在语义检查阶段对函数调用情况进行统计,当生成时,发现可以进行叶子函数的优化时,就产生特殊的指令,提高效率。
使用说明
编译
输入make,得到c1c执行文件。
运行
从命令行读取参数,使用方法类似GCC: 编译生成EIR中间代码: c1c src_file [-o out_file] 编译生成C代码: c1c src_file [-o out_file] -m c 编译生成MIPS汇编代码: c1c src_file [-o out_file] -m spim 帮助: c1c -h
原文: http://home.ustc.edu.cn/~hchunhui/c1.html