llvm-hs で遊んでみる

llvm-hs を使って遊んでみたので、備忘録として情報を残しておきます。


# はじめに

[llvm-hs-pure で遊んでみる](https://qiita.com/dmystk/items/049b783236abc785d04a) の続きです。

前回は [llvm-hs-pure](https://hackage.haskell.org/package/llvm-hs-pure) と [llvm-hs-pretty](https://hackage.haskell.org/package/llvm-hs-pretty) を使った LLVM IR へのコンパイラを作成してみましたが、今回は [llvm-hs](https://hackage.haskell.org/package/llvm-hs) を使った形でコンパイラを書き直してみました。具体的には、llvm-hs を使用して、コンパイラからオブジェクトファイルを出力するように修正しています。リンクについては対象外なのでご注意ください。

なお、この記事は全体を通して前回の記事で作成したソースコードを修正する形で記述しています。必要に応じて前回の記事を参照してください。

実装したコードは [GitHub](https://github.com/dmystk/llvm-hs-sample-compiler/tree/llvm-hs) にアップしてあるので、必要に応じてご活用ください。


# 環境設定

llvm-hs を使用するには LLVM のインストールが必要です。なお、ここに記載している事柄は、筆者が使用している Mac OS を前提とした内容となりますので、ご注意ください。

## LLVM のインストール

Homebrew を使って LLVM をインストールします。

本記事の執筆時点（2022/08/13 現在）では Stackage にある llvm-hs がサポートしている LLVM のバージョンは 9.x が上限のようなので、それに合わせて LLVM 9 をインストールします。[^1]

[^1]: Stackage では公開されていないようですが、GitHub 上の [llvm-hs](https://github.com/llvm-hs/llvm-hs/) リポジトリ上では LLVM 12 までサポートされているようです。

Homebrew を使った LLVM のインストール方法は２通りあります。

- `brew install llvm@9`
- `brew install llvm-hs/llvm/llvm-9`

前者は本家 LLVM が公開しているものをインストールする方法、後者は llvm-hs が公開しているものをインストールする方法のようです。後者の [GitHub リポジトリ](https://github.com/llvm-hs/homebrew-llvm) にはバージョン番号付きの LLVM ライブラリがインストールされる旨が記載されていますが、このようにインストールするメリットについては特に記載がなさそうです。

llvm-hs が推奨する方法は当然後者だと思いますが、llvm-hs 専用っぽい LLVM を入れるのは気が進まないので、今回は前者を使用します。

```shell
$ brew install llvm@9
```

インストールできたら、忘れずに .bashrc ないし .zshrc にパス設定を追加しておきます。

```shell:.bashrc
export PATH=/usr/local/opt/llvm@9/bin:$PATH
```

## llvm-hs の導入

Stack の各種設定に llvm-hs を追加します。

まずは package.yaml の依存関係に llvm-hs を追加します。llvm-hs-pretty および text は不要になるので削除しておきます。

```yaml:package.yaml
dependencies:
- base >= 4.7 && < 5
- parsec
- llvm-hs == 9.0.1
- llvm-hs-pure == 9.0.0
```

続いて stack.yaml へ依存関係を追加します。こちらも llvm-hs-pretty は不要になるので削除しておきます。また、ここでは省略していますが、まだ GHC 9.x では llvm-hs がビルドできなさそうだったので、`resolver` は更新せずに `lts-18.28` のままにしています。

```yaml:stack.yaml
extra-deps:
- llvm-hs-9.0.1
- llvm-hs-pure-9.0.0
```

依存関係の記述自体は上記のみで OK なのですが、llvm-hs のビルドの際の諸設定のため、stack.yaml にもう少し書き足す必要があります。以下の内容を追記します。

```yaml:stack.yaml
flags:
  llvm-hs:
    shared-llvm: false

ghc-options:
  llvm-hs: -optcxx=-std=c++11
```

Homebrew の LLVM は静的ライブラリとしてインストールされるため、上記 `flags` の設定が必要になります。詳細は以下の GitHub Issue を参照ください。

https://github.com/llvm-hs/llvm-hs/issues/283

また、GHC 8.10 以降では Cabal が何らかの理由で `-optcxx` の内容を無視してしまうらしく、上記のように `ghc-options` で明示的に C++ のバージョンを指定しないとコンパイルエラーになります。こちらも詳細は以下の GitHub Issue に書いてあるので、そちらを参照ください。

https://github.com/llvm-hs/llvm-hs/issues/315

なお、`brew install llvm-hs/llvm/llvm-9` でインストールした場合、Mac OS 限定で追加で実施が必要なコマンドがいくつかあるようです。llvm-hs の [GitHub リポジトリ](https://github.com/llvm-hs/llvm-hs/tree/llvm-9) に書いてあるので、必要であればそちらを参照ください。


# コンパイラの修正

前回はコンパイラから LLVM IR を出力していましたが、今回はオブジェクトファイルを出力するように修正します。

## LLVM IR 出力処理の削除

LLVM IR を出力しない以上、llvm-hs-pretty が提供している `ppllvm` は不要なので削除します。

```diff:Compiler.hs
-compile :: Expr -> Text
-compile expr = ppllvm $ buildModule "main" $ do
+compile :: Expr -> Module
+compile expr = buildModule "main" $ do
     printf <- externVarArgs "printf" [ptr i8] i32
     format <- globalStringPtr "%d\n" "format"
     function "main" [] i32 $ \[] -> do
         entry <- block `named` "entry"; do
             result <- toOperand expr
             call printf [(ConstantOperand format, []), (result, [])]
             ret (int32 0)
```

Compiler パッケージでは Module を作成するところまでを責務としておき、Module から実際に何を出力するかは Main パッケージ内で決めるという設計にしておきます。

## Module からオブジェクトファイルへの変換

ここからが llvm-hs を使うパートになります。

llvm-hs の使い方については、実際のコードを見ながらの方がわかりやすいと思うので、まずはコードの修正内容を示します。

```diff:Main.hs
 import System.IO
 import System.Environment
-import qualified Data.Text.Lazy.IO as LT
+import LLVM.Analysis
+import LLVM.Context
+import LLVM.Module
+import LLVM.Target
 import Parser
 import Compiler

 main :: IO ()
 main = do
   args <- getArgs
   let srcPath = head args
   src <- readFile srcPath
-  let distPath = srcPath ++ ".ll"
+  let distPath = srcPath ++ ".o"
   let result = compile <$> parse src
   case result of
-    Right ir -> LT.writeFile distPath ir
+    Right mod -> withContext $ \ctx ->
+      withModuleFromAST ctx mod $ \mod' -> do
+        verify mod'
+        withHostTargetMachineDefault $ \target ->
+          writeObjectToFile target (File distPath) mod'
     Left err -> print err
```

ここでは LLVM IR をファイルに出力していた処理を修正して、オブジェクトファイルを出力するように修正しています。

case 文以下のごちゃごちゃ書いている部分が llvm-hs の関数を利用した処理です。見てわかる通り、llvm-hs では、各種パッケージに定義されている `withXxx` という関数で必要な情報を取得し、それを利用して各種検証やファイル出力を行います。

以下、各行で何をしているかを詳しく見ていきます。

```hs:Main.hs
    Right mod -> withContext $ \ctx ->
```

最初に `withContext` を使って LLVM に必要な（スレッドごとの）状態を管理するコンテキストを取得しています。この関数は [`LLVM.Context`](https://hackage.haskell.org/package/llvm-hs-9.0.1/docs/LLVM-Analysis.html) に定義されています。

ここはほとんどお作法的な記述なので、深く気にせずにコールしておくのが吉です。

```hs:Main.hs
      withModuleFromAST ctx mod $ \mod' -> do
```

続いて `withModuleFromAST` をコールして、llvm-hs-pure が提供する Haskell の世界での Module の表現を、実際に LLVM の内部で使用されている表現形式に変換します。

この手の Module とバイトコード・アセンブラ・バイナリとの間の変換関数は [`LLVM.Module`](https://hackage.haskell.org/package/llvm-hs-9.0.1/docs/LLVM-Module.html) にまとめて定義されています。後述しますが、Module から生成したバイナリをファイルに書き込む際に使用する関数も、このパッケージに定義されています。

```hs:Main.hs
        verify mod'
```

LLVM の内部表現に変換した Module にサニティチェックをかけています。`verify` は [`LLVM.Analysis`](https://hackage.haskell.org/package/llvm-hs-9.0.1/docs/LLVM-Analysis.html) に定義されていますが、Stackage には特に詳細は書かれていないため、どのようなチェックが行われるのかはあまり理解できていません ... 。

```hs:Main.hs
        withHostTargetMachineDefault $ \target ->
          writeObjectToFile target (File distPath) mod'
```

最後に、ターゲットマシンの情報を取得して、そのターゲットマシン向けのオブジェクトファイルを出力しています。ここではターゲットマシンとしてホストマシン（コンパイラをビルドしたマシン）の情報が使用されるように実装しています。

[`LLVM.Target`](https://hackage.haskell.org/package/llvm-hs-9.0.1/docs/LLVM-Target.html) に定義されている `withHostTargetMachineDefault` でホストマシンの情報を取得しています。この方法は最も簡単なターゲットマシンの取得方法で、他にも最適化などのオプションをより細かく指定できる `withHostTargetMachine` や、ホストマシン以外のターゲットマシンを指定するための `withTargetMachine` などが同パッケージで提供されています。が、各オプションの意味をしっかり理解しないと利用できない感があるので、それなりにハードルは高そうです ... 。

取得したターゲットマシンの情報を利用して `writeObjectToFile` をコールすればオブジェクトファイルのバイナリデータを直接ファイル出力することができます。これは [`LLVM.Module`](https://hackage.haskell.org/package/llvm-hs-9.0.1/docs/LLVM-Module.html) に定義されている関数です。

以上でコードの修正は完了です。

## 遊ぶ

実際に動かしてみます。前回に引き続き、以下のサンプルプログラムを利用します。

```text:example.src
5 * (12 + 3) - 1
```

以下のコマンドで上記サンプルをオブジェクトファイルにコンパイルします。

```shell
$ stack run -- /path/to/example.src
```

`example.src.o` が出力されますが、これだけでは実行できないので、リンクします。

今回のコンパイラでは、計算結果を出力するために `printf` を利用しているので、標準ライブラリをリンクする必要があります。これは通常 libc に含まれているのですが、Mac OS では libc は libSystem の一部という扱いで提供されているようなので、そちらを使用する必要があります。

```shell
$ ld -L/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/lib -lSystem /path/to/example.src.o
```

上記コマンドを実行すると `a.out` が出力されます。

```shell
$ /path/to/a.out 
74
```

ちゃんと計算結果が出力されていますね。めでたい！

蛇足ですが、プログラム中の `writeObjectToFile` を `writeTargetAssemblyToFile` に変更すればアセンブリの出力ができます。ついでにこれも試してみましょう。

```diff:Main.hs
 main :: IO ()
 main = do
   args <- getArgs
   let srcPath = head args
   src <- readFile srcPath
-  let distPath = srcPath ++ ".o"
+  let distPath = srcPath ++ ".asm"
   let result = compile <$> parse src
   case result of
     Right mod -> withContext $ \ctx ->
       withModuleFromAST ctx mod $ \mod' -> do
         verify mod'
         withHostTargetMachineDefault $ \target ->
-          writeObjectToFile target (File distPath) mod'
+          writeTargetAssemblyToFile target (File distPath) mod'
     Left err -> print err
```

実行すると以下のような `example.src.asm` が得られます。ターゲットマシンに対するネイティブアセンブラなので、環境次第で中身が変化することにご注意ください。

```asm:example.src.asm
	.section	__TEXT,__text,regular,pure_instructions
	.macosx_version_min 10, 17
	.globl	_main
	.p2align	4, 0x90
_main:
	.cfi_startproc
	pushq	%rax
	.cfi_def_cfa_offset 16
	leaq	_format(%rip), %rdi
	movl	$74, %esi
	xorl	%eax, %eax
	callq	_printf
	xorl	%eax, %eax
	popq	%rcx
	retq
	.cfi_endproc

	.section	__TEXT,__cstring,cstring_literals
	.globl	_format
_format:
	.asciz	"%d\n"


.subsections_via_symbols
```

実行してみます。clang を利用してアセンブルします。

```shell
$ clang -L/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/lib /path/to/example.src.asm 
$ /path/to/a.out
74
```

ちゃんと動いています。良さげですね。


# 所感

というわけで llvm-hs を使ってコンパイラを書いてみました。

本当はコンパイラから直接、実行可能バイナリを出力したかったのですが、[Micro C](https://blog.josephmorag.com/posts/mcc0/) のような比較的しっかり書かれている実装ガイドでもリンカ周辺は避けられている節があり、ハードルが高そうだったのでパスしました。いつかは触れてみたいです。

リンカについては現時点ではノーアイデアですが、LLVM の仕組みや全体観がわかってくると対処法が見えてくるのでしょうか ... 。筆者自身はまだ LLVM についての知見はほとんどないので、知見のある方に教えていただけると非常に助かります。