[LLVM翻译]创建BOLT COMPILER:第6部分 去雕饰--将我们的高级语言进行简化!

649 阅读10分钟

原文地址:mukulrathi.co.uk/create-your…

原文作者:mukulrathi.co.uk/

发布时间:2020年7月1日-6分钟阅读

系列。创建BOLT编译器

即将推出


只要给我代码就可以了!

本博文中的所有说明性代码片段都链接到Bolt资源库中的相应文件。这里面的代码多得让这篇文章变得非常长。

本篇文章的前半部分将关注desugaring/文件夹,后半部分将涵盖ir_gen/文件夹

什么是desugaring?

编程语言是一系列抽象的语言。没有人通过输入0和1来编写程序--这不是人类可以读懂的。我们最接近硬件操作的是汇编代码,例如一系列的add mov和jmp指令。

汇编代码仍然不是一个真正令人愉快的编程体验。即使是像C / C++ / Rust这样我们认为是低级的语言,也在汇编代码上提供了许多抽象的东西,比如if语句和while循环。

我们称这些抽象为语法糖--因为它们使程序员在使用该语言编程时更加甜美。

但当我们在编写编译器时,我们要反其道而行之--我们要去掉源代码的糖分--去掉更高级别的结构。我们也把这称为降低高级语言构造。

在这篇文章中,我们将从去除for循环开始。然后我们将看看Bolt编译器前端的 "去ugaring "和 "IR Lowering "阶段。这将结束我们的编译器前端,并为我们的编译器后端切换到C++做准备。

循环的去糖化

我们desugar的第一种情况其实是介于解析和类型检查阶段之间的--将一个for循环去ugaring成一个while循环。

for (let i = 0; i < n; i:=i+1) {
  doSomething
}

// desugared

let i = 0;
while (i < n) {
  doSomething;
  i:=i+1
}

请注意,当类型检查表达式时,我们将其作为一种特殊情况来处理,然而你可以想象,如果有更多的糖(比如++i而不是i:=i+1),我们可能会在解析的AST和类型化的AST之间增加一个完整的去糖阶段。

let rec type_expr class_defns function_defns (expr : Parsed_ast.expr) env =
...
| Parsed_ast.For
      (loc, start_expr, cond_expr, step_expr, Parsed_ast.Block (block_loc, loop_expr)) ->
      (* desugar into a while loop *)
      type_block_with_defns
        (Parsed_ast.Block
           ( loc
           , [ start_expr
             ; Parsed_ast.While
                 (loc, cond_expr,
                 Parsed_ast.Block (block_loc, loop_expr @ [step_expr]))
             ] ))
        env

在类型检查阶段之间进行除渣

在类型检查的两个阶段之间,Desugaring得到了自己的阶段。数据竞赛类型检查比传统的类型检查(int、bool等)要复杂得多,所以我们简化了语言,以避免考虑那么多情况。

去除变量阴影

例如,考虑变量影子,我们可以在嵌套的作用域中声明同一个变量名x。考虑以下内容:

let x = 0;
if (x >= 0) {
  let x = 1;
  let y = x + 1 // we now refer to the value x=1
}
else { // we refer to the value x=0
 x := 1
}

变量影子是语法糖--我们不要求程序员在嵌套作用域中使用唯一的变量名。这使得之前讨论的别名活度分析变得更加困难。我们怎么知道x的哪个值被别名了呢?我们可以跟踪我们在哪个作用域中,或者rrr我们可以避开它。一旦我们给变量起了唯一的名字,处理起来就容易多了。

let _x0 = 0;
if (_x0 >= 0) {
  let _x1 = 1;
  let y = _x1+ 1
else {
 _x0 := 1
}

我们首先创建一个从旧变量名到新变量名的映射。我们计算这个变量到目前为止在外部作用域中被声明的次数,并把这个次数记在变量名的最后。而且为了说明这些是编译器生成的名字,我们在它们前面加了一个_,因为在Bolt中,程序员不能定义一个以_开头的变量。

type var_name_map = (Var_name.t * Var_name.t) list

let set_unique_name var_name var_name_map =
  let num_times_var_declared =
    List.length (List.filter ~f:(fun (name, _) -> name = var_name) var_name_map) in
  Var_name.of_string
    (Fmt.str "_%s%d" (Var_name.to_string var_name) num_times_var_declared)

除砂功能/方法重载

函数重载是指我们定义多个名称相同但参数类型不同的函数。如果你想根据传入的参数类型调用不同的打印方法,这很有用。

function void print(Foo x){
  ...
}
function void print(Bar x){
  ...
}
function void print(int x){
  ...
}

同样,这是一个很好的结构,但我们有一个问题--我们要调用哪个函数?我们无法从源代码中得知,但我们可以使用前一个类型检查阶段的参数类型信息。

通过将更复杂的语言构造去糖化为更简单的语言构造,我们使编译器的后续阶段变得更简单--它们不需要知道任何已经去糖化的东西。

我们在对函数应用表达式进行类型检查时,会对其参数的类型进行编码。

| Parsed_ast.FunctionApp (loc, func_name, args_exprs) ->
      type_args type_with_defns args_exprs env
      >>= fun (typed_args_exprs, args_types) ->
      get_matching_function_type class_defns func_name args_types function_defns loc
      >>| fun (param_types, return_type) ->
      ( Typed_ast.FunctionApp (loc, return_type, param_types, func_name, typed_args_exprs)
      , return_type )

名称混淆功能

现在,由于每个重载函数都有不同的参数类型,我们可以将参数类型映射为一个唯一的字符串,并将其附加到我们的函数名上。我们把这个生成唯一函数名的过程称为mangling。

我们将采用C++中使用的方法。

对于每一个基元类型,我们可以将它们映射为一个唯一的单字符,而对于类,我们将它们映射为以其长度为前缀的类名。然后我们将所有的param类型连接在一起。

为什么要预置长度?考虑一下参数类型(Foo x, Bar y)和(FooBar x)--如果我们连接它们的参数名,它们都会映射到FooBar。只有当我们预输入长度时,它们才能被区分开来--3Foo3Bar与6FooBar。

let name_mangle_param_types param_types =
  String.concat
    (List.map
       ~f:(function
         | TEVoid                  -> "v"
         | TEInt                   -> "i"
         | TEBool                  -> "b"
         | TEClass (class_name, _) ->
             let class_name_str = Class_name.to_string class_name in
             Fmt.str "%d%s" (String.length class_name_str) class_name_str)
       param_types)

然后给一个方法或函数取名mangle,我们有以下代码。

let name_mangle_overloaded_method meth_name param_types =
  Method_name.of_string
    (Fmt.str "_%s%s"
       (Method_name.to_string meth_name)
       (name_mangle_param_types param_types))

例如,使用这种名称混搭方案,testFun(Foo x, Bar y)映射为_testFun3Foo3Bar(Foo x, Bar y)。

如果你看一下Bolt repo的主分支,你会发现desugaring阶段也会去掉属名。这是一个值得在后面的系列文章中单独讨论的话题!

降低到IR

回顾到目前为止,我们首先看了循环的去ugaring--这发生在类型检查的解析和第一阶段之间。然后我们看了位于类型检查的两个阶段之间的去ugaring阶段。现在我们来看一下发生在类型检查之后的IR降低阶段。

IR是中间表示法的意思--它比源代码简单,但还没有完全降低到汇编代码的程度。

我们对IR的目标是接近LLVM的表示,使LLVM API的工作尽可能简单。我们还将剥离任何我们在运行程序时不需要的信息。

将对象降为结构

类是一个抽象的概念,它将字段和方法组合在一起。LLVM IR不包含类和对象,只包含结构,结构只是一组字段。

那么,我们如何从Bolt类的定义映射到一个结构呢?我们将类中的信息剥离出来。

  • 我们去掉了字段定义中的var / const字样
  • 我们放弃了能力注释
  • 除了字段类型,我们在AST中放弃了类型信息。
  • 我们放弃loc(我们用于类型检查错误信息的行位置信息)。
  • 我们放弃了字段名
  • 我们不再将方法与类相关联(稍后再谈!)。

回想一下,为我们的AST注释类型和能力的目的是为了检查程序是否正确。如果我们可以为一个表达式指定类型,那么它就是类型良好的,所以它满足了我们的正确性概念。同样的,如果我们可以分配能力,那么我们就知道我们的程序没有数据竞赛。而const只是一个编译器检查,防止我们重新赋值一个字段。

一旦我们检查了所有这些,我们就可以放弃这些信息,因为我们以后在编译器中不需要它。事实上,我们的类定义现在已经非常裸露了--只有类名(一个字符串)和一个字段类型的列表,LLVM将用它来决定为一个对象分配多少内存。

type class_defn = TClass of string * type_expr list

字段名对于我们程序员来说是很有用的,但是对于计算机来说,我们不需要给我们的字段命名,我们可以给它们编号,作为结构的索引。直观地讲,这就像数组索引一样。

let ir_gen_field_index field_name class_name class_defns =
  get_class_fields class_name class_defns
  |> fun field_defns ->
  List.find_mapi_exn
    ~f:(fun index (TField (_, _, name, _)) ->
      if name = field_name then Some index else None)
    field_defns

请注意这个List.find_mapi_exn函数名可能看起来很复杂,但目标是通过遍历(map)列表中的每个元素,以及该字段的索引(因此是mapi而不是map),找到与给定字段名相匹配的字段,如果没有找到,则引发一个异常(exn)。在实践中,这个函数永远不会引发异常,因为我们已经在前面的类型检查阶段检查了字段的存在。

方法只是普通的函数,它隐式地接受一个额外的参数:this,它指的是调用方法的对象。在 Python 中,这个附加参数 (被称为 self) 在方法声明中被显式声明。

请注意,我们需要再次为我们的方法命名,通过在类名前加上前缀。现在,我们在一个类内有唯一的方法名,当我们把它们作为普通函数分开时,它们需要全局唯一命名。

自动插入锁具

Bolt有一个锁定的功能,它类似于Java中的同步关键字--这将锁定包裹在任何访问中。由于我们要放弃这种锁定功能,我们需要在IR中指定锁/解锁指令。

type lock_type = Reader  | Writer

type expr =
  | Integer     of int
  | Boolean     of bool
  | Identifier  of identifier * lock_type option
  (* maybe acquire a lock when accessing an identifier *)
  ...
  | Lock        of string * lock_type
  | Unlock      of string * lock_type

和我们的插入锁,我们锁定对象(this),然后计算返回值,释放锁并返回值。

... {
  methodBody
}

// adding locks
...{
  lock(this);
  let retVal = methodBody;
  unlock(this);
  retVal
}

对应的生成代码是

let ir_gen_class_method_defn class_defns class_name
    (Desugared_ast.TMethod
       ( method_name
       , _
       (* drop info about whether returning borrowed ref *)
       , return_type
       , params
       , capabilities_used
       , body_expr )) =
    ...
   |> fun ir_body_expr ->
  (* check if we use locked capability *)
  ( match
      List.find
        ~f:(fun (Ast_types.TCapability (mode, _))
        -> mode = Ast_types.Locked)
        capabilities_used
    with
  | Some _lockedCap ->
      [ Frontend_ir.Lock ("this", Frontend_ir.Writer)
      ; Frontend_ir.Let ("retVal", Frontend_ir.Block ir_body_expr)
      ; Frontend_ir.Unlock ("this", Frontend_ir.Writer)
      ; Frontend_ir.Identifier (Frontend_ir.Variable "retVal", None) ]
  | None (* no locks used *) -> ir_body_expr )
  |> fun maybe_locked_ir_body_expr ->
  Frontend_ir.TFunction
    (ir_method_name, ir_return_type, ir_params, maybe_locked_ir_body_expr)

而对于标识符,如果我们要锁定它们,我们会根据是从它们身上读取还是给它们赋值,来获取一个Reader/Writer Lock。

let rec ir_gen_expr class_defns expr =
...
  | Desugared_ast.Identifier (_, id) ->
      ir_gen_identifier class_defns id
      |> fun (ir_id, should_lock) ->
      let lock_held = if should_lock then Some Frontend_ir.Reader else None in
      Frontend_ir.Identifier (ir_id, lock_held)
...
  | Desugared_ast.Assign (_, _, id, assigned_expr) ->
      ir_gen_identifier class_defns id
      |> fun (ir_id, should_lock) ->
      ir_gen_expr class_defns assigned_expr
      |> fun ir_assigned_expr ->
      let lock_held = if should_lock then Some Frontend_ir.Writer else None in
      Frontend_ir.Assign (ir_id, ir_assigned_expr, lock_held)

结束我们的编译器前端

正如前几部分提到的,Bolt仓库还包含了其他语言特性的代码(继承和泛型,在后面的文章中会提到)。所以不用担心ir_gen/文件夹中提到的 "vtables"。要查看这些特性被添加之前的简单版本,请运行git checkout simple-compiler-tutorial。

我们现在已经结束了对编译器前端的讨论!接下来我们将切换到编译器前端。

接下来我们将从OCaml切换到C++来生成LLVM IR代码。为此,我们将使用Protobuf,一种跨语言的二进制序列化格式。

一旦我们完成了这些工作,在几篇文章中我们就可以谈论LLVM的C++ API了!


www.deepl.com 翻译