Why not login to Qiita and try out its useful features?

We'll deliver articles that match you.

You can read useful information later.

5
1

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ファミコンのはじめかた(2) PPU編

Last updated at Posted at 2025-12-20

たるいと申します。
前回の記事が思ったより伸びてくれて、ちょっとうれしいです。

ということで調子に乗って、今回はPPU編をはじめていきます。
やっぱり、画面が出ないと楽しくないですよね。

Githubレポジトリはこちらです。実装全体を見たい方はどうぞ。
https://github.com/tarusake/tarunes/tree/hello-ppu

今回のゴール

Verilator のシミュレーションで、最終的に以下のような画面が表示されるところまでを目指します。
以下の画面を出すのに 直接関係しない部分は、容赦なく非実装で進めていきましょう。

tarunes (Ubuntu-22.04) 2025_12_17 8_58_07.png

ファミコンのPPU

PPUは、ファミコンで画面表示を担当するチップです。
最低限、以下のスペックを押さえておけば大丈夫です。

  • 解像度:256×240
  • 色数:最大52色(同時発色数は少なめ)
  • 動作周波数:CPUの3倍(今回は実装を簡単にするため、CPUと同じ周波数で動かします)
  • PPUアドレス空間:14bit(16KB)

シミュレーション時にGUIを出す

PPUに限らずですが、画像を生成する回路を作ったときに「どうやって結果を確認するか」 は、地味に悩ましい問題です。

古典的な方法だと、

  • ログを見て心の目で確認する
  • PGM形式で画像を吐く
  • 期待値ファイルを用意してDiffを取る(だいたい独自フォーマットになりがち)

といった手段が多いかなと思います。

今回は、もう少し直感的でモダンな方法を使いましょう。
Verilator を使っているので、C++でテストベンチを書くことができます。
つまり、C++で使えるGUIライブラリがそのまま使えるということです。

ここではSDL2を使って、シミュレーション結果をそのままウィンドウに表示します。
(画面が出ると単純に楽しいので、この機能はできるだけ早めに実装しておくとモチベーションが上がります)

というわけで、まずは既存のシミュレーションをC++テストベンチに移植するところから始めます。

テストベンチのC++への移植

src フォルダ内に tb_top.cpp を作成します。
Verilator の C++ テストベンチは、だいたい次のような形になるかと思います。

tb_top.cpp
#include "Vtarunes_top.h"
#include "verilated.h"
#include "verilated_vcd_c.h"

int main(int argc, char** argv) {
    Verilated::commandArgs(argc, argv);

    Verilated::traceEverOn(true);

    Vtarunes_top* dut = new Vtarunes_top;
    VerilatedVcdC* tfp = new VerilatedVcdC;
    dut->trace(tfp, 99);
    tfp->open("wave.vcd");

    dut->clk = 0;
    dut->rst = 0;

    const int RESET_CYCLES = 4;
    const int SIM_CYCLES   = 1000;

    for (int cycle = 0; cycle < SIM_CYCLES; cycle++) {

        if (cycle == RESET_CYCLES)
            dut->rst = 1;

        // Low
        dut->clk = 0;
        dut->eval();
        tfp->dump(Verilated::time());
        Verilated::timeInc(1);

        // High
        dut->clk = 1;
        dut->eval();
        tfp->dump(Verilated::time());
        Verilated::timeInc(1);
    }

    tfp->close();
    delete dut;
    delete tfp;
    return 0;
}

Makefile も書き換えていきましょう。
……とはいえ、このあたりはサクッと生成AIさんに作ってもらえますね。

ポイントは以下の通りです。

  • C++テストベンチ(tb_top.cpp)を使用
  • --cc:C++向けのモデルを生成
  • --exe:自前のC++テストベンチをリンク
  • --top-module:Verilogのトップモジュール名を指定(tarunes_top)
  • -CFLAGS:C++17を指定(tb_top.cppで必要)

Makefile はこんな感じになります。

Makefile
VERILATOR      := verilator
VERILATOR_FLAGS := -Wall --trace --Wno-fatal -GPROM_PATH=\"helloworld_prg.hex\"

RTL_DIR  := target
RTL_SRCS := $(wildcard $(RTL_DIR)/*.sv)

TB_CPP := src/tb_top.cpp
TOP    := tarunes_top

all: build

veryl-fmt:
	veryl fmt

veryl-build: veryl-fmt
	veryl build

build: veryl-build
	$(VERILATOR) $(VERILATOR_FLAGS) \
		--cc $(RTL_SRCS) \
		--top-module $(TOP) \
		--exe $(TB_CPP) \
		-I$(RTL_DIR) \
		-CFLAGS "-std=c++17"

	make -C obj_dir -f V$(TOP).mk

run:
	./obj_dir/V$(TOP)

clean:
	rm -rf obj_dir *.vcd

.PHONY: all build run clean veryl-fmt veryl-build

この後

make
make run

を実行すると、これまでと同様に
シミュレーションが走り、ログ(と波形)が出力されます。

なお、あとから気づいたのですが、make clean のあとにそのまま make を実行すると、RTL_SRCS が空になってしまいエラーになってしまいますね…

その場合は、もう一度 make を実行してください。

  • 1回目の make:Veryl から RTL が生成される
  • 2回目の make:生成された RTL が RTL_SRCS に正しく認識される

※このあたりは、後ほど修正します。

GUI出力の追加

tb_top.cpp に SDL2 を使ったGUI出力の処理を追加します。
おまじない的なコードが多いので、ここでは「だいたいこんな感じなんだな」くらいで大丈夫です。
この時点では pixel_buffer の中身はすべて 0 なので、表示されるのは 真っ黒な画面になります。

tb_top.cpp
#include "Vtarunes_top.h"
#include "verilated.h"
#include "verilated_vcd_c.h"
#include <SDL2/SDL.h>
#include <cstdint>
#include <cstdio>
#include <cstdlib>

int main(int argc, char** argv) {
    Verilated::commandArgs(argc, argv);

    Verilated::traceEverOn(true);

    Vtarunes_top* dut = new Vtarunes_top;
    VerilatedVcdC* tfp = new VerilatedVcdC;
    dut->trace(tfp, 99);
    tfp->open("wave.vcd");

    // SDL2の初期化
    if (SDL_Init(SDL_INIT_VIDEO) != 0) {
        fprintf(stderr, "SDL_Init Error: %s\n", SDL_GetError());
        exit(1);
    }

    // ウィンドウサイズ: 256x240を2倍スケーリング -> 512x480
    const int SCREEN_WIDTH = 256;
    const int SCREEN_HEIGHT = 240;
    const int SCALE = 2;
    const int WINDOW_WIDTH = SCREEN_WIDTH * SCALE;
    const int WINDOW_HEIGHT = SCREEN_HEIGHT * SCALE;

    SDL_Window* window = SDL_CreateWindow("tarunes",
                                          100,
                                          100,
                                          WINDOW_WIDTH, WINDOW_HEIGHT,
                                          SDL_WINDOW_SHOWN);
    if (!window) {
        fprintf(stderr, "SDL_CreateWindow Error: %s\n", SDL_GetError());
        SDL_Quit();
        exit(1);
    }

    SDL_Renderer* renderer = SDL_CreateRenderer(window, -1,
                                                SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);
    if (!renderer) {
        fprintf(stderr, "SDL_CreateRenderer Error: %s\n", SDL_GetError());
        SDL_DestroyWindow(window);
        SDL_Quit();
        exit(1);
    }

    SDL_Texture* texture = SDL_CreateTexture(renderer,
                                             SDL_PIXELFORMAT_ARGB8888,
                                             SDL_TEXTUREACCESS_STREAMING,
                                             SCREEN_WIDTH, SCREEN_HEIGHT);
    if (!texture) {
        fprintf(stderr, "SDL_CreateTexture Error: %s\n", SDL_GetError());
        SDL_DestroyRenderer(renderer);
        SDL_DestroyWindow(window);
        SDL_Quit();
        exit(1);
    }

    // ピクセルバッファ (ARGB8888)
    uint32_t pixel_buffer[SCREEN_HEIGHT][SCREEN_WIDTH];
    memset(pixel_buffer, 0, sizeof(pixel_buffer));

    dut->clk = 0;
    dut->rst = 0;

    const int RESET_CYCLES = 4;
    const int SIM_CYCLES   = 1000;

    for (int cycle = 0; cycle < SIM_CYCLES; cycle++) {

        if (cycle == RESET_CYCLES)
            dut->rst = 1;

        // Low
        dut->clk = 0;
        dut->eval();
        tfp->dump(Verilated::time());
        Verilated::timeInc(1);

        // High
        dut->clk = 1;
        dut->eval();
        tfp->dump(Verilated::time());
        Verilated::timeInc(1);

        // ピクセルバッファをテクスチャにコピーして表示
        SDL_UpdateTexture(texture, NULL, pixel_buffer, SCREEN_WIDTH * sizeof(uint32_t));
        SDL_RenderClear(renderer);
        SDL_RenderCopy(renderer, texture, NULL, NULL);
        SDL_RenderPresent(renderer);
    }

    // クリーンアップ
    SDL_DestroyTexture(texture);
    SDL_DestroyRenderer(renderer);
    SDL_DestroyWindow(window);
    SDL_Quit();

    tfp->close();
    delete dut;
    delete tfp;
    return 0;
}

Makefile も更新します。
ここでは SDL_CFLAGS と SDL_LDFLAGS を追加してください。

なお、まだ SDL2 をインストールしていない場合は、
あらかじめ以下のコマンドでインストールしておきましょう。

sudo apt install libsdl2-dev

ついでに、make clean のあとに make が失敗する問題を解消するため、
RTL_SRCS を使うのではなく、Verylが生成するリストファイル(tarunes.f) を
参照するように変更します。

これで、make clean 後でもそのまま make が通るようになります。

Makefile
VERILATOR      := verilator
VERILATOR_FLAGS := -Wall --trace --Wno-fatal -GPROM_PATH=\"helloworld_prg.hex\"

RTL_DIR  := target
VERYL_PROJ:= tarunes
TB_CPP := src/tb_top.cpp
TOP    := $(VERYL_PROJ)_top

SDL_CFLAGS := $(shell sdl2-config --cflags)
SDL_LDFLAGS := $(shell sdl2-config --libs)

all: build

veryl-fmt:
	veryl fmt

veryl-build: veryl-fmt
	veryl build

build: veryl-build
	$(VERILATOR) $(VERILATOR_FLAGS) \
		--cc \
		-f $(VERYL_PROJ).f \
		--top-module $(TOP) \
		--exe $(TB_CPP) \
		-I$(RTL_DIR) \
		-CFLAGS "-std=c++17 $(SDL_CFLAGS)" \
		-LDFLAGS "$(SDL_LDFLAGS)"

	make -C obj_dir -f V$(TOP).mk

run:
	./obj_dir/V$(TOP)

clean:
	rm -rf obj_dir target *.vcd

.PHONY: all build run clean veryl-fmt veryl-build

make が通ったら、続けて make run を実行してください。
真っ黒なウィンドウが表示されればOKです。

tarunes (Ubuntu-22.04) 2025_12_17 20_40_41.png

PPU追加

真っ黒な画面のままだとつまらないので、まずは 何かしら表示できるPPU を作ってみましょう。

ということで PPU.veryl を用意します。
……とはいえ、現時点ではまだ ハリボテPPU です。

ここでは最低限、

  • cycle(X座標)
  • scanline(Y座標)
  • RGB信号出力

だけを実装します。

画面解像度は 256×240 ですが、PPUの内部的な走査範囲は 341×262 なので、

  • cycle : 0〜340
  • scanline : 0〜261

の値を取るようにしています。

また、NES_PALETTE はハードウェアで固定された値です。
実際にはチップのバージョンや個体差によって微妙に色味が違うらしいのですが、今回はこの値を使います。

とりあえず動いているのが分かるように、横16ピクセルごとに色が変わる表示にしています。

ppu.veryl
module ppu (
    clk     : input  clock   ,
    rst     : input  reset   ,
    scanline: output logic<9>,
    cycle   : output logic<9>,
    pixel_r : output logic<8>,
    pixel_g : output logic<8>,
    pixel_b : output logic<8>,
) {

    var pixel_index  : logic<6> ;
    var palette_color: logic<24>;

    always_ff {
        if_reset {
            cycle    = 0;
            scanline = 0;
        } else {
            if (cycle == 340) {
                cycle = 0;
                if (scanline == 261) {
                    scanline = 0;
                } else {
                    scanline = scanline + 1;
                }
            } else {
                cycle = cycle + 1;
            }
        }
    }

    // NES Palette (64 colors) - RGB332 format
    const NES_PALETTE: logic<24> [64] = '{
        24'h808080, 24'h003DA6, 24'h0012B0, 24'h440096,
        24'hA1005E, 24'hC70028, 24'hBA0600, 24'h8C1700,
        24'h5C2F00, 24'h104500, 24'h054A00, 24'h00472E,
        24'h004166, 24'h000000, 24'h050505, 24'h050505,
        24'hC7C7C7, 24'h0077FF, 24'h2155FF, 24'h8237FA,
        24'hEB2FB5, 24'hFF2950, 24'hFF2200, 24'hD63200,
        24'hC46200, 24'h358000, 24'h058F00, 24'h008A55,
        24'h0099CC, 24'h212121, 24'h090909, 24'h090909,
        24'hFFFFFF, 24'h0FD7FF, 24'h69A2FF, 24'hD480FF,
        24'hFF45F3, 24'hFF618B, 24'hFF8833, 24'hFF9C12,
        24'hFABC20, 24'h9FE30E, 24'h2BF035, 24'h0CF0A4,
        24'h05FBFF, 24'h5E5E5E, 24'h0D0D0D, 24'h0D0D0D,
        24'hFFFFFF, 24'hA6FCFF, 24'hB3ECFF, 24'hDAABEB,
        24'hFFA8F9, 24'hFFABB3, 24'hFFD2B0, 24'hFFEFA6,
        24'hFFF79C, 24'hD7E895, 24'hA6EDAF, 24'hA2F2DA,
        24'h99FFFC, 24'hDDDDDD, 24'h111111, 24'h111111
    };

    always_comb {
        if (cycle <: 256 && scanline <: 240) {
            pixel_index = {2'b10, cycle[7:4]};
        } else {
            pixel_index = 'h0F;
        }

        // パレットルックアップ
        palette_color = NES_PALETTE[pixel_index];

        // 8ビットRGBに変換
        pixel_r = {palette_color[23:16]};
        pixel_g = {palette_color[15:8]};
        pixel_b = {palette_color[7:0]};
    }
}

それでは、作成したPPUを top.veryl に組み込みましょう。
あわせて、PPUから出力する信号に対応する端子の追加も忘れずに行います。

top.veryl
module top #(
    param PROM_PATH: string = "",
) (
    clk     : input  clock   ,
    rst     : input  reset   ,
    scanline: output logic<9>,
    cycle   : output logic<9>,
    pixel_r : output logic<8>,
    pixel_g : output logic<8>,
    pixel_b : output logic<8>,
) {
...
    inst ppu_inst: ppu (
        clk     : clk     ,
        rst     : rst     ,
        scanline: scanline,
        cycle   : cycle   ,
        pixel_r : pixel_r ,
        pixel_g : pixel_g ,
        pixel_b : pixel_b ,
    );
}

つぎに、scanline、cycle、pixel_r、pixel_g、pixel_b の値を使ってフレームバッファを更新する処理を追加します。
PPUから出力される各ピクセルのRGB値を、対応する座標のフレームバッファに書き込んでいく形です。
そして、最後の行の描画が終わったタイミングで、フレームバッファの内容を SDLのウィンドウに反映させます。
今回は10フレーム分描画したらシミュレーションを終了するようにします。

tb_top.cpp
    const int RESET_CYCLES = 4;
    const int MAX_FRAMES = 10;  // 10フレームで終了
    int frame_count = 0;
    int prev_scanline = 0;
    bool running = true;

    // メインループ
    while (running && frame_count < MAX_FRAMES) {

        // 1サイクルシミュレーション
        for (int half = 0; half < 2; half++) {
            if (Verilated::time() == RESET_CYCLES) {
                dut->rst = 1;
            }

            dut->clk = half;
            dut->eval();
            tfp->dump(Verilated::time());
            Verilated::timeInc(1);
        }

        // 有効ピクセル領域かチェック
        int scanline = dut->scanline;
        int cycle = dut->cycle;
        int pixel_r = dut->pixel_r;
        int pixel_g = dut->pixel_g;
        int pixel_b = dut->pixel_b;

        if (cycle < SCREEN_WIDTH && scanline < SCREEN_HEIGHT) {
            // 直接RGB値を使用
            // ARGB8888形式に変換 (Alpha=0xFF, R, G, B)
            pixel_buffer[scanline][cycle] = 0xFF000000 | 
                                           ((pixel_r & 0xFF) << 16) |
                                           ((pixel_g & 0xFF) << 8) |
                                           (pixel_b & 0xFF);
        }

        // フレームカウント: scanlineが0に戻ったら1フレーム完了
        if (prev_scanline == 261 && scanline == 0) {
            frame_count++;
            printf("Frame %d completed\n", frame_count);

            // ピクセルバッファをテクスチャにコピーして表示
            SDL_UpdateTexture(texture, NULL, pixel_buffer, SCREEN_WIDTH * sizeof(uint32_t));
            SDL_RenderClear(renderer);
            SDL_RenderCopy(renderer, texture, NULL, NULL);
            SDL_RenderPresent(renderer);
        }
        prev_scanline = scanline;
    }

    printf("Simulation finished after %d frames\n", frame_count);

make して、make run を実行してください。
今度は カラフルな画面 が表示されるはずです。

これで、PPUから出力された信号をGUI上で確認できるようになりました。
自分で書いた回路の出力がそのまま画面に出てくるの、なんかいいですよね。こういうの。

tarunes (Ubuntu-22.04) 2025_12_17 21_04_22.png

PPUバスの追加

PPU用のバスを作ります。
PPUのメモリ空間は16KB(14bit幅)で、メモリマップは以下の通りです。

アドレス 内容 説明
0000-1FFF キャラクタROM ROMカートリッジの内容です。
2000-2FFF VRAM ビデオRAMです。4KB分領域がありますが、実装されるVRAMは2KB分だけです。
3000-3EFF 未使用 未使用
3F00-3F0F BGパレットテーブル BG(背景)の色を選択します。
PPU内に実装しましょう。
3F10-3F1F スプライトパレットテーブル 今回は未使用なのでスルー。
3F20-3FFF 未使用 未使用

要するに、

  • 0000–1FFF → キャラクタROM
  • 2000–2FFF → VRAM

にアクセスが振り分けられればOKです。
なお、3F00 以降のパレット領域はPPU内部で完結させるため、外部バスには実装不要です。

bus_ppu.beryl
module bus_ppu (
    clk    : input   clock                  ,
    rst    : input   reset                  ,
    ppubus : modport bus_if::<8, 14>::slave ,
    crombus: modport bus_if::<8, 13>::master,
    vrambus: modport bus_if::<8, 11>::master,
) {

    var sel_crom: logic;
    var sel_vram: logic;

    // Addr sel
    assign sel_crom = ppubus.addr <: 16'h2000;
    assign sel_vram = ppubus.addr >= 16'h2000 && ppubus.addr <: 16'h3F00;

    // address assign
    assign crombus.addr = if sel_crom ? ppubus.addr : 'x;
    assign vrambus.addr = if sel_vram ? ppubus.addr : 'x;

    // Write bus assign
    assign vrambus.wdata = if sel_vram ? ppubus.wdata : 'x;
    assign vrambus.wen   = if sel_vram ? ppubus.wen : 'x;

    // rom
    assign crombus.wdata = 0;
    assign crombus.wen   = 0;

    // Read bus decode
    var sel_crom_d: logic;
    var sel_vram_d: logic;

    always_ff {
        if_reset {
            sel_crom_d = 0;
            sel_vram_d = 0;
        } else {
            sel_crom_d = sel_crom;
            sel_vram_d = sel_vram;
        }
    }

    always_comb {
        if (sel_crom_d) {
            ppubus.rdata = crombus.rdata;
        } else if (sel_vram_d) {
            ppubus.rdata = vrambus.rdata;
        } else {
            ppubus.rdata = 'x;
        }
    }

}

top.verylにキャラクタROM、VRAM、PPUを追加します。
VRAMは前作ったmemoryと同じでOKです。サイズは2KBなので11bit幅で。

top.veryl
module top #(
    param PROM_PATH: string = "",
    param CROM_PATH: string = "",
) (
    clk     : input  clock   ,
    rst     : input  reset   ,
    scanline: output logic<9>,
    cycle   : output logic<9>,
    pixel_r : output logic<8>,
    pixel_g : output logic<8>,
    pixel_b : output logic<8>,
) {
    // 追加
    inst ppubus : bus_if::<8, 14>;
    inst vrambus: bus_if::<8, 11>;
    inst crombus: bus_if::<8, 13>;
..
    inst ubus_ppu: bus_ppu (
        clk    : clk    ,
        rst    : rst    ,
        ppubus : ppubus ,
        crombus: crombus,
        vrambus: vrambus,
    );

    inst vram: memory::<8, 11> (
        clk   : clk    ,
        rst   : rst    ,
        membus: vrambus,
    );

    inst crom: memory::<8, 13> #(
        PATH: CROM_PATH,
    ) (
        clk   : clk    ,
        rst   : rst    ,
        membus: crombus,
    );
}

とりあえずmakeしてちゃんとなっているか確認しておきます。

VRAMへの書き込み

さて、HelloWorld!では、CPUは PPUのレジスタを経由して VRAMにデータを書き込んでいます。
そのため、このあたりの処理も実装していきましょう。

まずは、CPUバス側にPPUレジスタのアドレス範囲を登録します。
PPUのレジスタは、CPUメモリ空間上では 20002007 に割り当てられています。

ここでは、そのデコード処理をCPUバスに追加します。
以下は、追加した部分だけを抜き出したコードです
(ついでに、モジュール名を cpu_bus に変更しています)

bus_cpu.veryl
module bus_cpu (
...
    cpu_ppubus: modport bus_if::<8, 3>::master , 
...
) {
    var sel_ppu : logic; 
...
    assign sel_ppu  = cpubus.addr >= 16'h2000 && cpubus.addr <= 16'h2007;
...
    assign cpu_ppubus.addr = if sel_ppu ? cpubus.addr : 'x;
...
    assign cpu_ppubus.wdata = if sel_ppu ? cpubus.wdata : 'x;
    assign cpu_ppubus.wen   = if sel_ppu ? cpubus.wen : 'x;
...
    var sel_ppu_d : logic;
...
    always_ff {
        if_reset {
...
            sel_ppu_d  = 0;
...
        } else {
...
            sel_ppu_d  = sel_ppu;
...
        }
    }

    always_comb {
...
        } else if (sel_ppu_d) {
            cpubus.rdata = cpu_ppubus.rdata;
...

次に、PPUにレジスタ部を追加します。
ここでは CPU側からPPUを操作するためのインタフェースを実装します。
具体的には、

  • CPUバス側:cpubus(スレーブ)
  • PPU内部バス側:ppubus(マスタ)
    をPPUに持たせ、CPUからのレジスタアクセスを受け取れるようにします。

今回、最低限実装するPPUレジスタは以下の2つです。

  • $2006 : PPUADDR
  • $2007 : PPUDATA

PPUADDR($2006)

CPUから 2回に分けて書き込みを行い、
アクセスしたい PPUメモリ空間のアドレスを指定します。

  • 1回目の書き込み:上位8bit
  • 2回目の書き込み:下位8bit

というおなじみの仕様です。

PPUDATA($2007)

PPUADDRで指定したアドレスに対して、データの読み書きを行うレジスタです。
今回は 書き込み(Write)のみを実装します。

  • PPUDATAに書き込む
  • 指定されたPPUメモリマップのアドレスにデータが書き込まれる
  • 書き込み後、PPUADDRは自動でインクリメントされる

なお実機では、PPUレジスタ設定によってインクリメント量が +1 / +32 のどちらかになる仕様ですが、今回は該当するPPUレジスタを実装していないので、+1固定 として実装します。

このあたりまで実装できれば、CPUからVRAMやパレットRAMにデータを書き込むための最低限の土台が整います。

ppu.veryl
module ppu (
    clk     : input   clock                     ,
    rst     : input   reset                     ,
    cpubus  : modport bus_if::<8, 3>::slave     ,
    ppubus  : modport bus_if::<8, 14>::master   ,
...
    // VRAM Address Latch
    var reg_w: logic    ;
    var reg_v: logic<16>;

    var palette_ram: logic<8> [32];

    // register bus
    always_ff {
        if_reset {
            ppubus.addr  = 0;
            ppubus.wen   = 0;
            ppubus.wdata = 0;
            reg_w        = 0;
            reg_v        = 0;
            for i: u32 in 0..31 {
                palette_ram[i] = 0;
            }
        } else {
            // default
            ppubus.wen = 0;

            if (cpubus.wen) {
                case (cpubus.addr) {

                    6: {
                        reg_w = ~reg_w;
                        if (!reg_w) {
                            reg_v[15:8] = cpubus.wdata;
                        } else {
                            reg_v[7:0] = cpubus.wdata;
                        }
                    }

                    7: {
                        $display("[PPU WRITE] addr=%h data=%02h", reg_v, cpubus.wdata);
                        // Palette RAM
                        if (reg_v[15:8] == 8'h3F) {
                            palette_ram[reg_v[4:0]] = cpubus.wdata;
                        } else {
                            ppubus.wen   = 1;
                            ppubus.addr  = reg_v[13:0];
                            ppubus.wdata = cpubus.wdata;
                        }
                        reg_v = reg_v + 1;
                    }
                }
            }
        }
    }

    always_ff {
        if_reset {
            cpubus.rdata = 0;
        }
    }
...

ここで make して make run を実行すると、
以下のようなログと波形が確認できるはずです。

ログを見ると、

  • パレットRAMへの書き込み
  • VRAMへのタイルデータの書き込み

が行われていることが分かると思います。

これらのデータが正しく書き込めていれば、文字を表示するための下準備は完了です。

次はいよいよ、
PPUのレンダリング部を実装して、実際に画面に描画していきます。


VRAM部抜粋

ログをPPUでgrepしたもの(HELLO,WORLD!は後付け)

[PPU WRITE] addr=3f00 data=0f
[PPU WRITE] addr=3f01 data=00
[PPU WRITE] addr=3f02 data=10
[PPU WRITE] addr=3f03 data=20
[PPU WRITE] addr=3f04 data=0f
[PPU WRITE] addr=3f05 data=06
[PPU WRITE] addr=3f06 data=16
[PPU WRITE] addr=3f07 data=26
[PPU WRITE] addr=3f08 data=0f
[PPU WRITE] addr=3f09 data=08
[PPU WRITE] addr=3f0a data=18
[PPU WRITE] addr=3f0b data=28
[PPU WRITE] addr=3f0c data=0f
[PPU WRITE] addr=3f0d data=0a
[PPU WRITE] addr=3f0e data=1a
[PPU WRITE] addr=3f0f data=2a
[PPU WRITE] addr=21c9 data=48 // H
[PPU WRITE] addr=21ca data=45 // E
[PPU WRITE] addr=21cb data=4c // L
[PPU WRITE] addr=21cc data=4c // L
[PPU WRITE] addr=21cd data=4f // O
[PPU WRITE] addr=21ce data=2c // ,
[PPU WRITE] addr=21cf data=20 // 
[PPU WRITE] addr=21d0 data=57 // W
[PPU WRITE] addr=21d1 data=4f // O
[PPU WRITE] addr=21d2 data=52 // R
[PPU WRITE] addr=21d3 data=4c // L
[PPU WRITE] addr=21d4 data=44 // D
[PPU WRITE] addr=21d5 data=21 // !

BGレンダリングの追加

PPUは、8x8ピクセルのタイル(tile) と呼ばれる単位ごとに画面を描画します。
各タイルのパターンデータは、キャラクタROM(CHR ROM) に格納されています。

レンダリングというと大変そうに聞こえますが、処理を順番に分解していけば何とかなります。
※とはいえ、処理内容自体はそれなりにややこしいです。

まず最初に、タイルのX,Y座標から、CHR ROM上のアドレスを計算します。
計算したアドレスと、その次のアドレスからCHR ROMのデータを2バイト分読み込みます。

1ピクセルはCHR ROMデータ2bitで表現されるため、8ピクセル分を描画するには
2バイト(16bit) のデータが必要になります。

実機のPPUでは、タイルのX,Y座標に対応する2bitのattr(属性)データもVRAMから読み出す必要があります。ただし、本実装では簡単のため、このattrデータは常に0固定とします。

これらの処理は外部バス(VRAMやCHR ROM)へのアクセスを伴うため、時間がかかります。
そのため、8ピクセル分をまとめて先に取得し、内部レジスタに保存しておきます。

保存しておいたattrデータと、CHR ROMから読み込んだデータを1クロックごとに1ピクセル分ずつ取り出して描画処理を行います。

まず、attrデータ2bitと、CHR ROMのデータ2bitを組み合わせて4bitのpixel indexを生成します。
この4bitの値をインデックスとして、3F00〜に配置したパレットRAMを参照します。
すると、使用するべきパレット番号(8bit) が得られます。
※ NESのパレットは52色しかないため、実際には下位6bitしか使われません。

得られたパレット番号をインデックスとして、あらかじめ定義しておいた NES_PALETTE を参照すると、RGB値が取得できます。

ここで一点注意があります。
基本的に、上記のタイルフェッチ処理は、現在描画しているタイルではなく、1タイル先のタイルに対して行います。
これを行わないと、表示結果が横方向に1タイルずれてしまいます。

そのため、現在の cycle と scanline から、次に描画するタイルのX,Y座標をあらかじめ計算しておく必要があります。

このあたりをまとめた最小限の実装が以下の通りです。

ppu.veryl
    var visible: logic;
    assign visible = cycle <: 256 && scanline <: 240;

    // キャラクタROMのアドレス
    var chr_addr: logic<14>;

    // キャラクタROMのデータ
    var plane     : logic<8> [2];
    var plane_draw: logic<8> [2];

    // タイル情報と、次のタイル情報
    var tile_x     : logic<6>;
    var tile_y     : logic<6>;
    var next_tile_x: logic<6>;
    var next_tile_y: logic<6>;
    assign tile_x      = cycle[8:3];
    assign tile_y      = scanline[8:3];
    assign next_tile_x = if (tile_x == 31) ? 0 : tile_x + 1;
    assign next_tile_y = if (tile_x == 31) ? tile_y + 1 : tile_y;

    // VRAMとキャラクタROMからデータを取ってくる
    always_ff {
        if_reset {
            chr_addr      = 0;
            plane[0]      = 0;
            plane[1]      = 0;
            plane_draw[0] = 0;
            plane_draw[1] = 0;
        } else {
            if (visible) {
                case (cycle[2:0]) {
                    0: {
                        let nt_addr    : logic<14> = 14'h2000 + next_tile_y * 32 + next_tile_x;
                        ppubus.addr = nt_addr;
                    }
                    // 1: {}
                    2: {
                        let tile_id    : logic<8> = ppubus.rdata;
                        ppubus.addr = tile_id * 16 + scanline[2:0];
                        chr_addr    = tile_id * 16 + scanline[2:0];
                    }
                    3: {
                        ppubus.addr = chr_addr + 8;
                    }
                    4: {
                        plane[0] = ppubus.rdata;
                    }
                    5: {
                        plane[1] = ppubus.rdata;
                    }
                    // 6: {}

                    // レジスタに保存
                    7: {
                        plane_draw[0] = plane[0];
                        plane_draw[1] = plane[1];
                    }
                }
            }
        }
    }

    var color_2bit: logic<2>;
    var fine_x    : logic<3>;

    // tile内のX座標0 -> {plane[1][7],plane[0][7]};
    // tile内のX座標1 -> {plane[1][6],plane[0][6]};
    // ...
    // 保存していたROMデータをもとに1clkずつ情報をとりだす
    assign fine_x     = 7 - cycle[2:0];
    assign color_2bit = {plane_draw[1][fine_x], plane_draw[0][fine_x]};
...
    always_comb {
        // pixel_index生成(attr=00固定)
        if visiable {
            pixel_index = palette_ram[{2'b00, color_2bit}];
        } else {
            pixel_index = 'h0F;
        }

        // パレットルックアップ
        palette_color = NES_PALETTE[pixel_index];

        // 8ビットRGBに変換
        pixel_r = {palette_color[23:16]};
        pixel_g = {palette_color[15:8]};
        pixel_b = {palette_color[7:0]};
    }
}

ここで makeして make runをすると、、、HelloWorld!が表示されます!素晴らしい!

ちなみに、attr属性を手で変えてみると色が変わります。面白いですね!

pixel_index = palette_ram[{2'b10, color_2bit}];

FPGAに乗せるために、ちょこっと修正

ここまででBGレンダリング自体は一通り動くようになります。
……と、言いたいところですが、このままではFPGAに実装すると怒られます。

理由は単純で、ppubus.addr を 複数の always_ff から同時にドライブしているためです。
シミュレーションでは問題なく見えても、FPGAではアウトです。

今回の構成では、

  • レジスタアクセス部:CPUからのPPUレジスタ書き込み(ライトのみ)
  • レンダリング部:BG描画用のVRAM / CHR ROM読み出し(リードのみ)
    と役割がはっきり分かれています。

そこで、それぞれの always_ff で 専用のアドレスレジスタにラッチし、ppubus.wen 信号を使って、「今どちらのアドレスを使うか」を MUXで切り替える形にします。

ppu.veryl
    // ppubus.address mux
    var ppu_addr_cpu   : logic<14>;
    var ppu_addr_render: logic<14>;
    assign ppubus.addr     = if ppubus.wen ? ppu_addr_cpu : ppu_addr_render;

    // register bus
    always_ff {
        if_reset {
            ppu_addr_cpu = 0;
...
                    7: {
                        $display("[PPU WRITE] addr=%h data=%02h", reg_v, cpubus.wdata);
...
                        } else {
                            ppubus.wen   = 1;
                            ppu_addr_cpu = reg_v[13:0];
                            ppubus.wdata = cpubus.wdata;
                        }
...

    always_ff {
        if_reset {
            ppu_addr_render = 0;
...
        } else {
            if (visiable) {
                case (cycle[2:0]) {
                    0: {
                        let nt_addr        : logic<14> = 14'h2000 + next_tile_y * 32 + next_tile_x;
                        ppu_addr_render = nt_addr;
                    }
                    // 1: {}
                    2: {
                        let tile_id        : logic<8> = ppubus.rdata;
                        ppu_addr_render = tile_id * 16 + scanline[2:0];
                        chr_addr        = tile_id * 16 + scanline[2:0];
                    }
                    3: {
                        ppu_addr_render = chr_addr + 8;

このように アドレスを一度レジスタに受けてからMUXで切り替えることで、

  • 複数ドライバ問題を回避できる
  • FPGA実装でも素直に合成できる

構成になります。
※実機PPUとは内部構成は異なりますが、とりあえずはこれで。

まとめ

というわけで、PPUを実装して画面に文字を出すところまでたどり着きました。

  • Verilator + SDL2 でGUI表示
  • PPUの走査線・サイクル生成
  • VRAM / CHR ROM / パレットRAMの実装
  • CPUからのPPUレジスタ経由の書き込み
  • BGタイルフェッチ&レンダリング
  • 「HELLO, WORLD!」の表示
    と、ファミコンの「画面が出るまで」に必要な最低限の要素が一通りそろっています。

実機PPUと比べると、

  • スクロール未実装
  • Attributeは固定
  • スプライト未実装
  • タイミングもかなり簡略化

と、省略している部分は多いですが、CPUがVRAMに書いたデータが、PPUを通って画面に出る という一番おいしいところは、ちゃんと体験できる構成になっています。

シミュレーション上とはいえ、自分で書いたRTLから直接ピクセルが出てくる瞬間は、やっぱりテンション上がりますね。

おわりに

次回はいよいよ FPGAに載せて実画面に出す予定です。
想定ハードはTang Nano 20Kです。
https://akizukidenshi.com/catalog/g/g130974/
(やっとタイトルに書いてあるFPGAが出てきます)

5
1
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

Comments

No comments

Let's comment your feelings that are more than good

Qiita Advent Calendar is held!

Qiita Advent Calendar is an article posting event where you post articles by filling a calendar 🎅

Some calendars come with gifts and some gifts are drawn from all calendars 👀

Please tie the article to your calendar and let's enjoy Christmas together!

5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Login to continue?

Login or Sign up with social account

Login or Sign up with your email address