エミュレータ開発必勝本 〜君もエミュレータ製作者になれる!?〜 ====================================================================== はじめに ====================================================================== ---------------------------------------------------------------------- ●はじめに 私が PC-8801 エミュレータ「QUASI88」を作ろうと思い立ったのは、1997年 の秋頃です。当時はまだメジャーな PC-8801 エミュレータも存在せず (P88SR.EXEがこの頃にブレイクし始めた)、エミュレータ作成にはどのよう な知識・情報が必要かもわからないまま、手探りでエミュレータを作り始めま した。とりあえずは、fMSX のソースを解読してエミュレータのプログラミン グ技法を覚えました。そして、古い雑誌や書籍から PC-8801 のハード情報を 探し出しては、コツコツとコーディングをしていったものです。 あれから数年が経ちましたが、今でもエミュレータ製作全般に関する情報は あまり無いように思います。インターネットで検索してもほとんど見当たらな いようです。 (私の探し方が悪いだけでしょうか? エミュレータの使い方に ついての情報なら、それこそ吐いて棄てるほどあるんですけどね……) そこで僭越ながら、エミュレータを作る際に得た知識をもとに、エミュレー タの作り方を書いてみることにしました。とはいえ、私が作ったことがあるエ ミュレータは前述の QUASI88 だけですので、たいしたことが書けるわけでも ありません。本気でエミュレータを作ろうと思っている人なら当たり前すぎる 情報かもしれません。でも中にはかつての私のように、どんな情報でも知りた い、と思っている人がおられるかもしれません。そういった人の手助けにでも なってくれればと思います。 ---------------------------------------------------------------------- ●参考文献 いきなり最初から参考文献です。以下は私がインターネット上で見つけた、 エミュレータの作り方についての文献です。(これらが理解できるのでしたら、 もうこの文書は不要ですね・・・ ^^; ) How To Write a Computer Emulator http://fms.komkon.org/EMUL8/HOWTO.html Marat Fayzullin氏のページ ( http://www.komkon.org/fms/ ) にあ る、エミュレータの書き方ハウツーです。内容は英語です。 コンピュータエミュレータの書き方 日本語訳 http://www.geocities.co.jp/Playtown/2004/howto_j.htm bero氏のページ( http://www.geocities.co.jp/Playtown/2004/ ) にある、『How To Write a Computer Emulator』の和訳です。 エミュレータに関する技術情報 http://www.geocities.co.jp/SiliconValley/5604/tech/ Jay氏のページ ( http://www.geocities.co.jp/SiliconValley/5604/index.html ) にある、InfoNES作成時のエミュレータ開発資料です。 エミュレータの実装に関するメモ http://www1.interq.or.jp/~t-takeda/memo.txt 武田俊也氏のページ ( http://www1.interq.or.jp/~t-takeda/top.html )にある、エミュ レータの実装に関するメモです。武田氏はエミュレータの世界では著 名な方で、古いパソコンのエミュレータ開発に意欲的に取り組んでお られます。このメモについても今後も更新が期待できそうです。 So, you want to write an emulator? http://dsemu.org/tut/ Imran Nazar氏の、「DSemu: The Nintendo DS/GBA emulator」のペー ジ( http://www.dsemu.org/ )にある、エミュレータの書き方の紹 介のようですが、英語なのでなにが書いてあるのかよくわかりません。 「エミュレータのしくみ」某吉 著/工学社 (ISBN4-7775-1100-6 C3004) http://www.kohgakusha.co.jp/books/detail/4-7775-1100-6 エミュレータの動作について書かれた書籍です。あまり深く突っ込ん だことは書かれていないようので、エミュレータ作成に興味のある、 初心者にはなかなかよいのではないでしょうか。 ---------------------------------------------------------------------- ●免責事項 ここに書いてある事柄は、私個人の経験に基づくものです。私自身コンピュー タやエミュレータについて深い知識や造詣があるわけでもないので、思いっき り勘違いしたことを書いているかもしれません。特に、新旧問わずゲーム機の 機能(スプライトなど)や、最近のパソコン・ゲーム機の機能などについての 知識は素人レベルに近いです。 なにか間違いに気付かれましたら、ご指摘頂けたらさいわいです。 ====================================================================== 導入編・エミュレータって何? ====================================================================== ---------------------------------------------------------------------- ●エミュレータって何? エミュレータとは、あるコンピュータ(ハードウェア)の動作をそっくりそ のまま真似するプログラム(ソフトウェア)のことです。 普通、[A] というコンピュータ用に作られた (a) というソフトは、[B] と いうコンピュータでは動作しません(例えばウインドウズ用のソフトは、マッ キントッシュでは動作しません)。これは、[A] と [B] でコンピュータのハー ドウェア構造が異なるからです。 ここで [A] というコンピュータの内部動作をそっくりそのまま「真似」す るエミュレータの登場です。これを使えば (a) のソフトの動作をそっくりそ のまま真似できることになり、あたかも [B] というコンピュータで動作して いるかのように見せることが可能になるわけです。 余談:エミュレータとシミュレータの違い 世の中にはシミュレータというものもあります。これはエミュレー タとはどうちがうのでしょうか。筆者の個人的な考えは以下のような ものです。 o 仕様や規格の明確なものを、その通りに再現するのがエミュレータ o 不明確なものを、経験的・統計的に再現するのがシミュレータ 例えば飛行機の操縦を再現するのは「フライトシミュレータ」であっ て、「フライトエミュレータ」ではないわけです。 これはあくまで筆者の意見であって、ネットで調べてみると皆さんさ まざまな説を述べられています。つまり、明確な区分はない、という のが現時点での答と言えそうです。 ---------------------------------------------------------------------- ●用語の定義 エミュレータが何かがわかったところで、その他の用語を説明しておきます。 エミュレータを作るには、まずどのコンピュータの動作を真似させるのかを 決めておかないといけません。この対象となるコンピュータのことを、『ター ゲット』と呼びます。また、そのコンピュータは、実物の機械ということで、 『実機』とも呼ばれます。 エミュレータ自体はどの OS(コンピュータ)上で動かすのか、ということ も決めなくてはいけません。普通は自分が使っている OS上ということになる と思います。中には、「ウインドウズ上だけでなく、マッキントッシュ上でも 動くエミュレータが作りたい。」という意欲的な人もいることでしょう。この エミュレータ自体を動かす OS のことを、『プラットホーム』と呼びます。 エミュレータ上にて、ターゲットの動作を再現することを『エミュレートす る』、あるいは単に『エミュレート』と呼びます。エミュレータ作成の最終目 標のひとつは、ターゲット用に作られた全てのプログラムを正確にエミュレー トすることです。 ターゲット用に作られたプログラムは、さまざまな媒体に格納されています。 たとえば、ROMカートリッジ(ROMカセット)、カセットテープ、フロッピーディ スクなどです。これらは大抵の場合、ターゲット上ではそのまま扱うことがで きません。そのためエミュレータでは、これらの媒体の中身(プログラムやデー タ)をプラットホーム上で扱えるファイル形式に変換してから使用します。 このファイル形式に変換したものを、『イメージ』と呼びます。フロッピーディ スクの中身をファイル形式に変換したものであれば、「フロッピーディスクイ メージ」というように呼ぶわけです。また、このイメージを実際の媒体から抽 出してファイル形式に変換することを、『吸い出し』と呼びます。 ではここで、今までに出てきた用語を整理してみましょう。なお、これらの 用語は全てが一般的な用語として通用するのかどうかは、筆者はよく知りませ ん。なので、他人に対して使うときは気をつけるようにしょう。 エミュレータ … 特定のマシンの動作を、ソフトウェアで再現するプ ログラム(アプリケーション)のことです。 プラットホーム … エミュレータが動作するOSです。 ターゲット … エミュレートの対象となるマシンです。 エミュレート … エミュレータにてターゲットの動作を再現させるこ とです。 イメージ … ターゲット用に作られたソフトの中身を、プラット ホーム上にて扱えるファイルに変換したものです。 吸い出し … ターゲット用の媒体(メディア)から、プログラム やデータを抽出し、イメージに変換することです。 ---------------------------------------------------------------------- ●エミュレータを作るために必要なもの エミュレータを作るために必要なものは何があるでしょうか。私が必要と思 うものを以下にあげます。 (1) ターゲット(実機そのもの) 当然ですが、実機は必須です。エミュレータが正しく動作しているかの最終 判断は、実機との比較しかありません。イメージを作成する(一般には、イメー ジを吸い出すと言う)場合にも、実機が必要となります。 (2) プログラミングの知識 エミュレータもプログラムの一種である以上、エミュレータ作成にはプログ ラミングの知識が必須です。プログラム言語としては、C や C++ がよく使わ れます。中には、JAVA やアセンブラで書かれたエミュレータもあります。 (ちなみにこの文書では、C言語を使って説明していきます。なぜ C言語かと いうと、筆者の知っている言語がこれしかないからです。。。) プログラミングのスキル(技術)としては、結構高いものが要求されます。 「プログラム言語をちょっとかじったことがある」程度ではエミュレータの作 成はかなり難しいでしょう。 じゃあどの程度のスキルが必要か、といわれると返答に困るのですが、私が主 観的に考えるには、 『それなりに遊べるアクションゲームを作ったことがある。』 あたりのスキルが最低限必要ではないでしょうか。あくまで主観ですが。 ちなみに私はアクションゲームなんて一度も作ったことがありません。^^; なので、「C言語でいくつかソフトを作ったことはあるが、アクションゲーム なんて作ったことがないな〜」という方でも、大丈夫だと思います。ただ、プ ログラム言語に関してはたとえ自称であれ、中級レベル以上といえるだけの腕 前は必須でしょう。 (3) ターゲットに関する知識 一番重要なのがこれです。エミュレータの質はターゲットの情報、すなわち ターゲットのハードウェア仕様をいかに知っているかにかかっています。とは いえ、ターゲットの全てのハードウェア仕様などがメーカーから公開されてい ることは少なく、通常は自力で情報を収集することになります。 さいわいにして、最近はネット上でいろんな情報が氾濫しているので、ター ゲットがメジャーなものあれば、だれかが勝手に解析したハードウェア仕様が それなりに手に入るようです。 (4) 電子回路についての知識 エミュレータの作成には、「ターゲットが持っているハードウェアがどのよ うな動きをするのか」、を理解することが不可欠です。ではそのハードウェア が電気的にどのような仕組みになっているか、というような知識は必須でしょ うか? うーん、これに答えるのは難しいです。というのも私はこういった電 気・電子系の知識が全く無いのです。回路図? なにそれ? という感じなので すがそれでもなんとかエミュレータを作ることができました。 もちろん、メーカが公開しているハードウェアマニュアルやプログラマーズ マニュアルの類のものは、それほど穴があくほど読みました。この手の仕様書 は、プログラマ向けの情報も豊富に書かれており、電気的な知識は不要なんで すよね。もちろんハードウェア設計者向けにもいろいろと書かれていますが、 それらは全てスルーです ^^; 。 それに、私が作ったエミュレータのターゲットは、PC-8801 というパソコン です。なので、ハードウェアについては不明があれば、実機の PC-8801 上で 検証用プログラムを作っては動かし、その結果から仕様を推測する、という手 段を使うことができました。イメージデータの吸い出しも、イメージ吸い出し プログラムを実機上で作成して行いました。 では、家庭用ゲーム機とかの場合はどうでしょうか。先ほどのように、検証 用プログラムを作って実機で動作確認する、なんてことは普通はできませんよ ね? やはりゲーム機を分解して基板を調べたりするのでしょうか? だとすれ ばそれなりに電子回路とかの知識がないとツラそうです。 それにイメージを吸い出す際にも専用のイメージ吸い出し基板とかを設計・ 製作できる知識が必要な気がします。以前、とあるサイト上にて、有志が某家 庭用ゲーム機のイメージ吸い出し基板の回路図を公開しているのを見つけまし た。この回路図があればあなたも簡単にイメージの吸出しができます、という ことでしょうけれども、私には到底無理そうです。 こうしてみると、ターゲットによっては電子回路の知識がなくてもなんとか なるが、一般にはある程度の知識がないと、エミュレータ作成は難しい、とい うのが答えになりそうです。 (5) エミュレータを作る熱意 もしかすると、本当に重要なのはこれかもしれません。とある掲示板でエミュ レータの作者さんが、エミュレータ作成には最低 500時間、完成まで 2000時 間はかかる、とおっしゃっておりました。私の場合どのぐらい時間がかかった かはよく把握していませんが、エミュレータを作りたいと思ってから公開する までが 1年弱、公開後も改良を続けて大半のターゲット用ソフトがまともに動 くようなったのがさらに 1年後、といったところです。 いずれにせよ、初めてエミュレータを作るのであれば、完成までには相当に 長い期間がかかるでしょう。これだけの間、同じプログラムを作りつづけてい くわけですがら、相応のモチベーション(動機づけ)を持たなくてはなりませ ん。プログラミングのスキルが足りない場合はなおさら時間がかかるでしょう。 それでもエミュレータを作りたい、その熱意がなによりも大事だと思います。 ====================================================================== 入門編・エミュレータの仕組み ====================================================================== エミュレータは「ハードウェアの動作を真似する」ものです。つまりエミュ レータを作るは、ターゲットがどんなハードウェアから構成されているのかを 知る必要があります。 ここでは、一般的なハードウェアについての基本的な知識と、それをエミュレー タで実装するにはどうするのかについてを C言語の簡単なサンプルプログラム を交えて説明してみたいと思います。 なお、繰り返しになりますが筆者はコンピュータのハードについてあまり詳し くありません(私がハード仕様を詳しく知っているのは、PC-8801 という古い パソコンだけです)。とりあえず私の知っている限りのことをいろいろと書い てみたいと思いますが、私の知識は旧世代パソコンのあたりで止まっているの で説明も旧世代パソコンを中心としたものになります。そのため、現在のコン ピュータとはその機能も概念も思想も異なる場合がありますが、そのつもりで お願いします。 ---------------------------------------------------------------------- ●ターゲットの外部装置 まずはターゲットの外側にある装置です。ターゲットがパソコンの場合は以 下のようなものがあります。 【入力装置】 キーボード マウス ジョイステック 【出力装置】 画像出力(CRT = ブラウン管) サウンド出力 BEEP音出力 【外部記憶装置】 ROMカートリッジ(カートリッジ や ROMカセットとも呼ばれる。) カセットテープ フロッピーディスク これはは旧世代のパソコンの典型的な例ですが、まあ最近のパソコンとさほ ど変わらないですね。もう少し後の世代のパソコンになるとハードディスクや CD-ROM ドライブが搭載されますが、これらは外部記憶装置に分類されます。 ターゲットが家庭用ゲーム機の場合も似たような感じです。 【入力装置】 ジョイステック 【出力装置】 画像出力(テレビ) サウンド出力 【外部記憶装置】 ROMカートリッジ これも後の世代になると、外部記憶装置に CD-ROM ドライブなどが登場して きます。 さてこれらの外部装置ですが、これらは基本的にプラットホームが持つ機能 と置き換えることが可能です。以下に例をあげましょう。 キーボードからの入力であれば、プラットホーム側のキーボード入力をその ままエミュレータで処理します。ただし、ターゲットのキーボードには F15 キーがあるのに、プラットホーム側のキーボードにはそんなキーがない、とい うような場合は何らかの工夫が必要になります。 画像の出力であれば、ターゲットの CRT に表示されるものと全く同じ画像 を、プラットホームの画面上に表示すればいいことになります。これには プ ラットホームの機能 - たとえばウインドウの描画機能 - などを使うことにな ります フロッピーディスクの読み書きであれば、これらは「イメージ」としてファ イルとして扱うことになりますので、プラットホーム上にてこのファイルを操 作(読み書き)することになります。 (これらの実現方法は、プラットホームに依存することになりますので、この 章では説明しません。また別の機会にしましょう。) ---------------------------------------------------------------------- ●ターゲットの内部構造 次はターゲットの内部、すなわちハードウェア構成そのものです。 (・・・なんかこんなことをいろいろ書いていると、小学生のころに読んだマ イコンの入門書を思い出してきた・・・) メモリ +-------+ +------------------+ クロック --->| | | +-----+ +-----+ | 信号 | |<======>| | ROM | | RAM | | | | | +-----+ +-----+ | | CPU | +------------------+ | | 割り込み --->| | +------------------+ 信号 | |<======>| I/O ポート | +-------+ +------------------+ エミュレータ作成の際には、基本的にはこれらの構成物すべてをエミュレート する必要があります。 ---------------------------------------------------------------------- ●メモリ 最初は説明の簡単な、メモリについてです。 メモリとは記憶回路のことで、1バイトの値をたくさん記憶しておくことが できます。記憶できる容量のことをメモリ容量といい、これは使われているメ モリの種類により異なります。 C言語でメモリをエミュレートする場合、char 型の一次元配列で表すのが一番 お手軽です。以下は、8KB つまり 8192バイトのメモリを C言語で表した例で す。 char memory[ 0x2000 ]; メモリには 1バイトごとに通し番号がふってあり、この番号のことを「番地」 あるいは「アドレス」とよびます。番地は 0 から始まりますので、ちょうど C言語の配列の表現と同じになります。 以下は、メモリの 0x1200番地に 値 0x28 を書き込む、という動作を C言語で 表した例です。 memory[ 0x1200 ] = 0x28; メモリに記憶される内容は、プログラム(命令)、データ、ワーク(変数)の いずれかになりますが、メモリ上ではこれらの区別はありません。 メモリの種類 ============ メモリの種類は、大きく分けると ROM と RAM があります。 ROM は Read Only Memory の略で、読み出し専用のメモリです。ここにはプ ログラムや、データなどの不変の内容が記憶されています。ROM は電源を切っ てもその内容は消えません。 ROM を C言語で表現する場合、以下のように ROMの内容を直接ハードコーディ ングする方法が考えられます。 const char rom[ 0x1000 ] = { /* 望ましくない実装 */ 0x00, 0x01, 0x02, … }; しかし、ROMのデータは大抵の場合、そのデータを作成したメーカなりが著作 権を持っています。つまり、このように直接エミュレータに組み込むことは違 法となります。なので、普通はこのデータ部分を「ROMイメージデータ」とし てユーザに用意してもらい、エミュレータ側ではそのファイルを読みこんで、 配列にセットするというようにします。 RAM は Random Access memory の略で、読み書き可能なメモリです。ここに はワーク(変数)などの、内容の変化するものが記憶されます。もちろん、 RAMにプログラムやデータなども記憶できます。RAM は電源を切ると、その内 容が失われてしまいます。 RAM を C言語で表現する方法は、いままでにでてきた通りです。 char ram[ 0x1000 ]; 当然、malloc でメモリを確保するのもありです。 セーブ用のメモリ ================ ROM はデータが書けないし、RAM はデータを書けるけど電源を切ると消えて しまう、ではゲームのデータなどを途中でセーブするにはどうすればいいので しょう? 家庭用ゲーム機では、バックアップRAM やフラッシュROM を使うこ とで解決しています。 バックアップRAM は電源を切っても内容が消えないRAMです。これは電池で RAM に電源を供給して内容が消えないようにしてあるだけなので、電池が切れ れば RAM の内容は消えます(確かそうだっと思います)。 フラッシュROM は、書き込みが可能な ROM です。ROM なので書き込み後に 電源を切っても内容は消えません。でも RAM とは違って 1バイト単位での書 き込みは出来ず、4KB とか 32KB とかの比較的大きな単位(これはフラッシュ ROM の種類によって異なります)でしか書き込みすることになります。そのた め、書き込む時は特殊な手順を踏む必要があります。また、書き込み回数には 上限(数万〜数十万回)があり、それを超えると書き込みができなくなります。 昔の家庭用ゲーム機では、ROMカートリッジ内部に バックアップRAM があっ て、そこにセーブするものが多かったようですが、最近のゲーム機では別売り の フラッシュROM にセーブするようになってるようです(家庭用ゲーム機は 持ってないのでよく知らないですけど、確かそうですよね?)。 これらのメモリをエミュレートする場合も、通常の ROM や RAM の場合と同 様で構わないわけですが、エミュレータの終了時にその内容をファイルに書き 出すなどして、データを保存するようにしておく必要があります。 ちなみに、ここで挙げた以外にも何種類か 消えないRAM や 書き込み可能な ROM がありますので、ターゲットがそういうメモリを使用している場合は調べ ておきましょう。 ---------------------------------------------------------------------- ● CPU CPU は中央演算装置の略で、メーカーによっては MPU などと呼ばれること もあります。ターゲットによっては、複数のCPUを有するものもあります。 CPUは内部にレジスタと呼ばれる、一時的な記憶エリアが数本〜数十本、あり ます(一般的にレジスタの数は、一本、二本と数えるようです)。 汎用レジスタ ………… データ処理やアドレス計算に使用します。大抵の CPU は数本の汎用レジスタを持ちます。CPU によっ ては30本以上の汎用レジスタを持つものもあります。 プログラムカウンタ … CPU がメモリのどの番地からプログラム(命令)を 読み出すのかがセットされているレジスタです。PC と略します。CPUによって名称はまちまちで、IP (インストラクションポインタ)と呼ぶ CPU もあ ります。 ステータスレジスタ … CPU の状態を保持するレジスタです。これも CPU によってコンディションレジスタ、フラグレジスタ などとさまざまな名称があります。 スタックポインタ …… サブルーチン(関数)を呼び出した際に、その戻り 先を格納するメモリ領域のアドレスがセットされて いるレジスタです。CPU によっては存在しなかった り、汎用レジスタで代用してたりします。 その他 ………………… その CPU 固有のさまざまなレジスタが存在します。 CPU の基本的な動作は、以下のようになります。 1. メモリからプログラム(命令)を読み出す。 2. 読み出した命令が何であるかを解釈する。 3. その命令どおりに実行する。 1. の動作はフェッチ(fetch)と呼ばれます。ここでは PC (プログラムカウ ンタ) が指し示す番地のメモリから命令を読み出します。 2. の動作はデコー ド(decode)と呼ばれます。そして 3. で実際に命令を実行(execute)しま す。PC はこれらの処理の間に(おそらくは 1. の動作時に)インクリメント されます。これにより、PC は次の命令がある番地を指すようになります。そ して再び 1. の動作に戻ります。 CPU が実行できる命令には、通常以下のようなものがあります。 ・メモリからレジスタに、値をコピーする。 ・レジスタからメモリに、値をコピーする。 ・レジスタの値を演算(加減乗除、ビット演算)する。 ・分岐する。(PCに新たな値をセットする。) 基本的には CPU は、メモリの値をいったんレジスタにコピーして、その後レ ジスタの値を演算し、その結果をメモリにコピーする、という動作を繰り返し ます( CPU によってはメモリ上の値を直接演算したりできます)。そして必 要ならば演算の結果に応じて、命令の実行順序を変える(分岐する)わけです。 CPU はコンピュータの頭脳部などと呼ばれますが、こうしてみると大したこと は出来ないように感じます。でもこれをエミュレートしようとするとなかなか 大変なのですよ。まずは、CPU が処理できる命令を一通り把握しておく必要が あります。そのためには CPU のアセンブラぐらいはマスターしておくほうが よいでしょう。 余談:キャッシュ、パイプライン実行、予測分岐 … 比較的新しい CPU の場合は高速化のために、キャッシュ、パイプ ライン実行、予測分岐などのさまざまな機能があります。筆者はこれ らの機能をよく知らないので説明できないのですが、高速化のためだ けの機能であれば、エミュレートしなくても特に支障はないんでしょ うかね? だれか詳しく説明してくれないかなぁ。 CPU のエミュレート ================== では、CPUをエミュレートする方法を C言語で簡単に書いてみます。 まずはレジスタですが、レジスタのサイズや数は CPU により決まっています。 以下は、8bit のレジスタを 2本と 16bitの PC を定義する例です。 unsigned char A; /* Aレジスタ */ unsigned char B; /* Bレジスタ */ unsigned short PC; /* PC */ そして、CPUの処理部です。 for( ;; ){ unsigned short addr; unsigned char opcode; opcode = memory[ PC ++ ]; /* メモリから命令を読みだす */ /* 同時に PC も次の番地へ */ switch( opcode ){ /* 命令により、処理を分岐 */ case 0x00: /* 実際の処理部分は、ここ */ /* ……… */ break; case 0x01: /* ……… */ break; /* 全ての命令に対して、処理を記述する */ } } CPU の実際の動作である、fetch → decode → execute をえんえんと繰り返 しているのがわかると思います。ここでは、命令は全て 1バイトでできている ように書いていますが、CPU によっては、命令が複数のバイトで出来ているも のもあります。その場合は、メモリから命令を読み出す個所が複数必要になり ます。 肝心の各命令の処理は、case文 の中で行うわけですが、以下にいくつか例を 挙げて見ます。 case XXXX: /* A レジスタをクリアする */ A = 0x00; break; case YYYY: /* B = A and B を演算 */ B &= A; break; case ZZZZ: /* B レジスタに 値を代入 */ B = memory[ PC ++ ]; /* 代入する値は、 */ break; /* 命令に続けて */ /* セットされている */ case UUUU: /* 分岐する */ addr = memory[ PC ++ ]; /* 分岐先の番地は、 */ addr <<= 8; /* 命令に続けて */ addr |= memory[ PC ++ ]; /* セットされている */ PC = addr; break; case VVVV: /* Aレジスタの値をメモリに代入 */ addr = memory[ PC ++ ]; /* 代入先の番地は、 */ addr <<= 8; /* 命令に続けて */ addr |= memory[ PC ++ ]; /* セットされている */ memory[ addr ] = A; break; ここで最後の命令を見てください。これはメモリに値を書き込むという命令の 処理ですが、仮にこのメモリが、以下のような構成だったとしましょう。 メモリ の 0x0000番地 〜 0x0fff番地 は ROMで読み出しのみ可能 メモリ の 0x1000番地 〜 0x1fff番地 は RAMで読み書き可能 メモリ の 0x2000番地 以降は、存在しない。 そして命令の処理の結果、0x400 番地に書き込むことになったとすれば、ROM に書き込むことになってしまいます。実機では ROM に書き込んでもメモリの 内容は変化しないので、エミュレートする場合もそうしないといけません。 これを解決するためには、メモリのリード・ライトを行う関数を用意し、メモ リアクセスの際は配列を直接読み書きしないようにします。 /* * メモリリード関数 */ unsigned char memory_read( unsigned short addr ) { if( addr < 0x2000 ){ return memory[ addr ]; }else{ return 0xff; /* 存在しないので、固定値を返す */ } } /* * メモリライト関数 */ void memory_write( unsigned short addr, unsigned char data ) { if( addr < 0x1000 ){ /* ROM なので、ライト不可! よって無視する */ }else if( addr < 0x2000 ){ memory[ addr ] = data; }else{ /* 存在しないので、ライトは無意味! */ } } ---------------------------------------------------------------------- ● I/O ポート CPU とメモリがあれば、コンピュータとしての動作が可能になりますが、こ れだけでは動作の結果を外部 - すなわち人間に伝えることができません。そ こで登場するのが外部とやりとりするための窓口、I/O ポートです。 I/O とは Input/Output の略で、I/O ポートと外部装置は直接ないし間接に 接続されてます。そして、I/O ポートにアクセスすることで、外部装置を制御 することが出来ます。 通常 I/O ポートは複数個存在し、その各々を区別するために番地が割り振っ ています。そして、入力は 「I/O ポートの 0x10番地から 1バイト読み込む」、 出力は 「I/O ポートの 0x20番地に 1バイト書き出す」、というような動作で 行います。このように書くとメモリへの読み書きとそっくりなのがわかるでしょ う。でも、メモリへの読み書きが単なるデータ(値)の読み書きなのに対し、 I/O ポートへの読み書き(入出力)は、外部装置とのやりとりと意味します。 以下に例を示します。 o IN ポートにアクセスする例 ポート 0x10 番地から値をリードすると、ジョイステックの状態が取 得できる。リードした値の bit 0 が 1 の場合、Aボタンが押されて いる。 o OUT ポートにアクセスする例 ポート 0x20番地の bit 0 に 値 1 をライトすると、BEEP音が鳴る。 値 0 をライトすると BEEP音が止まる。 OUT ポートから値を読み出すとどうなるかは、ハードウェアの設計によりま す。上の OUT ポートの例で、ポート 0x20 番地からリードするとどうなるで しょうか。ハードによってはちゃんと直前にライトした値がリードできるよう に設計してあるかも知れません。あるいは、不定ないし固定の値がリードでき るかもしれません。もしかすると、ポート 0x20 番地をリードするとマウスの 状態が取得できるように設計してあるかもしれません。このように同じ番地を アクセスしてもリードとライトで動作が異なるのが、メモリと違う点です。 余談:I/Oポートに接続されるメモリ メモリの項で、セーブ用に使われるメモリとしてバックアップRAM やフラッシュROM を紹介しましたが、他にもシリアルEEPROM と呼ば れる読み書き可能な ROM があります。これはバックアップRAM やフ ラッシュROM とは違い、CPU から直接読み書きすることはできません。 ではどのようにしてアクセスするのかというと、I/O ポートを介して 1 ビットづつ読み書きするのです。シリアルEEPROM は設定の保存用 などに広く使われているそうです。 I/O ポートのエミュレート ======================== I/O ポートのエミュレートですが、これはポートのどの番地をアクセスした かによって処理が変わってきます。そこでこれらを処理する専用の関数を作成 します。 /* * I/O ポート addr 番地から リードした時の処理 */ unsigned char port_in( unsigned short addr ) { unsigned char status; switch( addr ){ /* 各番地ごとに、処理を分岐 */ case 0: /* ……… */ return 0x00; case 0x10: status = read_joystick(); /* ジョイスティック状態取得 */ return status; /* 全ての入力ポートに対して、処理を記述する */ } } /* * I/O ポート addr 番地に、data をライトした時の処理 */ void port_out( unsigned short addr, unsigned char data ) { switch( addr ){ /* 各番地ごとに、処理を分岐 */ case 0: /* ……… */ break; case 0x20: if( ( data & 1 ) == 0 ){ beep_on(); /* ブザー ON */ }else{ beep_off(); /* ブザー OFF */ } break; /* 全ての出力ポートに対して、処理を記述する */ } } あとは、CPU処理時にフェッチした命令が入力・出力命令であれば、これらの 関数を呼び出すようにします。 ---------------------------------------------------------------------- ● メモリマップド I/O CPU によっては、専用の I/O ポートを持たないものがあります。こういう CPU は、メモリマップド I/O と呼ばれる方式を採用しており、メモリもI/Oポー トも一緒くたに扱えるようになっています。 メモリマップド I/O の例 メモリの 0x0000番地 〜 0x0fff番地 は ROMで読み出しのみ可能 メモリの 0x1000番地 〜 0x1fff番地 は RAMで読み書き可能 メモリの 0x2010番地 から値をリードすると、ジョイスティックの 状態が取得できる メモリの 0x2020番地 に 値 1 をライトすると、BEEP音が鳴る メモリマップド I/O のエミュレート ================================= メモリマップド I/O をエミュレートするには、前に説明したメモリリード・ ライト関数と I/Oリード・ライト関数を合体させればできあがりです。 /* * メモリリード関数 */ unsigned char memory_read( unsigned short addr ) { unsigned char status; if( addr < 0x2000 ){ /**** メモリ ****/ return memory[ addr ]; }else if( addr == 0x2010 ){ /**** I/O ポート ****/ status = read_joystick(); /* ジョイスティック状態取得 */ return status; }else /* ………… */ { /* 全てのメモリ・入力ポートに対して、処理を記述する */ } } /* * メモリライト関数 */ void memory_write( unsigned short addr, unsigned char data ) { if( addr < 0x1000 ){ /**** メモリ(ROM) ****/ /* ROM なので、ライト不可 */ }else if( addr < 0x2000 ){ /**** メモリ(RAM) ****/ memory[ addr ] = data; }else if( addr == 0x2020 ){ /**** I/Oポート ****/ if( ( data & 1 ) == 0 ){ beep_on(); /* ブザー ON */ }else{ beep_off(); /* ブザー OFF */ } }else /* ………… */ { /* 全てのメモリ・出力ポートに対して、処理を記述する */ } } ---------------------------------------------------------------------- ●クロックと実行速度 クロックとは ============ CPU に入力される信号のひとつに、クロックと呼ばれるものがあります。こ のクロックは、一定の周期で規則正しく High と Low を繰り返します。この 周期はクロック周波数と呼ばれます。例えば クロック周波数が 2.5MHz の場 合は、この High / Low の信号が 1 秒間に 2500000回、CPUに入力されること になります。 High +------+ +------+ | | | | | | | | | | | | Low -------+ +------+ +---- →時間の流れ |<----------->| この 1周期(時間)のことを、 1クロックという単位で呼びます。 CPU は、クロックの High / Low にあわせて動作します。いわばクロックは 指揮者のようなものです。そして CPU はこの指揮者に合わせて楽器を奏でる 演奏者です。クロックが速ければ CPU の動作速度は速く、クロックが遅けれ ば CPU の動作速度は遅くなります。 例として、Z80 という CPUの動作を見てみます。以下は、Bレジスタの値を A レジスタにコピーする命令です。 LD A,B Z80は、クロック4周期分の速度 (つまり 4クロック) にあわせてこの命令を実 行します。入力されるクロックが 2.5MHz だとすると、1クロックは、2.5MHz = 0.4μ秒、よって、この命令を実行するには 4クロック、すなわち 1.6μ秒 の時間がかかることになります。 Z80 の場合は、命令の種類によって実行にかかるクロック数は異なりますが、 CPU によっては、どの命令でも実行にかかるクロック数は同じ、というものも あります。こういった詳細は、CPU のメーカーが公開しているマニュアルに書 かれているはずです。 命令によってはメモリに読み書きすることがありますが、メモリの読み書き 速度はメモリの性能によって決まるため、それにそれに応じて余分にクロック 数を必要とすることがあります(これはメモリウェイトと呼ばれます)。 実行速度をエミュレートする ========================== エミュレータでは、この処理にかかるクロック数の計算は結構重要です。 いま、上記の CPU を 1秒間分エミュレートするとします。つまり 2500000 クロック数、命令を実行するわけです。では、この処理が終わった時点でプラッ トホーム上の時間(つまり、実際の時間)はどのぐらい経過しているでしょう か? プラットホーム上 での実際の 1秒 |<-------------------------------------------->| 1秒 エミュレータで 2500000クロック |<--------------------->| 実行するのに 0.5秒 要した時間 あまった時間 |<-------------------->| 0.5秒 仮に、プラットホーム上の時間で 0.5秒しか経過していなければ、実機で 1秒 かかる処理を、エミュレータは 0.5 秒で処理できたことになります。 余った 0.5 秒の間エミュレータの動作を停止させれば、あたかも実機と同じ 速さでエミュレートしているかのように見えます。もちろん、この余った 0.5 秒の間もエミュレータを動作させれば、実機の倍の速さでエミュレートしてい るようにみえるでしょう。 一方、プラットホーム上の時間で、2秒経過していた場合、このエミュレータ は実機の半分の速度でしかエミュレートできないことになります。 以下に、CPU を 1秒間、エミュレートする例をC言語で書いてみしょう。 まず、実行クロック数をカウントする変数を用意します。 int passed_clock = 0; あとは、CPUの処理時に各命令に応じたクロック数を加算し、1秒間分(ここ では、2500000クロック分)これを繰り返します。 for( ;; ){ opcode = memory_read( PC ++ ); switch( opcode ){ case 0x00: /* 〜 */ /* 命令に応じて、 */ passed_clock += 10; /* クロック数を加算 */ break; /* : : : */ } if( passed_clock >= 2500000 ) /* 1秒間分、処理を */ break; /* 実行したら抜ける */ } ちなみにクロック数を加算する部分は、テーブルを持っておくほうがスマート ですね。 for( ;; ){ opcode = memory_read( PC ++ ); passed_clock += clock_table[ opcode ]; /* テーブルで! */ /* ……… */ } では次に、エミュレータでの1秒と、プラットホーム(実時間)での 1秒を合 わせる処理の例を挙げてみます。 t0 = get_now_time(); /* 処理前の時刻を取得 */ passed_clock = 0; /* ここの部分は */ for( ;; ){ /* 上と同じ */ /* : */ /* */ /* : */ /* */ /* : */ /* */ if( passed_clock >= 2500000 ) /* */ break; /* */ } /* */ t1 = get_now_time(); /* 処理後の時刻を取得 */ lapse = t1 - t0; /* これが処理にかかった時間になる */ if( lapse < 1秒 ){ /* 実時刻で 1秒経過してない場合 */ sleep( 1秒 - lapse ); /* なにもせずに、残り時間をつぶす */ } ここで、現在時刻を取得する関数 get_now_time() と、時間をつぶす関数 sleep() がでてきますが、これらの関数はプラットホーム側にて用意されてい る関数を使うことになります。(この部分は、標準の C言語だけでは実現でき ないのです) 余談:clock() 関数 C言語の標準関数に、clock() というプログラムが起動してから現 在のまでの経過時間を返す関数があります。これを使えば、処理時間 がわかるのでは、と考える人もいると思いますが、悲しいかな、この 関数はかなり精度が低いのです。昔のパソコン用のC言語では、秒単 位の時間を返すものもありました。(この clock() が返す時間単位 は、time.h の CLOCKS_PER_SEC にて定義されています。) それに、この関数はCPU時間を返すことになっており、実時間ではな いのです。つまりマルチタスクな OSにて、プログラムA を 1秒、プ ログラムB を 2秒処理して、実時間で 3秒経過した場合、プログラム A にて clock() 関数を呼び出すと、1秒経過、という結果が返ってく ることになります。これだと、実時間とエミュレータでの時間を合わ せることができません。 ---------------------------------------------------------------------- ●割り込み 割り込みとは ============ CPU に入力される重要な信号のひとつが、割り込み信号です。これは、割り 込みの発生を CPU に伝えるための信号ですが、割り込みとは何のことでしょ うか? 例えば、あなたが会社で仕事をしていたとしましょう。一生懸命仕事をして いると電話が鳴りました。あなたは仕事をしている手をいったん止めて、電話 に出ます。電話の相手は明日の打ち合わせの時間を聞いてきたのであなたは手 短に答え、電話を切ります。そして、再び先ほどの仕事を再開します。 割り込みを例え話で説明すると、こんな感じでしょうか。ここでは電話のベル の音をきっかけに仕事を中断しましたが、この場合電話のベルが割り込み信号 ということになります。 CPU は普段は PC(プログラムカウンタ)の指す命令を順次実行しているわ けですが、割り込みが発生した場合、ただちにあらかじめ設定された番地から プログラムを実行します。そして、その処理が終わると割り込み発生の直前の 命令から、なにくわぬ顔をして命令の実行を再開します。 たとえば、キーボードを押すと割り込みが発生するようなハードウェアがあっ たとします。通常、CPU はプログラムを順に実行していきますが、キーボード が押されたら(割り込みが発生したら)、直ちにキーボード処理ルーチンに分 岐します。そして処理が終わったら、何事も無かったかのように CPU は先ほ どのプログラムの続きを実行します。もしこの割り込みがないと、CPU は定期 的にキーボードが押されたかどうかをチェックしなくてはいけなくなります。 他の代表的な割り込みとしては、タイマー割り込みがあります。例えば、 0.1秒ごとに I/O ポートにデータを出力しなくてはいけない場合を考えてみま しょう(こういった周期的な処理は、サウンドの出力などでよくみかけます)。 この場合、 0.1 秒ごとに割り込みが発生するようにタイマーを設定します。 そして、タイマー割り込み発生時の処理ルーチンの方に、「I/O ポートにデー タを出力するプログラム」を書いておきます。こうしておけば、あとはいつも どおりに CPU を実行させていれば、0.1 秒ごとにタイマー割り込みが発生し、 そして割り込み処理ルーチンにて I/O ポートにデータが出力されます。 このように大抵の CPU では、複数の種類の割り込みを受け付けることが出 来て、その割り込みごとに個別の処理ルーチンを実行できるようになっていま す。(中には、一種類の割り込みしか受け付けられないようなCPUもありますが) 外部割り込みと内部割り込み ========================== 外部割り込みとは、CPU の外部から発生する割り込みで、その種類はハード ウェアの設計によります。代表的な割り込みとしては、通信の割り込み、CRT の割り込み、フロッピーディスクからの割り込み、キーボードからの割り込み、 タイマーからの割り込み、などがあります。 内部割り込みは、CPU 内部にて発生する割り込みで、その種類は CPU によ り異なります。例えば、0 で割り算をした、ありえない命令をフェッチした、 メモリ境界をまたがってアクセスした(*)、意図的に割り込み処理ルーチンに 分岐する命令を実行した、などです。 内部割り込みは、CPU の命令の実行によって発生する割り込みであるため、 エミュレータではこれは命令の実行処理の一部として処理することができます。 以降の文章では、割り込みとは外部割り込みをのことを指すこととします。 (*)メモリ境界をまたがってアクセスした メモリの奇数番地から、short 型で値を読み書きしようとし た場合などがこれにあたります。Pentium などのインテルの CPU は問題なく読み書きできるようですが、ほとんどの CPU では異常動作するか、内部割り込みが発生します。 余談:周辺機器を内蔵したCPU CPU によっては、タイマーやシリアル通信を内蔵したものがありま す。これらも割り込みを発生するのですが、これは内部割り込みとは 呼ばないようです。(内蔵周辺割り込みなど、その CPU固有の呼び方 をするようです) マスカブル割り込みとノンマスカブル割り込み(NMI) =============================================== マスカブル割り込みとは、その発生を抑制できる割り込みのことです。例え ば、一時的にキーボードの割り込みを受け付けたくない、という場合、キーボー ド割り込みを「マスク」すれば、この割り込みは発生しなくなります。 ノンマスカブル割り込み(NMI)とは、「マスク」することの出来ない割り 込みです。組み込み機器などでは、停電検知処理にノンマスカブル割り込みが 使われるということを聞いたことがありますが、パソコンや家庭用ゲーム機で はどういった用途でつかわれるのかよく知りません。とあるゲーム機では、ポー ズ機能に NMI が使われているという噂ですが、複数の割り込みを扱いにくい (または使えない) CPU を使ったパソコン・ゲーム機などでは、単に通常の 割り込みの一種として使われていることもありそうです。 割り込み禁止状態 ================ CPU には、割り込み許可状態と、割り込み禁止状態という二つの状態があり ます。割り込み許可状態では、発生した割り込みを受け付けますが、割り込み 禁止状態では、割り込みが発生しても無視します(割り込み処理ルーチンへ分 岐しません)。 なお、割り込み禁止状態であっても、ノンマスカブル割り込みや、内部割り込 みは無条件で受け付けます。 割り込みの優先順位 ================== 割り込みには優先順位があります。複数の割り込みが同時に発生した場合、 まず一番優先順位の高い割り込みの処理ルーチンが呼び出されます。この処理 が終わった後、次に優先順位の高い割り込みの処理ルーチンが呼び出されます。 このようにして、次々と処理されます。 割り込み処理ルーチンの処理の最中に、さらに優先順位の高い割り込みが発 生した場合、その割り込み処理ルーチンが呼び出されます。この処理が終わる と、先ほどの割り込み処理ルーチンの続きを実行します。このように割り込み 処理中にさらに優先の高い割り込みが発生するすることを多重割り込みと呼び ます。 割り込みの優先度の決定や、多重割り込みのロジックは CPU やハードウェ アによって異なります。大抵は割り込みコントローラと呼ばれるチップが、ど の割り込みを CPU に伝えるか、など制御しているようです。 割り込みのエミュレート ====================== 割り込みのエミュレートで問題となるのは、いつ割り込みを発生させるか、 ということです。 大抵の割り込みは周期的な割り込みです。たとえば、CRT からの割り込みは 60Hz 毎に発生します。シリアル通信割り込みは、通信速度 19200bps・1バイ トあたり 10bit の場合、1秒間に1920回発生します。タイマー割り込みの場合 は設定した時間が経過したら発生します。 そこで、これらの割り込みがいつ(何μ秒後に)発生するかを計算し、これを CPU のクロック数で置き換えます。あとは CPU をエミュレートして実行クロッ ク数を積算していき、規定の時間に達したら割り込みフラグをセットします。 割り込みフラグがセットされたら、CPU は割り込み処理ルーチンに分岐するよ うにします。 これで周期的な割り込みは実現できます。あとは、キーボード割り込みのよう な、不定期に発生する割り込みですね・・・。どうしましょうか・・・ とりあえず、周期的な割り込みのエミュレートを C言語で書いてみましょう。 割り込みが 50ms 周期で発生するとします。2.5MHz のクロック周波数で駆動 する CPU ならば、 0.05 * 2500000 = 125000 クロック分、命令を実行できる はずです。 int INT_active = FALSE; /* 割込発生時に、真になるフラグ */ int intr_clock = 125000; /* 割込発生の周期 */ for( ;; ){ for( ;; ){ if( INT_active ){ /* 割り込み発生してたら */ interrupt_ack(); /* 割り込み応答をする */ } opcode = memory_read( PC ++ ); /* ここは同じ */ switch( opcode ){ /* */ case 0x00: /* */ /* 〜 */ /* */ passed_clock += 10; /* */ break; /* */ /* */ /* … */ /* */ } /* */ if( passed_clock >= intr_clock ) /* 割込発生周期に */ break; /* なったら抜ける */ } occer_interrupt(); /* ここで割り込み発生 */ } 下の方にある、 occer_interrupt() 関数で、割り込み発生の処理をします。 具体的には、この関数の中で INT_active を 真にします。ただし、割り込み マスクの設定がされている場合は割り込みを発生させるわけには行かないので、 INT_active は真にしてはいけません。 上の方にある、 interrupt_ack() 関数は、割り込みを受け付ける関数です。 割り込みを受け付けると、 PC(プログラムカウンタ)に、割り込み処理の先 頭番地をセットします。ただし、CPU が割り込み禁止状態にあるときは、 INT_active が真であっても割り込みを受け付けてはいけません。 (なお、割り込み受け付け時には、PCをセットする以外にも、もともとの PC の値をスタックにコピーしたり、ステータスレジスタの値を変更したり、など の処理をする場合がありますが、これらの詳細は、CPUにより異なります。) 周期的な割り込みが複数ある場合、例えば、ひとつは 50ms 周期、もうひとつ は 333ms 周期、もうひとつは 15ms周期、なんて場合は、どの割り込みが最初 に発生するかを計算して、それに応じて CPU の実行クロック数を決めなけれ ばいけません。さらにこれらの周期が CPU の命令によって変更された場合は、 これまた計算をしなおすことになります。 このことを考慮すると、CPU エミュレートのプログラムは、以下のように実行 するクロック数を指定できるほうが便利そうです。 void cpu_exec( int exec_clock ) { for( ;; ){ if( INT_active ){ interrupt_ack(); } opcode = memory_read( PC ++ ); switch( opcode ){ case 0x00: /* 〜 */ passed_clock += 10; break; /* … */ } if( passed_clock >= exec_clock ) /* 指定クロック数分 */ break; /* 実行したら抜ける */ } } こうしておいて、この関数を呼び出す側で、どれだけの時間(クロック数) CPU を処理するかを指定します。 cpu_exec( 2500000 * 0.01 ); /* 10ms間、CPUを実行 */ 古いパソコンなどでは割り込みも数も少ないようですが、割り込みの多いター ゲットの場合は、エミュレータに実装するのが面倒になっていきます。この辺 りは、じっくりを考えて作る必要があります。 ---------------------------------------------------------------------- ●グラフィック 前項にて説明したハードウェアの内部構造図には、グラフィックの表示につ いて記載しておりません。これは、表示の仕組みがハードウェアによってさま ざまに異なるからです。 私の知っている一昔前のパソコンでは、メモリの一部の内容がそのまま画面 に表示されました。この画面表示を行うメモリを、VRAM (ビデオRAM)と呼び ます(フレームバッファと呼ばれることもあります)。画面の表示内容を変え る場合は、CPUが直接VRAMにデータを書き込みます。すると、そのデータに応 じた内容が CRT画面に表示されます。 +-------+ +--------+ +--------+ | CPU |<======>| VRAM |------->|表示回路| ……>(CRT画面) +-------+ +--------+ +--------+ 機種によっては CPU が VRAM を直接アクセスできないようなものもありま す。この場合、VRAM は 表示回路側に接続されています。CPUは、I/O ポート を介して、表示回路側のチップに対してコマンドを送ります。このコマンドに よって、VRAMの内容が更新されて CRT に表示されるわけです。 +-------+ +------------+ +--------+ | CPU |<===>| I/O ポート |-------------->|表示回路| ……>(CRT画面) +-------+ +------------+ +------+ | | | VRAM |<--->| | +------+ +--------+ 余談:I/O ポートにマッピングされた VRAM 昔のパソコンには、VRAM がメモリでなく、I/O ポートに接続され たものがあったようです。メモリマップド I/O ならぬ、I/O ポート マップド VRAM といったところでしょうか。(私は X1ユーザじゃな いので、詳細は知りませんが) ドット単位で表示を制御するVRAM ============================== VRAMに書き込んだデータが、ビットマップイメージとして表示される仕組み です。 【モノクロ(白黒)表示の例】 VRAMに書き込まれた値のビットパターンがそのまま画面上に表示されます。 各ビットが1ドットで、0 なら黒、1 なら白で表示されます。 1bit = 1ドット +-------+ +--------+ +--------+ | CPU |<======>| VRAM |------->|表示回路| ……>(CRT画面) +-------+ +--------+ +--------+ VRAM 1バイトあたり 8ドットになるので、 VRAMが 8000 バイトあれば、320ドッ ト×200ドットの画面を表示することができます。 VRAMの内容 ビットに直すと・・・ +-----+ A000番地 | 51H | → 01010001 +-----+ A001番地 | 3FH | → 00111111 +-----+ | | : : 実際のCRT画面での表示 +------------------------------------------------------------- |■□■□■■■□■■□□□□□□……… | | 【カラー表示の例(プレーンドアクセス方式)】 上記のモノクロ表示の例では、各ドットが各ビットに対応するため、2種類 の色(白と黒)しか表現できません。そこでこの VRAMを複数個用意し、これ らを重ねることでカラーを表現します。 1bit = 1ドット +-------+ +--------+ +--------+ | CPU |<=+====>| VRAM |---+---->|表示回路| ……>(CRT画面) +-------+ | +--------+ | +--------+ | +--------+ | +<===>| VRAM |---+ | +--------+ | | +--------+ | +<===>| VRAM |---+ +--------+ 複数のVRAMの各々を、プレーンと呼びます。以下は 3個のプレーンからなる例です。 VRAMの内容 (プレーンその1) (プレーンその2) (プレーンその3) +-----+ +-----+ +-----+ A000番地 | 83H | C000番地 | 15H | E000番地 | B5H | +-----+ +-----+ +-----+ A001番地 | 00H | C001番地 | 00H | E001番地 | 00H | +-----+ +-----+ +-----+ | | | | | | : : : : : : A000 番地をビットに直すと → 1 0 0 0 0 0 1 1 C000 番地をビットに直すと → 0 0 0 1 0 1 0 1 E000 番地をビットに直すと → 1 0 1 1 0 1 0 1 これらのビットを縦に読むと、 カラーコードになる。 → 5 0 1 3 0 3 4 7 カラーコードと色の対応 カラーコード 0 → 黒 カラーコード 1 → 青 カラーコード 2 → 赤 カラーコード 3 → 紫(マゼンタ) カラーコード 4 → 緑 カラーコード 5 → 水色(シアン) カラーコード 6 → 黄色 カラーコード 7 → 黒 実際のCRT画面での表示 +------------------------------------------------------------- |水黒青紫黒紫緑白… | 上記の例では VRAM は 3プレーンなので、同時に表示できる色数は、最大で 8 色になります。これが 8プレーンになると 256色、 16プレーンになると 63356 色の同時発色が可能となります。 カラーコードと実際にCRT上に表示される色との対応ですが、これはハード ウェアによっては、上のように固定となっています。 しかし、中には自由に変更できるハードウェアもあります。つまりカラーコー ド 1 を緑に、カラーコード 7 を黄色に、……というように、好きに設定でき るわけです。このようにカラーコードと実際の色の割り当てを自由に行う機能 を、パレット機能ないし単にパレットと呼びます。また、カラーコードと実際 の色との対応の設定は、パレット用のメモリ領域(I/O ポートないし VRAMに 接続されます。カラールックアップテーブルと呼ばれることもあります)に書 き込むすることで行います。 +-------+ +--------+ +--------+ | CPU |<=+====>| VRAM |---+---->|表示回路| ……>(CRT画面) +-------+ | +--------+ | | | | +--------+ | +->| | +<===>| VRAM |---+ | +--------+ | +--------+ | | | +--------+ | | +<===>| VRAM |---+ | | +--------+ | | +--------+ | +====>|パレット|------+ +--------+ ちなみに、VRAMの内容はそのままでパレットだけを変更した場合、CRTに表 示されている画像の色が、直ちに変化します。これをうまく利用すれば、VRAM は一切変更せずに、パレットを変更するだけで画像が動いて見えるようにでき ます。これは、パレットアニメーションと呼ばれ、パレット機能のあるコン ピュータのゲームでは定番の手法でした。 【カラー表示の例(インデックスカラー)】 VRAMの 1バイトが 1ドットとして画面上に表示されます。320ドット×200ドッ トの画面を表示するには、VRAM が 64000 バイト必要です。 8bit = 1ドット +-------+ +--------+ +--------+ | CPU |<=+====>| VRAM |-------->|表示回路| ……>(CRT画面) +-------+ | +--------+ | | | +--------+ | | +<===>|パレット|-------->| | +--------+ +--------+ VRAMの 1バイトの値と、実際に表示される色の対応は、別のメモリ領域に設定 します。 VRAMの内容 VRAMの値と色の対応の例 +-----+ F000番地 | 04H | 00H → R= 0 G= 0 B= 0 +-----+ 01H → R=255 G=255 B=255 F001番地 | FFH | 02H → R= 0 G=128 B=128 +-----+ 03H → R=255 G= 30 B= 50 F002番地 | 01H | 04H → R=255 G=255 B= 0 +-----+ : | : | FFH → R= 0 G= 0 B= 0 実際の画面 +----------------------------- |黄黒白……… | | この、VRAMの値と実際の色の対応については、前述のプレーンドアクセス方式 にて説明したパレットと同じです。つまり、自由に変更が可能です(ハードウェ アによっては固定のものもある・・・かもしれない) 【カラー表示の例(パックドピクセル)】 15ビットカラーの場合、VRAM の 2バイトが、1ドットとして表示されます。 15bit = 1ドット +-------+ +--------+ +--------+ | CPU |<======>| VRAM |------->|表示回路| ……>(CRT画面) +-------+ +--------+ +--------+ この時の色ですが、VRAM の 2バイトがそのまま RGB値となります。 bit15 bit 0 +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ <------------> <------------> <------------> Red Green Blue 4〜 0bit … B値 9〜 5bit … G値 14〜10bit … R値 (15bit目は無視される) 24ビットカラーの場合、VRAM の 4バイトが、1ドットとして表示されます。 この場合は、0〜2バイト目が 各々のRGB値です。(3バイト目は無視される) 320ドット×200ドットの画面を表示するには、15ビットカラーなら 128000 バ イト、24ビットカラーの場合は 256000 バイトの VRAM が 必要です。 (ここでは、RGBが各5ビットないし8ビットの例を挙げましたが、これらはハー ドによって違うこともあります。) キャラクタ単位で表示を制御するVRAM ================================== VRAMに書き込んだデータが、文字(キャラクタ)として表示される仕組みで す。この VRAM は テキストVRAM と呼ばれたりします。 +-------+ +------------+ +--------+ | CPU |<===>|テキストVRAM|-------------->|表示回路| ……>(CRT画面) +-------+ +------------+ +--------+ | | |フォント|-->| | |画像 ROM| +--------+ +--------+ テキストVRAMに書かれたデータは、そのまま1バイトが ASCII 1文字となっ て画面に表示されます。テキストVRAMが 2000 バイトあれば、80文字×25行の テキスト画面を表示することができます。 テキストVRAMの内容 実際のCRT画面での表示 +-----+ +----------------------------------+ F000番地 | 41H | |ABC! | +-----+ | | F001番地 | 42H | | | +-----+ | | F002番地 | 43H | | | +-----+ | | F003番地 | 21H | | | +-----+ | | | | | | : | | | | | x0| +-----+ +----------------------------------+ F7CE番地 | 78H | +-----+ F7CF番地 | 30H | +-----+ 表示回路によっては、テキストの色を指定できたり、アンダーバーを付けたり、 などのいった属性を設定できるものもあります。これらの属性もテキストVRAM のどこかに書いておくことになります。また、ASCII文字だけでなく漢字を表 示できるものもあります。 さて、実際に表示する各文字(フォント)のビットマップデータですが、これ は表示回路の方に組み込まれており、CPU 側からはアクセスできません。 ゲーム機の表示機能 ================== ゲーム機などでは、表示の制御をシンプルかつ高速にするために、さまざま な機能を持っています。 以下はその一例です。 +-------+ +------+ +--------+ | CPU |<===>| VRAM |------------------->|表示回路| ……>(CRT画面) +-------+ +------+ +-------------+ | | |キャラクタROM|-->| | +-------------+ +--------+ まず、キャラクタROMと呼ばれるメモリがあり、ここに 8ドット×8ドットの ビットマップデータが複数個セットされています。このビットマップデータの ことを、キャラクタと呼びます。 キャラクタには、各々キャラクタ番号が割り振られます。 キャラクタROM +-----+ キャラクタ番号 0 | | ← ここに 8ドット×8ドットのビット | | マップデータが格納されている。 | | +-----+ 画像フォーマットや、1キャラクタ キャラクタ番号 1 | | あたりの実際のメモリ使用量は、 | | 表示回路の仕様による。 | | +-----+ | | : 一方 VRAMには、このキャラクタ番号をセットします。1キャラクタは 8×8 ドットなので、320ドット×200ドットの画面を表示するには 1000バイトの VRAM があれば十分間に合います。 と、ここまで説明して気が付いた人もいると思いますが、これは前述のテキス トVRAM の表示と同じ仕組みです。テキストVRAMでは、フォント画像データが 表示回路内部に用意されていますが、ゲーム機ではこのデータは ROMカセット などの形で、外部から供給されるため、ここにあらかじめゲームで使用する画 像を書き込んでおけばよいのです。 ここででてきたキャラクタROM は ROMなので書き換えが不可能ですが、これが RAM になっているものもあります。この場合は当然、書き換えが可能になり、 8×8ドット単位という制約はあるものの、自由に表示が出来るようになります。 (表示回路によっては、16×16ドットなどの場合もあります) パソコンでも、テキストVRAM のフォント画像データを自由に変更できる機能 を持つものがありました。この機能は PCG(プログラマブル・キャラクタ・ジェ ネレータ)機能と呼ばれています。 ちなみに、VRAMの内容はそのままでも、キャラクタRAM のデータを書き換える と、表示される内容もそれに応じて直ちに変化します。 次に、ゲーム機の典型的なグラフィック機能である、スプライトを説明しま す。 +-------+ +----------+ +--------+ | CPU |<===>|スプライト|------------------->|表示回路| ……>(CRT画面) +-------+ | 制御 RAM | +-------------+ | | +----------+ |キャラクタROM|-->| | +-------------+ +--------+ スプライトも VRAM と同じく、キャラクタROM が存在します。これは普通、 VRAM 用のキャラクタROM とは別に用意されます。キャラクタ(ビットマップ データ)のサイズも、16×16ドットなど、VRAMよりも大きなサイズになってい る場合が多いです。表示回路によっては、サイズが自由に設定できたりもしま す。これらの画像データは、スプライトキャラクターとか、スプライトパター ンと呼ばれることもあります。ROM でなく、RAMの場合は好きな画像データを 設定できます。 そして、スプライト制御RAM と呼ばれるメモリ領域に、パラメータをセットし ます。これもスプライトアトリビュートとか、スプライトテーブルとかいろん な呼び方があるようです。 スプライト制御RAM +-----+ スプライト番号 0 | | ← どのキャラクタ番号の画像(ビットマ | | ップ)を、画面上のどの座標(x,y) | | に表示するのか、などを設定する。 +-----+ スプライト番号 1 | | スプライト制御RAM に実際に書き込む | | データ(パラメータ)の詳細は、 | | 表示回路の仕様による。 +-----+ | | : これだけで、あとはハードウェアが自動的に指定した位置にビットマップを表 示してくれます。VRAM の場合と違って、1ドット単位での位置指定になります。 スプライトの画像データには透明の色指定があるので、スプライト同士が重なっ た場合でも、きれいに表示されます。また、重なったスプライトのどちらを上 側に表示するかの優先度の設定もできます。 表示できるスプライトの個数は、スプライト制御RAM の容量次第、ということ になるわけですが、実際には表示回路により制約があったりします。特に、ス プライトを横方向に並べて表示するとハードにかなり負担がかかるようで、表 示の一部が欠けたり、表示されなかったりするようです。 初期の頃のスプライト機能はサイズも固定(16×16ドットや 8×8ドット)で ひとつのキャラクターに使用できる色数も少ない貧弱なものでした。その後ハー ドの進化とともに、サイズは自由は選べるようになり、色数は無制限、さらに 反転・拡大・縮小・回転などが可能になり、どんどん多機能になってきました。 パソコンの表示機能と、ゲーム機の表示機能を見比べた場合、パソコンのほ うがドット単位で表示内容を制御できるので自由度が高いように見えますが、 ドット単位で VRAM を制御するには高速な CPU が必要となります。昔はそん な高速な CPU は無かったので、専用の表示回路で高速に表示できるゲーム機 のほうが表示能力が勝っていました(ゲームなどの用途に限っての話ですが)。 そんなわけで、ゲーム用途を意識したパソコンではスプライトを搭載している ものがたくさんあります。 その他の表示機能 ================ ・重ね合わせ 内部で複数の画面(VRAM)を持ち、これらを重ね合わせて表示できる機能で す。この時、重なりの上側になる画面は、どれか1色を透明として扱えるよう になります。また、どの画面を上に持っていくかを設定できる場合もあります。 VRAMその1の内容 VRAMその2の内容 +--------------------+ +-------------------+ | | | ○| | ● | | /\ | | ●● | | / \ /\ | | ●●● | |/ \ \ | | ‖ | | \| | ‖ | | | +--------------------+ +-------------------+ 実際のCRT画面での表示 +-------------------+ | ○| VRAMその1が | /\● | 上側の画面として | / ●●/\ | 重なって表示される例 |/ ●●● \ | | ‖ \| | ‖ | +-------------------+ ・ハードウェアスクロール 実際の表示画面よりも大きなサイズのVRAMを持ち、表示の開始位置を設定で きる機能です。下は、VRAMが横方向に2個分ある例です。 VRAMの内容 +----------------------------------------+ | ○| | /\ | | /\ /\ / \ /\ | | / \ / \ / \ \ | |/ / \ \| | △▲□ | +----------------------------------------+ ↑ ここを、表示開始位置に設定すると…… 実際のCRT画面での表示 +-------------------+ | | | /\ | |\ / \ /| | \ / \ | | \ | |△▲□ | +-------------------+ 表示回路によっては、縦方向に2画面あるものや、縦横方向に何画面もあるも のもあります。上下左右が繋がっている場合もあります。このような機能があ ると、スクロールするゲームが作りやすくなります。 ・拡大、縮小、回転 VRAMのデータを表示する際に、拡大・縮小・回転などのエフェクトを行う機 能です。 ・ポリゴン 最近のパソコンやゲーム機では、3Dの表示機能、すなわちポリゴン表示機能 が重視されています。スプライト機能などはもはや過去の遺物となりつつある ようです。なお、筆者は最近のパソコンやゲーム機でゲームしたことが皆無な ので、ポリゴンってよくわからんけどなんかリアルな表示機能、程度しか知り ません。よって説明するのは不可能です。ごめんなさい・・・ エミュレータ上での表現 ====================== CPU が VRAM を直接アクセスせずに、表示回路側のチップにコマンドを送る 方式の場合は、このコマンドを解析して、CPUとのやりとりをまずエミュレー トする必要があります。そして、そのコマンドに応じて VRAM を更新します。 実機では最終的にはこの VRAMの内容が画面に表示されるので、これをエミュ レートするには、VRAM の内容をプラットホームが扱える画像フォーマットに 変換し、そしてプラットホーム側のAPI関数にてこの画像を表示させるように すればいいわけです。 残る問題は、プラットホーム上にて表示するタイミングです。VRAMが書き換え られるたびにプラットホーム上に表示しているととてつもなく表示回数が増え ます。といって、1秒間に1回とかの割合で表示すると、紙芝居のような表示に なってしまいます。 結論からいうと、 1/60秒、すなわち 16.67ms秒周期で表示を行うのがベスト な方法です(この理由については、後ほど詳しく説明します)。 1/60秒とい うことであれば、前に出てきた 2.5MHz の CPU の場合、 2500000/60 = 41667 クロック実行する度に、表示すればいいわけでので、以下のような流れになり ます。 for( ;; ){ cpu_exec( 2500000/60 ); /* 1/60秒分、CPU処理 */ display_vram(); /* ターゲットにて表示 */ } 最後の display_vram() が、ターゲットの VRAM内容をプラットホームの画像 フォーマットに変換して表示する処理関数です。この処理はすべてターゲット とプラットホームに依存する内容になります。 ---------------------------------------------------------------------- ●サウンド まず、音はどうして聞こえるのでしょうか。小学校の理科で習ったと思いま すが、音は空気を伝わって聞こえてきます。つまり、音=空気の振動です。 この「空気の振動」を図にすると、下のようになります。横軸方向が時間の 流れ、縦軸方向が空気の振動です。時間とともに空気が振動して、これが人間 の耳には「さまざまな音」として認識されるわけです。 ↑ | | 空 | 気 | の | ***** 振 | ** ** 動 | ** ** | * * |* * ***** | ** ** ** | * ** | * * | ** ** | ** **** | ** | | +------------------------------------------------- 時間の流れ → では、この図の「空気の振動」を「電気信号の強弱」の置き換えてみます。 強 | | ↑ | | | ***** 電 | ** ** | ** ** 気 | * * |* * ***** 信 | ** ** ** | * ** 号 | * * | ** ** | ** **** ↓ | ** | 弱 | +------------------------------------------------- 時間の流れ → 時間とともに、電気信号の強弱を変化させるわけです。そして、この電気信号 をスピーカーに入力すると、スピーカーは、この電気信号の波形のとおりの音 を鳴らします。 以上より、「音」は「電気信号の強弱」で再現できることがわかりました。あ とは、この「電気信号の強弱」をデータに変換すれば、アナログ情報である 「音」をデジタルなデータとして扱うことができるようになります。 「電気信号の強弱」をデジタルなデータにするには、以下のようにします。ま ず、時間ですが 1秒間を 44100 等分します。そして電気信号の強弱ですが、 これは 65536 等分します。つまり、最弱で 0x0000、最強で 0xffff になりま す。すると、1秒あたりに 44100個の、short 型のデータ列ができあがります。 0xffff +------------------------------------------------- | | | | +-+-+-+ | +-+ | | |-+ | +-+ | | | | |-+ | | | | | | | | | データ +-| | | | | | | |-+ +-+-+-+ (16bit | | | | | | | | | |-+ +-+ | | |-+ の値) | | | | | | | | | | | +-+ | | | | | | | | | | | | | | | |-+ + | | | | | | | | | | | | | | | | | |-+ +-| | | | | | | | | | | | | | | | | | | |-+ +-+-| | | | | | | | | | | | | | | | | | | | | |-| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 0x0000 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+--- |<------------------------------------------->| 1秒間を、44100等分する このような手法で音をデジタルデータにすることを、サンプリングと呼びます。 サンプリングして出来たデータは、PCM とか PCMデータ と呼ばれます。ここ では、1秒間を 44100等分しました。これは 44.1KHz にあたりますが、これは サンプリング周波数と呼ばれます。また、電気信号の強弱を 16bit の値で表 現しましたが、これは量子化ビット数と呼ばれます。 サンプリング周波数も量子化ビット数も、大きければ大きいほど高品質な音に なります。ちなみに、サンプリング周波数 44.1KHz、量子化ビット数 16bit は、CD(コンパクトディスク)と同じです。CD の場合は、ステレオ音声なの で、この PCMデータが 2個分(2チャンネル分)記録されています。 エミュレータで音を鳴らす場合は、PCMデータをそのまま鳴らす API関数が プラットホーム側にて用意されていると思いますので、それを使うことになり ます。つまり、エミュレータでターゲットの音を再現するというのは、ターゲッ トが出す音と同じ音の PCMデータを作り出すという意味になります。 ターゲットのサウンド機能 ======================== ここで、昔のコンピュータのサウンド機能について簡単に書いてみます。 一番シンプルなのが、ビープ音です。これは、I/O ポートの特定のビットを 1 にすれば「ピーーー」と音がなり、0 にすれば音が止まるというものです。 そして有名なのが、AY-3-8910 という音源チップです。これのチップは予め決 まった波形データを出力するだけなのですが、音程・音量の制御ができ、3チャ ンネルの同時出力が可能でした。つまり、三重和音を奏でることが出来るわけ で、これを使えば幅広いサウンド出力が可能となります。 その後、YAMAHA の FM音源チップが広く使われるようになります。これはサイ ン波のデータを出力するのですが、パラメータを指定すればこのサイン波の形 を歪めることができます。これにより、パラメータを組み合わせればかなり自 由に波形が作れる、すなわち好きな音が作れるようになりました。 そして PCM音源が登場するわけですが、なにしろ PCMデータはメモリを食いま す。CD と同じ品質の音を、10秒ちょっとの間、モノラルで再生するだけで 1MB の容量が必要になるわけですから、広く使われるのはここ最近なってから です。(最近とはいっても CD-ROM が搭載されるようになる頃からは普通に使 われるようになりましたが) サウンド機能のエミュレート ========================== では、サウンドをエミュレートするにはどうすればいいのでしょう? ター ゲットが PCM音源を持っている場合は、PCMデータを出力している個所がある ので、そこでデータを別のバッファにコピーし、適当なタイミングでプラット ホーム側に出力すればいいわけです。 ターゲットが PCM 以前、つまり音源チップを使っていた頃の機種の場合、 この音源チップが出力する音と同じ内容の、PCMデータを作る必要がありま す。・・・が、これって、いったいどうやって作るんでしょう? 実のところ、筆者はこの方法を知りません。実機の音をサンプリングして、 そのデータを使う、というのもありですが、FM音源のように波形をプログラム でいじくることが出来るチップの場合は、そのパラメータに応じてデータを補 正してやらねばなりません。それは一体どのようにするのか・・・ というわけで、これ以上のことはここでは書けません。ごめんなさい。 : : : まあ、なにも書かないのもなんなんで、ちょこっとだけサンプルを書いてみま す。手っ取り早く、ブザーを鳴らす例を考えてみましょう。 ここで考えるブザーは、以下のような波形(矩形波と呼ばれる)の音を出力し ます。この波形は1秒間に 882 回数、出力されます。つまり周波数 882 Hz で す。 +----+ +----+ +----+ +----+ | | | | | | | | | | | | | | | | -------+ +----+ +----+ +----+ +---- →時間の流れ |<------->| 1秒間に 882回、 この波形が出力される。 ブザーの制御は、I/O ポートにて行いまう。I/O ポート 0x20番地の ビット 0 に 1 をライトすると、ブザーON(発声)、0 をライトすると、ブザーOFF(停 止)です。 今、 2.5MHz の CPU を 1秒間、つまり 2500000 クロック分、実行させます。 すると、1000000クロック実行させた時点でブザーをオン、2000000クロック実 行させた時点でブザーをオフしました。 実行した |------------------------------------------------------| クロック数 | | | | (2.5MHz) 0 1000000 2000000 2500000 ここでブザー ここでブザー をオンした をオフした これを実際のターゲット上の時間であらわすと、こうなります。 |<---------------------------------------------------->| | | | | 0秒 0.4秒 0.8秒 1秒 つまり、0.4秒〜0.8秒の間、ブザーをオンしていることになります。 では、次に周波数 882Hz のブザーはどのような PCM データになるかを考えて みます。PCMデータは、44.1KHz、16bit(unsigned short)とします。 このデータは、0xffff |<-->| +----+ +----+ +----+ +----+ | | | | | | | | | | | | | | | | -------+ +----+ +----+ +----+ +---- →時間の流れ |<-->| このデータは、0x0000 PCM データ 1秒分 は 44100個の unsigned short で表現されます。これと上 記のブザーの波形 882回分のデータは、同じ個数になるはずです。つまり、ブ ザー波形 1回分のデータ個数は、44100 / 882 = 50 個となります。 { 0xffff, 0xffff, 0xffff, 0xffff, … 0xffff, /* 全部で25個 */ 0x0000, 0x0000, 0x0000, 0x0000, … 0x0000, }, /* 全部で25個 */ では、実際にブザーをオンしている間、このPCMデータが出力されるように、1 秒間分の PCMデータを作成してみます。 unsigned short pcm_data[ 44100 ]; PCMデータは、上の配列変数に作成することにします。pcm_data[0] が、 0 秒 目の PCM データ、pcm_data[ 44099 ] が、 1秒目の PCMデータです。これか ら算出すると、ブザーをオンした 0.4秒目は pcm_data[ 17640 ] に、ブザー をオフした 0.8 秒目は、 pcm_data[ 35280 ] に位置することになります。 |<---------------------------------------------------->| | | | | 0秒 0.4秒 0.8秒 1秒 ここで ここで ブザーオン ブザーオフ |<--------(A)-------->|<-------(B)--------->|<---(C)-->| pcm_data [0] 〜 [17640] 〜 [35280] 〜 [44099] (A)の区間(pcm_data[0] 〜 pcm_data[17639])は無音なので、全て 0x0000 をセットします。 (B)の区間(pcm_data[17640] 〜 pcm_data[35279])はブザーが発声している ので、0xffffを25個・0x0000を25個、を繰り返しセットしていきます。 (C)の区間(pcm_data[35280] 〜 pcm_data[44099])は再び無音なので、全て 0x0000 のをセットします。 こうして出来た PCMデータを、プラットホーム側にて再生すれば、ブザー音の エミュレートの完成です。 0xffff +-+ +-+ +-+ +-+ +-+ +-+ | | | | | | | | | | | | | | | | | | | | | | | | 0x0000 ----------------------+ +-+ +-+ +-+ +-+ +-+ +----------- | | | | pcm_data [0] 〜 [17640] 〜 [35280] 〜 [44099] さて、このようにして作成した PCMデータですが、これは 1秒分のデータなの で当然ですが 1秒間しか再生できません。つまり、エミュレータでサウンドを 鳴らすには、この1秒間のデータ作成を絶え間なく行い、1秒おきに連続して再 生していく必要があります。 この 1秒おきに連続して再生、というのには注意をはらわないといけません。 再生はプラットホーム側のOSの仕事なので、正確に 1秒間だけ行われます。エ ミュレータでの次のデータ作成が 1秒よりも遅れた場合、そのわずかなズレは ノイズとなって聞こえてしまいます。ならば再生が終わらないうちにつぎつぎ データを作ってしまえ、となると、今度はプラットホーム側が内部で用意して あるデータバッファ領域があふれてしまい、これまたノイズとなって聞こえて しまいます。このあたりの処理をどのように対処するかは、プラットホーム側 の API仕様などにより異なります。 以下は、もっともシンプルな実装です。 for( ;; ){ t0 = get_now_time(); /* 処理前の時刻を取得 */ clear_pcm_data(); /* PCMデータを初期化する */ cpu_exec( 2500000 ); /* 1秒分、CPU処理 */ /* CPU処理内にて、I/O ポート */ /* 0x20番地にアクセスした */ /* 場合、PCMデータを作成する */ make_pcm_data(); /* PCMデータの残りを作成する */ output_pcm_data(); /* PCMデータをプラットホーム側で再生 */ t1 = get_now_time(); /* 処理後の時刻を取得 */ lapse = t1 - t0; /* これが、処理にかかった時間になる */ if( lapse < 1秒 ){ /* 実時刻で 1秒経過してない場合 */ sleep( 1秒 - lapse ); /* なにもせずに、残り時間をつぶす */ } } CPU 処理中に、I/O ポート 0x20番地のアクセスが発生した場合、その時点ま でに実行したクロック数に基づき、その時点までの PCMデータを作成します。 また、1秒分の処理が終わった後も残りの未作成の PCMデータを作成します。 そしてこうして出来上がった 1秒分の PCMデータを、プラットホーム側にて再 生します。 上記では、プラットホーム上の 1秒(実時間)と、エミュレータでの 1秒を合 わせるための、ディレイ(時間つぶし)を行っています。これにより、正確に 1秒ごとに PCMデータがプラットホーム側に渡るはずです。 なお、この例はタイミングの取り方が結構いいかげんです。実際の実装につい てはプラットホームとの連携によって変わるので、あくまでこのような処理の 仕方もあるんだ、という程度の認識にとどめておいてください。 ちなみに、ここでは 1秒ごとに PCMデータを再生させていましたが、これだと 音の出力が 1秒ずれることになります。これだけずれると、かなり違和感があ るので、実際にはもっと細かな間隔で再生させる必要があります。 この間隔は、画像を表示するタイミング、つまり 1/60 秒に合わせるのが一番 無難だと思われます ---------------------------------------------------------------------- ●VBLANK処理 さて、グラフィックおよびサウンドの章にて、画像の表示・サウンドの出力 は 1/60 秒の間隔で行うがよい、と書きましたが、この 1/60 秒というのは実 は VBLANK の周期なのです。ここでは、VBLANK とはなんなのか、なぜ VBLANK の周期に合わせるのがいいのかを説明したいと思います。 ブラウン管の表示のしくみ ======================== 最近は、パソコンは液晶モニタ、テレビもプラズマ、とブラウン管を見る機 会も減ってきたように思いますが、少し前までは画面の表示といえばブラウン 管(CRT)があたりまえでした。 この CRT は、どのようにして画面を映し出しているのでしょうか。筆者も 詳しいことは知らないので聞き覚え程度の知識ですが、簡単に書いてみます。 ブラウン管の一番後ろに電子銃というのがあって、ここからビームが出ます。 ビームとはなんなのか筆者にもよくわかりませんが、とにかく電子銃からビー ムが発射され、これがブラウン管の画面の裏側に衝突します。するとこの衝突 した一点が一瞬光ります。この光が画面を映し出すもとになるわけです。 ブラウン管を横から見たところ +--------------+ +-------+ ‖ | ビーム ‖ | □・・・・・・・ →‖☆ 画面の一点が |電子銃 ‖ 光って見える +-------+ ‖ +--------------+ こっちが こっちが ブラウン管の 画面の正面側 背面側 でもこれだとブラウン管に表示されるのは、ビームがぶつかった一点だけです。 ブラウン管にひとつの画面を表示させるには、電子銃の向きを変えて、画面の あちこちにビームをまんべんなく発射する必要があります。 例えば、極太マジックを使って画用紙を真っ黒に塗りつぶす場合、どうするで しょうか。やみくもにマジックで塗りつぶしても、どこかに塗り忘れができて しまいます。そこで、まずは画用紙の左上からまっすぐ右に直線をひきます。 そして次は、さきほどひいた直線のちょっと下にマジックをずらして、再度左 から右へ直線をひきます。そして次は、また先ほどひいた直線のちょっと下 に……とこれを繰り返していきます。そして画用紙の一番下に直線をひいて出 来上がりです。これで、画用紙は塗り忘れなく真っ黒に塗りつぶされました。 再び話をブラウン管に戻します。ブラウン管が画面を表示するのも、このマジッ クで画用紙を塗りつぶすのと同じ要領でおこないます。 まず、画面の左上にビームを発射します。 +----------------------+ |☆ | (図1) | | | | | | | | | | | | +----------------------+ そして、ずーっと右方向に電子銃の向きをずらしていきます。 +----------------------+ |→→→→→→→☆ | (図2) | | | | | | | | | | | | +----------------------+ 画面の一番右端にきたら、 +----------------------+ | ☆| (図3) | | | | | | | | | | | | +----------------------+ ビームを止めます。そして、電子銃の向きだけを画面の左に向けます。 このとき、ちょっとだけ下に位置をずらします。 下の図でいうと、(A) の位置から (B) の位置に電子銃の向きを変えます。 +----------------------+ | (A)| (図4) |(B) | | | | | | | | | | | +----------------------+ そして再びビームを発射し、 +----------------------+ | | (図5) |☆ | | | | | | | | | | | +----------------------+ ずーっと右方向に電子銃の向きをずらしていきます。 +----------------------+ | | (図6) |→→→→→→→☆ | | | | | | | | | | | +----------------------+ これを何回も繰り返していきます。画面の縦の解像度が 200ラインの場合は、 200回、480ラインの場合は 480回繰り返すことになります。 そして、画面の一番下のラインの右端までたどり着いたら、画面の表示は完了 です。 +----------------------+ | | (図7) | | | | | | | | | | | →→→→☆| +----------------------+ 画面表示が完了したら、次の表示にそなえて、電子銃の向きを画面の左上に向 けます。このときはビームは止めておきます。 下の図でいうと、(X) の位置から (Y) の位置に電子銃の向きを変えます。 +----------------------+ |(Y) | (図8) | | | | | | | | | | | (X)| +----------------------+ さて、ビームがブラウン管にぶつかって光るのはほんの一瞬です。そのため、 せっかく表示させた画面も一瞬で消えてしまいます。それでは困るので、上の 動作を絶え間なく繰り返し、画面が消えるまえに再び画面を表示しなおします。 この繰り返しの周期ですが、日本やアメリカのテレビでは、1秒間に60回となっ ています。(ヨーロッパのテレビでは1秒間に50回。これは規格の違いです)。 つまり、テレビで静止画が表示されていても、じつは1秒間に60コマという猛 烈な速度で表示が繰り返されているわけです。 パソコンの CRT の場合でも話は全く同じです。パソコンの場合、表示回路 が、VRAM の内容を色信号に変換して、CRT に送り出します。CRT は上の手順 にそって画面を表示するわけですから、表示回路は CRT に絶え間なく色信号 を送りつづけなくてはなりません。そのため、表示回路はひたすら VRAM から データを読み出して色信号に変換しつづけます。 +--------+ +--------+ +---------+ | VRAM |------->|表示回路| …… 色信号 ……> | CRT画面 | +--------+ +--------+ +---------+ さて、ここで気になるのが、CPU が VRAM にアクセスしたらどうなるのかです。 メモリには複数のデバイスから同時にアクセスできません。表示回路が VRAM を読み出すのと、CPU が VRAM に書き込むのは同時にはできないのです。 表示回路が VRAM からデータを読んでいるときは、CPU のアクセスはしばらく 待たされます。つまり CPU の処理速度が一瞬遅くなるわけですが、これは別 に問題ないでしょう。では、CPU が VRAM にアクセスしているときに、表示回 路が VRAM を読もうとしたらどうなるでしょうか。今度は表示回路が待たされ ることになるわけですが、残念ながら CRT のほうは待ってくれません。表示 回路や CRT の仕様にもよるわけですが、ノイズが表示されるかもしれません。 (表示回路が VRAM にアクセスしているときに CPU が VRAMをアクセスすると、 CPU がフリーズしてしまうようなマシンもあったという噂です) 余談:デュアルポートRAM こうした事態を避けるため、表示回路とメインCPU との両方から同 時にアクセスできるようにしたメモリを使ったものもあります(最近 のパソコンもそうなのかな?)。このように別々のデバイスからアク セスできるメモリをデュアルポートRAM というそうです。 もうひとつの問題は、CRT は画面を 1ラインずつ表示していくという点です。 今、画面一面に赤色が表示されているとします。電子銃は、赤色を画面に表示 すべく、画面を1ラインづつ描いてゆきます。 +----------------------+ | | | | | | | | |→→→→→→→→→☆ | ここで画面の色を | | 赤から青に変えると…… | | +----------------------+ 電子銃が上のラインを描いている時に、画面一面の色を青に変えます。すると、 この次の瞬間から、電子銃は青色を画面に表示し始めます。つまり、以下のよ うな画面が出来上がります。 +----------------------+ | | | | | | 上半分は、赤色 | | 〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜 ここを境に | | | | 下半分は、青色 +----------------------+ この次に画面を再表示するときは、一面青色で表示されるので、このような中 途半端な画面が表示されるのは、わずか一瞬、1/60秒間だけです。ところが、 これが何度も発生すると人間の目にははっきりとちらつきとなって見えます。 ともかく、表示中のラインにかかる VRAM を変更するとちらつきが発生するわ けです。 HBLANK と VBLANK ================ 上の図の(図4)を見てください。電子銃の向きを右端から左端に変えると ころですが、この間はビームは発射されません。この間のことを、HBLANK と 呼びます。 HBLANK は 水平帰線期間とも呼ばれます。 また、(図8)を見てください。電子銃の向きを右下から左上に変えるとこ ろですが、この間もビームは発射されません。この間のことを、VBLANK と呼 びます。 VBLANK は 垂直帰線期間とも呼ばれます。 この二つの期間では、ビームが発射されません。ということは、表示回路か ら CRT に色信号を送る必要はないので、表示回路が VRAM をアクセスするこ ともありません。逆にいえば、この二つの期間に、メインCPU が VRAM をアク セスしても、画面表示には一切影響がないわけです。VBLANK(垂直帰線期間) の間に VRAM の操作を集中させれば、上で説明したような、一瞬画面の表示が 中途半端な状態になるのを防ぐことができます。 ゲーム機や、多くのパソコンではこの VBLANK・HBLANK の開始のタイミング で割り込みが発生するように設計してあります。それぞれ、VSYNC割り込み、 HSYNC割り込みなどと呼ばれていますが、ゲームソフトの場合はこの割り込み 発生にて VRAM へのアクセスを一気に行うようになっているようです。 タイマとしての VBLANK ===================== 他にもゲームでは VBLANK が周期的に発生するという特徴を利用して、タイ マとして使われることがあります。 ここで、昔のパソコンのゲームの話をしてみます。そのゲームは画面に数多 くのゲームキャラクターが表示されました。スプライトの無いパソコンなので、 VRAM にゲームキャラクタを直接転送しているわけですが、画面上にゲームキャ ラクタが数多く表示される時とそうでない時とで、処理にかかる時間に差があ りました。つまり、キャラクタが画面にあまり表示されていないときは、ゲー ムのスピードが上がり、キャラクタがたくさん表示されると、ゲームのスピー ドが落ちるわけです。 そのゲームはよくできていて、この対策として、画面に表示されるキャラクタ の数に応じて、ウェイト(時間つぶしのための無駄な処理)が入っていました。 こんな感じの処理をプログラムに入れていたわけです。 for( i=0; i<1000; i++ ) ; これによりゲームの速度が一定になるように調整してたのですが、この方法で はゲームの処理が変わると、その速度に応じてウェイトも変えなくてはならず、 調整が非常にめんどうです。さらに、パソコンがモデルチェンジして高速化し てしまった場合、ゲームの速度もつられてアップしてしまいます。これの根本 的な解決策としては、なんらかの絶対的な時間を基準に処理速度を合わせるこ とです。 この時間合わせのためのタイマーとして目を付けられたのが、VSYNC割り込み です。この割り込みは 1/60 秒おきに発生します。VSYNC割り込みが 6回発生 すれば、時間が 0.1秒経過したことになります。これを使ってウェイトを調整 すればゲームの処理時間を一定に保つことができます。 ゲーム機でよく採られる手法は、VSYNC割り込みの発生により、プログラムを 処理するというものです。 まず、CPU はゲームの処理を行います。そして画面に表示させる内容を決定し、 VSYNC割り込みを待ちます。VSYNC割り込みが発生すると、先ほど決定した内容 に従って VRAM の更新を行います。これは VBLANK期間中に終わらせます。そ して再びゲーム処理を行い、次に画面に表示させる内容を決定して、次の VSYNC 割り込みを待ちます。 →時間の流れ VSYNC割り込み |---------------|---------------|---------------| 発生タイミング + + + + CPUの処理時間 <=======> <===> <============> CPUの処理時間は毎回ばらばらだが、処理の開始の タイミングは、常に同じ。 これをひたすら繰り返していくわけです。CPU が行う処理はその時々に応じて 変わるため、処理時間は不定ですが、この方法ならCPUの処理時間は実質 1/60 秒固定になるので、見た目の速度は一定になります。 余談:処理落ち 上の手法では、CPU の1回の処理は必ず 1/60秒以内に終わらなくて はいけません。もし、1/60秒を超えてしまった場合は次の VSYNC 割 り込みまで待たなくてはいけないので、この時だけは処理間隔が 1/30 秒になってしまいます。VRAM の更新もそれまで行われません。 これが俗にいう、「処理落ち」現象です。昔のシューティングゲーム では、画面上に弾や敵機のキャラクターが数多く表示されると、CPU の処理が 1/60 秒では間に合わなくなり、処理落ちがよく発生しまし た。処理落ちの状態が続くと、ゲームの速度がスローになってしまう のですが、弾や敵機をよけやすくなるという副作用もありました。最 近のゲーム機では高速な CPU を使っているため、弾や敵機を数多く 表示させても処理落ちするようなことはなさそうですが、遊ぶ側のこ とを考えて、わざと処理落ちさせて弾をよけやすくする、などの配慮 をしているという噂を聞いたことがあります。 また、ゲームではジョイスティックの入力を常時チェックするわけですが、全 体の処理が上記のような流れになっている以上、処理の開始時にチェックする のが合理的です。つまり、VSYNC割り込みのたびにボタンが押されたかどうか をチェックするわけです。これから逆算すると、ボタンを押した・離したの チェックは1秒間に60回なので、どんなに速くボタンを連打しても秒30回しか 押したことが検知されないことになります。 エミュレータにおける VBLANK・VSYNC ================================== 実際のゲームなどで VSYNC割り込みがどのように使われるかが大体わかった ところで、エミュレータに話を戻します。 まず、画面の表示タイミングですが、実機が VBLANK のタイミングで行われ るのであれば、プラットホーム上に画面を描画するのもこのタイミングに合わ せるべきです。 次に、キーの入力です。プラットホームにてキー操作した場合、それをエミュ レータに伝えるわけですが、このタイミングも VBLANK に合わせればいいです。 なぜなら、エミュレータ上で動かすプログラムも、VBLANK のタイミングでキー 入力をチェックしているからです。(当然、すべてがそうなっているわけでは ないですが、このタイミングにしておけばまず問題はないはずです)。 そしてサウンド出力です。サウンド出力は一定の周期で行えばどんな周期で も問題ないはずです。でも、ゲームなどでは画面の変化に応じて音をだします。 例えば爆発の画像を表示をする時には、爆発音を出すはずです。このことを考 えると、やはり VBLANK のタイミングで出力するのが最適になります。 これらの全ての処理を、プラットホーム上での時間(実際の時間)の、1/60秒 以内にすべて行えば、実機とほとんど変わらないタイミングで入力・出力をエ ミュレート出来るわけです。 プラットホーム上 での実際の1/60秒 |<-------------------------------------------->| 1/60秒 プラットホームの キー入力を取得 |<->| CPU処理を 41667 クロック分 実行 |<--------------------->| (2.5MHz) サウンド出力の データを作成 |<----->| ウインドウ描画の データを作成 |<--->| あまった時間 |<-->| 上の通りにC言語で書いてみます。 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データをプラットホーム側で再生 */ display_vram(); /* プラットホーム側にて表示 */ t1 = get_now_time(); /* 処理後の時刻を取得 */ lapse = t1 - t0; /* これが、処理にかかった時間になる */ if( lapse < 1/60秒 ){ /* 実時刻で 1/60秒経過してない場合 */ sleep( 1/60秒 - lapse ); /* なにもせずに、残り時間をつぶす */ } } HBLANK と HSYNC =============== VBLANK の重要性がわかったところで、今度は HBLANK です。HSYNC割り込み は HBLANK になる時に発生します。ということは、HSYNC割り込みが発生する たびにカウンタを +1 していけば、現在どのラインまでを表示したかを知るこ とが可能です(ちなみにこのカウンタを 0 に初期化するのは、VBLANKの時に なります)。 現在の表示ラインがわかるだけで何ができるの? と思った人もいるでしょ うが、ゲームではHSYNC割り込みを使ったさまざまな技が登場します。 まずは有名なラスタスクロールです。 前項にてハードウェアスクロールの説明をしました。以下は、VRAM が横2画 面分あるような表示回路をもつハードです。このハードでは、実際に VRAM の どの位置から表示をはじめるのかを I/O ポートなどで設定します。現在、矢 印「↓」の位置から表示するように設定してあります。 表示開始位置 ↓ +----------------------------------------+ | ○| | /\ | VRAMの内容 | /\ /\ / \ /\ | | / \ / \ / \ \ | |/ / \ \| | △▲□ | +----------------------------------------+ 一方、HSYNC 割り込みの処理部分には、VRAMを表示する位置を左に 1 ドット ずらす設定を行うプログラムを組み込んでおきます。 さて、実際に CRT にて表示が行われると、1ライン分の表示が終わるたびに HSYNC 割り込みが発生します。そのたびに、割り込み処理が実行され、VRAM を表示する位置が左に1ドットづつずれていきます。そして、最終ラインでは、 矢印「↑」の位置まで、表示位置が移動したとしましょう。 最初のラインでの表示開始位置は、ここ。 ↓ +----------------------------------------+ | ○| | /\ | VRAMの内容 | /\ /\ / \ /\ | | / \ / \ / \ \ | |/ / \ \| | △▲□ | +----------------------------------------+ ↑ 最終ラインでの表示開始位置は、ここ。 この結果、VRAM を以下のような平行四辺形で切り取ったような感じの画面が 表示されることになります。 +-------------------+ / / / / / / / / / / / / +-------------------+ これを使えば、HSYNC割り込みの処理内容次第でさまざまな表現が可能です。 たとえば、VRAMの表示開始位置を、サイン波を描くように変化させれば、画面 が1ライン刻みで横方向にうねうねと動いてみえます。 このように 1ライン単位でスクロールさせる技が、ラスタスクロールです。も ちろん、表示回路の機能そのものとしてラスタスクロールを持っているものも あります。これは、あらかじめ各ラインごとに VRAM の表示開始位置を設定で きるというハードの機能を使うものです。しかしそんな機能がなくても HSYNC 割り込みを利用すればラスタスクロールが可能、ということでいろんなゲーム 機でこの演出が行われていました。 次に HSYNC 割り込みとスプライトを使った面白い技術を紹介しましょう。 スプライト機能を持っている表示回路の場合、画面上に表示できるスプライト の個数はあらかじめ決められています。例えば、32個分しかスプライトを表示 できないとしましょう。この32個のスプライトを画面の上半分に表示するよう に、スプライト制御RAMを設定しておきます。 CRT にて表示が始まると、これらのスプライトが表示されていきますが、この とき HSYNC 割り込みにて現在どのラインを表示しているのかをカウントして おきます。 画面の半分まで表示が終わったとき、画面の上半分にはスプライトが32個表示 されています。そして、画面半分の時点の HSYNC 割り込みのタイミングにて、 スプライト制御RAMの内容を全て書き換えて、今度は画面の下半分にスプライ トを32個表示するように設定します。 この結果、1画面の表示が終わったとき、画面上には64個のスプライトが表示 されていることになります。このようにスプライトの表示個数を見かけ上増や す技は、スプライトダブラーと呼ばれているそうです。(上の例では、画面の 上半分と下半分にまたがる位置にあるスプライトは正常に表示されません。こ れを正常に表示させるために、いろんなテクニックが使われたようです) 他にも HSYNC 割り込みでパレットを変更し、見かけ上の画面の色数を増や す、とか実にさまざまな技が編み出されています。エミュレータを作る際には そのターゲットにて使われていた技をある程度知っておく必要があります。で ないと、単純に VBLANK のたびに表示データを作成、とやってると実際の画面 のとおりにエミュレートできない、なんてことになります。もっともこれらの 技は実際に知っていてもエミュレートするのが難しそうではありますが・・・ 筆者が作ったエミュレータのターゲット、PC-8801 には VSYNC 割り込みはあっ たけれども、HSYNC 割り込みはありませんでした。なので、 HSYNC 割り込み のエミュレートについてはよく知りません。割り込み自体は単純な周期割り込 みです。200ラインの画面の場合、VBLANK期間以外の期間に周期的に 200回、 割り込みが発生することになります。しかし、この HSYNC割り込みの処理のな かでどのような表示処理が行われているか、そしてそれをエミュレータで再現 するにはどうすればいいのか、いろいろと考慮する点が多そうです。 ---------------------------------------------------------------------- ●その他あれこれ エンディアンネス ================ メモリや 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 を持つものは古くからあります。 これらの 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 などでは、高速化のために内部で分岐予測を行っています。 つまり、分岐先を統計的に予測して予め処理を行っておこう、という わけです。この予測が当たれば高速な処理ができますが、予測がはず れれば正しい分岐先の処理をやり直すわけで、かえって処理速度が落 ちたりします。分岐先に偏りが無い(ランダムな)場合は、予想が外 れやすそうなので、これを避けるためにプログラムの時点で分岐を無 くすようにしておきます。 ・アセンブラで書く 最終手段がこれ。ただし生産性も保守性も下がるし、移植性もなくな ります。下手なコードを書くと、コンパイラの生成するコードよりも 遅かったりします。本当に高速化が必要な数行分だけをインラインア センブルで記述する、というのはそれなりに有効でしょう。 ====================================================================== 実践編・エミュレータを作ろう ======================================================================