CPUエミュレーションを速くしたい - 静的バイナリ変換のハイブリッドシステム
たまにはエミュレーションネタを書かないとねw
以前に作っていた「FCC」というiアプリ用ファミコンエミュレータでは、通常のCPUエミュレーションに加えて、
静的バイナリ変換によってJavaコードに事前変換した6502のコードを実行するレイヤを設けて、
互換性と高速性を実現しようとしました。
タイトルに書いた「ハイブリッド」というのは、エミュレーションと静的バイナリ変換の両方を使うという
ところから来ています。
多分、あまり前例が無い仕組みで高速化を図ったんじゃないかと思うんです。
今回は、その辺の仕組みについて解説したいと思います。
まず、基本となるエミュレータの簡単な仕組みです。
CPUが実行するプログラムは、単純な命令の集まりからできています。
その命令は、足し算をしろという命令や、フラグが立っていたら分岐しろ、というような命令など、いろいろです。
ファミコンのCPUである6502は、1バイトから3バイトまでの可変長命令セットです。
つまり、1つの命令が、1バイトだったり、2バイトだったり、3バイトだったりするわけです。
1バイト命令から3バイト命令それぞれを、バイトごとに区切ると、
1バイト命令 : オペコード1バイト
2バイト命令 : オペコード1バイト + オペランド1バイト
3バイト命令 : オペコード1バイト + オペランド2バイト
という構造になっています。
オペコードが命令の種類を表し、オペランドがデータを表します。
オペコードは、オペレーションコードの略で、日本語にすると、まあ「命令語」といったところでしょうか。
オペランドは日本語でいうと「被演算子」です。
「1を足せ」という命令があった場合、「足せ」というのがオペコードで、「1」がオペランドに相当します。
CPUのエミュレータは、まず、プログラムコードの列から1バイト読み出して、オペコードが何であるかを判別することが、エミュレーション実行の最初の一歩となります。
オペコードを取り出したら、その種類によってそれぞれの処理を行うのですが、一般的にはswitch文を使って分岐する方法がとられることが多いようです。
while (残り実行クロック > 0) { オペコードをフェッチする switch (オペコード) { case 0x69: Immediate ADCの処理を行う break; case 0x65: Zero Page ADCの処理を行う break; case 0x75: Zero Page,X ADCの処理を行う break; case 0x6D: Absolute ADCの処理を行う break; case 0x7D: Absolute,X ADCの処理を行う break; case 0x79: Absolute,Y ADCの処理を行う break; case 0x61: Indirect,X ADCの処理を行う break; case 0x71: Indirect,Y ADCの処理を行う break; case 0xE9: Immediate SBCの処理を行う break; case 0xE5: Zero Page SBCの処理を行う break; /* 以下省略 */ } }
という感じです。
6502の1命令ごとに、switch文で分岐して、命令ごとの処理を行う処理を、ぐるぐるループするわけです。
ここで問題となるのが、6502の1命令をエミュレーションするたびに、switchを1回実行する必要があるということです。
switchは条件分岐ですから、あまり軽い処理ではありません。
では、毎回switchで分岐しなくても、6502のコードを実行できる方法が無いかというと、それがあるんです。
一言でいうとインライン展開です。
6502のエミュレーションコード(switch文のcase句の中に書いたコード)を、6502のプログラムコードの並び通りに、ひたすら並べていけばいいわけです。
これでswitch-caseで分岐するオーバーヘッドを減らすことができます。
それってエミュレーションって言えるの?というツッコミはもちろんあると思いますが、
そこを追求するとエミュレーションの正確な定義ってなんなんだっていう話になってしまうので、
ひとまずおいときます。
さて、ここで問題となるのが、6502の分岐命令をどうやって実現するかということです。
6502のアドレスごとにラベルをつけるという手が考えられます。
例えば、
AD8000: インライン展開されたコード AD8001: インライン展開されたコード AD8002: インライン展開されたコード AD8004: インライン展開されたコード AD8005: インライン展開されたコード
という具合です。
「インライン展開されたコード」の部分に、6502のエミュレーションコードをインライン展開したものが
入るわけです。
$8002番地へ分岐する命令があった場合、
goto AD8002;
と、goto文を使って分岐させます。
これならば、絶対番地を指定して分岐する命令ならば、問題ないのですが、
絶対番地+インデックスのようなアドレッシングで分岐する命令を処理できなくなります。
一般的な言語では、goto文のラベルを変数にすることはできません。
(というより、そもそもJavaにはgoto文が存在しませんが)
静的に飛び先を確定できる6502の命令しか、この方法ではうまくいかないわけです。
静的に飛び先を確定できない場合に対応するために、switch-caseのcase句をつかって、
6502のアドレスを表現することが考えられます。
while (残り実行クロック > 0) { switch (pc) { case 0x8000: インライン展開されたコード case 0x8001: インライン展開されたコード case 0x8002: インライン展開されたコード case 0x8004: インライン展開されたコード case 0x8005: インライン展開されたコード /* 以下略 */ } }
case句が、フォールスルー(fall through)になっているところがポイントです。
これならば、静的に飛び先がわからない分岐命令も処理できます。
例えば、$8004番地+Xレジスタというアドレッシングの分岐命令があった場合を考えます(アブソリュート・インデックス・アドレス指定)。
これは、
pc = 0x8004 + X; break;
と、プログラムカウンタを表す変数へアドレス計算を行った値を代入した後で、breakすることで実現できます。
pcへ代入した後でbreakすることで、caseのフォールスルーから抜けて、switch文の頭へ戻り、そのpcのcase句へジャンプすることができます。
この方法を使えば、6502のコードを、Javaコードへ静的に変換することが比較的簡単にできます。
これを私は「静的リコンパイル」とか「デコンパイル」と呼んでいます。
一度、一般的な構造の6502のエミュレータを組んだ後であれば、そのエミュレータのコードをインライン展開するようなプログラムを組めばいいので、楽です。
この方法で、Javaコードへあらかじめ変換しておくことにより、普通にエミュレーションを行った場合より5倍程度高速に処理を実行できます。
しかし、ここで問題となることがあります。
- 自己書換にどう対応するか?
- 変換後のプログラムサイズが大きくなりすぎないか?
大きくは、以上2つの問題が発生します。
静的にJavaコードへ変換している以上、そのままでは自己書換コードを実現するのは不可能です。
当たりまえですが、Javaなどの高級言語で自己書換はできません。
ファミコンの一部のソフトには、RAMエリアに命令列を書き出して、そこへジャンプするような処理を行うソフトもあります。
自分が確認したソフトでは、ハイドライドスペシャルがそんな処理をやっていました。
これも、自己書換の一種と言えるでしょう。
まあ、ファミコンの場合、ハイドライドスペシャルのような例外を除くと、プログラムコードはROM内にあるため、自己書換をつかっているケースは少数です。
かといって、これらのソフトが動かないのでは困ります。
そこで、今回のブログエントリのタイトルのように、エミュレーションと静的バイナリ変換を「ハイブリッド」にする方法が登場するわけです。
- 静的バイナリ変換によるコード実行は高速だが、自己書換に対応できず、コードサイズが増える
- エミュレーション実行は、低速だが、自己書換に対応できて、コードサイズが増えない
という特徴があります。
この二つの実行方法を都合のよいように切り替えながら、実行することができれば、高速性、互換性、省サイズを実現できるかなーというわけです。
車のハイブリッドと同じようなイメージです。
じゃあ、具体的にはどうするか?ということですが、それはまた次回に書きます。