実機が存在しなくてもエミュレータを使えば動きの中身が見える

msyksphinz氏(以下、msyksphinz):「Rustで作るフルスクラッチQEMU型エミュレータ」と題して、発表をします。

簡単に自己紹介をさせてください。Twitterだとこういうアカウントでいろいろと活動しています。趣味で「FPGA開発日記」というブログを書いていて、RISC-V、FPGA、CPU、低レイヤプログラミングなど、興味のあるものの記事を書いています。本業はハードウェア開発エンジニアをしていて、汎用CPUの設計などの仕事をしています。

今回はRustというテーマなんですが、職場はRustの「ラ」の字も出てこないところです。こういうことを趣味でやっています。最近は、RISC-Vのことなどを雑誌に連載する機会も増えてきています。

本日は、QEMUのお話をします。私自身、どういう仕組みになっているのかを勉強する良い機会になったので、共有したいと思います。

まず簡単に、命令セットエミュレータ、シミュレータとは何かから説明します。世の中にはいろいろなターゲットのバイナリが存在していて、RISC-V、ARM、x86みたいなバイナリがあります。ただ、これらを動かしたい時に実機が存在しないことがよくあるわけですね。

そういう時にエミュレータを使うのですが、例えば私だったら、x86のデスクトップPCとかでエミュレータを動かして、RISC-Vのバイナリを動かします。こんな感じで実機が存在しなくても、別の実機を使ってその異なるバイナリの動きを模擬して、中身を見ることができるのがエミュレータの特徴です。

世の中にはいろいろなエミュレータが出てきていて、これからお話しするQEMU以外にもいろいろな命令セットシミュレータが公開されています。例えば、実機を入手するのが難しい量子コンピュータのシミュレータなどもシミュレータを使うことで、実際にどういうことが起きているのかを見ることができます。

シミュレータは、私たちのような汎用CPUの設計屋さんにとっては非常に馴染み深いもので、例えば私がCPUを設計して、動きがどうなっているんだろうと見る時に、このRTLシミュレータとは別に、この命令セットシミュレータで同じバイナリを動かして動作が一致するのかを見てデバッグを行ったりします。そんな感じで非常に馴染みの深いツールになっています。

さまざまなアーキテクチャをエミュレーションできる「QEMU」

QEMUに焦点を当てていきます。QEMUは、みなさんがご存知のとおりプロセッサのエミュレータです。さまざまなアーキテクチャをエミュレーションすることができます。上のほうでいろいろなアーキテクチャを受け入れます。これはゲストアーキテクチャといいます。それをホストアーキテクチャ、つまり私の場合はx86で変換することでエミュレーションするというツールです。

QEMUは、受け入れたバイナリを1回、Tiny Code Generator(TCG)という表現に変換して、さらにホスト命令に変換するというかたちになっています。この形式は、コンパイラだとLLVM、LLVM-IRみたいな中間表現を取るかたちによく似ていると思っていて、QEMUはエミュレータ界のLLVMだと個人的に思っています。

大きく分けて2つの方式があります。1つがインタープリタ型。ゲスト命令を1命令ずつ解釈して、その動作を何かしらのプログラミング言語で模倣することで、エミュレーションする方式です。もう1つがQEMUが取っているバイナリ変換型です。ゲスト命令をホスト命令に直接変換して実行する方式です。

どちらにも長所・短所があります。インタープリタ型は、実装が簡単です。ただしバイナリ変換型に比べると遅い。バイナリ変換型は、直接命令を変換するのでインタープリタ型と比較して非常に速度が速いという特徴があります。

Tiny Code Generatorによる中間表現

次にQEMUの中身について触れていきます。QEMUは、1回バイナリを受け取るとTiny Code Generatorという表現に変換します。ここでは、RISC-Vのバイナリを受け取って、それをx86上で実行するという例を取り上げてみます。RISC-VのADDI命令、即値加算ですね。これを受け取った場合にどうするかというと、大きく4つのTCGに変換されます。

1つがRegister Readですね。x3、gpレジスタからtmp2にデータを読み出して、即値をtmp3に格納して、tmp2とtmp3を加算します。最後の計算結果tmp2をa5の汎用レジスタに格納するというTCGに変換されます。それぞれを1対1でx86の命令に置き換えていくことでエミュレーションができます。

なので、この場合はRISC-Vの1命令が3命令のx86命令に置き換わるんですが、QEMUは強力な最適化によって前後のメモリアクセス命令を除去することもできます。一番良いパターンだと、RISC-Vの1命令、ADDIがx86のaddq命令の1命令に置き換えられます。これがQEMUが非常に高速である所以になっていると思います。

自作シミュレータにはRustを採用

TCGの仕組みを理解したところで、本当に理解できているかどうか自分でイチからQEMUらしきものをフルスクラッチで作ってみました。

言語を何にしようかと思ったんですが、Rustを私もちょくちょく使っていて、「Rustで書くと安全だ」という神話もよく聞いていました。QEMUはいろいろバグを出すと安全じゃないとかよく言われていて、「QEMUをRustで書き換えるといいよ」みたいな話も出てきたので、自分でちょっとやってみて、どんな感じになるのかを見たいと思いました。

成果物はGitHubに公開していて、RISC-Vのバイナリをx86でエミュレーションするというものをRustで作ってみました。ただ、勉強用に書いたのでほぼ実用性はないし、数ヶ月前に作って飽きてしまったので最近はあまりメンテナンスできていません。

自作シミュレータの実装

中身の実装について紹介します。この自作QEMUは、大きく3つのコンポーネントで成り立っています。1つはゲストマシン用コンポーネント、つまりRISC-Vを模擬しているものです。メモリ、プログラムカウンタ、レジスタファイル、システムレジスタを模擬しています。もう1つがホストマシン用コンポーネント、x86側ですね。変換したx86命令をバッファリングしておくためのブロックで構成されています。

真ん中が共通コンポーネントで、これが実際にエミュレーションを行うエンジンです。大きな流れをご紹介すると、まずはフェッチします。メモリから命令を取ってきてデコードして、デコード結果に基づいてTCGを生成します。今度はTCGからx86命令の変換を行います。この段階でx86命令ブロックに格納しておいて、次に繰り返しなどで同じプログラムカウンタを踏んだ場合は、この変換処理をスキップして、直接このブロックから取りにいくということを行っています。

最後にこの変換したx86命令を実行することで、レジスタにアクセスしたり、プログラムカウンタを更新したりします。あとの説明のために、大きく分けてこの上のデコード、フェッチの辺りを制御モード、下のx86命令実行のところをエミュレーションモードと名前を付けて説明します。

これはちょっとした小話なんですが、自作QEMUを作るにあたってデバッグで非常に苦労しました。自作QEMUなので、バイナリがバイナリを作り出します。どんな命令を生成されたのかが確認できないという困った問題が起きました。

GDBとかを当てればいいんですが、DWARFのデバッグ情報とかまでは生成できず、GDBが当てられないままエミュレータを実行すると、途中でExceptionが起きて終わりみたいな、困った状態になりました。

なのでどうしたかというと、本物のQEMUで自作QEMUを動かして、そのうえでRISC-Vバイナリを動かすというちょっと再帰的なかたちを取りました。本物のQEMUは自作QEMUの動きを全部事細かにトレースして取ってくれるので、自作QEMUがどういう命令を生成して実行しようとしたのかというログを詳細を取ってくれます。

なのでそのログを見ながら、どこのオフセットが間違っている、どこのレジスタが間違っているみたいなのをデバッグしてデバッグを行っていきました。

速度測定の結果インタープリタ型には圧勝 QEMUには完敗

すごくがんばって実装を行って、ある程度テストパターンが動くところまでもっていきました。とりあえず速度を測ってみたいなと思ったので、Dhrystoneという非常に一般的なベンチマークプログラムを動かしてどれだけの速度になるのかを測ってみました。

それがグラフになっていて、真ん中の赤いやつが私の実装です。この下のやつがインタープリタ型のSpikeという、わりと一般的に使われているRISC-Vのシミュレータで、一番上がQEMUです。インタープリタ型に比べると圧勝できたのですが、QEMUと比べるとぜんぜん勝てていない状態で、なんでこんなにQEMUは速いんだろうとQEMUを読み解きながら調査をしていきました。

(次回へつづく)