メモリや CPUのレジスタをエミュレートする上で重要な事項としてエンディアンネスの問題があります。
エンディアンネス(ないし、単にエンディアンともいいます)とは、メモリに数値を格納する際の、並び方のことです。
メモリの 0x1000 番地に、unsigned long 型の値 ・ 0x12345678 を格納する場合を考えてみましょう。
番地 0x1000 0x1001 0x1002 0x1003
+------+------+------+------+------+-----
メモリ
| |
| |
| |
+------+------+------+------+------+-----
|
Z80 などの場合は、以下のような形式で格納されます。この形式はリトルエンディアンと呼ばれます。
番地 0x1000 0x1001 0x1002 0x1003
+------+------+------+------+------+-----
メモリ | 0x78 | 0x56 |
0x34 | 0x12 | |
+------+------+------+------+------+-----
|
6809 などの場合は、以下のような形式で格納されます。この形式はビッグエンディアンと呼ばれます。
番地 0x1000 0x1001 0x1002 0x1003
+------+------+------+------+------+-----
メモリ | 0x12 | 0x34 |
0x56 | 0x78 | |
+------+------+------+------+------+-----
|
CPUエミュレートにてメモリアクセス処理をする場合は、このエンディアンによって処理が異なるので、ターゲットの CPU
のエンディアンがどちらかを把握しておかないといけません。
リトルエンディアンCPUで、メモリから、32ビットの Aレジスタに代入
A = memory[
0x1000 ] << 24;
A |= memory[ 0x1001 ] << 16;
A |= memory[ 0x1002 ] << 8;
A |= memory[ 0x1003 ];
|
ビッグエンディアンCPUで、メモリから、32ビットの Aレジスタに代入
A = memory[
0x1000 ];
A |=
memory[ 0x1001 ] << 8;
A |=
memory[ 0x1002 ] << 16;
A |=
memory[ 0x1003 ] << 24;
|
さらに、プラットホーム側 CPU のエンディアンも把握しておかないといけません。
以下は、Z80 という CPU にある HL レジスタの例です。このレジスタは16bit のレジスタですが、上位 8bit を
Hレジスタ、下位 8bit を Lレジスタとして使用することもできます。
16bit
+------+------+
HLレジスタ | H | L |
+------+------+
8bit 8bit
|
例えば、HL レジスタに 0xabcd という値をセットしたとすると、 H レジスタは 0xab、L レジスタは 0xcd
という値がセットされているようにも扱えます。
このレジスタをエミュレートする場合、 C言語では共用体を使います。
union{
struct{
unsigned
char L;
unsigned
char H;
} b;
unsigned
short w;
} HL;
|
こうすれば、HLレジスタは 変数名 HL.w 、Hレジスタは 変数名 HL.b.H 、 L レジスタは、 変数名 HL.b.L
で扱うことが出来ます。
ただし上記はプラットホームが Pentium などのリトルエンディアン CPU の場合です。マッキントッシュの場合、CPU は PowerPC
なのでビッグエンディアンですから、この場合は以下のような共用体を定義しないといけません。
union{
struct{
unsigned char
H; /* ←ここに注目 */
unsigned char
L; /* ←ここに注目 */
} b;
unsigned short w;
} HL;
|
ウインドウズでもマックでも動くエミュレータを作ろう、と思っている人はあらかじめこのことを考慮してプログラムを作っておきましょう。でないと、「まず
はウインドウズ用に作ってそのあとでマック用に作り直そう」としたときにハマるかもしれません。
余談:エンディアン
エンディアンの語源は、スウィフトのガリバー旅行記に出てくる、ある国の宗派の名称です。ゆで卵を食べるときに、大きい端のほうから食べる宗派がビッグエ
ンディアン、小さい端のほうから食べる宗派がリトルエンディアンで、この二つの宗派はいつも言い争っている、というようなお話です。ようは、宗派でいがみ
合っている連中はとるに足らない些細なことで常にいさかいを起こしている、という風刺なわけですが、エミュレータ作りにおいては、エンディアンはささいな
こと、とはいかないようです。
CPU の説明のところで抜けていましたが、CPU に入力される大事な信号にリセット信号があります。リセット信号が入ると、CPU
は初期化動作をします。具体的には、PC(プログラムカウンタ)に初期値をセットします。この初期値は CPU によって異なります。ほかにも CPU
によっては割り込み禁止状態になったり、SP(スタックポインタ)を初期値にセットしたりと、いろいろな初期化が行われます。
このリセット信号は、コンピュータの電源を入れたときに発生するように設計してあります。電源をいれると毎回同じように起動するのは、リセット信号のおか
げなわけです。
また、コンピュータの本体にリセットボタンがついているのもあります。このボタンを押すと、大抵はリセット信号が発生するようになっています。これを使え
ば、電源を入れなおさなくても電源を入れたのと同じ状態にできるわけです。
リセット信号は、CPU
以外の回路にも入るようで、リセットボタンを押すと周辺の回路も初期化されるみたいです。もっとも全てがそうとは限りません。例えばメモリは、リセットボ
タンを押してもその内容は変化しません。メモリは電源を切ると消えてしまうので、電源をオンしたときと、リセットボタンを押したときで、メモリの内容が異
なることになります。
大抵のエミュレータには、メニューなどからリセットをかけられるようになっています。エミュレータによっては、メニューから電源のオンやオフを指示できる
ようなものもあります。
パソコンでもゲーム機でも、複数の CPU を持つものは古くからあります。これらの CPU は 共有RAMとか、I/O
ポートを介してデータのやり取りと行うようになっています。
これをエミュレートするには、複数の CPU を同時に実行させなくてはいけません。今までの説明では、CPU の処理は 16.6ms
を1回の区切りとして行いました。これをさらに分割して、例えば 1.66ms ごとに複数の CPU
を順次切り替えてエミュレートしていくことになります。
for( i=0; i<10; i++ ){
CPU-A を 1.66ms 分、エミュレート
CPU-B を 1.66ms 分、エミュレート
CPU-C を 1.66ms 分、エミュレート
}
/*
この時点で、全てのCPUは 16.6ms 分、エミュレート完了 */
|
しかし実際のターゲットでは、これらの CPU は同時に動いて、お互いデータをやりとりしています。仮に CPU-A が CPU-B
にデータを送り、CPU-B はその1ms後に CPU-A に結果を返す、という場合、10分割だと CPU-A だけが 1.66ms
時間が進んでしまうので、CPU-B からの結果を受け取ることができません。
これを解決するには分割数を増やすしかありません。極論すれば、1クロックごとにエミュレートすれば同時に動いているのと同じになるはずです。しかし、分
割数を増やせば増やすほど、プログラムの実行速度が遅くなります。
結局のところ、各々の CPU がどのような役割をもっているのか、どういうタイミングで各々の CPU
はデータのやり取りをするのか、ということを調べた上で、もっとも効率的な分割数というのを見つけるしかないしょう。
ここでは「さすがエミュレータならでは」と思わせるような機能を紹介します。これらの機能はエミュレータに必須ではないですが、やはりエミュレータを作る
からにはこういった付加価値を付けてみたいものです。
1.
ターゲットの機能拡張
メモリを増設する、CPUのクロックをアップする、といったターゲットの機能そのものを拡張します。
他にも、ターゲットCPUとの同期を取らずにプラットホームの許す限りの最高速で動かす、とか、エミュレータ内で CPU が
BIOSを呼び出すと独自の高速な処理を呼び出す、とかの機能も考えられます。
2.
ステートセーブ
ノートパソコンなんかではサスペンドという機能がついていて、電源を切っても次回電源をオンすれば、直ちに電源を切る直前の状態に復帰してくれます。これ
は CPU
やメモリの状態をバッテリーで保持しているからですが、これと同じ機能がエミュレータにあると便利です。セーブ機能の無いゲームの途中でも、エミュレータ
の内部状態をファイルに書き出しておけば、次回エミュレータ起動時には前回の終了直前の状態にもどるわけです。
この機能は実装が結構面倒で、最初からこの機能を意識したコードを書いておかないと、あとから機能追加しようなんて場合結構痛い目にあいます。
ちなみに WindowsXP でもこれに似た機能がついているようですね・・・筆者は XP を持ってないのでよくわかりませんが。
3.
スクリーンキャプチャ
そのまんま、画面のビットマップイメージをファイルに変換する機能です。なにもそんな機能がなくても、例えば Windows だったら
PrintScreen
キーを押せばいいわけですが、せっかくだしいろんな機能を入れてみましょう。例えば、0.1秒ごとにスクリーンキャプチャして、GIFアニメにしちゃうと
かどうでしょうか?
4.
リプレイ
これは、キーボードの入力をそのままファイルに出力しておくというものです。で、次回の起動時はキーボードではなく、ファイルから入力するわけです。する
と、前回の操作した時と全く同じ内容が再現できちゃいます。これを使っアドベンチャーゲームの解法を保存しよう、なんて考えているのは筆者ぐらいでしょう
か。
5.
チート
アクションゲームなどで、プレイヤーの残りがもっとたくさんあったらなあ、と思うことはありませんか?
例えば、今プレイヤーの残りが3だったとします。まず一旦ここでメモリの内容を別の領域に覚えておきます。そしてゲームを続けると、敵にやられて残りが2
になってしまいました。ここで、今のメモリの内容と、先ほど別領域に覚えておいたメモリの内容を比較します。メモリのどこかの値が 0x03 から
0x02
に変化しているはずです。もし変化している個所がたくさんある場合は、この作業を何度も繰り返してみます。そうしているうちに、プレイヤーの残りの数を保
持しているメモリがどの番地にあるのかがわかるはずです。あとはこのメモリ内容をいじれば、プレイヤーの残りが簡単に増やせられます。このようにメモリ解
析などによりゲームのパラメータ(などのメモリ内容)を改竄することをチートと呼びます。この作業を手助けするのがチート機能です。
6.
ターゲットのデバッグ機能
ターゲットCPUを1命令ずつ実行させたり、レジスタの値を変更したり、メモリの内容を読み書きしたり、と、こういった機能を充実させればエミュレータの
開発効率もアップするかもしれません。また一部のコアなユーザには、チート機能以上に喜ばれるでしょう。
エミュレータというのは、結構処理の重たいアプリケーションです。今までに説明してきた方法は、エミュレータ上でターゲットを 1/60
秒分処理させるのに、プラットホーム上の実時間では
1/60秒かからない前提でしたが、実際にエミュレータを作ってみると、実時間で2倍も3倍もかかる、なんてことがよくあります。解決策としては、エミュ
レータを高速化するしかないわけですが、実際のところ、どんな高速化が考えられるでしょうか。
1.
表示しない
今までの方法は、エミュレータ上でターゲットを
1/60秒分処理するたびに、ターゲットの画面をプラットホーム上で表示させていました。しかし、このプラットホームでの表示というのは思ったより処理時
間がかかるものです。そこで、この表示を行わないようにするとかなり処理速度が上がります。
といっても全然表示しないわけにもいきません。例えば、2/60秒分処理するたびに表示させる、とかにするわけです。これだと秒あたり
30回の表示で済みます。たいていのエミュレータには「秒あたりのフレーム数の設定」などがあり、1秒あたり何回表示させるかを設定できるようになってい
ます。もっとも 秒 5 回、などと設定すると、処理速度は上がるものの、かなりカクカクした画面表示になることでしょう。
一番効果的なのは、実際の処理時間に応じて、表示するかどうかを決める、というものです。以下は、その実装例です。
for( ;; ){
t0 =
get_now_time(); /* 処理前の時刻を取得
*/
input_keyboard();
/* プラットホームからキー入力する */
clear_pcm_data();
/* PCMデータを初期化する */
cpu_exec( 2500000/60 ); /*
1/60秒分、CPU処理 */
make_pcm_data();
/* PCMデータの残りを作成する */
output_pcm_data();
/* PCMデータをプラットホーム側で再生 */
t1 =
get_now_time(); /* 処理後の時刻を取得
*/
lapse = t1 -
t0;
/* これが、処理にかかった時間になる */
if( lapse < 1/60秒
){ /* 実時刻で 1/60秒経過してない場合 */
display_vram(); /*
プラットホーム側にて表示 */
t1 =
get_now_time(); /* 表示処理後の時刻を取得 */
lapse = t1 - t0;
if( lapse < 1/60秒
){ /* まだ 1/60秒経過してない */
sleep(
1/60秒 - lapse ); /* 残り時間をつぶす */
}
}
}
|
つまり、ターゲット上で 1/60秒分の時間を処理しても、まだ実時間で 1/60秒経過していない場合のみ、表示関数 display_vram()
を呼び出すようにしているわけです。ただし、これだとエミュレータの処理速度が遅い場合、全く表示がされない、なんてことが起こりうるので、一定時間表示
が無い場合は強制的に表示させる、などのフォローが必要です。
2.
鳴らさない
サウンドを一切鳴らさないというのも考えられます。とはいえ、表示と違って処理速度が遅い場合のみ鳴らさないというのはダメです(そんなことをすると、途
切れ途切れになったサウンドになってしまいます)。
設定でサウンド出力の有無を切り替えられるようにしたり、サウンド出力のサンプリング周波数や量子化ビット数を小さくして処理を軽くしたり、などが考えら
れます。
3.
アルゴリズムを見直す
やはりどんなプログラムでも、アルゴリズムの見直しは高速化に有効な手段です。
では、どこを見直せばいいのでしょうか。プログラムの高速化の常套手段は、「ループの一番内側を最適化する」です。すなわち、エミュレータの場合はループ
の一番内側といえば、CPUの命令のエミュレート部分です。ここの高速化を検討してみましょう。
まず、もっともポピュラーなのは、処理をテーブルに置き換えることです。
例えば、Z80 には、レジスタのビットが立っている (1になっている)
個数が奇数か偶数かを示すフラグがありますが、これを普通にプログラムするとシフト命令と AND
命令をビット数回繰り返すことになります。しかし、予め結果をテーブルで用意しておけば、テーブル参照だけで済むわけです。
メモリリード関数を高速化するのも有効です。CPU命令の処理では、このメモリリード関数が頻繁に呼び出されます。
例えば、今以下のようなメモリを考えてみましょう。
CPUのメモリアドレス空間は 16bit(0x0000〜0xffff番地まで)
実際のターゲットのメモリ構成
0x0000 〜 0x3fff … ROM領域
0x4000 〜 0x7fff … RAM領域
0x8000 〜 0xffff … メモリは存在しない。
ただし、ここにアクセス
すると0x0000〜0x7fff と同じ内容が見える。すなわち、0x8000 番地をリードすると0x0000 番地の値が読める。0xffff
番地にライトすると、0x7fff 番地に書き込まれる。
これのメモリリード・メモリライト関数はこうなります。
extern unsigned char rom[ 0x4000 ];
extern unsigned char ram[ 0x4000 ];
unsigned char memory_read( unsigned short addr )
{
if ( addr < 0x4000 )
return rom[ addr ];
else if( addr <
0x8000 ) return ram[ addr &
0x3fff ];
else if( addr <
0xc000 ) return rom[ addr &
0x3fff ];
else
return ram[ addr & 0x3fff ];
}
voidr memory_write( unsigned short addr, unsigned char data )
{
if( ( addr & 0x7fff ) < 0x4000 )
return; /* ROM */
else{
ram[ addr & 0x3fff ] =
data;
}
}
|
これを以下のようにすっきりさせます。
extern unsigned char mem[ 0x10000
]; /* mem[0x0000]〜mem[0x3fff] と
mem[0x8000]〜mem[0xbfff]
には、同じ値がセット済み */
unsigned char memory_read( unsigned short addr )
{
return mem[ addr ];
}
voidr memory_write( unsigned short addr, unsigned char data )
{
if( ( addr &
0x7fff ) < 0x4000 )
return; /* ROM */
else{
ram[ addr & 0x3fff
] = data;
ram[(addr & 0x3fff) +
0x8000] = data;
}
}
|
メモリライト関数の処理が増えていますが、メモリライト関数よりもメモリリード関数のほうが呼び出される頻度が高いので、メモリリード関数の処理速度を優
先に考えています。
他にも、エミュレータならではの効率化があると思いますが・・・なんかいいネタはないですかね?
4.
環境依存の技をつかう
残るは環境依存、すなわち「コンパイラ依存」「CPU依存」の技を駆使する方法です。ただし、これらの技は両刃の剣でもあります。特定のコンパイラで処理
速度が速くなる方法を見つけても、そのコンパイラのバージョンが上がると役に立たなくなるかもしれません。特定の
CPUで速度が速くなる方法をみつけても、他の環境ではさっぱりかもしれません。それでもやはり高速化は魅力・・・という方は、それなりにやってみる価値
があるかもしれません。
で、どのような技があるかというと、例によって筆者はあまり知りません。ネット上で検索すればいくつかでてくるようなので、自分の環境に応じて調べてみる
のがよいでしょう。
ここでは、C言語の一般的な高速化テクニックを述べます。
・a*=2 と a<<=1 はどちらでもかまわない。
この程度の高速化は放っておいてもコンパイラが勝手にやります。
・register 変数を使う
最近のコンパイラは register 宣言を無視することも多いようです。
・switch〜case でずらずら並べるよりも、関数テーブルにする
例えば、CPUの命令処理がこれにあたります。もっとも出来のいいコンパイラなら内部
で勝手にジャンプテーブルを生成するので、コンパイラに任せておいてもいいでしょう。
・繰り返し行う演算式は、その結果を変数に置いておく
それなりに効果がでるはずです。場合によってはコンパイラが勝手にやってくれることも
ありそうです。
・ループ内で不変のコードがあれば、ループの外に追い出す
ループの中で毎回同じ演算をやってるなら、ループの外で一回やっておけばいい、という
わけです。
・for( i=0; i<10000; i++ ) よりも for(
i=10000; i; i-- ) を使う
それなりに使える、かもしれない。でも出来のいいコンパイラならある程度はやってくれ
るかも。
・小さなループであれば、展開する。
ループの判定にかかるオーバーヘッドを減らすわけです。大抵のコンパイラでは、最適化
のオプションである程度制御できるようです。
・よく使う関数は、インライン関数やマクロにする。
前述のメモリリード関数などをインライン関数にすると効果が出ます。インライン関数が
使えるかどうかはコンパイラによりますが、マクロなら必ず使えますね。
・for、while、do〜while で最も速いのを選ぶ
これはコンパイルした結果を比較して判断するしかないです。
・条件式を工夫する
例えば、if( A && B && C ) 〜
という条件式では、A → B → C の順に条件をチェックしていきます。A は 一致、Bは不一致だった場合、C
のチェックは行われません。つまり、不一致である確立の高い条件を左に置けば、処理速度のアップが望めます。これと同じことは、|| でも言えます。
・テーブルのサイズを考慮する
先にテーブル化は効果的、と書きましたが、巨大なテーブルの場合は気をつけましょう。
例えば Pentium では、キャッシュのサイズを超えるような大きなテーブルを使うと、かえって処理速度が落ちることがあります。
・配列のアクセスをポインタで行うかどうかを考慮する
昔は、配列はポインタでアクセスすると処理が速くなる、と言われていましたが、
Pentium ではなぜか配列のままアクセスするほうが速い場合がままあります。
・分岐を減らす
Pentium
などでは、高速化のために内部で分岐予測を行っています。つまり、分岐先を統計的に予測して予め処理を行っておこう、というわけです。この予測が当たれば
高速な処理ができますが、予測がはずれれば正しい分岐先の処理をやり直すわけで、かえって処理速度が落ちたりします。分岐先に偏りが無い(ランダムな)場
合は、予想が外れやすそうなので、これを避けるためにプログラムの時点で分岐を無くすようにしておきます。
・アセンブラで書く
最終手段がこれ。ただし生産性も保守性も下がるし、移植性もなくなります。下手なコー
ドを書くと、コンパイラの生成するコードよりも遅かったりします。本当に高速化が必要な数行分だけをインラインアセンブルで記述する、というのはそれなり
に有効でしょう。
「ホームページ」に戻る