たるいと申します。
FPGAファミコンをスクラッチで作ろうと活動しております。
本記事はHardware Description Language Advent Calendar 2025 15日目の記事です。
はじめに
「レトロゲーム」は、FPGAの活用例としてよく知られている分野の一つです。
中でもファミコン(NES)開発は挑戦者が多く、技術資料や先人の知見も豊富に公開されています。そのため、学習題材としても非常に魅力的で、やりがいのあるプロジェクトと言えるでしょう。
本記事では、ファミコン開発のごく初期段階として、「HELLO WORLD」を実行できる最小限の機能を持つCPUを自作することを目標にします。
CPUは Veryl で記述し、Verilator を用いてシミュレーション実行するところまでをゴールとします。
まずは「動くCPUを自分の手で作る」体験を通して、以降の本格的なファミコン開発への足がかりを作っていきましょう。
ファミコンのCPU
言わずと知れた家庭用ゲーム機の金字塔、それが**ファミリーコンピュータ(ファミコン)**です。
その心臓部となるCPUには、当時 Apple II などにも採用されていた MOS Technology 6502 系のCPUコアが使われています。
本記事では、この6502をそのまま完全再現するのではなく、
「HELLO WORLD」の前半の命令を実行できる、動作理解に必要な最低限の命令セットに絞った簡易版 6502 CPUを自作します。
HELLO WORLD自体、必要な命令数はたったの11個!(かつ割り込みも不要)
たった11命令ですよ?これなら作れそうではありませんか?ぜひトライしてみましょう。
Verylに慣れている方なら2-3時間程度で作成できると思います。
開発環境
本記事で使用する開発環境は以下の通りです。
- Ubuntu 22.04(WSL 環境でも可)
- Veryl 0.17.1
- Verilator 5.042
Verilatorについて
本記事では Verilator 5 系以降 が必須です。
ただし、Ubuntu 22.04 の公式 apt リポジトリからインストールされる Verilator は** 4 系列** のため、そのままでは要件を満たしません。
そのため、以下の公式リポジトリから ソースコードを取得してビルド・インストールしてください。
Verilator 公式 GitHub
https://github.com/verilator/verilator
Git Quick Install 手順
https://verilator.org/guide/latest/install.html
上記の「Git Quick Install」に従えば、特に迷うことなく導入できます。
Verylについて
Veryl のインストール方法については、以下の公式ドキュメントを参照してください。
Veryl 公式ドキュメント(インストール)
https://doc.veryl-lang.org/book/ja/03_getting_started/01_installation.html
Hello, World!
本記事で使用するサンプルソフトは、
ファミコン技術の日本語解説として非常に定評のある「NES研究室」様が作成されたものです。
このプログラムを起動すると、本来は画面に 「Hello World!」 と表示されます。
処理内容は非常にシンプルで、概ね次の流れになっています。
- プログラム ROM からデータを読み出す
- PPUのレジスタにいろいろWriteする
- 処理完了後、無限ループに入る
いわゆる「最小構成の表示プログラム」です。
ただし今回は PPU の実装は行いません。
(解説を書く時間が間に合わなかったというのが正直なところです…)
そのため、実際に画面に文字が表示されることはなく、
CPU の実行ログを追うことで正しく動作していることを確認します。
本来、ファミコンで最も面白く、そして最も難しいのは PPU 周りなのですが、
現時点では自分自身も「人に解説できるレベル」まで理解が追いついていないため、
今回は CPU 部分にフォーカスすることにしました。
作成手順
ソースコードは以下のGithubレポジトリに置いています。
不明点がある場合はコードをご確認ください。
https://github.com/tarusake/tarunes
Verylプロジェクトを作る
Verylのプロジェクトフォルダを作りましょう。
自分はtarunesとしました。
空のsrcフォルダが作られるので、その中にソースコードを記述していきましょう。
$ veryl new tarunes
[INFO ] Created "tarunes" project
$ cd tarunes
$ git init
メモリインタフェースの記述
以下の通り、メモリインターフェースを記述します。
実機のバスを踏襲して、非常にシンプルなバスとなっています。
ファイルを作成し終わったら
veryl fmt
veryl build
コマンドで、記述が正しいかチェックしておきましょう。
interface bus_if::<DATA_WIDTH: u32, ADDR_WIDTH: u32> {
var addr : logic<ADDR_WIDTH>;
var wen : logic ;
var wdata: logic<DATA_WIDTH>;
var rdata: logic<DATA_WIDTH>;
modport master {
addr : output,
wen : output,
wdata: output,
rdata: input ,
}
modport slave {
..converse(master)
}
}
メモリ、ROMの記述
作成したメモリインターフェースを利用してメモリを作成します。
今回はこれで、RAMとROM両方に対応します。
module memory::<DATA_WIDTH: u32, ADDR_WIDTH: u32> #(
param PATH: string = "",
) (
clk : input clock ,
rst : input reset ,
membus: modport bus_if::<DATA_WIDTH, ADDR_WIDTH>::slave,
) {
var mem: logic<DATA_WIDTH> [2 ** ADDR_WIDTH];
initial {
if PATH != "" {
$readmemh(PATH, mem);
}
}
always_ff {
if_reset {
membus.rdata = 0;
} else {
membus.rdata = mem[membus.addr];
if membus.wen {
mem[membus.addr] = membus.wdata;
}
}
}
}
CPUスケルトンの作成
CPUスケルトンを作成します。
ファミコンのCPUはリセット時にROMカートリッジ内のデータである、0xFFFC、0xFFFDを読んで、その値をPC(プログラムカウンタ)に設定します。
まずはそこまでを記述します。
module cpu (
clk : input clock ,
rst : input reset ,
cpubus: modport bus_if::<8, 16>::master,
) {
// CPU Registers
var reg_a : logic<8> ; // Accumulator
var reg_x : logic<8> ; // X register
var reg_y : logic<8> ; // Y register
var reg_sp: logic<8> ; // Stack pointer
var reg_pc: logic<16>; // Program counter
var reg_p : logic<8> ; // Status Register
enum cpu_state_t {
RESET0,
RESET1,
RESET2,
RESET3,
FETCH,
DECODE,
EXEC,
}
var state: cpu_state_t;
always_ff {
if_reset {
state = cpu_state_t::RESET0;
cpubus.addr = 0;
cpubus.wen = 0;
cpubus.wdata = 0;
} else {
case state {
// 0xFFFCをセット
cpu_state_t::RESET0: {
cpubus.addr = 16'hFFFC;
state = cpu_state_t::RESET1;
}
// 0xFFFDをセット
cpu_state_t::RESET1: {
cpubus.addr = 16'hFFFD;
state = cpu_state_t::RESET2;
}
// RESET0でセットしたデータが反映されるので読む
cpu_state_t::RESET2: {
cpubus.addr = 16'h0000;
reg_pc[7:0] = cpubus.rdata;
state = cpu_state_t::RESET3;
}
// RESET1でセットしたデータが反映されるので読む
cpu_state_t::RESET3: {
reg_pc[15:8] = cpubus.rdata;
state = cpu_state_t::FETCH;
}
}
}
}
}
バスを作成
CPUとPROM、WRAMを接続します。
アドレスマップは以下の通りです。
| アドレス | 内容 | 説明 |
|---|---|---|
| 0000-07FF | WRAM | CPUのRAM、2KBなので、アドレス幅は11bit |
| 0800-1FFF | WRAMミラー | 0000-07FFのミラー、今回は使わないけど一応実装しておく |
| 2000-7FFF | 今回は使わない | |
| 8000-FFFF | プログラムROM | カートリッジのデータ、32KBなので、アドレス幅は15bit |
リードデータは1clk遅れるため、リードデータのセレクト信号は1clk遅れです。
ROM領域へは書き込みしないため、wen、wdataはゼロ固定です。
module bus (
clk : input clock ,
rst : input reset ,
cpubus : modport bus_if::<8, 16>::slave ,
wrambus: modport bus_if::<8, 11>::master,
prombus: modport bus_if::<8, 15>::master,
) {
var sel_prom: logic;
var sel_wram: logic;
// Addr sel
assign sel_wram = cpubus.addr <: 16'h2000;
assign sel_prom = cpubus.addr >= 16'h8000;
// address assign
assign prombus.addr = if sel_prom ? cpubus.addr : 'x;
assign wrambus.addr = if sel_wram ? cpubus.addr : 'x;
// Write bus assign
assign wrambus.wdata = if sel_wram ? cpubus.wdata : 'x;
assign wrambus.wen = if sel_wram ? cpubus.wen : 'x;
// rom
assign prombus.wdata = 0;
assign prombus.wen = 0;
// Read bus decode
var sel_prom_d: logic;
var sel_wram_d: logic;
always_ff {
if_reset {
sel_prom_d = 0;
sel_wram_d = 0;
} else {
sel_prom_d = sel_prom;
sel_wram_d = sel_wram;
}
}
always_comb {
if (sel_prom_d) {
cpubus.rdata = prombus.rdata;
} else if (sel_wram_d) {
cpubus.rdata = wrambus.rdata;
} else {
cpubus.rdata = 'x;
}
}
}
接続
TOPモジュールで以下のように接続します。
module top (
clk: input clock,
rst: input reset,
) {
inst cpubus : bus_if::<8, 16>;
inst wrambus: bus_if::<8, 11>;
inst prombus: bus_if::<8, 15>;
inst cpu_inst: cpu (
clk : clk ,
rst : rst ,
cpubus: cpubus,
);
inst wram: memory::<8, 11> (
clk : clk ,
rst : rst ,
membus: wrambus,
);
inst prom: memory::<8, 15> (
clk : clk ,
rst : rst ,
membus: prombus,
);
inst ubus: bus (
clk : clk ,
rst : rst ,
cpubus : cpubus ,
prombus: prombus,
wrambus: wrambus,
);
}
テストベンチの作成
今回はテストベンチをSystemverilogで記述します。
srcフォルダ内に作成してください。
initial beginの中で何個かテスト命令を直接書いています。
`timescale 1ns/1ps
module tb_top;
reg clk = 0;
reg rst = 0;
tarunes_top dut (
.clk(clk),
.rst(rst)
);
// クロック生成(10ns = 100MHz)
always #5 clk = ~clk;
initial begin
$dumpfile("wave.vcd");
$dumpvars(0, tb_top);
// リセットベクタ (FFFC = 00h, FFFD = 80h)
dut.prom.mem[16'h7FFC] = 8'h00;
dut.prom.mem[16'h7FFD] = 8'h80;
// SEI
dut.prom.mem[16'h0000] = 8'h78;
// LDX #$FF
dut.prom.mem[16'h0001] = 8'hA2;
dut.prom.mem[16'h0002] = 8'hFF;
// TXS
dut.prom.mem[16'h0003] = 8'h9A;
// LDA #$00
dut.prom.mem[16'h0004] = 8'hA9;
dut.prom.mem[16'h0005] = 8'h00;
// リセット解除
#20 rst = 1;
// 必要なサイクルだけ回す
#200;
$display("PC = %h", dut.cpu_inst.reg_pc);
$finish;
end
endmodule
Makefileの作成
Makefileを作成しましょう。
これはsrcフォルダではなく、プロジェクトのルートに作成してください。
VERILATOR_FLAGS := -Wall --trace --Wno-fatal --top-module tb_top --binary
RTL_DIR := target
RTL_SRCS := $(wildcard $(RTL_DIR)/*.sv)
all: build
veryl-fmt:
veryl fmt
veryl-build: veryl-fmt
veryl build
build: veryl-build
verilator $(VERILATOR_FLAGS) \
-I$(RTL_DIR) \
src/tb_top.sv $(RTL_SRCS)
run:
./obj_dir/Vtb_top
clean:
rm -rf obj_dir *.vcd tb_top
.PHONY: all build run clean veryl-fmt veryl-build
シミュレーションの実行
make
を実行すると、
- VerylからSystemverilogの生成
- Verilatorでのコンパイル
が実行され、./obj_dir/Vtb_top に実行ファイルが作成されます。
make run (もしくは./obj_dir/Vtb_top 直打ち)でシミュレーションが実行できます。
ログにPC=8000が表示されていればOK
$ make run
./obj_dir/Vtb_top
PC = 8000
- src/tb_top.sv:45: Verilog $finish
- S i m u l a t i o n R e p o r t: Verilator 5.042 2025-11-02
- Verilator: $finish at 220ns; walltime 0.002 s; speed 83.005 us/s
- Verilator: cpu 0.003 s on 1 threads; alloced 126 MB
デバッグ用信号の追加
stateが数字なので非常にわかりにくいです。
デバッグ用の信号を追加します。
function debug_cpu_state_str (
state: input cpu_state_t,
) -> logic<64> {
case (state) {
cpu_state_t::RESET0: return "RESET0";
cpu_state_t::RESET1: return "RESET1";
cpu_state_t::RESET2: return "RESET2";
cpu_state_t::RESET3: return "RESET3";
cpu_state_t::FETCH : return "FETCH";
cpu_state_t::DECODE: return "DECODE";
cpu_state_t::EXEC : return "EXEC";
}
}
let state_debug: logic<64> = debug_cpu_state_str(state);
再度
make
make run
を実行し、state_debug信号をASCII表示させるといい感じになります。

FETCHステートを追加
リセットが正しく終わって、命令フェッチの準備ができましたので、
FETCHステートを作りましょう。
reg_pcをメモリアドレスにセットします。
2clk後にデータが返ってくるので、フラグを立てて1clk待つことにします。
// RESET1でセットしたデータが反映されるので読む
cpu_state_t::RESET3: {
reg_pc[15:8] = cpubus.rdata;
state = cpu_state_t::FETCH;
}
// PCを読み込む、データが返ってきたらDECODEステートへ
cpu_state_t::FETCH: {
cpubus.addr = reg_pc;
cpubus.wen = 0;
mem_ready = 1;
// 1clk待つ
if (mem_ready) {
mem_ready = 0;
state = cpu_state_t::DECODE;
}
}
DECODEステートの時にリードデータが8'h78になっているはずです。
命令リスト追加
必要な命令のenumを作成します。
パッケージ等で分けてもいいのですが、ここではcpu.verylにベタ書きしてしまいましょう。
// CPU Inst(only helloworld)
enum opcode_t: logic<8> {
// Register Control
SEI = 8'h78,
TXS = 8'h9A,
INX = 8'hE8,
DEY = 8'h88,
// Load & Store
LDA_IMM = 8'hA9,
LDA_ABX = 8'hBD,
LDX_IMM = 8'hA2,
LDY_IMM = 8'hA0,
STA_ABS = 8'h8D,
// Branch
BNE = 8'hD0,
// JMP
JMP_ABS = 8'h4C,
}
最初の命令:SEI
最初の命令はSEIです。
割り込みを無効にするため、ステータスレジスタのbit[2]を1にします。
※ただし、本CPUはそもそも割り込み機能がないため、動作に影響は与えません。
この命令は簡単なので、DECODEステートで処理してしまいましょう。
この時に$displayで命令を実行した記述を追加しておきます(これがCPUトレースになります)。
まず、DECODEステートでopcodeを保存しておくため、opcode変数を作っておきます。
// opcode
var opcode: logic<8>;
DECODEステートの記述は以下です。
デバッグ記述、フラグ立てる(本体)、プログラムカウンタのインクリメント、フェッチステートへの移動、を行います。
cpu_state_t::DECODE: {
opcode = cpubus.rdata;
case (cpubus.rdata) {
// IRQフラグ無効
// ただし割り込み未実装のため現時点では意味はない
opcode_t::SEI: {
$display("[DECODE] PC=%04h OPCODE=SEI", reg_pc);
reg_p[2] = 1'b1;
state = cpu_state_t::FETCH;
reg_pc = reg_pc + 1;
}
}
}
SIM実行すると、SEI命令が実行された後、reg_p[2]=1になっていることがわかります。

おっと、ステータスレジスタを初期化していなかったので、リセットに追加します。
ステータスレジスタの初期値は8'h24です。
(つまりもともとフラグは立っていたのでこの命令は特に意味はなかったということですね!)
しれっとほかのレジスタの初期化も追加しておきます。
always_ff {
if_reset {
state = cpu_state_t::RESET0;
cpubus.addr = 0;
cpubus.wen = 0;
cpubus.wdata = 0;
reg_p = 8'h24; // bit[5]=1,bit[2]=1
reg_pc = 16'h00;
} else {
即値命令:LDX_IMMを実装する
次に実行される命令はLDX_IMMです。
IMMは即値命令のことですね。命令の次のメモリアドレスに格納されている値をレジスタXに書き込みます。わかりやすいですね。
次の命令を読み込む必要があるため、命令の実行はEXECステートで実行することにします。
DECODEステートに以下の記述を追加します。
// 即値命令
opcode_t::LDX_IMM: {
cpubus.addr = reg_pc + 1;
mem_ready = 1;
if (mem_ready) {
mem_ready = 0;
state = cpu_state_t::EXEC;
}
}
すると、EXECステートで即値を読み込みます(ここではFF)

つまり、EXECステートで、レジスタXにこの値を書き込んでやればよいです。
処理が終わった後はFETCHステートに戻るのをお忘れなく。
おっと、LDX命令はステータスレジスタの更新も必要です。
更新後のレジスタの値がゼロならbit1(Zeroフラグ)を、
レジスタの値が負(符号bitすなわちbit7=1)なら、bit7(負フラグ)を、立ててください。
reg_xへの反映は1clk遅れるので、このステートでは、cpubus.rdataを直接読んでしまいましょう。
2バイト長さの命令なので、プログラムカウンタを2だけ増加させてFETCHステートに戻ります。
cpu_state_t::EXEC: {
case (opcode) {
opcode_t::LDX_IMM: {
$display("[EXECUTE] PC=%04h LDX_IMM", reg_pc);
reg_x = cpubus.rdata;
reg_p[1] = (cpubus.rdata == 0);
reg_p[7] = cpubus.rdata[7];
reg_pc = reg_pc + 2;
state = cpu_state_t::FETCH;
}
}
}
TXSを実装する
TXSはスタックポインタにレジスタXの値を代入するだけです。TXSはフラグ更新しなくてOKなのでとても楽。
reg_spの初期値は8'hFDな点にだけはご注意を。
always_ff {
if_reset {
state = cpu_state_t::RESET0;
...
reg_sp = 8'hFD;
reg_p = 8'h24; // bit[5]=1,bit[2]=1
cpu_state_t::DECODE: {
...
opcode_t::TXS: {
$display("[DECODE] PC=%04h OPCODE=TXS", reg_pc);
reg_sp = reg_x;
// TXSはフラグ更新しない
state = cpu_state_t::FETCH;
reg_pc = reg_pc + 1;
}
LDA_IMMを実装する
tb_top.svに書いた最後の命令です!
が
DECODEステートはLDX_IMMと同じ、
EXECステートはコピペでOKです。
この辺をうまく実装するとどんどん手が抜けます(回路サイズにも効いてくるはず)
cpu_state_t::DECODE: {
...
// 即値命令
opcode_t::LDA_IMM,
opcode_t::LDX_IMM : {
cpubus.addr = reg_pc + 1;
mem_ready = 1;
if (mem_ready) {
mem_ready = 0;
state = cpu_state_t::EXEC;
}
}
cpu_state_t::EXEC: {
case (opcode) {
opcode_t::LDA_IMM: {
$display("[EXECUTE] PC=%04h LDA_IMM", reg_pc);
reg_a = cpubus.rdata;
reg_p[1] = (cpubus.rdata == 0);
reg_p[7] = cpubus.rdata[7];
reg_pc = reg_pc + 2;
state = cpu_state_t::FETCH;
}
helloworldを読み込む
tb_top.svに書いた命令すべて実行してしまいましたね…
続きの命令を追加しましょう…としたいところですが、一つずつテストベンチに追加するのはやめて、これからはカートリッジROMをテストベンチに読み込めるようにしましょう。
helloworld_prg.hexを作る
ファミコンのROMは.nesという拡張子がデファクトスタンダードです。
このファイルにプログラムROMのデータと、キャラクタROMのデータがバイナリで格納されています。RTLシミュレーションで使いやすいように、.nesファイルをプログラムROM、キャラクタROM用の.hexに変換するスクリプトを作成します。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# NES ROM (.nes) を読み込み、PRG ROM / CHR ROM を HEXファイルとして出力するスクリプト
import sys
import os
def save_hex(data, filename):
"""バイナリデータを1バイトずつHEXで1行出力"""
with open(filename, "w") as f:
for b in data:
f.write(f"{b:02X}\n")
print(f"{filename} に {len(data)} バイト書き込み完了")
def main():
if len(sys.argv) < 2:
print("Usage: python extract_hex.py <romfile.nes>")
sys.exit(1)
rom_path = sys.argv[1]
base_name = os.path.splitext(os.path.basename(rom_path))[0]
with open(rom_path, "rb") as f:
header = f.read(16)
if header[:4] != b"NES\x1A":
print("不正なNESファイルです")
sys.exit(1)
prg_size = header[4] * 16 * 1024 # 16KB単位
chr_size = header[5] * 8 * 1024 # 8KB単位
print(f"PRG ROM size: {prg_size} bytes ({header[4]} x 16KB)")
print(f"CHR ROM size: {chr_size} bytes ({header[5]} x 8KB)\n")
prg_data = f.read(prg_size)
chr_data = f.read(chr_size)
# 出力ファイル名
prg_out = f"{base_name}_prg.hex"
chr_out = f"{base_name}_chr.hex"
# 出力
save_hex(prg_data, prg_out)
if chr_size > 0:
save_hex(chr_data, chr_out)
else:
print("このROMにはCHR ROMが含まれていません(CHR RAMタイプ)")
if __name__ == "__main__":
main()
NES研究室様のhelloworld.zipをダウンロードしてください。解凍したファイルに含まれるsample1.nesをプロジェクトフォルダにコピーします。helloworld.nesとリネームしておきます。
以下コマンドを打つと、helloworldのプログラムROMファイル「helloworld_prg.hex」が生成されます。
chmod +x nes2hex.py
./nes2hex.py helloworld.nes
helloworld_prg.hexをSIMに読み込む
topモジュールから.hexファイルを指定できるようにします。
module top #(
param PROM_PATH: string = "",
) (
clk: input clock,
rst: input reset,
) {
...
inst prom: memory::<8, 15> #(
PATH: PROM_PATH,
) (
clk : clk ,
rst : rst ,
membus: prombus,
);
tb_top.svから.hexファイルを指定します。
dut.prom.mem[xxx]でメモリ値を代入していた記述は削除します。
SIM時間を少し長くしておきます。
tarunes_top #(
.PROM_PATH("helloworld_prg.hex")
)
dut (
.clk(clk),
.rst(rst)
);
...
// 必要なサイクルだけ回す
#1000;
未実装命令を読んだときにエラーを出す
未実装命令を読んだときにメッセージを出すようにします。
こうすることで次に実装するべき命令がわかるようになります。
cpu_state_t::DECODE: {
...
default: {
$display("[DECODE] Unknown Opcode : PC=%04h OPCODE=%02h", reg_pc, cpubus.rdata);
}
}
make
make run
を実行すると以下のログが出てきます。
8D(STA_ABS)を実装する必要がありますね。
$ make run
./obj_dir/Vtb_top
[DECODE] PC=8000 OPCODE=SEI
[EXECUTE] PC=8001 LDX_IMM
[DECODE] PC=8003 OPCODE=TXS
[EXECUTE] PC=8004 LDA_IMM
[DECODE] Unknown Opcode : PC=8006 OPCODE=8d
STA_ABSの実装
STA_ABSは、
3バイトの命令で、8D 下位アドレス 上位アドレスです。
上位アドレス(8bit)、下位アドレス(8bit)からできた16bitアドレスへAレジスタの値をコピーします。このメモリアクセスをAbsolute(ABS)と呼びます。
3バイト命令なので、新しいステート、OP1を追加して、OP1(下位アドレス)を保存しておきます。
var op1 : logic<8>;
...
enum cpu_state_t {
...
DECODE,
OP1,
...
function debug_cpu_state_str (
...
cpu_state_t::DECODE: return "DECODE";
cpu_state_t::OP1 : return "OP1";
DECODEステートで、下位アドレスを読むステート(OP1)に移動させます。
cpu_state_t::DECODE: {
...
// それ以上の命令
opcode_t::STA_ABS: {
cpubus.addr = reg_pc + 1;
mem_ready = 1;
if (mem_ready) {
mem_ready = 0;
state = cpu_state_t::OP1;
}
}
OP1ステートでは、上位アドレスを読んでEXECに移動させます。
cpu_state_t::OP1: {
case (opcode) {
opcode_t::STA_ABS: {
op1 = cpubus.rdata;
cpubus.addr = reg_pc + 2;
mem_ready = 1;
if (mem_ready) {
mem_ready = 0;
state = cpu_state_t::EXEC;
}
}
}
}
EXECステートでは、以下のようにして、メモリライトを実施します。
cpu_state_t::EXEC: {
...
opcode_t::STA_ABS: {
$display ("[OP2] PC=%04h OPCODE=STA_ABS", reg_pc);
cpubus.addr = {cpubus.rdata, op1};
cpubus.wdata = reg_a;
cpubus.wen = 1'b1;
reg_pc = reg_pc + 3;
state = cpu_state_t::FETCH;
}
実行すると、2000番地に02を書き込んでいます。
実際ここはPPUレジスタになります。今回はPPUがないので空振りです。
LDY_IMMの追加
さて、次に実装するのはLDY_IMMです。
これはもう説明不要ですね。LDX_IMM、LDA_IMMと同じように実装してください。
LDA_ABXの追加
次に実装するのはLDA_ABXです。LDAなので、指定されたメモリの値をレジスタAへロードするなのですが、メモリのアドレスの算出方法が特殊です。
3バイトの命令で、BD 下位アドレス 上位アドレスなのですが、
Absolute Index Xでは上位アドレス(8bit)、下位アドレス(8bit)からできた16bitアドレスにXレジスタの値を加算したアドレスへのアクセスを示します。
この処理をするためにOP2ステートを追加します。
var op2 : logic<8>;
...
enum cpu_state_t {
...
OP1,
OP2,
...
function debug_cpu_state_str (
...
cpu_state_t::OP1 : return "OP1";
cpu_state_t::OP2 : return "OP2";
DECODEステートではSTA_ABSと同じでOKです。
cpu_state_t::DECODE: {
...
// それ以上の命令
opcode_t::STA_ABS,
opcode_t::LDA_ABX : {
cpubus.addr = reg_pc + 1;
mem_ready = 1;
if (mem_ready) {
mem_ready = 0;
state = cpu_state_t::OP1;
}
}
OP1ステートでは、新たに作ったOP2ステートへ遷移させます
cpu_state_t::OP1: {
...
opcode_t::LDA_ABX: {
op1 = cpubus.rdata;
cpubus.addr = reg_pc + 2;
mem_ready = 1;
if (mem_ready) {
mem_ready = 0;
state = cpu_state_t::OP2;
}
}
}
}
OP2ステートでAbsolute Xのアドレス計算をします。
cpu_state_t::OP2: {
case (opcode) {
opcode_t::LDA_ABX: {
cpubus.addr = {cpubus.rdata, op1} + reg_x;
mem_ready = 1;
if (mem_ready) {
mem_ready = 0;
state = cpu_state_t::EXEC;
}
}
}
}
最後にEXECステートでLDAの処理に追加します。
LDA_IMMは2バイト命令なので、プログラムカウンタを+2すればOKでしたが、
この命令は+3しないといけないので、if文で分けておきます。
cpu_state_t::EXEC: {
case (opcode) {
opcode_t::LDA_IMM,
opcode_t::LDA_ABX : {
$display("[EXECUTE] PC=%04h LDA_XXX", reg_pc);
reg_a = cpubus.rdata;
reg_p[1] = (cpubus.rdata == 0);
reg_p[7] = cpubus.rdata[7];
if (opcode == opcode_t::LDA_IMM) {
reg_pc = reg_pc + 2;
} else {
reg_pc = reg_pc + 3;
}
state = cpu_state_t::FETCH;
}
INX,DEY追加
INX,DEYを追加します。
簡単なので2つまとめて実装しちゃいましょう!
INXはXレジスタをインクリメントし、DEYはYレジスタをデクリメントします。
(もちろんDEX,INYもあります。実装してもいいです。)
以下のようにして、計算します。
letを使うとwireのように即反映されます。中間変数として使っています。
cpu_state_t::DECODE: {
...
opcode_t::INX: {
$display("[DECODE] PC=%04h OPCODE=INX", reg_pc);
let result : logic<8> = reg_x + 1;
reg_x = result;
reg_p[1] = (result == 0);
reg_p[7] = result[7];
state = cpu_state_t::FETCH;
reg_pc = reg_pc + 1;
}
opcode_t::DEY: {
$display("[DECODE] PC=%04h OPCODE=DEY", reg_pc);
let result : logic<8> = reg_y - 1;
reg_y = result;
reg_p[1] = (result == 0);
reg_p[7] = result[7];
state = cpu_state_t::FETCH;
reg_pc = reg_pc + 1;
}
BNE追加
さあ、ついに(?)ブランチ命令です。
BNEは
Branch if Not Equalはステータスレジスタのゼロフラグがクリアされていた場合、分岐します。
すなわち、reg_p[1]=0の時分岐です。そうでない場合は分岐せず次の命令に行きます。
分岐先は、次のアドレスの直値です。符号付きで計算する点に注意してください。
2バイト命令なので、DECODEステートではほかの即値命令と同じところに入れてOKです。
cpu_state_t::DECODE: {
...
// 即値命令
opcode_t::LDA_IMM,
opcode_t::LDX_IMM,
opcode_t::LDY_IMM,
opcode_t::BNE : {
cpubus.addr = reg_pc + 1;
mem_ready = 1;
if (mem_ready) {
mem_ready = 0;
state = cpu_state_t::EXEC;
}
}
EXECステートで分岐を決めます。
符号付きで計算するのにちょっとごちゃごちゃさせてます。
(もっとスマートな書き方がありそうな気はしてる。)
cpu_state_t::EXEC: {
case (opcode) {
// Z=0なら分岐する
opcode_t::BNE: {
$display("[EXECUTE] PC=%04h OPCODE=BNE", reg_pc);
if (reg_p[1] == 0) {
reg_pc = $signed(reg_pc) + 2 + {cpubus.rdata[7] repeat 8, cpubus.rdata};
} else {
reg_pc = reg_pc + 2;
}
state = cpu_state_t::FETCH;
}
あと、サイクル数が伸びてきたのでtb_top.svのサイクル数を増やしておきます。
// 必要なサイクルだけ回す
#10000;
JMP_ABSの追加
ついに(helloworldでは)最後の命令です!
JMP_ABSは、後続の2バイト(下位、上位)で指定されたアドレスにジャンプします。とても簡単ですね。
helloworldではJMP_ABSに飛ぶように指定して無限ループとして使っています。
3バイトの命令なので、まずはOP1ステートに飛びます。
cpu_state_t::DECODE: {
...
// それ以上の命令
opcode_t::STA_ABS,
opcode_t::LDA_ABX,
opcode_t::JMP_ABS : {
cpubus.addr = reg_pc + 1;
mem_ready = 1;
if (mem_ready) {
mem_ready = 0;
state = cpu_state_t::OP1;
}
}
OP1では、STA_ABSと同じでOK。
cpu_state_t::OP1: {
case (opcode) {
opcode_t::STA_ABS,
opcode_t::JMP_ABS : {
op1 = cpubus.rdata;
cpubus.addr = reg_pc + 2;
mem_ready = 1;
if (mem_ready) {
mem_ready = 0;
state = cpu_state_t::EXEC;
}
}
EXECステートで、ジャンプ先のアドレスをプログラムカウンタに代入して
FETCHステートに戻ればOKです。
cpu_state_t::EXEC: {
...
opcode_t::JMP_ABS: {
$display("[EXECUTE] PC=%04h OPCODE=JMP_ABS", reg_pc);
reg_pc = {cpubus.rdata, op1};
state = cpu_state_t::FETCH;
}
これでHelloworld!で使う命令をすべて実装しました!
おめでとうございます!
実行ログ
ログを見ると、今まで実装した命令が実行されていることがわかりますね。
最後はJMP_ABSの無限ループです。
$ make
$ make run
./obj_dir/Vtb_top
[DECODE] PC=8000 OPCODE=SEI
[EXECUTE] PC=8001 LDX_IMM
[DECODE] PC=8003 OPCODE=TXS
[EXECUTE] PC=8004 LDA_IMM
...
[EXECUTE] PC=8046 OPCODE=STA_ABS
[EXECUTE] PC=8049 LDA_XXX
[EXECUTE] PC=804b OPCODE=STA_ABS
[EXECUTE] PC=804e OPCODE=JMP_ABS
[EXECUTE] PC=804e OPCODE=JMP_ABS
[EXECUTE] PC=804e OPCODE=JMP_ABS
[EXECUTE] PC=804e OPCODE=JMP_ABS
[EXECUTE] PC=804e OPCODE=JMP_ABS
[EXECUTE] PC=804e OPCODE=JMP_ABS
[EXECUTE] PC=804e OPCODE=JMP_ABS
PC = 804e
- src/tb_top.sv:30: Verilog $finish
まとめ
今回は、ファミコンエミュレータ実装の最初の一歩として、
最低限必要となる ROM / RAM / CPU命令 を実装し、
それらを用いてシミュレーションを行うところまでを解説しました。
エミュレータ開発は、一見すると難しそうに感じますが、
実際にはこのように必要な要素を一つずつ実装して積み上げていくことで、
少しずつ形になっていきます。
今回紹介した手順をベースに、他の命令や機能を順番に追加していくことで、
ファミコンエミュレータは完成へと近づいていきます。
思った以上に長くなってしまったので、今回はこのあたりで一区切りとします。
続きでは、命令の追加や割り込み、PPU 周りなども取り上げていければと思っています。
(やっぱり画面が出ないと面白くないですよね)
また気が向いたときに続きを書く予定ですので、
興味のある方はぜひお付き合いください。
参考文献
-
NES研究室
非常にわかりやすい日本語情報のサイトです。
サンプルソフトはこちらからダウンロードさせていただきました。 -
NES Dev
英語情報ですが、非常に詳しく内容が網羅されています。 -
veryl-riscv-book
Verylを使ってCPUを作る流れがとても丁寧に解説されています。



