osdev-jpでは、OS開発に有用な情報を収集し公開しています

View My GitHub Profile

OpeLa コンパイラの内部設計

OpeLa メインページ

C++ の機能をどこまで使うか

OpeLa コンパイラは、セルフホストを達成するまでは C++ で記述します。 C++ の素晴らしい機能、ライブラリを活用して作りたいところですが、最終的に OpeLa 言語に移植することを考えるとバランスが大切です。 移植をスムーズにするためには、OpeLa コンパイラを実装するのに利用した C++ の言語機能やライブラリを、OpeLa 言語でも実現する必要があるからです。

OpeLa 言語には、MikanOS を実装するために欲しい言語機能とライブラリを用意したいと考えています。 したがって、OpeLa コンパイラもその範囲で実装するのが良いだろうと思います。 OpeLa コンパイラ V1 は、何も制約を考えずに実装していましたが、V2 はこれらの制約を踏まえて実装します。

OpeLa コンパイラ V2 を実装するのに利用する C++ の機能を考えるために、MikanOS の実装に活用している機能、ライブラリを洗い出しました。

基本的には、上記の機能は MikanOS の実装に必要と考えられますので OpeLa 言語に搭載する方針です。一部機能は、バランスを考えて代替の機能とすることもあります。

C++ のテンプレートは複雑な機能なので、ちょっと違う形で実現しようと思っていましたが、考えを改めて実装することにしました。 →OpeLa のジェネリクスに登場する演算子の考察

したがって、これは古い記事です:C++ のテンプレートの使い方と OpeLa での代替手段の検討

Ver.2 の言語仕様

MikanOS の実装で使っている C++ の機能の洗い出し結果を踏まえ、Ver.2 の言語仕様をまとめます。 言語仕様は内部設計ではないので OpeLa 言語仕様 で扱います。

レジスタ割り当て

Ver.2 はレジスタマシンとして構成するため、どの値をどのレジスタに割り当てるかを決める「レジスタ割り当て」が重要です。 ナイーブなアルゴリズムではすぐにレジスタが足りなくなる可能性があります。 OpeLa Register Assignment で設計します。

整数のキャスト方法

整数のキャストとは uint8int64 などの整数型の間の型変換のことです。 OpeLa Type Cast で説明します。

Ver.2 の構文木データ構造

構文木のデータ構造 では、OpeLa コンパイラが内部で用いる各種のデータ構造を詳しく説明します。 AST ノードを表す Node や型を表す Type などについて知りたい方はご覧ください。

ドット演算子とデータサイズ

2021/09/07 時点では、ドット演算子(構造体のフィールドアクセス)に対応するコードは次の通りに生成されます。(x を構造体型の変数、a をフィールド名とする)

  1. x.a の左辺 x を、lval=true でコード生成する。→レジスタに x のアドレスを設定するコードが生成される。
  2. フィールド a のオフセットを計算し、x のアドレスからそのオフセットだけ離れたメモリを読むコードを生成する。

具体的には、x struct{a int; b int;} というローカル変数のフィールド b を読むコードは次のようになります。

lea rdi, [rbp-16]
mov rdi, qword ptr [rdi+8]

1 行目で x のアドレス(ローカル変数なので RBP レジスタが基準となる)を RDI に設定します。2 行目で、RDI を基準としてオフセット 8(b のオフセットに対応)の領域を読みます。

構造体は一般に 8 バイトより大きく、レジスタに格納しきれないのでこのような実装にしています。しかし、左辺を値として扱いたいケースがいくつかあります。

前者は最適化なのでやらなくても動きはしますが、後者は構造体を返す外部の関数を呼び出す際には対応が必須です。以降でその実現方法を検討します。

構造体が 8 バイトに収まる場合

このケースへの対応は比較的簡単だろうと思います。なぜなら、GenerateAsm は式の評価結果を 1 つのレジスタに格納する、という前提を変えずに済みそうだからです。必要な箇所で構造体のサイズを求め、8 バイト以下ならレジスタにアドレスではなく値を設定する、という条件分岐を書けば実現できる気がします。

構造体が 16 バイトに収まり、かつ関数の戻り値とする場合

このケースはちょっと難しいです。なぜなら、今のところ GenerateAsm は式の値が複数のレジスタにまたがる可能性を全く考慮していないからです。関数の戻り値というのは「関数呼び出し式(kCall)」の値ですから、れっきとした「式の値」なわけですね。

System V AMD64 ABI で定められている「16 バイトに収まる値を返す際は RDX:RAX に格納する」というルールと、GenerateAsm が仮定する「式の値はレジスタ 1 個で保持する」というルールが競合します。特に、外部(C 言語側)で定義された関数を OpeLa 言語から呼び出す際に困ったことになります。外部定義の関数は ABI にしたがって RDX:RAX で構造体を返す一方、OpeLa 言語は 2 つのレジスタに式の値が格納されている状況に対応できないからです。

ここで Pairstruct {a int64; b int64;} という型、構造体を戻す関数を func f() Pair としましょう。ぱっと思いつくのは f().a というように、ドット演算子の左辺が関数呼び出し式のときだけ特別扱いする方法です。関数の戻り値が 16 バイト以下の構造体であり、かつドット演算子を直接適用するときだけ、左辺の値が RDX:RAX に格納されているという前提でコード生成を行います。

ただ、式の値というのはドット演算子の左辺にだけ現れるわけではありません。あらゆる式に登場し得ます。例えば関数の引数に指定する(g(f()))、変数への代入あるいは初期化で使う(x := f();)などです。構造体が式の値として現れ得る場面で「対象の式が関数呼び出し式で、戻り値の型が 16 バイト以下の構造体だったら」という条件分岐を入れることはできなくはないでしょう。ただ、これは抜け漏れが出そうで、筋が良いやり方とは思えません。16 バイト以下の構造体の値をレジスタで取り回すのであれば、GenerateAsm の設計を根本的に見直して、複数レジスタにまたがる値を扱えるようにしたいです。

(保留)8 バイトを超える構造体はメモリに置く

今のところの解決方法としては、8 バイトを超える構造体はメモリに置き、そのアドレスをレジスタに格納する、というやり方を採用しようと思います。といっても、今までの GenerateAsm の作り方はこの方針ですから、既存のやり方を継続するということです。

今回新たに導入するのは、関数の戻り値が 8 バイト超、16 バイト以下の構造体の場合に、RDX:RAX に設定された戻り値を一旦メモリに格納し、そのアドレスでもって戻り値を扱うようにする、という部分です。

では、RDX:RAX で返された戻り値を置くためのメモリ領域はどこにすべきでしょうか?大きく分けて 2 つ考えられます。関数呼び出し式に対する GenerateAsm を呼び出す側でメモリ領域を準備するか、GenerateAsm の中でメモリ領域を確保するかです。

効率が良いのは前者と思います。なぜなら、その式の値をどのように利用するかを知っているのは GenerateAsm を呼び出す側だからです。例えば、関数の戻り値を変数に代入する処理を考えます。関数呼び出し式を評価する段階では、関数の戻り値を代入するための変数は既に特定されていて、メモリの位置も分かっています。ですので、GenerateAsm にはその変数のメモリ領域のアドレスを渡せば、RDX:RAX からメモリへのコピーは 1 回で完了します。

後者の方法では、GenerateAsm の中で一時的なメモリ領域を確保して RDX:RAX の値をコピーし、その後、最終的な変数のメモリ領域へと再度コピーしなければなりません。メモリコピーを行うコードを 2 箇所に書かなければならないわけです。

前者の方法ですべてが解決するかというと、問題は残ります。次の 2 つの式を考えます:x.af().a。どちらも 式.a という形をしています。x.a の場合、変数 x は既に定義されていて、メモリ領域を与えられています。そこで、GenerateAsm に対しては、その変数のメモリ領域のアドレスを指定すれば良いでしょう。一方で f().a では f() の結果を格納するためのメモリ領域はどこにもありませんから、この式を評価するときに一時的な領域を生成する必要があります。ドット演算子の左辺の式の種類によって、GenerateAsm を呼び出す前に行う処理が変わってくるということを意味します。

これらの差を解決する上手い方法が見つかるまで、8 バイトを超える構造体を値として扱う機能は保留にします。これは構造体を返せないというわけではなく、構造体のポインタでもって受け渡しすることにより代替することができます。すなわち、構造体を戻り値にする(func f() Pair)のではなく、ポインタとして受け取ってそこに必要な値を書き込む(func f(*Pair))、というやり方で回避するということです。

Ver.1 の設計

以下は V1 の設計です。V2 ではデータ構造が変わるかもしれません。

Type 構造体の仕様

直感的に分かるように、具体例により仕様を記述します。

配列型 kArray

配列型(kind: kArray)は、base に要素の型、num に要素数が設定される。

[[images/opela_type_spec_kArray.png]]

関数型 kFunc

関数型(kind: kFunc)は、next に引数型のリストの先頭要素、base に戻り値型が設定される。 引数型リストの最後には可変長引数を表す kVParam が来得る。

[[images/opela_type_spec_kFunc.png]]

構造体型 kStruct

構造体型(kind: kStruct)は、next にフィールド型のリストの先頭要素が設定される。 フィールド型は name にフィールド名が設定される。

[[images/opela_type_spec_kStruct.png]]