エミュレータの実装に関するメモ 武田が開発しているエミュレータの実装に関するメモです。 他の方のノウハウも読んでみたいなーと思ってみたり。 - 全体的な構成 ソフトウェアの大雑把な構成は、以下の通りになっています。 win32メイン ←→ emuクラス ←→ vmクラス win32メインは、windowsアプリのメイン処理です。 windowsからやってくるイベントに応じて、ウィンドウを開いたり、画面を 表示したり、キーボードの入力を受け取ったり、メニュー操作に反応したり、 といった、一般的なwin32アプリの部分です。 vmクラスは、エミュレートする対象となるマシンを仮想的に実現します。 cpuとかメモリとか音源とかディスクドライブとか、そんなのです。 これらの間を取り持つのが、間に挟まれたemuクラスです。 例えば、win32メイン側から受け取った「このキーを押した」とか「離した」 とかの情報を管理して、vmクラスから今のキーボードの状態がどうなって いるかの問い合わせがあったら答えてやる、といったことをしています。 このような構造にすることで、vmクラスからwindowsという環境に特化した 処理を完全に分離することができます。 例えばPC UNIXとかMacに移植したい、ということがあった場合も、vmクラスは そのまま使用することが可能となる訳です。 長期プロジェクトの場合、プロジェクトが完了する前にwindowsという環境が 廃れてしまわないとも限りませんので、自分で移植するつもりがなくても、 一応こういう実装を心がけています。 - タイミング調整 エミュレータの構造は、簡単化すると以下のようになります。 while(1) { // 以下を繰り返す 仮想マシンを一定時間駆動する 画面を更新する } しかし、今時のcpuでz80を全力で駆動すると、エミュレートする対象の マシンよりも、とてつもなく速いマシンになってしまいます。 従って、どこかでタイミングを取って、ほどほどの速度で動作するように してやらなければなりません。 while(1) { // 以下を繰り返す start = 現在時間 仮想マシンを1/60秒分だけ駆動する 画面を更新する end = 現在時間 if(end - start < 1/60秒) 1/60秒 - (end - start)だけ待機する } この例では、例えばRX-78のようにTVに画面を表示するタイプのマシンで、 毎秒60回画面が表示されるようなものを想定しています。 こうすることで、仮想マシンが実際より速く動いてしまうことが防げます。 しかし、「仮想マシンを駆動して画面を表示する」という処理が1/60秒以下に 収まらない場合、今度は実際よりどんどん遅れていってしまいます。 またwindowsでは、他のタスクにcpuを食われるなど処理速度が一定でないので、 エミュレーション速度が速くなったり遅くなったりと、不安定になりがちです。 next = 現在時間 + 1/60秒 skip = 0 while(1) { // 以下を繰り返す 仮想マシンを1/60秒分だけ駆動する if(next > 現在時間) { // 時間に余裕がある 画面を更新する skip = 0 if(next > 現在時間) { // 画面を更新してもなお余裕がある (next - 現在時間)だけ待機する } } else { // 画面更新をスキップした回数をカウント skip = skip + 1 if(skip > 最大スキップ数) { // あまりに画面の更新頻度が低い場合 画面を更新する skip = 0 next = 現在時間 } } // 次の締め切り時間を取得する next = next + 1/60秒 } 処理はちょっと複雑になってきましたが、趣旨は単純です。 毎回画面を更新すると実際のマシンより遅くなってしまうようであれば、 時間に余裕がないときは画面の更新をスキップすることで、なんとか 実際のマシンの速度についていこうとします。 (仮想マシンの駆動よりも、画面の更新の方が時間がかかる場合が多い) また、あまりに時間に余裕がないと全く画面が表示されなくなりますので、 そういうときはたまに強制的に画面を更新してやるようになっています。 なお、windowsの場合、while(1) { ... }のようにずっと同じ処理を繰り返し 続けることはできません。 私の場合は、メッセージループの中で、特にメッセージが来ていないときに この処理を1回だけ実行するようにしていますが、マルチスレッドにして、 別スレッドでこのループを実行するという形式の方が一般的のようです。 - 一定時間だけ仮想マシンを駆動する処理 コンピュータでは、一般的にはトップにcpuがいて、このcpuからの命令で メモリやその他の周辺装置が駆動されます。 従って、仮想マシンを一定時間駆動するということは、cpuを一定期間だけ 駆動するということになります。(ちょっと乱暴) cpuはクロック信号に従って動作しますので、cpuの動作期間はクロック数で 指定するのが簡単です。 たとえばRX-78ではクロックは4.1MHzですので、1/60秒だけ駆動するには、 4100000/60クロックだけcpuを駆動する必要があります。 cpuを駆動する処理は、例えばこんな感じになります。 void run_cpu(n) { // nクロックだけcpuを駆動する clock = clock + n while(clock > 0) { op = メモリから取得した命令 switch(op) { case あんな命令: あんな命令を実行する clock = clock - 掛かったクロック数 break; case こんな命令: こんな命令を実行する clock = clock - 掛かったクロック数 break; } } } cpuでは、命令ごとにそれを実行するのに必要なクロック数が決まっています。 従って、命令を実行するのに掛かったクロック数を足していって、与えられた クロック数だけ実行し終わったら抜ける、といった処理になります。 但し、例えば100クロックだけcpuを駆動しろと言われて、丁度100クロック だけ命令を実行できるとは限りません。実際は103クロック実行してしまったり して、時間が経つごとに駆動時間の誤差が蓄積してしまいます。 そこで、ここではclockという変数を使うことで、今回余計に実行してしまったら、 次回は指示されたクロック数よりその分だけ少なめに実行しよう、といった 処理にすることで、全体として誤差が大きくならないように工夫しています。 例えば、今回は100と指示されたにもかかわらず103も実行してしまったので、 次回は100と指示されても97だけでやめておこう、といった感じです。 #switch-caseより、各命令を関数として実装して、関数ポインタの配列を #使って呼び出した方が良い、とかそういう実装レベルの話はここでは #措いておいてください。>識者各位 - 周辺装置の駆動とイベント処理 一般的な周辺装置は、cpuからの指示(out命令とかin命令が実行される)に 応じて駆動されます。 これは、例えばこんな風に実装されます。 void write_io(addr, data) { // cpuが、out addr,dataを実行した switch(addr) { case プリンタポート: dataをプリンタポートに出力する break; } } uint8 read_io(addr) { // cpuが、in addrを実行した switch(addr) { case キーボード: return キーボードの状態 } // 周辺装置が指定されたaddrにつながっていない場合、 // バスがプルアップされているとffが返る return 0xff } cpuから指示があって、すぐに処理が完了するようなものはこれでokです。 しかし実際には、例えばフロッピーディスクのモータを回せと命令して、 実際に回り始めるまで0.5秒かかるなど、指示があってもすぐに完了しない 場合があります。 また、例えば時計のように、cpuから特に指示がなくても勝手に動いている ようなものもあります。 このような周辺装置を実現するために、イベントという概念を導入します。 これは、これだけ時間が経過したらこの処理を実行してくれ、とか、 これだけの時間おきに繰り返しこの処理を実行してくれ、といった依頼に 応じて処理を実行するものです。 周辺装置側の処理としては、例えばこんな感じになります。 void initialize() { // 初期化 1秒おきに、timer()を実行してください モータの状態 = モータは回っていない } void write_io(add, data) { switch(addr) { case モータを回せ: 0.5秒後に、motor_on()を実行してください break case モータを止めろ: 0.5秒後に、motor_off()を実行してください break } } uint8 read_io(addr) { switch(addr) { case モータの状態: return モータの状態 } return 0xff } void timer() { // 1秒おきに呼び出される 時計を1秒だけ進める } void motor_on() { // モータを回せ、から0.5秒後に呼び出される モータの状態 = モータは回っている } void motor_off() { // モータを止めろ、から0.5秒後に呼び出される モータの状態 = モータは回っていない } このイベント処理を実現するためには、cpuを駆動しつつ、依頼された時間に なったら依頼された処理を実行する必要があります。 とは言え、cpuが命令を実行するたびにイベントが発生しないか確認する、 という処理にすると、流石に処理速度が落ちてしまいますので、何かしら 工夫が必要です。 void run_vm(n) { // nクロックだけ仮想マシンを駆動する while(n > 0) { if(イベントが登録されていない) { // nクロックだけcpuを駆動する run_cpu(n) return } if(n < 次にイベントが発生する時間) { run_cpu(n) 次にイベントが発生する時間 = 次に〜時間 - n return } // nクロックだけcpuを駆動する前に、次のイベントが発生 while(n >= 0) { // 次にイベントが発生するまでcpuを駆動する run_cpu(次にイベントが発生する時間) n = n - 次にイベントが発生する時間 登録されたイベントを実行する 繰り返し実行するイベントでない場合は消去する // この次に発生するイベントを確認 次にイベントが発生する時間 = (新しい値) } } } この処理のみそは、次にイベントが発生する時間を把握していることです。 nクロックだけcpuを駆動しろと指示されたとき、それを実行し終わるまでに イベントが発生しないようであれば、そのままcpuを駆動します。 指示されただけcpuを駆動し終わる前にイベントが発生するようであれば、 取り敢えずイベントが発生するまでcpuを駆動して、イベントの実行を行って、 残りクロックだけまたcpuを駆動する、といった感じです。 以下余談。 仮想マシンを1/60秒分だけ駆動するという処理を実装するとき、私の場合は 以下のような処理を好んで使用しています。 void run_1frame() { // 1/60秒だけ仮想マシンを駆動する for(v = 0; v < 画面のライン数; v++) { // 現在の表示ライン位置を設定する set_vsync(v) for(h = 0; h < 1ラインの文字数; h++) { // 現在の表示文字位置を設定する set_hsync(h) // 仮想マシンを駆動 run_vm(1文字を表示するのに相当するクロック) } } } これは、私がファミコンのエミュレータからこの業界に入ってきたという 経歴が影響しています。 ファミコンの場合、画面を表示しながら、1ライン描画するごとにグラフィック ROMのバンク切り替えをするとか、Motherなど1文字ごとにバンク切り替えを するようなソフトがあります。 このため、cpuの駆動、hblank/vblankの信号の状態、それに伴う割り込み、 画面描画処理などを完璧にタイミングを合わせる必要があり、これを簡単に 実現するためにこういった実装をしていた訳です。 この場合、run_vm()で1文字の描画期間中だけ仮想マシンを駆動したら、 その後で1ライン中の1文字分だけ仮想画面の描画を実行することになります。 pc系の場合はここまでシビアな実装を要求されることは少ないのですが、 mz700の野球拳2000のように、1ライン描画するごとにvramの内容を変更する、 という動作を再現するためには、こういった配慮が要求されます。 #TVの表示がどう行われているかご存知でない方は、水平同期とか垂直同期 #とか、そういう言葉で検索してみてください。 以下、具体的な実装に関する執筆方針。 - メモリの実装 仮想マシン上でメモリをどう表現するかというお話。 RAMの場合とか、書き込みできないROMの場合とか、バンク切り替えとか、 メモリがフルデコードされていなくてゴーストが現れる場合とか、 そういうのを実現するためには何が必要か、といったことを書く。 ポインタ配列を使って、w[addr >> 4][addr & 0xfff] = dataとか、 return r[addr >> 4][addr & 0xfff]とか。 w[n] = dummy, r[n] = romで書き込みできないようにするとか。 - 画面生成 vramからどのように仮想画面を生成するかというお話。 プレーンから1ドットの色番号を取得して、パレットを通して描画、とか。 テキスト画面の場合は漢字ROMを経由して出力とか、反転や点滅とか。 プレーンのマスク機能、画面のプライオリティの実現とか。 描画スタートアドレスを変更してスクロール機能とか。 win32側からスクリーンバッファを貰ってくる処理とか。 winceのgapiについてとか。 解析方法について - ROMの吸い出し方あれこれ - 未知のマシンの解析手法 - ?