先日こんな記事を書いたように、Visual Studioから使えるClangのツールセットFafnirを作ったのですが。
使ってみた方がいればお気づきと思われますが、中間ファイルのキャッシュが効かず、毎回フルビルドが実行されてしまうという問題がありました(修正済み)。
どうしてそんな問題が発生したのか、また、どう修正を行ったのか、書いてみようと思います。
MSBuildはいかにして依存関係を知るのか
例えばMakefileの場合、依存関係を全てファイル内に記述することになります。
ソースファイルがインクルードするヘッダが増えた場合、依存関係が増えるのでMakefileに依存を書き加える必要があり、そうしないとヘッダファイルを更新してもリビルドしてくれなくなるという問題があります。
例えばNinjaの場合は、コンパイル時に詳細を出力させるオプションを渡し、出力をパースすることで依存関係を知るので、ヘッダファイルをわざわざ手動で書く必要はありません。
ただし、Ninjaはcl.exeが日本語でログを吐いたりするとパースできなくなります(msvc_deps_prefix を設定することでパースできるようになりますが)
なんにせよ、ビルドシステムにはソースファイルの依存関係を知る方法が必要です。
MSBuildの場合、ヘッダファイルの依存を教える必要はありませんが、コンパイラのログを見ている訳でもありません。カスタムビルドイベントも作れるので、依存ファイルがC/C++のヘッダファイルだけとは限りませんし。
MSBuildはビルド中間ディレクトリの中に、.tlogという拡張子を持ったファイルを作成します。このファイルに依存関係が入っています。
tlogは追跡ログ(Tracking Log)ファイルというもので、MSBuildのビルドタスクの、
- どのようなコマンドラインが呼ばれたか
- ビルド中にどのファイルを読んだか
- ビルド中にどのファイルを書いたか
という情報のいずれかが入っています。
これにより、
- コマンドラインが変更された時にリビルドする
- 読み込んだファイル(依存関係)に変更があった時にリビルドする
- 前回のビルドで書き込みがうまく行ったかどうかを検知する
といったことが可能になるようです。
まあ、実のところそこまでちゃんとMSBuildについて理解しているわけではなく、そもそも公式資料にそんなに詳しいものがなく、キャッシュが効かないぞとIssueでつつかれて慌てて調べたのですが、そう間違ってはいないと思います。
コマンドラインに関しては単純に呼び出したものを記録しておけばいいのですが、どのファイルを読み書きしたか、という情報がtlogに含まれているのはなかなか不思議な話です。
なんせ、Fafnirはコマンドラインを転送する対象のclang.exeのパスを、インストール時に.targetというファイルに書き出す仕組みになっているのですが、そのファイルまでtlogに含まれていたのです。もちろん、私がtlogに書き出す仕組みを作ったわけではありません。
MSBuildはビルドタスクを実行する時に、Tracker.exeというアプリケーションを経由して、Tracker.exeが実際のコマンドを実行します。
Tracker.exeはコマンドを起動した後、DLLインジェクションでAPIフックを仕込んでいるようです。
CreateFile関数をフックすれば、コンパイラがコンパイル中に読み書きしたファイルは全て追跡できる、というわけです。
Clangはなぜキャッシュが効かなかったのか
ところが、冒頭で述べたように、Fafnirのツールセットではキャッシュが効きませんでした。
最初は孫プロセスまでDLLインジェクションされていないのではなどと疑ってみましたが、どうもそうではない。
調べているうちに、clang.exeはコンパイル時にテンポラリファイルを作成していることに気づきました。
コンパイル中はテンポラリファイルに書き出していて、コンパイル終了時にリネームしている。
ここで、Tracker.exeはファイルの書き込みは追跡しているが、移動は追跡していないのではないか、という疑念が浮かびました。実際に試してみても、どうやらそうらしい。
ということはつまり、移動先のファイルをなんらかの方法で、一回でも書き込みオープンすれば、ログにその情報が出力されることになります。
書き込み先を特定する手段は2つ考えられて、
- コマンドラインをパースして出力先を特定する
- Tracker.exeと同じようにDLLインジェクションをして、ファイルの移動を検知する
1の方法だと、clangのバージョンによってオプションが増えたりした場合に対応するのが困難になります。LLVM用のツールですがソースコードはLLVMに依存したくないので、2の方法を使うことにします。
方針が決まりました。
MoveFileが呼ばれた時に、移動先のファイルを一瞬書き込みオープンするだけでログにちゃんと出力されるようになるはずです。
実際にはこの予想もちょっと違っていて、最終的にはSetFileInformationByHandleというAPIをフックすることになったのですが。
DLLインジェクションとAPIフック
DLLインジェクションとは、
- プロセスをCREATE_SUSPENDEDで起動
- VirtualAllocExで作成したプロセスにメモリ領域割当て
- WriteProcessMemoryで割り当てたメモリ領域にDLLのパスを書き込み
- CreateRemoteThreadでLoadLibraryとメモリ領域を指定
- WaitForSingleObjectでスレッドの終了を待つ
- VirtualFreeで割り当てた領域の解放
- ResumeThreadで作成したプロセスのメインスレッドを再開
といった処理を行うことで、リモートプロセスで任意のDLLを注入するハックです。
APIフックは、
- モジュールのインポートアドレステーブルを書き換え、別の関数を呼び出すようにする
- 関数本体の先頭を書き換え、別の関数にジャンプするようにする
などの方法を使って、APIの動作を上書きするハックです。
実行中のプロセスの一部を書き換えることになるので、間違ったコードを書くとまずい動作をすることになってしまいかねません。まあ、大抵はやばいことになる前にプロセスが死んで終了になりますが。
ともあれ、今回行ったのは、
- CreateProcessA, CreateProcessWをフックして、プロセス起動時にDLLインジェクションを行うようにする
- SetFileInformationByHandleをフックして、ファイルが移動される前に一瞬対象ファイルを開くようにする
というDLLを作成し、それを注入するという手段です。
DLLインジェクションを実装するにあたって、
CreateRemoteThreadしてWaitForSingleObjectしたのになぜかGetExitCodeThreadがSTILL_ALIVEを返すからおかしいと思ったらINFINITEを渡すはずがINFINITYを渡していた
— 白山風露@ᓚᘏᗢ (@kazatsuyu) 2018年3月1日
DLLインジェクションまではうまく行った。
— 白山風露@ᓚᘏᗢ (@kazatsuyu) 2018年3月1日
APIフックがうまくいかない。コアダンプが溜まってきた
みたいなこともありましたが、それをクリアしたところ、めでたく
よっしゃ来たああああああああああ!!!!1!11! pic.twitter.com/3EdS7ylu9L
— 白山風露@ᓚᘏᗢ (@kazatsuyu) 2018年3月2日
キャッシュが効くようになりました。
というわけで、ビルドがキャッシュされるようになったFafnir v1.0.1.0をリリース済みです。
Visual Studio使いだけどClangも使ってみたい人は、ぜひ試してみてください。