[SpinalHDL] 代入(assignment)について正しく理解する

投稿者: | 2020/03/10

Correct understanding of assignments in SpinalHDL.

シミュレーション波形の見直し

先日、SpinalHDL で文字列送信のコードを設計しましたが、後になってシミュレーション波形を見ていて釈然としない点がありました。

以下に波形を示します。

io.payload の更新が 1クロック遅れる

最初のクロックエッジで io_payload g "H" になった後、次のクロックでも io.payload が "H" のままになっているのは、私の設計の誤りですね。これですと、1文字目の "H" に続いて 2文字目も "H" の送信指示になってしまいます。これはどう直したら良いのでしょう?

コード src/main/scala/Uart_TinyFPGA_BX.scala の中で次のような記述があるのですが、ここの readSync() がいけないようです。

    active
      .whenIsActive {
        uart.io.valid := True
        uart.io.payload := rom_str.readSync(n_char_sent)
        when(uart.io.ready) {
          n_char_sent := n_char_sent + 1
          when(n_char_sent === str.length - 1) {
            uart.io.valid := False
            uart.io.payload := 0
            n_char_sent := 0
            goto(waiting)
          }
        }
      }

これですと、uart.io.payload の値は、「次のクロック」に同期して変更されることになります。そのため、1クロック遅れてしまうのです。正しくは、

uart.io.payload := rom_str.readAsync(n_char_sent)

と書かなくてはいけません。ちなみに、readSync() を使っても、なぜ最初のクロックエッジでは「1サイクル遅れずに」"H" が出力されているように見えるかというと、その「前」の時点で既に n_char_sent が 0 になっているので、最初のクロックエッジに「正しく同期して」rom_str[0] が読み出されているから、ということになります。readSync() を使うと、n_char_sent の値から 1クロック遅れて io.payload に値が反映される訳です。よく分からない方は、SpinalHDL が出力する Verilog ファイルを覗いてみるのが良いでしょう。

io.ready がおかしい

次の問題は、io.ready の振舞いが正しくない点です。上記の波形で io.ready が True の部分がありますが、それだと、その次のクロックエッジで新しい io.payload をフェッチ可能であることを意味しますので、(実際にはまだフェッチできない)ここで io.ready が True であるのは間違いです。正しくは、文字を完全に送信し終わった段階(次のクロックエッジで、新しい文字をフェッチできる状態)で io.ready をアサートしないといけないのです。

私も正しく理解していなかったのですが、valid-ready バスの ready とは、シンク側から、ソース(送信側)が次のデータを送って良い「状態」を伝えているのではなく、ソースが送信サイクルを完了して良い「サイクル」を伝えているのです。またそうでないと、valid が False の状況では ready が Don't care であるという定義とも矛盾します。ですから、シンク(受信側)は初めから ready を True にして待つ必要はありません。

UartCore の設計を次のように修正しましょう。

diff --git a/src/main/scala/Uart.scala b/src/main/scala/Uart.scala
index 2019c47..c1cdf5b 100644
--- a/src/main/scala/Uart.scala
+++ b/src/main/scala/Uart.scala
@@ -47,7 +47,7 @@ class UartCore(
 
     idle
       .whenIsActive {
-        io.ready := True
+        io.ready := False
         io.txd := True
         when(io.valid) {
           n_bits_sent := 0
@@ -85,6 +85,7 @@ class UartCore(
         io.ready := False
         io.txd := True
         when(ct_full) {
+          io.ready := True
           goto(idle)
         }
       }

上記 2つの問題を修正した後のシミュレーション波形を示します。なお、修正版のコードはこちらです。

なお、io.payload の内容をレジスタ data にコピーした段階で io.ready をアサートして良さそうに思えますが、それではいけません(たぶん)。なぜかと言うと、そのように設計すると start bit 送信時から io.ready が常に True になってしまい、ソースは次々とデータを送り込んできてしまうからです。あくまでも、文字を完全に送信し終わった段階まで io.ready は True にできません。それが困る場合には、やはり FIFO を入れるしかなさそうです。

ちなみに、先日の APB バスの実装でステータス tx_ready を示すために次のように書きましたが、これはもはや利用できない(間違った)実装ということになります。

  }.elsewhen(io.PADDR === addr_status && read) {
    io.PREADY := True
    io.PRDATA := uart.io.ready.asBits(1 bits).resized
  }

正確には、UartCore の内部状態が idle であるときに tx_ready であるように設計すべきでしょう。(また、複数の APB バスマスタが存在する場合は、バスアービタを入れないとダメでしょう。)

blocking と non-blocking 代入(assignment)の違いを理解する

Verilog HDL では、blocking 代入と non-blocking 代入の記述が明確に分けられています。前者では演算子に「=」を使い、後者では「<=」を使います。一般に、前者は組合わせ論理(回路)で用い、後者は順序回路で用います。

これに対して、SpinalHDL ではいずれの場合も演算子「:=」で記述でき、文脈に合わせて blocking あるいは non-blocking として解釈してくれるのですが、漫然とコードを書いていると読みにくいコードになったり、思い通りの動作にならなかったり、combinational loop のエラーの要因になったりします。

私も(最初は正しく理解したような気もするのですが)、この理解がごっちゃになってしまい、おかしなコードを書いたり、期待した動作にならなかったりして頭が混乱しました。少し整理しておきましょう。

いま、例えばこのようなコードがあったとします。

  val io = new Bundle {
    val valid = in Bool
    val ready = out Bool
    val payload = in Bits (len_data bits)
    val txd = out Bool
  }

  val n_bits_sent = Reg(UInt(log2Up(len_data) bits)) init (0)
  val ct_timer = Reg(UInt(log2Up(period_timer) bits)) init (0)
  val data = Reg(Bits(len_data bits)) init (0)

(略)

  val fsm = new StateMachine {
    val idle = new State with EntryPoint

    idle
      .whenIsActive {  
        io.ready := True       // これは blocking
        io.txd := True         // これも blocking
        when(io.valid) {
          io.ready := False    // これも blocking
          n_bits_sent := 0     // これは、non-blocking (次のサイクルで 0 になる)
          ct_timer := 0        // 同
          data := io.payload   // 同 (次のサイクルで data が io.payload になる)
          goto(startbit) 
        }
      }

コメント中にも記しましたが、io への代入は blocking 代入になります。つまり、状態 idle にでは、io.txd は即時に True になります。io.valid が True の状態では io.ready は即時に False になり、そうでないときは True になります。これらは、順序回路のレジスタではなく組合わせ論理ですので、次のクロックサイクルで変化するのではなく、状態 idle では常にそのように出力されます。

一方、n_bits_sent、ct_timer、data はレジスタですので、これは non-blocking 代入となり、次のクロックサイクルで代入が発生します。

繰り返しになりますが、Verilog などで論理設計の経験のない私のようなソフト屋は、SpinalHDL を最初に学んだときは漫然と「分かったようなつもり」になりますが、FSM(有限状態マシン)でバス回路を設計していると、おそらく私のように混乱し始めます。老婆心ではありますが、御参考になれば幸いです。

さらに一つ付け加えますと、ソフト屋さんの多くは(私と同様に)、上記のようなコードで io.valid はいつ参照されるのだろう、と混乱することがあると思います。このクロックサイクルに移る直前の値なのでしょうか? それとも、次のクロックサイクルに移る直前でしょうか?  答えは後者です。上記のコードであれば、(論理素子の伝播遅延を無視すれば)シミュレーション波形において、あるクロックサイクルの期間中で io.valid が True であれば、次のサイクルのエッジで n_bits_sent や ct_timer が変化し、状態 startbit に遷移することになります。

シミュレーション波形の読み方

ちなみにまったく別件かと思いますが、SpinalHDL のシミュレーションで次のように書いた場合、論理回路はどのクロックサイクルで dut.io.payload や valid を参照できるのでしょうか?  言い換えると、シミュレーション波形における dut の表示値は、どのクロックで論理回路に適用されるのでしょうか?

        if (idx == 10) {
          dut.io.payload #= 123
          dut.io.valid #= true

答えは、シミュレーション波形で io.payload や io.valid が代入された「直後」のタイミングで、論理回路にもその値が読み込まれます。ですので、io.payload を参照する組合わせ論理がある場合には、そのクロックサイクル内で入力値が反映されます。ただし、その値を参照する non-blocking 代入(状態遷移など)がある場合は、次のクロックエッジで値が反映されることになります。つまり上記で言うと、idx == 10 で始まるサイクルの中で io.valid を True として参照できますが、when() オブジェクトなどで順序回路に適用する場合には、次のクロックエッジで non-blocking 代入が発生することになります。

なんか疲れました。今日はここまで。