`
kimmking
  • 浏览: 536374 次
  • 性别: Icon_minigender_1
  • 来自: 中华大丈夫学院
社区版块
存档分类
最新评论

自己动手: 创建 .NET Framework 语言编译器

阅读更多
自己动手
创建 .NET Framework 语言编译器
Joel Pobar

本文讨论:
  • 语言定义
  • 编译器各阶段
  • CLR 抽象堆栈
  • 正确获得 IL 的工具
本文使用了以下技术:
.NET Framework
编译器黑客在计算机科学领域算得上名声远扬。我曾在“专业开发人员大会”上看到 Anders Hejlsberg 发表一篇演讲之后走下演讲台时,立即有一群人请求他在书上签名并摆出各种姿势要求合影留念的场面。对于那些致力于学习和了解 Lambda 表达式详情、类型系统和汇编语言的人来说,黑客们的智力都颇具神秘色彩。现在,您也可以通过编写自己的 Microsoft® .NET Framework 编译器来分享某些荣耀。
针对 .NET Framework 的编译器有数百种,用于对数十种语言编写的代码进行编译。这些语言在 .NET CLR 中实现交融,代码可以平稳地正常运行并执行交互操作,而不会出现冲突。在构建大型软件系统时,技术精湛的开发人员可以利用这一特性,在程序中添加一些 C# 和 Python 代码。这些开发人员确实给人留下了深刻印象,但他们无法与真正的大师(即编译器黑客)相比,因为大师们深刻了解虚拟机、语言设计以及这些语言和编译器的具体细节。
在本文中,我将带您了解一个用 C# 编写的编译器(“Good for Nothing”编译器,名称很贴切)的代码,并向您介绍构建自己的 .NET 编译器所需的高级体系结构、原理和 .NET Framework API。首先介绍语言定义,接着探讨编译器的体系结构,然后带您了解一下用于生成 .NET 程序集的代码生成子系统。本文的目标是帮助您了解编译器开发的基础知识并深入了解各种语言如何有效地针对 CLR 进行编程。我并不是真的要开发一种语言来替代 C# 4.0 或 IronRuby,但是在本讨论中仍提供了大量鲜为人知的技术隐秘,相信定能激发您对编译器开发技术的热情。

语言定义
软件语言都是针对特定目的开发的。从改善信息表现形式(例如 Visual Basic®),到提高工作效率(例如 Python,旨在最有效地利用每一行代码),再到专用化(例如 Verilog,一种供处理器制造商使用的硬件描述语言),甚至只是为了满足作者的个人喜好(例如,Boo 的创建者对 .NET Framework 情有独钟,而对其他可用语言不屑一顾),目的千差万别,不一而足。
确定目的之后,您便可以设计语言(可将这一过程视为语言蓝图)。计算机语言必须非常精确,以便编程人员准确表达所需的内容,使编译器可以准确理解和生成所表达的确切内容的可执行代码。必须指定语言蓝图才能在实施编译器的过程中消除歧义。为此,可以使用元语法,这种语法用于描述语言的语法。现在存在相当多的元语法,因此,您可以根据个人喜好选择一种。我将使用一种名为 EBNF (Extended Backus-Naur Form) 的元语法来指定“Good for Nothing”语言。
有必要提一下,EBNF 非常有名:它是图灵奖得主兼 FORTRAN 主要开发人员 John Backus 发明的。对 EBNF 进行深层次的讨论不在本文论述范围之内,但我会对基本概念进行解释。
图 1 中显示了“Good for Nothing”的语言定义。根据我的语言定义,语句 (stmt) 可以是变量声明、分配、for 循环,从命令行读取整数或者输出到屏幕。语句可以指定多次,以分号分隔。表达式 (expr) 可以是字符串、整数、算术表达式或标识符。标识符 (ident) 的命名方式为:以字母字符开头,后跟字符或数字等等。很简单,我已定义了一个提供基本算术功能、一个小型类型系统以及基于控制台的简单用户交互的语言语法。
<stmt> := var <ident> = <expr>
        | <ident> = <expr>
        | for <ident> = <expr> to <expr> do <stmt> end
        | read_int <ident>
        | print <expr>
        | <stmt> ; <stmt>

<expr> := <string>
        | <int>
        | <arith_expr>
        | <ident>

<arith_expr> := <expr> <arith_op> <expr>
<arith_op> := + | - | * | /

<ident> := <char> <ident_rest>*
<ident_rest> := <char> | <digit>

<int> := <digit>+
<digit> := 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

<string> := " <string_elem>* "
<string_elem> := <any char other than ">

您可能已注意到,在此语言定义中,特性部分的长度很短。我没有指定数字可以为多大(例如是否可以大于 32 位整数),甚至也未指定是否可以为负数。真正的 EBNF 定义将准确定义这些细节,但是为了简明起见,在此处的示例中未包含这部分信息。
下面是一个“Good for Nothing”语言程序示例:
var ntimes = 0;
print "How much do you love this company? (1-10) ";
read_int ntimes;
var x = 0;
for x = 0 to ntimes do
   print "Developers!";
end;
print "Who said sit down?!!!!!";
您可以将此示例程序与语言定义进行比较,从而更好地理解语法的工作原理。到这里为止,语言定义就完成了。

高级体系结构
编译器的任务就是将编程人员创建的高级任务转换为计算机处理器可以理解和执行的任务。换句话说,编译器将编译用“Good for Nothing”语言编写的程序并将该程序转换为 .NET CLR 可以执行的程序。编译器可以通过一系列转换步骤(将语言分为我们关心的部分并抛弃我们不需要的内容)来实现这一操作。编译器遵循常规软件设计原则,将称为“阶段”的松散耦合组件组合在一起以执行转换步骤。图 2 显示了执行编译器的各阶段的组件:扫描器、分析器和代码生成器。在每个阶段中,对语言进行了进一步的分解,有关程序目标的信息将在下一阶段介绍。
图 2 编译器各阶段 (单击该图像获得较大视图)
编译器奇客通常会将这些阶段抽象地分组为前端和后端。前端包括扫描和分析,而后端通常包括代码生成。前端的任务是发现程序的句法结构,并将程序从文本转换为称作“抽象语法树”(AST) 的高级内存中表示形式(稍后我将对其进行简短介绍)。后端的任务是获取 AST 并将其转换为计算机可以执行的语法。
因为扫描器和分析器通常耦合在一起,而代码生成器通常紧紧地耦合到目标平台,所以编译器通常将三个阶段分为前端和后端。此设计允许开发人员在语言要求跨平台的情况下替换不同平台的代码生成器。
我已在本文随附的代码下载中提供了“Good for Nothing”编译器的代码。在我介绍每个阶段的组件以及探讨实现细节时,您可以照着操作。

扫描器
扫描器的主要任务是将文本(源文件中的字符流)分解为分析器可以使用的块(称作“标记”)。扫描器确定最终发送到分析器的标记,因此能够引发语法中未定义的内容(如注释)。对于“Good for Nothing”语言,扫描器关注字符(A-Z 和常用符号)、数字 (0-9)、定义操作的字符(例如 +、-、* 和 /)、用于字符串封装的问号以及分号。
扫描器将相关字符流一起分组为标记以供分析器使用。例如,字符流“h e l l o w o r l d !”将分组为一个标记“hello world!”。
“Good for Nothing”扫描器非常简单,仅在实例化时需要一个 System.IO.TextReader。这将启动扫描进程,如下所示:
public Scanner(TextReader input)
{
    this.result = new Collections.List<object>();
    this.Scan(input);
}
图 3 显示了 Scan 方法,该方法具有一个简单的 while 循环,用于遍历文本流中的每个字符并查找在语言定义中声明的可识别字符。每次找到可识别字符或字符块时,扫描器都会创建一个标记并将其添加到 List<object>(在这种情况下,我将其作为对象键入。但是,我应创建 Token 类或类似内容,以封装有关标记的详细信息,如行号和列号)。
private void Scan(TextReader input)
{
  while (input.Peek() != -1)
  {
    char ch = (char)input.Peek();

    // Scan individual tokens
    if (char.IsWhiteSpace(ch))
    {
      // eat the current char and skip ahead
      input.Read();
    }
    else if (char.IsLetter(ch) || ch == '_')
    {
      StringBuilder accum = new StringBuilder();

      input.Read(); // skip the '"'

      if (input.Peek() == -1)
      {
        throw new Exception("unterminated string literal");
      }

      while ((ch = (char)input.Peek()) != '"')
      {
        accum.Append(ch);
        input.Read();

        if (input.Peek() == -1)
        {
          throw new Exception("unterminated string literal");
        }
      }

      // skip the terminating "
      input.Read();
      this.result.Add(accum);
    }
        
    ...
  }
}

您可以看到,如果代码遇到 " 字符,它会假设该字符将封装字符串标记;因此我使用该字符串,并将其打包到 StringBuilder 实例和添加到列表中。在扫描生成标记列表之后,标记将通过一个名为 Tokens 的属性转到 parser 类。

分析器
分析器是编译器的心脏,以多种形式和大小呈现。“Good for Nothing”分析器执行多种任务:确保源程序符合语言定义,如果存在故障,则会处理错误的输出。分析器还会创建代码生成器使用的程序语法的内存中表示形式,而且,“Good for Nothing”分析器还可以确定要使用的运行时类型。
我首先要做的事情就是看一下程序语法 AST 的内存中表示形式。然后将查看通过扫描器标记创建此语法树的代码。将快速、高效、方便地对 AST 格式编写代码,并且代码生成器可以对该格式遍历多次。图 4 中显示了“Good for Nothing”编译器的 AST。
public abstract class Stmt
{
}

// var <ident> = <expr>
public class DeclareVar : Stmt
{
    public string Ident;
    public Expr Expr;
}

// print <expr>
public class Print : Stmt
{
    public Expr Expr;
}

// <ident> = <expr>
public class Assign : Stmt
{
    public string Ident;
    public Expr Expr;
}

// for <ident> = <expr> to <expr> do <stmt> end
public class ForLoop : Stmt
{
    public string Ident;
    public Expr From;
    public Expr To;
    public Stmt Body;
}

// read_int <ident>
public class ReadInt : Stmt
{
    public string Ident;
}

// <stmt> ; <stmt>
public class Sequence : Stmt
{
    public Stmt First;
    public Stmt Second;
}

/* <expr> := <string>
 *  | <int>
 *  | <arith_expr>
 *  | <ident>
 */
public abstract class Expr
{
}

// <string> := " <string_elem>* "
public class StringLiteral : Expr
{
    public string Value;
}

// <int> := <digit>+
public class IntLiteral : Expr
{
    public int Value;
}

// <ident> := <char> <ident_rest>*
// <ident_rest> := <char> | <digit>
public class Variable : Expr
{
    public string Ident;
}

// <arith_expr> := <expr> <arith_op> <expr>
public class ArithExpr : Expr
{
    public Expr Left;
    public Expr Right;
    public BinOp Op;
}

// <arith_op> := + | - | * | /
public enum ArithOp
{
    Add,
    Sub,
    Mul,
    Div
}

“Good for Nothing”语言定义的快速概览显示了与来自 EBNF 语法的语言定义节点松散匹配的 AST。虽然抽象语法树会捕获这些元素的结构,但在封装语法时最好考虑一下语言定义。
可以通过多种算法进行分析,但探讨算法不在本文论述范围之内。总之,这些算法在遍历标记流以创建 AST 方面不同。在“Good for Nothing”编译器中,我使用了名为 LL(从左到右、最左派生)的自上而下的分析器。这仅意味着从左到右读取文本,并根据下一个可用输入标记构造 AST。
我的分析器类的构造函数仅采用扫描器创建的标记列表:
public Parser(IList<object> tokens)
{
    this.tokens = tokens;
    this.index = 0;
    this.result = this.ParseStmt();
    
    if (this.index != this.tokens.Count)
        throw new Exception("expected EOF");
}
分析工作的核心由 ParseStmt 方法完成,如图 5 中所示。该方法返回的 Stmt 节点充当树的根节点,且与语言语法定义的顶级节点相匹配。通过在标识对语言语法(变量声明和分配、for 循环、read_ints 和输出)中的 Stmt 节点有用的标记时使用索引作为当前位置,分析器可以遍历标记列表。如果无法标识标记,则将引发异常。
private Stmt ParseStmt()
{
    Stmt result;

    if (this.index == this.tokens.Count)
    {
        throw new Exception("expected statement, got EOF");
    }

    if (this.tokens[this.index].Equals("print"))
    {
        this.index++;
        ...
    }
    else if (this.tokens[this.index].Equals("var"))
    {
        this.index++;
        ...
    }
        else if (this.tokens[this.index].Equals("read_int"))
    {
        this.index++;
        ...
    }
    else if (this.tokens[this.index].Equals("for"))
    {
        this.index++;
        ...
    }
    else if (this.tokens[this.index] is string)
    {
        this.index++;
        ...
    }
    else
    {
        throw new Exception("parse error at token " + this.index + 
            ": " + this.tokens[this.index]);
    }
    ...
}

如果标识了某个标记,则会创建 AST 节点并执行该节点所要求的任何进一步分析。创建输出 AST 代码所需的代码如下所示:
// <stmt> := print <expr>
if (this.tokens[this.index].Equals("print"))
{
    this.index++;
    Print print = new Print();
    print.Expr = this.ParseExpr();
    result = print;
}
这里发生了两件事情。由于语言定义要求输出标记后跟表达式,因此可以通过增加索引计数器来丢弃输出标记,并且可以调用 ParseExpr 方法来获取 Expr 节点。
图 6 显示了 ParseExpr 代码。该代码遍历当前索引的标记列表,标识符合表达式的语言定义要求的标记。在这种情况下,该方法仅查找字符串、整数和变量(由扫描器实例创建),并返回表示这些表达式的适当 AST 节点。
// <expr> := <string>
// | <int>
// | <ident>
private Expr ParseExpr()
{
  ...
  if (this.tokens[this.index] is StringBuilder)
  {
    string value = ((StringBuilder)this.tokens[this.index++]).ToString();
    StringLiteral stringLiteral = new StringLiteral();
    stringLiteral.Value = value;
    return stringLiteral;
  }
  else if (this.tokens[this.index] is int)
  {
    int intValue = (int)this.tokens[this.index++];
    IntLiteral intLiteral = new IntLiteral();
    intLiteral.Value = intValue;
    return intLiteral;
  }
  else if (this.tokens[this.index] is string)
  {
    string ident = (string)this.tokens[this.index++];
    Variable var = new Variable();
    var.Ident = ident;
    return var;
  }
  ...
} 

对于符合“<stmt> ; <stmt>”语言语法定义要求的字符串语句,将使用序列 AST 节点。此序列节点包含两个指向 stmt 节点的指针,并形成 AST 树结构的基础。下面详细介绍了用于处理序列示例的代码:
if (this.index < this.tokens.Count && this.tokens[this.index] == 
    Scanner.Semi)
{
    this.index++;

    if (this.index < this.tokens.Count &&
        !this.tokens[this.index].Equals("end"))
    {
        Sequence sequence = new Sequence();
        sequence.First = result;
        sequence.Second = this.ParseStmt();
        result = sequence;
    }
}
图 7 中显示的 AST 树是“Good for Nothing”代码的以下代码段的运行结果:
图 7 helloworld.gfn AST 树和高级别跟踪 (单击该图像获得较大视图)
var x = "hello world!";
print x;

以 .NET Framework 为目标
在获取执行代码生成的代码之前,首先回顾一下我们的目标。因此,下面我将介绍 .NET CLR 提供的编译器服务,包括基于堆栈的虚拟机、类型系统和用于创建 .NET 程序集的库。我还将简要谈及标识和诊断编译器输出中的错误所需的工具。
CLR 是一个虚拟机,也就是说它是一款模拟计算机系统的软件。与任何计算机一样,CLR 具有一组它可以执行的低级操作,一组内存服务,以及一种用来定义可执行程序的汇编语言。CLR 使用基于堆栈的抽象数据结构来模拟代码的执行,并使用一种名为“中间语言”(IL) 的汇编语言来定义可在堆栈上执行的操作。
如果执行了用 IL 定义的计算机程序,则 CLR 将只模拟根据堆栈指定的操作,按指令压入和弹出要执行的数据。假设要使用 IL 将两个数值相加。以下代码演示了 10 + 20 执行过程:
  ldc.i4    10    
  ldc.i4    20    
  add        
第一行 (ldc.i4 10) 将整数 10 压入堆栈。第二行 (ldc.i4 20) 接着将整数 20 压入堆栈。第三行 (add) 将这两个整数弹出堆栈,再将它们相加,然后将结果压入堆栈。
通过将 IL 和堆栈语义转换为处理器的基础计算机语言,可以在运行时通过实时 (JIT) 编译或预先通过本机映像生成器 (Ngen) 等服务模拟堆栈计算机。
存在许多可用于构建程序的 IL 指令,范围从基本算法到流控制再到多种调用约定。有关所有 IL 指令的详细信息,请查看欧洲计算机厂家协会 (ECMA) 规范(位于 msdn2.microsoft.com/aa569283)的第 III 部分。
CLR 的抽象堆栈不只是对整数执行操作。它的类型系统内容非常丰富,包括字符串、整数、布尔型、浮点、双精度型等。为了使我的语言能够在 CLR 上安全运行且与其他符合 .NET 的语言进行互操作,我将某些 CLR 类型系统并入自己的程序中。具体说来,“Good for Nothing”语言定义了数字和字符串两种类型,我已将这两种类型映射到 System.Int32 和 System.String。
“Good for Nothing”编译器使用名为 System.Reflection.Emit 的基类库 (BCL) 组件来处理 IL 代码生成及 .NET 程序集的创建和打包操作。该库是一个低级库,它通过 IL 语言提供简单代码生成抽象功能而与底层硬件紧密相关。也可通过其他已知 BCL API(包括 System.Xml.XmlSerializer)使用该库。
创建 .NET 程序集(图 8 中所示)所需的高级类具有每个逻辑 .NET 元数据抽象的生成器 API,因此一定程度上遵循生成器软件设计模式。AssemblyBuilder 类用于创建 PE 文件和设置必要的 .NET 程序集元数据元素(如清单)。ModuleBuilder 类用于在程序集内创建模块。TypeBuilder 类用于创建 Types 及其相关元数据。MethodBuilder 和 LocalBuilder 类则用于将 Methods 和 Locals 分别添加到 Types 和 Methods 中。ILGenerator 类用于使用 OpCodes 类(一个包含所有可能的 IL 指令的大枚举)为 Methods 生成 IL 代码。“Good for Nothing”代码生成器中使用了所有这些 Reflection.Emit 类。
图 8 用于构建 .NET 程序集的 Reflection.Emit 库 (单击该图像获得较大视图)

正确获得 IL 的工具
即使是经验最丰富的编译器黑客,也有可能在代码生成级别出错。最常见的故障是 IL 代码出错,这会导致堆栈中出现不平衡。如果发现 IL 出错(在加载程序集或对 IL 执行 JIT 时,具体取决于程序集的信任级别),则 CLR 通常将引发异常。使用名为 peverify.exe 的 SDK 工具可以方便地诊断和修复这些错误。该工具将对 IL 进行验证,确保代码正确并且可以安全地执行。
例如,下面显示了一些尝试将数字 10 与字符串“bad”相加的 IL 代码:
ldc.i4    10
ldstr    "bad"
add
对包含此错误 IL 的程序集运行 peverify 将导致发生以下错误:
[IL]: Error: [C:\MSDNMagazine\Sample.exe : Sample::Main][offset 0x0000002][found ref 'System
.String'] Expected numeric type on the stack.
在此示例中,peverify 报告 add 指令期望两种数值类型相加,却发现一个是整数类型,另一个是字符串类型。
ILASM(IL 汇编程序)和 ILDASM(IL 反汇编程序)是两种 SDK 工具,用于将文本 IL 编译为 .NET 程序集,并将得到的程序集相应地反编译为 IL。使用 ILASM 可以快速简便地测试将成为编译器输出基础的 IL 指令流。只需在文本编辑器中创建测试 IL 代码并将其输入 ILASM。同时,ILDASM 工具可以快速查看编译器为特定代码路径生成的 IL。其中包括 C# 编译器等商业编译器发出的 IL。该工具提供用于查看语言之间类似语句的 IL 代码的良好方法;换句话说,具有类似结构的其他编译器可以重用为 C# 的 for 循环生成的 IL 流控制代码。

代码生成器
“Good for Nothing”编译器的代码生成器严重依赖 Reflection.Emit 库来生成可执行 .NET 程序集。我将介绍和分析类的重要部分;其他部分留待您空闲时细读。
图 9 中显示的 CodeGen 构造函数用于设置 Reflection.Emit 基础结构,在开始发出代码之前需要该基础结构。首先我会定义程序集的名称并将其传递给程序集生成器。在此示例中,我使用源文件名作为程序集名称。接着定义一个 ModuleBuilder 模块,使用的名称与程序集名称相同。然后在 ModuleBuilder 上定义 TypeBuilder,以仅保留程序集中的类型。没有定义任何类型作为“Good for Nothing”语言定义的第一个类成员,但至少需要一个类型才能在启动时保留运行的方法。MethodBuilder 定义 Main 方法来保留将为“Good for Nothing”代码生成的 IL。我必须对此 MethodBuilder 调用 SetEntryPoint,以便在用户运行可执行程序时该类能在启动时运行。然后我使用 GetILGenerator 方法从 MethodBuilder 创建全局 ILGenerator (il)。
Emit.ILGenerator il = null;
Collections.Dictionary<string, Emit.LocalBuilder> symbolTable;

public CodeGen(Stmt stmt, string moduleName)
{
  if (Path.GetFileName(moduleName) != moduleName)
  {
    throw new Exception("can only output into current directory!");
  }

  AssemblyName name = new 
    AssemblyName(Path.GetFileNameWithoutExtension(moduleName));
  Emit.AssemblyBuilder asmb = 
    AppDomain.CurrentDomain.DefineDynamicAssembly(name, 
      Emit.AssemblyBuilderAccess.Save);
  Emit.ModuleBuilder modb = asmb.DefineDynamicModule(moduleName);
  Emit.TypeBuilder typeBuilder = modb.DefineType("Foo");

  Emit.MethodBuilder methb = typeBuilder.DefineMethod("Main", 
    Reflect.MethodAttributes.Static, 
    typeof(void), 
    System.Type.EmptyTypes);

  // CodeGenerator
  this.il = methb.GetILGenerator();
  this.symbolTable = new Dictionary<string, Emit.LocalBuilder>();

  // Go Compile
  this.GenStmt(stmt);

  il.Emit(Emit.OpCodes.Ret);
  typeBuilder.CreateType();
  modb.CreateGlobalFunctions();
  asmb.SetEntryPoint(methb);
  asmb.Save(moduleName);
  this.symbolTable = null;
  this.il = null;
}

设置 Reflection.Emit 基础结构后,代码生成器将调用用于遍历 AST 的 GenStmt 方法。这将通过全局 ILGenerator 生成必要的 IL 代码。图 10 显示了 GenStmt 方法的子集,首次调用该方法时,从 Sequence 节点开始,然后继续根据当前 AST 节点类型执行 AST 切换。
private void GenStmt(Stmt stmt)
{
    if (stmt is Sequence)
    {
        Sequence seq = (Sequence)stmt;
        this.GenStmt(seq.First);
        this.GenStmt(seq.Second);
    }        
    
    else if (stmt is DeclareVar)
    {
        ...    
    }        
    
    else if (stmt is Assign)
    {
        ...        
    }                
    else if (stmt is Print)
    {
        ...    
    }
}    

DeclareVar(用于声明一个变量)AST 节点的代码如下所示:
else if (stmt is DeclareVar)
{
    // declare a local
    DeclareVar declare = (DeclareVar)stmt;
    this.symbolTable[declare.Ident] =
        this.il.DeclareLocal(this.TypeOfExpr(declare.Expr));

    // set the initial value
    Assign assign = new Assign();
    assign.Ident = declare.Ident;
    assign.Expr = declare.Expr;
    this.GenStmt(assign);
}
这里需要完成的第一件事情是向符号表中添加变量。符号表是核心编译器数据结构,用于将符号标识符(在这种情况下为基于字符串的变量名称)与其类型、位置和程序中的范围相关联。“Good for Nothing”符号表很简单,因为所有变量声明都位于 Main 方法中。因此我使用简单的 Dictionary<string, LocalBuilder> 将符号与 LocalBuilder 关联起来。
将符号添加到符号表后,我会将 DeclareVar AST 节点转换为 Assign 节点,以便为变量分配变量声明表达式。使用以下代码生成 Assignment 语句:
else if (stmt is Assign)
{
    Assign assign = (Assign)stmt;
    this.GenExpr(assign.Expr, this.TypeOfExpr(assign.Expr));
    this.Store(assign.Ident, this.TypeOfExpr(assign.Expr));
}    
执行此操作可生成 IL 代码以将表达式加载到堆栈上,然后发出 IL 将表达式存储在适当的 LocalBuilder 中。
图 11 中显示的 GenExpr 代码获取 Expr AST 节点,并将加载表达式所需的 IL 发出到堆栈计算机上。StringLiteral 和 IntLiteral 有些类似,因为它们都会将用于加载相应字符串和整数的 IL 指令定向到堆栈上:ldstr 和 ldc.i4。
private void GenExpr(Expr expr, System.Type expectedType)
{
  System.Type deliveredType;
    
  if (expr is StringLiteral)
  {
    deliveredType = typeof(string);
    this.il.Emit(Emit.OpCodes.Ldstr, ((StringLiteral)expr).Value);
  }
  else if (expr is IntLiteral)
  {
    deliveredType = typeof(int);
    this.il.Emit(Emit.OpCodes.Ldc_I4, ((IntLiteral)expr).Value);
  }        
  else if (expr is Variable)
  {
    string ident = ((Variable)expr).Ident;
    deliveredType = this.TypeOfExpr(expr);

    if (!this.symbolTable.ContainsKey(ident))
    {
      throw new Exception("undeclared variable '" + ident + "'");
    }

    this.il.Emit(Emit.OpCodes.Ldloc, this.symbolTable[ident]);
  }
  else
  {
    throw new Exception("don't know how to generate " + 
      expr.GetType().Name);
  }

  if (deliveredType != expectedType)
  {
    if (deliveredType == typeof(int) &&
        expectedType == typeof(string))
    {
      this.il.Emit(Emit.OpCodes.Box, typeof(int));
      this.il.Emit(Emit.OpCodes.Callvirt, 
        typeof(object).GetMethod("ToString"));
    }
    else
    {
      throw new Exception("can't coerce a " + deliveredType.Name + 
        " to a " + expectedType.Name);
    }
  }
}

变量表达式通过调用 ldloc 并在相应 LocalBuilder 中传递,可以仅将方法的本地变量加载到堆栈上。图 11 中显示的最后一部分代码用于将表达式类型转换为预期类型(称为“类型强制”)。例如,可能需要在调用中将类型转换为 print 方法,而将整数转换为字符串以便可以成功输出。
图 12 演示了如何将变量分配给 Store 方法中的表达式。通过符号表查询变量名称,然后将对应的 LocalBuilder 传递给 stloc IL 指令。这只会从堆栈弹出当前表达式并将表达式分配给本地变量。
private void Store(string name, Type type)
{
  if (this.symbolTable.ContainsKey(name))
  {
    Emit.LocalBuilder locb = this.symbolTable[name];

    if (locb.LocalType == type)
    {
      this.il.Emit(Emit.OpCodes.Stloc, this.symbolTable[name]);
    }
    else
    {
      throw new Exception("'" + name + "' is of type " + 
        locb.LocalType.Name + " but attempted to store value of type " + 
        type.Name);
    }
  }
  else
  {
    throw new Exception("undeclared variable '" + name + "'");
  }
} 

用于为 Print AST 节点生成 IL 的代码很有意义,因为在 BCL 方法中调用了该代码。将在堆栈上生成该表达式,而 IL 调用指令用于调用 System.Console.WriteLine 方法。Reflection 用于获取传递给调用指令所需的 WriteLine 方法句柄:
else if (stmt is Print)
{ 
  this.GenExpr(((Print)stmt).Expr, typeof(string));
  this.il.Emit(Emit.OpCodes.Call, 
    typeof(System.Console).GetMethod("WriteLine", 
    new Type[] { typeof(string) }));
}
调用某个方法时,方法参数按后进先出的方式从堆栈弹出。也就是,方法的第一个参数来自堆栈顶项,第二个参数来自第一个的下面,依此类推。
这里最复杂的代码就是为“Good for Nothing”for 循环生成 IL 的代码(参见图 13)。这与商业编译器生成此种类型代码的方法很类似。但是,解释 for 循环最好的方式是查看生成的 IL(图 14 中所示)。
// for x = 0
IL_0006:  ldc.i4     0x0
IL_000b:  stloc.0

// jump to the test
IL_000c:  br         IL_0023

// execute the loop body
IL_0011:  ...

// increment the x variable by 1
IL_001b:  ldloc.0
IL_001c:  ldc.i4     0x1
IL_0021:  add
IL_0022:  stloc.0

// TEST
// load x, load 100, branch if
// x is less than 100
IL_0023:  ldloc.0
IL_0024:  ldc.i4     0x64
IL_0029:  blt        IL_0011

else if (stmt is ForLoop)
{
    // example:
    // var x = 0; 
    // for x = 0 to 100 do
    //   print "hello";
    // end;

    // x = 0
    ForLoop forLoop = (ForLoop)stmt;
    Assign assign = new Assign();
    assign.Ident = forLoop.Ident;
    assign.Expr = forLoop.From;
    this.GenStmt(assign);            
    // jump to the test
    Emit.Label test = this.il.DefineLabel();
    this.il.Emit(Emit.OpCodes.Br, test);

    // statements in the body of the for loop
    Emit.Label body = this.il.DefineLabel();
    this.il.MarkLabel(body);
    this.GenStmt(forLoop.Body);

    // to (increment the value of x)
    this.il.Emit(Emit.OpCodes.Ldloc, this.symbolTable[forLoop.Ident]);
    this.il.Emit(Emit.OpCodes.Ldc_I4, 1);
    this.il.Emit(Emit.OpCodes.Add);
    this.Store(forLoop.Ident, typeof(int));

    // **test** does x equal 100? (do the test)
    this.il.MarkLabel(test);
    this.il.Emit(Emit.OpCodes.Ldloc, this.symbolTable[forLoop.Ident]);
    this.GenExpr(forLoop.To, typeof(int));
    this.il.Emit(Emit.OpCodes.Blt, body);
}

IL 代码首先分配初始 for 循环计数器,然后立即使用 IL 指令 br(分支)跳转到 for 循环测试。与 IL 代码左侧列出的标签相似的标签用于让运行时知道下一个指令分支到的地方。测试代码将使用 blt(如果小于则分支)指令检查变量 x 是否小于 100。如果为 True,则执行循环体,同时递增 x 变量,然后再次运行测试。
图 13 中的 for 循环代码生成对计数器变量执行分配和增量操作所需的代码。还可以对 ILGenerator 使用 MarkLabel 方法来生成分支指令可以分支到的标签。

总结...几乎涵盖所有方面
我已根据简单的 .NET 编译器演练了代码并探讨了一些基本原理。本文旨在为您提供构造编译器这个神秘世界的基础。尽管在网上您能够发现一些有价值的信息,但是还应参考一些书籍。我向您推荐以下书籍:《Compiling for the .NET Common Language Runtime》,作者:John Gough(Prentice Hall,2001);《Inside Microsoft IL Assembler》,作者:Serge Lidin(Microsoft Press®,2002);《Programming Language Pragmatics》,作者:Michael L. Scott(Morgan Kaufmann,2000)以及《Compilers: Principles, Techniques, and Tools》,作者:Alfred V. Oho、Monica S. Lam、Ravi Sethi 和 Jeffrey Ullman(Addison Wesley,2006)。
这些书籍完全涵盖了编写自己的语言编译器需要了解的核心内容;不过,我并没有真正完成对编译器的讨论。对于特别重要的内容,我将查看一些高级主题以更好地为您讲解。

动态方法调用
方法调用是所有计算机语言的基础,但调用存在一个范围。较新的语言(如 Python)会将方法和调用的绑定延迟到最后一刻,这称为“动态调用”。流行的动态语言(如 Ruby、JavaScript、Lua,甚至 Visual Basic)都具有这一模式。为了使编译器发出代码以执行方法调用,编译器必须将方法名称看作一个符号,并将其传递给将按照语言语义执行绑定和调用操作的运行库。
假设您关闭了 Visual Basic 8.0 编译器中的 Option Strict。方法调用会成为后期绑定,Visual Basic 运行时将在运行时执行绑定和调用操作。
Visual Basic 编译器不会向 Method1 方法发出 IL 调用指令,而是向名为 CompilerServices.NewLateBinding.LateCall 的 Visual Basic 运行时方法发出调用指令。在此过程中,编译器将传入一个对象 (obj) 以及方法 (Method1) 的符号名称,并传入所有方法参数。Visual Basic LateCall 方法然后会使用 Reflection 来查询对象的 Method1 方法,如果找到,则执行基于 Reflection 的方法调用:
   Option Strict Off

Dim obj
obj.Method1()

IL_0001:  ldloc.0
IL_0003:  ldstr      "Method1"
...
IL_0012:  call       object CompilerServices.NewLateBinding::LateCall(object, ... , string, ...)

使用 LCG 快速执行后期绑定
基于 Reflection 的方法调用的速度可能特别慢(请参阅我在网上发表的文章:“Reflection: Dodge Common Performance Pitfalls to Craft Speedy Applications”,网址是 msdn.microsoft.com/msdnmag/issues/05/07/Reflection)。方法绑定和方法调用都比简单的 IL 调用指令要慢得多。.NET Framework 2.0 CLR 包括一项名为“轻量级代码生成”(LCG) 的功能,该功能可用于动态创建弹出代码,从而使用速度更快的 IL 调用指令在调用站点和方法之间搭建桥梁。这将显著增加方法调用的速度。仍需要查询对象的方法,如果找到,则可以针对每个重复调用创建和缓存 DynamicMethod 桥接。
图 15 显示了一种非常简单的后期绑定,用于执行 bridge 方法的动态代码生成。该绑定首先查询缓存并查看以前是否见过该调用站点。调用站点首次运行时,将生成 DynamicMethod,该方法返回一个对象并将对象数组作为参数。对象数组参数包含的实例对象和参数用于对方法进行最终调用。生成的 IL 代码将对象数组解压缩到堆栈上,首先解压缩实例对象然后解压缩参数。然后将发出调用指令并将调用结果返回被调用方。
通过委托快速调用 LCG 桥接方法。我只是将
分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics