Loading...
2025/12/03
この記事はProgate Path コミュニティ Advent Calendar 2025 4日目の記事です。一日目の記事を書かれていたさどるふさんとおしゃべりをしていたときに教えていただいたChip-8を実際に実装してみた記事です。
Chip-8の実装を示しつつ「プログラムがどのように動作するか?」を解説しようと思ったのですが、思いの外解説パートと実装パートが別れてしまいました。実装パートには余談を入れたりしているので、まぁ暇なときに読んでもらえれば良いと思います。
「プログラムはコンパイルによってコンピュータが読めるバイナリというものに変換してから実行する」という説明はよくなされます。実際、私も文脈に応じてこのような説明を試みることは多々ありますし、このような説明を耳にしたことのある人は多いのではないでしょうか。 しかし、コンパイルされたバイナリがどのようにして実行されているのか、という点についてはブラックボックスとして取り扱われていがちです。どうやってCPUがバイナリを"読ん"で、どのように"計算"をしているのか、といった内容を、Chip-8の実装を通じて書いていきます。
ここまで当たり前にバイナリの話を書いていますが、そもそもバイナリとはどういうものかを説明します。
バイナリとは?ということを調べてみると、以下のような主張を目にすることでしょう。
情報技術においては、コンピュータが直接的に処理するために2進数で表現されるデータのことを指すが、一般的にテキスト形式以外のデータ形式を指す言葉として用いられる。バイナリデータ、バイナリコード、バイナリ形式などとも呼ばれる。対義語はテキストファイル。 (バイナリ - Wikipedia)
しかし、これらは人間の都合でしかなく、コンピュータで扱う任意のファイルはコンピュータ上で0/1の列として扱われますから、任意のファイルはバイナリである、という主張が出来ます。
少なくともここではcomputer-friendlyなテキストファイルがバイナリであるということにしましょう。例として、実際にChip-8のROMとしてMAZEを見てみましょう。…とはいってもバイナリは人間の目で読むことが難しいので、代わりにhex-dump(16進数表示)を見てみることにします。

MAZEとは、「迷路を表示させる」というプログラムです。たったこれだけの、34 byteという長さに迷路を表示させるための命令が詰め込まれていることが分かります。右側にはASCII文字として解釈した場合のテキストが表示されていますが、全く意味のある文章になっていない(そもそも文字/記号以外の制御文字が含まれている場合もある)ことからもcomputer-friendlyな形式であるというのが分かると思います。
Chip-8のバイナリは2byte(= 8bit)を一命令としていますが、例えばx86-64アーキテクチャでは1~8byteで可変で、RISC-Vアーキテクチャでは基本的には4byteと、コンピュータの性能向上に伴い扱える命令セットが増えていることが感じられます。
バイナリについて話したので、ここではメインとなるCPUについての話をします。CPUとは計算をする装置です。ここでの計算とは四則演算や剰余、比較に加えて、コードの実行を手助けする命令(if文による分岐を思い浮かべてもらうと良いと思います)、あるいはメモリ上に保存された値を読む・メモリ上に値を書き込む、等といったことが該当します。
これらの計算それぞれには、対応するオペコードと呼ばれる番号が割り当てられています。命令を実行する際には、サイクルに基づいて命令を実行するタイミングでROMから命令を読み出し、その計算を行います。
Chip-8で扱える計算はたったの35種類です(オペコード一覧)。一覧を見れば分かると思いますが、いくつかの計算の種類ごとに先頭の文字が分けられており非常に合理的になっています。
オペコード一覧を見ていると、 VX, VYといった謎の文字がたくさん出てきています。実際にはこれらのX, Yは何かしらの値であり、V0 だったりV8だったりするわけですが。これはハードウェアとして存在するレジスタを表しています。
レジスタはCPUが利用できる変数のようなもので、そのサイズは一つあたり8bitです。CPUは何かしらの計算をする際、直接メモリ上の値を参照せず、一度レジスタに値を読み込みその値を用いて計算を行います。なぜこんな面倒なことを?と思うかもしれませんが、メモリへの値の参照がレジスタへの参照に比べて非常に遅いことが主な理由でしょう。
Chip-8には自由に利用可能なレジスタが16個あり、それぞれV0からVFまでの名前がついています。ただし、VFレジスタは直接つかわず、フラグレジスタとして利用されるようです。
その他にも特別なレジスタがあり、例えばProgram Counter(PCレジスタ)という「次に実行するべき命令はメモリ上のどこに格納されているか」を表すレジスタがあります。命令を実行するというのはすなわち、Program Counterが指すアドレスの命令を読み込み、その読み込んだ命令に従って計算をする、ということなのです。
ここまでで概ねコンピュータについての解説をしましたが、実は非自明な状態のものが一つだけ残っています。何度か「メモリ」に触れていましたが、これが何かをまだ解説していません。
コンピュータの文脈でメモリ、というと多くの場合RAM(Random Access Memory)を指します。CPUにレジスタがありますが、高々16個しか自由に使えませんから、それ以上の値を保存するためにはこのRAMが必須になります。Chip-8において特に標準的なサイズが決まっているわけではないようですが、エミュレータでは多くの場合4096byte(4KB)だけの用意されることが多いようです。
現代のコンピュータにおいて、メモリは以下の4つの領域を持ちます。
テキスト領域はバイナリが、データ領域はstaticな変数やグローバル変数などのために割り当てられる領域です。
そして、重要になるのがヒープとスタック。スタック領域には色々な値が積まれます(詳細は低レイヤを知りたい人のためのCコンパイラ作成入門をやってみるのが良いと思います)が、多くの人にとってわかりやすいのはローカル変数でしょう。ではヒープ領域には何が?となるところですが、ここはプログラム中の動的なメモリ確保が出来ます。
わかり易い例は単方向連結リストでしょう。以下のような(疑似)コードを考えます。
typedef struct { Node* nxt; int32_t value; } Node; Node* create_node(int value); int32_t main() { Node* head = create_node(0); for(int32_t i=0; i<100; i+=1) { Node* new_head = create_node(i+1); new_head.nxt = head; head = new_head; } return 0; }
Nodeがいくつ作られるかは人間の目からは明らかですが、プログラムからそれを知るのは困難です。人間からみてもいくつ作られるかわからない場合だってあります。 スタック領域に確保するためには関数の実行時に「その関数内で利用する変数の数とサイズ」を知っている必要があるので、このような場合ではスタックにはおけません。
そこで利用するのがヒープです。ノードを作るたびにヒープ領域にint32_tと次の要素がどこかを指すアドレスを持てる領域を確保し、そのヒープへのアドレスをスタック上で持つことにします。単方向リストの先頭リストへのアドレスはスタックで持つ、実際の要素はヒープ上で持つ、というような分担がなされています。
このあたりの話は慣れるまではややこしい(し私自身も正しく理解している自信がない)のですが、知っているとmalloc の気持ちなどが何となく分かるようになったり、まあ色々嬉しいことがあったりなかったりする気がします。
ところで、身近なものにもう一つ「メモリ」と呼ばれるものがあります。Read-Only Memory、ROMですね。Chip-8の文脈では例えばゲームそのもののデータはROMとなります。ROMへのアクセスはかなり遅いので、プログラムを実行する際はROMのデータをRAMに一度コピーしてから実行することになります。
はじめはAn Introduction to Video Game Emulation with Rustを参考にしていたのですが、途中から方針転換してCowgod’s Chip-8 Technical Reference (とGemeniによる実装順の提案)を参考に自力実装することにしました。言語は趣味により、Rustです。
必要なものを用意します。i_regはIレジスタで、RAMの要素を読み書きする際に利用します。また、現代のコンピュータではスタックはRAM上に確保されることが多いですが、Chip-8では別途スクラッチパッドメモリとして用意されるようなので、それに従います。
pub const SCREEN_WIDTH: usize = 64; pub const SCREEN_HEIGHT: usize = 32; const RAM_SIZE: usize = 4096; const NUM_REGS: usize = 16; const STACK_SIZE: usize = 16; const NUM_KEYS: usize = 16; const START_ADDR: u16 = 0x200; pub struct Emu { pc: u16, ram: [u8; RAM_SIZE], screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT], v_reg: [u8; NUM_REGS], i_reg: u16, stack_pointer: u16, stack: [u16; STACK_SIZE], keys: [bool; NUM_KEYS], delay_timer: u8, sound_timer: u8, } impl Emu { pub fn new() -> Self { Self { pc: START_ADDR, ram: [0; RAM_SIZE], screen: [false; SCREEN_WIDTH * SCREEN_HEIGHT], v_reg: [0; NUM_REGS], i_reg: 0, stack_pointer: 0, stack: [0; STACK_SIZE], keys: [false; NUM_KEYS], delay_timer: 0, sound_timer: 0, } } }
ROMをロードしないことには何も動かせないので、事前に用意したRAM領域上にROMをロードします。ROMをファイルから読み込んで、それを1byteずつRAMへと書き込んでいきます。
アーキテクチャの仕様として、0x200番値以降にROMを書き込む必要があるので、そこから順に書き込んでいきます。現代のコンピュータとは違って複数のプロセスがどう、などは気にする必要がなく、単に書き込んでいけばよいです。
pub fn load_rom(&mut self, buffer: &Vec<u8>) { for (i, &byte) in buffer.iter().enumerate() { if 0x200 + i < 4096 { self.ram[0x200 + i] = byte; } } }
ここから実際の命令の実装をしていきます。はじめは、IBM Logoを出せるようにするのが良いと言われたので、そのとおりにやっていきます。
現在のpcとpc+1から値を取得し、それを順につなげたものを16bitの命令として解釈します。その後、pcを進めておきます。
let opcode = { let a = self.ram[self.pc] as u16; let b = self.ram[self.pc + 1] as u16; (a << 8) | b }; self.pc += 2;
まずは00E0 (画面クリア)、 1nnn (アドレスジャンプ)、6xkk (レジスタへのロード)、Annn (Iレジスタの設定)、Dxyn (描画命令)を実装します。
00E0 (画面クリア)ディスプレイをすべてfalseにすればよいです。
fn screen_clear(&mut self) { for i in 0..SCREEN_WIDTH * SCREEN_HEIGHT { self.screen[i] = false; } }
1nnn (アドレスジャンプ)nnnの部分を、opcode & 0x0FFFとbit maskで取り出せばよいですね。
let addr = opcode & 0x0FFF; self.pc = addr;
6xnn (レジスタへのロード)これもbit maskを頑張ればよいです。
let x = (opcode & 0x0F00) >> 8; let val = opcode & 0x00FF; self.v_reg[x as usize] = val as u8;
7xnn (レジスタへの加算)これもまたbit maskを頑張ればよいです。
let reg = (opcode & 0x0F00) >> 8; let val = opcode & 0x00FF; self.v_reg[reg as usize] += val as u8;
Annn (Iレジスタの設定)これもやばりbit maskを頑張ればよいです。
let val = opcode & 0x0FFF; self.i_reg = val;
Dxyn (描画命令)これは厄介。直感に反するのですが、スクリーン上の座標で(Vx, Vy) から(Vx+8, Vy+n) の長方形領域を描画します。について、メモリアドレスI + i から1byte分を読み取り、その読み取り結果を1bitずつ見てスクリーンの現在の表示とのXOR(排他的論理和)を計算し、それをスクリーンの表示とします。IはIレジスタに格納されている値です。
self.v_reg[0xf] = 0; let x = (opcode & 0x0F00) >> 8; let y = (opcode & 0x00F0) >> 4; let n = opcode & 0x000F; let vx = self.v_reg[x as usize] as usize % SCREEN_WIDTH; let vy = self.v_reg[y as usize] as usize % SCREEN_HEIGHT; for i in 0..n as usize { if vy + i == SCREEN_HEIGHT { break; } let sprite = self.ram[self.i_reg as usize + i]; for j in (0..8).rev() { let targ = (sprite >> j) & 1; if targ == 1 { let idx = vx + (7 - j) + (vy + i) * SCREEN_WIDTH; self.screen[idx] = !self.screen[idx]; if !self.screen[idx] { self.v_reg[0xf] = 1; } } } }
これらを実装し、IBMのロゴを表示するROMを実行すると、以下のように表示されます。(表示部分はGeminiに丸投げしました。非本質なので。)

実装の全体はこちらからどうぞ。
フラクタル図形を再帰的に描画するROMを実行することを目標にします。
ここでいくつかの8xyn 系の実装が必要になりますが、これらをすべて実装してしまいます。基本的には代入を伴う算術演算なのでそこまで難しくありませんが、いくつかVF レジスタへの影響のある物があるので注意しながら実装します。ORやXOR, ANDなどの論理演算も含まれますが、これらはすべてbitwiseで計算をします。とはいえ、数値間の論理演算は多くの言語でbitwiseでの計算が行われるでしょうから、やるだけではあります。
閑話休題。ここで、レジスタの話を少し書いてみます。楽しくなって実装の話をたくさん書いていますが、元の目的はコンピュータがどのように動作するかを解説することですからね。
現在のコンピュータでも使われるx86-64アーキテクチャではeflagsという名前でフラグレジスタ専用のレジスタがあります。どのような条件でフラグが立つのかを調べてみると、以下のようなものがあるようです。
主なフラグには、キャリーフラグ(CF)、ゼロフラグ(ZF)、符号フラグ(SF)、オーバーフローフラグ(OF)、パリティフラグ(PF)などがあります。
Chip-8に存在しない機能として特有なのが、ゼロフラグと符号フラグでしょう。前者は演算結果が0である、後者は演算結果の最上位bitが1となる場合にフラグが立ちます。これらはif文の実行のためによく用いられます。例えばa==b であれば、a-b を計算してゼロフラグが立っているか否かで分岐をする、ということが行われます。
1970年代に開発されたアーキテクチャが、現代のアーキテクチャの持つ機能の多くを既に持っていた、というところで驚嘆させられますね。
続いていくつかFxnn 系の実装も必要になるので、必要なものを実装していきます。F系は利用するレジスタが一つだけなので下8bitを識別として使っているようですが、そのナンバリングの法則はよくわかりません。必要なのは下2桁が1E, 55, 65の3つです。
Fx55, Fx65 はメモリアドレスIから順に、それぞれV0レジスタからVxレジスタまでへ格納/書き込みするであって、V0レジスタからV「Vxの持つ値」レジスタまでではないことに注意が必要です(一敗)。
更に、スキップ系の命令を一気に実装してしまいます。スキップというとよくわからないような気がしますが、次の命令を無視する、実装上は単に追加でProgram Counterを2進めるというだけです。これに該当するのは3xnn、4xnn、5xy0 、9xy0の4つです。3xnnと4xnnは対になっていて、3はVxレジスタとnnが同じであればスキップ、4は異なっていればスキップです。5と9はレジスタ同士の比較で、5はVxレジスタとVyレジスタが同じであればスキップ、9は異なればスキップとなっています。
ここまで実装すれば、以下のようなフラクタル図形の出力が得られます。実装例はこちらにあります。

続いて、BC testを動作させられるようにします。これは各種機能が正しく動作しているかをチェックできるROMだそうです。
さて、そのためには追加でF系に対応する処理を実装する必要があります。
Fx29Iレジスタを、Vxの値に対応するフォント位置のアドレスに変更します。例えば、1に対応するフォントは以下のようになっています。
00100000 (0x20) 01100000 (0x60) 00100000 (0x20) 00100000 (0x20) 01110000 (0x70)
実際に表示をするDxyn を実装するセクションで、「長方形領域を更新するという直感的ではない仕様となっている」と述べましたが、このフォントの表し方を見ればそんな仕様も納得ですね。
フォントがどこに置かれるかは自由に決めて良さそうなので、ここではメモリ上の0xD00 をベースとして、それ以降に配置することにしました。困ったら後で変えるかもしれません。
Fx33果たしてどこで使うのやらという感じですが、Vx レジスタの値を10進数で解釈したときに、一の位をI+2 、十の位をI+1 、百の位をI に格納します。
これらを実装しBC testを実行すると以下の表示が得られます。うまくいってますね。

続いて、冒頭のバイナリの話で上げたMAZEをここらで表示してみます。とはいってもここで新規に必要になる実装はほとんどなく、Cxkk を実装すれば動きます。Cxkk はランダム命令で、0~255の乱数を生成し、kk とのANDを取った値をVxに代入する、という命令です。AND命令でMASKまでできるというは少し驚きです。
乱数を生成する、というのはそれこそプログラムを初めて書いた頃から何度も行ってきたことだと思います。しかし、乱数とはなにか、というのは意外と考えたことがないかもしれません。
乱数には大きく2種類存在します。一つは擬似乱数で、これがよく使われる”乱数”です。疑似、ということから分かるように、真に乱数ではなく非常に長い周期でループをする、決定論的に計算可能な値です。この性質から、seedを再現することで全く同じ挙動を再現可能になります(乱数のはずなのに!)。 もう一つはハードウェア乱数生成器で、例えばコンピュータの排熱などが電圧に及ぼすノイズを値としてみなすなどによって生成されます。これが全く予想不可能な値を取ることは容易に想像できます。
こう見てみるとハードウェア乱数生成器を用いれば良いのではないか?という気持ちになりますが、そうも行きません。擬似乱数を生成するコストに対してハードウェア乱数を生成するコストが非常に大きかったりという問題があるからです。そのため、つい最近に至るまで擬似乱数の研究というのは続けられているようです。例えば、2014年にはPCGと呼ばれる新たな擬似乱数が発表されていたりもします。この話は長くなりそうなので、気が向けば別の記事で書こうと思います。
なお、Chip-8でどのような生成方法を用いているかという文献は見当たりませんでしたが、おそらくハードウェアで生成しているものと思われます。
Rustの標準ライブラリには乱数モジュールが含まれていないので、randクレートを利用します。
let x = (opcode & 0x0F00) >> 8; let mask = opcode & 0x00FF; let mut rng = rng(); self.v_reg[x as usize] = (rng.random_range(0..256) & mask) as u8;
実装をして実行すると、以下のように迷路のような表示が得られます。実行毎に異なる模様になっているのも分かりますね。

ここまで来ると、Demoの殆どが動くようになっています。残りのParticles、Zero Demoに必要な実装を終わらせて、Demoをコンプリートしましょう。
まず、2nnn と00EEを実装します。
2nnn はスタック上に戻り先のアドレスとして現在のProgram Counterを置いておきつつ、アドレスnnn へとジャンプします。これは アドレスnnn に置かれた関数を呼び出している、という認識をしておくとよいでしょう。
00EEはRET命令───多くの言語でreturnと表現される命令です。Chip-8においては2nnnによってスタックに配置された戻り先アドレスへと戻って行く、という命令になります。
実装上はstack_pointerを少しいじるだけで出来ますから、比較的簡単な部類だと思います。これでPARTICLESが動作するようになります。
続けてZero Demoを動かすため、 Fx15 の実装に取り掛かります。これはDelay TimerをVx レジスタの値で上書きする、という処理です。これ自体は簡単な代入なので、すぐに実装できます。
しかしDelay Timerはタイマーです。60Hzで一カウント(つまり、1/60秒で一カウント)減らす必要があり、その処理を書かなければなりません。これらはコードの実行とは独立に行われなければなりません。そこで以下のような実装をしました。
pub fn execute(&mut self) { static FPS: u64 = 60; let frame_duration = Duration::from_micros(1_000_000 / FPS); let instructions_per_frame = 500 / FPS; loop { let frame_start = Instant::now(); for _ in 0..instructions_per_frame { self.fetch(); } self.update_timers(); self.display(); let elapsed = frame_start.elapsed(); if elapsed < frame_duration { sleep(frame_duration - elapsed); } } } fn update_timers(&mut self) { if self.delay_timer > 0 { self.delay_timer -= 1; } if self.sound_timer > 0 { self.sound_timer -= 1; } }
コードの実行は500Hz、1フレームあたり9, 10命令ほど進めます。その後にタイマーとディスプレイの更新を行い、1フレームの経過するまで待機します。
タイマーが0になったときの処理はROM側がポーリングによって勝手にしてくれるので、エミュレータで対応する必要はありません。これでZero Demoも動くようになったはずです。以下は動くZero Demoの一フレームを切り取った画像です。

実装全体はここにあります。
ここまででほとんどの命令を実装しましたが、まだいくつか足りていない命令があります。ここではそれらすべての実装をしていきます。まず、ここまで出てきていないのが不思議ではありますがBnnn を実装します。これはV0 + nnn でPCを更新するという命令で、ここまできた方であれば実装はそう難しくないでしょう。続いて、Fx07 のVx レジスタにDelay Timerの値を代入、もしてしまいましょう。
そして、残り足りていないのはキーパッドに関する命令で、Ex9E 、ExA1 、Fx0A の3つです。Ex9E 、 ExA1 はVxレジスタの値が現在押されているかどうかで処理を分けるもので、Ex9E は押されていれば次の命令をスキップ、ExA1 はその逆です。
Fx0A は少し特殊で、同期的に入力を受け付けて、その入力値をVx レジスタへと代入します。これらはCLI上でそのままするのは難しいので、crosstermを導入して解決しました。
ここまですべて実装すると、色々なゲームで遊べるようになります。お疲れ様でした!
最終的な完成物はGitHubのardRiriy/chip-8においてあります。操作性はめちゃくちゃ悪いですが、実装の参考にはなるかもしれません。あと、もしかしたら実装漏れがあるかもしれませんのでもし気づいたらこっそり教えてください(?)。
なんやかんや12時間足らずで完成したので実装量的にはかなり軽いですし、コンピュータの色々なことに思いを馳せることが出来て私としては幸せでした。
…この記事で何を書きたかったかは、あまり考えないことにしておきます。