36
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FPGAファミコンのはじめかた

Last updated at Posted at 2025-12-14

たるいと申します。
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!」 と表示されます。
処理内容は非常にシンプルで、概ね次の流れになっています。

  1. プログラム ROM からデータを読み出す
  2. PPUのレジスタにいろいろWriteする
  3. 処理完了後、無限ループに入る

いわゆる「最小構成の表示プログラム」です。

ただし今回は 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
コマンドで、記述が正しいかチェックしておきましょう。

bus_if.veryl
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両方に対応します。

memory.veryl
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(プログラムカウンタ)に設定します。
まずはそこまでを記述します。

cpu.veryl
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はゼロ固定です。

bus.veryl
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モジュールで以下のように接続します。

top.veryl
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の中で何個かテスト命令を直接書いています。

tb_top.sv
`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フォルダではなく、プロジェクトのルートに作成してください。

Makefile
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

波形で見ても、reg_pc=8000となっていますね。
image.png

デバッグ用信号の追加

stateが数字なので非常にわかりにくいです。
デバッグ用の信号を追加します。

cpu.veryl
    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表示させるといい感じになります。
image.png

FETCHステートを追加

リセットが正しく終わって、命令フェッチの準備ができましたので、
FETCHステートを作りましょう。
reg_pcをメモリアドレスにセットします。
2clk後にデータが返ってくるので、フラグを立てて1clk待つことにします。

cpu.veryl
                // 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になっているはずです。

image.png

命令リスト追加

必要な命令のenumを作成します。
パッケージ等で分けてもいいのですが、ここではcpu.verylにベタ書きしてしまいましょう。

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変数を作っておきます。

cpu.veryl
    // opcode
    var opcode: logic<8>;

DECODEステートの記述は以下です。
デバッグ記述、フラグ立てる(本体)、プログラムカウンタのインクリメント、フェッチステートへの移動、を行います。

cpu.veryl
                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になっていることがわかります。
image.png

おっと、ステータスレジスタを初期化していなかったので、リセットに追加します。
ステータスレジスタの初期値は8'h24です。
(つまりもともとフラグは立っていたのでこの命令は特に意味はなかったということですね!)
しれっとほかのレジスタの初期化も追加しておきます。

cpu.veryl
    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ステートに以下の記述を追加します。

cpu.veryl
                        // 即値命令
                        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)
image.png

つまり、EXECステートで、レジスタXにこの値を書き込んでやればよいです。
処理が終わった後はFETCHステートに戻るのをお忘れなく。
おっと、LDX命令はステータスレジスタの更新も必要です。
更新後のレジスタの値がゼロならbit1(Zeroフラグ)を、
レジスタの値が負(符号bitすなわちbit7=1)なら、bit7(負フラグ)を、立ててください。
reg_xへの反映は1clk遅れるので、このステートでは、cpubus.rdataを直接読んでしまいましょう。
2バイト長さの命令なので、プログラムカウンタを2だけ増加させてFETCHステートに戻ります。

cpu.veryl
                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;
                        }
                    }
                }

reg_xにFFが反映され、ステータスも更新されてますね。
image.png

TXSを実装する

TXSはスタックポインタにレジスタXの値を代入するだけです。TXSはフラグ更新しなくてOKなのでとても楽。
reg_spの初期値は8'hFDな点にだけはご注意を。

cpu.veryl
    always_ff {
        if_reset {
            state        = cpu_state_t::RESET0;
...
            reg_sp       = 8'hFD;
            reg_p        = 8'h24; // bit[5]=1,bit[2]=1
cpu.veryl
                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.veryl
                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.veryl
                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に変換するスクリプトを作成します。

nes2hex.py
#!/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ファイルを指定できるようにします。

top.veryl
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時間を少し長くしておきます。

tb_top.sv
    tarunes_top #(
        .PROM_PATH("helloworld_prg.hex")
        )
        dut (
        .clk(clk),
        .rst(rst)
    );
    ...
    // 必要なサイクルだけ回す
    #1000;

未実装命令を読んだときにエラーを出す

未実装命令を読んだときにメッセージを出すようにします。
こうすることで次に実装するべき命令がわかるようになります。

cpu.veryl
                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(下位アドレス)を保存しておきます。

cpu.veryl
    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.veryl
                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.veryl
                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.veryl
                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がないので空振りです。

image.png

LDY_IMMの追加

さて、次に実装するのはLDY_IMMです。
これはもう説明不要ですね。LDX_IMM、LDA_IMMと同じように実装してください。

LDA_ABXの追加

次に実装するのはLDA_ABXです。LDAなので、指定されたメモリの値をレジスタAへロードするなのですが、メモリのアドレスの算出方法が特殊です。
3バイトの命令で、BD 下位アドレス 上位アドレスなのですが、
Absolute Index Xでは上位アドレス(8bit)、下位アドレス(8bit)からできた16bitアドレスにXレジスタの値を加算したアドレスへのアクセスを示します。

この処理をするためにOP2ステートを追加します。

cpu.veryl
    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.veryl
                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.veryl
                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.veryl
                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.veryl
                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追加

INXDEYを追加します。
簡単なので2つまとめて実装しちゃいましょう!
INXはXレジスタをインクリメントし、DEYはYレジスタをデクリメントします。
(もちろんDEX,INYもあります。実装してもいいです。)

以下のようにして、計算します。
letを使うとwireのように即反映されます。中間変数として使っています。

cpu.veryl
                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.veryl
                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.veryl
                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のサイクル数を増やしておきます。

tb_top.sv
        // 必要なサイクルだけ回す
        #10000;

JMP_ABSの追加

ついに(helloworldでは)最後の命令です!
JMP_ABSは、後続の2バイト(下位、上位)で指定されたアドレスにジャンプします。とても簡単ですね。
helloworldではJMP_ABSに飛ぶように指定して無限ループとして使っています。
3バイトの命令なので、まずはOP1ステートに飛びます。

cpu.veryl
                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.veryl
                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.veryl
                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を作る流れがとても丁寧に解説されています。

36
27
0

Register as a new user and use Qiita more conveniently

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?