0
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?

Shrike-LiteでAtCoder問題を解く(10):BRAMテンプレートを作る

0
Last updated at Posted at 2026-07-18

はじめに

これまでの実装では、FPGA内部で保持する値の多くをCLB上のFF(フリップフロップ)で構成してきました。

小さな値を数個保持するだけなら、それで十分です。しかし、配列のように多数の値を保持しようとすると、必要なFFや配線が急速に増えていきます。

第8回で実装したABC466B - Representative Ballsの制約版でも、色ごとの最大値を保持する回路がFPGAのリソースを大きく使用しました。

そこで今回は、AtCoder問題の実装をいったんお休みして、ForgeFPGAに内蔵されているBRAM(Block RAM)の使い方を調べてみます。

今回確認したいことは、次の三つです。

  • BRAMとは何か
  • ForgeFPGAではBRAMをどうやって使うのか
  • AtCoder問題で繰り返し使えるBRAMテンプレートを作れるか

まずRenesas公式とShrike公式のサンプルを確認し、BRAMを再利用可能な実装部品にできそうかを整理してみます。

今回の方針

BRAMの基礎資料として、Renesas公式の次のサンプルを参照します。

このサンプルは、SLG47910に内蔵されたBRAMを使って、8段×4bitのFIFO(キュー)を実装しています。

一方、Shrike公式リポジトリには、BRAMをLIFO(スタック)として利用するサンプルがあります。

今回は、Renesas公式サンプルでBRAMそのものの構造と設定方法を確認し、Shrike公式サンプルでShrike-Liteから実際に利用する方法を確認します。

FIFOやLIFOをそのままテンプレートにするのではありません。

両方のサンプルからBRAMの操作に必要な共通部分を取り出し、AtCoder問題の回路から使いやすいテンプレート用モジュールとして整理することを目標にします。


BRAMって何?

FPGA内で値を保存する方法

FPGAで値を保存する代表的な方法には、次のようなものがあります。

  • FFを使う
  • LUTをメモリとして使う
  • BRAMを使う

FFは、カウンタや状態、演算途中の値など、少数のデータを保持するのに適しています。

しかし、例えば8bitの値を128個保存しようとすると、単純計算で1024bit分の記憶領域が必要です。これをすべてFFで構成すると、記憶部分だけでForgeFPGAのFF資源をほぼ使い果たします。

BRAMは、このようなまとまった量のデータを保存するため、FPGA内にあらかじめ用意されている専用メモリです。

CLB内のFFを大量に並べるのではなく、専用のメモリブロックへデータを保存できるため、配列やバッファを扱う回路を小さく実装できます。

ForgeFPGAのBRAM

Shrike-Liteに搭載されているForgeFPGA SLG47910には、8個のBRAMスライスがあります。

Renesas公式資料では、次のように配置されています。

  • North BRAM:BRAM_0~BRAM_3
  • South BRAM:BRAM_4~BRAM_7

各BRAMスライスは4Kbitの容量を持ち、幅と深さを次のように切り替えられます。

RATIO[1:0] 構成
2'b00 512×8bit
2'b01 1024×4bit
2'b10 2048×2bit
2'b11 4096×1bit

1スライスあたりの総容量は同じですが、1アドレスで読み書きするbit数を変えられます。

今回作るテンプレートでは、MicroPythonやSPIの1byte転送と組み合わせやすい512×8bit構成を使用します。

使用するBRAMはNorth BRAM側のBRAM_0だけに固定し、アドレス幅は9bit、利用範囲は0~511番地とします。

幅や深さ、使用するBRAM番号を自由に変更できる汎用モジュールにはしません。別の構成が必要になった場合は、用途が分かる名前を付けた別テンプレートとして用意する方針です。

ForgeFPGAのBRAMはFPGA Coreの外側にある

ForgeFPGAのBRAMで特徴的なのは、BRAMが通常のVerilogモジュールと同じFPGA Core内に置かれているわけではないことです。

Renesas公式資料の構成図では、中央にFPGA Coreがあり、その外側にNorth BRAMとSouth BRAMが配置されています。

FPGA CoreからBRAMを使うには、専用の信号をトップモジュールのポートへ出し、ForgeFPGA Workshop上でBRAMブロックへ接続する必要があります。

そのため、一般的なFPGA用のVerilogコードで見かける次のような配列を記述するだけでは、ForgeFPGA内蔵BRAMへの接続になりません。

reg [7:0] memory [0:511];

少なくとも公式サンプルでは、BRAMのデータ、アドレス、Enableなどを専用ポートとして明示しているようですね。

今回の実装方針は公式サンプルの流儀に従うことにします。


BRAMはどうやって使うの?

Renesas公式FIFOサンプル

Renesas公式のAN-FG-011 FIFO using BRAMでは、BRAM_0を512×8bitに設定し、その一部を8段×4bitのFIFOとして使用しています。

使用しているのはBRAM全体のごく一部です。

  • アドレス:8個
  • データ幅:4bit
  • 物理BRAM構成:512×8bit

BRAMの前後に、次の回路が追加されています。

  • Write Pointer
  • Read Pointer
  • FULL/EMPTY判定
  • 書き込み制御
  • 読み出し制御

FIFOは「先に入れた値を先に取り出す」ためのキューの仕組みです。

しかし、BRAM自身がFIFOなのではありません。

BRAMは指定されたアドレスへ値を書き、指定されたアドレスから値を読むメモリです。どの順番でアドレスを進めるかは、BRAMの周囲に実装した回路が決めています。

つまり、FIFOサンプルの中には、

  • BRAMを操作する共通部分
  • FIFOを実現する問題固有部分

が一緒に入っています。

Shrike公式LIFOサンプル

Shrike公式のstack_processorは、BRAMをLIFOとして使用するスタックベースの小さなプロセッサです。

LIFOは「最後に入れた値を最初に取り出す」仕組みです。

サンプルは、主に次の三つのモジュールで構成されています。

モジュール 役割
top SPI命令の解釈、スタック制御、レジスタ、演算
spi_target SPI Slave通信
lifo_bram BRAMを使用したLIFO

RP2040からSPIで命令を送り、4bitの値をスタックへPushしたり、Popした値を内部レジスタへ移したり、加減算や乗算などを行ったりできます。

公式の動作例では、2と5をPushし、内部レジスタへ取り出して乗算し、結果の10を再びスタックへPushして読み出しています。

このサンプルは、BRAM単体の動作例ではありません。

しかし、次の要素がすでに組み合わされています。

  • Shrike-LiteのRP2040
  • SPI通信
  • FPGA側の命令処理
  • BRAMへの書き込み
  • BRAMからの読み出し
  • MicroPythonによる実機テスト

そのため、Shrike-LiteでBRAMを使う実用例として、かなり良い参考になりそうです。

公式LIFOサンプルを再テストする

ここで、Shrike公式stack_processorを動かしてみます。

確認する内容は次の通りです。

  1. 公式のbitstreamを書き込む
  2. Thonnyからmultiplication.pyを実行する
  3. 2と5がスタックへPushされる
  4. 乗算結果の10が読み出される
  5. 最終状態としてEmpty=1、Full=0と、乗算結果の10が返る

実行すると、Thonnyのシェルに次の処理メッセージが表示されました。

Sent 0x12, Received 0x00
Sent 0x15, Received 0x00
Sent 0x33, Received 0x00
Sent 0x34, Received 0x00
Sent 0xC2, Received 0x00
Sent 0x32, Received 0x00
Sent 0x20, Received 0x00
Sent 0x00, Received 0x8A

最後に0x8Aが返り、Empty=1、Full=0、乗算結果0xA(10)を確認できました。

これで、Shrike-Lite上のBRAM_0、SPI通信、BRAMへの書き込みと読み出しが正常に動作することを確認できました。


BRAMの信号を確認する

Renesas公式FIFOとShrike公式LIFOでは、BRAM_0へ接続するために、おおむね次の信号を使用しています。

構成を決める信号

信号 役割
BRAM0_RATIO[1:0] BRAMの幅と深さを選択する

2'b00を指定すると、BRAM_0は512×8bitとして動作します。

書き込み用の信号

Verilog上の信号名 BRAM端子 役割
BRAM0_DATA_IN[7:0] WDATA[7:0] 書き込むデータ
BRAM0_WRITE_ADDR[8:0] WADDR[8:0] 書き込み先アドレス
BRAM0_WEN nWEN 書き込み有効。Lowで有効
BRAM0_WCLKEN nWCLKEN 書き込みクロック有効。Lowで有効
REF_WRITE_CLK 書き込みクロック

読み出し用の信号

Verilog上の信号名 BRAM端子 役割
BRAM0_READ_ADDR[8:0] RADDR[8:0] 読み出し元アドレス
BRAM0_DATA_OUT[3:0] RDATA[3:0] 読み出したデータ。BRAM端子自体は8bit幅だが、公式サンプルでは下位4bitのみ使用
BRAM0_REN nREN 読み出し有効。Lowで有効
BRAM0_RCLKEN nRCLKEN 読み出しクロック有効。Lowで有効
REF_READ_CLK 読み出しクロック

ForgeFPGA Workshop上のBRAM端子では、nWENnRENのように、信号名の先頭にnが付いている制御信号はActive Lowです。

一方、Shrike公式サンプルのVerilogでは、これらに接続する信号がBRAM0_WENBRAM0_RENと命名されており、nが省略されています。

そのため、Verilog上の信号名だけから極性を判断せず、接続先のBRAM端子とコード内の代入値を確認する必要があります。

後ほどテンプレート化するときには、信号名の記法を統一したほうがよさそうですね。

書き込みと読み出しには時間がかかる

BRAMは、アドレスを指定した瞬間に値が出てくる組み合わせ回路ではありません。

Shrike公式stack_processorのREADMEには、書き込みと読み出しのタイミングについて次の説明があります。

  • 書き込みには1サイクル必要
  • 読み出しには2サイクル必要
  • 読み出したデータは2サイクル目に有効になる

問題固有回路から、

このアドレスを読んでください

と要求した次の瞬間に、読み出しデータを使えるわけではないですよ、ということですね。

例えば、stack_processorの実装では、cntを使って数サイクル待ち、cnt == 4になったときに読み出しデータをレジスタへ取り込んでいます。

このタイミングは、テンプレートのインターフェースを決める上で重要です。

読み出し要求とデータ有効の間をどのように表現するかは、よく考えてからテンプレートにする必要がありそうですね。


ForgeFPGA Workshopで必要な設定

BRAMは、Verilogコードを追加するだけでは使用できません。

ForgeFPGA Workshop側でも、使用するBRAMとクロックを設定する必要があります。

North BRAMを有効にする

BRAM_0~BRAM_3はNorth BRAMに含まれます。

BRAM_0を使用する場合は、ForgeFPGA WorkshopのBRAM PropertiesでNorth BRAMを有効にします。

メイン画面のブロック接続図からBRAMブロックをクリックすると、画面左上にBRAMプロパティが表示されます。

Shrike-LiteでAtCoder問題を解く(10)_001.png

stack_processor公式サンプルでは、Memory RetentionNorth BRAM Enableが有効に設定されています。

Shrike-LiteでAtCoder問題を解く(10)_002.png

IO PlannerでBRAMクロックを設定する

Renesas公式やShrike公式サンプルでは、IO Planner上でBRAMの読み出しクロックと書き込みクロックを設定しています。

通常のGPIOだけでなく、BRAMへ供給するクロック信号についても割り当てが必要です。

次の図はIO PlannerでBRAM配線の一部を確認した結果です。

Shrike-LiteでAtCoder問題を解く(10)_004.png

FloorplanでBRAM_0を確認する

合成後は、FloorplanでBRAM_0が有効になっていることを確認できます。

Floorplanの表示は、Verilog記述画面からFloorplanアイコンをクリックします。

Shrike-LiteでAtCoder問題を解く(10)_003.png

Floorplanでは、BRAM_0が有効になり、FPGA Coreから各信号が接続されていることを確認できます。

Shrike-LiteでAtCoder問題を解く(10)_005.png


BRAM実装で注意すること

公式サンプルを確認した段階で、少なくとも次の点には注意が必要そうです。

BRAM番号とNorth/Southの設定を一致させる

BRAM_0を接続しているのにNorth BRAMを無効にすると、正しく使用できません。

使用するBRAM番号とForgeFPGA Workshop側の設定を一致させる必要があります。

Enable信号の極性を確認する

WENRENなど、Active Lowの信号があります。

通常のEnable信号と同じ感覚で扱うと、読み書きの有効/無効が逆になります。

読み出しの待ち時間を考慮する

BRAMの出力には遅延があります。

読み出し要求を出したサイクルと、読み出しデータを使用するサイクルを分ける必要があります。

BRAMの内容は明示的に初期化する

通常、リセット信号だけでBRAM内の全アドレスが自動的にゼロクリアされるわけではありません。

今回のテンプレートでは、まだ一度も書き込んでいないアドレスを読み出した場合でも必ず0が返るように、次の二つのタイミングでBRAM_0の全512byteへ0を書き込みます。

  • ハードウェアリセット解除後
  • SPIのRESETコマンド受信後

ゼロクリア中はBRAMアクセスモジュールのbusyをHighにし、通常の読み出し要求と書き込み要求は受け付けません。

MicroPython側では共通仕様として、ハードウェアリセット解除後とRESETコマンド送信後に1ms待ってから、次のSPI通信を開始します。

BRAMそのものと、FIFO/LIFOを分けて考える

FIFOとLIFOの違いは、主にアドレスの管理方法です。

  • FIFO:書き込み位置と読み出し位置を別々に進める
  • LIFO:スタックポインタをPushとPopで増減させる

BRAMは、どちらの場合でも指定されたアドレスへ読み書きしているだけです。

テンプレート化するときは、FIFOやLIFOのアドレス管理をBRAM共通部へ入れない方が、AtCoder問題で使いやすくなりそうです。


BRAMはテンプレート化できる?

ここから、AtCoder問題で繰り返し使用するためのBRAMテンプレートの仕様を決めます。

Shrike公式LIFOサンプルのlifo_bramをそのまま再利用すると、Push、Pop、スタックポインタ、Empty/Full判定が付いてきます。

スタックが必要な問題には便利ですが、配列のように任意のアドレスへアクセスしたい場合には使いにくくなります。

そこで、LIFO固有部分とBRAMの読み書きに共通する部分を分離します。

テンプレートへ共通化する部分

区分 内容
BRAM接続部分 データ、アドレス、Enable、RATIO
BRAMアクセス制御 読み書き要求の受付、待ち時間、データ有効通知
LIFO固有部分 スタックポインタ、Push、Pop、Empty、Full
Stack Processor固有部分 SPI命令、A/B/Cレジスタ、演算

AtCoder用テンプレートとして残すのは、BRAM接続部分とBRAMアクセス制御です。

LIFO固有部分とStack Processor固有部分は取り除き、問題固有回路からアドレスとデータを指定できるようにします。

BRAM構成を固定する

今回は、最小構成として次の仕様に固定します。

項目 仕様
使用するBRAM North BRAM側のBRAM_0のみ
幅と深さ 512×8bit
アドレス幅 9bit
利用範囲 0~511番地
FPGA Core側クロック 50MHz
BRAM読み書きクロック FPGA Coreと同じ50MHz
同時アクセス 読み出しと書き込みを同時には行わない

BRAM番号、幅、深さを切り替えられる汎用モジュールにはしません。

例えば4bit幅や複数BRAMを使う構成が必要になった場合は、既存テンプレートへ設定項目を増やすのではなく、用途が分かる名前を付けた別テンプレートとして作成します。

また、main.vの先頭付近には、使用するBRAM、幅と深さ、アドレス範囲、ForgeFPGA Workshop側で必要な設定をコメントとして記載します。

テンプレートの役割分担

これまで作成したSPIテンプレートへ、BRAMアクセス用モジュールを追加します。

RP2040
  │
  │ SPI
  ▼
spi_target
  │
  ▼
main.vの問題固有処理
  │
  │ 読み書き要求
  ▼
BRAMアクセスモジュール
  │
  │ BRAM専用信号
  ▼
BRAM_0

役割は次のように分けます。

部分 役割
spi_target.v RP2040とのSPI通信
main.v コマンド処理とAtCoder問題固有ロジック
BRAMアクセスモジュール BRAMの読み書き手順、待ち時間、ゼロクリアの管理
BRAM_0 データの保存

FPGA CoreとBRAM_0の接続

BRAM_0はFPGA Coreの外側にあるため、通常のVerilogサブモジュールのように、コード内でインスタンス化するだけでは使用できません。

まず、BRAMアクセスモジュールの信号をmain.vのトップレベルポートへ接続します。

さらにForgeFPGA WorkshopのIO Plannerで、それらのポートをBRAM_0のWDATAWADDRnWENRDATAなどの専用端子へ割り当てます。

操作方法はGPIOのピン割り当てと似ていますが、接続先は基板外のGPIOではなく、ForgeFPGA内部のBRAMブロックです。

BRAMの読み出しクロックと書き込みクロックについても、IO PlannerでREF_READ_CLKREF_WRITE_CLKへ50MHzクロックを割り当てます。

BRAMアクセスモジュールからトップレベルへ出す信号名は固定します。

bram0_ratio[1:0]

bram0_write_data[7:0]
bram0_write_addr[8:0]
bram0_wen_n
bram0_wclken_n

bram0_read_data[7:0]
bram0_read_addr[8:0]
bram0_ren_n
bram0_rclken_n

公式サンプルでは、Active Lowの信号に接続するVerilog側の信号名からnが省略されていました。

今回のテンプレートでは、bram0_wen_nbram0_ren_nのように、Lowで有効になることが名前から分かるようにします。

REF_WRITE_CLKREF_READ_CLKはVerilogモジュールのポートには出さず、IO Planner側で設定します。

問題固有回路とのインターフェース

テンプレートを使いやすくするため、問題固有回路からBRAM専用信号を直接操作させないことにします。

Active LowのEnable信号やBRAM固有の読み出しタイミングはBRAMアクセスモジュール側で処理し、問題固有回路からは次の信号を使用します。

信号 方向 役割
write_req 入力 1クロックの書き込み要求
write_addr[8:0] 入力 書き込み先アドレス
write_data[7:0] 入力 書き込むデータ
read_req 入力 1クロックの読み出し要求
read_addr[8:0] 入力 読み出し元アドレス
read_data[7:0] 出力 読み出したデータ
read_valid 出力 read_dataが有効になったことを示す1クロックパルス
busy 出力 BRAMアクセスまたはゼロクリアの処理中
clear 入力 BRAM0全領域のゼロクリア。1クロックパルス

ほんの少しですが抽象化を行い、問題固有回路からはアドレスとデータ、読み書き要求だけを扱える形にします。

busyread_validの使い分け

busyは、BRAMアクセスモジュールが処理中であることを示す状態信号です。

busy=1の間は、新しい読み出し要求や書き込み要求を受け付けません。

一方、read_validは、読み出したデータが有効になったことを示す1クロックだけのパルス信号です。

信号 確認する内容
busy 次のBRAMアクセスを要求できるか
read_valid 今のread_dataを取り込むべきか

読み出し側は、busyがLowになることではなく、read_valid=1を確認してread_dataを取り込みます。

書き込みには返却データがないため、busyがLowへ戻った時点を処理完了とします。

read_dataは次の読み出しが完了するまで最後の値を保持しますが、有効な取り込みタイミングはread_valid=1のクロックです。

読み書き要求のルール

読み書き要求は、次のルールで扱います。

  • write_reqread_reqは1クロックだけHighにする
  • 要求はbusy=0のときだけ受け付ける
  • busy=1中に届いた要求は無視する
  • write_reqread_reqの同時Highは禁止する
  • 両方が同時にHighになった場合は、実装上は書き込みを優先する
  • 要求を受け付けた時点で、アドレスと書き込みデータをモジュール内部へ保持する
  • アドレスは9bit固定なので、範囲外チェックは行わない

書き込み要求を受け付けたら、書き込みアドレスとデータを保持し、bram0_wen_nを1クロックだけLowにします。

読み出し要求を受け付けたら、読み出しアドレスを保持し、bram0_ren_nを1クロックだけLowにします。

bram0_wclken_nbram0_rclken_nは、公式サンプルと同じく常時Lowとして、BRAMクロックを常に有効にします。

読み出しは4クロック待つ

Shrike公式stack_processorのREADMEでは、読み出しデータが有効になるまで2クロック必要と説明されています。

ただし、Shrike公式stack_processorでは、読み出し要求後にcnt == 4となるまで待ってからデータを取り込んでいます。

今回のテンプレートでも最短の読み出し時間は狙わず、公式サンプルと同様に4クロック待ってから読み出しデータを確定します。

読み出し時の動作は次のようになります。

  1. busy=0のときにread_reqを受け付ける
  2. 読み出しアドレスを保持し、busy=1にする
  3. bram0_ren_nを1クロックだけLowにする
  4. 4クロック待つ
  5. bram0_read_dataread_dataへ取り込む
  6. 同じクロックでread_valid=1にする
  7. 次のクロックでread_valid=0busy=0へ戻す

read_valid=1のクロックでは、まだbusy=1とします。

FPGAの内部クロックはSPI通信より十分に速いため、数クロック長く待つことによる影響はほとんどありません。

リセット時にBRAM全体をゼロクリアする

ハードウェアリセット解除後と、SPIのRESETコマンドを受信した後には、BRAM_0の0~511番地へ順番に0を書き込みます。

BRAMの内容を既知の状態にしておくことで、まだ一度も書き込んでいないアドレスを読み出した場合でも、必ず0が返るようにします。

ゼロクリア中は次の状態とします。

  • busy=1
  • read_valid=0
  • 通常の読み出し要求と書き込み要求は受け付けない
  • 0番地から511番地まで、1アドレスずつ0を書き込む
  • 全領域の書き込み完了後にbusy=0へ戻る

SPI側には、初期化中を通知するための新しいREPLYステータスは追加しません。

MicroPython側の共通処理で、ハードウェアリセット解除後とRESETコマンド送信後に1ms待ってから、次の通信を開始します。

# FPGAリセット解除後、BRAM全体のゼロクリア完了を待つ
time.sleep_ms(1)

512byteのゼロクリアはFPGA内部で行います。MicroPython側は初期化処理を担当せず、完了までの固定時間を待つだけです。

高機能な汎用コントローラにはしない

今回は、読み出しと書き込みの同時実行、要求のキューイング、エラー通知、アドレス範囲チェックなどは実装しません。

通信相手は自作のMicroPythonプログラムだけであり、AtCoderの入力条件も問題文によって保証されています。

必要最低限の規約を守れば確実に動き、コードを読んだときに処理を追いやすいインターフェースを目指します。


テンプレートとして整理する

動作確認後は、BRAM_0を512×8bitで使用することが名前から分かるテンプレートとして保存します。

プロジェクト名と最終的なファイル構成は次の通りです。

atcoder_spi_bram0_8bit_template/
├── atcoder_spi_bram0_8bit_template.ffpga
├── bitstream/
│   └── atcoder_spi_bram0_8bit_template.bin
├── ffpga/
│   └── src/
│       ├── main.v
│       ├── spi_target.v
│       └── bram0_8bit_access.v
└── firmware/
    └── micropython/
        └── atcoder_spi_bram0_8bit_template_test.py

問題ごとに変更する部分と、共通部分は次のように整理します。

ファイル/部分 問題ごとに変更
spi_target.v 原則変更しない
BRAMアクセスモジュール 原則変更しない
BRAM専用ポート接続 原則変更しない
BRAM_0の512×8bit設定 原則変更しない
ハードウェア/SPI RESET時のゼロクリア 原則変更しない
main.vの問題固有部分 変更する
SPIコマンド 必要に応じて変更する
MicroPythonテスト 問題ごとに変更する
ForgeFPGA WorkshopのBRAM設定 テンプレートをコピーして使用する

BRAMテンプレートを実装する

ここまでに決めた仕様に従って、実際にBRAMテンプレートを作成します。

今回は、これまで使用してきたSPIテンプレートを基に、次の二つを追加・変更します。

  • BRAM_0の読み書き手順を担当するbram0_8bit_access.v
  • SPIからBRAMの読み書きを確認するための動作確認用main.v

BRAMへのアクセス方法はbram0_8bit_access.vに記述します。

一方、SPIコマンドによるアドレス指定や読み書き指示は、BRAMテンプレートの共通仕様ではありません。今回は動作確認のため、main.vの問題固有部分として実装します。

SPIテンプレートをコピーしてファイル名を整える

第5回で作成したatcoder_spi_templateをコピーし、新しいプロジェクトを作成します。

プロジェクト名は次のようにします。

atcoder_spi_bram0_8bit_template

ファイル構成は次の通りです。

atcoder_spi_bram0_8bit_template/
├── atcoder_spi_bram0_8bit_template.ffpga
├── bitstream/
│   └── atcoder_spi_bram0_8bit_template.bin
├── ffpga/
│   └── src/
│       ├── main.v
│       ├── spi_target.v
│       └── bram0_8bit_access.v
└── firmware/
    └── micropython/
        └── atcoder_spi_bram0_8bit_template_test.py

spi_target.vは、これまでのSPIテンプレートと同じものを使用します。

問題固有処理を記述するmain.vは、後ほどBRAM動作確認用に変更します。

ForgeFPGA WorkshopでBRAM_0を有効にする

コピーしたプロジェクトをForgeFPGA Workshopで開き、BRAM PropertiesからNorth BRAM Enableを有効にします。

BRAM_0はNorth BRAM側に含まれるため、今回必要なのはNorth BRAMです。South BRAMは使用しません。

Shrike公式stack_processorではMemory Retentionも有効になっていますが、今回のテンプレートでは無効にします。

Memory Retentionは、デバイスのリセット時や低消費電力モード時などにBRAMの内容を保持するための設定です。

しかし、今回のテンプレートでは、ハードウェアリセット解除後にBRAM_0の全領域を明示的にゼロクリアします。

内容を保持していても直後にゼロで上書きされるため、Memory Retentionを有効にする必要はありません。

Shrike-LiteでAtCoder問題を解く(10)_006.png

bram0_8bit_access.vをCustom Moduleとして追加する

次に、ForgeFPGA Workshopでbram0_8bit_accessという名前のCustom Moduleを追加します。

ForgeFPGA Workshopでは、Verilogファイルをプロジェクトフォルダへコピーしただけでは、Custom Moduleとして認識されません。

操作方法は、第5回の「spi_target.vを自分のプロジェクトへ取り込む」と同じです。詳しい手順は第5回を参照してください。

Custom Moduleを追加すると、ForgeFPGA Workshopによって同名のVerilogファイルが作成されます。

先に完成版のbram0_8bit_access.vを配置していると、Custom Moduleの追加操作によって既存ファイルを上書きしてしまう可能性があります。

そのため、今回は先にCustom Moduleを追加し、作成されたbram0_8bit_access.vを後からAIに編集してもらいます。

Shrike-LiteでAtCoder問題を解く(10)_007.png

AIにbram0_8bit_access.vの実装を依頼する

BRAM_0の読み書き手順を、問題固有回路から分離したモジュールとして実装します。

ファイル名はbram0_8bit_access.v、モジュール名はbram0_8bit_accessとします。

このモジュールが担当する処理は次の通りです。

  • BRAM_0を512×8bit構成で使用する
  • Active LowのEnable信号を制御する
  • 書き込み要求を1件ずつ処理する
  • 読み出し要求を受け付けた次のクロックから4クロック待ってデータを取り込む
  • read_validで読み出し結果の有効タイミングを通知する
  • ハードウェアリセット解除後に全512byteをゼロクリアする
  • clear入力後に全512byteをゼロクリアする
  • 処理中はbusyをHighにする

AIには、Custom Module追加時に作成された既存のbram0_8bit_access.vへ処理を実装するよう依頼しました。

実際に依頼する際は、皆さんの環境に合わせて、Git操作や指定外ファイルの変更を禁止するなど、必要な条件を追加してください。

既存のShrike-Lite用SPIテンプレートにある、
BRAM_0アクセス用Custom Moduleを実装してください。

変更するファイルは次の1ファイルです。

ffpga/src/bram0_8bit_access.v

モジュール名は`bram0_8bit_access`としてください。

Shrike公式`stack_processor`のBRAM実装を参考にしながら、
BRAM_0を512×8bit構成で使用し、問題固有回路から次の信号で
読み書きできるようにしてください。

- `write_req`
- `write_addr[8:0]`
- `write_data[7:0]`
- `read_req`
- `read_addr[8:0]`
- `read_data[7:0]`
- `read_valid`
- `busy`
- `clear`

BRAM側のポートは次の名前にしてください。

- `bram0_ratio[1:0]`
- `bram0_write_data[7:0]`
- `bram0_write_addr[8:0]`
- `bram0_wen_n`
- `bram0_wclken_n`
- `bram0_read_data[7:0]`
- `bram0_read_addr[8:0]`
- `bram0_ren_n`
- `bram0_rclken_n`

仕様は次の通りです。

- 読み書き要求は`busy=0`のときだけ受け付ける
- 書き込み時は`bram0_wen_n`を1クロックだけLowにする
- 読み出し時は`bram0_ren_n`を1クロックだけLowにする
- 読み出し要求後は4クロック待ってデータを取り込む
- 読み出し完了時に`read_valid`を1クロックだけHighにする
- ハードウェアリセット解除後に0~511番地をゼロクリアする
- `clear`入力時にも0~511番地をゼロクリアする
- ゼロクリア中は`busy=1`とする
- ゼロクリアでは、各アドレスについてWENのLowとHighを交互に行う
- 処理中の通常要求は無視する
- `clear`は処理中でも受け付け、0番地からゼロクリアをやり直す
- 優先順位は、ハードウェアリセット、`clear`、書き込み、読み出しとする
- `bram0_wclken_n`と`bram0_rclken_n`は常時Lowとする

Verilogで記述し、SystemVerilog固有構文は使用しないでください。

追加する処理には、可能であれば日本語コメントを入れてください。
日本語が難しい場合は、処理ブロックごとに英語コメントを入れてください。

仕様に不明点がある場合は、独断で補完せず質問してください。

bram0_8bit_access.vを確認する

実装されたbram0_8bit_access.vは、次の状態を持つFSMになりました。

状態 役割
STATE_IDLE 通常の読み出し要求、書き込み要求、clearを待つ
STATE_WRITE_DONE 書き込みEnableをHighへ戻し、書き込みを完了する
STATE_READ_WAIT BRAM読み出し後の4クロック待ち
STATE_READ_VALID read_validを終了し、busyをLowへ戻す
STATE_CLEAR_WRITE 現在のアドレスへ8'h00を書き込む
STATE_CLEAR_GAP WENをHighへ戻し、次のアドレスを準備する

通常の読み書きは一度に1件だけ処理します。

書き込みでは、bram0_wen_nを1クロックだけLowにします。

読み出しでは、bram0_ren_nを1クロックだけLowにした後、要求受付の次のクロックから4クロック待ってデータを取り込みます。

ゼロクリアでは、STATE_CLEAR_WRITESTATE_CLEAR_GAPを交互に繰り返し、0番地から511番地まで8'h00を書き込みます。

1アドレスにつき2クロック使用するため、50MHz動作では全領域のゼロクリアが約20.5µsで完了します。MicroPython側で1ms待つ仕様には十分な余裕があります。

以下が bram0_8bit_access.vの全コードです。

// BRAM_0専用の、512×8bit構成のアクセスモジュール。
// RATIOは2'b00固定で、使用するアドレスは0~511とする。
// REF_WRITE_CLKとREF_READ_CLKは、IO Plannerで50MHzクロックへ割り当てる。
// bram0_wclken_nとbram0_rclken_nは常時Lowとし、BRAMクロックを常に有効にする。
// ハードウェアリセット解除後とclear入力後に、BRAM_0の全領域をゼロクリアする。
// 読み出しは公式サンプルに合わせ、要求受付後に4クロック待ってデータを取り込む。
module bram0_8bit_access (
    input              clk,
    input              rst_n,
    input              clear,
    input              write_req,
    input      [8:0]   write_addr,
    input      [7:0]   write_data,
    input              read_req,
    input      [8:0]   read_addr,
    output reg [7:0]   read_data,
    output reg         read_valid,
    output reg         busy,

    output     [1:0]   bram0_ratio,
    output reg [7:0]   bram0_write_data,
    output reg [8:0]   bram0_write_addr,
    output reg         bram0_wen_n,
    output             bram0_wclken_n,
    input      [7:0]   bram0_read_data,
    output reg [8:0]   bram0_read_addr,
    output reg         bram0_ren_n,
    output             bram0_rclken_n
);

    // BRAM_0は512×8bit構成で使用し、書き込み・読み出しクロックを常に有効にする。
    assign bram0_ratio    = 2'b00;
    assign bram0_wclken_n = 1'b0;
    assign bram0_rclken_n = 1'b0;

    // 読み出しの待ちクロック数設定(0、1、2、3の4クロック)
    localparam [1:0] READ_WAIT_LAST = 2'd3;

    // 通常要求を待つ状態。
    localparam [2:0] STATE_IDLE        = 3'd0;
    // 通常書き込みのEnableパルスを終了する状態。
    localparam [2:0] STATE_WRITE_DONE  = 3'd1;
    // BRAM読み出しデータが有効になるまで待つ状態。
    localparam [2:0] STATE_READ_WAIT   = 3'd2;
    // 読み出し結果の通知を終了する状態。
    localparam [2:0] STATE_READ_VALID  = 3'd3;
    // ゼロクリア対象アドレスへ書き込みEnableを出す状態。
    localparam [2:0] STATE_CLEAR_WRITE = 3'd4;
    // ゼロクリアの書き込みEnableをHighへ戻し、次のアドレスへ進む状態。
    localparam [2:0] STATE_CLEAR_GAP   = 3'd5;

    reg [2:0] state;
    reg [8:0] clear_addr;
    reg [1:0] read_wait_count;

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            state            <= STATE_CLEAR_WRITE;
            clear_addr       <= 9'd0;
            read_wait_count  <= 2'd0;
            read_data        <= 8'h00;
            read_valid       <= 1'b0;
            busy             <= 1'b1;
            bram0_write_data <= 8'h00;
            bram0_write_addr <= 9'd0;
            bram0_wen_n      <= 1'b1;
            bram0_read_addr  <= 9'd0;
            bram0_ren_n      <= 1'b1;
        end else begin
            // Enableとread_validは必要な状態だけで1クロック有効にする。
            bram0_wen_n <= 1'b1;
            bram0_ren_n <= 1'b1;
            read_valid  <= 1'b0;

            // clearは全状態で最優先し、進行中の処理を中断して0番地からやり直す。
            if (clear) begin
                state            <= STATE_CLEAR_WRITE;
                clear_addr       <= 9'd0;
                read_wait_count  <= 2'd0;
                busy             <= 1'b1;
                bram0_write_data <= 8'h00;
                bram0_write_addr <= 9'd0;
            end else begin
                case (state)
                    STATE_IDLE: begin
                        busy <= 1'b0;

                        // write_reqとread_reqが同時の場合は、write_reqを優先する。
                        if (write_req) begin
                            bram0_write_addr <= write_addr;
                            bram0_write_data <= write_data;
                            bram0_wen_n      <= 1'b0;
                            busy             <= 1'b1;
                            state            <= STATE_WRITE_DONE;
                        end else if (read_req) begin
                            bram0_read_addr <= read_addr;
                            bram0_ren_n     <= 1'b0;
                            read_wait_count <= 2'd0;
                            busy            <= 1'b1;
                            state           <= STATE_READ_WAIT;
                        end
                    end

                    STATE_WRITE_DONE: begin
                        // 直前の1クロックだけLowだった書き込みEnableを解除して完了する。
                        busy  <= 1'b0;
                        state <= STATE_IDLE;
                    end

                    STATE_READ_WAIT: begin
                        busy <= 1'b1;

                        // 要求受付クロックは数えず、次のクロックから4クロック数える。
                        if (read_wait_count == READ_WAIT_LAST) begin
                            read_data  <= bram0_read_data;
                            read_valid <= 1'b1;
                            state      <= STATE_READ_VALID;
                        end else begin
                            read_wait_count <= read_wait_count + 1'b1;
                        end
                    end

                    STATE_READ_VALID: begin
                        // read_validを1クロックで解除し、読み出し処理を完了する。
                        busy  <= 1'b0;
                        state <= STATE_IDLE;
                    end

                    STATE_CLEAR_WRITE: begin
                        // 現在のクリア対象へ8'h00を書き込むため、1クロックだけLowにする。
                        busy             <= 1'b1;
                        bram0_write_addr <= clear_addr;
                        bram0_write_data <= 8'h00;
                        bram0_wen_n      <= 1'b0;
                        state            <= STATE_CLEAR_GAP;
                    end

                    STATE_CLEAR_GAP: begin
                        // EnableがHighの間に次のアドレスを準備し、Lowの連続を防ぐ。
                        busy <= 1'b1;
                        if (clear_addr == 9'd511) begin
                            busy  <= 1'b0;
                            state <= STATE_IDLE;
                        end else begin
                            clear_addr       <= clear_addr + 1'b1;
                            bram0_write_addr <= clear_addr + 1'b1;
                            state            <= STATE_CLEAR_WRITE;
                        end
                    end

                    default: begin
                        // 不正状態からは、安全に0番地からのゼロクリアへ戻る。
                        state            <= STATE_CLEAR_WRITE;
                        clear_addr       <= 9'd0;
                        read_wait_count  <= 2'd0;
                        busy             <= 1'b1;
                        bram0_write_data <= 8'h00;
                        bram0_write_addr <= 9'd0;
                    end
                endcase
            end
        end
    end

endmodule

動作確認用のSPIコマンドを決める

今回のmain.vでは、RP2040からBRAMのアドレスとデータを直接指定できるようにします。

これはBRAMテンプレートの共通仕様ではなく、BRAMの読み書きを確認するための問題固有ロジックです。

MOSIとMISOは、どちらも次の1byte形式にします。

bit 7:4  CMD
bit 3:0  DATA

MOSIコマンド

CMD 名前 DATA 動作
4'h0 CMD_NOP 未使用 状態を変更しない。MISO返信の受信にも使用
4'h1 CMD_SET_ADDR_MSB DATA[0] BRAMアドレスのbit8を設定
4'h2 CMD_SET_ADDR_HIGH DATA[3:0] BRAMアドレスのbit7:4を設定
4'h3 CMD_SET_ADDR_LOW DATA[3:0] BRAMアドレスのbit3:0を設定
4'h4 CMD_SET_DATA_HIGH DATA[3:0] 書き込みデータのbit7:4を設定
4'h5 CMD_SET_DATA_LOW DATA[3:0] 書き込みデータのbit3:0を設定
4'h6 CMD_WRITE 未使用 保持中のアドレスへ保持中のデータを書き込む
4'h7 CMD_READ 未使用 保持中のアドレスから読み出す
4'h84'hE 予約 今回は使用しない
4'hF CMD_RESET 未使用 内部状態を初期化し、BRAM全領域をゼロクリアする

9bitのアドレスは、次の3コマンドに分けて送ります。

CMD_SET_ADDR_MSB   address[8]
CMD_SET_ADDR_HIGH  address[7:4]
CMD_SET_ADDR_LOW   address[3:0]

8bitの書き込みデータは、次の2コマンドに分けます。

CMD_SET_DATA_HIGH  write_data[7:4]
CMD_SET_DATA_LOW   write_data[3:0]

MISO返信

BRAMから読み出した8bitデータは、上位4bitと下位4bitに分けて2byteで返信します。

CMD 名前 DATA
4'h0 REPLY_IDLE 有効な返信なし
4'h8 REPLY_HIGH 読み出し値のbit7:4
4'h9 REPLY_LOW 読み出し値のbit3:0

例えば、BRAMから8'hA3を読み出した場合は、次の2byteを返信します。

0x8A  REPLY_HIGH DATA=A
0x93  REPLY_LOW  DATA=3

SPIのMISOは1byte遅れて返るため、RP2040側はCMD_READ送信後にCMD_NOPを2回送信して結果を受け取ります。

AIにmain.vの変更を依頼する

次に、既存のmain.vをBRAM動作確認用へ変更します。

main.vには、次の共通部分を残します。

  • spi_target.vのインスタンス
  • SPI受信完了ストローブの生成
  • BRAM_0のトップレベルポート
  • bram0_8bit_accessのインスタンス
  • BRAMアクセスモジュールとの接続信号

一方、次の処理は今回の動作確認用ロジックであり、問題固有部分として記述します。

  • SPIコマンドの定義と解釈
  • 9bitアドレスの組み立て
  • 8bit書き込みデータの組み立て
  • write_reqread_reqclearの発行
  • 読み出し結果の保持
  • MISOへの上位4bit、下位4bit返信
  • 読み出し返信状態の管理

AIには、main.vだけを変更し、それ以外のファイルを変更しないよう依頼しました。

既存のShrike-Lite用SPIテンプレートの`main.v`を変更し、
BRAM_0の読み書きを確認できるようにしてください。

変更するファイルは次の1ファイルです。

ffpga/src/main.v

既存の`spi_target.v`と`bram0_8bit_access.v`を使用してください。

MOSIとMISOは、どちらも次の1byte形式とします。

- bit 7:4:CMD
- bit 3:0:DATA

MOSIコマンドは次の通りです。

- `4'h0`:NOP
- `4'h1`:アドレスbit8を設定
- `4'h2`:アドレスbit7:4を設定
- `4'h3`:アドレスbit3:0を設定
- `4'h4`:書き込みデータbit7:4を設定
- `4'h5`:書き込みデータbit3:0を設定
- `4'h6`:書き込み要求
- `4'h7`:読み出し要求
- `4'hF`:内部状態を初期化し、BRAM全領域をゼロクリア
- `4'h8`~`4'hE`:予約

書き込みと読み出しは、`bram0_8bit_access`の`busy`がLowのときだけ
要求を1クロック発行してください。

読み出し完了時は`read_valid`を確認し、読み出した8bitデータを保持してください。

MISOでは、読み出し結果を次の2byteに分けて返信してください。

- `4'h8`+読み出し値の上位4bit
- `4'h9`+読み出し値の下位4bit

SPIの返信は1byte遅れるため、読み出し後にNOPを2回受信し、
1回目で上位4bit、2回目で下位4bitを返信してください。

`spi_target.v`のインスタンス、SPI受信ストローブ生成、
BRAM用トップレベルポート、`bram0_8bit_access`との接続は共通部分として扱ってください。

SPIコマンドの解釈、アドレスとデータの保持、読み書き要求、
読み出し返信処理は、今回の動作確認用の問題固有部分として
コメント上でも区別してください。

Verilogで記述し、SystemVerilog固有構文は使用しないでください。

追加する処理には、可能であれば日本語コメントを入れてください。
日本語が難しい場合は、処理ブロックごとに英語コメントを入れてください。

仕様に不明点がある場合は、独断で補完せず質問してください。

main.vを確認する

生成されたmain.vでは、bram0_8bit_accessをインスタンス化し、問題固有部分から抽象化された読み書き信号を操作します。

write_reqread_reqclearは、対応するコマンドを受信したクロックだけHighになる1クロックパルスです。

BRAM読み出し完了時には、read_valid=1を確認して読み出し値を保持します。

MISO返信は次の三状態で管理します。

状態 役割
REPLY_STATE_IDLE 読み出し結果なし
REPLY_STATE_HIGH 次のSPI転送で上位4bitを返信
REPLY_STATE_LOW 次のSPI転送で下位4bitを返信

読み出し時の通信シーケンスは次のようになります。

CMD_READ
  ↓
BRAM読み出し完了を待つ
  ↓
tx_dataへREPLY_HIGHを設定
  ↓
1回目のCMD_NOPで上位4bitを受信
  ↓
tx_dataへREPLY_LOWを設定
  ↓
2回目のCMD_NOPで下位4bitを受信
  ↓
tx_dataをREPLY_IDLEへ戻す

今回のSPIコマンドは、BRAMアクセスモジュールの共通部分には含めません。

将来のAtCoder実装では、SPIからBRAMアドレスを直接指定するのではなく、問題固有ロジックが内部でアドレスを生成する場合もあります。

以下が生成されたmain.v全コードです。

// North BRAM側のBRAM_0専用で、BRAM構成は512×8bitとする。
// BRAMアクセス処理はbram0_8bit_access.vが担当する。
// MOSIとMISOは、上位4bitをCMD、下位4bitをDATAとして使用する。
// 読み出し結果は、上位4bitと下位4bitの2byteに分けて返信する。
// ハードウェアリセット解除後とCMD_RESET後に、BRAM全領域をゼロクリアする。
// CMD_RESET送信後は、MicroPython側で1ms待ってから次のコマンドを送信する。
// REF_WRITE_CLKとREF_READ_CLKの50MHz BRAMクロックはIO Plannerで設定する。
(* top *) module main (
    // ===== 共通部分:Shrike-LiteとSPIの外部端子 =====
    (* iopad_external_pin, clkbuf_inhibit *) input clk,
    (* iopad_external_pin *) output clk_en,
    (* iopad_external_pin *) input rst_n,

    (* iopad_external_pin *) input  spi_ss_n,
    (* iopad_external_pin *) input  spi_sck,
    (* iopad_external_pin *) input  spi_mosi,
    (* iopad_external_pin *) output spi_miso,
    (* iopad_external_pin *) output spi_miso_en,

    // BRAM_0専用接続
    // IO PlannerでForgeFPGA内部のBRAM_0端子へ割り当てる。
    (* iopad_external_pin *) output [1:0] bram0_ratio,
    (* iopad_external_pin *) output [7:0] bram0_write_data,
    (* iopad_external_pin *) output [8:0] bram0_write_addr,
    (* iopad_external_pin *) output       bram0_wen_n,
    (* iopad_external_pin *) output       bram0_wclken_n,
    (* iopad_external_pin *) input  [7:0] bram0_read_data,
    (* iopad_external_pin *) output [8:0] bram0_read_addr,
    (* iopad_external_pin *) output       bram0_ren_n,
    (* iopad_external_pin *) output       bram0_rclken_n
);

    // ===== 共通部分:内部クロックとSPI送受信データ =====
    assign clk_en = 1'b1;

    wire [7:0] rx_data;
    wire       rx_data_valid;
    reg  [7:0] tx_data;

    reg        rx_data_valid_d;
    wire       rx_data_strobe;

    // SPI受信完了を1クロックのストローブへ変換し、同じコマンドの重複処理を防ぐ。
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            rx_data_valid_d <= 1'b0;
        else
            rx_data_valid_d <= rx_data_valid;
    end
    assign rx_data_strobe = rx_data_valid & ~rx_data_valid_d;

    // ============================================================
    // 問題ごとに変更する部分
    // ============================================================
    // 今回はBRAMテンプレートの動作確認用として、
    // SPIからBRAMのアドレスと書き込みデータを直接指定し、
    // bram0_8bit_accessへ読み書き要求を発行する。
    //
    // AtCoder問題を実装するときは、SPIコマンドによる直接制御に限らず、
    // 問題固有ロジックがアドレスやデータを生成してBRAMを操作してよい。
    // このため、以下のコマンド定義、保持レジスタ、要求生成、
    // 読み出し返信処理は問題固有部分として扱う。
    // ============================================================

    // 動作確認用SPIコマンド定義
    localparam [3:0] CMD_NOP           = 4'h0;
    localparam [3:0] CMD_SET_ADDR_MSB  = 4'h1;
    localparam [3:0] CMD_SET_ADDR_HIGH = 4'h2;
    localparam [3:0] CMD_SET_ADDR_LOW  = 4'h3;
    localparam [3:0] CMD_SET_DATA_HIGH = 4'h4;
    localparam [3:0] CMD_SET_DATA_LOW  = 4'h5;
    localparam [3:0] CMD_WRITE         = 4'h6;
    localparam [3:0] CMD_READ          = 4'h7;
    localparam [3:0] CMD_RESET         = 4'hF;

    localparam [3:0] REPLY_IDLE = 4'h0;
    localparam [3:0] REPLY_HIGH = 4'h8;
    localparam [3:0] REPLY_LOW  = 4'h9;

    // 読み出し返信状態
    localparam [1:0] REPLY_STATE_IDLE = 2'd0;
    localparam [1:0] REPLY_STATE_HIGH = 2'd1;
    localparam [1:0] REPLY_STATE_LOW  = 2'd2;

    wire [3:0] rx_cmd;
    wire [3:0] rx_command_data;
    assign rx_cmd          = rx_data[7:4];
    assign rx_command_data = rx_data[3:0];

    // アドレスと書き込みデータの保持
    reg [8:0] held_bram_addr;
    reg [7:0] held_write_data;

    // BRAMアクセス要求と読み出し結果
    reg        bram_clear;
    reg        bram_write_req;
    reg        bram_read_req;
    wire [7:0] bram_access_read_data;
    wire       bram_access_read_valid;
    wire       bram_access_busy;
    reg  [7:0] held_read_data;
    reg  [1:0] reply_state;

    // BRAM読み書き動作確認用のSPIコマンド処理
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            held_bram_addr  <= 9'd0;
            held_write_data <= 8'h00;
            bram_write_req  <= 1'b0;
            bram_read_req   <= 1'b0;
            bram_clear      <= 1'b0;
            held_read_data  <= 8'h00;
            reply_state     <= REPLY_STATE_IDLE;
            tx_data         <= 8'h00;
        end else begin
            // BRAM要求信号は、コマンドを受け付けたクロックだけHighにする。
            bram_write_req <= 1'b0;
            bram_read_req  <= 1'b0;
            bram_clear     <= 1'b0;

            // RESETによるBRAMゼロクリア要求。busyや返信状態に関係なく最優先する。
            if (rx_data_strobe && (rx_cmd == CMD_RESET)) begin
                held_bram_addr  <= 9'd0;
                held_write_data <= 8'h00;
                bram_write_req  <= 1'b0;
                bram_read_req   <= 1'b0;
                bram_clear      <= 1'b1;
                held_read_data  <= 8'h00;
                reply_state     <= REPLY_STATE_IDLE;
                tx_data         <= 8'h00;
            end

            // 読み出し結果を保持し、次のSPI転送に備えて上位4bit返信を設定する。
            else if (bram_access_read_valid) begin
                held_read_data <= bram_access_read_data;
                reply_state    <= REPLY_STATE_HIGH;

                // MISOへの上位4bit返信
                tx_data <= {REPLY_HIGH, bram_access_read_data[7:4]};
            end

            // 読み出し返信中は、NOPだけで上位・下位・IDLEの順に進める。
            else if (rx_data_strobe && (reply_state != REPLY_STATE_IDLE)) begin
                if (rx_cmd == CMD_NOP) begin
                    case (reply_state)
                        REPLY_STATE_HIGH: begin
                            // MISOへの下位4bit返信
                            tx_data     <= {REPLY_LOW, held_read_data[3:0]};
                            reply_state <= REPLY_STATE_LOW;
                        end

                        REPLY_STATE_LOW: begin
                            tx_data     <= {REPLY_IDLE, 4'h0};
                            reply_state <= REPLY_STATE_IDLE;
                        end

                        default: begin
                            tx_data     <= 8'h00;
                            reply_state <= REPLY_STATE_IDLE;
                        end
                    endcase
                end
            end

            // 返信中でない場合だけ、通常のMOSIコマンドを処理する。
            else if (rx_data_strobe) begin
                case (rx_cmd)
                    CMD_NOP: begin
                        // 通常時のNOPでは保持値を変更しない。
                    end

                    CMD_SET_ADDR_MSB: begin
                        // アドレスbit8だけを更新し、DATA[3:1]は無視する。
                        held_bram_addr[8] <= rx_command_data[0];
                    end

                    CMD_SET_ADDR_HIGH: begin
                        held_bram_addr[7:4] <= rx_command_data;
                    end

                    CMD_SET_ADDR_LOW: begin
                        held_bram_addr[3:0] <= rx_command_data;
                    end

                    CMD_SET_DATA_HIGH: begin
                        held_write_data[7:4] <= rx_command_data;
                    end

                    CMD_SET_DATA_LOW: begin
                        held_write_data[3:0] <= rx_command_data;
                    end

                    CMD_WRITE: begin
                        // 書き込み要求の発行。busy中と直前要求の受付中は無視する。
                        if (!bram_access_busy && !bram_write_req && !bram_read_req)
                            bram_write_req <= 1'b1;
                    end

                    CMD_READ: begin
                        // 読み出し要求の発行。busy中と直前要求の受付中は無視する。
                        if (!bram_access_busy && !bram_write_req && !bram_read_req)
                            bram_read_req <= 1'b1;
                    end

                    default: begin
                        // 予約コマンド4'h8~4'hEは何も変更しない。
                    end
                endcase
            end
        end
    end

    // ============================================================
    // 共通部分
    // ============================================================

    // 公式サンプルから取り込んだSPI Slaveモジュール
    spi_target #(
        .CPOL(1'b0),
        .CPHA(1'b0),
        .WIDTH(8),
        .LSB(1'b0)
    ) u_spi_target (
        .i_clk(clk),
        .i_rst_n(rst_n),
        .i_enable(1'b1),

        .i_ss_n(spi_ss_n),
        .i_sck(spi_sck),
        .i_mosi(spi_mosi),
        .o_miso(spi_miso),
        .o_miso_oe(spi_miso_en),

        .o_rx_data(rx_data),
        .o_rx_data_valid(rx_data_valid),

        .i_tx_data(tx_data),
        .o_tx_data_hold()
    );

    // BRAM_0アクセス共通モジュールとの接続
    // 問題固有部分は、このモジュールの抽象化された読み書き信号を使用する。
    bram0_8bit_access u_bram0_8bit_access (
        .clk              (clk),
        .rst_n            (rst_n),
        .clear            (bram_clear),
        .write_req        (bram_write_req),
        .write_addr       (held_bram_addr),
        .write_data       (held_write_data),
        .read_req         (bram_read_req),
        .read_addr        (held_bram_addr),
        .read_data        (bram_access_read_data),
        .read_valid       (bram_access_read_valid),
        .busy             (bram_access_busy),

        .bram0_ratio      (bram0_ratio),
        .bram0_write_data (bram0_write_data),
        .bram0_write_addr (bram0_write_addr),
        .bram0_wen_n      (bram0_wen_n),
        .bram0_wclken_n   (bram0_wclken_n),
        .bram0_read_data  (bram0_read_data),
        .bram0_read_addr  (bram0_read_addr),
        .bram0_ren_n      (bram0_ren_n),
        .bram0_rclken_n   (bram0_rclken_n)
    );

endmodule

IO PlannerでBRAM_0の配線とクロックを接続する

Verilogコード上でbram0_8bit_accessを接続しただけでは、FPGA Core外側のBRAM_0にはつながりません。

IO Plannerで、main.vのトップレベルポートをBRAM_0の専用端子へ割り当てます。結構、苦行です。頑張りましょう。

BRAM_0の接続配線リスト

IO Plannerでは、アドレスやデータのバスも1bitずつ個別に表示されます。

Shrike公式stack_processorでは、BRAMから読み出す値が4bitのため、RDATA[3:0]だけが接続されています。

今回のテンプレートではBRAM_0を512×8bit構成で使用するため、読み出しデータについてもRDATA[7:0]の8bitすべてを接続します。

接続する配線は次の通りです。

書き込みアドレス
BRAM_0端子 Core Direction main.vのポート
BRAM0_WADDR[8] Output bram0_write_addr[8]
BRAM0_WADDR[7] Output bram0_write_addr[7]
BRAM0_WADDR[6] Output bram0_write_addr[6]
BRAM0_WADDR[5] Output bram0_write_addr[5]
BRAM0_WADDR[4] Output bram0_write_addr[4]
BRAM0_WADDR[3] Output bram0_write_addr[3]
BRAM0_WADDR[2] Output bram0_write_addr[2]
BRAM0_WADDR[1] Output bram0_write_addr[1]
BRAM0_WADDR[0] Output bram0_write_addr[0]
書き込みデータ
BRAM_0端子 Core Direction main.vのポート
BRAM0_WDATA[7] Output bram0_write_data[7]
BRAM0_WDATA[6] Output bram0_write_data[6]
BRAM0_WDATA[5] Output bram0_write_data[5]
BRAM0_WDATA[4] Output bram0_write_data[4]
BRAM0_WDATA[3] Output bram0_write_data[3]
BRAM0_WDATA[2] Output bram0_write_data[2]
BRAM0_WDATA[1] Output bram0_write_data[1]
BRAM0_WDATA[0] Output bram0_write_data[0]
書き込み制御
BRAM_0端子 Core Direction main.vのポート
BRAM0_nWEN Output bram0_wen_n
BRAM0_nWCLKEN Output bram0_wclken_n
読み出しアドレス
BRAM_0端子 Core Direction main.vのポート
BRAM0_RADDR[8] Output bram0_read_addr[8]
BRAM0_RADDR[7] Output bram0_read_addr[7]
BRAM0_RADDR[6] Output bram0_read_addr[6]
BRAM0_RADDR[5] Output bram0_read_addr[5]
BRAM0_RADDR[4] Output bram0_read_addr[4]
BRAM0_RADDR[3] Output bram0_read_addr[3]
BRAM0_RADDR[2] Output bram0_read_addr[2]
BRAM0_RADDR[1] Output bram0_read_addr[1]
BRAM0_RADDR[0] Output bram0_read_addr[0]
読み出しデータ
BRAM_0端子 Core Direction main.vのポート
BRAM0_RDATA[7] Input bram0_read_data[7]
BRAM0_RDATA[6] Input bram0_read_data[6]
BRAM0_RDATA[5] Input bram0_read_data[5]
BRAM0_RDATA[4] Input bram0_read_data[4]
BRAM0_RDATA[3] Input bram0_read_data[3]
BRAM0_RDATA[2] Input bram0_read_data[2]
BRAM0_RDATA[1] Input bram0_read_data[1]
BRAM0_RDATA[0] Input bram0_read_data[0]
読み出し制御
BRAM_0端子 Core Direction main.vのポート
BRAM0_nREN Output bram0_ren_n
BRAM0_nRCLKEN Output bram0_rclken_n
BRAM構成
BRAM_0端子 Core Direction main.vのポート
BRAM0_RATIO[1] Output bram0_ratio[1]
BRAM0_RATIO[0] Output bram0_ratio[0]

合計すると、BRAM_0だけで次の40本を接続します。

種類 本数
書き込みアドレス 9
書き込みデータ 8
書き込み制御 2
読み出しアドレス 9
読み出しデータ 8
読み出し制御 2
BRAM構成 2
合計 40

IO Planner上ではバスが1bitずつ展開されるため行数は多く見えますが、同じ種類の信号をbit番号どおりに接続していく作業です。

また、上記の40本とは別に、North側BRAMの読み出しクロックと書き込みクロックを、FPGA Coreと同じclkへ接続します。

BRAMクロック端子 Core Direction 接続先
REF_BRAM(0..3)_READ_CLK Output clk
REF_BRAM(0..3)_WRITE_CLK Output clk

この2本は、BRAM_0~BRAM_3で共有するクロックです。

合計42本、かなり面倒ですが、設定してください。

テンプレート化すれば、この作業を毎度行わなくていい、というだけで、報われる気がしますね。

FPGA bitstreamを生成する

配線とクロックの割り当てが完了したら、合成を実行してbitstreamを生成します。

これまでと同様に生成されたFPGA_bitstream_MCU.binファイルを次のようにリネームします。

atcoder_spi_bram0_8bit_template.bin

合成後は、少なくとも次の点を確認します。

  • Verilogのコンパイルエラーがない
  • BRAM_0の端子割り当てにエラーがない
  • North BRAMが有効になっている
  • Floorplan上でBRAM_0が使用されている
  • CLB、LUT、FFの使用量が過大になっていない

手元の環境での合成と配置配線は、エラー0件で完了しました。

ただし、ForgeFPGA Workshopからは、組み合わせ回路、IOB FF、クロックバッファなどに関する警告が表示されています。

BRAMの読み出しアドレスについても、IOBへFFを格納できないという警告が1件表示されましたが、PnRツールが代替配置を行った、という内容が表示されました。どうやらbitstreamの生成は無事完了しています。

警告の影響については、続く実機テストで確認することにします。

AIにatcoder_spi_bram0_8bit_template_test.pyの生成を依頼する

最後に、RP2040からBRAMの読み書きを確認するMicroPythonプログラムを作成します。

既存のatcoder_spi_template_test.pyを参考にし、次の共通部分はそのまま使用します。

  • bitstream書き込み処理
  • SPI初期化
  • SPIピン設定
  • SPI周波数
  • CPOL/CPHA
  • FPGAリセット処理
  • 1byteのSPI送受信処理

BRAMテンプレート固有部分として、次の処理を追加します。

  • 9bitアドレスを3コマンドへ分割する
  • 8bitデータを2コマンドへ分割する
  • 指定アドレスへ書き込む
  • 指定アドレスから読み出す
  • REPLY_HIGHREPLY_LOWを8bitデータへ復元する
  • ハードウェアリセット後に1ms待つ
  • CMD_RESET送信後に1ms待つ

最低限、次の内容を確認します。

  • リセット直後の未書き込みアドレスから0が返る
  • 指定アドレスへ書き込んだ値を読み戻せる
  • 0番地と511番地でも読み書きできる
  • 同じアドレスを上書きできる
  • CMD_RESET後に書き込んだ値が0へ戻る
Shrike-Lite用BRAMテンプレートを実機確認する
MicroPythonプログラムを作成してください。

作成するファイルは次の1ファイルです。

firmware/micropython/atcoder_spi_bram0_8bit_template_test.py

既存の`atcoder_spi_template_test.py`を参考にし、
bitstream書き込み、SPI初期化、ピン設定、SPI周波数、
CPOL/CPHA、FPGAリセット、1byteのSPI送受信処理は
既存テンプレートと同じ構成にしてください。

使用するbitstreamは次の通りです。

atcoder_spi_bram0_8bit_template.bin

MOSIとMISOは、どちらも上位4bitをCMD、
下位4bitをDATAとする1byte形式です。

使用するMOSIコマンドは次の通りです。

- `4'h0`:NOP
- `4'h1`:アドレスbit8を設定
- `4'h2`:アドレスbit7:4を設定
- `4'h3`:アドレスbit3:0を設定
- `4'h4`:書き込みデータbit7:4を設定
- `4'h5`:書き込みデータbit3:0を設定
- `4'h6`:書き込み
- `4'h7`:読み出し
- `4'hF`:BRAM全領域をゼロクリア

9bitアドレスは3コマンド、
8bitの書き込みデータは2コマンドに分けて送信してください。

読み出し時は、アドレス設定後にREADを送り、
続けてNOPを2回送信してください。

MISO返信は次の形式です。

- `4'h8`+読み出し値の上位4bit
- `4'h9`+読み出し値の下位4bit

返信CMDを確認した上で、2byteから8bitデータを復元してください。

次の処理を関数に分けて実装してください。

- CMDとDATAを1byteにして送信する
- BRAMアドレスを設定する
- 書き込みデータを設定する
- 指定アドレスへ書き込む
- 指定アドレスから読み出す
- RESETコマンドを送る
- MISO返信を8bitデータへ復元する

ハードウェアリセット解除後とRESETコマンド送信後は、
BRAM全領域のゼロクリア完了を待つため1ms待機してください。

最低限、次の内容をテストしてください。

- リセット直後の未書き込みアドレスから0が返る
- アドレス0へ書き込み、読み戻せる
- アドレス511へ書き込み、読み戻せる
- アドレス256以上へ書き込み、読み戻せる
- 複数アドレスへ別々の値を書き込み、正しく読み戻せる
- 同じアドレスを上書きできる
- RESET後に書き込み済みアドレスが0へ戻る

各テストは1ケース1行程度で、
テスト名、アドレス、書き込み値、受信した2byte、
読み出し値、期待値、PASS/FAIL、処理時間を表示してください。

最後にPASS数、総テスト数、全体結果を表示してください。

MicroPythonで記述してください。

追加する処理には、可能であれば日本語コメントを入れてください。
日本語が難しい場合は、処理ブロックごとに英語コメントを入れてください。

仕様に不明点がある場合は、独断で補完せず質問してください。

生成されたコード全文です。

from machine import Pin, SPI
import time
import shrike


# ===== 共通部:bitstream名とShrike-Liteのピン設定 =====
BITSTREAM = "atcoder_spi_bram0_8bit_template.bin"

SCK = 2
CS = 1
MOSI = 3
MISO = 0
FPGA_RESET = 14


# SPIコマンド
CMD_NOP = 0x0
CMD_SET_ADDR_MSB = 0x1
CMD_SET_ADDR_HIGH = 0x2
CMD_SET_ADDR_LOW = 0x3
CMD_SET_DATA_HIGH = 0x4
CMD_SET_DATA_LOW = 0x5
CMD_WRITE = 0x6
CMD_READ = 0x7
CMD_RESET = 0xF

# MISO返信コマンド
REPLY_HIGH = 0x8
REPLY_LOW = 0x9


# ===== 共通部:FPGAへのbitstream書き込みとハードウェアリセット =====
shrike.reset()
shrike.flash(BITSTREAM)

reset_pin = Pin(FPGA_RESET, Pin.OUT, value=1)
reset_pin.value(0)
time.sleep_ms(100)
reset_pin.value(1)
time.sleep_ms(100)

# ハードウェアリセット解除後のBRAM全領域ゼロクリア完了を待つ。
time.sleep_ms(1)


# ===== 共通部:SPI Masterの初期化 =====
cs = Pin(CS, Pin.OUT, value=1)

spi = SPI(
    0,
    baudrate=1_000_000,
    polarity=0,
    phase=0,
    bits=8,
    firstbit=SPI.MSB,
    sck=Pin(SCK),
    mosi=Pin(MOSI),
    miso=Pin(MISO)
)


# ===== 共通部:1byteのSPI送受信 =====
def spi_exchange(value):
    tx = bytes([value])
    rx = bytearray(1)

    cs.value(0)
    spi.write_readinto(tx, rx)
    cs.value(1)

    return rx[0]


# CMDとDATAを上位/下位4bitへ格納し、1byteとして送信する。
def send_command(command, data=0):
    value = ((command & 0x0F) << 4) | (data & 0x0F)
    return spi_exchange(value)


# 9bitアドレスを3つのコマンドに分けて設定する。
def set_bram_address(address):
    send_command(CMD_SET_ADDR_MSB, (address >> 8) & 0x01)
    send_command(CMD_SET_ADDR_HIGH, (address >> 4) & 0x0F)
    send_command(CMD_SET_ADDR_LOW, address & 0x0F)


# 8bit書き込みデータを上位/下位4bitに分けて設定する。
def set_bram_write_data(value):
    send_command(CMD_SET_DATA_HIGH, (value >> 4) & 0x0F)
    send_command(CMD_SET_DATA_LOW, value & 0x0F)


# 指定アドレスへ8bit値を書き込む。
def write_bram(address, value):
    set_bram_address(address)
    set_bram_write_data(value)
    send_command(CMD_WRITE)


# 返信CMDを検査し、正しい2byteの場合だけ8bit値へ復元する。
def decode_read_reply(reply_high, reply_low):
    high_command = (reply_high >> 4) & 0x0F
    low_command = (reply_low >> 4) & 0x0F

    if high_command != REPLY_HIGH or low_command != REPLY_LOW:
        return None

    return ((reply_high & 0x0F) << 4) | (reply_low & 0x0F)


# 指定アドレスを読み出す。MISOは1byte遅延するためNOPを2回送る。
def read_bram(address):
    set_bram_address(address)
    send_command(CMD_READ)
    reply_high = send_command(CMD_NOP)
    reply_low = send_command(CMD_NOP)
    value = decode_read_reply(reply_high, reply_low)
    return reply_high, reply_low, value


# コマンドリセットを送り、BRAM全領域ゼロクリア完了を待つ。
def reset_bram():
    send_command(CMD_RESET)
    time.sleep_ms(1)


def format_optional_value(value):
    if value is None:
        return "N/A"
    return "0x{:02X}".format(value)


# 1ケースを実行し、要求された情報を1行で表示する。
def run_case(name, address, written_value, expected_value,
             write_before_read=False, reset_before_read=False):
    start_us = time.ticks_us()

    if reset_before_read:
        reset_bram()
    if write_before_read:
        write_bram(address, written_value)

    reply_high, reply_low, read_value = read_bram(address)
    elapsed_us = time.ticks_diff(time.ticks_us(), start_us)

    passed = read_value is not None and read_value == expected_value
    result = "PASS" if passed else "FAIL"

    print(
        "TEST={} ADDR={} WRITE={} MISO=0x{:02X},0x{:02X} "
        "READ={} EXPECT=0x{:02X} {} TIME={}us".format(
            name,
            address,
            format_optional_value(written_value),
            reply_high,
            reply_low,
            format_optional_value(read_value),
            expected_value,
            result,
            elapsed_us
        )
    )

    return passed


pass_count = 0
case_count = 0


def record_case(name, address, written_value, expected_value,
                write_before_read=False, reset_before_read=False):
    global pass_count, case_count

    case_count += 1
    if run_case(
        name,
        address,
        written_value,
        expected_value,
        write_before_read,
        reset_before_read
    ):
        pass_count += 1


# 1. ハードウェアリセット直後の未書き込みアドレスは0。
record_case("hardware-reset-zero", 123, None, 0x00)

# 2. 先頭アドレスへ書き込み、同じ値を読み戻す。
record_case("address-0", 0, 0xA3, 0xA3, write_before_read=True)

# 3. 最終アドレスへ書き込み、同じ値を読み戻す。
record_case("address-511", 511, 0x5C, 0x5C, write_before_read=True)

# 4. address[8]を使用するアドレスへ書き込み、同じ値を読み戻す。
record_case("address-bit8-256", 256, 0xF1, 0xF1, write_before_read=True)

# 5. 複数アドレスへ先に別々の値を書き、後から個別に読み戻す。
multi_patterns = (
    (17, 0x3C),
    (85, 0xC7),
    (300, 0x6D),
)

for address, value in multi_patterns:
    write_bram(address, value)

for index in range(len(multi_patterns)):
    address, value = multi_patterns[index]
    record_case("multiple-{}".format(index + 1), address, value, value)

# 6. 同じアドレスを別の値で上書きできることを確認する。
record_case("overwrite", 0, 0x7E, 0x7E, write_before_read=True)

# 7. CMD_RESET後、書き込み済みの複数アドレスが0へ戻ることを確認する。
# 最初のケースでCMD_RESETを送り、後続ケースでもクリア結果を確認する。
record_case("command-reset-1", 0, 0x7E, 0x00, reset_before_read=True)
record_case("command-reset-2", 17, 0x3C, 0x00)
record_case("command-reset-3", 256, 0xF1, 0x00)
record_case("command-reset-4", 511, 0x5C, 0x00)


overall_result = "PASS" if pass_count == case_count else "FAIL"
print(
    "SUMMARY PASS={} TOTAL={} RESULT={}".format(
        pass_count,
        case_count,
        overall_result
    )
)

実機で動作確認する

生成したbitstreamとMicroPythonプログラムをShrike-Liteへ転送し、Thonnyからテストを実行します。

テストでは、送信したアドレスとデータ、受信した値、期待値、PASS/FAILを1ケース1行で表示します。

[shrike_fpga] flashing: atcoder_spi_bram0_8bit_template.bin
[shrike_flash] FPGA programming done.
TEST=hardware-reset-zero ADDR=123 WRITE=N/A MISO=0x80,0x90 READ=0x00 EXPECT=0x00 PASS TIME=1310us
TEST=address-0 ADDR=0 WRITE=0xA3 MISO=0x8A,0x93 READ=0xA3 EXPECT=0xA3 PASS TIME=2348us
TEST=address-511 ADDR=511 WRITE=0x5C MISO=0x85,0x9C READ=0x5C EXPECT=0x5C PASS TIME=2235us
TEST=address-bit8-256 ADDR=256 WRITE=0xF1 MISO=0x8F,0x91 READ=0xF1 EXPECT=0xF1 PASS TIME=2262us
TEST=multiple-1 ADDR=17 WRITE=0x3C MISO=0x83,0x9C READ=0x3C EXPECT=0x3C PASS TIME=1306us
TEST=multiple-2 ADDR=85 WRITE=0xC7 MISO=0x8C,0x97 READ=0xC7 EXPECT=0xC7 PASS TIME=1520us
TEST=multiple-3 ADDR=300 WRITE=0x6D MISO=0x86,0x9D READ=0x6D EXPECT=0x6D PASS TIME=1354us
TEST=overwrite ADDR=0 WRITE=0x7E MISO=0x87,0x9E READ=0x7E EXPECT=0x7E PASS TIME=2202us
TEST=command-reset-1 ADDR=0 WRITE=0x7E MISO=0x80,0x90 READ=0x00 EXPECT=0x00 PASS TIME=1814us
TEST=command-reset-2 ADDR=17 WRITE=0x3C MISO=0x80,0x90 READ=0x00 EXPECT=0x00 PASS TIME=1243us
TEST=command-reset-3 ADDR=256 WRITE=0xF1 MISO=0x80,0x90 READ=0x00 EXPECT=0x00 PASS TIME=1340us
TEST=command-reset-4 ADDR=511 WRITE=0x5C MISO=0x80,0x90 READ=0x00 EXPECT=0x00 PASS TIME=1320us
SUMMARY PASS=12 TOTAL=12 RESULT=PASS

テスト結果

実機テストは、12ケースすべてPASSしました。

SUMMARY PASS=12 TOTAL=12 RESULT=PASS

確認できた内容は次の通りです。

  • ハードウェアリセット後の未書き込みアドレスから0を読み出せる
  • 0番地と511番地へ8bit値を書き込み、同じ値を読み戻せる
  • address[8]を使用する256番地以上でも正しく読み書きできる
  • 複数のアドレスへ異なる値を書き込み、個別に読み戻せる
  • 同じアドレスを別の値で上書きできる
  • CMD_RESET後、確認した各アドレスから0を読み出せる
  • REPLY_HIGHREPLY_LOWの2byteから、元の8bit値を復元できる

例えば、0番地へ8'hA3を書き込んだケースでは、MISOから次の2byteが返りました。

MISO=0x8A,0x93

0x8Aの下位4bitが読み出し値の上位4bit、0x93の下位4bitが読み出し値の下位4bitです。二つを結合すると、書き込んだ8'hA3へ正しく復元できます。

最初はすべて0が返った

最初の実機テストでは、SPI通信とMISO返信は動作しているものの、書き込んだ値を読み出すと、すべて0が返りました。

原因は、ForgeFPGA WorkshopのBRAM PropertiesでNorth BRAM Enableを有効にし忘れていたことでした。

この状態でも合成とbitstream生成は完了し、MISOにはREPLY_HIGHREPLY_LOWが返るため、一見するとBRAMの読み出し処理まで動いているように見えます。

North BRAM Enableを有効にしてbitstreamを再生成したところ、同じVerilogコードとMicroPythonプログラムで12ケースすべてPASSしました。

BRAM_0を使う場合は、IO Plannerの配線だけでなく、North BRAM Enableの設定も忘れずに確認する必要があります。

なお、Memory Retentionは無効のままで正常に動作しました。今回のテンプレートではリセット後にBRAM全領域をゼロクリアするため、保持された値は使用しません。

リソース使用量

合成後の主なリソース使用量は次の通りでした。

リソース 使用量 利用可能数 使用率
CLB LUT5s 139 1120 12.41%
CLB FFs 82 1120 7.32%
IOB FFs 39 736 5.30%
CLBs 34 140 24.29%
4k BRAMs 1 8 12.50%

この値には、SPI Slave、BRAMアクセスモジュール、今回の動作確認用SPIコマンド処理が含まれています。

BRAM_0を1スライス使用することで512byteの記憶領域を確保しながら、CLB使用率は24.29%に収まりました。

合成時には組み合わせ回路、IOB FF、クロックバッファなどに関する警告が表示されましたが、今回の実機テストでは読み書きやSPI通信への影響は確認されませんでした。


まとめ

今回は、ForgeFPGAに内蔵されているBRAMをAtCoder問題から利用するため、BRAM_0専用のテンプレートを作成しました。

作成したbram0_8bit_access.vは、North BRAM側のBRAM_0を512×8bit構成で使用します。

問題固有回路からは、BRAM専用のActive Low信号や読み出しタイミングを直接扱わず、次の信号でアクセスできます。

  • write_reqwrite_addrwrite_data
  • read_reqread_addr
  • read_dataread_valid
  • busy
  • clear

読み出し要求後は4クロック待ってデータを取り込み、read_validを1クロックだけHighにします。

ハードウェアリセット解除後とclear入力後には、BRAM_0の0~511番地へ順番に0を書き込みます。MicroPython側では、ゼロクリア完了を待つため1ms待機します。

動作確認用のmain.vでは、SPIから9bitアドレスと8bitデータを指定し、BRAMへ書き込みと読み出しを行いました。読み出し結果は、上位4bitと下位4bitの2byteに分けてMISOへ返信します。

実機テストでは、先頭・末尾・256番地以上を含む複数アドレスへの読み書きと上書きに加え、ハードウェアリセット直後の未書き込みアドレス、およびCMD_RESET後の複数アドレスから0が読み出されることを確認し、12ケースすべてPASSしました。

また、ForgeFPGAでBRAMを使用するには、次の設定が必要です。

  • BRAM PropertiesでNorth BRAM Enableを有効にする
  • IO PlannerでBRAM_0用の40本の信号を接続する
  • REF_BRAM(0..3)-READ_CLKREF_BRAM(0..3)-WRITE_CLKclkへ接続する

特にNorth BRAM Enableを忘れても合成は完了するため、書き込んだ値が常に0として読み出される場合は、最初に確認した方がよさそうです。

今回の構成では、SPI通信、問題固有処理、BRAMアクセス処理を分離できました。

RP2040
  │
  │ SPI
  ▼
spi_target
  │
  ▼
main.vの問題固有処理
  │
  │ write_req / read_req
  ▼
bram0_8bit_access
  │
  │ BRAM専用信号
  ▼
BRAM_0(512×8bit)

今後は、main.vの問題固有部分だけを書き換えることで、BRAMを配列のように使うAtCoder問題へ展開できます。

次回

次回は、作成したBRAMテンプレートを使い、再度ABC466Bを実装してみます。お楽しみに。

前回: Shrike-LiteでAtCoder問題を解く(9):ABC466B - Representative Balls(完全版)をストリームループで実装する

次回: Shrike-LiteでAtCoder問題を解く(11):ABC466B - Representative Balls(完全版・BRAM利用)


参考資料

0
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
0
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?