[LLVM翻译]创建BOLT COMPILER:第7部分 OCaml和C++的Protobuf教程。

1,062 阅读14分钟

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

原文作者:mukulrathi.co.uk/

发布时间:2020年10月3日-8分钟阅读

系列。创建BOLT编译器

即将推出


现在我们已经把我们的语言分解成一个简单的 "Bolt IR",接近LLVM IR,我们想生成LLVM IR,但有一个问题,我们的Bolt IR是一个OCaml值,但我们使用的是原生C++ API。但有一个问题,我们的Bolt IR是一个OCaml值,但我们使用的LLVM API是原生的C++ API。我们不能直接把这个值导入到C++编译器后台,所以我们需要先把它序列化成一个独立于语言的数据表示。

嘿,大家好! 如果你是从Google上看到这个教程,并且不关心编译器,不用担心! 本教程并不涉及任何与编译器相关的内容(这只是说明性的例子)。如果你关心OCaml,那么前半部分会是你的菜,如果你是为C++而来,你可以跳过OCaml部分。

协议缓冲区模式

协议缓冲区(又名protobuf )根据给定的模式将数据编码为一系列二进制消息。这个模式(写在.proto文件中)捕获了你的数据结构。

每条消息都包含一些字段。

字段的形式是修改器类型name = someIndex。

每个字段都有一个修饰符:必填/可选/重复。重复的修饰符对应于该字段的列表/向量,例如重复的PhoneNumber代表一个List<PhoneNumber>类型的值。

例如,下面的模式将在通讯录中定义一个Person。每个人都必须有一个名字和id,并且可以选择有一个电子邮件地址。他们可能会有多个电话号码(因此会有重复的PhoneNumber),例如他们的家,他们的手机和工作。

message Person {
  required string name = 1;
  required int32 id = 2;
  optional string email = 3;

  enum PhoneType {
    MOBILE = 0;
    HOME = 1;
    WORK = 2;
  }

  message PhoneNumber {
    required string number = 1;
    optional PhoneType type = 2 [default = HOME];
  }

  repeated PhoneNumber phones = 4;
}

请注意,在Person消息的模式中,我们还对字段进行了编号(=1, =2, =3, =4)。这允许我们以后添加字段而不改变现有的字段顺序(所以你可以继续解析这些字段而不必知道新的字段)。

每次我们想添加一个新的 "类型 "字段时,我们都要定义一个模式(就像你定义一个类来添加一个新类型的对象一样)。我们甚至可以在另一个模式中定义一个模式,例如,在Person的模式中,我们为PhoneNumber字段定义了模式。

我们也可以定义一个枚举类型PhoneType的模式。这个枚举作为我们电话号码的 "标签"。

现在,正如你可能希望一个字段是许多选项之一(由枚举类型指定),你可能希望消息正好包含许多字段中的一个。

例如,在我们的编译器中,我们可能希望将一个标识符编码为两个选项之一。在这里,如果我们已经将数据标记为变量,我们就不想在索引字段中存储数据。

type identifier =
    | Variable of string
    | ObjField of string * index (* name and field index *)

在我们的protobuf中,我们可以为这些选项中的每一个添加字段,并使用关键字oneof来指定该组中只有一个字段同时被设置。

现在,虽然这告诉 Protobuf 这些值中的一个被设置了,但我们没有办法知道哪一个被设置了。所以我们可以引入一个标签枚举字段,并在反序列化对象时查询它的值(Variable或ObjField)。

message identifier {
  enum _tag {
    Variable_tag = 1;
    ObjField_tag = 2;
  }

  message _ObjField {
    required string objName = 1;
    required int64 fieldIndex = 2;
  }

  required _tag tag = 1;
  oneof value {
    string Variable = 2;
    _ObjField ObjField = 3;
  }
}

注意,由于string的构造函数Variable只有一个类型为string的参数,所以我们可以直接将该字段的类型设置为string。我们同样可以用schema定义一个消息_Variable。

message identifier {
  enum _tag {
    Variable_tag = 1;
    ObjField_tag = 2;
  }

  message _ObjField {
    required string objName = 1;
    required int64 fieldIndex = 2;
  }

  required _tag tag = 1;
  oneof value {
    string Variable = 2;
    _ObjField ObjField = 3;
  }
}

将 OCaml 类型定义映射到 Protobuf 模式。

现在我们已经看了标识符模式,让我们来充实一下前端 "Bolt IR "模式的其他部分。

如果你注意到,每当我们有类似标识符的OCaml变体类型时,我们有一个直接的公式来指定相应的模式。我们。

  • 增加一个标签字段来指定值的构造函数。
  • 如果一个构造函数有多个参数,例如对于ObjField,则定义一个消息模式。如果构造函数只有一个参数(Variable),我们就不需要。
  • 指定一个包含每个构造函数的字段的oneof块。

我们可以手动写出所有的模式,但是每次我们的语言改变时,我们都必须重写这些模式。然而,我们的袖子里有一个隐藏的武器--一个可以帮我们完成这个任务的库。

如果你想了解更多关于其他消息类型的信息,请看完整的Protobuf Schema指南

PPX派生Protobuf

OCaml允许库使用px钩子来扩展其语法。ppx库可以使用这些钩子对文件进行预处理,扩展语言功能。所以我们可以用其他信息来标记我们的文件,比如哪些类型需要有protobuf模式生成。

我们可以更新我们的Dune构建文件,用ppx生成的protobuf库来预处理我们的ir_gen库。

(library
 (name ir_gen)
 (public_name ir_gen)
 (libraries core fmt desugaring ast)
 (flags
  (:standard -w -39))
 (preprocess
  (pps ppx_deriving_protobuf bisect_ppx --conditional))

请注意flags命令抑制了警告39--"未使用的rec标志"--因为px_deriving_protobuf库生成的代码会引起很多这样的警告。我强烈建议你也抑制这些警告。

另外,bisect_ppx是我们用来计算测试覆盖率的工具。它有一个 --条件标志,因为如果我们不计算测试覆盖率,我们就不想用覆盖率信息来预处理文件。

告诉PPX派生protobuf我们要序列化一个类型定义是很容易的--我们只需在类型定义的结尾处贴上一个[@@派生protobuf]。对于变体类型,我们必须为每个变体指定一个[@key 1]、[@key 2]。

例如,对于我们.mli文件中的标识符类型定义。

type identifier =
  | Variable of string [@key 1]
  | ObjField of string * int [@key 2]
  [@@deriving protobuf]

我们在.ml文件中做同样的事情,只是我们还指定了要将protobuf模式写入的文件。

type identifier =
  | Variable of string [@key 1]
  | ObjField of string * int [@key 2]
[@@deriving protobuf {protoc= "../../frontend_ir.proto"}]

这个路径是相对于 src 文件 frontend_ir.ml 的。因此,由于这个 frontend_ir.ml 文件在 repo 的 src/frontend/ir_gen/ 文件夹中,protobuf 模式文件将被写入 src/frontend_ir.proto。如果你没有给protoc参数指定文件路径,那么.proto文件将被写入_build文件夹。

有一个额外的提示,px派生的protobuf库不会序列化列表的列表。比如你不能有

type something = Foo of foo list list [@key 1] [@@deriving protobuf])

这是因为如果我们有一个foo的消息模式,那么要得到一个foo类型的字段列表是很直接的--我们在字段模式中使用重复的foo。但是我们不能说重复重复foo就能得到一个类型为foo的list的列表。所以为了解决这个问题,你必须在这里定义另一个类型。

type list_of_foo = foo list [@@deriving protobuf]
(*note no @key since not a variant type *)

type something = Foo of list_of_foo list [@key 1] [@@deriving protobuf]

最后,为了使用这个模式序列化我们的Bolt IR,PPX派生protobuf为我们提供了<type_name>_to_protobuf序列化函数。我们使用这个函数来获取二进制protobuf消息,并将其作为字节序列写入输出通道(out_chan)。

  let ir_gen_protobuf program out_chan =
    let protobuf_message = Protobuf.Encoder.encode_exn program_to_protobuf program in
    output_bytes out_chan protobuf_message

在我们整体的Bolt编译器流水线中,如果提供了.ir文件,我就把输出写成.ir文件,或者写成stdout。

...
 match compile_out_file with
    | Some file_name ->
        Out_channel.with_file file_name ~f:(fun file_oc ->
            ir_gen_protobuf program file_oc)
    | None           -> ir_gen_protobuf program stdout )

自动生成的Protobuf Schema。

PPX派生库的repository的README里有对映射的详细解释。我们已经过了一个自动生成模式的例子,所以模式应该不会太陌生。不过我做了一个小小的修改。对于(ObjField of string * int),我说生成的输出是。

message _ObjField {
    required string objName = 1;
    required int64 fieldIndex = 2;
  }

不幸的是,这并不完全正确。为了清楚起见,我加入了objName和fieldIndex,但库不知道字符串*int的语义是什么,所以看起来像这样。

  message _ObjField {
    required string _0 = 1;
    required int64 _1 = 2;
  }

这就是自动生成模式的缺点。你得到的是通用的字段名_0,_1,2等等,而不是objName和fieldIndex。而对于type block_expr = expr list,你得到的是字段名

message block_expr {
  repeated expr _ = 1;
}

用C++解码Protobuf

好了,到目前为止,我们已经看了Protobuf模式的定义,以及PPX Deriving Protobuf如何对消息进行编码并生成模式。现在我们需要使用C++对其进行解码。与编码部分一样,我们不需要知道Protobuf如何用二进制表示消息的细节。

生成Protobuf反序列化文件

protoc编译器会自动从.proto文件中生成类和函数定义:这在.pb.h头文件中暴露出来。对于frontend_ir.proto,对应的头文件是frontend_ir.pb.h。

对于Bolt,我们是用构建系统Bazel来构建C++编译器后端。我们不需要手动运行protoc编译器来获取我们的.pb.h头文件,然后链接其他所有生成的文件,而是可以利用Bazel与protoc的集成,让Bazel在构建过程中为我们运行protoc,并将其链接进来。

Bazel构建系统可以使用很多语言,比如Java、Dart、Python,而不仅仅是C++。所以我们指定了两个库--一个是独立于语言的proto_library,另一个是围绕该库的特定C++库(cc_proto)。

load("@rules_cc//cc:defs.bzl", "cc_library")

# Convention:
# A cc_proto_library that wraps a proto_library named foo_proto
# should be called foo_cc_proto.
cc_proto_library(
    name = "frontend_ir_cc_proto",
    deps = [":frontend_ir_proto"],
    visibility = ["//src/llvm-backend/deserialise_ir:__pkg__"],

)

# Conventions:
# 1. One proto_library rule per .proto file.
# 2. A file named foo.proto will be in a rule named foo_proto.
proto_library(
    name = "frontend_ir_proto",
    srcs = ["frontend_ir.proto"],
)

我们将cc_proto_library(...)作为构建依赖文件包含到任何需要使用.bb.h文件的文件中,Bazel将为我们编译protobuf文件。在我们的例子中,这是我们的deserialise_ir库。

编译过程中,我们的库是deserialise_ir库。

load("@rules_cc//cc:defs.bzl", "cc_library")

cc_library(
    name = "deserialise_ir",
    srcs =  glob(["*.cc"]),
    hdrs = glob(["*.h"]),
    visibility = ["//src/llvm-backend:__pkg__", "//src/llvm-backend/llvm_ir_codegen:__pkg__", "//tests/llvm-backend/deserialise_ir:__pkg__"],
    deps = ["//src:frontend_ir_cc_proto", "@llvm"]
)

反序列化Protobuf序列化文件

frontend_ir.pb.h文件定义了一个命名空间Frontend_ir,每个消息都映射到一个类。因此,对于我们的Bolt程序来说,由frontend_ir.proto文件中的程序消息代表,对应的Protobuf类是Frontend_ir::program。

为了反序列化一个给定类型的消息,我们创建一个相应类的对象。然后我们调用ParseFromIstream方法,该方法从输入流中反序列化消息并将其存储在对象中。这个方法返回一个布尔值,表示成功/失败。我们已经定义了自己的自定义DeserialiseProtobufException来处理失败。

Frontend_ir::program deserialiseProtobufFile(std::string &filePath) {
  Frontend_ir::program program;
  std::fstream fileIn(filePath, std::ios::in | std::ios::binary);
  ...
  if (!program.ParseFromIstream(&fileIn)) {
    throw DeserialiseProtobufException("Protobuf not deserialised from file.");
  }
  return program;
}

所以我们完成了!

嗯,有一点要注意... 这个自动生成的类是一个愚蠢的数据占位符。我们需要从程序对象中读出数据。

从protobuf类中读出Protobuf数据。

如果你试图阅读protoc生成的文件frontend_ir.pb.h,你会意识到这是一个garguantuan的怪胎,它不如官方教程中的标准例子好看。所以,与其尝试从文件中读取方法,不如解释一下头文件的结构。

提示:确保你在IDE中设置了代码补全--这意味着你不需要在frontend_ir.pb.h中手动搜索正确的方法。

我们将使用下面定义的模式来反序列化消息。

package Frontend_ir;

message expr {
  enum _tag {
    Integer_tag = 1;
    Boolean_tag = 2;
    Identifier_tag = 3;
    ...
    IfElse_tag = 11;
    WhileLoop_tag = 12;
    Block_tag = 15;
  }
  message _Identifier {
    required identifier _0 = 1;
    optional lock_type _1 = 2;
  }
  ...
  message _IfElse {
    required expr _0 = 1;
    required block_expr _1 = 2;
    required block_expr _2 = 3;
  }
  message _WhileLoop {
    required expr _0 = 1;
    required block_expr _1 = 2;
  }
  ...
  required _tag tag = 1;
  oneof value {
    int64 Integer = 2;
    bool Boolean = 3;
    _Identifier Identifier = 4;
    ...
    _IfElse IfElse = 12;
    _WhileLoop WhileLoop = 13;
    block_expr Block = 16;
    ...
  }
}

message block_expr {
  repeated expr _ = 1;
}

Protobuf消息类和枚举

每个Protobuf消息定义都映射到一个类定义。Protobuf枚举映射到C++枚举。为了给每个类/枚举一个全局唯一的名字,它们由包名(Frontend_ir)和它们所嵌套的任何类前缀。

所以 message expr 对应于类 Frontend_ir_expr,而嵌套在 message expr 中的 enum _tag 则映射到 Frontend_ir_expr__tag。

在repo中,bin_op消息也有一个嵌套的_tag枚举,这个枚举映射到Frontend_ir_bin_op__tag(注意这种命名间距意味着它不会与expr消息中的_tag定义冲突)。

这对于任意级别的嵌套都是适用的,例如expr消息中嵌套的消息_IfElse就映射到Frontend_ir_expr__IfElse这个类。

枚举_tag的特定枚举值,例如Integer_tag可以被称为Frontend_ir_expr__tag_Integer_tag。

Protobuf不是通过将包名和类名用_连起来的方式来写类\enums,Protobuf还有一个嵌套类类型的别名,所以你可以将这些嵌套的消息类写成Frontend_ir::expr和Frontend_ir::expr::_IfElse。

访问特定的Protobuf字段

每个protobuf所需的字段field_name都有一个对应的访问方法field_name()(其中字段名被转换为小写)。所以expr消息中的字段tag会映射到Frontend_ir::expr类中的方法tag(),字段Integer会映射到方法integer(),IfElse会映射到ifelse()等等。

NB: 重申一下,不要混淆字段名,例如IfElse和Protobuf消息中字段_IfElse的类型(注意前面的下划线)。这只是因为OCaml PPX派生的protobuf库给了它们这些名字--如果我们手动编写这个proto模式,我们可以选择不那么容易混淆的名字。

对于可选字段,我们可以用同样的方式访问字段,但我们也有一个has_field_name()布尔函数来检查字段是否存在。

对于重复的字段,我们则有一个field_name_size()函数来查询项数,我们可以使用field_name(i)方法访问项i。所以对于字段名_1,对应的方法是_1_size()和_1(i)。对于自动生成的模式中的字段名_,对应的方法是__size()和_(i)。

净化我们的前端IR

让我们通过将我们的protobuf类直接映射到我们的OCaml类型定义的C++类来实践这一点。每一个OCaml expr变体都映射到一个抽象的ExprIR类的子类。

std::unique_ptr<ExprIR> deserialiseExpr(const Frontend_ir::expr &expr){
  switch (expr.tag()) {
    case Frontend_ir::expr__tag_IfElse_tag:
      return std::unique_ptr<ExprIR>(new ExprIfElseIR(expr.ifelse()));
    case Frontend_ir::expr__tag_WhileLoop_tag:
      return std::unique_ptr<ExprIR>(new ExprWhileLoopIR(expr.whileloop()));
    ...
  }
}

我在编译器后台全部使用std::unique_ptr来避免显式管理内存。你也可以使用标准指针--这只是个人的喜好。

还记得我们如何有一个特定的标签字段来区分expr类型的变体。我们在标签字段的值上有一个开关语句。这个标签告诉我们oneof{}字段中的哪个字段被设置了。然后我们访问相应设置的字段,例如expr.ifelse()的IfElse_tag情况。

std::unique_ptr<ExprIR> deserialiseExpr(const Frontend_ir::expr &expr){
  switch (expr.tag()) {
    case Frontend_ir::expr__tag_IfElse_tag:
      return std::unique_ptr<ExprIR>(new ExprIfElseIR(expr.ifelse()));
    case Frontend_ir::expr__tag_WhileLoop_tag:
      return std::unique_ptr<ExprIR>(new ExprWhileLoopIR(expr.whileloop()));
    ...
  }
}

查看_IfElse和block_expr消息的消息模式。

  message _IfElse {
    required expr _0 = 1;
    required block_expr _1 = 2;
    required block_expr _2 = 3;
  }

  message block_expr {
  repeated expr _ = 1;
}

我们的构造函数依次读取 _IfElse 消息的每一个字段,然后我们可以使用我们的 deserialiseExpr 直接反串 _0 字段。然后我们可以使用我们的deserialiseExpr直接反序列化_0字段。但是,由于消息块_expr有一个重复的字段_,我们必须在for循环中迭代该字段中的expr消息列表。

ExprIfElseIR::ExprIfElseIR(const Frontend_ir::expr::_IfElse &expr) {
  Frontend_ir::expr condMsg = expr._0();
  Frontend_ir::block_expr thenBlockMsg = expr._1();
  Frontend_ir::block_expr elseBlockMsg = expr._2();

  condExpr = deserialiseExpr(condMsg);
  for (int i = 0; i < thenBlockMsg.__size(); i++) {
    thenExpr.push_back(deserialiseExpr(thenBlockMsg._(i)));
  }
  for (int i = 0; i < elseBlockMsg.__size(); i++) {
    elseExpr.push_back(deserialiseExpr(elseBlockMsg._(i)));
  }
}

Bolt模式的其他反序列化代码也遵循同样的模式。

总结

在本篇文章中,我们已经从我们的OCaml前台转换到了我们的前台。


通过www.DeepL.com/Translator(免费版)翻译