Field Programmable Gate Arrays(FPGA)とHDLの基本を学びます。
LAST REVISION: 2022/11/28 23:04
必要なハードウェア
Field Programmable Gate Arrays
Field Programmable Gate Arrays(FPGA)は、シリコンファンドリー関連のコストを削減しつつ、カスタムハードウェアを作成する比較的古い方法です。残念ながら、チップ設計の複雑さのほとんどは残っているので、最適化に対する挑戦やハードウェアが必要とする本当の要件の最適設計をあきらめ、多くの人が制約を受け入れて既製品のチップを好む理由になっています。
ソフトウェアには多くのライブラリがあり、そこから使い始めることができます。FPGAにもIPブロックと呼ばれるライブラリがありますが、とても高価で、標準化されたプラグ&プレイインターフェイスがないので、全てをシステムに統合しようとする際には頭痛のタネになります。Arduinoが製品ラインアップにFPGAを導入しようとする目的は、プログラム可能なハードウェアを活用し、複雑さの多くを取り除いたうえで、マイクロコントローラ向けの拡張可能な周辺機器を提供することです。もちろん、これを実現するには、いくつかの制約を課し、ブロックを相互接続するための標準手法を定義することで、自動化することになります。
与えられたルールの集合に厳密に対応する標準インターフェイスを定義することが最初のステップですが、その前に、我々が必要とするインターフェイスの種類を定義することが重要です。マイクロコントローラとのインターフェイスを持つので、プロセッサーと周辺機器を接続するバスを最初に定義します。このようなバスはコントローラーと周辺機器とに存在し、バス上の信号は同じで向きが逆です。バスとコントローラー/周辺機器のアーキテクチャの詳細は、このドキュメントを参照してください。。
重要ですが標準化できない2番目のインターフェイスは、外部と接続する入出力信号です。それぞれのブロックは独自の信号を定義するので標準を定義することはできませんが、信号の組をグループ化してまとめることはできます。これを、コンジット(conduit)と呼びます。
最後に、ストリーミングデータを転送するのに有用になる三番目のインターフェイスのクラスがあります。この場合、連続したデータストリームを転送したいのですが、受信ブロックが処理できないときは流れを一時中断できる必要があります。このため、データだけではなく、UARTで使われるような、ある種のフロー制御信号も必要となります。
可読性についても多少標準化したいので、コーディング規約も設定します。もちろん、スペース/タブや表記方法など、多くの異なる宗教がありますが、好みのものを一つ選んでいきます…
宗教について話した後、言語についても言及します…我々は、VHDLよりも(System)Verilogを好むので、IPブロックのほとんどはVerilogで記述しています。Verilogを選択した理由は、一般的にVerilogはC言語に似ていて、パラメータブロックを容易に構築できる構造を持っているためです。
コーディング規約
- 宣言されたエンティティの前に接頭辞を記述し、型を識別します。変数名はすべて大文字で、複数の単語右派アンダースコアで分離します。具体例を以下に示します。
接頭辞 | 説明 |
---|---|
w | Wire。全ての信号の組み合わせ用。例: wDATA。典型的にはwire指令で定義されます。 |
r | Reg。全ての連続した信号用。例: rSHIFTER。典型的にはreg指令で定義されます。 |
i | Input。module宣言での全ての入力信号。例: iCLK。典型的にはinput指令で定義されます。 |
o | Oputput。module宣言での全ての出力信号。例: oREAD。典型的にはoutput指令で定義されます。 |
b | Bidirectional。module宣言での全ての入出力信号。例: bSDA。典型的にはinout指定で定義されます。 |
p | Parameter。parametrizeブロックでの全てのパラメータ。例: pCHANNEL。典型的にはparam指令で定義されます。 |
c | Constant。全ての定数もしくは派生した値。parametrizeで直接利用できません。例: cCHANNEL_BITS。典型的にはlocalparam指令で定義されます。 |
e | Enumerated。一つ以の信号もしくはレジスタでの定数。例: 状態マシンを定義するeSTATE。典型的にはenum指令で定義されます。 |
- タブではなくスペースを使います。タブサイズに関係なくコードがきれいに見えるからです。
- インデントはスペース2つです。
- 条件ブロックは1行しかなくても常にbegin/endを持ちます。begin/endはif/elseと同じ行にあります。
- 同じグループに属する信号は共通の接頭辞を共有します。
インターフェイスプロトタイプ
軽量バス(Lightweight Bus)
周辺機器間を相互接続するバスです。データバスは32ットで、接続するレジスタ数に応じ、アドレスバスは可変幅です。バスは信号の組が後に続きます。
信号 | 方向 | 方向 | 幅 | 説明 |
---|---|---|---|---|
コントローラー | 周辺機器 | |||
ADDRESS | 出力 | 入力 | 可変 | アドレス登録時に幅を決める |
READ | 出力 | 入力 | 1 | ストローブを読む |
READ_DATA | 入力 | 出力 | 32 | 読みこまれるデータ |
WRITE | 出力 | 入力 | 1 | ストローブを書く |
WRITE_DATA | 出力 | 入力 | 32 | 指定されたアドレスにデータを書く |
BYTE_ENABLE | 出力 | 入力 | 4 | 32ビットワードのどのバイトを書くかを決めるフラグで、オプションの信号 |
WAIT_REQUEST | 入力 | 出力 | 1 | 周辺機器がビジー状態化を示すフラグで、オプションの信号。 この信号がアサートされていないときだけ、ストローブの読み書きが有効です。 |
書き込みサイクルでは、ADDRESSとWRITE_DATAは、ストローブのWRITEと同じクロックサイクルでラッチされます。一方、読み込みサイクルでは、READ_DATAはストローブのREADの直後のサイクルで周辺機器に出力され、読み出されるADDRESSも示されます。
パイプラインバス
複雑なブロック間を相互接続するバスです。一度に複数のコマンドを操作でき、要求に対し可変回数応答します。このバスは、以下の信号を追加することで、軽量バスを拡張します。
この動作は、1クロック読取遅延(1 clock read latency)とも呼ばれます。周辺機器にまだREADもしくはWRITE操作に反応するのに可変クロックを必要とするとき、他の操作が実行されるのを防ぐために、コントローラーをロックします。ある意味、OSがマルチタスクを実現するためにdelayを使うのに対して、プログラムでビジーループを使うことに似ています。
信号 | 方向 | 方向 | 幅 | 説明 |
---|---|---|---|---|
コントローラー | 周辺機器 | |||
BURST_COUNT | 出力 | 入力 | 可変 | 実行する連続操作の数 |
READ_DATAVALID | 入力 | 出力 | 1 | データがいつコントローラーに提供されるかを示すフラグとして周辺機器はこの信号を使います。任意の遅延でアサートでき、連続性は保証されません。バーストサイズが4の読み込み操作は、それぞれのストローブ読み込みで、4回のREAD_DATAVALIDをアサートします。 |
この方法の主な利点は、各トランザクションにおいて、複数の読み書きを行う意図があることを、コントローラが周辺機器と通信できることです。実際、ストローブの読み書きにおいて、BURST_COUNTシグナルは周辺機器に対して、トランザクションの長さがどれくらいになるかを伝えます。
コントローラーは、操作が受け入れる状態になるまでWAIT_REQUESTをアサートします。書き込み操作の場合、BURST_COUNTとADDRESSは、最初のストローブのときにだけサンプリングされ、その後、周辺機器は要求されたワード数分WRITEストローブがアサートされるのを期待し、自動的にアドレスをインクリメントします。読み出し操作の場合は、WAIT_REQUESTがアサートされないとき、 1回のREADストローブが、要求されたワード数を知るためのREAD_DATAVALIDをアサートすることにより返されるBURST_COUNT数分のワードを読むよう周辺機器に伝えます。
読み込み操作が開始された後は、操作を受け入れるかは周辺機器次第ですが、一般的には、パイプラインバスの恩恵を受けるため、少なくとも2並列の操作が可能です。
ストリーミングインターフェイス
近日作成
(System)Verilogモジュールの構造
SystemVerilogのモジュール宣言には複数の方法がありますが、我々が最も好むのは、パラメーターを使う方法です。ブロック入力がコンパイル時にカスタマイズできるためです。これは以下のようなものです。
|
|
ここでは、モジュールのプロトタイプと、入出力ポートだけを定義しました。次に、モジュールヘッダーとendmodule文の間に有用な論理を付け加える必要があります。
カウンターの例を始めたので、それを本当に実装するコードを書いていきます。
|
|
上記のコードは、コード自身が説明しています。全てのクロックの立ち上がりで、入力のiRESETがHIGHならカウンターをリセットします。そうでなければ1インクリメントします。ブロックを既知の状態に戻すリセット信号は有効ですが、必ずしも必要ではありません。
これは興味深いですが、少しトリッキーなことをしました。oCOUNTERを出力regとして宣言しました。これは単なる配線ではなくメモリを持っているということを意味しています。このように登録された"<=“という代入を使うことができます。この代入は、次のクロックサイクルまで保持されます。
別の方法として、module宣言からreg文を削除し、カウンターを次のように定義することもできます。
|
|
これは基本的には同じです。しかし、レジスタの定義と操作を行い、常に = により出力信号に代入し続けています。<=は信号はクロックのエッジでだけ変化しますが、=は常に値を代入し続けることです。結果として信号はどんな時でも変化します。しかし、この例で行っているように、クロックのエッジでしか変化しないレジスタに信号を代入しても、得られる信号は基本的には単なる別名ということになります。
興味深いことに、ハードウェア記述言語での他の文と同じように、代入は並列に実行されます。全て並列に実行されるのでコード内の順序はあまり関係なく、alwaysブロックの前にoCOUNTERをrCOUNTERに代入することもできます。順序が全く関係しないということは完全に正しいというわけではないので、この問題は後述します。
継続代入のもう一つの興味深い使用方法は、論理式が生成できることです。例えば、カウンターは以下のように書き換えることもできます。
|
|
我々はまだ同じことをしていますが、論理的にもう少しわかりやすい方法になりました。wNEXT_COUNTER信号にrCOUNTER+1の値を継続的に代入しています。wNEXT_COUNTERは、rCOUNTERの値が変化すると、ほぼすぐに変化します。しかし、rCOUNTERは、次のクロックの立ち上がりエッジでしか更新されないので、rCOUNTERは(<=で代入しているので)クロックエッジでしか変化しないということになります。
並列処理と優先順位
前章で記述したように、全てのハードウェア記述言語は並列文の概念を持っています。命令を逐次実行するソフトウェアプログラミング言語とは違い、全ての命令は並列実行されます。例えば、以下のコードをブロックに書くと、あるクロックエッジでレジスタが同時に変更されることを観測できます。
|
|
もちろん、全ての処理が並列実行されるのであれば、逐次処理を実行する手段が必要です。これは簡単な状態マシンを作成することで実現できます。状態マシンは、入力と内部状態に基づき出力を生成するシステムです。ある意味、出力(oCOUNTER)は以前のマシンの状態(rCOUNTER)に基づき変化していたので、先ほどのカウンターは状態マシンでした。もう少し面白いことをしてみましょう。開始後、指定した長さのパルスを作成する状態マシンを作成します。マシンは、eST_IDLEとeST_PULSE_HIGH、eST_PULSE_LOWの3つの状態を持ちます。eST_IDLE状態では入力コマンドをサンプリングし、受信するとeST_PULSE_HIGH状態に移行します。そこでは、指定したクロック数だけその状態にとどまります。クロック数は、pHIGH_COUNTパラメータで指定します。その後、eST_PULSE_LOWに移行します。そこではpLOW_COUNTクロックその状態にとどまり、eST_IDLE状態に戻ります。これがどのようなコードになるか見てみましょう。
|
|
説明しなければならないいくつかの新しいことがあります。最初に、enumを使ってrSTATE変数を定義しています。これにより、ハードコードされた数字ではなく、理解しやすい値を状態に代入することができます。また、状態マシン全体を書き換えることなく、新しい状態を追加することができるという利点もあります。
2番目に、case/endcaseブロックを導入し、信号の状態に応じた異なる処理を定義できるようにしました。文法はCに似ているので、ほとんどの読者にはなじみ深いものだと思います。さまざまなコードブロック内の文は変わらず並列実行されますが、検査される変数の異なる値により決定されるので、同時には一つのブロックだけが有効になることに注意してください。eST_IDLEでは、iPULSE_REQがHIGHになるのを検出するまでは、この状態にとどまり続けます。iPULSE_REQがHIGHになると、状態を変え、カウンターをHIGH状態とする時間にリセットし、パルスの出力を開始します。
oPULSEは、regなので再度代入されるまではその状態を保持することに注意してください。次の状態ではもう少し複雑になります。各クロックでカウンターをデクリメントします。そして、カウンターが0になると状態も変えます。oPULSEを0にしrCOUNTERを再度代入します。2つの代入が並列実行されるので、このことが何を意味するのかを知る必要があります。幸運なことに、全てのHDLは2つの並列文が同じレジスターに実行されたときは、最後の文だけが実行されることになっているので、我々が書いたプログラムは、通常はカウンターをデクリメントし、カウンターが0になった時だけ状態を変更し、pLOW_COUNTに再初期化します。
この時点で、eST_PULSE_LOWのときに何が起こるのかは非常に明確です。カウンターをデクリメントしていき、0になった時に状態をeST_IDLEに戻します。状態がeST_IDLEに戻った時、rCOUNTERは再度デクリメントされ、その結果0xff(もしくは-1)になります。しかし、iPULSE_REQを受信したときに適切な値にリセットするので、このことを気にする必要はありません。
eST_PULSE_LOW状態を抜けるとき、rCOUNTERをリセットすることもできます。しかし、HDLでは、何か事項するとリソースを消費しハードウェアを遅くするので、本当に必要なことだけを行うのがいいとされています。最初はこのことは危険に見えますが、経験を積むとこれが役立つことがわかるようになります。これと同じ概念がリセットのロジックにも適用されます。本当に必要でない限り、実装方法によっては、リソースを消費しシステム速度を低下させます。このため、注意して使う必要があります。
実世界での例
次は、Vidorで使う簡単な周辺機器であるPWMの実例を見てみましょう。ここでは、複数のPWM出力ができ、各PWMチャネルの相対的位相を定義できる小さい部品を作ることが目標です。
これを実現するために、カウンターと、指定した値で出力を切り替えるためのいくつかの比較器が必要です。PWM周波数もプログラム可能としたいので、システムが使っているものとは別の周波数で動作し、本当に必要な周期を得るためのカウンターが必要です。このため、分周器を使います。分周器は別のカウンターで、UARTでボーレートを得るのに似た方法で、基本となるクロックをより低い値に分周します。
コードを見ていきましょう。
|
|
新しく学ぶことが多くあります。モジュール定義から始めましょう。必要とするビット幅を持つアドレスバスを設定するために、組み込み関数を使っています。アドレスの大きさをレジスタに必要な最低値に抑えるためです。例えば、10チャンネル必要であれば、22のアドレスが必要となります。各アドレスビットは、利用できるアドレス数を2倍にするので、全部で5ビット必要で、合計32のアドレスとなります。これをパラメータ化するために、iADDRESSの幅を、$clog2(2*pCHANNELS+2)と定義し、レジスタを2次元配列として定義します。
多次元配列を作成するには2つの方法があります。ここでは、「アンパック型」を使います。アンパック型配列は、レジスタ宣言の左側に添え字を付加し、レジスタを分離したエンティティとして定義します。もう一つの方法は、この例では使っていない「パック型」で、宣言の左側に両方の添え字を書きます。その結果、二次元配列は、全てのレジスタを結合したひとつの大きなレジスタとして見えます。
ここでのもう一つの興味深い技はレジスタを操作する論理の定義方法です。最初に、ここでは書き込み専用のレジスタを実装しているので、iREADとiREAD_DATA信号はありません。次に、パラメータレジスタの集合を必要とします。このうち、最初の2つのレジスタは常に存在しますが、残りは動的に定義され実装したいチャネル数に応じて取り扱います。これを実現するため、0(偶数)か1(奇数)を調べるために、2進数で表現したときの最下位ビット(LSB)に着目します。1チャネルにつき2つのレジスタがあるので、アドレスが2より小さいかどうかでふるまいを変える簡単な方法です。
もしアドレスが2より小さい場合は、共通レジスタを分周器の計測用と計測期間として実装します。2以上の場合は、LSBをHIGHかLOWの比較器の値として書き込むのを決定します。
他の簡単な例
いくつかのことを学ぶ他の簡単な例が、直行エンコーダです。PWMよりも簡単に見えますが、重要な課題があります。最初の課題は外部信号の取り扱いです。外部信号が内部クロックと同期している保証がないので、レジスタのデータが不定だったり、クロックサイクル中に変化する、準安定性(metastability)と呼ばれる現象に直面します。読取中のレジスタの入力でデータが変化すると、レジスタが不定な状態に崩壊し、任意の時点で、1または0になるためです。このため、レジスタの連鎖を加えることで、最初のレジスタが準安定状態になったとしても、後続のレジスタが安定状態になり、続くロジックに、全てのロジックが不安定状態による汚染されることなく後続のロジックに供給できるようにし、入力信号を再同期させる必要があります。
ここで行っている、もう一つの興味深い事柄は、エンコーダから出力される直交信号からストローブと方向を決めるために、連続代入を行っていることです。コード内には、波形がどのようなものかを示す簡単なグラフがあります。しかし、波形がどのようなものであるかを完全に理解するには、等式が異なる時点の信号を使っていることを考慮する必要があります。これは、非同期入力を同期させるために使うシフトレジスタを使って遅延させ、シフトレジスタの別の時点を調べることで、前のシグナルがどのようなクロックだったのかがわかります。特に、シフトレジスタの入力側を見ると新しいデータが得られ、終端側に移動すると古いデータを得ることができます。式を見ると^という演算子を使っていることがわかります。これは、排他的論理和(XOR)で、2つのオペランドが異なるときに1を返し、そうでないときは0を返します。
波形を見ると、AかBがエッジになるとストローブがパルスを生成していることがわかります。これは、各信号と遅延した信号のXORをとることで実現しています。一方、方向信号はもう少し複雑です。しかし、ストローブ信号がHIGHのときに、エンコーダの回転方向により常に0か1になっていることがわかります。実際、方向信号にパルスがありますが、ストローブとは同時ではないので、無視されます。
一見したところ明確ではないかもしれませんが、式は、全ての入力に対して同じロジックを並列に計算しています。実際、rRESYNC_ENCODERレジスタは二次元配列で、最初の添え字はシフトレジスタの取得を示し、2番目の添え字はエンコーダチャネルです。特定の添え字のrRESYNC_ENCODERを参照するといつも、添え字によって示されたクロックの合計分遅延した全てのエンコーダ入力を含む1次元配列を選択していることを意味します。また、配列に対してビット単位の論理演算を実行すると、同時に複数の論理式を実行していることも意味します。これは、配列がパック型だから可能であり、アンパック型配列の場合は要素が分離されているので、このような式は実現できず、個別に指定する必要があります。
他の例で実行したように、ブロックは複数の入力を実装し、イネーブル信号(これは、チャネル分の大きさを持つ配列です)をチェックするforループで実行します。イネーブル信号がHIGHのとき方向と、増加・減少に従ったそのチャネルのカウンタを確認します。これは、 ? : 演算子(条件式)で容易に行うことができ、C言語と同様に動作します。
最後に、レジスタは読み込み専用のカウンタで、読み込み信号をチェックするだけで簡単に実装でき、アドレスが添え字となったカウンタの配列を出力データに代入するだけで、RAMと同様なので、バスインターフェイスはとても簡単です。
|
|
説明したように、カウンタの深さとチャネル数を変更でき、コードは読みやすい方法で関連するすべてのロジック生成がスケールアップし、高度にパラメータ化された設計なので、手際が良く簡潔なハードウェア記述のいい例と言えるかもしれません。もちろん、同じことを行う方法はたくさんあり、同時にもっとも簡単な方法のひとつは、(System)Verilogの能力をもう少し理解する必要があります。
Last revision 2018/07/17 by DP & SM
オリジナルのページ
https://docs.arduino.cc/learn/programming/vidor
最終更新日
December 28, 2022