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 代入が発生することになります。
なんか疲れました。今日はここまで。