HOME

@pgate1
投稿日 2021年12月22日 更新日 2024年10月19日

はじめてのSFL+

HDL Advent Calender 2021 22日目だよ。

本記事では、国産HDLであるSFL(Structured Function description Language)を、 個人的に使いやすく独自拡張した SFL+ について紹介する。

はじめに

個人的FPGA向けプロジェクトで SFL+ を使っているので、 SFL+ コードを見た人が混乱しないようにここに軽くまとめてみる。

ツールチェインは、SFL+ から 標準SFL への変換は自作のプリプロセッサ(sflp)を使用し、 SFL / NSL から VerilogHDL / VHDL / SystemC への変換には オーバートーン社で公開されている NSL Core 処理系 を使用させて頂いている。 生成された VerilogHDL をシミュレーションしたり、FPGAベンダの論理合成ツールに突っ込んだりする。

SFL

1980年代にNTT電気通信研究所で研究・開発されたハードウェア記述言語。 いわゆるDSL(Domain Specification Language)。 同期式単相クロック回路を前提としているため記述が簡潔で、 効率的に記述できることが特徴。 当時、SFLシミュレーションや論理合成環境として、PARTHENON(Parallel Architecture Refiner THEorized by Ntt Original coNcept)システムが提供されていた。 ウン千万円でナン十本売れたとか。 基本的な構文や動作の考え方等については、 PARTHENON HOME PAGE を参照されたし。

参考書籍『ULSIの効果的な設計法』1994年発行

SFLひいてはPARTHENONシステムを主体に置いた本。 大規模な論理回路の効率的な設計論と、 その実現可能性を判断すべく開発されたPARTHENONシステムおよび、SFLの記述方法について解説。 当時すでに論理回路の最適化は人手でやる規模を超えていて、 PARTHENONの実用性はこの人手を超えた最適化能力であることを強調している。 これは、集積回路製造部門に対するアーキテクチャ設計部門のお気持ちを、 システムを開発することで実に大掛かりなアンチテーゼとして表明した物語だ。

参考書籍『作りながら学ぶコンピュータアーキテクチャ』2001年発行

コンピュータアーキテクチャ初学者向けに、 ボトムアップでRISCプロセッサを丁寧に解説しながら実際にSFLで記述したらどうなるか、という本。 通称ヘネパタ本を授業で使うのは物理的にも心理的にも重いということもあって、 より授業であつかいやすくかつ実践的なアプローチをとったもの。 パイプライン処理の原理やキャッシュの性能評価、割り込み等についても解説されている。 本書は開発言語としてSFLを使用した緑本だけど、のちにVerilogHDLを使用した改訂版(黄本)が出版されている。

個人的見解

SFL の記述のしやすさを簡単なイメージで示すと下図ような感じだと思うけど、 おそらく公式の見解とはずれてそうなので、いちユーザである私の印象として説明する。
SFL は、ある機能動作の時に各レジスタや信号がどのようなふるまいをするか、 というところをベースに記述できるので可読性が良く、機能自体の追加やデバッグがしやすい。 一方RTL記述では、レジスタに対して各機能の動作を記述するため、 機能ごとの記述に局所性を持たせづらく可読性が落ちることがある。 ま、私のRTL記述の書き方の問題なのかもしれないけど。

レベルギャップを埋める 2024/05/11追記

例えばC言語で実装されたアルゴリズムをFPGAに実装しようとすると、 ビヘイビアレベルからRTLへ落とし込む作業で大きなレベルギャップが存在する。 例えば for や while のような繰り返し構文を、if goto に置き換える。 もしくは、複数クロックかかる演算を1クロックにまとめたり、 1行の演算を複数クロックに分けたりしてローレベルに落とし込む。 これらのノウハウは一部の書籍で明文化されていることもあるが、 初学者がこれを知らないとこのレベルギャップで躓いてしまう、 いわゆる順次実行から並列実行へのパラダイムシフトに対する適応が困難なのだ。 そのギャップを埋めるのが SFL のようなRTLよりも少し抽象度が高いDSLであり、 RTLよりも高い目線での実装ノウハウを体得できる。 SFL からRTLへの変換はツールが行ってくれるので、 レベルギャップを感じること無く実装できていたように思う。

このようなレベルギャップを埋めるための他のDSLとしてScalaベースの Chisel が有名だが、 静的型付け言語であるということと、 VerilogHDLへのトランスパイル時にトップモジュール以下のファイル群をトレースしてクラス間依存を確認するため、 いわゆるフルビルドのようなことを行っており変換処理に時間がかかるらしい。 それでも約27万行のデザインで変換時間が85secという情報もあり、極端に遅いわけではなさそう? インクリメンタルビルドできれば良いのかもしれないけど。

SFL+

標準の SFL そのままだと使いづらい部分を、個人的に SFL+ として独自拡張し可読性と利便性を向上させた。 SFL+ から自作のプリプロセッサ(sflp)を通して SFL に変換している。

SFL+ の構文については、SFL の構文がベースにあり、 SFL を VerilogHDL 等に変換するツールの機能として一部記述しやすくなっている部分と、 それに加え SFL+ 独自拡張の部分がある。 個別に説明しても混乱しかねないので、 現在 SFL+ として使用している形での説明としてまとめたつもり。

場合によっては SFL の言語設計思想に反するものもあるかもしれないけど、広い心で許してほしい。 もし怒られが発生した場合、この記事は消える。 しかし SFL+(プラス)って命名が雑過ぎませんかね…。

SFL+ の構文

SFLの記述はC言語同様に大文字小文字を区別し、ブロック単位は { 中カッコ } で区切られる。

記述の構造


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のように考えると、 制御端子はメンバ関数、 内部のレジスタ等はメンバ変数のように例えられる。

instroutoutput同様に回路内部で参照可能。

記述としてはinstruct write aaa();if(write) aaa();も同じ動作なので、 状況によって使い分けている。

条件内で制御端子をaaa();として記述すれば、 条件成立時にaaaは1となり、 条件が成立しない場合はその制御端子は0となる。 実はデータ端子も似たような動作をするが、 制御動作を明確に記述する場合はほぼ制御端子を使用する。

演算子

主に式中で使用する演算子は以下の通り。 他のHDLと異なるものがあるので注意。
演算子機能
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>式のビット切り出し例
演算子については SFL+拡張として C言語 や VerilogHDL に合わせる手もあったけど、 SFL へのリスペクトの意味もあり基本的にはそのままで使用している。

等号演算子


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 else


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 case


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;
}

記述内のどこでも宣言できるので便利すぎる。

処理系について

Cygwin にて make している。 WSL環境も試してみたが、Cygwinの方がファイルアクセスしやすい。 WSLでは、Linux内のホームディレクトリを使用せずに /mnt/c にホームディレクトリを作成&指定してやれば、 コマンド実行環境として使用する分には問題は無い。 ただ、sfl2vl のCygwinサポートも継続していただけると嬉しい。
Makefile
.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+ プリプロセッサのようなものとして作ったもので、独自拡張記述から標準のSFL記述に変換する。 sflpコマンドで.sflpから.sfl.hを生成する。 未公開なんだ、すまない。

% sflp sample.sflp

ちょっと工夫したのは、この後段で sfl2vl で VerilogHDL に変換するとき、 sfl2vl でのエラー行数表示が元の SFL+ファイルと一致するようにした。 また、信号宣言生成等をできるだけ一行で行い、行数が膨れ上がらないようにした。

sfl2vl コマンド


% sfl2vl sample.sfl -O2

-O2オプションで、グレイコードを使用したり、 条件信号をシンプルにしたりなど、回路規模を考慮した最適化が入る。

sfl2vl のオプションとして、リセットやクロックの動作を選択できるオプションがある。 これらを設定することで、使用するFPGAや上位モジュールに合わせてリセットの極性を合わせたり、 クロック動作を設定したりすることが容易になっている。 以下オプション指定しなければ、非同期リセットHiイネーブル、クロック立ち上がり動作がデフォルトとなっている。

sfl2vl で生成される VerilogHDL は非常にシンプルな記述のため、 Verilator でのリントチェックでも余計なWarningは出ない。 すごく相性が良い。

生成される VerilogHDL の例

このような SFL+ 記述を変換すると、
sample.sflp
circuit sample
{
    input data<8>;
    reg_wr sum<8>;
    instrout run;

    if(data<0>==0b0) sum += data;
    if(sum==64) run();
}

このような VerilogHDL が生成される。
sample.v
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 は生成されるようだ。

SFL+ にできないこと

標準の SFL 仕様に含まれておらず、SFL+ でもまだ実現できていない機能について。

なぜ SFL+ を使うのか?

人は初めて出会ったHDLを親だと思い込む習性がある。 親のHDLよりも書いたHDL、もっと親のHDL書いて。

他のメジャー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に対しては常時代入のみ許可されているため、記述の仕方で混乱することがないという特徴がある。

SFL+ を使用した個人的プロジェクト

SFL+ でコンシューマゲーム機の互換機能を作った。 .sflpコードはGitHubに置いてあるので参考までに。

NSL

NSL(Next Synthesis Language)IP ARCH, Inc.が開発し、 オーバートーン社によってサポートされている。 オーバートーン社のサイトでは、NSL サンプルや、NSL を VerilogHDL 等に変換する処理系として nsl2vl 等が提供されている。 噂によれば、NSL は、SFL に惚れこんだ開発者が独自拡張し作ったということらしい。 SFL の stage の代わりにseqstateを導入することで、 よりシンプルにステートマシンを記述できるようになっている。 NSL が開発されたからこそ、nsl2vl や sfl2vl が開発され、SFL+ を作成することができた。 NSL開発者様にありがとうございますと言いながら sfl2vl を使っている (もちろん SFL開発者様にも感謝を込めて)。 これからFPGAを始める方や、メジャーHDLでストレスを感じている方は、 はじめての NSL してみてはいかがだろうか。

個人的に感じたNSLのウィークポイント 2024/05/11追記

ただしNSLの仕様上、痒いところに手が届かない部分もあるわけで…。 なんかいろいろ言ってすみません…。

おわりに

SFL+ を読み解けるようまとめてみたけど、 説明が未熟なところもあるので、順次追記したい。 また、高位合成言語やAlternateHDL自作勢の参考になればうれしいし、 他のHDLの便利なところも取り込んでいければさらに幸せになりそう。 そしてもっと SFL+ を使って、いろんなものをFPGAに実装してみたい。

SFLを使う理由としてどこまでのものが実装可能なのかというのがあった。 それで8~32bitシステムを実装してみたわけだけど、まだまだポテンシャルはあるように見える。 ただ現状、上述したように様々な言語プリプロセス機能を追加して使っている以上、 それはSFLオリジンではないのかもしれない。 まぁ言語のパラダイム的根幹部分が保たれていれば問題は無いと考える。 逆に、SFL+がSFLオリジンとは異なるパラダイム言語となるためにはどのような機能を追加すればいいのか、 そういったことを考えていけば新しい沼に足を踏み入れられるのではないか。


©2021 pgate1