前一阶段我们完成了编译器中的重要阶段——语义分析。现在,程序中的每一个变量和类型都有其正确的定义;每一个表达式和语句的类型都是合法的;每一处方法调用都选择了正确的方法定义。现在即将进入下一个阶段——代码生成。代码生成的最终目的,是生成能在目标机器上运行的机器码,或者可以和其他库链接在一起的可重定向对象。代码生成,和这一阶段的各个优化手段,统称为编译器的后端。目前大部分编译器,在代码生成时,都倾向于先将前段解析的结果转化成一种中间表示,再将中间表示翻译成最终的机器码。比如Java语言会翻译成JVM bytecode,C#语言会翻译成CIL,再经由各自的虚拟机执行;IE9的javascript也会先翻译成一种bytecode,再由解释器执行或者进行JIT翻译;即使静态编译的语言如C++,也存在先翻译成中间语言,再翻译成最终机器码的过程。中间表示也不一定非得是一种bytecode,我们在语法分析阶段生成的抽象语法树(AST)就是一种很常用的中间表示。.NET 3.5引入的Expression Tree正是采用AST作为中间表示的动态语言运行库。那为什么这种做法非常流行呢?因为翻译中中间语言有如下好处:
- 使用中间语言可以良好地将编译器的前端和后端拆分开,使得两部分可以相对独立地进行。
- 同一种中间语言可以由多种不同的源语言编译而来,而又可以针对多种不同的目标机器生成代码。CLR的CIL就是这一特点的典型代表。
- 有许多优化可以直接针对中间语言进行,这样优化的结果就可以应用到不同的目标平台。
我们这次动手编写编译器,自然也少不了中间语言这一步。为了达到亲手实践的目的,我们将会自己定义中间语言,但是那样的话想要把编译出的程序运行起来就还需要很多工作。为了提前体验运行目标代码的成就感,同时验证编译器前端的正确性,我们这次先将miniSharp编译成CLR的中间语言——CIL(Common IL,MSIL),并且就使用.NET自带的Reflection.Emit库来做。
首先来了解一下CIL的特点。CIL是一种bytecode,在.NET的程序集里他是二进制方式存在的。我们常常见到的是用ILDASM或者ILSpy反汇编而成的汇编形态。例如这一段:
.method public hidebysig newslot |
和机器语言相比,CIL是一种高度抽象的中间语言。程序集里有非常丰富的元数据,可以直接对应到源代码里的类和方法。而CIL仅仅用于描述方法体的逻辑。CIL较少反应出运行时真正发生在CPU上的事情,而更多地与源代码中的语句和表达式接近。所以我们说CIL是一种相当高级的中间语言。CIL是一种栈式机。要注意的是,这里的“栈”与运行时的内存堆和栈的“栈”没有任何关系。CIL的栈是一个运算栈(evaluation stack),它在运行时实际是不存在的,但我们必须要在理解CIL运行过程时想象它存在。运算栈在CIL中的作用是保存运算的中间结果,这与寄存器机的寄存器有些类型。CIL的每一条指令都只能对运算栈顶进行操作。
看上面的IL代码,第一行ldarg.1指令的作用是将1号实参加载到运算栈的栈顶上,第二条ldc.i4.1指令是将32位整型常量1压入运算栈。注意“ldc.i4.1”是一条指令,它是不带参数的。IL中有许多这种缩短格式的指令,以消除或减少指令参数,从而减少目标代码的体积。经过这两条指令后,运算栈中有两个值:栈顶是32位常量1,其下面是方法的1号参数值。这时遇到指令clt,这条指令会将运算栈先后弹出两个值,并比较它们的大小,如果后弹出的值小于先弹出的值,则将32位整数“1”压入运算栈,反之则将“0”压入运算栈。假设该方法第一个实参传的是“0”,以上过程如图所示:
执行指令 | 运算栈 | ||
空 | |||
ldarg.1 | 压入参数值0
| ||
ldc.i4.1 | 压入常数1
| ||
clt | 弹出1 弹出0 比较0 < 1,所以压入1
|
接下来的brfalse指令又会弹出运算栈顶的值,并根据这个值决定是否要进行跳转。以此类推,即可理解每条指令的作用。任何一条IL指令总会将一些值压入栈;或者从栈中弹出一些值;或者先弹出一些值,再压入一些值。这些不同的动作称为这条指令的栈转换行为。每条指令都有固定的栈转换行为,只要理解了栈转换行为,就等于完全理解一条IL指令。
MSDN中OpCodes类的帮助中详细介绍了每一条指令的栈转换规则。当我们需要了解CIL指令的含义时,这个帮助就是最好的资料。简单了解了CIL与运算栈之后,大部分指令的行为都是很好理解的。我这里稍微解释一下某些特殊的规则。
在CIL指令表当中大家会看到许多指令有多个版本。比如ldloc指令用于将局部变量加载到运算栈顶。这个指令就有ldloc、ldloc.s、ldloc.0、ldloc.1等不同的版本。这其中的ldloc是该指令的长版本,其他指令则是短版本。因为CIL是bytecode,所以这些指令在程序集中都是一个或两个字节的代码。ldloc长版本指令自身编码为两个字节(FE 06),而且它需要一个uint16(两字节)的参数,所以它一共需要占四个字节的空间。我们知道一个方法很少有65536这么多个本地变量,很多也就是1-2个,有十几个的已经算是非常多了。所以都用这么长的指令非常浪费。短版本ldloc.s自身编码只有一个字节(11),而且它的参数是uint8(一个字节),该指令只占2个字节的空间。但是ldloc.s只能加载索引在0-255范围内的本地变量。最后,针对最常用的头4个本地变量,还有四个最短版本。比如ldloc.0,仅占一个字节的编码(06)没有参数。我们在生成代码的时候需要根据访问本地变量的索引来选取不同的指令:
private void EmitLoadLocal(int locIndex) { switch (locIndex) { case 0: m_ilgen.Emit(OpCodes.Ldloc_0); break; case 1: m_ilgen.Emit(OpCodes.Ldloc_1); break; case 2: m_ilgen.Emit(OpCodes.Ldloc_2); break; case 3: m_ilgen.Emit(OpCodes.Ldloc_3); break; default: if (locIndex <= 255) { m_ilgen.Emit(OpCodes.Ldloc_S, (byte)locIndex); } else { m_ilgen.Emit(OpCodes.Ldloc, (short)locIndex); } break; } } |
下面我们就开始为miniSharp语言编写CIL代码生成器。和语义分析阶段类似,我们只需要编写一个AST的Visitor实现即可。要注意,我们不仅需要生成方法的IL代码,还需要生成程序集、模块、类、方法、构造函数、字段等定义。Reflection.Emit为这些结构提供了各类Builder类型,使用非常方便,但必须注意一些规则:
- 为了生成exe,程序集入口的PEFileKind应当是ConsoleApplication(默认是Dll)。
- 每个类对应一个TypeBuilder,生成一个类之后必须调用其CreateType方法真正生成类型。一个类CreateType之前,它的父类必须已经CreateType过才行。所以要按照继承顺序创建各个类。
- TypeBuilder上也有Type类的所有方法,如GetConstructor、GetMethod之类,但只有当TypeBuilder调用过CreateType之后这些方法才能使用。所以我们必须自己保存未完成类型的成员信息。
下面的代码展示了按照类继承顺序生成各类的代码:
public override AstNode VisitProgram(Program ast) { List<ClassDecl> classesInHierarchyOrder = new List<ClassDecl>(); var topBaseClasses = from c in ast.Classes where c.BaseClass.Type == null select c; classesInHierarchyOrder.AddRange(topBaseClasses); while (classesInHierarchyOrder.Count < ast.Classes.Count) { foreach (var c in ast.Classes) { foreach (var b in classesInHierarchyOrder.ToArray()) { if (c.BaseClass.Type == b.Type) { classesInHierarchyOrder.Add(c); } } } } foreach (var c in classesInHierarchyOrder) { Visit(c); } Visit(ast.MainClass); return ast; } |
下面展示MainClass的生成方法。这里用了一个技巧,即static class = abstract + sealed。
public override AstNode VisitMainClass(MainClass ast) { m_currentType = m_module.DefineType( ast.Type.Name, TypeAttributes.Class | TypeAttributes.Abstract | TypeAttributes.Sealed); m_currentMethod = m_currentType.DefineMethod( "Main", MethodAttributes.Public | MethodAttributes.Static, typeof(void), new[] { typeof(string[]) }); m_ilgen = m_currentMethod.GetILGenerator(); foreach (var s in ast.Statements) { Visit(s); } m_ilgen.Emit(OpCodes.Ret); m_currentType.CreateType(); m_mainMethod = m_currentMethod; return ast; } |
搞定类和方法之后,就开始要生成方法体的代码了。这一部分最主要的翻译对象是语句和表达式,有一个要注意的规则:
- 表达式执行之后,该表达式的结果应当压入运算栈。
- 语句执行后,运算栈应当被清空。
如果不满足上述规则,生成的代码就很有可能是错的,要非常小心。下面展示两个最基本的语句——if else和while的生成方法。
public override AstNode VisitIfElse(IfElse ast) { var ifBlock = m_ilgen.DefineLabel(); var elseBlock = m_ilgen.DefineLabel(); var endif = m_ilgen.DefineLabel(); Visit(ast.Condition); //the e-stack should have a bool value m_ilgen.Emit(OpCodes.Brfalse, elseBlock); //if block m_ilgen.MarkLabel(ifBlock); Visit(ast.TruePart); m_ilgen.Emit(OpCodes.Br, endif); //elseblock m_ilgen.MarkLabel(elseBlock); Visit(ast.FalsePart); //after if m_ilgen.MarkLabel(endif); return ast; } public override AstNode VisitWhile(While ast) { var beforeWhile = m_ilgen.DefineLabel(); var afterWhile = m_ilgen.DefineLabel(); m_ilgen.MarkLabel(beforeWhile); Visit(ast.Condition); //the e-stack should have a bool value m_ilgen.Emit(OpCodes.Brfalse, afterWhile); Visit(ast.LoopBody); m_ilgen.Emit(OpCodes.Br, beforeWhile); m_ilgen.MarkLabel(afterWhile); return ast; } |
这里if语句采用的是brfalse指令。实际上CIL中有许多条件分支语句,如blt、bge等等,可直接翻译if ( a > b )这样的结构,效率更高。此次我采用偷懒的做法,全都用clt, cgt这样的有返回值的指令来计算大于小于等比较运算,但后统一用brfalse来执行条件跳转。上面这段代码还展示了Label在Emit API中的使用方法。翻译赋值语句和数组赋值语句时要注意,为本地变量、本地参数或类的字段赋值时采用的指令和栈转换动作均有所不同,需要分别考虑。比如ldfld之前必须先将目标对象压栈,如果是this的话应该用ldarg.0指令(实例方法默认第0个参数是this引用)
再来演示两个基本表达式的翻译,二元运算符和方法调用:
public override AstNode VisitBinary(Binary ast) { //push operands Visit(ast.Left); Visit(ast.Right); switch (ast.Operator) { case BinaryOperator.Add: m_ilgen.Emit(OpCodes.Add); break; case BinaryOperator.Substract: m_ilgen.Emit(OpCodes.Sub); break; case BinaryOperator.Multiply: m_ilgen.Emit(OpCodes.Mul); break; case BinaryOperator.Divide: m_ilgen.Emit(OpCodes.Div); break; case BinaryOperator.Less: m_ilgen.Emit(OpCodes.Clt); break; case BinaryOperator.Greater: m_ilgen.Emit(OpCodes.Cgt); break; case BinaryOperator.Equal: m_ilgen.Emit(OpCodes.Ceq); break; case BinaryOperator.LogicalAnd: m_ilgen.Emit(OpCodes.And); break; case BinaryOperator.LogicalOr: m_ilgen.Emit(OpCodes.Or); break; default: m_ilgen.Emit(OpCodes.Pop); m_ilgen.Emit(OpCodes.Pop); m_ilgen.Emit(OpCodes.Ldc_I4_0); break; } return ast; } public override AstNode VisitCall(Call ast) { var methodRInfo = GetClrMethod(ast.Method.MethodInfo); //push target object Visit(ast.Target); //push arguments foreach (var arg in ast.Arguments) { Visit(arg); } m_ilgen.EmitCall(OpCodes.Call, methodRInfo, null); return ast; } |
注意这里翻译&&和||运算符时没有生成“短路”操作,因此与C#的语义稍有不同。如果要支持短路也非常容易,大家可以亲自实验一下。翻译二元运算符时,如果语义分析正确无误,不应该进入default分支。所以在此只进行一种错误处理的逻辑,它仍然要保持运算栈的平衡。翻译方法调用时,应当先将方法的目标对象压栈,然后从左到右依次压入每个实参,最后调用call指令完成调用。
所有的TypeBuilder都调用CreateType之后,最后调用AssemblyBuilder.Save方法,就可以将目标程序集写入磁盘了!
public void Create(Ast.AstNode ast, string url) { Visit(ast); Debug.Assert(m_assembly != null); m_assembly.SetEntryPoint(m_mainMethod, PEFileKinds.ConsoleApplication); m_assembly.Save(url); } |
现在终于可以试试看了,我们来编译一段miniSharp代码试试:(阶乘计算)
static class 程序入口
{
//中文注释
public static void Main(string[] args)
{
Fac o;
o = new Fac();
System.Console.WriteLine(o.ComputeFac(8));
}
}
class Fac
{
public int ComputeFac(int num)
{
int num_aux;
if (num < 1)
num_aux = 1;
else
num_aux = num * (this.ComputeFac(num - 1));
return num_aux;
}
}
|
生成的程序集:
运行结果:
看到自己的编译器正确地编译源代码,是否觉得很有成就感呢?如果只想做一个托管编程语言,那么生成CIL就是最后一步了。但是CLR帮我们做的实在太多了,不能满足我们的求知欲。所以,下一阶段我们将亲手实现从中间语言到目标机器代码的编译器后端部分。从下一篇开始本系列的间隔时间会变得比较长而且不确定,因为我自己也需要一边学习一边实践。
希望大家继续关注我的VBF项目:https://github.com/Ninputer/VBF 和我的微博:http://weibo.com/ninputer 多谢大家支持!