本記事では、国産HDLであるSFL(Structured Function description Language)を、 個人的に使いやすく独自拡張した SFL+ について紹介する。
ツールチェインは、SFL+ から 標準SFL への変換は自作のプリプロセッサ(sflp)を使用し、 SFL / NSL から VerilogHDL / VHDL / SystemC への変換には オーバートーン社で公開されている NSL Core 処理系 を使用させて頂いている。 生成された VerilogHDL をシミュレーションしたり、FPGAベンダの論理合成ツールに突っ込んだりする。
このようなレベルギャップを埋めるための他のDSLとしてScalaベースの Chisel が有名だが、 静的型付け言語であるということと、 VerilogHDLへのトランスパイル時にトップモジュール以下のファイル群をトレースしてクラス間依存を確認するため、 いわゆるフルビルドのようなことを行っており変換処理に時間がかかるらしい。 それでも約27万行のデザインで変換時間が85secという情報もあり、極端に遅いわけではなさそう? インクリメンタルビルドできれば良いのかもしれないけど。
SFL+ の構文については、SFL の構文がベースにあり、 SFL を VerilogHDL 等に変換するツールの機能として一部記述しやすくなっている部分と、 それに加え SFL+ 独自拡張の部分がある。 個別に説明しても混乱しかねないので、 現在 SFL+ として使用している形での説明としてまとめたつもり。
場合によっては SFL の言語設計思想に反するものもあるかもしれないけど、広い心で許してほしい。 もし怒られが発生した場合、この記事は消える。 しかし SFL+(プラス)って命名が雑過ぎませんかね…。
circuit cir_name
{
入出力宣言
レジスタやデータ端子宣言、サブモジュール宣言
動作記述
ステージ記述
}
処理系を考慮し、一つの .sflp ファイルには、
一つのモジュールのみ記述する。
また、モジュール定義がcircuit sample
なら、
ファイル名はsample.sflp
のように、
モジュール名とファイル名は合わせる。
input in_data<8>; // 8bit入力端子
output adrs<32>; // 32bit出力端子
bidirect dbus<8>; // 8bit双方向端子
出力端子output
はそのまま回路内部でも参照できる。
双方向端子bidirect
はハイインピーダンスや移植性を考慮するとFPGA内部モジュール間ではほとんど使用しない。
双方向端子を使用するのはFPGA外の周辺デバイスとのインタフェースでのみ。
reg_wr az; // 1bitの0で初期化
reg_wr arz<4>; // 4bitの0000で初期化
reg_ws as; // 1bitの1で初期化
reg_ws ars<4>; // 4bitの1111で初期化
reg a; // 1bit初期化なし
reg a<4> = 0x5; // 任意値初期化例
レジスタ宣言は3種類あり、基本的には0初期化のreg_wr
を使う。
ただしリセット信号のファンアウトを増やしたくない場合は初期化なしのreg
を使う。
sel s<4>; // 4bit端子
いわゆる信号線やワイヤと同じような意味で使用し、
レジスタと異なり次クロックへのデータ保持はしない。
reg r; // レジスタ
sel s; // データ端子
par{
r := s; // クロック同期でレジスタは右辺値になる
s = 0b1; // クロックに関係なくデータ端子は右辺値になる
}
SFL / SFL+ は同期式単相クロックを前提としているため、
レジスタとデータ端子の動作が明確になっている。
レジスタに対してはクロック同期時にのみの代入しか許されないし、
データ端子や出力端子に対してはクロックに関係なく常に右辺値が代入されている。
なお SFL は並列に記述されたものはすべて並列実行され、 他のHDLのような、書き方によっては上から順に実行される、という動作はしない。
instrin write; // 制御入力端子
instrout ack; // 制御出力端子
instrself clear; // 自モジュール内で使用
// 制御端子宣言時に引数宣言も行う例
input adrs<16>, data<8>;
instrin write(adrs, data);
/*
上位モジュールから
ram.write(A, D);
のように使う。
*/
// 制御動作
instruct write cells[adrs] := data;
// 条件内の制御動作
if(cond) ack();
サブモジュールの記述をC++のclassのように考えると、
制御端子はメンバ関数、
内部のレジスタ等はメンバ変数のように例えられる。
instrout
はoutput
同様に回路内部で参照可能。
記述としてはinstruct write aaa();
もif(write) aaa();
も同じ動作なので、
状況によって使い分けている。
条件内で制御端子をaaa();
として記述すれば、
条件成立時にaaa
は1となり、
条件が成立しない場合はその制御端子は0となる。
実はデータ端子も似たような動作をするが、
制御動作を明確に記述する場合はほぼ制御端子を使用する。
演算子 | 機能 |
---|---|
a | b | 論理和 |
a & b | 論理積 |
a @ b | 排他的論理和 |
^a | 論理否定 |
a || b | ビット連結 |
a + b | 加算 |
a - b | 減算 |
/|a | リダクション論理和 |
/&a | リダクション論理積 |
/@a | リダクション排他的論理和 |
8#a | 符号拡張 |
(4#0b0) || a | ゼロ拡張例 |
a >> 2 | 論理右シフト |
a << 2 | 論理左シフト |
(a + b)<3:0> | 式のビット切り出し例 |
a == b
a != b
// 演算子との優先順位を確実にするために () を付けること。
if((a==0x10) | (a==0x20)) g();
a += b; // 加算
a -= b; // 減算
a |= b; // 論理和(ビットを立てる、など)
a &= ^b; // 論理積(ビットを下ろす、など)
c++; // インクリメント(使用頻度多い)
d--; // デクリメント
対象はレジスタと分散メモリになるメモリ記述のみで、計算後の値は次のクロックに反映される。
データ端子については値を保持できないので対象外。
mem m[256]<8>; // 8bit 256word メモリ
instruct read data_out = m[adrs];
instruct write m[adrs] := data_in;
// ゼロフィルの例(FPGA書き込み時のみ有効)
mem param[16]<8> = {0};
// 初期値設定の例(FPGA書き込み時のみ有効)
mem param[16]<8> = {
0x02, 0x04, 0x06, 0x08 // 先頭4Wordの値設定、それ以降はゼロフィル。
};
メモリ記述を論理合成ツールの自動推論でFPGAの内蔵メモリ(Block RAM:ブロックメモリ)にマッピングするには、
ある程度のサイズを宣言し、
WriteとReadのI/Fをそれぞれ一箇所に記述する必要がある。
論理合成ツールによっては、宣言サイズが小さかったり、
WriteやReadの記述が複数個所に記述されていたりすると、
単なるレジスタの集合(Distributed RAM:分散メモリ)としてマッピングされる。
また、小規模のmemをブロックメモリに推論してほしくない場合は、
のちに説明しているregsやregfileを使用する。
アクセス方法について、 内蔵メモリとして使用する場合は、Readデータは次のクロックで使用するようにする。 レジスタの集合として使用する場合は、同じクロック内でReadデータを使用できたり、 複数アドレスに同時Writeできる。
メモリデータの値はゼロフィル宣言や初期値設定が可能だが、これはFPGAプログラム時のみ有効。 ROMとして使うのであればそれで問題ないが、RAMとして使用する場合は、 動作時にゼロフィルや初期値設定(ROMから値をコピー)する回路を作る必要がある。
mem gpr[8]<4>;
gpr[0xE] := 0x3;
gpr[5] := 0x2;
メモリインデックスで16進数と10進数が使えるように。
0b1001 // 2進数
0o4567 // 8進数(ほとんど使ってない)
0d6789 // 10進数
0x89AB // 16進数
a = 640; // 普通の10進数表記は左辺のビット幅で扱われる
0b10_01 // 0b1001と同じ
reg csig<8> = 'N'; // 0x4E
sel str<40>;
str = "think"; // str = 't'||'h'||'i'||'n'||'k'; に変換
str = "ミカクニン"; // 半角カナもOK
文字コードとして8bitのASCIIコードが割り当てられる。
// 並列動作
par{
a := b;
c := d;
o = s;
}
// 条件付き動作
// 条件が成立するものが並列動作する
any{
cond1 : a := b;
cond2 : c := d;
else : e := f; // どの条件も成立しない場合
}
// 優先度付き条件動作
// 上の方が優先度が高く、条件が成立したもののみ動作する
alt{
cond1 : a := b;
cond2 : c := d;
else : e := f; // どの条件も成立しない場合
}
同期式回路が主体のため、条件が抜けていてもラッチは生成されない。
条件が抜けている場合、現在の処理系ではデータ端子は0になるようになっている。
// ステージ宣言
stage_name clear_stg { task do(); }
// ステージ起動(次のクロックでfirst_stateが動作する)
generate clear_stg.do();
// ステージの動作状態を参照
if(clear_stg.do) clear_running();
stage clear_stg {
first_state st1;
state st1 par{
// 動作1
goto st2;
}
state st2 par{
// 動作2
goto st1;
finish; // ステージ停止
}
}
ステージはステートマシン記述のための構文で、単一stateのみ動作が可能。
stage構文の代わりに状態レジスタとany文で記述できないこともないけど、
管理が面倒なのでstage構文を使用する。
if(flag) pat_a();
else pat_b();
SFL にはifに対するelseが無かったので追加。
// サブモジュールの入出力宣言ファイルをinclude(相対パス)
%i "sub_cir.h"
// 文字列の置き換え
%d SIZE 16
#define SIZE 16
// 記述切り替え
#define SIM
#ifdef SIM
:
#else
:
#endif
#ifndef SIM
:
#endif
regs sig[32]<8>; // reg_wrとして展開
sels sp[32]<4>;
以下で説明する繰り返し構文にて使うための準備。
こちらは単純にレジスタ宣言の内部展開のみ行う。
regfile rf[32]<8>; // reg_wrとして展開
こちらはレジスタ宣言として内部展開されることに加え、
専用のアクセッサを埋め込むことでmemのように使用することが可能。
ただしmemと異なり個別のレジスタとして内部宣言されるため、
確実に分散メモリにマッピングされる(はず)。
その分ロジックを消費する。
// 並列動作
par(i=0;i<32;i++){
sig[i] := sig[i]<6:0> || 0b0;
}
// 条件付き動作
any(i=0;i<32;i++){
sp[i] : sig[i] := sig[i]<6:0> || 0b0;
else : sigm := 0b0000;
}
// 優先度付き条件動作
alt(i=0;i<32;i++){
sp[i] : sig[i] := sig[i]<6:0> || 0b0;
else : sigm := 0b0000;
}
sel x<4>;
join(i=0;i<4;i++){
x = a<i> || x;
}
// x = a<0> || a<1> || a<2> || a<3>; に展開
join(i=0;i<4;i++){
x = x || a<i>;
}
// x = a<3> || a<2> || a<1> || a<0>; に展開
switch(adrs){
case 0b00: aaa();
case 0b01: bbb();
default: ccc();
}
単一条件選択の時に便利。
if(a==0b0){
sel depth<4>; // ローカル宣言
any{
b==0b0 : depth = 4;
b==0b1 : depth = 8;
}
reg_wr data<4>; // ローカル宣言(レジスタなので値を保持する)
data := depth;
}
記述内のどこでも宣言できるので便利すぎる。
.SUFFIXES: .sflp .sfl .h .v
SFLP = sample.sflp
SFLS = $(SFLP:.sflp=.sfl)
HEAD = $(SFLP:.sflp=.h)
VLOG = $(SFLS:.sfl=.v)
sfl2vl:
make sfl
make vl
verilator --lint-only sample.v
sfl: $(SFLS)
vl: $(VLOG)
%.sfl: %.sflp
sflp $<
%.v: %.sfl
sfl2vl $< -O2
clean:
rm -f $(SFLP:.sflp=.sfl) $(SFLP:.sflp=.h) $(SFLS:.sfl=.v)
.PHONY: sfl2vl sfl vl clean
.sflp
から.sfl
と.h
を生成する。
未公開なんだ、すまない。
% sflp sample.sflp
ちょっと工夫したのは、この後段で sfl2vl で VerilogHDL に変換するとき、
sfl2vl でのエラー行数表示が元の SFL+ファイルと一致するようにした。
また、信号宣言生成等をできるだけ一行で行い、行数が膨れ上がらないようにした。
% sfl2vl sample.sfl -O2
-O2
オプションで、グレイコードを使用したり、
条件信号をシンプルにしたりなど、回路規模を考慮した最適化が入る。
sfl2vl のオプションとして、リセットやクロックの動作を選択できるオプションがある。 これらを設定することで、使用するFPGAや上位モジュールに合わせてリセットの極性を合わせたり、 クロック動作を設定したりすることが容易になっている。 以下オプション指定しなければ、非同期リセットHiイネーブル、クロック立ち上がり動作がデフォルトとなっている。
-neg_res
-sync_res
-neg_clk
circuit sample
{
input data<8>;
reg_wr sum<8>;
instrout run;
if(data<0>==0b0) sum += data;
if(sum==64) run();
}
このような VerilogHDL が生成される。
module sample ( p_reset , m_clock , data , run );
input p_reset, m_clock;
wire p_reset, m_clock;
input [7:0] data;
wire [7:0] data;
output run;
wire run;
reg [7:0] sum;
wire _net_0;
wire _net_1;
assign _net_0 = ((data[0])==1'b0);
assign _net_1 = (sum==8'b01000000);
assign run = _net_1;
always @(posedge m_clock or posedge p_reset)
begin
if (p_reset)
sum <= 8'b00000000;
else if ((_net_0))
sum <= (sum+data);
end
endmodule
デフォルトでは、リセット信号名はp_reset
、
クロック信号名はm_clock
となる。
ちなみに、par{ a=0b0; a=0b1; }
やpar{ b:=0b0; b:=0b1; }
のような、
代入がコンフリクトしている場合はエラーとなり VerilogHDL は生成されない。
ループ回路par{ a=b; b=a; }
については警告は出るが VerilogHDL は生成されるようだ。
r<3:0> = a; // NG
stage
は任意の場所に書けず、
通常の動作par
any
alt
や制御動作instruct
の後に書く必要がある。
つまり、.sflp の後ろ部分はステージ記述がまとまっている。
任意位置に記述できれば、さらに機能記述しやすいんだけど、ね。
任意位置記述をしようとするならば、 sflpの機能として SFL への変換時にステージ記述のみを後ろにまとめればいい。 sfl2vlでのエラー行番号をリマッピングする仕組みを入れればできそうではある。
if(cond_1){
out_1();
}
else if(cond_2){ // NG
out_2();
}
else{
out_3();
}
sflpを少し修正すれば使えるようになりそうだけど、
alt
で書いた方が分かりやすいかな。
a = cond ? 0x00 : 0xFF; // NG
単発の if else だけで済む場面は多いので、三項演算子が使えれば可読性が上がりそうな気はする。
あと、下位モジュールをVerilogHDLで作ってparameter指定可能としたものを、 SFL+からモジュール宣言時にパラメータ指定することはできるっぽい。 今はVerilogHDLの乗算モジュールを使う時に各種ビット幅固定のものを取り揃えているけど、 宣言時パラメタライズで書けば一つの可変ビット乗算モジュールを使いまわせそう。
reg val<4>;
if(val<3:2> == 0b00) // 通常はこう
if(val ==? 0b00??) // NG
SystemVerilogの機能だけど、使えると便利そう。
他のメジャーHDLは他の場面で使う機会はいくらでもあるけど、 せっかくだから誰も使ってない SFL でどこまでできるか試しているというのが理由としては大きい。
というか、ちゃんと大文字小文字を区別し、センシティビティリストが不要で、 begin end ではなく { 中カッコ } で並列動作を記述できるというだけでもう好き。
そもそも、SFL+ に慣れてるせいかもしれないけど、 同期式単相クロック回路としてどのような動作をするのかが明確であり、 クロック動作やビット幅についてもほどよい厳密さがあるため、 記述の仕方で迷うような場面がほぼ無い。 これはストレスフリーであり、以下のことにつながるように思う。
まぁ、コア記述の移植性は良いよね。 基本的なモジュール構成としては、 トップモジュールはメジャーHDLを使って、 周辺チップとのインタフェースやPLL等の接続を記述している。 このため、メイン機能はコア記述として SFL+ で記述するので、 異なるFPGAボードにフィッティングする場合は、 トップモジュールを差し替えればいい感じにしている。
(2024/05/11追記): また、VerilogHDLはregとwireを宣言しつつ、 ブロッキング代入とノンブロッキング代入で期待する動作を記述しなければならず混乱する (このあたりSystemVerilogではlogic宣言に統一されたことで、always_ffやalways_comb記述で動作が明確になった)。 これに対しSFLでは、regとselを宣言するがregに対してはクロック動作時代入のみ、 selに対しては常時代入のみ許可されているため、記述の仕方で混乱することがないという特徴がある。
seq
やstate
を導入することで、
よりシンプルにステートマシンを記述できるようになっている。
NSL が開発されたからこそ、nsl2vl や sfl2vl が開発され、SFL+ を作成することができた。
NSL開発者様にありがとうございますと言いながら sfl2vl を使っている
(もちろん SFL開発者様にも感謝を込めて)。
これからFPGAを始める方や、メジャーHDLでストレスを感じている方は、
はじめての NSL してみてはいかがだろうか。
reg
wire
宣言はできるが、スコープがローカルではなくグローバル。
state_name
宣言が面倒。また、他proc内でのstate_name
宣言と被ってはいけない。
wire data[8][4];
のような配列信号宣言ができない。
+=
-=
演算子がない。
input
を上位モジュールで参照できない(SFLは参照できる)。
func_out
をモジュール内で参照できない(SFLは参照できる)。
integer i;
をビット切り出しできない。
SFLを使う理由としてどこまでのものが実装可能なのかというのがあった。 それで8~32bitシステムを実装してみたわけだけど、まだまだポテンシャルはあるように見える。 ただ現状、上述したように様々な言語プリプロセス機能を追加して使っている以上、 それはSFLオリジンではないのかもしれない。 まぁ言語のパラダイム的根幹部分が保たれていれば問題は無いと考える。 逆に、SFL+がSFLオリジンとは異なるパラダイム言語となるためにはどのような機能を追加すればいいのか、 そういったことを考えていけば新しい沼に足を踏み入れられるのではないか。