TinyFPGA BX の SPI ROM 上で RISC-V のプログラムを直接実行する

(更新

Running code on SPI ROM of TinyFPGA BX with VexRiscv (small) Briey SoC, or XiP (execute-in-place) in short.

前回は、VexRiscv 上の RISC-V SoC に AXI4 のバスマスタを追加してみましたが、今回はその逆に、AXI4 のバススレーブを追加してみました。それだけでは面白くないので、TinyFPGA BX 上の SPI ROM にメモリマップでアクセスできるようにして、SPI ROM 上のバイナリを直接 RISC-V から実行できるようにしました。

つまり、例えば 0x8000_0000 番地を RISC-V CPU コアからリードアクセスすると、SPI ROM にアドレスを送信してデータを読み出すようにする訳です。TinyFPGA BX に載っている iCE40-LP8K 上のブロック RAM は 128K ビット(16K バイト)しかないので、SPI ROM 上にバイナリを載せることができれば、ずっと大きなプログラムを実行できるようになります。

なお、今回は SPI ROM のアクセスを通常のシリアルアクセスで実装しました。これをさらに、Quad-I/O Read ですとか DDR(double data rate)アクセスできるようにすると、もっと高速にできますが、次回以降への宿題にしたいと思います。

GitHub レポジトリ

コードは、こちらに置きました。

ビルド方法の説明はこちらです。

コードの説明

SpiMasterCtrl クラス

class SpiMasterCtrl というのが、SPI ROM にアクセスする部分です。

case class SpiMasterCtrlCmdSet(
    g: At25sf081Generics = At25sf081Generics()
) extends Bundle {
  val spiCmd = Bits(g.widthCmd bits)
  val addr = UInt(g.widthAddr bits)
  val withAddr = Bool
  val withRead = Bool
  val lenRead = UInt(log2Up(256 * 4) bits)
}

class SpiMasterCtrl(
    g: At25sf081Generics = At25sf081Generics()
) extends Component {
  val io = new Bundle {
    val cmd = slave(Stream(SpiMasterCtrlCmdSet()))
    val data = master(Flow(Fragment(Bits(g.widthData bits))))
    val spi = master(SpiMaster(useSclk = true))
  }

cmd は、外部からコマンドを受け付ける Stream インターフェイスで、data は、読み出したデータを返す Flow インターフェイスです。spi は、SPI インターフェイスへの接続部分ですね。

cmd ストリームのペイロードには、以下が含まれます。

  • spiCmd: SPI メモリへのコマンド(8ビット)
  • addr: アドレスが必要な場合は、アドレス(24ビット)
  • withAddr: アドレスが必要か否か(Bool)
  • withRead: データを読み出すか否か(Bool)
  • lenRead: データを読み出す場合は、そのバイト数マイナス1(10ビット)

SpiMasterCtrl のシミュレーション

シミュレーション用のオブジェクト SpiMasterSim を作ったので、実際に動かしてみましょう。

まずは、コマンドだけを指定する場合です。(クリックすると拡大できます)

ここではコマンド 0xab を送っています。良さそうですね。ちなみに、コマンド送信が終わると io.data.valid 信号がアサートされます。

次に、コマンドとアドレスを指定する場合です。これは実際には使わないと思いますが、作ってしまったのでテストします。

これも大丈夫そうです。

最後に、データリード(4バイト)を伴うテストです。

これも OK ですね。なお、1バイト読み出すたびに io.data.valid がアサートされ、io.data.payload.fragment でデータが返ってきます。最後は、io.data.payload.last ビットもアサートされます。

AXI4 スレーブインターフェイスの実装

続いて、VexRiscv SoC の AXI4 クロスバに接続するために AXI4 スレーブインターフェイスを設計します。最初、スレーブのほうがマスタより簡単だろうと思ったのですが、実はとんでもなく、スレーブのほうが大変でした。なぜかというと、スレーブはマスタからの指示内容を限定できないので、ワードサイズやバーストアクセス長を正しく解釈して実装しないといけないからです。

ただし、完全に厳密に全ての AXI4 仕様を満たすのは無理なので、以下のような制約を設けました。

  • ARID(リードアクセスID): これは正しく処理します。
  • ARLEN(バーストアクセス長): これは 0〜255まで正しく処理します。
  • ARSIZE(バーストサイズ): これは常に 0b010(4バイト転送)と仮定します。実際には VexRiscv はバイトアクセスやハーフワードアクセスもしてきますが、RDATA(32ビット幅)の適切な場所にデータが入っていれば問題なく動作するようなので、そのようにしました。
  • ARBURST(バーストタイプ): 常に INCR と仮定します。これで大丈夫そうです。
  • AR系の、それ以外の信号ラインは使いません。
  • RID, RDATA, RLAST, RVALID は正しく処理します。RREADY は無視します。

AXI4 スレーブインターフェイスは、Axi4RomAt25sf081 クラス で実装しています。

アドレスマップ

さて、この AXI4 スレーブの ROM をどのようにメモリマップするか、です。TinyFPGA BX では、ROM(1Mバイト)の 0x2_8000 からビットストリームが入り、0x5_0000 からの 704Kバイトはユーザー用に開放されています。そこで、ROM のユーザーエリアを CPU から見て 0x8000_0000 にマップし、同時に、ROM 全体のメモリ空間を 0x8010_0000 からマップすることにしました。

AXI4 スレーブインターフェイスのテスト

上記を組み込んだ(ミニ)Briey を Verilator シミュレーションでテストしてみました。まずは、I キャッシュを使わない場合です。(クリックすると拡大できます)

バスからのアドレス 0x0(バス幅は 21ビット)のときに 0x5_0000 に 4バイトのリードが発生し、AXI4 の R インターフェイスで 32ビットのデータが一つ返されていることが分かります。(SPI ROM のシミュレーションをしていないので、返り値 RDATA は 0x0 になっています。)

続いて、I キャッシュをオンで試してみましょう。(見づらくてスミマセン。クリックして拡大するか、御自身でシミュレーションしてみてください。sbt “runMain flogics.vexriscv.tinyfpga.Briey” でビルドしてから、ディレクトリ VexRiscv/src/test/cpp/flogics/briey で make clean run TRACE=yes すると波形データ Briey.vcd を得られます。ただし、BOOT という文字が出力されたら、すぐに Ctrl-C で停めたほうが良いです。波形データが巨大になります。)

ちゃんと RVALID が 8回アサートされ、最後に RLAST が立っているのが分かります。

あ、I キャッシュを有効にするパッチを示しておきます。

diff --git a/src/main/scala/vexriscv/flogics/Briey.scala b/src/main/scala/vexriscv/flogics/Briey.scala
index dba5301..a394bf1 100644
--- a/src/main/scala/vexriscv/flogics/Briey.scala
+++ b/src/main/scala/vexriscv/flogics/Briey.scala
@@ -189,6 +189,24 @@ object BrieyConfig{
         rxFifoDepth = 16
       ),
       cpuPlugins = ArrayBuffer(
+        new IBusCachedPlugin(
+          resetVector = 0x80000000l,
+          prediction = STATIC,
+          config = InstructionCacheConfig(
+            cacheSize = 512,
+            bytePerLine =32,
+            wayCount = 1,
+            addressWidth = 32,
+            cpuDataWidth = 32,
+            memDataWidth = 32,
+            catchIllegalAccess = true,
+            catchAccessFault = true,
+            asyncTagMemory = false,
+            twoCycleRam = true,
+            twoCycleCache = true
+          )
+        ),
+/*
         new IBusSimplePlugin(
           resetVector = 0x80000000l,
           cmdForkOnSecondStage = true,
@@ -198,6 +216,7 @@ object BrieyConfig{
           catchAccessFault = false,
           compressedGen = false
         ),
+*/
         new DBusSimplePlugin(
           catchAddressMisaligned = false,
           catchAccessFault = false,

SPI ROM を Resume コマンドで起こす

一つ大事なことを忘れてました。SpiMasterCtrl を設計したとき、どうしても SPI ROM の内容が読めず、オシロで覗いても分からず、えらく苦労したのでした。実は、どうも TinyFPGA のブートローダーで SPI ROM からビットストリームを読み出して起動した後、SPI ROM が Deep Power-Down モードになっているようなのです。確かにそのほうが消費電流が小さくなるのですが…。

ようやくそのことに気づいて、SPI ROM に Resume from Deep Power-Down コマンドを送るようにしたら、正しく動作するようになりました。そのロジックは、前述の AXI4 スレーブインターフェイスの Axi4RomAt25sf081 クラスにシーケンサとして組み込んであります(fsmResume 参照)。

その動作が、以下の波形です。

なお、Deep Power-Down からの resume や、その後のデータアクセスには最低 5マイクロ秒のウェイトが必要らしいので(データシート参照)、そのウェイトおよびリセット論理を設計してあります。class MiniBriey の、以下の部分です。

  val resetCtrl = new ClockingArea(resetCtrlClockDomain) {
    val timeWait = 10 us
    val marginBy = 2
    val cycleWait1 = (timeWait * axiClkFreq).toBigInt
    val cycleWait2 =
      (timeWait * 2 * axiClkFreq + (2 * 8 + 2) * marginBy).toBigInt

    SpinalInfo("timeWait = " + timeWait.toString)
    SpinalInfo("cycleWait1 = " + cycleWait1.toString)
    SpinalInfo("cycleWait2 = " + cycleWait2.toString)

    val reset1 = Reg(Bool) init (True)
    val reset2a = Reg(Bool) init (True)
    val reset2b = Reg(Bool) init (True)

    val systemResetCounter = Counter(cycleWait2 + 1)

    when(!systemResetCounter.willOverflowIfInc) {
      systemResetCounter.increment()
    }

    when(systemResetCounter.value === cycleWait1) {
      reset1 := False
    }

    when(systemResetCounter.value === cycleWait2) {
      reset2a := False
      reset2b := False
    }

    when(BufferCC(io.asyncReset)) {
      systemResetCounter.clear()
    }

    val spiRomReset  = reset1
    val systemReset  = reset2a
    val axiReset     = reset2b
  }

  val spiRomClockDomain = ClockDomain(
    clock = io.axiClk,
    reset = resetCtrl.spiRomReset,
    frequency = FixedFrequency(axiClkFreq)
  )

  val spiRomArea = new ClockingArea(spiRomClockDomain) {
    val spiRom = new Axi4RomAt25sf081(byteCount = 2 MB)
  }

なお余談ですが、なぜ reset2a と reset2b が分かれているかというと、これを一緒にまとめると、FPGA 上で誤動作するのです。(具体的には、JTAG インターフェイスがおかしくなり、GDB の monitor reset halt コマンドが効かなくなる。) 理由は不明ですが、リセット信号の fan-out が大きくなりすぎることと関連があるのかも知れません。

RISC-V CPU プログラムのロードと実行

上記のような修正により、RISC-V CPU のプログラムは、もはや Verilog コードで実装してビットストリームとして書き込む必要がなくなりました。ビルドしたプログラムを objcopy コマンドの -O binary オプションで .bin ファイルに変換し、tinyprog の -u オプションで指定して SPI ROM のユーザーエリアに書き込めば、それで OK です。

なお、リンカスクリプトの修正が必要な点に注意してください。初期化する部分は SPI ROM 上に配置して良いですが、NOLOAD の部分(スタックやヒープ、.bss や .noinit など)は RAM 上に配置する必要があります。

今日はここまでとします。

お問い合わせはお気軽に!

お問い合わせを頂いた後、継続して営業活動をしたり、ニュースレター等をお送りしたりすることはございません。
御返答は 24時間以内(営業時間中)とさせて頂いております。必ず返信致しますが、時々アドレス誤りと思われる返信エラーがございます。返答が届かない場合、大変お手数ではございますが別のメールアドレスで督促頂けますと幸いです。