[SpinalHDL] UART を作って FPGA で動かす(その2)

(更新

Improving my first UART logic, designed by SpinalHDL, for FPGA.

ソフト屋のための SpinalHDL FPGA 設計入門「UART 設計編」の第2回目です。前回は、とりあえず一文字のキャラクタを連続して送信する回路を設計し、実際に UART-USB ブリッジを介してパソコンに繋いでみました。

今回の内容は以下の通りです。

  • UartCore 状態遷移図のあちこちにある冗長なカウンタをまとめる
  • UartCore をインスタンス化するためのパラメタに clock_rate と bit_rate を追加する
  • 文字列を送信できるロジックを追加する

その他に、

  • レジスタの初期値を明示する
  • クロックドメインのリセット種別が、レジスタ初期化に与える影響を確認する
  • SpinalHDL の標準ライブラリ Counter を使ってみる

をやってみたいと思います。

前回までの流れ

  1. UART を作って FPGA で動かす(その1)

まずは Git repo

今回も最初に Git repo へのリンクを書いてしまいます。

なお、今後も少しずつ改良を重ねていきたいと思いますので、コミットのタグに御注意ください。

修正版

blocking assignment と non-blocking assignment あたりの理解を整理したので、修正版を上げておきます。なお、その辺の経緯は 3月10日の投稿に詳しく書いてあります。

冗長なカウンタをまとめる

前回の Uart.scala のコードを見ると、次のような冗長なカウンタ設計が気になります。

    startbit
      .whenIsActive {
        ct_timer := ct_timer + 1
        when(ct_timer === period_timer - 1) {
(略)
    sending
      .whenIsActive {
        ct_timer := ct_timer + 1
        when(ct_timer === period_timer - 1) {

これを一つにまとめて、回路規模が小さくなるかどうか見てみましょう。まず、カウンタ回路を一つに集約します。

  /*
   * Clock Divider Counter
   */
  val ct_full: Bool = (ct_timer === period_timer - 1)
  ct_timer := ct_timer + 1
  when(ct_full) {
    ct_timer := 0
  }

続いて、状態遷移回路の各状態を次のように修正します。

    startbit
      .whenIsActive {
        when(ct_full) {
          goto(sending)
        }

なお、今回 verbose_delay を取り除いてしまったので回路が若干異なるのですが、変更の前後で Yosys のログ出力を比較してみましょう。

変更前(カウンタ集約前)

   Number of wires:                 71
   Number of wire bits:            117
   Number of public wires:          71
   Number of public wire bits:     117
   Number of memories:               0
   Number of memory bits:            0
   Number of processes:              0
   Number of cells:                 91
     SB_CARRY                       10
     SB_DFFE                        22
     SB_DFFSR                        2
     SB_DFFSS                        1
     SB_LUT4                        56

変更後(カウンタ集約後)

   Number of wires:                 56
   Number of wire bits:            102
   Number of public wires:          56
   Number of public wire bits:     102
   Number of memories:               0
   Number of memory bits:            0
   Number of processes:              0
   Number of cells:                 76
     SB_CARRY                       10
     SB_DFFE                        11
     SB_DFFSR                       13
     SB_DFFSS                        1
     SB_LUT4                        41

Number of cell が 91 から 76 に削減できました。ということで、やはり、カウンタはちゃんと集約したほうが良さそうです。

アップカウンタとダウンカウンタの比較

ところで、ソフト屋として(ソフト屋でなくても?)一つ気になることがあります。それは、このようなカウンタをアップカウンタとして実装するのと、ダウンカウンタとして実装するのでは、合成後の回路規模が違ったりするのでしょうか。ソフトウェアの設計の場合は、コンパイル後の命令数や所要クロック数が異なることがありますね。(ま、それほど大きな違いではないので、通常のソフトウェア設計(よほど性能がクリティカルでない場合)では、コードの可読性を優先することになると思います。)

HDL 設計ではどうなるのか、ちょっと試してみました。オリジナルはカウントアップになっていて

  val ct_full: Bool = (ct_timer === period_timer - 1)
  ct_timer := ct_timer + 1
  when(ct_full) {
    ct_timer := 0
  }

となってますが、これを次のように変更しました。(ct_full という名前は不適切ですが、そのままにしています。)

  val ct_full: Bool = (ct_timer === 0)
  ct_timer := ct_timer - 1
  when(ct_full) {
    ct_timer := period_timer - 1
  }

結果ですが、いずれの場合でも Yosys 出力の回路規模は全く同一でした。現実には、実装しようとしている設計や論理合成ツールで結果が異なるかも知れませんので一概には言えませんが、アップカウンタとダウンカウンタについては、どちらを使えば良いかと、それほど気にしなくても良いようです。ソフトと同様、設計の可読性を優先したほうが良さそうです。

ちなみに後ほど Counter という SpinalHDL 標準ライブラリを紹介しますが、そのドキュメントを見ると、アップカウンタを採用していてダウンカウンタは提供されていませんので、ただ数を数え上げるだけであれば性能は気にしなくて良さそうです。(また、手でカウンタを書くよりも Counter ライブラリを使ったほうが良さそうだということも言えそうです。)

インスタンス化パラメタの追加

タイトルが分かりづらいですが、ソフト屋の皆さんには「クラスをインスタンス化する際のパラメタ」と言えばお分かりになるかと思います。Verilog 屋さんには、こんなやつのことだと言えば御理解頂けるかと思います。(こちらから引用)

ram_sp_sr_sw #( 
    .DATA_WIDTH(16), 
    .ADDR_WIDTH(8), 
    .RAM_DEPTH(256))  ram(clk,address,data,cs,we,oe);

この、DATA_WIDTH とか ADDR_WIDTH などを「インスタンス化のためのパラメタ」と呼ぶことにしましょう。SpinalHDL でも、もちろんこの機能があります。

今回は、UartCore のインスタンス化パラメタに clock_rate と bit_rate を追加しましょう。それぞれ、UARTCore に供給されるクロックの周波数と、UART のビットレート(いわゆるボーレート)に対応し、UartCore クラスをインスタンス化する際にパラメタとして与えられるようにする訳です。こんな感じです。

class UartCore(
    len_data: Int,
    clock_rate: HertzNumber,
    bit_rate: HertzNumber
) extends Component {
  // ...
}

HertzNumber という型(type)が新しいですが、これは SpinalHDL で定義している型で、16 MHz とか 115200 Hz のように指定できるので便利そうです。具体的には、次のようにインスタンス化します。

      new UartCore(
        len_data = 8,
        clock_rate = 16 MHz,
        bit_rate = 115200 Hz
      )

ところが、一つ難しい点があります。(Scala に詳しい方にはなんということないのでしょうが。)

UartCore クラスの中で、これらの値を使ってカウンタを定義したい訳です。前回は

  val ct_timer = Reg(UInt(log2Up(period_timer) bits))

のように記述しましたが(分かりやすいよう、tmp_period の名前は修正しました)、period_timer の計算はどうしたら良いのでしょうか。結果を先に言いますと、次のように書けば良さそうです。(難しい!!)

  val period_timer = (clock_rate / bit_rate)
    .setScale(0, BigDecimal.RoundingMode.HALF_UP)
    .toBigInt
  val ct_timer = Reg(UInt(log2Up(period_timer) bits))

まず最初に、log2Up() 関数には BigInt 型を与えないといけないので clock_rate / bit_rate の値を BigInt に変換したいのですが、その前に丸めをしたいところです。(実は、前回は丸めを「切り捨て」にしていたのですが、今回は「四捨五入」にします。) そのために、.setScale() というクラスメソッドを呼び出しています。実は、前述の HertzNumber 型というのは BigDecimal という Scala 型を継承していて、これを整数に丸めるには .setScale() というメソッドが必要なのです。詳しくは Scala のリファレンスガイドを参照してください。RoundingMode というのも説明が見つかるはずです(後者はどちらかというと、Java から来た概念のようです)。

私は上記の結論を得るのに 1時間程度を要してしまいましたが、Scala の専門家でない方(私を含む)は、上記をそのままコピーして使って頂ければよろしいかと思います。逆に、Scala あるいは SpinalHDL の専門家の方には、もっとエレガントな方法を教えて頂きたいです。 🙂

文字列を送信できるロジックの追加

いかがでしょうか。そろそろ息切れがしてきたのではないでしょうか。難しい話が多いので、文頭から読んできた方は、ここいらで休憩頂いたほうが良さそうです。

では、続けます。

前回の実装では、ただひたすら「A」という ASCII キャラクタを送信してましたが、これではつまらないので、文字列を送信できるクラス(回路)を設計してみました。ソースコード Uart_TinyFPGA_BX.scala のクラス UartTxString が、それです。

このクラスの定義では、いくつか新しい概念を使っていますので、一つずつ説明しましょう。

文字列をメモリ(Mem オブジェクト)に変換する

これは、実際にはほとんど使うことはないでしょう。一度、ソフトコアプロセッサなどを実装してしまえば、おそらく HDL 中で Scala 文字列を使うことは稀なのではないでしょうか。しかしうっかり、このプレゼンテーション資料の中の SinusGenerator クラスというのを見てしまうと、なんだかむらむらと好奇心が刺激されます。結局、また 1時間強を要し、ようやく(クラス UartTxString 中の)次のようなコードをでっち上げました。はっきり言って、御理解頂く必要はないと思います。Scala の専門家ではない我々は、「ふーん、そんなことができるんだ」と流し読みすれば良いと思います。(Scala の専門家の方は、もっと分かりやすい実装を教えてください…)

  def chrTable =
    (0 until str.length).map(i => {
      B(str.toList(i).toInt, chr_size bits)
    })
  val rom_str = Mem(Bits(chr_size bits), initialContent = chrTable)

  // ...

    active
      .whenIsActive {
        uart.io.valid := True
        uart.io.payload := rom_str.readSync(n_char_sent) // ここで rom_str を読み出す

コンパイル時のログ出力

どちらかというと、こちらの話のほうが役立ちそうです。SpinalHDL で設計したコードを Scala でコンパイルしているとき、途中でログ(メッセージ)出力ができたら良いのにな、と思うことが出てくる筈です。そのためには、SpinalHDL のユーティリティ関数(オブジェクト?)SpinalInfo() が便利です。例えば上記コードで、chrTable が具体的にどのようなものになるか知りたい場合は、

  SpinalInfo("chrTable: " + chrTable.toString)

のようなコードを書けば良い訳です。デバッグ時、これは便利だと思います。

ユーティリティクラス Counter

クラス UartTxString 中の状態遷移(ステートマシン)fsm は、それほど難しいものでないと思いますので、説明は省略します。簡単に言うと、状態 active のときは次々とキャラクタを送信することで文字列出力とし、状態 waiting の時は、1秒間のウェイト(ディレイ)を入れることになります。

ここでは、そのような設計で便利な Counter クラスを紹介します。カウンタを設計するには、Uart.scala の中で書いたように

  val ct_full: Bool = (ct_timer === period_timer - 1)
  ct_timer := ct_timer + 1
  when(ct_full) {
    ct_timer := 0
  }

とか書けば良いのですが、このようなカウンタは頻繁に使用するものなので、SpinalHDL では専用のユーティリティクラスを用意しています。例えば上記のようなカウンタを実装したい場合には、次にようにします。

import spinal.lib.Counter

// ...

  val ct_timer = Counter(period_timer)
  ct_timer.increment()

  // 例: 状態遷移の中
        when(ct_timer.willOverflow) {
          goto(active)
        }

ct_timer は、0 から period_timer – 1 にインクリメントしていくカウンタです。カウンタをインクリメント条件(when(…) {} 中など)で、ct_timer.increment() という記述をしてやります。.willOverflow というのは、いま現在のカウンタ値が period_timer -1 であって、.increment() したらオーバーフローするという条件の時に「真」になります。なお、increment() されない条件のときには .willOverflow は真になりません。ドキュメントはこちらにありますが、正しく理解するためには、Counter クラスのソースコードを読んでみたり、実際に実験したりするほうが良さそうです。

最後に、この文字列送信回路を動かしてみましょう。そのためには、タグ blog-20200130 をチェックアウトし、make clean && make upload すれば、TinyFPGA BX に書き込めます。UART-USB ブリッジの GND 信号を接続し、さらに TinyFPGA の基板上の 14番ピン(FPGA のピン名称 H9)を UART-USB ブリッジ側の受信ピン(RxD)に繋ぎます。

シリアルコンソールに次のような表示(1秒毎に Hello World!)があれば正常です。

レジスタの初期値を明示

ところで、前回の Uart.scala コードには一つ問題がありました。それは、レジスタの初期値を指定していないのです。場合に依っては、状態遷移の中でうまく初期化されてしまうこともあるのですが、やはり明示的な初期値を指定したほうが安心です。

Verilog HDL では、FPGA 用の設計をするときやシミュレーションの場合に、レジスタ定義に初期値を与えることができます。また FPGA でない場合(ASIC などの設計)では、リセット信号を使ってレジスタを初期化するのが一般的なようです。

SpinalHDL では、FPGA, ASIC いずれの場合でも共通のやり方でレジスタ初期化をするための構文(init (…))が用意されているので、それを使ってみましょう。なぜ前回、この辺を曖昧に済ませてしまったかというと(言い訳ですが)、この構文が果たして、リセット信号を使う場合(つまり ASIC など)でも正しく適用されるかどうか、不安があったからです。せっかく init (…) 構文を使っても、レジスタが正しく初期化されないケースがあるとつまらないですよね。

さて。まずはレジスタ定義に全て init (…) を追加してみましょう。例えば次のように書きます。

  val n_char_sent = Reg(UInt(log2Up(str.length) bits)) init (0)

末尾の init (0) が初期値の指定ですね。

それでは、この記述が FPGA の場合とそうでない場合に、どのように働くのか(どのような Verilog HDL コードに変換されるのか)調べてみましょう。そのためには、以前に説明したクロックドメインの理解が必要になります。クロックドメインの定義で、ClockDomainConfig() に resetKind = BOOT を指定すると、レジスタの初期値は次のような Verilog コードに変換されます。

  reg [3:0] n_char_sent = (4'b0000);

一方で、resetKind に SYNC を指定した場合は、次のような Verilog コードに変換されます。(Verilog を御存知ない方は理解なさらなくても結構ですが、少しだけでも知っていると安心かと思います。)

  always @ (posedge io_CLK) begin
    if(reset) begin
      n_char_sent <= (4'b0000);

つまり、resetKind が SYNC の場合は、CLK のエッジのタイミングで reset 入力が真である場合に、レジスタに初期値が代入される、という訳です。

まとめますと、FPGA 設計の場合でも ASIC 設計の場合でも、SpinalHDL でクロックドメインを正しく指定していれば、init (…) 構文は共通して利用できる、ということになります。これで安心しましたね! 🙂

なお、先ほど Counter クラスというものを紹介しましたが、これを利用する場合にも、自動的に init (…) 構文が適用されるようです。

今後の宿題

今回は、多くの宿題を解決できたと思います。今後は引き続き、以下のような感じで作業を進めていこうと思っています。

おつかれさまでした。

お問い合わせはお気軽に

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