today   2019-03-28

access_time  11 mins

ChiselでBF処理系を作る(2)

チャタリング除去回路の実装と構文の解説

前回はBF処理系の説明と全体構成について解説した。次からはいよいよ実装を行う。

graph LR PC -- USB --- USB_Uart USB_Uart --Tx--> UartTxRx UartTxRx--Rx--> USB_Uart UartTxRx -- program/stdin -->FIFO_din FIFO_din -- program --> BF_Processor FIFO_din -- stdin--> BF_Processor BF_Processor -- stdout --> FIFO_dout FIFO_dout -- stdout --> UartTxRx External_SW --program/run -->UntiChatter UntiChatter -->BF_Processor style UntiChatter fill:#f66,stroke:#f33

今回は赤で示すUntiChatterモジュールを実装する。

チャタリング

プッシュスイッチ、スライドスイッチなどは機械入力を電気信号に変換するが実際の信号ではOff->Onとはキレイに遷移してくれない。具体的にはOn/Offを短期間内で繰り返すような波形となる。

波形で書くとbutton_inのようになっているため、後の回路が誤動作してしまうことは避けたい。目標としてはdoutのような応答を作りたい。

対策

チャタリング防止で調べるといろいろ出てくるが、ボタン入力の高周波成分を取り除ければよいのである。主には以下のあたりが主流だと思う。

  1. 回路上にローパスフィルタを挿入
  2. ボタン入力を(FPGA上で)ローパスフィルタを通して出力
  3. ボタン入力をキャプチャする周波数を下げる

今回は2を採用するが、以下の点から2.3.併用とする。

(先の図ではslow_clkで書いてしまったが)ボタン入力に100MHzほどスピードを上げる必要がなく、移動平均の値保存にLEを使用しすぎるので100Hz~1kHz程度でキャプチャする。

ボタン入力のローパスフィルタ

おそらく普通にやろうと思ったら、以下のようなコード(架空)になると思われる。入力値を保持しておいて平均値を出せば良い。

queue<int> buf;
int dout;
while(true) {
	buf.enqueue(get_input());
	if (buf.size() > BUF_SIZE) {
		buf.dequeue()
	}
	dout = buf.sum() / buf.size();
}

論理回路で実装するなら(定石ではあるが)簡略化できる点がある。一つはtrue/falseの2値であること、もう一つはボタンの入力が安定したら平均値は最大値に張り付くはずなので平均値計算は不要である。

これを踏まえると以下のような回路を考えるのが妥当である。

graph LR Button -- bool -->BUF_0 BUF_0 --> BUF_1 BUF_1 --> BUF_2 BUF_2 --> BUF_3 BUF_0 -.-> assign BUF_1 -.-> assign BUF_2 -.-> assign BUF_3 -.-> assign assign["BUF_0 and BUF_1 and BUF_2 and BUF_3"] -- bool -->dout
  1. 実線 - クロックに同期してレジスタへ代入
  2. 点線 - レジスタの値を参照して計算

クロックについては省略したが、立ち上がりが来るたびにButton->BUF_0, BUF_0->BUF_1…と代入することでBUFの長さで過去クロック分のデータを蓄積することができる。

あとは保持されている値がすべてTrueであることを確認して出力してあげれば良い。

実装

大体の方式が決まったところで実装に移る。ソースコード全体を示す。

UntiChatter.scala - Github

package bf

import chisel3._
import scala.math.{ log10, ceil }

class UntiChatter(sysFreq: Double, captureFreq: Double = 100, averageWidth: Int = 8, isPositive: Boolean = true) extends Module {
  val io = IO(new Bundle {
    val din = Input(Bool())
    val dout = Output(Bool())
  })
  val duration: Int = (sysFreq / captureFreq).toInt
  val log2: Double => Double = (x: Double) => log10(x)/log10(2.0)
  val counterWidth: Int = ceil(log2(duration)).toInt + 1

  val counter = RegInit(UInt(counterWidth.W), 0.U)
  val trigger = RegInit(Bool(), false.B)
  when(counter < duration.U) {
    counter := counter + 1.U
    trigger := false.B
  } .otherwise {
    counter := 0.U
    trigger := true.B
  }

  val dout = RegInit(Bool(), false.B)
  io.dout := dout
  val captureData = RegInit(UInt(averageWidth.W), 0.U)
  when (trigger) {
    captureData := (captureData << 1).asUInt + Mux(io.din, 1.U, 0.U)
    dout := { if (isPositive) captureData.andR else captureData.orR }
  }
}

おそらくscalaがわかるがHDLがわからない人よりも、HDLはわかるがChisel(及びScala)がわからないという方のほうが多いと思うので、最初なのでscalaの構文にも触れながら解説する。

パッケージ指定

package bf

これは名前空間とほぼ相違ない。package宣言下のclassなどはこの名前空間に属する。つまり今回の場合はbf.UntiChatterになるというわけである。小規模な場合省略も可能。

インポート

import chisel3._
import scala.math.{ log10, ceil }

先のパッケージの説明であったとおり、特定のパッケージに属するクラスなどを呼び出す場合はインポートが必要である。includeだと思ってもらえれば良い。

chisel3._とアンダーバーがあるが、これはワイルドカードと等価でchisel3下にあるものすべてが利用可能になる。

一方{log10, ceil}のように特定の物のみをインポートすることも可能。

クラス宣言

先に例をいくつか出して解説する。

class MyClass {
}

scalaのクラス宣言はシンプルである。

class MyClass(a: Int, b: String) {
}

コンストラクタに引数をもたせたい場合はこう

class MyClass(a: Int, b: String = "Hello") {
}

デフォルト引数も指定可能

class MyClass(a: Int, b: String = "Hello") {
	def this() = {
		this(100, "Overload")
	}
}

コンストラクタのオーバーロードがしたい場合は、thisメソッドを定義する。

class MyClass() extends BaseClass {
}

継承はextendsを使う。

これらを踏まえた上でUntiChatterの定義を見る。細かいことを言うとtraitとかcase classとかあるので一旦放置。

class UntiChatter(sysFreq: Double, captureFreq: Double = 100, averageWidth: Int = 8, isPositive: Boolean = true) extends Module {
}

sysFreq, captureFreq, averageWidth, isPositiveをコンストラクタ引数に持ち、Moduleを継承したクラスであることがわかる。 Chiselで自作のモジュールを作る際は、基本的にchisel3.Moduleを継承する。またchiselではコンストラクタに指定した変数をもとにロジックの動作を決めても構わない。例えばデータのビット幅であったり定数であったりなど(Verilogでいうところのlocalparamと同じ扱い)

一応パラメータの値を解説しておく。

名前 概要 想定(デフォルト引数)
sysFreq システムクロック周波数 100e6
captureFreq 入力を受け取る周期 100
averageWidth 入力値を保持する数 8
isPositive 入力値が負論理だった場合のオプション true

入出力宣言

val io = IO(new Bundle {
	val din = Input(Bool())
	val dout = Output(Bool())
})

chisel3.Moduleではフィールド変数にioをオーバーライドする必要がある。ここにはBundleというcで言うところの構造体(struct)にあたるものをIOに投げたものを代入してあげれば良い。

※scalaチックなことを気にする必要はないので、今はこのような構文で描けば良いと理解してもらって差し支えない。

Bundleの中にはIOポートとして外部に公開したいポートと、その種類について記述する必要がある。

例えば8bitの数値を受け取るのであればval num = Input(UInt(8.W))と宣言する。

ちょっとややこしいので3つほど解説する。

scalaの変数宣言

scalaの変数宣言には、valvarがある。valは一度代入した値を変更することができない。基本的にはvalを使うことが推奨される。

val a = 5
var b = "Hello"

b = "World" // エラーなし

a = 10 // valは不変なので後から値を変更できない

また型を明示する場合は以下のように書く。

val a: Int = 5

scalaの数値 vs chiselの数値

UInt(8.W)と書いた.Wについて。chiselにおける型(UInt, SInt, Width, Bits, Bool…)はscalaのプリミティブ型(Int, Boolean, …)とは別物として処理され暗黙のキャストもなされない(scalaの言語仕様上は可能なので、おそらく意図しないバグ混入対策かと)

なので、scalaプリミティブ型からchisel型に変換するために以下のメソッドが用意されている

プリミティブ chisel メソッド名 使い所
Int UInt .U 15.U 整数を符号なし整数にしたいとき
Int SUInt .S -20.S 符号付き整数にしたいとき
Int Width .W 8.W ビット幅の指定をしたい場合
Boolean Bool .B true.B 真偽値を使いたいとき

ちなみにchisel内での型変換(例えばSInt->UIntなど)は、.asUInt, asSIntなどが用意されているので必要な場面で参照してほしい。

scalaの構文について

Input, Boolにnewがいらないのを気持ち悪く感じるかもしれないので追記する。気にならない人は読み飛ばしてもらって構わない。

scalaがscala言語上にDSLを作るにあたって素晴らしい言語であることは自明なのだが、その特徴らしい構文がいくつか出てきていたので紹介する。

コンストラクタ呼び出し vs apply
val a = new MyClass("Hello")

これはコンストラクタを呼び出している。

val a = MyClass("Hello")

実はこれはMyClass.apply("Hello")と等価。scalaでstaticメソッドのようなものを作る場合はclassではなくobjectで宣言した以下のようなクラスを用意する。

object MyClass {
	def apply(str: String) = {
		???
	}
}
2項演算の実態

c++などでは演算子はオーバーロードしていたが、scalaの場合は引数が一つのメソッドを空白を開けて演算子のように扱うことができる。つまり

println(3 + 5)
println(3.+(5))

が等価である。更に言うなら記号である必要もなく、PythonでいうところのRangeに相当するものは以下のようにかける。

0 to 10 map(_ * 2) foreach(println)	
0.to(10).map(x => x * 2).foreach(println)

ideoneで試した

implicit class

20.UInt->chisel3.UIntに変換できることはわかるがどうしてプリミティブ型にchisel変換用のメソッドが生えているのか疑問に思った方がいるかも知れない。

実はこれにはカラクリがあって、C#やKotlinでは拡張関数(メソッド)と呼ばれる機構と同じようなものが実装されている。

例えば"Hello".myPrintlnを使えるようにしたいとする。その場合以下のようなクラスを定義しその名前空間が見える場所が見えるようにimportしてあれば使えるようになる。

implicit class MyStringExtension(val src: String) {
	def myPrintln: Unit = println("!!!!!" + src + "!!!!!")
}

ideoneで試した

chiselはこの機構を利用してscalaプリミティブ型との相互運用を図っている。

と、脱線が過ぎたのでここから本題に戻る。

事前計算(in scala)

val duration: Int = (sysFreq / captureFreq).toInt
val log2: Double => Double = (x: Double) => log10(x)/log10(2.0)
val counterWidth: Int = ceil(log2(duration)).toInt + 1

システムクロックからキャプチャする周波数に変換するにあたって、カウンタに必要なビット幅とカウンタをリセットする周期を計算している。 verilog HDLのlocalparamだとどうしてもここの柔軟性が確保できずモヤモヤしながらコメントを書いていたので、私的にはかなり嬉しい機能である。

ちなみにこの計算はすべてscalaの機能だけで構成されているので、verilogに変換する段階で定数に置き換えられる。(公式ドキュメントではscala land, chisel landと呼称している)

一定時間ごとにトリガを出力する回路

val counter = RegInit(UInt(counterWidth.W), 0.U)
val trigger = RegInit(Bool(), false.B)

when(counter < duration.U) {
	counter := counter + 1.U
	trigger := false.B
} .otherwise {
	counter := 0.U
	trigger := true.B
}

これはverilogで書き下すなら(ビット幅などは正確な構文ではない)以下の回路と同じになる

reg [counterWidth-1:0] counter;
reg trigger;

always @ (posedge clk) begin
	if(reset) begin
		counter <= 0;
		trigger <= 1'd0;
	end else if(counter < duration) begin
		counter <= counter + 1
		trigger <= 1'd1
	end else begin
		counter <= 0
		trigger <= 1'd0
	end
end

きっと読者の多くはverilog HDLで記述されたほうを見て理解できるのではないかと思う。それはさておき順番に解説する。

レジスタの初期化

Reg(<type>)及びRegInit(<type>, <initial value>)で初期化することができる。

val counter = RegInit(UInt(10.W), 0.U)

例えば上記は10bitの幅を持つレジスタを初期値0で宣言している。この初期値宣言はresetがアサートされているときに初期値に設定されるようなverilogを生成してくれるようになる。

またここまで触れてこなかったがCLK、RESETについては開発者側が明示しなくても自動的にchiselが生成してくれるので基本的に記述する必要はない。(複数クロックドメインを扱う場合は別)

条件分岐

chiselではModuleクラスのフィールドに記述した内容はそのままverilogに変換される。(chiselに絡んだ変数が含まれる場所のみ。先の事前計算などはverilog上には残らない)

ただしif文などはchisel landで条件分岐に使う構文が用意されている。 例えば今回when, .otherwise句を使っているが、これはif, elseのchisel land版である。

when(counter < duration.U) {
	counter := counter + 1.U
	trigger := false.B
} .otherwise {
	counter := 0.U
	trigger := true.B
}

:=演算子があるが、これは代入演算子である。 これが組み合わせ論理回路になるか同期回路になるかは代入先にレジスタがあるかどうかであるようだ。(※要調査)

たったこれだけでcaptureFreqで設定した周期ごとにtriggerがアサートされる回路が完成した。

直近すべての値がTrueだったときにTrueを出力する回路

いよいよ本命の実装をする。先の回路でtrigger信号を受け取れるようになったので、triggerが来たときに値を取り込むシフトレジスタを実装してみる。(※Vecでやっても良かったが値シフトでやった)

val dout = RegInit(Bool(), false.B)
io.dout := dout

val captureData = RegInit(UInt(averageWidth.W), 0.U)
when (trigger) {
	captureData := (captureData << 1).asUInt + Mux(io.din, 1.U, 0.U)
	dout := { if (isPositive) captureData.andR else captureData.orR }
}

captureDataはaverageWidthで指定された幅のレジスタで初期化し、triggerの値が来るたびに値を左シフトして一番下に現在の値を取り込む。

verilogで書くなら以下のようになる。(※isPositive=trueの場合。後述)

reg dout;
assign io_dout = dout;

reg [averageWidth - 1:0] captureData;

always @ (posedge clk) begin
	if (reset) begin
		captureData <= 0;
		dout <= 1'd0;
	end else if(trigger) begin
		captureData <= { capturedata[averageWidth - 1:1], din};
		dout <= &captureData;
	end
end

dout <= &captureDataはverilog HDLにあるリダクション演算子というものである。意味はすべてのビットのandした結果を返す。以下と等価。

dout <= captureData[averageWidth - 1] & captureData[averageWidth - 2] & ..中略.. captureData[0];

代入演算子によるassign

val dout = RegInit(Bool(), false.B)
io.dout := dout

io.doutは最初に宣言した出力ポートなので何もアサインされてない(エラーになる)

なので、何かしらの組み合わせ論理回路かレジスタの値を:=を使って設定する。

Mux

3項演算子と等価。型変換が面倒だったので。以下のような記述をしているだけである。

io.din ? 1'd1 : 1'd0

scala landでの処理切り替え

先程から注釈として何度も入れてきたが、scala landの構文が事前処理されることをマクロとして利用できる。

またscalaの構文はすべて値を返すので、if~elseを利用してメタ的に処理切り替えが実装できる(なので3項演算子は存在しない)

具体的にはコンストラクタでisPositiveというボタン入力が正論理/負論理であるパラメータを受け取り、生成する回路を動的に変更している。

if (isPositive) captureData.andR else captureData.orR

andRはリダクション演算子のand、orRはリダクション演算子のorである。チャタリングが収まっていればすべてand(もしくはor)を満たすことができるのでdoutがアサートされる仕掛けである。

完成

あっちこっち説明してまとまりがなかったが、これで目的のチャタリング除去回路を実装することができた。

テストについては簡単なものしか書いていないので詳解しないが、UntiChatterSpec.scala -Githubを参照していただきたい。

scalaの構文についてもすこし触れたが、chiselとして使うだけであれば詳しく知らなくても十分使えると思う。ただテストを書く場合などもちろん知っておくと得する要素は大きい。

次は同期FIFOを実装する。次以降は一度説明したところを端折れるのでもっと大雑把に説明する予定。

次回 - ChiselでBF処理系を作る(3)