15
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Z80エミュレータを作ってみた話し

Last updated at Posted at 2022-08-29

はじめに

Z80 は 1976 年に Zilog 社が発表した 8bit CPU なので、今(2022年)から 46 年前のオーパーツです。もうすぐ半世紀ですね。

Z80 エミュレータというと mame の Z80 エミュレータが中々使いやすいです。

ライセンス的にも結構緩い(3箇条BSD)ですし、何よりも稼働実績が多いので、再現度の高いエミュレーションが実現できると思います。

ただ、「インタフェース的に使いやすいか?」というと若干微妙感があります。

Z80 CPU のモジュールとして責務分離はされていますが、グローバルの名前空間を複数使っていたり、必要なファイルが1ヘッダ (hpp) に纏められていなかったり、同期タイミングが雑だったり、デバッグ用の機能が充実していなかったり、レガシーな C++ だったり...etc

これぐらい不満が出てくると、自分でエミュレータ作った方が手っ取り早いですね。

という訳で、2019年ごろに Z80 についての前提知識ゼロ(※)の状態から実際に作ってみました。そこそこ大変でしたが面白かったです。

私が最初に触ったコンピュータは中学校にあった PC-9801(80386時代) という現代っ子なので、実のところ Z80 のコンピュータには特段思い入れがありません。思い入れがあったのはゲームギアぐらいで、ゲームギアを触っていた当時は CPU が Z80 ということは特に気にしてませんでした。 (そんなん気にする人ほぼ居ないですよねw)

このエミュレータについては、作り始めた時にブログで少し紹介した程度で特に頑張ってプロモーションした訳でもないですが、その割に GitHub 上で 18 stars(2022.08.29時点)と、結構沢山の評価を頂いたので、せっかくだから Qiita でも紹介しておこうと思います。

基本的な使い方

組み込み方法

シングルヘッダ形式で提供しているので z80.hpp をプロジェクトに追加して #include するだけで使えます。

MMU の実装方法

z80.hpp は「CPUとしての責務のみを果たす」ことを基本コンセプトにして実装しています。

具体的には、z80.hpp には(レジスタを除く)メモリに関する実装(MMU; Memory Management Unit)を持たせないようにしています。

メモリアクセスの実装は、CPU からの要求に従って1バイトづつメモリを Read or Write するコールバックで実装する形にしています。

#include <stdio.h>
#include <string.h>
#include "z80.hpp"

// MMU; Memory Management Unit
// z80.hpp では MMU は提供していないので自前で準備する必要があります。
// 以下、64KB RAM と 256 bytes の I/O を持つシンプルな MMU の実装例です。
class MMU {
  public:
    unsigned char RAM[0x10000];
    unsigned char IO[0x100];

    MMU() {
        memset(&RAM, 0, sizeof(RAM));
        memset(&IO, 0, sizeof(IO));
    }
};

// メモリアドレス(addr)から1バイト読み込む処理
static unsigned char readByte(void* arg, unsigned short addr) {
    return ((MMU*)arg)->RAM[addr];
}

// メモリアドレス(addr) へ 1 byte の値(value)を書き込む処理
static void writeByte(void* arg, unsigned short addr, unsigned char value)
{
    ((MMU*)arg)->RAM[addr] = value;
}

// I/O ポート (port) からの入力処理
static unsigned char inPort(void* arg, unsigned char port)
{
    return ((MMU*)arg)->IO[port];
}

// I/O ポート (port) へ 値(value) を書き込む処理
static void outPort(void* arg, unsigned char port, unsigned char value) {
    ((MMU*)arg)->IO[port] = value;
}

int main(int argc, char* argv) {
    MMU mmu;
    Z80 z80(readByte, writeByte, inPort, outPort, &mmu);
    return 0;
}

なお、Z80 では 16ビット (2バイト) のメモリアクセスをする命令(ペアレジスタ命令)もありますが、内部的には 8ビット(1バイト)づつ読み書き(R/W)する形で動作しています。

同期処理の実装方法

Z80::setConsumeClockCallback でコールバックを登録することで、CPU が一定クロック動いた都度コールバックされるので、そこで周辺機器(VDP や AY-3-8910 など)の調歩同期処理を実装する想定です。

    z80.setConsumeClockCallback([](void* arg, int clock) -> void {
        printf("consumed: %dHz\n", clock);
    });

昔のコンピュータのエミュレータを作ったことがある方ならよくご存知だと思いますが、昔のコンピュータのエミュレータを開発する上で一番面倒臭い事(醍醐味?)が、この同期実装です。

最近のプログラムは、OSがプリエンプティブ方式で複数のプロセス(タスク)を同時並行的に動作させるマルチタスク環境下での実行を前提としています。しかし、Z80の頃はガチガチのシングルタスク前提であることに加え、パイプライン制御や予測分岐など CPU の命令実行時間が変動する(短くなる)最適化機能も無さそうなので、プログラムを実行した時に要する正確な時間(リアルタイム性)の保障がある点が昨今のプログラムとは大きく異なります。 (リアルタイム性が重要なロボット制御などの分野ではまだ Z80 のニーズがあるかも?)

このリアルタイム性が原因で、動作タイミングのズレがエミュレーションの再現性に与える影響が大きいです。その点がエミュレータ実装の大変さでもあり面白さでもあるのかもしれません。リアルタイムシステムのエミュレーションをする上での重要なポイントは より細かい単位時間での処理の同期 です。

例えば、Z80A のクロックレートは 3,579,545Hz なので、1Hz (279.365115ナノ秒) 単位で周辺デバイスの動作と調歩同期をするのがベターだと考えられます。

また、Z80Aを搭載しているあるゲーム機のテクニカルリファレンスを読んでいたところ、マスタークロックが 10,738,635Hz (ちょうど Z80A の 3倍)という記述が見受けられたので、その 1Hz (93.121705ナノ秒) 単位での同期がベストかもしれません。その場合、マスタークロックが3Hz進む都度、CPU を 1Hz 動かすことになります。

ただし、同期タイミングを短くすると、エミュレーションに要する CPU 負荷が増大してしまいます。

Z80の各種命令は、3〜5Hzぐらいのマシンサイクル(Mサイクル)が1つ or 複数で構成されています。

例えば、Z80 User ManualADD A, n (即値加算) の解説を見てみると、
image.png

M Cycles = 2 , T States 7 (4,3) と書かれていますが、これは2つのMサイクルで構成されていて、最初のMサイクルは 4Hz、2つ目のMサイクルは 3Hz といった意味です。

ADD A, r の実装をアルゴリズム的に解釈すると、

  1. 命令フェッチ  instruction = memory[PC++]
  2. 命令デコード
  3. 即値 (n) フェッチ n = memory[PC++]
  4. 演算 (A = A + n) してフラグ(F)を更新

という動きになります。

恐らく、もっともオーバヘッドが高いのがメモリの読み込み(フェッチ)だろうから、1フェッチが基本的に4Hz、連続する次のフェッチは3Hzに短縮されるのかな...という想定でした。(実際、マシンサイクル数はフェッチ回数と一致するらしいことがエミュレータを実装してみて分かったので)

ただし、この理解は正しくないようです。(この辺の詳しい動作の解説も結構あるのですが、ハード寄りの記述を理解するのは中々難しい)

Z80 の1命令あたりの実行速度は 4Hz〜23Hz とバラエティに富んでいます。主に インデックスレジスタ(IX, IY)を扱う命令の実行速度が遅いようです。(遅いからゲームではあまり使われませんが)

そのため、命令単位での同期だと同期タイミングとして少し長すぎるので、エミュレーションの再現性に問題が生じるのではないか?と考えられます。

そこで、Mサイクル(概ね3〜5Hz)単位での同期ができることがベターだと考えられ、コールバック方式ならシンプルに実装できそうだということで私の Z80 エミュレータはこのアーキテクチャを採用しました。 インタフェース的には1Hz単位でも大丈夫な形にしてあるので最終的には1Hz単位でも同期できるようにしたいところです。

ロボット制御システムのシミュレータなど、より正確な精度で Z80 を用いるシステムを作りたい場合、mame の Z80 エミュレータよりも、当方の Z80 エミュレータを使って頂いた方が良いと思います。その反面、動作のオーバーヘッドは mame よりも大きいので、20年ぐらい前の低スペックな PC でも快適に動作できるゲームエミュレータの開発(性能が優先されるシーン)であれば、恐らく mame の処理方式の方が有利だと考えられます。

実行方法

Z80::execute で CPU を実行できます。

    // when executing about 1234Hz
    int actualExecuteClocks = z80.execute(1234);

引数で指定したクロック数以上になった時、処理をその時点で中断してリターンします。なお、必ずしも引数で指定されたクロック数と一致する訳ではなく、正確に実行されたクロック数は戻り値で返る仕様です。 どちらかといえば、コールバックでの同期処理を前提としているのでこの辺はふわっとしたスペックにしました。

同期処理の中で Z80::requestBreak を実行することで、引数指定値に到達する前に処理を中断させることもできます。(ゲーム機のエミュレータであれば VSYNC 検出のタイミングで Z80::requestBreak しつつ、 Z80::execute には INT_MAX を指定するのが良いかもしれません)

デバッグ方法(動的ディスアセンブル)

Z80::setDebugMessage で CPU 実行時の動的なディスアセンブルを出力します。

    z80.setDebugMessage([](void* arg, const char* message) -> void {
        time_t t1 = time(NULL);
        struct tm* t2 = localtime(&t1);
        printf("%04d.%02d.%02d %02d:%02d:%02d %s\n",
               t2->tm_year + 1900, t2->tm_mon, t2->tm_mday, t2->tm_hour,
               t2->tm_min, t2->tm_sec, message);
    });

動的ディスアセンブルでは、レジスタの値や分岐命令の判定などを動的に表示します。

利用例

以下、z80.hppを使って作ってみたエミュレータ群の例を示します。

自作ゲーム機(FCS80)

Z80A + AY-3-8910 + 自作VDP という謎構成の 8bit ゲーム機

自作 VDP は 16KB のビデオメモリ(VRAM)しか使わない仕様(MSX1と同等)ですが、ファミコン以上にカラフルな16色のキャラクタが描画できたり、背景だけでなく前景も(背景とは別ネームテーブルで)描画できたり、背景と前景を別々に1ピクセル単位でスムースにスクロールできたりなど、ファミコンを大幅に上回る高性能な機能構成になっています。(PCエンジンよりはちょっと低性能ぐらいかなという想定です)

ちなみに、VRAM を 16KB にしたのはメインメモリ(64KB)の内 16KB を VRAM へのメモリマップにしつつ、RAM 16KB、プログラム 32KB (8KB単位のバンク切り替え) というメモリマップ構成にすることで、通常のメモリアクセス命令(LDx)でVRAMアクセスできるようにしたかった(実際にファミコンやMSXのプログラムを作ってみたところ、VRAMアクセス用に2回連続で I/O 命令を実行しないといけないという仕様がとても野暮ったく感じた)ためです。

要するに、自慰行為(俺が作った最強の8bitゲーム機)です。

Z80コンソール

標準入出力しか持たないシンプルなコンピュータです。

文明が崩壊しても動かせる Collapse OS が少し前に話題になりましたが、Collapse OS が想定する崩壊よりもさらに進んだレベルの崩壊が起きても恐らく動かせるであろうコンピュータです。

Collapse OS は TMS9918 のテキストモードを前提にしていますがそれすら不要で、実用的なエッセンシャル・コンピュータとしての最小構成要素にまで簡略化したアーキテクチャを設計してみたくて作りました。

基本構成をシンプルにする代わりに I/O でのプラグインを簡素に実装できるようにすることで、必要な周辺機器の開発を文明崩壊後の未来人たちに委ねていくスタイルです。

実のところ、文明崩壊云々の件は Collapse OS の記事を見てインスパイアされただけで、もともとの目的は Bash などのコンソール上で簡素に実行できる Z80 仮想コンピュータを作ってみたかったというものです。

文明崩壊というキーワードがあればバズれたのかw(こういう発想ができる人が羨ましい)

試しに作ってみた SG-1000 & MSX エミュレータ

完全に自作のアーキテクチャだと、CPUがバグっていることに気づき難いという問題があるので、実際にあるゲーム機やパソコンをエミュレーションしてみることも重要です。という訳で SG-1000, オセロマルチビジョン, MSX1 のマルチエミュレータなども作ってみました。

ちなみに、ナムコのMSX版ボスコニアンのタイトル画面のカラーテーブルの書き換えがかなりシビアなので、それを基準にしてVSYNCの同期タイミングを調整しました。どのぐらいシビアなのかというと、MSX1の実機BIOSなら正常にバグらず表示されるけど C-BIOS だとバグるレベルです。

実機BIOS C-BIOS ver 0.29
image.png image.png
ロゴが全部オレンジになる
(多分これが正常)
ロゴの下部が若干白でチラつく
(多分これはグリッチ)

現行の再現度が高いと言われる OSS の MSX エミュレータでもボスコニアンのタイミング調整が完璧なものはありませんでした。

ただし、Windows は持っていないので blueMSX(一番再現性が高いらしい?)は未確認 & 実機動作は未確認なので、実は実機でもタイトルの色が少しバグっているのがデフォルトかも...(あとMSX2とかでも多分バグるかも?)

15
15
9

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?