過去ブログの、BSVによるスペースインベーダーの再設計の記事#234~#239, #254をまとめてQiitaに投稿しました。さらに考察を加えています。
BSV (Bluespec SystemVerilog)によるスペースインベーダーの再設計
過去ブログ記事でUltra96ボードを用いた、VerilogHDLによるSpace Invadersゲームの作成を投稿しましたが、その続きです。

過去ブログの、BSVによるスペースインベーダーの再設計の記事#234~#239, #254をまとめてQiitaに投稿しました。さらに考察を加えています。
BSV (Bluespec SystemVerilog)によるスペースインベーダーの再設計
過去ブログ記事でUltra96ボードを用いた、VerilogHDLによるSpace Invadersゲームの作成を投稿しましたが、その続きです。

![]() |
11 |
BSVの設計トライアル (21) |
![]() |
トライアルの結果、BSVによるゲームFSMが完成しました。過去記事のステートベースのサウンドステートマシンと異なり、ステート分解をしていないため、rule文を一切使用していません。全てbsc(Bluespec Compiler)の、StmtFSMライブラリにステート管理を任せました。
基本的にはCで記述するようにゲームが記述できることが分かりました。例えば、弾の移動及び衝突判定、衝突処理(爆発マーク)、爆発マーク消去等のアルゴリズムを考えると、自弾、敵弾共にアルゴリズムは共通で、疑似コードで書けば、
if (弾爆発タイマ >= 1) { // 弾爆発中
弾爆発タイマ++;
if (弾爆発タイマ == MAX) {
弾削除; // 論理的な消去
弾爆発マーク消去; // 物理的な消去
弾爆発タイマ停止;
}
} else {
if (弾が出ていない and 弾生成条件) {
弾生成処理;
弾発射音; // 自弾のみ
}
if (弾存在) {
衝突判定;
if (対象物) { // 自弾の場合はインベーダ及びUFO、敵弾の場合は自機
弾削除; // 論理的な消去
対象物ステート <= 爆発;
対象物爆発タイマ <= 0;
} else if (上下ハズレ || ベース || 弾) { // 弾:自弾の場合は敵弾、敵弾の場合は自弾
弾マーク消去;
弾爆発マーク;
弾爆発タイマ <= 1;
} else { // 衝突していない場合
弾を進める;
}
}
}
一方、対象物は、
if (対象物ステート == 爆発) {
if (対象物爆発タイマ==0) {
対象物爆発タイマ <= 1;
対象物爆発音;
対象物爆発マーク;
} else {
対象物爆発タイマ++;
if (対象物爆発タイマ == MAX) {
対象物削除; // 論理的な消去
対象物爆発マーク消去; // 物理的な消去
}
}
}
のようになりますが、StmtFSMを使うと、このようなシーケンスをクロック毎のステートに分解しなくて記述できます。
某所で質問があったので、タイミングについて解説します。基本の1 tickは1/60秒で、その中で、インベーダ1匹、敵弾全弾、自機、自弾、UFO、スコア等の処理を行います。以下は実際のBSVのメインループのコードです。
while (game_flag) seq // メインループ
for (noy <= 0; noy < `Inv_TateS; noy <= noy + 1) seq // インベーダの行処理
for (nox <= 0; nox < `Inv_YokoS; nox <= nox + 1) seq // インベーダの列処理
if (inv_s[nox][noy]) seq // インベーダが生きてれば
ivader; // インベーダ処理
gun; // 自機処理
bullet; // 自弾処理
for (idx <= 0; idx < extend(max); idx <= idx + 1) seq
invBullet(idx); // 敵弾全弾処理
endseq
ufo; // UFO処理
scores; // スコア表示
endJudge; // 終了判定
counter <= counter + 1; // tickカウンタ++
wait_timer; // インナーループを1/60secにするウエイト
endseq
endseq
endseq
endseq
gameOver; // ゲームオーバー表示
1tick=1/60secの間に、インベーダ1匹(2ピクセル移動)の処理に対して、自機(1ピクセル移動)、敵弾(1ピクセル移動)、自弾(4ピクセル移動)の処理が行われます。インベーダは初期に55匹存在するので、1/55倍のスピードで始まりますが、最終的に1倍のスピードになります。従って、インベーダを倒すたびにインベーダ全体は速くなり、一方その他の速度は変わらないわけです。
FPGAでの実装では1 tick内にインベーダ全体を移動することは可能ですし、そのような実装も見ますが、ゲーム性が変わってしまいます。具体的には、インベーダ全体の速度が次第に速くならなかったり、後ろのインベーダを撃つことができなくなります。
例えば、インベーダゲームのレインボーは、後ろのインベーダを撃つことにより出現します。インベーダは残りが一匹になると左へは2ピクセルずつ右には3ピクセルずつ移動します。下2段のインベーダは、左右2ピクセルまでの移動では跡が残らない図形になっていますが、3ピクセルだと跡が消えずに残ります。もちろん今回の実装でもレインボーを体験できます。
図254.1は、BSVで再設計したゲームFSMにより動作する、インベーダーゲームの動画です。過去記事に書いたように、サウンドが4ch同時発声と高品質になりました。

![]() |
14 |
BSVの設計トライアル (6) |
![]() |
ミキサーは4chある音源のミキシングを行います。ミキシングは符号無し8bit整数を16bitに符号拡張し、加算することで行います。サウンド平均化を試しましたが、かえって違和感が出るため、シンプルな加算としました。念のためにクリッピング処理を入れています。
完成したsound階層を図239.1に示します。

従来回路図234.2と比べてサウンドチャネルが4倍になっただけでなく、ミキサー回路を新設したため、かなり大きくなっています。表239.1に各モジュールの物量(LUT数)を示します。LUTは使用個数は4,216個であり、全容量70,560個中の5.98%でした。またBRAMは使用個数は39.5であり、全容量216中の18.3%でした。
ゲーム全体の各モジュールの物量を表239.1に示します。色付け部分が今回BSVで開発したモジュールです。
| モジュール | 説明 | 物量 | BSV行数 | Verilog行数 | ||
|---|---|---|---|---|---|---|
| LUT数 | LUT割合[%] | BRAM数 | ||||
| invader_move | 自機、インベーダ、UFO、自弾、敵弾、スコア等を処理するFSM | 3,239 | 76.7 | 0.0 | ー | 2,125 |
| mkSoundFSM3 | UFO音を演奏するFSM | 197 | 4.7 | 0.0 | 448 | 939 |
| mkSoundFSM2 | インベーダ歩行音を演奏するFSM | 192 | 4.6 | 0.0 | 950 | |
| mkSoundFSM1 | インベーダ爆発音を演奏するFSM | 190 | 4.4 | 0.0 | 922 | |
| mkSoundFSM0 | 自弾発射音、自機爆発音、自機増加音を演奏するFSM | 184 | 4.7 | 0.0 | 957 | |
| mixer | 4チャネルのサウンドミキサー | 44 | 1.0 | 0.0 | ー | 30 |
| graphic_control | VGA映像信号生成回路 | 38 | 0.9 | 0.0 | ー | 92 |
| para_seri | サウンドパラシリ変換、タイミング生成回路 | 22 | 0.5 | 0.0 | ー | 101 |
| sound_rom0 | 自弾発射音、自機爆発音、自機増加音を格納するROM | 20 | 0.5 | 7.0 | IP | |
| sound_rom1 | インベーダ爆発音を格納するROM | 20 | 0.5 | 7.0 | IP | |
| sound_rom2 | インベーダ歩行音を格納するROM | 20 | 0.5 | 7.0 | IP | |
| sound_rom3 | UFO音を格納するROM | 20 | 0.5 | 7.0 | IP | |
| pattern_rom | 自機、インベーダ、UFO、自弾、敵弾、スコア等の図形を格納するROM | 7 | 0.2 | 3.5 | IP | |
| buttons | スイッチとボタンをORする回路 | 4 | 0.1 | 0.0 | ー | 21 |
| display_out | ブランキング期間に表示を抑止する回路 | 3 | 0.1 | 0.0 | ー | 21 |
| vram | ビデオRAM | 2 | 0.0 | 8.0 | IP | |
| onestage | ゲームFSMとサウンドFSMの間のバッファ | 2 | 0.0 | 0.0 | ー | 43 |
| clk_wiz | クロック制御回路 | 1 | 0.0 | 0.0 | IP | |
図239.2に全体のセルの配置図を示します。

基本的に、サウンドエンジンはサウンド番号を受け、ROMに示されるサウンド長だけサウンドを演奏し、サウンド中に割込みが入る場合にはその割込みサウンドを演奏しますが、例外が2つあります。
自機増加音は自弾発射音、自機爆発音と独立であるため、別チャネルとするほうが良いのですが、頻度が低いのと別チャネルにするとハードウエアが無駄のため、このような仕様としました。実験の結果、優先サウンドとしてもゲーム上特に問題はありませんでした。
![]() |
13 |
BSVの設計トライアル (5) |
![]() |
FORMAT機能、DATA機能からコールされるREADCOUNT共通シーケンス及び、そこからコールされるREADMEM共通シーケンスを解説します。
// input: romaddr
// output: (romaddr,...,romaddr+3) => dcount;
// romaddr + 4 => romaddr;
//
rule ruleREADCOUNT (state.func == READCOUNT);
rule ruleS0 (state.step == S0);
state.step <= S1;
ret2 <= ret; // push to stack
i <= 3;
workd <= 0;
endrule
rule ruleS1 (state.step == S1);
state <= State_t {cat:state.cat, func:READMEM, step:S0};
ret <= State_t {cat:state.cat, func:READCOUNT, step:S2};
worka <= romaddr + i;
endrule
rule ruleS2 (state.step == S2);
if (i == 0) begin
state <= ret2;
dcount <= workd<<8 | extend(romdata);
romaddr <= romaddr + 4;
end else begin
state.step <= S1;
workd <= workd<<8 | extend(romdata);
i <= i - 1;
end
endrule
endrule // READCOUNT
図示すると、図238.1のようなステート遷移となり、リトルエンディアンで格納されている4バイトの数値を1バイトずつ4回取り出し、dcountにまとめる機能を持ちます。

次にREADMEMはROMから1バイト読み出す共通シーケンスです。当初はwireを用いても階層の上り下りでレイテンシがかかり、7サイクルとなりましたが、後述のmkConnectionを用いたことにより、RTLと同様の3サイクルの設計とすることができました。
上記のREADCOUNTから呼ばれる際はサイクル数は無関係ですが、サウンド再生中は正しいスループットで読み出す必要があるため、過去記事にもあるように、コール元が1サイクルとコール先(READMEM)が3サイクルの4サイクルで1バイトを読み出す前提で、FSMのクロック周波数=176.4KHzを決めています。
// READ MEM
// input: worka
// output: romdata;
//
rule ruleREADMEM (state.func == READMEM);
rule ruleS0 (state.step == S0);
addr <= worka;
state.step <= S1;
endrule
rule ruleS1 (state.step == S1); // ROM address phase
state.step <= S2;
endrule
rule ruleS2 (state.step == S2); // ROM data phase
data <= romdata;
state <= ret;
endrule
endrule // READMEM
![]() |
10 |
BSVの設計トライアル (4) |
![]() |
階層化ステートマシンを解説します。基本的にBluespecにおいてはrule文のガードによりステートに入る条件を定義し、rule文の内部でステートの処理を定義します。そこで、ruleを構造化することで、ステートの構造化を実現します。具体的には以下のような構造となります。
rule ruleSOUND (state.cat == SOUND);
rule ruleFORMAT (state.func == FORMAT);
rule ruleS0 (state.step == S0);
// call readcount();
state <= State_t {cat:SOUND, func:READCOUNT, step:S0};
ret <= State_t {cat:SOUND, func:FORMAT, step:S1};
case (code)
'h1: romaddr <= 0 + 16;
'h2: romaddr <= 4318 + 16;
'h9: romaddr <= 13094 + 16;
endcase
endrule
rule ruleS1 (state.step == S1);
state <= State_t {cat:SOUND, func:DATA, step:S0};
romaddr <= romaddr + extend(dcount);
endrule
endrule // FORMAT
ruleS0ではcodeによりROMアドレス先頭を決定します。wave formatの16バイト目からフォーマット長取得を行います。readcount()をコールしており、そのリターンはruleS1ステートです。readcount()ではdcount変数に4バイトの数値が得られます。

rule ruleDATA (state.func == DATA);
rule ruleS0 (state.step == S0);
// call readcount();
state <= State_t {cat:SOUND, func:READCOUNT, step:S0};
ret <= State_t {cat:SOUND, func:DATA, step:S1};
romaddr <= romaddr + 4; // skip "data"
endrule
rule ruleS1 (state.step == S1);
state <= State_t {cat:SOUND, func:PLAY, step:S0};
romaddr <= romaddr - 1;
endrule
endrule // DATA
dcountにデータ長が得られたので、実際のサウンドデータを出力するため、PLAYに移行します。

![]() |
9 |
BSVの設計トライアル (3) |
![]() |
waveフォーマット解析とデータ供給を実行する、メインのFSMの設計を行います。このような小さいFSMであっても階層設計を行います。その理由はソフトウェアと同じで、構造化のためです。最上位ステートは図236.1のようにINIT, SOUNDの2ステートとします。基本的にINITでは外部からサウンドコードが送信されてくるのを待ち、自分のコードであればSOUNDに遷移します。サウンド出力が終了すると、またINITに戻ります。

フラットなFSMではステート変数を整数で持ちますが、階層FSMでは構造体で持つことにします。階層は3階層で、上位2階層が意味のある機能を示し、最下層はステップとします。次に構造体の記述を示します。
typedef struct {
Category_t cat;
Function_t func;
Step_t step;
} State_t deriving(Bits,Eq);
上位からカテゴリー、ファンクション、ステップと名付けました。最上位のカテゴリーは先の図のように、 INIT, SOUNDの2ステートからなります。
typedef enum { INIT, SOUND } Category_t deriving(Bits,Eq);
INITでは中間階層である機能は特になく、2ステートを持ち、LRCLKと同期させます。これはFSMクロックがLRCLKよりも速いため、LRCLKと確率的に位相が合わなくなるためです。
SOUNDでは機能としてFORMAT, DATA, PLAY, READCOUNT, READMEMの5種を持ちます。それぞれの機能は、フォーマット解析、データ長取得、演奏です。さらにそこから呼ばれる共通シーケンス(サブルーチン)として、データ長読み出し、データ1バイト読み出しが存在します。

typedef enum { FORMAT, DATA, PLAY, READCOUNT, READMEM } Function_t deriving(Bits,Eq);
同階層内にコール関係があり、DATAからREADCOUNTをコールします。さらにREADCOUNTからREADMEMをコールするので、リターンスタックは2段必要です。
最下層のステップの名前には特に意味が無く、シーケンシャルにステートを進めるだけです。シーケンシャルになる理由は、フォーマット解析やROMのデータ待ちなど様々です。
typedef enum { S0, S1, S2, S3, S4, S5, S6 } Step_t deriving(Bits,Eq);
以上は宣言であり、実際のインスタンシエーションは以下のようにモジュール内部で行います。
Reg#(State_t) state <- mkReg(State_t{cat:INIT,func:?,step:S0}),
ret <- mkRegU,
ret2 <- mkRegU;
ステート構造体stateと、さらに同じ構造を持つ2本のリターンレジスタret, ret2をインスタンスしています。
![]() |
8 |
BSVの設計トライアル (2) |
![]() |
図234.2に示したように、サウンドROMが存在するので、BSVで作成します。ROMは合成には使用しませんが、bsimシミュレーションのために、 ROMは書き込まないRAMとして実装します。RAMはBSVではRegFileライブラリを使用します。RegFileは実際には1W5RのマルチポートRAMですが、他のポートは使用しません。
import RegFile::*;
最初にこの行によりライブラリRegFileを導入します。インスタンスは次の行で行います。同時にROMファイルの内容をロードします。
typedef Bit#(15) Addr_t;
typedef Bit#(8) Data_t;
RegFile#(Addr_t, Data_t) rom <- mkRegFileLoad("sound_data/ch00.hex", 0, 18593);
以下にROMファイルの内容を示します。チャネル0のファイルは3つのサウンドを持つ、18,594バイトのファイルです。
52
49
46
46
d6
10
:
映像とサウンドを一致させるには難しさがあります。





![]() |
7 |
BSVの設計トライアル |
![]() |
過去にスペースインベーダーをRTLで開発しました。今回はBSVのトライアルとして既存のverilogモジュールの入れ替えを実施します。小さめのFSMであるサウンドFSMを入れ替えますが、合わせて仕様も高級なものにしようと思います。RTLで記述したときは、サウンドチャネルは1個で、優先度判定を行いサウンド割込みを行いました。今回は、サウンドチャネルを複数持って、サウンド重畳を行うことを考えます。そのため、複数の音を出し音量を平均化するような、サウンドミックス回路を新設します。
さて、音は以下の10種類ですが、同時発生を考えて、自機音、インベーダ音1, 2、UFO音の4チャネルとします。また、サウンド演奏中に割込みが入るため、プリエンプティブに設計する必要があります。
| サウンドの説明 | CODE番号 | サウンドチャネル | バイト数 | 先頭オフセット+16 | |
|---|---|---|---|---|---|
| ON | OFF | ||||
| 自機弾発射音 | 1 | ー | 自機音チャネル(#0) | 4,318 | 0 + 16 |
| 自機爆発音 | 2 | ー | 8,776 | 4,318 + 16 | |
| 自機増加音 | 9 | ー | 5,500 | 13,094 + 16 | |
| インベーダ爆発音 | 3 | ー | インベーダ音チャネル(#1) | 4,622 | 0 + 16 |
| インベーダ歩行音1 | 4 | ー | インベーダ音チャネル(#2) | 1,266 | 0 + 16 |
| インベーダ歩行音2 | 5 | ー | 1,570 | 1,266 + 16 | |
| インベーダ歩行音3 | 6 | ー | 1,570 | 2,836 + 16 | |
| インベーダ歩行音4 | 7 | ー | 2,180 | 4,406 + 16 | |
| UFO爆発音 | 8 | ー | UFO音チャネル(#3) | 25,968 | 0 + 16 |
| UFO飛行音 | 10 | 16 + 10 | 1,846 | 25,968 + 16 | |
現状のRTL設計でのブロック図をVivadoで表示したものを図234.1に示します。

新仕様のブロック図は図234.2のようになります。色付けの部分が新規設計モジュールです。

![]() |
22 |
FIFO及びメモリコントローラの除去 |
![]() |
前稿で設計したグラフィックディスプレイコントローラですが、あとから振り返るとFIFOが無駄であることに気づきました。早速リファクタリングします。
リファクタリング前のクロックドメインが3つあるのに比べて、リファクタリング後は2つに減っています。それだけでなく、FIFOとメモリをリードしてFIFOに入れる回路の2つを削減しています。これはdual port memoryが本来FIFOの役割を果たすためです。


FIFOとメモリ読み出し回路を削減し、メモリ読み出し回路から出ていたメモリアドレスをグラフィックコントローラから出す変更により、問題なくリファクタリングが達成できました。
FPGAの設計トライアルとしてスペースインベーダを取り上げたブログ記事を書いてきましたが、最初はAvnetのMicroZedボードで、PMOD-VGAとPMOD-I2Sのインタフェースを購入して、入力はボード上のスイッチを使用していました。
開発がほぼ完了した段階で、AvnetからUltra96 ボードが発売されたので、さっそく購入し移植しようとしましたが、Ultra96はPMODインタフェースを搭載していないため、インタフェースボードを設計せざるを得なくなりました。
そのため、Eagleを使用して、PCBを設計し、さらに格安でPCBを製造するベンダを探しました。中国から送られてきたボードに部品を実装し、ジョイスティックを作成し、ゲームを動作させることができました。
この経緯をQiitaに投稿しました。

合わせてYouTubeにも上げています。