これはなに

2019年にRV32Kv1という自作ISA用のLLVMバックエンドを作ったときの自分とメンバ用のメモ。 メモなので当然読みにくい。これをブラッシュアップしてまともな文章にする予定だったが、 その作業が遅れているので、一旦メモのまま公開する。内容について質問したい場合は Twitter @ushitora_anqouまでリプライなどを貰えれば反応するかもしれない。

ソースコードは未公開。

ブラッシュアップはGitHubにて行っている。

このLLVMバックエンドの開発は、もともと2019年度未踏事業において Virtual Secure Platformを開発するために行われた。

アセンブラ簡単使い方

とりあえずビルドする。ビルドには

  • cmake

  • ninja

  • clang

  • clang++

が必要。

これらを入れた後 cmake を次のように走らせる。

$ cmake -G Ninja \
    -DLLVM_ENABLE_PROJECTS=clang \
    -DCMAKE_BUILD_TYPE="Debug" \
    -DBUILD_SHARED_LIBS=True \
    -DLLVM_USE_SPLIT_DWARF=True \
    -DLLVM_OPTIMIZED_TABLEGEN=True \
    -DLLVM_BUILD_TESTS=True \
    -DCMAKE_C_COMPILER=clang \
    -DCMAKE_CXX_COMPILER=clang++ \
    -DLLVM_USE_LINKER=lld \
    -DLLVM_TARGETS_TO_BUILD="X86" \
    -DLLVM_EXPERIMENTAL_TARGETS_TO_BUILD="RISCV;RV32K" \
    ../llvm
$ cmake --build .

その後アセンブラを起動する。アセンブラは build/bin/llvm-mc である。

$ cat foo.s
li x9, 3
mv x11, x1
sub x9, x10
add x8, x1
nop

$ bin/llvm-mc -arch=rv32k -filetype=obj foo.s | od -tx1z -Ax -v
000000 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00  >.ELF............<
000010 01 00 f5 00 01 00 00 00 00 00 00 00 00 00 00 00  >................<
000020 68 00 00 00 00 00 00 00 34 00 00 00 00 00 28 00  >h.......4.....(.<
000030 04 00 01 00 8d 44 86 85 89 8c 06 94 01 00 00 00  >.....D..........<
000040 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  >................<
000050 00 2e 74 65 78 74 00 2e 73 74 72 74 61 62 00 2e  >..text..strtab..<
000060 73 79 6d 74 61 62 00 00 00 00 00 00 00 00 00 00  >symtab..........<
000070 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  >................<
000080 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  >................<
000090 07 00 00 00 03 00 00 00 00 00 00 00 00 00 00 00  >................<
0000a0 50 00 00 00 17 00 00 00 00 00 00 00 00 00 00 00  >P...............<
0000b0 01 00 00 00 00 00 00 00 01 00 00 00 01 00 00 00  >................<
0000c0 06 00 00 00 00 00 00 00 34 00 00 00 0a 00 00 00  >........4.......<
0000d0 00 00 00 00 00 00 00 00 04 00 00 00 00 00 00 00  >................<
0000e0 0f 00 00 00 02 00 00 00 00 00 00 00 00 00 00 00  >................<
0000f0 40 00 00 00 10 00 00 00 01 00 00 00 01 00 00 00  >@...............<
000100 04 00 00 00 10 00 00 00                          >........<
000108

0x34から0x3dにある 8d 44 86 85 89 8c 06 94 01 00 が出力である。

このような出力の他に -show-encoding を用いる方法もある。

$ bin/llvm-mc -arch=rv32k -show-encoding foo.s
	.text
	li	x9, 3                   # encoding: [0x8d,0x44]
	mv	x11, x1                 # encoding: [0x86,0x85]
	sub	x9, x10                 # encoding: [0x89,0x8c]
	add	x8, x1                  # encoding: [0x06,0x94]
	nop	                        # encoding: [0x01,0x00]

概要

これを読めば自作アーキテクチャ(RV32Kv1)の機械語を出力するLLVMバックエンドを作成することができる。

この文書はAsciiDocを用いて記述されている。記述方法については[4]を参照のこと。

参考にすべき資料

  • Writing an LLVM Backend[18]

  • 『きつねさんでもわかるLLVM〜コンパイラを自作するためのガイドブック〜』[7]

  • RISC-V support for LLVM projects[10] LLVMにRISC-Vサポートを追加するパッチ群。バックエンドを開発するためのチュートリアルも兼ねているらしく docs/ 及びそれと対応したpatchが参考になる。 またこれについて、開発者が2018 LLVM Developers' Meetingで登壇したときの動画は[11]より閲覧できる。 スライドは[30]より閲覧できる。

  • TableGenのLLVMのドキュメント[21]

  • The LLVM Target-Independent Code Generator[31]

  • Create an LLVM Backend for the Cpu0 Architecture[35]

    • これをもとにLLVMバックエンドを開発しているブログ[44]

  • ELVMバックエンド[36]

    • 限られた命令でLLVM IRの機能を達成する例として貴重。

      • でも意外とISAはリッチだったりする。

    • Hamajiさんのスライドも参考になる[37]

  • 2018年度東大CPU実験で開発されたLLVM Backend[40]

    • これについて書かれたAdCのエントリもある[41]

  • LLVM Language Reference Manual[43]

    • LLVM IRについての言語リファレンス。意外と実装すべき命令が少ないことが分かる。

RV32Kv1アーキテクチャ仕様

RV32Kv1はRISC-V[5]のISA[6]であるRV32Cをベースとして設定する。 RV32Cからそのまま借用する命令は下記のとおりである。

  • LWSP

  • SWSP

  • LW

  • SW

  • J

  • JAL

  • JR

  • JALR

  • BEQZ

  • BNEZ

  • LI

  • MV

  • ADD

  • SUB

  • NOP

RV32Cに無く、新設する命令は下記のとおりである。

  • SLT

    • 次に示すReservedのうち上の方を使う。

rv32c reserved insts

エンディアンにはリトルエンディアンを採用する。

レジスタは次の通り。

  • x1/ra : return address register / ABI link register

  • x2/sp : ABI stack pointer

  • x8-x15 : general purpose register

分岐時のPC更新は PC ← PC + d とする( PC ← PC + 1 + d でない)。

関数呼び出し規約は次の通り。

  • x8-x15 : 引数0番目〜7番目

  • x8 : 戻り値

  • sp : callee-saved

  • x15 : frame register

マイルストーン

  • MachineCodeからMCLayerに変換するアセンブラ

    • RV32Kアセンブリを受け取ってELFバイナリを出力する。

    • ELFバイナリである必要は微塵もないが、既存のLLVMコードベースを利用するためにはこれが必要。

      • 最終的には自作バイナリに出力するようにしたい。

    • 実際に食わせるためにはELFバイナリを適当にstripする変換器を必要するかもしれない。

    • というか同一ファイル内でシンボル解決を行うリンカもどきが必要になる。あと main もとい _start がバイナリの一番最初にないといけないなどありそう。

    • 自作C標準ライブラリも必要かも。

LLVMのソースコードを用意する

LLVMのソースコードを取得する。今回の開発ではv8.0.0をベースとする。 Git上でrv32kブランチを作り、その上で開発する。

$ git clone https://github.com/llvm/llvm-project.git
$ cd llvm-project
$ git checkout llvmorg-8.0.0
$ git checkout -b rv32k

LLVM・Clangをビルドする

[1], [2], [3]を参考にしてLLVMとClangをNinjaを用いてビルドする。 以降の開発において参考にするため、x86とRISC VをLLVM・Clangの出力可能ターゲットとして指定する。

なお、CPUがIntel Core i5 7200U、メモリが8GBのLet’s note上でのビルドには1時間半程度要した。

$ mkdir build
$ cd build
$ cmake -G Ninja \
    -DLLVM_ENABLE_PROJECTS=clang \
    -DCMAKE_BUILD_TYPE="Debug" \
    -DBUILD_SHARED_LIBS=True \
    -DLLVM_USE_SPLIT_DWARF=True \
    -DLLVM_OPTIMIZED_TABLEGEN=True \
    -DLLVM_BUILD_TESTS=True \
    -DCMAKE_C_COMPILER=clang \
    -DCMAKE_CXX_COMPILER=clang++ \
    -DLLVM_USE_LINKER=lld \
    -DLLVM_TARGETS_TO_BUILD="X86" \
    -DLLVM_EXPERIMENTAL_TARGETS_TO_BUILD="RISCV" \
    ../llvm
$ cmake --build . # OR ninja

LLVM・Clangを使う

RV32バックエンドを使う例をいくつか掲げる[12]

$ bin/clang -target riscv32-unknown-linux-gnu -S -o main.s main.c    # main.cをRV32のアセンブリにコンパイル
$ bin/clang -target riscv32-unknown-linux-gnu -emit-llvm -o main.bc -c main.c   # main.cをRV32用のLLVM IRにコンパイル
$ llvm-project/build/bin/llvm-dis main.bc -o -  # LLVM IRをテキスト形式に変換

LLVMをテストする

llvm-lit を使用してLLVMをテストできる。

$ bin/llvm-lit test -s  # 全てのテストを実行する
$ bin/llvm-lit -s --filter 'RV32K' test # RV32Kを含むテストを実行する
$ bin/llvm-lit -as --filter 'RV32K' test # テスト結果を詳細に表示する

スケルトンバックエンドを追加する

[8]を参考に、中身のないスケルトンのバックエンドをLLVMに追加する。 これによって llc などの出力などにRV32Kが追加される。

RV32KをTripleに追加する

参照先[9]のパッチのとおりに書き換える。 基本的に riscv32 という文字列を検索し、都度 rv32k の項目を追加していけばよい。 ただし、RISC-Vには32bit( riscv32 )と64bit( riscv64 )の変種があるため変換テーブルを用意する必要があるが、 RV32Kにはその必要はない。 UnknownArch を返せば良い。

コードを改変した後 $ bin/llvm-lit -s --filter "Triple" test でテストを行う。

RV32KのELF定義を追加する

[13]を参考にファイルを書き換える。 途中 llvm-objdump.cpp の書き換え該当箇所が見当たらない。とりあえず放置(TODO)。

出力するELF形式のテストは、出力すべきELFの細部が決まっていないため、 とりあえず書かないでおく(TODO)[1]

バックエンドを追加する

[14]を参考にファイルを修正・追加する。 ただし RV32KTargetMachine::RV32KTargetMachine() の初期化子で使用している getEffectiveCodeModel() がそのままではコンパイルエラーを出すため修正する[2]

ファイル中のライセンス条項などは、将来的にはApache 2.0 Licenseを明記する予定だが、 とりあえず削除しておく[3]

さて追加したバックエンドも含めてLLVMの再ビルドを行う。 そのためには再び cmake を実行する必要がある:

$ cmake -G Ninja \
    -DLLVM_ENABLE_PROJECTS=clang \
    -DCMAKE_BUILD_TYPE="Debug" \
    -DBUILD_SHARED_LIBS=True \
    -DLLVM_USE_SPLIT_DWARF=True \
    -DLLVM_OPTIMIZED_TABLEGEN=True \
    -DLLVM_BUILD_TESTS=True \
    -DCMAKE_C_COMPILER=clang \
    -DCMAKE_CXX_COMPILER=clang++ \
    -DLLVM_USE_LINKER=lld \
    -DLLVM_TARGETS_TO_BUILD="X86" \
    -DLLVM_EXPERIMENTAL_TARGETS_TO_BUILD="RISCV;RV32K" \
    ../llvm
$ cmake --build . # OR ninja

無事 llc --version にRV32Kが含まれるようになった:

$ bin/llc --version
LLVM (http://llvm.org/):
  LLVM version 8.0.0
  DEBUG build with assertions.
  Default target: x86_64-unknown-linux-gnu
  Host CPU: skylake

  Registered Targets:
    riscv32 - 32-bit RISC-V
    riscv64 - 64-bit RISC-V
    rv32k   - RV32K
    x86     - 32-bit X86: Pentium-Pro and above
    x86-64  - 64-bit X86: EM64T and AMD64

簡易的なアセンブラを実装する

スケルトンバックエンドに実装を追加して、簡易アセンブラを構築する。 わざわざ「簡易」と銘打っているのは、命令を LI, MV, ADD, SUB, NOP のみに限るためである[4]。 残りの命令については後の章で実装する予定である[5]

TableGenファイルを追加する

LLVMのDSLであるTableGenを使用し、RV32Kのレジスタや命令について記述する。 追加すべきTableGenファイルは次の通り:

  • RV32KRegisterInfo.td: レジスタ

  • RV32KInstrFormats.td: 命令形式

  • RV32KInstrInfo.td: 命令

  • RV32K.td: 全体

RISCV ディレクトリ以下に RISCVInstrFormatsC.tdRISCVInstrInfoC.td などがあるので参考にする。 LWSP はエンコードされると SP の情報を持たないが、表記上は lwsp rd, 100(sp) と書くため SP をとる必要がある。 これのために RegisterClass として SP を定義している。ただし SP には X2 のみが所属する。 また、単純に全てのレジスタを GPR として1つの括りにしてしまうと、 sub 命令のオペランドとして x2 などが許容されてしまう。 これを防ぐため、 x8 から x15 を束ねて GPRC という名の RegisterClass を作成し、これを sub 命令のオペランドの型として指定する。

即値を単純にとる命令はそのまま記述すればよいが、デコードした値を左シフトするような命令は注意が必要である。 すなわち即値の [7:2] のみを生成コード中に持つような場合は Instruction 中に8bitの即値を宣言し、 その [7:2] 部分が Inst と対応するという形をとる。 なお、この点について RISCVInstrFormatsC.td に書いてある RVInst16CJ の実装が間違っている気がする。 offset は12bitではなかろうか(TODO [RVInst16CJ-offset])。また Bcz も同様の問題があるように見える(TODO)。

文字列リテラル中の変数展開をTableGenはサポートしている。すなわち "$rd, ${imm}(${rs})" と書けばレジスタ rdrs、 即値 imm の中身が展開される。 なおここで変数名を {} で囲む必要がある。というのもTableGenでは () もオペランドの名前として認識されてしまうからだ。 実際 $rd, $imm(${rs}) と書くと次のようなエラーになる。

error: unable to find operand: 'imm('

なおここでの表記はパーズの際に行うパターンマッチのパターンとしての役割と、 アセンブリとして出力を行う際のパターンとしての役割があるようだ(TODO: ほんまか?)。

2アドレス方式などで、読み込むレジスタと書き込むレジスタが一致する場合 let Constraints = "$rd = $rd_wb"; などと書けばよい。

必要なTableGenファイルを追加した後、これらのTableGenファイルが正しいかどうか llvm-tblgen を用いて確認する:

$ bin/llvm-tblgen -I ../llvm/lib/Target/RV32K/ -I ../llvm/include/ -I ../llvm/lib/Target/ ../llvm/lib/Target/RV32K/RV32K.td

初めて実行すると、おそらくたくさんエラーがでるので頑張って全て修正する。 変換が成功した場合、生成されたコードが、自分が得たいものになっているかどうかを確認する。

RV32K用の MCTargetDesc を追加する

RV32KのELF形式オブジェクトファイルを出力できるようにする。そのために LLVMInitializeRV32KTargetMC() を実装する。 MCTargetDesc サブディレクトリを作成し、その中に RV32KMCTargetDesc.{cpp,h} を作成する。 他のファイルに依存する部分はコメントアウトして、適当な CMakeLists.txtLLVMBuild.txt を作成・編集すると、 ビルドが通るようになる。そこで次に実装すべき場所を次のように特定する:

$ bin/llvm-mc -arch=rv32k -filetype=obj foo.s
llvm-mc: /home/anqou/workspace/llvm-project/llvm/tools/llvm-mc/llvm-mc.cpp:355: int main(int, char **): Assertion `MAI && "Unable to create target asm info!"' failed.
Stack dump:
0.	Program arguments: bin/llvm-mc -arch=rv32k -filetype=obj foo.s
 #0 0x00007ff0c8f93e26 llvm::sys::PrintStackTrace(llvm::raw_ostream&) /home/anqou/workspace/llvm-project/llvm/lib/Support/Unix/Signals.inc:495:11
 #1 0x00007ff0c8f94029 PrintStackTraceSignalHandler(void*) /home/anqou/workspace/llvm-project/llvm/lib/Support/Unix/Signals.inc:559:1
 #2 0x00007ff0c8f91eb3 llvm::sys::RunSignalHandlers() /home/anqou/workspace/llvm-project/llvm/lib/Support/Signals.cpp:68:5
 #3 0x00007ff0c8f947c4 SignalHandler(int) /home/anqou/workspace/llvm-project/llvm/lib/Support/Unix/Signals.inc:0:3
 #4 0x00007ff0c8af73c0 __restore_rt (/usr/lib/libpthread.so.0+0x123c0)
 #5 0x00007ff0c8628d7f __GI_raise (/usr/lib/libc.so.6+0x37d7f)
 #6 0x00007ff0c8613672 __GI_abort (/usr/lib/libc.so.6+0x22672)
 #7 0x00007ff0c8613548 _nl_load_domain.cold.0 (/usr/lib/libc.so.6+0x22548)
 #8 0x00007ff0c8621396 (/usr/lib/libc.so.6+0x30396)
 #9 0x00005588f8384783 main /home/anqou/workspace/llvm-project/llvm/tools/llvm-mc/llvm-mc.cpp:357:3
#10 0x00007ff0c8615223 __libc_start_main (/usr/lib/libc.so.6+0x24223)
#11 0x00005588f838402e _start (bin/llvm-mc+0x2402e)
zsh: abort (core dumped)  bin/llvm-mc -arch=rv32k -filetype=obj foo.s

この場合 AsmInfo が次に実装すべき場所だと分かる。

具体的な編集方法はパッチ[15]を参考にする。

RV32KAsmBackend.cpp の作成時に多くのコンパイルエラーが出た。どうやらこのあたりの仕様変更があったようだ。 具体的には下記のとおりである。

  • applyFixup の引数変更。

  • mayNeedRelaxation の引数変更。

  • writeNopData の引数変更。

  • MCAsmBackend::MCAsmBackend の引数変更。

  • createObjectWritercreateObjectTargetWriter への名称・引数・戻り値変更。それに伴う createRV32KELFObjectWriter の引数・戻り値変更。

以上を実装して動かすとSEGVで落ちる。デバッガで追いかけると、どうやら MCSubtargetInfo の生成関数である createRV32KMCSubtargetInfo を実装しなければならないようだ。RISC Vの最新のソースコードを参考に実装する。 基本的にはTableGenが生成する createRV32KMCSubtargetInfoImpl をそのまま使えば良いが、これを使用するためには CMakeLists.txttablegen(LLVM RISCVGenSubtargetInfo.inc -gen-subtarget) を追加する必要があるため注意が必要だ。 ちなみにRISC Vのパッチ群では、これらは[16]で実装されている。なぜここで実装しなければならなくなったかは、よくわからない。

諸々実装すると、次のように出力される:

$ bin/llvm-mc -arch=rv32k -filetype=obj foo.s
bin/llvm-mc: error: this target does not support assembly parsing.

なお createRV32KMCCodeEmitter を実装しなくてもこの表示が出るが、それでいいのかはよくわからない。 とりあえずパッチ[15]にある分は全て実装する。

createRV32KMCCodeEmitter を実装する過程で、TableGenがどのように InstrFormats でのフィルード名と InstrInfo での insouts の名前を対応付けるのか調べた。例えば次のように BEQZ 命令をTableGenにて宣言したとする。

class RVInstCB<bits<3> funct3, bits<2> op, dag outs, dag ins, string opcodestr, string argstr>
: RVInst<outs, ins, opcodestr, argstr, []> {
  bits<9> offset;
  bits<3> rs1;

  let Inst{15-13} = funct3;
  let Inst{12} = offset{8};
  let Inst{11-10} = offset{4-3};
  let Inst{9-7} = rs1;
  let Inst{6-5} = offset{7-6};
  let Inst{4-3} = offset{2-1};
  let Inst{2} = offset{5};

  let Opcode = op;
}
def BEQZ : RVInstCB<0b110, 0b01, (outs), (ins GPR:$rs1, simm9_lsb0:$imm),
                    "beqz", "$rs1, $imm">;

このときこのTableGenソースコードは期待通りに動かない(多分:TODO)。 なぜなら class RVInstCB で指定した offsetrs1 が、 def で指定した $rs1$imm に対応しないからである。 TableGenの実装[6]や ドキュメント[17]によると、これらを対応させる方法には2種類ある:

  1. 両者で同じ名前を採用する。

  2. 両者で宣言順序を揃える[7]

上の例では RVInstCB では offset が先に宣言されているにも関わらず、 BEQZ では $rs1 を先に使用した。 この結果 $rs1 には offsetrs1 の両方が結びつくことになる[8]。 なおこのような重複が発生してもTableGenは特に警告等を表示しないようだ。よくわからない [9]

RV32KMCCodeEmitter::encodeInstruction 内で使用している support::endian::Writer<support::little>(OS).write(Bits); はそのままでは通らない。 RISC Vを参考に support::endian::write<uint16_t>(OS, Bits, support::little); としておく。

RV32KAsmParser を追加する

アセンブリをパーズするための RV32KAsmParser を追加する。これによってアセンブリをオブジェクトファイルに直すことができるようになる。 新しく AsmParser ディレクトリを作成し、その中に RV32KAsmParser.cpp 及びそれに付随するファイルを作成する。 例によって、パッチ[19]を参考に実装をすすめる。

RV32KAsmParser.cpp に記述するコード量がそれなりにあるため少々面食らうが、 要するに RV32KAsmParserRV32KOperand の2つのクラスを作成し、実装を行っている。 パーズの本体は RV32KAsmParser::ParseInstruction である。これを起点に考えれば、それほど複雑な操作はしていない。

即値に関して SImm6SImm12 に直す必要がある。これらはTableGenが生成するコードから呼ばれるようだ。

ところでRV32Kの命令には add などレジスタを5bitで指定する命令と、 sub などレジスタを3bitで指定する命令の2種類がある。エンコードに際して、これらの区別のための特別な処理は必要ない。 というのも、3bitでレジスタを指定する場合その添字の下位3bit以外が無視されるため、 結果的に正しいコードが出力される[10]。 例えば x8 を指定すると、これに 1000 という添字が振られ、4bit目を無視することで 000 となるため、 3bitでのレジスタ指定方法として正しいものになる。

そうこうしていると簡易的なアセンブラが完成する:

$ cat foo.s
li x9, 3
mv x11, x1
sub x9, x10
add x8, x1
nop

$ bin/llvm-mc -arch=rv32k -filetype=obj foo.s | od -tx1z -Ax -v
000000 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00  >.ELF............<
000010 01 00 f5 00 01 00 00 00 00 00 00 00 00 00 00 00  >................<
000020 68 00 00 00 00 00 00 00 34 00 00 00 00 00 28 00  >h.......4.....(.<
000030 04 00 01 00 8d 44 86 85 89 8c 06 94 01 00 00 00  >.....D..........<
000040 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  >................<
000050 00 2e 74 65 78 74 00 2e 73 74 72 74 61 62 00 2e  >..text..strtab..<
000060 73 79 6d 74 61 62 00 00 00 00 00 00 00 00 00 00  >symtab..........<
000070 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  >................<
000080 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  >................<
000090 07 00 00 00 03 00 00 00 00 00 00 00 00 00 00 00  >................<
0000a0 50 00 00 00 17 00 00 00 00 00 00 00 00 00 00 00  >P...............<
0000b0 01 00 00 00 00 00 00 00 01 00 00 00 01 00 00 00  >................<
0000c0 06 00 00 00 00 00 00 00 34 00 00 00 0a 00 00 00  >........4.......<
0000d0 00 00 00 00 00 00 00 00 04 00 00 00 00 00 00 00  >................<
0000e0 0f 00 00 00 02 00 00 00 00 00 00 00 00 00 00 00  >................<
0000f0 40 00 00 00 10 00 00 00 01 00 00 00 01 00 00 00  >@...............<
000100 04 00 00 00 10 00 00 00                          >........<
000108

0x34から0x3dにある 8d 44 86 85 89 8c 06 94 01 00 が出力であり、 正しく生成されていることが分かる[11]。 We made it!

簡易アセンブラのテストを書く

適当な入力に対してアセンブラが正しい出力を行うかを確認しつつ 新たなバグの発生を抑止するために、LLVMのフレームワークを用いてテストを書く。

RV32KInstPrinter を実装する

テストを書くために、まずRV32Kのためのinstruction printerを作成する。 これは内部表現である MCInst から文字列表現であるアセンブリに変換するための機構である。 この後で作成するテストでは、アセンブリを MCInst に変換した上で、 それをアセンブリに逆変換したものがもとのアセンブリと同じであるか否かでテストを行う。

まず例によって InstPrinter ディレクトリを作成した後、適切に CMakeLists.txtLLVMBuild.txt を作成・編集する。 また RV32KInstPrinter を作成するための関数を LLVMInitializeRV32KTargetMC にて登録する必要がある。

その後 InstPrinter/RV32KInstPrinter.{cpp,h} を作成する。 いつものようにこれはパッチ[20]を参考にして行う。

RV32KInstPrinter::printRegName の実装で用いる getRegisterName の第二引数に 何も渡さなければAltNameを出力に使用する。第二引数に RV32K::NoRegAltName を渡すことで、 レジスタを X0, X1, …​ と表示することができる。

上記をビルドした後、 実際にアセンブリを MCInst に変換し、さらにそれをアセンブリに戻すには、 llvm-mc を用いる:

$ bin/llvm-mc -arch=rv32k -show-encoding foo.s
	.text
	li	x9, 3                   # encoding: [0x8d,0x44]
	mv	x11, x1                 # encoding: [0x86,0x85]
	sub	x9, x10                 # encoding: [0x89,0x8c]
	add	x8, x1                  # encoding: [0x06,0x94]
	nop	                        # encoding: [0x01,0x00]

-show-encoding を指定することよって当該アセンブリがどのような機械語に 翻訳されるか確認することができる。テストではこの機械語の正誤も確認する。

テストを書く

簡易アセンブラが正しく動作していることを保証するためにテストを書く。 ここでは「適当な入力を与えたときに正しく解釈し正しい機械語を生成するか」を確認する。

前節と同様にパッチ[20]を参考に記述する。 まず test/MC/RISCV ディレクトリを作成する。 その中に rv32k-valid.srv32k-invalid.s を作成し、 前者で正しいアセンブリが適切に処理されるか、 後者で誤ったアセンブリに正しくエラーを出力するかを確認する。

記述後 llvm-lit を用いてテストを行う:

$ bin/llvm-lit -as --filter 'RV32K' test
PASS: LLVM :: MC/RV32K/rv32k-valid.s (1 of 2)
Script:
--
: 'RUN: at line 1';   /home/anqou/workspace/llvm-project/build/bin/llvm-mc /data/anqou/workspace/llvm-project/llvm/test/MC/RV32K/rv32k-valid.s -triple=rv32k -show-encoding      | /home/anqou/workspace/llvm-project/build/bin/FileCheck -check-prefixes=CHECK,CHECK-INST /data/anqou/workspace/llvm-project/llvm/test/MC/RV32K/rv32k-valid.s
--
Exit Code: 0


********************
PASS: LLVM :: MC/RV32K/rv32k-invalid.s (2 of 2)
Script:
--
: 'RUN: at line 1';   not /home/anqou/workspace/llvm-project/build/bin/llvm-mc -triple rv32k < /data/anqou/workspace/llvm-project/llvm/test/MC/RV32K/rv32k-invalid.s 2>&1 | /home/anqou/workspace/llvm-project/build/bin/FileCheck /data/anqou/workspace/llvm-project/llvm/test/MC/RV32K/rv32k-invalid.s
--
Exit Code: 0


********************
Testing Time: 0.11s
  Expected Passes    : 2

テストに通っていることが分かる。YATTA!

アセンブラに残りの命令を追加する

簡易アセンブラに残りの命令を実装する。「簡易」を取り払うということである。 参考にするRISC Vのパッチは[22]である。

lw 命令を追加する

lw 命令は7bit符号なし即値をとる。この即値の下位2ビットは0であることが要求される。 この即値はアセンブリ中でもLLVM内部でも7bitのまま保持される。

ParserMatchClassAsmParser がどのようにそのオペランドを読めばよいか指定する。 TableGenで直接指定できない性質は EncoderMethod などを用いてフックし、C\++側から指定する[23]

uimm8_lsb00let EncoderMethod = "getImmOpValue"; は一見不思議に見えるが正常で、 ここでえられた即値は lwimm になり、そちらで整形される。 だが simm12_lsb0getImmOpValueAsr が指定されるので offset は12bitではなく11bitになっている。

uimm8_lsb00 をつくると RV32KOperand::isUImm7Lsb00 が無いと言われるので追加する必要がある。 この UImm7Lsb00 という名前は ImmAsmOperandName に従って作られているようだ。

generateImmOutOfRangeError の実装に使われているおもしろデータ構造 Twine は 文字列の連結を二分木で持つことで高速に行うことができるらしい[24]

メモリ表記のアセンブリをパーズするためには RV32KAsmParser を変更する必要がある。 この処理のエントリポイントは parseOperand で、ここから parseMemOpBaseReg を呼び出す。

li x11, x12, x13 というアセンブリを流し込むと invalid operand for instruction を期待 しているにもかかわらず immediate must be an integer in the range [-32, 31] と出てしまう。 LW を追加する前はそのメッセージが出ていたのでいろいろ調査したが原因不明。 git stash してもとのコードをテストしてみると、実際には

$ bin/llvm-mc -arch=rv32k -show-encoding test.s
    .text
test.s:1:14: error: invalid operand for instruction
li x11, x12, x13
             ^

となっている。すなわちエラーが発生している位置がずれている。もとからおかしかったのだ。 おそらくこれは将来的に解決されるだろうという判断のもと保留(TODO)。

テストを忘れずに追加してコミット。

ところで getImmOpValue はオペランドにある即値をそのまま返す関数であり、 即値の EncoderMethod にフックとして使用する。 しかしこれは simm6 の実装時には不要であり、 実際 let EncoderMethod = "getImmOpValue"; をコメントアウトしてもテストには通る。 [22]では getImmOpValueAsr1 のみが実装されている。 RISC Vの最終的なコードで getImmOpValue は、即値として使えるような複雑な命令を解釈している。

sw 命令を追加

lw 命令を追加する際に諸々を整えたので、やるだけ。

lwsp 命令を追加

tdファイルに lwsp を追加してビルドしようとすると次のようなエラーが出た。

error: Invalid bit range for value
  let Inst{3-2} = imm{7-6};
                     ^

どうやら imm が6bit即値として扱われているようだ。原因を探すと RVInstCI 内で imm を 6bitと定義していたことが原因だったようだ。RISC V側での実装に合わせ、これを10bitに変更する。 同様に RVInstCI である li の定義も変更し、即値については各々の def で適切に処理するようにした。

sp を用いたテストを書くと、出力時にこれが x2 に変換されてしまうためにエラーになってしまう。 RegisterInfo.tdRegAltNameIndices の定義を変更しAltNameで出力をまかなえるようにした上で、 RV32KInstPrinter::printRegName の実装を改変した。

swsp 命令を追加

tdファイルに swsp を追加するだけ。

j 命令を追加

RISC Vの仕様上 c.j 命令は12bitの即値フィールドを持つ。 しかしLLVMのRISC Vバックエンド実装では RVInst16CJ は11bitの即値を持つ。 これは末尾1bitが必ず0であるために( lsb0 )この1bitを捨てることができるためである。 しかしこの実装は lw などがとる即値をそのままのビット数でデータを持つことと一貫性がないように 見受けられる。そこでRV32K実装では j 命令などもすべてそのままのビット数でデータを持つことにする。 したがって simm12_lsb0 などに指定する EncoderMethodgetImmOpValueAsr1 ではなく simm6_lsb00 と同様の getImmOpValue となる。

jaljrjalrbeqzbenz 命令を追加

やるだけ。

属性[1]を指定する

isBranch やら isTerminator やら hasSideEffects やらを命令毎にちゃんと設定する。 これはアセンブラでは意味をなさないが、コンパイラ部分を作り始めると重要になるのだろう。多分(TODO: ほんまか?)。

ところでどのような属性フィールドがあるのかはリファレンスを読んでも判然としない。 TableGenのソースコードを読みに行くと llvm/utils/TableGen/CodeGenInstruction.h にて 属性のための大量のフラグが定義されているが、各々がどのような目的で使用されるかは書いていない。 llvm/include/llvm/CodeGen.hmayLoadisTerminatorisBarrier などの一部分のフラグについて説明がある一方、 hasSideEffects などのフラグについては説明がない。

hasSideEffectsllvm/lib/Target/RISCV/RISCVInstrInfoC.td の中で C_UNIMPC_EBREAK でのみ 1 に設定されている。 特殊な事象が起こらない限り 0 にしておいて良さそうだ。

classdef の中に let field = value; と書くのと、外に let field = value in …​ ないし let field = value in { …​ } と書くのは同じ効果を持つが、外に書くと複数の classdef にまとめて効果を持つという点においてのみ異なる[25]

class の段階で hasSideEffects などについて列挙するのは、あまりよいスタイルと思えない。 というのもRV32CなどのISAでは、エンコーディングフォーマットが同じでも全く違う意味をもつ命令を 意味することが往々にしてあるからだ。また我々の開発のように後々ISAを変更することが半ば確定している状況において、 class と複数の def におけるフラグの整合を保ちつつ変更するのは骨が折れる。 それなら class はビットパターンのみを扱うものとし、 def にその意味的な部分(フラグの上げ下げ)を書くほうが、後々の拡張性を考えるとよいと思う[12]

レジスタ指定の GPRGPRC を使い分ける

RV32KRegisterInfo.td において GPRCX8 から X15 までの汎用レジスタを指し、 GPRX1X2 を含むすべてのレジスタを指すように定義されている。 RV32Kの命令のうち3bit幅でレジスタ番号を指定する命令には GPRC を用い、 5bit幅でレジスタ番号を指定する命令には GPR を用いるようにすることで、 LLVMに適切なレジスタを教えることができる。

この別はディスアセンブラを開発する段になって重要になる。というのも、バイナリ中にレジスタ番号として 1 と出てきた場合、 このオペランドが GPRGPRC かによって指すレジスタが x1 または x9 と異なるからである。

ディスアセンブラを実装する

アセンブラが一通り実装できたので、今度はディスアセンブラを実装する。 これによって llvm-objdump -d を使用することができるようになる。 このディスアセンブラのテストには、すでに記述した rv32k-valid.s などのテストを使用できる。 すなわちアセンブラによってアセンブリを機械語に直し、さらにディスアセンブラによって機械語をアセンブリに直した結果が、 元のアセンブリと一致するかどうかをみればよい[13]

参考にするRISC Vのパッチは[16]である。

RV32KDisassembler を追加する

*_lsb0*_lsb00 の取り回しがよくわからなくなったので整理[14]する。 TableGenで指定するビットによって、即値のどの部分をどのように命令コード中に配置するかを決定することができる。 これによって RV32KCodeEmitter::getImmOpValueAsr1 などの EncoderMethod に頼る必要がなくなる。 同様にディスアセンブラについても RV32KDisassembler::decodeSImmOperandAndLsl1 などの DecoderMethod に 頼る必要がなくなる。言い換えれば、これらのフック関数が受け取る即値は、TableGenで指定したビット単位の エンコード・デコードをすでに受けた値になる[15]RV32KAsmParser::isUImm12 などが呼び出す getConstantImm が返す即値も同様である。

ナイーブに実装すると lwspswsp が入ったバイナリをディスアセンブルしようとしたときに エラーがでる。これは例えば次のようにして確認することができる。

$ cat test.s
lwsp x11, 0(sp)

$ bin/llvm-mc -filetype=obj -triple=rv32k < test.s | bin/llvm-objdump -d -

原因は lwspswsp がアセンブリ上はspというオペランドをとるにも関わらず、 バイナリにはその情報が埋め込まれないためである。このためディスアセンブル時に オペランドが一つ足りない状態になり、配列の添字チェックに引っかかってしまう。

これを修正するためには lwspswsp に含まれる即値のDecoderが呼ばれたときをフックし、 sp のオペランドが必要ならばこれを補えばよい[16]。 この関数を addImplySP という名前で実装する。ここで即値をオペランドに追加するために呼ぶ Inst.addOperandaddImplySP の呼び出しの順序に注意が必要である。 すなわち LWSPRV32KInstrInfo.td で定義したときのオペランドの順序で呼ばなければ lwsp x11, sp(0) のようなおかしなアセンブリが生成されてしまう。

ちなみにエンコード方式にコンフリクトがある場合はビルド時に教えてくれる。

Decoding Conflict:
		111...........01
		111.............
		................
	BNEZ 111___________01
	BNEZhoge 111___________01

これを防ぐためには、もちろん異なるエンコード方式を指定すればよいのだが、 他にディスアセンブル時に命令を無効化する方法としてTableGenファイルで isPseudo = 1 を指定して疑似命令にしたり isCodeGen = 1 を指定してコード生成時にのみ効力を持つ 命令にすることなどができる。

relocationとfixupに対応する

ワンパスでは決められない値についてあとから補うための機構であるfixupと、 コンパイル時には決定できない値に対してリンカにその処理を任せるためのrelocationについて 対応する。参考にするパッチは[27]

必要な作業は大きく分けて次の通り。 * Fixupの種類とその内容を定義する。 * Fixupを適用する関数を定義する。 * アセンブラがFixupを生成するように改変する。 * Fixupが解決されないまま最後まで残る場合は、これをrelocationに変換する。

Fixupを定義する

RV32KFixupKinds.h を新規に作成し enum Fixups を定義する。 RV32Kv1では beqzbnez がとる8bitの即値と jjal がとる11bitの即値のために 必要なFixupを定義する。 なお enum の最初のフィールドには FirstTargetFixupKind を設定し、 最後のフィールドは NumTargetFixupKinds として、定義した enum のフィールドの個数を設定する。

Fixupの種類からその情報を返すのは RV32KAsmBackend::getFixupKindInfo が行う。 ここでの offset の値は、Fixupのために得られた即値を何ビット左シフトするかを意味し、 bits は TODO を意味している。そこで、即値のフィールドが命令中で2つに分かれている命令のためのFixup である fixup_rv32k_branch では offsetbits を各々 016 にしておく [17]

Fixupを適用する関数を定義する

要するに RV32KAsmBackend::applyFixup の実装である。補助関数として adjustFixupValue も実装する [18]

アセンブラにFixupを生成させる

AsmParserCodeEmitter を書き換え、必要なときにアセンブラにFixupを生成させるようにする。

さてRISC Vでは %hi%lo などが使えるために、これらを評価するための機構として RISCVMCExpr を導入している。 具体的には RISCVAsmParser::parseImmediate でトークンに AsmToken::Percent が現れた場合に RISCVAsmParser::parseOperandWithModifier を呼び出し、この中でこれらをパーズして RISCVMCExpr を生成している。

しかしRV32Kではこれらの % から始まる特殊な即値[19] に対応せず[20]、あくまで分岐命令の即値におけるラベルのFixupのみが行えれば十分である。 [21]

そこでまず isSImm9Lsb0 などにシンボルが来た場合には true を返すようにする。 これはすなわち、即値を指定するべきところにラベル名が来た場合は true とするということである [22]。 その次に getImmOpValue を変更し、即値を書くべき場所にシンボルが来ている場合にはFixupを生成するようにする。 このとき fixup_rv32k_branchfixup_rv32k_jump のいずれを発行するかは、 オペランドの種類で switch-case して判断している。 これは良くないコーディングスタイルであり、実際[28]ではこれを避けるために 種々のInstFormatに手を加えるとはっきり書いてあるのだが、ここでは作業の単純さを重視して ハードコーディングすることにする。

それから RV32KAsmParser::parseImmediate を変更し AsmToken::Identifier が来たときには MCSymbolRefExpr を生成するようにしておく。この変更は[22]に含まれていたものだが、 取り込み忘れていた。

Fixupからrelocationへの変換部を実装する

RV32KELFObjectWriter::getRelocType を実装すればよいのだが、実のところRV32Kにおけるrelocationはほとんど想定おらず、 仕様も決まっていない。そこでここでは実装を省略する。

Fixupのためのテストを書く

.space を使って適当に間隔を開けながら命令を並べ、正しく動作しているかを確かめる。

これでアセンブラ部分は終了。We made it!

コンパイラのスケルトンを作成する

空の関数とALUオペレーションをサポートできるような最小のバックエンドを作成する。

  • RV32KAsmPrinterRV32KMCInstLower

    • MachineInstrMCInst に変換する。

    • llvm::LowerRV32KMachineInstrToMCInstMCInst のオペコード・オペランドを設定する

  • RV32KInstrInfo

    • TableGenによる自動生成。命令を表す enum の定義。

  • RV32KRegisterInfo

    • ほとんどTableGenによる自動生成。

    • getReservedRegs で変数に割り付けるべきでないレジスタを指定する。

    • getFrameRegister はframe indicesに用いられるレジスタを指定する(?:TODO)

  • RV32KISelDAGToDAG

    • SelectionDAG ノードに対して適切なRV32K命令(多分 MachineInstr )を見繕う。

    • RV32KDAGToDAGISel::Select がエントリポイント

      • ただしこれはTableGenが生成した SelectCode 関数を呼んでいるだけ。

  • RV32KISelLowering

    • LLVM IRを SelectionDAG に変換する。

    • この処理はほとんどターゲット非依存である。

      • フックを設定して挙動を変化させる。

  • RV32KInstrInfo.td

    • ここに「 SelectionDAG をどのようにRV32Kの命令セット(多分 MachineInstr )に変換するか」が書かれる。

    • def : Pat<(hoge), (piyo)>; という表記で「 hogeSelectionDAG がパターンマッチしたとき piyo に変換する」を意味する。

      • ここで piyo に記入するのは ins のオペランドのみである。 outs は「戻り値」となる。(TODO;詳細は?)

    • どのような SelectionDAG にマッチできるかは include/llvm/Target/TargetSelectionDAG.td に情報がある。

    • マッチは命令( add のような)のみならず「オペランドが即値であるか」などにも適用できる。ただしこの場合その即値の定義が ImmLeaf を継承していることが必要。

  • 関数呼び出し規約やCallee-savedなレジスタについてもここで指定する。

  • update_llc_test_checks.py を使用することで、LLVM IRで書かれた関数から生成されるアセンブリのテストを自動的に生成することができる。

おおよそ次のような過程をたどるようだ[32]

LLVM IR
|
| ISelLowering (関数呼び出し規約などSelectionDAGでは扱えない要素を消す)
v
SelectionDAG (仮想的/物理的なレジスタによる表現)
|
| ISelDAGToDAG
v
DAG (MachineInstrのリスト)(命令の順序を決める)
|
|
v
(ここでSSA-basedの最適化・レジスタ割り付け・プロローグエピローグ挿入を行う)
|
| MCInstLower
v
MCInst

utils/UpdateTestChecks/asm.py を変更する

update_llc_test_checks.py を使用するために必要な変更らしい。RISCVのための記述を参考にしながら追記すれば良い。

RV32KTargetLowering を実装する

とりあえずLLVM IRに近いところから実装していくことにする。 RV32KTargetLowering はLLVM IRを SelectionDAG に変換するためのクラスである。 RV32KISelLowering.{h,cpp} で定義される。

まずこのクラスのコンストラクタで、ターゲットの仕様を指定する。

次いでメンバ関数の LowerFormalArguments で関数呼び出し時の引数の扱いについて記述する。 ここでは引数はすべてレジスタ経由で渡されることにする。 その場合、使用するレジスタクラスを指定して仮想レジスタを作成し、引数に割り付けて追加する。

最後に LowerReturn で、関数呼び出しから戻るときの戻り値の扱いについて記述する。 戻り値を解析し、そのすべてをレジスタに詰め込み(複数個あることを前提?)、最後に RET_FLAG を出力している [23]。 ここの処理は全体的によくわからない(TODO;特に Chain, Flag, RetOps がどのような働きをしているのか判然としない)。 Chain を通じて SelectionDAG がじゅじゅつなぎになっている?

引数が渡ってくるレジスタを CopyFromReg でくるむことで、その(仮想的/物理的)レジスタがこの SelectionDAG の 外側で定義されたものであることを示している。

なおRV32Kは下位(sub)に位置づけられるべきターゲットが存在しないが、 それでもターゲットの情報を保持するために RV32KSubTarget を定義することが必要である。 CPUNameRV32K.tdRV32KMCTargetDesc と合わせて generic-rv32k としておく。 Lanaiの実装を見ると generic だけで統一しても良いようだ(TODO)。

setBooleanContents(ZeroOrOneBooleanContent); はターゲットが1/0でtrue/falseを判定することを設定する。

さて上記のように関数呼び出し時の処理を記述するためには、 そもそも関数呼び出し規約の定義を行う必要がある。これは RV32KCallingConv.td にて行う。 関数呼び出し時の引数・戻り値を処理するためのレジスタを指定する。またcallee-savedなレジスタも指定する。

ここで戻り値を返すためのレジスタを複数指定することができるようだ。 これはおそらくLLVM IRが複数の戻り値を扱うことができることの単純な反映であろうし(TODO;ほんまか?)、 また例えばRISC VのCalling conventionではa0とa1が戻り値を返すためのレジスタとして指定されているためでも あると思う。

なおここで sp である x2 をcallee-savedとして指定する必要はない。 というのもこの処理は関数プロローグ・エピローグで行うことに(将来的に)なるからだ [24]

RV32KDAGToDAGISel を実装する

SelectionDAG をRV32Kコードに変換するためのクラスを実装する。 RV32KISelDAGToDAG.cpp で実装される。 このクラスのエントリポイントは Select であるが、これ自体はTableGenが生成する関数である SelectCode を 呼び出すだけである。そこでこの処理の本体は RV32KInstrInfo.td に記述されることになる。

RISCVのCompressedの実装を見ると Pat を継承しない方法で処理をしていた。よくわからない(TODO)。

ALU操作のみにとりあえず対応するため、現状対応するのは ADDSUB 、それから便宜上必要になる PseudoRET の3つのみである。

RV32KFrameLowering を実装する

関数のプロローグとエピローグを出力するためのクラスを実装する。 これらではスタックフレームサイズの調整を行う。 とはいいつつもこれらはまだ実装の必要がないため、ただのプレースホルダになっている。

スタックがアドレス負方向に伸びることを TargetFrameLowering のコンストラクタの第一引数に StackGrowDown を渡すことで表現している。

RegisterInfo を実装する

まず RV32KRegisterInfo.td を変更し、レジスタを割付優先度順に並び替える。

続いて RV32KRegisterInfo クラスを実装する。 getReservedRegs で通常のレジスタ割り付けでは使用しないレジスタを指定する。

RV32KAsmPrinterRV32KMCInstLower を実装する

[29]と同様の実装をすればよい。 本体の処理は LowerRV32KMachineInstrToMCInst である。 これは MachineInstrMCInst に変換している。

その他

ビルドに必要なファイルなどを実装する。

RV32KInstrInfolet guessInstructionProperties = 0; という文を追加している。 命令の属性( hasSideEffects など)を推論するためのオプションのようだ(TODO)。

RV32KGenInstrInfo.inc を読み込むため RV32KInstrInfo.{h,cpp} を追加する。

RV32KTarget>achine.{h,cpp} を変更する。ここでは RV32KSubtarget のインスタンスを 得るための getSubtargetImpl を実装すると同時に、実行パスに RV32KDagToDAGISel を挿入するために RV32KPassConfig を実装する [25]

テストを行う

CodeGen のためのテストを作成する。これはLLVM IRを入力し、出力されるアセンブリが正しいかどうかを判定するものである。

テストの枠組みに頼らず、手でテストを行うためには次のようにする。

$ cat test.ll
define i32 @sub(i32 %a, i32 %b) nounwind {
; RV32K-LABEL: sub:
; RV32K:       # %bb.0:
; RV32K-NEXT:    sub x8, x9
; RV32K-NEXT:    jr x1
  %1 = sub i32 %a, %b
  ret i32 %1
}

$ bin/llc -mtriple=rv32k -verify-machineinstrs < test.ll
	.text
	.file	"<stdin>"
	.globl	sub                     # -- Begin function sub
	.p2align	3
	.type	sub,@function
sub:                                    # @sub
# %bb.0:
	sub	x8, x9
	jr	x1
.Lfunc_end0:
	.size	sub, .Lfunc_end0-sub
                                        # -- End function

	.section	".note.GNU-stack","",@progbits

ついに我々はLLVM IRからコード生成を行うその第一歩を踏み出せたようだ。Here we go! [26]

SelectionDAG とはなにか

[32]を参考にまとめる。

  • SelectionDAGSDNode をノードとする有向非巡回グラフである。

    • SDNode は大抵opcodeとオペランドを持つ。

    • include/llvm/CodeGen/ISDOpcodes.h を見るとどのようなものがあるか分かる。

  • SelectionDAG は2つの値を持つ:データフローとコントロールフロー

    • データフローは単に値がノードになっているだけ。

    • “chain” ノードによって副作用を持つ操作(load, store, call, returnなど)の順序が決まる。

      • 副作用を持つ操作はすべてchainを入力として受け取り、新たなchainを出力しなければならない。

        • 伝統的にchainの入力は0番目のオペランドになっており、出力は最後の値になっている。ただしinstruction selectionが起こった後はそうとも限らない。

  • “legal” なDAGは、そのターゲットがサポートしている操作・型のみで構成されている。

SelectionDAG を用いたinstruction selectionは SelectionDAG を最適化・正規化することで行われる [33]。 この過程は -view-dag-combine1-dags などのオプションを llc に与えることでグラフとして見ることができる。

$ bin/llc -mtriple=rv32k -view-dag-combine1-dags  < test.ll
# DOTファイルビューワがあればそれが起動する。ない場合は次のようにしてSVGに変換する。
$ dot -Tsvg /tmp/dag.sub-86df29.dot -o dot.svg
simple sub combine1

それっぽい。

定数に対応する

定数をレジスタに読み込むためのパターンを RV32KInstrInfo.td に追加する。 すなわち simm6 が来たら li を呼ぶようにすれば良い。ただしここで li のオペランドには、 渡ってきた即値のみを渡せばよいことに注意が必要である。すなわちレジスタを指定する必要はない。 これは Pat の右側に書くDAGは (ins) のオペランドのみを記載すればよいからである。

なお simm6Pat でも使用できるように ImmLeafsimm6 の継承先に追加する必要がある。

メモリ操作に対応する

ロードとストアのための Pat を追加する。offsetが0の場合と非ゼロの場合で別の def が必要なことに注意。 lwload に対応付け swstore に対応付ければ、最低限の実装が整う。 RISC Vではさらに sextloadi8LB に対応させるなどしているが、 RV32Kv1ではこれらの命令が無い。 lw と他の命令を合わせれば実現できるかもしれないが、 その実装方法がよくわからない。 setOperationActionCustom 指定とかすればできそうだが詳細不明。(TODO) [27]

なお参考にするコミット[34]のコミットメッセージには copyPhysReg を 実装する必要があると書いてあるが、実際に実装してみるとこれは必要ない。 この関数はどうやらレジスタの中身を移動させる命令を生成するための関数のようで、 使用するレジスタが多くなると使われるようだ。必要になるまで遅延することにする。

続くコミット[38]はグローバル変数を扱うためのコミットのようだ。 グローバル変数を load する場合、まずその読み込むべきアドレスの値をレジスタに作る必要があるが、 即値ロード命令が限定されているため、即値のアドレスの上位ビットと下位ビットを分けて読み込む必要がある。 そのためにコミットでは %hi%lo を使用しているが、現状我々のバックエンドにはこれらが実装されていない。 そこで一旦このコミットは飛ばすことにする[28]

条件分岐に対応する

条件分岐に[39]を参考にして対応する。 ISDには条件分岐として BRCONDBR_CC の2つがある。 BRCOND は条件分岐のみを行うのに対し、 BR_CC は2ノード間の比較と条件分岐をともに行う。 ここでは、よりパターンマッチが容易な BRCOND にのみ対応することにし、 BR_CCsetOPerationAction を用いて Expand する [29]

setltsetgt に対応する

TableGenを用いて brcond に対するパターンマッチを記述する。

RV32Kv1では BEQZBNEZ のみが定義されている[30]。 そこで setltsetgt が自然に定義できる。すなわち $a < $b という比較なら SLT $a, $bBNEZ $a, hoge とする。

分岐命令のパターンマッチでは bb というクラスがよく登場する。( bb:$imm9 )。これはおそらくbasic blockで、 分岐の際に用いるプリミティブな型のようだ。パターンマッチの後半で正しい型を指定することで、実際の型を指定できる気がする。TODO これと絡む即値の型は Operand<i32> の代わりに Operand<OtherVT> を継承する必要があるようだ。 なおbasic blockを処理するために MachineOperand::MO_MachineBasicBlock についての場合分けを LowerRV32KMachineOperandToMCOperand に追加する必要がある。

LLVM IRをテキスト形式で書く際 brcond という命令は直接は存在せず br 命令を介してこれを使うことになる。 その際 false 部の処理を行うため br 命令も発行されることに注意が必要である。 すなわち brcond のためのパターンマッチのみならず br のためのパターンマッチも潜在的に不可欠である。 これを解決するために

def : Pat<(br bb:$imm12), (J simm12_lsb0:$imm12)>;

としても良いように見えるし、実際動く。 しかしRISC Vの実装では PseudoBR を定義して解消しているため、とりあえずこれを採用してみる [31]

このPseudo命令を定義するためには RV32KAsmPrinter::lowerOperand[38]を参考にして実装する必要がある。

RV32Kv1の現在の定義では SLT のみが存在するため、ナイーブに定義できるのは setlt のみである。 これを使用するようにSelectionDAGを構成するためには次のようなLLVM IRを入力する。

define i32 @foo(i32 %a, i32 *%b) {
  %val3 = load volatile i32, i32* %b
  %tst3 = icmp slt i32 %val3, %a
  br i1 %tst3, label %end2, label %end
end:
  ret i32 1
end2:
  ret i32 0
}

ここで

  br i1 %tst3, label %end2, label %end

とすると途中で setge に変換されてしまうため注意が必要である。

brcond with br

引数を逆転させることで setgt についてもパターンマッチ可能である。

なお setlt などを挟まず brcond が単体で現れる場合についてもパターンマッチが可能だが、 これについてLLVM IRでテストを書くと and のSelectionDAGが現れてしまうためRV32Kv1では コンパイルできない。仕方がないのでテストはなしにしておく。

seteqsetne に対応する

subbeqz を組み合わせることで seteq を実現可能である。 また subbnez を組み合わせることで setne を実現可能である。 しかし sub は左辺を破壊する命令のため、前後の命令との兼ね合いによってはレジスタの値を別のレジスタに移したり、 スタックに保存する必要がある[32]。そこで[implement-copyPhysReg]で示唆したように [34]を参考にして RV32KInstrInfo::copyPhysReg を実装し、 さらに[39]を参考にして RV32KInstrInfo::storeRegToStackSlotRV32KInstrInfo::loadRegFromStackSlot を実装する。 またこれに必要な RV32KRegisterInfo::eliminateFrameIndex も実装する。 この関数は MachineInstr にオペランドとして含まれる FrameIndexFrameRegOffset に変換するための関数である。 storeRegToStackSlot などではオペランドとして FrameIndex を指定し、 即値は 0 にしておく。そのうえで eliminateFrameIndex を呼び出し、 正しいオペランドに変換している[33]。 ここでRV32Kv1の lwsw が符号なし即値をとることが問題になる。 すなわち Offset が負数になる場合に対処できない。 この問題はどうすることもできないので、とりあえず放置する。 また FrameReg はRV32Kv1でははっきりと決まっていいないがとりあえず X15 にしておく。

ここまでで次のようなLLVM IRが

define void @foo(i32 %a, i32 *%b) {
  %val1 = load volatile i32, i32* %b
  %tst1 = icmp slt i32 %val1, %a
  br i1 %tst1, label %end, label %test2

test2:
  %val2 = load volatile i32, i32* %b
  %tst2 = icmp sgt i32 %val2, %a
  br i1 %tst2, label %end, label %test3

test3:
  %val3 = load volatile i32, i32* %b
  %tst3 = icmp eq i32 %val3, %a
  br i1 %tst3, label %end, label %test4

test4:
  %val4 = load volatile i32, i32* %b
  %tst4 = icmp ne i32 %val4, %a
  br i1 %tst4, label %end, label %test5

test5:
  %val5 = load volatile i32, i32* %b
  br label %end

end:
  ret void
}

次のようなRV32Kv1コードに変換される。

	.globl	foo                     # -- Begin function foo
	.p2align	3
	.type	foo,@function
foo:                                    # @foo
# %bb.0:
	lw	x10, 0(x9)
	slt	x10, x8
	bnez	x10, .LBB0_5
	j	.LBB0_1
.LBB0_1:                                # %test2
	lw	x10, 0(x9)
	mv	x11, x8
	slt	x11, x10
	bnez	x11, .LBB0_5
	j	.LBB0_2
.LBB0_2:                                # %test3
	lw	x10, 0(x9)
	sub	x10, x8
	beqz	x10, .LBB0_5
	j	.LBB0_3
.LBB0_3:                                # %test4
	lw	x10, 0(x9)
	sub	x10, x8
	bnez	x10, .LBB0_5
	j	.LBB0_4
.LBB0_4:                                # %test5
	lw	x8, 0(x9)
.LBB0_5:                                # %end
	jr	x1
.Lfunc_end0:
	.size	foo, .Lfunc_end0-foo
                                        # -- End function

	.section	".note.GNU-stack","",@progbits

関数呼び出しに対応する

関数呼び出しをサポートするためにはi) PseudoCALL を実装しii) RV32KTargetLowering::LowerCall を実装すれば良いようだ。前者は JAL に展開され、 後者はLLVM IRから SelectionDAG への変換を担う。

LLVM IRの call は、まず luiaddi で関数のアドレスを取得した後、 jalr でその場所で飛ぶようなアセンブリに変換される。ただLLVM 9の clang でアセンブリを出力 させると call 命令を使うものが出力される。RISC Vのソースコードを見ると次のようにある。

// PseudoCALL is a pseudo instruction which will eventually expand to auipc
// and jalr while encoding. This is desirable, as an auipc+jalr pair with
// R_RISCV_CALL and R_RISCV_RELAX relocations can be be relaxed by the linker
// if the offset fits in a signed 21-bit immediate.
// Define AsmString to print "call" when compile with -S flag.
// Define isCodeGenOnly = 0 to support parsing assembly "call" instruction.

アセンブリ出力時のみ call を使うようだ。

関数のアドレスはグローバルなので、そのアドレスを取得するコードを lowerCall 中に 記述する必要がある。パッチ[42]では lowerGlobalAddress を読んでいるが、 これは実装していない。そこでこの処理を応急処置的に書くことにする。 現状のRV32Kv1ではすべての関数呼び出しのアドレスはFxiupで解決される(relocationに回らない) と前提しているので、そのとおりに実装する。

LowerCall ではおおよそi) 引数を解析してii) CALLSEQ_START ノードを出力しiii) 引数を処理するためのDAG( CopyToReg )をはさみiii) CALL ノードを出力しiv) CALLSEQ_END ノードを出力し v) 返ってきた値を CopyFromReg ノードを出力することで 取り出している、ようだ。他にも様々な処理が挟まっているが、よくわからない。TODO

CopyFromReg は物理レジスタ(physical register)から仮想レジスタ(virtual register)に 値をコピーするようなDAGを生成し、逆に CopyToReg は物理レジスタ仮想レジスタの中の 値をコピーするようなDAGを生成する。一般のレジスタ割り付けが行われる以前であっても、 例えば関数の引数・戻り値のように割り付けるべき物理レジスタが決まる場合がある。 この区別をこのDAGは行っているようだ。 したがって LowerCall では CopyToReg を使って引数を詰め込み、 関数を呼び、それが終わったら CopyFromReg を使って戻り値を取り出しているということになる。

さて PseudoCALL はパッチ[42]では jalr に展開される。 一見 jal に展開すれば良いように思えるし、実際RISC Vの実装ではそうなっているのだが、 そうするためには PseudoCALLins に指定するクラスを作成する必要がある。 RISC Vでは bare_symbol が、Lanaiでは CallTarget がそれに当たる。 複雑で良くわからないのでとりあえずここは jalr に展開することにする。

let Defs = [X1]PseudoCALL にかぶせているのは、おそらく X1jalr によって 書き換えられることを意味している。 jalrX1outs に含めていないのは、 おそらく jalrX1 を出力するというわけではないからだと思うがわからない。TODO

getCallPreservedMask を定義する必要がある。Callee-savedなレジスタに関する情報を 渡すための関数のようだ。(おそらく;TODO)TableGenが再生する CSR_RegMask をそのまま返せば良い。

CallSeqStartCallSeqEndADJCALLSTACKDOWNADJCALLSTACKUP に結びつける必要がある。 これらが let Defs = [X2], Uses = [X2] で囲まれているのは(おそらく) X2 を書き換えつつ、 かつその(過去の?;TODO)値を使用するから。

すると次のようなエラーが出る。

Can't store this register to stack slot
UNREACHABLE executed at /home/anqou/ano/secure_vm/llvm-project/llvm/lib/Target/RV32K/RV32KInstrInfo.cpp:55!

そこで ADJCALLSTACKDOWN らを RV32KGenInstrInfo のコンストラクタに渡すようにすると、 また次のようなエラーが出る。

Call Frame Pseudo Instructions do not exist on this target!
UNREACHABLE executed at /home/anqou/ano/secure_vm/llvm-project/llvm/include/llvm/CodeGen/TargetFrameLowering.h:299!

そこで eliminateCallFramePseudoInstr を実装する。 この関数は関数呼び出しのための疑似命令を具体的な命令に置き換えるための関数のようだが、 ここではその疑似命令を削除するに留める。

するとまた初めのエラーが再燃した。

Can't store this register to stack slot
UNREACHABLE executed at /home/anqou/ano/secure_vm/llvm-project/llvm/lib/Target/RV32K/RV32KInstrInfo.cpp:56!

よくよく見てみると、当該ソースコードは次のようになっている。

void RV32KInstrInfo::storeRegToStackSlot(MachineBasicBlock &MBB,
                                         MachineBasicBlock::iterator I,
                                         unsigned SrcReg, bool IsKill, int FI,
                                         const TargetRegisterClass *RC,
                                         const TargetRegisterInfo *TRI) const {
  DebugLoc DL;
  if (I != MBB.end())
    DL = I->getDebugLoc();

  if (RV32K::GPRCRegClass.hasSubClassEq(RC))
    BuildMI(MBB, I, DL, get(RV32K::SW))
        .addReg(SrcReg, getKillRegState(IsKill))
        .addFrameIndex(FI)
        .addImm(0);
  else
    llvm_unreachable("Can't store this register to stack slot");
}

レジスタをスタックに対比するMIを生成するためのコードである。 ここで sw 命令が GPRC レジスタのみをとることが災いし x1 をスタックに積むことができない。 したがってRV32Kv1の枠組みでは関数呼び出しを行えないことが判明した

RV32Kv1のためのLLVMバックエンド作成はここで(突然)中止である[34] [35]

参照


1. これを属性と呼んでいいかどうかよくわからないが、わかりやすいし呼びやすいのでとりあえずこれで。内部的にはrecordと書かれることが多いようだ。
1. この変更は後で行ったため、後ろの記述に齟齬が発生する場合がある(TODO)。
2. 同ファイル内にある同名のstatic関数を関数呼び出しの候補に入れていないようだ。原因不明。v8.0.0にて採用されているRISC-Vのコードを参考に、別の場所で定義されている同名関数を利用するよう修正した。
3. LLVMのARMバックエンドはApache 2.0 Licenseに例外条項を 付与したライセンスになっていた。検討の余地あり。
4. この方針はアセンブラを書き始めてから決めたため、以下の記述に不整合が生じている場合がある。いずれ修正する(TODO)。
5. 蓋し RV32KInstrInfo.td を書くのは見た目によらず難しい。ある程度進んでから戻ってくるほうが良いだろう。
6. utils/TableGen/CodeEmitterGen.cpp などを参照。
7. 関係するすべての class での宣言が対象になるようだが、それらがどの順に対応するのかは判然としない。
8. 名前での対応により rs1 が結びつき、順序による対応で offset が結びつく。
9. アセンブルではエラーが出ないが、ディスアセンブルで「オペランドが無い」というエラーが(添字チェックに引っかかるという形で)出る場合がある。
10. LLVMのRISC V実装でもこの方式が採用されている。
11. 白状すると、確認していない。次のステップでテストを書くため、そこで確認する。
12. ただしこれは命令数が少ない場合に限るのかもしれない。
13. [26]にはfull round trip testingと表現されている。
14. 整理というより半分推測だが。
15. バイナリに直接触れるのはTableGenが出力するコードなので、当然といえば当然だが。
16. この実装手法はRISC Vのそれによる。かなりad-hocだと感じるが、他の方法が分からないのでとりあえず真似る。
17. これはRISC Vの実装を真似ている。
18. ところでRISC Vの fixup_riscv_rvc_{jump,branch} の実装では即値の幅のチェックを行っていない。 なぜかはよくわからない。あと fixup_riscv_jal のコメントが間違っている気がする。
19. 内部的にはoperand with modifiersと呼ばれているようだ。
20. とりあえずはね。
21. なおRISC Vの classifySymbolRef を見るとシンボルの引き算ができるように見えるが、実際に試してみると getImmOpValueUnhandled expression が出て落ちてしまった。よくわからない。即値としては認識される( isSImm12 などが true を返す)が、 Fixupとして受領されない( getImmOpValue でエラーになる)気がする。TODO
22. ここで isImm を呼び出す必要があることに注意が必要である。「即値」と聞くと整数値のみを受け取ると誤解しがちだが、 実際には RV32KOperandImmOpMCExpr をその値として持っている。したがって、シンボルなどを含めた 即値を求めるための演算そのものが対象になっている。すなわち isImmtrue であることを確認することで、 ラベル名が来るべきところにレジスタ名などが来ないことを担保しているのである。 またより形式的には、これは RV32KOperand が持つ共用体の中身が ImmOp となっていることを保証している。
23. ここでの RET_FLAGRV32KDAGToDAGISel にて JR X1 に変換される疑似命令である。 [7]では直接 jr 命令を参照していたが、それよりも「関数からのリターンである」 という意味的情報を付加できるというメリットがありそうだ(TODO;ほんまか?)
24. [29]のコミットメッセージを参考のこと。
25. 他の変換は RV32KSubtarget 経由で呼び出される。この変換のみが異なるようだ。
26. 言い換えればここからが本番ということで、ここまでは長い長い前座だったのだ。 それでも一つ終わったことは良いことだ。祝杯にコーヒーを入れよう。
27. その後RV32Kv2以降でHW側で実装と合意。
28. そもそもRV32Kv1の枠組みでは32bit即値を読み込むことが 困難であるという事情もある。
29. おそらく BRCOND に書き換えるということ。どう書き換えるかがどこで定義されているかはよくわからない。TODO
30. RV32Cではこれらは BEQBNE 命令から置換する形で使用される。
31. 要するにちゃんとした理由がないのだが、そのうち同様の実装を必要とする Pseudo命令が出てくるだろうという予測はある。
32. 実はこの時点ではこの需要は微妙なところだが、まあそのうち必要になることに疑いはない。
33. と思う。裏はあんまり取っていない。TODO
34. 作りながら、このISAでは 限界がありそうだと感じていたが、しかしLLVMがハングするまでここで中止になることはわからなかった。
35. あとから気づいたが、直接スタックに積むのではなく一旦 GPRC のレジスタに mv してから スタックに積めばこの問題を回避できたのかもしれない。ただここで「どのレジスタに一旦退避するか」を どう求めればよいかは良くわからない。