モノ創りで国造りを

ハード/ソフト問わず知見をまとめてます

Verilogでゲームを作る --Designing Video Game Hardware in Verilogの簡易和訳-- part1

Verilogビデオゲーム

Verilogの参考書を探してるとアマゾンで以下の本に出会った。

Designing Video Game Hardware in Verilog

Designing Video Game Hardware in Verilog

プログラミング言語の入門書の多くに、ゲーム作製をテーマにしているものがある。
Verilogでは同様のコンセプトの書籍が見つからなかったので、さっそく購入。
まだ全部読んでいないため、良書かどうかの判断はできないが、
せっかくなので、重要な箇所だけかいつまんで和訳しようと思う。
全部翻訳すると著作権に抵触するらしいので。
なるべく最後まで完結できるように頑張る。

目次はこちら

yuji2yuji.hatenablog.com

1章 Bool 論理

基本的な内容なので省略!! 内容は以下。

  • ブール代数
  • 真理値表
  • ビットとデータ数
  • 2の補数
  • 桁上げ
  • signedとunsigned
  • 10進数と16進数

2章 ディスクリートハードウェア

基本的な内容なので省略!! 内容は以下。

  • ロジックゲートの歴史
  • TTL
  • 7400シリーズ
  • 回路図

3章 クロックとフリップフロップ

シーケンシャルロジック(順序回路)

過去の入力ではなく、現在の入力のみを考慮する組み合わせ回路について既に説明した。
面白い事をするには、回路にメモリが必要。
順序回路は現在を考慮した回路ですが、過去を記憶することも可能。
この種の回路はクロック信号によって駆動される。
全ての動作がクロックに同期しているので、同期回路と呼ばれている。
ほとんどの回路は組み合わせ回路と順序回路で構成される。
ただ一つの例外として、ROMはその状態が入力に関わらず変化しないので、組み合わせ回路とみなされる事がある。

クロック

同期回路において、すべての動作はクロックに同期する。
クロックは決まった周波数でLowとHighを交互に繰り返す。
例えば1MHzのクロック 信号は1秒間に100万回LowとHighを繰り返す。
クロックシグナルは水晶発振子やクロック分配機によってい生成される。
回路はしばしば異なる周波数/位相のクロックを含む。

フリップフロップ

フリップフロップは順序回路に不可欠な構成要素だ。フリップフロップはクロックでのみ変化する1つのビットを記憶する。
フリップフロップはエッジでトリガーされる。状態は常にクロック信号の立ち上がりor立下りのエッジでのみ変化し得る。
出力信号はクロックエッジの前と後で常に安定である。この規約を乱すと結果としてセットアップタイムバイオレーションまたはホールドタイムバイオレーションが生じ、メタステーブル(準不安定)な状態になる。

ラッチ

ラッチはフリップフロップに似ているが非同期である。クロックのエッジではなく入力信号に反応する。
一般的にラッチは使用せずフリップフロップのみを使用するのが良い。なぜなら、非同期な回路はメタステーブルな状態を招くからだ。

4章 HDL(Hardware Description Language)

省略。

5章 Verilogの初歩

Verilogの基本文法の説明なので省略。以下の表だけ頭に入れておけば十分。
f:id:yuji2yuji:20190818003243p:plain

6章 IDE

The 8bitworkshop IDEなるものがあることの紹介なので省略。
実際に画面に表示するためのシミュレーターも含まれている。実機ない人でもデモできる。
8bitworkshop

7章 シンプルなクロック分配器

最初のVerilogモジュールとして、クロック分配器を作ろう。
クロック分配器はクロック信号の入力を受けて、整数で分割されたクロックを生成する。
例えば、10MHzのクロックを2で分割して5MHzのクロックを生成する。

一般的に水晶発振子はマスタークロックを生成する。クロック分配回路はシグナルを方形波にし、回路の様々な目的に合わせた低い周波数を生成する。
以下にクロック分配器のタイミングダイアグラムを示す。

f:id:yuji2yuji:20190818004318p:plain

フリップフロップを直列に接続して回路を実現する。

module clock_divider(
    input clk,
    input reset,
    output reg clk_div2,
    output reg clk_div4,
    output reg clk_div8,
    output reg clk_div16
);

always @(posedge clk)
    clk_div2 <= ~clk_div2;

always @(posedge clk_div2)
    clk_div4 <= ~clk_div4;

always @(posedge clk_div4)
    clk_div8 <= ~clk_div8;

always @(posedge clk_div8)
    clk_div16 <= ~clk_div16;

この方法は同期回路になっていないので、常に正しいといえる方法ではない。
本書のデモではこれを使用する。
回路図は以下となる。
f:id:yuji2yuji:20190818004843p:plain

8章 バイナリカウンター

Verilogなら簡単にバイナリカウンターが作成できる。
非同期リセットもついでに記載。

assign cntr_div2 = counter[0];
assign cntr_div4 = counter[1];
assign cntr_div8 = counter[2];
assign cntr_div16 = counter[3];

always @(posedge clk, posedge reset)begin
    if(reset)
        counter <= 0;
    else
        counter <= counter + 1;
end

9章 ビデオ信号発生器

CRTディスプレイへの描画と同様にビデオ信号を生成する。 CRTでは左から右へ移動するスキャンする電子銃が各ピクセルの明暗を作る。
f:id:yuji2yuji:20190818084721p:plain
同期シグナルはCRTのスキャン信号のストップとスタートを決める。同期シグナルは2つある。
水平同期(horizontal sync)は新しいスキャンラインを生成する。
vertical sync(垂直同期)は新しいfieldを生成する、つまり電子ビームを左上に移動させる。

8bitworkshop IDEにはリアルタイムで信号を表示するCRTシミュレーションがある。
CRTシミュレーションのディスプレイは256x240のピクセルで、各ピクセルはクロック1サイクルの幅を持つ。
最初に、水平と垂直の同期信号を生成するモジュールを作成する。
f:id:yuji2yuji:20190818085409p:plain

同期信号発生器は262スキャンライン各々について309クロックをカウントする。
フレームレートを60fpsとするとクロックは
262x309x60 = 4.875MHzとなる。
モジュールの入出力を書いてみる。

module hvsync_generator(clk, reset, hsync, vsync, display-on, hpos, vpos);
    input clk;
    input reset;
    output hsync, vsync;
    output display_on;
    output [8:0] hpos;
    output [8:0] vpos;

モジュールの入力はclkとresetの二つだけ。reset信号はモジュールの初期状態を保証する。
hsyncとvsyncは水平と垂直の同期信号。
display_onは現在のピクセルが表示可能エリアにある場合にセットする。
hposとvposは9bitカウンターで、現在の水平・垂直の位置を与える。
ディスプレイの表示可能領域のピクセルは(0, 0)~(255, 239)である。全領域(0, 0)~(308, 261)である。
8bitだと255までしかカウントできないので、hposとvposは9bit必要になる。
コードを分かりやすくするために、localparamを設定する。

localparam H_DISPLAY    = 256;
localparam H_BACK = 23;//back porch
localparam H_FRONT  = 7;//front porch
localparam H_SYNC    = 23;//horizontal sync width

localparam V_DISPLAY   = 240;
localparam  V_TOP      = 4;//top border
localparam V_BOTTOM    = 14;//bottom border
localparam V_SYNC      = 4;vertical sync line

localparamは算術演算ができる。新たに同期信号のスタートとストップを示すカウンターと0に戻るためのMAXカウンターを定義する。

localparam H_SYNC_START = H_DISPLAY + H_FRONT;
localparam H_SYNC_END   = H_DISPLAY + H_FRONT + H_SYNC -1;
localparam H_MAX = H_DISPLAY + H_BACK + H_FRONT + H_SYNC -1;

localparam V_SYNC_START = V_DISPLAY + V_BOTTOM;
localparam V_SYNC_END   = V_DISPLAY + V_BOTTOM + V_SYNC -1;
localparam V_MAX        = V_DISPLAY + V_TOP +V_BOTTOM + V_SYNC -1;

hposを扱うカウンターをalwaysで記載。

//jorizontal position counter

wire hmaxxed = (hpos == H_MAX) || reset;

always @(posedge clk)
begin
    hsync <= (hpos >= H_SYNC_START && hpos<=H_SYNC_END);
    if(hmaxxed)
        hpos <= 0;
    else
        hpos <= hpos + 1;
end

水平同期中ならhsyncは1とする。加えてH_MAX又はリセットが押された場合はhposは0、そうでないならhposは1インクリメントする。

垂直同期についても別のalwaysブロックで記載する。

always @(posedge clk)
begin
    vsync <= (vpos >= V_SYNC_START && vpos <= V_SYNC_END);
    if(hmaxxed)
    begin
        if(vmaxxed)
            vpos <= 0;
        else
            vpos <= vpos + 1;
    end
end

display_onシグナルは以下で計算する。

assign display_on = (hpos < H_DISPLAY) && (vpos < V_DISPLAY);

10章 テストパターン

f:id:yuji2yuji:20190818094816p:plain 9章で作成したモジュールをインクルードする。

`include "hvsync_generator.v"

新しいテストモジュールを作成する。モジュール名にtopを付けることでIDEがtopモジュールであると認識する。

module test_hvsync_top(clk, reset, hsync, vsync, rgb);
    input clk, reset;
    output hsync, vsync;
    output [2:0] rgb;
    wire display_on
    wire [8:0] hpos;
    wire [8:0] vpos;

前回同様のモジュールを作成する。hsyncとvsynの2つとrgbの3bitを出力する。
次にhvsync_generatorのインスタンスを生成する。

hvsync_generator hvsync_gen(
    .clk(clk),
    .reset(reset),
    .hsync(hsync),
    .vsync(vsync),
    .display_on(display_on),
    .hpos(hpos),
    .vpos(vpos)
);

次にRGBを定義する。

wire r = display_on && (((hpos & 7)) == 0 ||| (vpos & 7) == 0);
wire g = display_on && vpos[4];
wire b = display_on && hpos[4];

assign rgb = {b, g, r};

11章 Digit

ビデオゲームがやってくるまでは、ピンボールが王様で、プレーヤーのスコアを表示する必要があった。機械式のスコアリールが一般的だったが、多くの摩耗が見られた。
いくつかのゲームはネオンが充填されたガラスの内側に10本の数字のワイヤを重ね合わせたNixie tubeを使うものもあった。
f:id:yuji2yuji:20190818160830p:plain

7セグ Digit

f:id:yuji2yuji:20190818160920p:plain
1970年代から、特にLEDとLCDが広く利用可能になったときに、7セグメントディスプレイはハイテクと同義語になった。

我々が作る回路はseven_segment_decorderモジュールである。
これは4bitの数値を7bitの配列に変換し、各々のビットを異なるセグメントで表現する。
segments_to_bitmapモジュールは7ビット配列と現在のスキャンラインを取得し、それをスキャンライン用に出力されるビットを表す5ビット配列に変換する。
f:id:yuji2yuji:20190818161709p:plain
ここでは、7セグメントのビデオ生成方法について詳しく説明しませんが、参考文献で引用されたWilliam Arkushの本で詳しく説明されている。

ビットマップ化されたDigitsとROM

ビットマップフォントを使用する事で、我々は任意の形を表現する事が出来る。
各文字や数字のビットパターンはROMを使った2次元配列に保存される。一般的に1は明るいピクセルで0は暗いピクセルである。
ROMは変化しないビットパターンを保存するチップであり。本質的にルックアップテーブルである。
アドレスによってアクセスされる。
例えばROMが10bitのアドレスを持っていれば、210の1024種類のデータを提供可能である。
ほとんどのROMは複数のbitを同時に提供できる。例えば8bits ROMは各々のアドレスに8bitのデータを保存している。
VerilogはROMの場所を明確化しない。むしろ合成ツールはいくつかの異なる構成からROMを推測する。
f:id:yuji2yuji:20190818162657p:plain

ROMモジュールのインターフェースを示す。

module digits10_case(digit, yofs, bits);
    input [3:0] digit;//digit 0-9
    input [2:0] yofs;//vertial offset. 0-4
    output reg [4:0] bits;

    wire [6:0] address = {digit, yofs};

解説

ディスプレイに表示する数字の縦は5ピクセルとするので、yofsは0-4の5値が取れれうように、3bitとする。

次にそれぞれのアドレス固有の出力データを示す。

always @(*)begin
    case (address)
        //digit "0"
        7'o00: bits = 5'b11111;    // *****
        7'o01: bits = 5'b10001;    // *   *
        7'o02: bits = 5'b10001;    // *   *
        7'o03: bits = 5'b10001;    // *   *
        7'o04: bits = 5'b11111;    // *****
       //digit "1"
        7'o10: bits = 5'b01100;    //  **
        7'o11: bits = 5'b00100;    //   *
        7'o12: bits = 5'b00100;    //   *
        7'o13: bits = 5'b00100;    //   *
        7'o14: bits = 5'b11111;    // *****
        // ... etc ...
    endcase
end

caseステートメントでは8進数を使用していることに注意。 セレクターの1つは3ビットであるため、8進表記により少し読みやすくなる。

解説

addressの下3bitはyofsである。8進数表記した場合の1桁は3bitに相当するので、case文中のセレクタの下一桁はまさにyofsそのものを示している。

イニシャルブロック

initialキーワードを使用して配列を設定することで、このモジュールを作成することもできる。 caseステートメントの代わりに、assignステートメントを使用して配列にインデックスを付け、ビット出力を駆動する。

reg [4:0] bitarray[0:15][0:4];
assign bits = bitarray[digit][yofs];

initial begin
    //digit "0"
    bitarray[0][0] = 5'b11111;
    bitarray[0][1] = 5'b10001;
    bitarray[0][2] = 5'b10001;
    bitarray[0][3] = 5'b10001;
    bitarray[0][4] = 5'b11111;
    //digit "1"
    bitarray[1][0] = 5'b01100;
    bitarray[1][1] = 5'b00100;
    bitarray[1][2] = 5'b00100;
    bitarray[1][3] = 5'b00100;
    bitarray[1][4] = 5'b11111;
    // ... etc ...
end

配列は2次元で、1次元が数字(16エントリ)、1次元がYオフセット(5エントリ)。したがって、各桁には5つのエントリがあり、各エントリは5ビット(reg [4:0]で示される)。

verilogではfor文も使えるけど、おすすめしない。

外部ファイル

Verilogでは外部ファイルを読み込むことができる。

initial begin
    $readmemb("ditigs5x5.txt", bitarray);
end

ただし8bitworkshop IDEは外部ファイルの読み込みをサポートしていないので、ROMの初期化には上述のいずれかの方法を使用する。

テストモジュール

数字を表示するため、hvsync_generatorモジュールを使って簡単なテストモジュールを作ってみる。

wire [3:0] digit = hpos[7:4]; // selected digit 0-9
wire [2:0] xofs = hpos[3:1]; // horiz. offset (2x size)
wire [2:0] yofs = vpos[3:1]; // vert. offset (2x size)
wire [4:0] bits; //output bits from ROM

digits10_array numbers( //ROM module
    .digit(digit),
    .yofs(yofs),
    .bits(bits)
);

解説

wire [3:0] digit = hpos[7:4]; hposが16の倍数になるたびにdigitが1インクリメントされる。
wire [2:0] yofs = vpos[3:1];垂直方向のオフセットvposの0,2,4,6,8, ...14を2で割ったもののいずれか。
ただしROMにはyofsは0-4までしか対応していない。つまりvposの0-8に相当する。
従ってwire [3:0] digit = hpos[7:4];から16ピクセルごとに数字が変わるので、数字がダブルことはない。

wire r = 0;
wire g = display_on && bits[xofs ^ 3'b111];
wire b = 0;
assign rgb = {b,g,r};

解説

bits[xofs ^ 3'b111]と上記のwire [2:0] xofs = hpos[3:1];について、
bitsはROMからの出力。例えばアドレスがdigitが0でyofsが0のとき、bitsは5'b11111。
xofsは水平方向のオフセットhposの下4bitのデータ0,2,4,6,8, ...14を2で割ったもののいずれか。
bitsデータのMSb(先頭bitなのであえて小文字で記載)から順番にデータを取り出したいので、xofsを反転させている。
xofs ^ 3'b111はxofs[2:0]の全ビットを反転させたもの。ビットを反転させると数字の大小も反転することを利用している。

例えば、 hposが3'b000(=0)を反転させると3'b111(=7)
hposが3'b001(=1)を反転させると3'b110(=6)
hposが3'b010(=2)を反転させると3'b101(=5)
hposが3'b011(=3)を反転させると3'b100(=4)