あるコンピュータのエミュレータを書きたいが、どこから始めるべきか知らない人々から大量の電子メールを受け取った後にこのドキュメントを書いた。 次のテキストに含まれるすべての意見と助言は私の単独のものであり、絶対的な真実とうけとってはならない。 私は再コンパイル技術の経験があまりないので、ドキュメントは「コンパイル」に対立するものとして、いわゆる「インタープリタ」エミュレータを主としてカバーする。 それは、これらの技術についての情報を見つけることができる場所に対してポインタまたは2を持っている。
このドキュメントに何か見当たらない、または修正したい思う場合、自由に私に電子メールでコメントを送る。 しかし、flame、馬鹿、ROMイメージのリクエストには答えない。 このドキュメントの最後にあるいくつかの重要なftp/WWWアドレスは行方不明なので、ここのリンク切れを何か知っている場合、そのことを私に伝える。 このドキュメントの中にないすべてのFAQも同様である。
それが非常に複雑でも(例えばコモドールAmigaコンピュータのように)、どんなコンピュータシステムでもエミュレートできることに注意することが必要である。 しかし、そういったエミュレーションの性能は非常に低いことがある。
while(CPUIsRunning)
{
Fetch OpCode
Interpret OpCode
}
そういったコードの利点は、デバッグの容易さ、移植性、同期の容易さを含んでいる(渡されたクロックサイクルを単に数えて、このサイクル数にエミュレーションの残りを結び付けることができる)。
ただ一つの、大きく、明白な欠点は性能である。 解釈には多くのCPU時間かかる。また、適正な速度でコードを実行することをかなり速いコンピュータに要求することがある。
+ 一般に、より速いコードを生成することができる。
+ エミュレートされたCPUのレジスタを格納するために、エミュレートするCPUレジスタを直接使用できる。
+ エミュレートするCPUの類似したオペコードで、多くのオペコードをエミュレートできる。
- コードは移植性がない、つまり、異なるアーキテクチャのコンピュータで実行できない。
- コードをデバッグし、保守することが困難である。
+ 様々なコンピュータやオペレーティングシステムで動作するように、コードの移植性を高くすることができる。
+ コードをデバッグし、保守することが比較的容易である。
+ 実際のハードウェアがどうやって動作しているかの様々な仮定を迅速にテストできる。
- Cは純粋なアセンブラコードより一般に遅い。
エミュレータは全く複雑なプロジェクトであり、できるだけ速く動作するためにコードを最適化する必要があるので、選択した言語についてのよい知識は動作するエミュレータを書くための絶対に必要である。 コンピュータ・エミュレーションは明確にプログラミング言語を学習するプロジェクトの1つではない。
comp.sys.msx MSX/MSX2/MSX2+/TurboR computers comp.sys.sinclair Sinclair ZX80/ZX81/ZXSpectrum/QL comp.sys.apple2 Apple ][ etc.これらのニュースグループに記入する前に適切なFAQをチェックする。
自分のCPUエミュレーションコアを書きたい人や、どうやって作動するか知ることに興味のある人のために、Cの典型的なCPUエミュレータの骨格を下に提供する。
実際のエミュレータでは、いくつかの部分をスキップしたり、いくつかの他のものを独自に加えたいかもしれない。
Counter=InterruptPeriod;
PC=InitialPC;
for(;;)
{
OpCode=Memory[PC++];
Counter-=Cycles[OpCode];
switch(OpCode)
{
case OpCode1:
case OpCode2:
...
}
if(Counter<=0)
{
/* Check for interrupts and do other */
/* cyclic tasks here */
...
Counter+=InterruptPeriod;
if(ExitRequired) break;
}
}
最初に、CPUサイクルカウンタ(Counter)とプログラムカウンタ(PC)に対する初期値を割り当てる:
Counter=InterruptPeriod;
PC=InitialPC;
Counterは、次に疑われた割り込みに残された、CPUサイクルの数を含んでいる。
このカウンタが終了する時、必ずしも割り込みが生じるわけではないことに注意する:タイマの同期やスクリーン上のスキャンラインの更新のような他の多くの目的のためにそれを使用することができる。
後でもっと詳しく説明する。
PCは、エミュレートされたCPUがその次のオペコードを読むメモリアドレスを含んでいる。
初期値を割り当てた後、メインループを始める:
for(;;)
{
このループを次のようにも実装できることに注意する。
while(CPUIsRunning)
{
ここで、CPUIsRunningはブールの変数である。
CPUIsRunning=0のセットによりいつでもループを終了できるように、これはある長所を持つ。
不運にも、すべてのパスでこの変数をチェックすることは全く多くのCPU時間をとり、可能な場合、回避されるべきである。
さらに、このループを次のように実装しない。
while(1)
{
なぜなら、この場合、いくつかのコンパイラは1が真か偽かチェックするコードを生成するからである。
確かにコンパイラにループのすべてのパスのこの不必要な仕事を行ってほしくない。
さて、ループの中にいる場合、第1のものは次のオペコードを読み、プログラムカウンタを変更することである:
OpCode=Memory[PC++];
これがエミュレートされたメモリから読む最も単純で最も速い方法である一方、必ずしも実現可能だとは限らないことに注意する。
メモリにアクセスするより普遍的な方法
は、このドキュメントでその後カバーされる。
オペコードが取って来られた後、このオペコードのために必要になった多くのサイクルまでにCPUサイクルカウンタを減少させる:
Counter-=Cycles[OpCode];
Cycles[]テーブルは、各オペコードのためのCPUサイクルの数を含んでいるべきである。
あるオペコード(条件付きのジャンプやサブルーチン呼び出しのような)が、それらの引数に依存して、異なるサイクル数をとることがあることに注意する。
しかし、これはコード中でその後調節できる。
今、オペコードを解釈し、実行する時が来た:
switch(OpCode)
{
switch()の構築はif() ...else if()ステートメントの連続へコンパイルされるので非能率的である、というのは共通の誤解である。
少数のcaseでの構築ではこれが真実である一方、大きな構築(100-200以上のcase)は常ジャンプテーブル(それはそれらを全く効率的にする)へコンパイルするように見える。
オペコードを解釈する2つの代案の方法がある。 1番目は、関数のテーブルを作り、適切なものを呼ぶことである。 関数呼び出しのオーバーヘッドがあるので、この方法はswitch()ほど効率的でなく見える。 第2の方法は、ラベルのテーブルを作り、gotoステートメントを使用することである。 この方法はswitch()よりわずかに速い一方、それは「あらかじめ計算されたラベル」をサポートするコンパイラだけで動作する。 他のコンパイラは、ラベル・アドレスの配列を作ることができない。
オペコードの解釈と実行に成功した後、何か割り込みが必要かどうかチェックする時が来る。
この瞬間では、さらにシステムクロックと同時になる必要のあるすべてのタスクを実行できる:
if(Counter<=0)
{
/* Check for interrupts and do other hardware emulation here */
...
Counter+=InterruptPeriod;
if(ExitRequired) break;
}
これらの
周期的なタスク
は、このドキュメントでその後カバーされる。
単にCounter=InterruptPeriodを割り当てないで、Counter+=InterruptPeriodを行うことに注意する:Counterの中に負のサイクル数があることがあるので、これはサイクルを数えるのをより正確にする。
さらに、
if(ExitRequired) break;
の行を見る。
ループのすべてのパスで終了をチェックするのは高価すぎるので、カウンタが終了する場合に限り、チェックを行う:ExitRequired=1をセットした時、これはなおエミュレーションを終了するだろう、しかし、それは多くのCPU時間はかからない。
Data=Memory[Address1]; /* Read from Address1 */ Memory[Address2]=Data; /* Write to Address2 */しかし、そういった単純なメモリアクセスは、次の理由のため必ずしも可能だとは限らない:
Data=ReadMemory(Address1); /* Read from Address1 */ WriteMemory(Address2,Data); /* Write to Address2 */ページアクセス、ミラー、I/O取り扱いなどような特別の処理はすべて、これらの関数の内部で行われる。
ReadMemory()とWriteMemory()は非常に頻繁に呼ばれるので、エミュレーションで通常多くのオーバーヘッドになる。
したがって、それらはできるだけ効率的に作らなければならない。
ここに、ページがつけられたアドレス空間にアクセスするために書かれた関数の例がある:
static inline byte ReadMemory(register word Address)
{
return(MemoryPage[Address>>13][Address&0x1FFF]);
}
static inline void WriteMemory(register word Address,register byte Value)
{
MemoryPage[Address>>13][Address&0x1FFF]=Value;
}
inlineのキーワードに気づく。
それは、関数への呼び出しを行う代わりに、コードへ関数を埋め込むようにコンパイラに命じる。
コンパイラがinlineや_inlineをサポートしない場合、関数をstaticにしてみる:いくつかのコンパイラ(例えばWatcomC)は、それらをインライン展開することにより、短いstatic関数を最適化する。
さらに、ほとんどの場合、ReadMemory()はWriteMemory()より数倍頻繁に呼ばれることを覚えておく。 したがって、ReadMemory()をできるだけ短く単純にして、WriteMemory()中でほとんどのコードを実装することは価値がある。
そういったタスクをエミュレートするために、それらを適切なCPUサイクルの数に結び付けるべきである。 例えば、CPUが2.5MHzで動作することになっており、ディスプレイが50Hzのリフレッシュ周波数(PALビデオの標準)を使用する場合、VBlank割り込みは次のサイクル毎に生じなければならないだろう。
2500000/50 = 50000 CPU cycles
さて、全スクリーン(VBlankを含んで)が256のスキャンラインの高さであり、そのうちの212がディスプレイで実際に表示される(つまり、他の44がVBlankに陥る)、と仮定すれば、エミュレーションは次のサイクル毎にスキャンラインを各々リフレッシュする必要があるということが得られる。
50000/256 ~= 195 CPU cyles
その後、VBlank割り込みを生成するべきであり、その後、VBlankをやめるまで何も行ってはならない。つまり
(256-212)*50000/256 = 44*50000/256 ~= 8594 CPU cycles
各タスクのために必要とされるCPUサイクルの数を注意深く計算する。次に、割り込み周期のために最も小さな数を使用し、それに他のすべてのタスクを結び付ける(それらは、カウンタのすべての終了上で必ずしも実行してはならない)。
Watcom C++ -oneatx -zp4 -5r -fp3 GNU C++ -O3 -fomit-frame-pointer Borland C++これらのコンパイラ、または違うコンパイラの1つに対してオプションのよりよい設定を見つければ、私にそのことを知らせる。