💎

新しいHDL(ハードウェア記述言語)を考える

2022/12/15に公開約4,800字25件のコメント

はじめに

HDL (ハードウェア記述言語)は ASIC や FPGA を開発するための言語です。一般的には昔からある Verilog/VHDL/SystemVerilog がよく使われており、私自身は SystemVerilog を主に使っています。

最近はこれらを代替する言語として Chisel が RISC-V のエコシステムを中心にある程度使われるようになってきました。Chisel については SystemVerilog を置き換えられる言語になるのではないかと期待していた部分もあったのですが、現時点で ASIC 開発用言語として使用できる状態には至っていないと思っています。

https://qiita.com/dalance/items/43175bfd61c0754ecb1c

というわけで当面は SystemVerilog を使い続けるしかない状況ですが、そうはいってもいろいろと不便な言語ではあるのでもう少し何とかしたいところです。
ちょうど parol というパーサジェネレータの記事を読んで、言語実装してみたい気分になったので、新しい HDL を考えてみることにしました。実装言語は Rust になります。

https://zenn.dev/ryo33/articles/26f87f776b4bfa

簡素な HDL 専用構文

まず構文についてです。SystemVerilog の構文は規格書の BNF が46ページもあり、これを全て
Rust 実装した sv-parser は45,000行という大規模なものになっています。

https://github.com/dalance/sv-parser

これほど大きな構文になっているのは、通常のRTL記述で使うような記述だけでなく、ゲート記述や SystemVerilogAssertion などいろいろなものを全てカバーしているためです。
構文には曖昧な部分がいくつもあり、本格的な処理系を実装するためには規格に従ったパーサを実装するだけでは足りません。意味解析を通して曖昧性を解決する必要があるため、実装のハードルは非常に高いです。(実際に商用の EDA ツールでさえ規格に従った記述を通せないことが少なくありません)

一方、既存の代替言語の多くはプログラミング言語の内部 DSL として実装されています。例えば

  • Chisel: Scala
  • MyHDL: Python

という具合です。
この方式は代替言語のパーサ実装をせずに済むだけでなく、各種エディタ用のシンタックスハイライトやフォーマッタ、リンタなどをそのまま再利用できるというメリットがあります。

しかし、内部 DSL であるということはベース言語の構文に制約されることになります。ベース言語はプログラミング言語なので、どうしても HDL として表現したいことが表現しにくい場面が多く出てきます。具体的には

  • ビット幅表現
  • クロックの扱い
  • 信号の方向

といったあたりで、これらは HDL としては極めて基本的な要素であるにもかかわらず、既存のプログラミング言語では簡潔に表現できません。

そこで新言語は SystemVerilog の構文を簡素化した独自言語にすることを考えています。ただし構文要素の取捨選択においては SystemVerilog からの引継ぎや利便性よりパーサの実装負荷を重視します。

例えば実装が重い要素の1つにプリプロセッサがあります。単に ifdefdefine を展開するだけならその名の通り事前にプリプロセッサで処理してしまえばいいのですが、Language Serverなどで適切な位置にエラーを表示したいなどと考えると途端に困難になります。

また、SystemVerilog では互換性維持のための古い書き方と新しい書き方が混在したり、ツールによってはトラブルの起きやすい非推奨な書き方をしてしまうことがありました。簡素化によって記法を統一し、非推奨記述をそもそも記述できないようにすることは、パーサだけでなくユーザにもメリットがあるのではないかと思います。

SystemVerilog へのトランスパイラ

多くの代替言語は Verilog を出力するようになっています。これにより商用含めた既存の EDA ツールとインターフェースすることができますし、 OSS のツールでは Verilog しかサポートされていないことも多いのでやむを得ない選択ではあります。

しかしせっかく代替言語において複雑なデータ構造を表現できるようにしても、 Verilog に変換した時点でバラバラの変数に展開されてしまうため、その後のデバッグなどが困難になります。
また Chisel などで顕著ですが、代替言語のセマンティクスと Verilog のセマンティクスが違いすぎると、生成されたコードと元コードの対応を取ることが困難になってきます。

そのため新言語は SystemVerilog へのトランスパイラとして開発します。構文を SystemVerilog ベースにすることである程度自然に達成できそうですし、生成されるソースコードの可読性も高くできるのではないかと思います。

トランスパイル時チェック

問題のある記述はできるだけ構文レベルで排除するようにしたいですが、どうしても意味解析後にしか分からないようなチェック項目もあります。例えば以下のようなものです。

  • 合成不能記述(ex. always_comb でのラッチ生成)
  • 未使用変数
  • case網羅
  • ビット幅不一致

これらを後段のツール(例えば合成ツールなど)に渡すことなく、ソースコード編集中にリアルタイムにチェックできるようにしたいです。

現代的な開発ツール

現代的なプログラミング言語では、リンタやフォーマッタ、 Language Server があるのが当たり前になっています。SystemVerilog 向けの OSS もいくつかありますが、先に述べたような実装の困難さもあり、完全なものが揃っているという状態ではありません。

新言語においてはこれらの開発ツールが最初から揃っている状態を目指します。
ソースコードの再利用をし易くするためのパッケージマネージャ的な機能も欲しいところです。

ソースコード(案)

今のところ以下のようなソースコードをイメージしています。基本的には SystemVerilog から予約語と言語機能を、構文要素の多くは Rust から引き継いでいます。

module ModuleA #(
    // 整数型は u32/u64/i32/i64/f32/f64
    parameter  ParamA: u32 = 10           ,
    localparam ParamB: u64 = 64'hffff_ffff,
    localparam ParamC: i32 = 10           , // 末尾カンマ対応
) (
    clk   : input  logic                 , // 型は後置
    port_a: input  logic                 ,
    port_b: output logic                 ,
    port_c: inout  logic [ParamA][ParamC],
    if_a  : InterfaceA.master            , // インターフェース
    // ifdef 相当
    // プリプロセッサではなくアノテーションで表現する
    #[if DefineA]
    port_d: output logic [ParamA],
) {
    // 型のビット幅は範囲ではなく幅で指定
    // SystemVerilogのように範囲指定にすると [1:10] や [9:0]
    // のようにバリエーションが生まれるので
    a: logic     ;
    b: logic [A] ;
    c: logic [20];

    assign a = 1;
    // 宣言と同時に assign
    assign d: logic[32] = 1;

    // ユーザ定義型
    enum EnumA {
        A,
        B,
    }
    struct StructA {
        a: logic    ,
        b: logic[10],
    }
    x: EnumA  ;
    y: StructA;

    always_ff (posedge clk) {
        // if の () なし
        if port_a == 1 {
            // 代入演算子は blocking / non-blocking の区別なし
            // always_ff / always_comb で区別する
            b = port_c[0][5];
        }
    }

    always_comb {
        c = b[2:0] + 10;
        x = EnumA::A;
        y.a = 1;
        y.b = 20;
    }

    // function/task のパラメータオーバーライド対応
    // static/automatic の区別なし。全て automatic 相当
    function FuncA #(
        parameter ParamX: u32 = 1,
    ) -> logic[ParamX] (
        a: input  logic[ParamX],
        b: output logic[ParamX],
        c: ref    logic[ParamX],
    ) {
        b = a + 1;
        c = a / 10;
        return a + 2;
    }

    // module のインスタンスとパラメータオーバーライド
    ModuleB #(
        ParamA, // 同名のパラメータ接続
        ParamB: 100,
    ) b (
        clk, // 同名のポート接続
        port_b: port_a,
        port_c: port_c,
    )
}

今後の予定

とりあえず parol を数日触って感触は分かってきたので、冬休みを使ってある程度動くものを作ってみる予定です。もし要望などあれば言っていただければ検討します。

Discussion

クロックのエッジ、リセットの仕様 (async or sync, high active or low active) は、ほぼ全体で共通でしょうから、always_ff には、使うクロックとリセットだけを指定して、生成時に極性など仕様を指定するのはどうでしょうか?
そして、リセットの極性を隠匿しているので、if (!i_rst_n) とか if (i_rst) の相当の糖衣構文として、if_reset も導入します。

// リセットあり
always_ff (i_clk, i_rst_n)
  if_reset {

  }
  elsif {

  }

// リセット無し
always_ff (i_clk)
  if {

  }
  else {

  }

エッジとか極性とかを明示的に指定したいのであれば、以下の様にするのはどうでしょうか?

always_ff (posedge i_clk, async_low i_rst_n)

always_ff (i_clk, sync_high i_rst)

一層のこと、ポート宣言時にクロック、リセットであることを指定できるようにして、always_ff でクロック、リセットの指定がなければ、ポート宣言から推定するのも良いかもですね。

SV では、幅などをパラメータ化した型の取り回しが面倒なので、そこも解決したいですね。
なので、パラメータを受けられる package みたいなのがあると嬉しいです。

if とか case などの制御構文を式にすると、以下のような書きかができるので、うれしいですね。

foo =
  if {
    
  }
  else {

  }

いろいろとありがとうございます。
always_ffは確かにもっとFF特化でいいですね。
センシティビティリストにたくさん書けるのはVerilogの名残でしょうし。
packageはSV側に直接対応する機能がないのでちょっと大変かもしれません。
if式は便利そうです。

エッジとか極性とかを明示的に指定したいのであれば、以下の様にするのはどうでしょうか?

エッジとか極性は、クロックとかリセットの信号に付くべき属性で、always_ff 上で指定するべき属性ではないですね。

FF毎に極性が違うというのは一般的ではないにしても回路として構成可能でおそらく合成も通るので、それを排除してしまうのはちょっと微妙かもしれません。
ASICで特殊な回路を作りたくなった時にそこだけVerilogで書かざるを得なくなってしまうというのは残念な気持ちになるので…。
属性指定がモジュール単位ならモジュールで切れば対応は可能そうですが、ちょっと悩ましいですね。

いずれにしろ、always_ff @(posedge i_clk, negedge i_rst_n) を毎回書くのは、(エディタの補助があるとはいえ)面倒なので、略記法は欲しいですね。

そうですね。
そういう意味では最初の案のように「デフォルトの極性が自動適用されて、それを明示的に上書きも可能」というくらいが落としどころかもしれません。

そうですね。
あれぐらいなら、Verilog ユーザーにも受け入れやすそうですし。

配列の走査も強化してほしいですね。
Ruby 使いとしては、map/reduce/any?/all?/none? あたりがあると嬉しいです。
ただ、これに関しては、chisel の二の舞になりそうではありますが。

chisel の二の舞になりそうではありますが

対象の配列とループ中で参照した変数を入力とするfunctionに変換したら、読みにくさは軽減できるか?

確かにあまりリッチな機能を入れると生成コードがカオスになりますよね…
まぁ後から追加しても互換性的に問題ない部分なので、いい解決策があったらという感じでしょうか

ビット選択とかスライスの際のインデックスの指定に関してですが、負数に対応していると嬉しいですね。
負のときはMSBからの位置を表していて、-1はMSB、-2はMSB-1という具合です。

これはいいですね
MSBは結構よく指定する割に毎回長いパラメータ名を書くことになるので

良さそうに思ったのですが、今回のはインタプリタではないのでパラメータを含んだ式の評価値が負になるかどうかをトランスパイル時点では判定不能ですね…。
SV側で判定させるような三項演算子の式を埋め込むのは可能ですが可読性が厳しくなりますし。

ちょっと見栄えはいまいちかもしれませんが、 予約語 msb にするとかでしょうか。

a = a[msb:8]; // 8bit shift

msb は使いそうな単語ですので、SVに倣って$とか記号を割り振るのはどうでしょうか?

確かにqueueの末尾は $ なんですが、MSBが末尾かと言われるとちょっと微妙な気もします。
あと正規表現なんかの行末 $ に見慣れているせいか、行頭側に $ があるのは結構違和感あるかもしれません。

a = a[$:8];

折衷案で$msbはどうでしょうか?

$msb のような場合は $ に特別な意味があるように見えてしまうのであまりよくない気がしていて、記号か予約語の2択かな、と思います。

記号の場合は2文字以上もいけるのでもう少しいい感じの記号を探すのはありかもしれません。

予約語の方ですが、変数名としての msb が潰れてもそれほど困らないのでは?という気もしてきました。使いそうなのは以下のようなパターンと思います。

parameter msb = width - 1;

logic msb;
assign msb = signal[width-1];

前者はまさに予約語 msb そのものなので使えなくなっても問題なく、後者はわざわざlogicにいれなくても signal[msb] と書けるので十分ではないかと。

確かに、msb を予約語にしても問題なさそうですね。
となると、対称的に、(あまり意味はないですが) lsb も予約語に欲しいところです。

よく見るとインスタンス化だけ前置型ですね。
他に揃えて後置にすると以下のような感じでしょうか。
セミコロン終端しないなら最後は{}にしたほうが他との整合性が取れそうです。

b: ModuleB #(
    ParamA, // 同名のパラメータ接続
    ParamB: 100,
) {
    clk, // 同名のポート接続
    port_b: port_a,
    port_c: port_c,
}

末尾のセミコロンの省略は、パーサーの実装的には、難しいのでしょうか?

簡単なのはセミコロンがある方だと思います。パース時も空白と改行をまとめてスキップしてしまえますし、フォーマッタも文の意味が変わってしまう可能性なく自由に改行できるのでやりやすいです。

Rubyのように改行を区切りとして行継続に特別な記号を使う方式なら、パーサ自体はそこまで複雑にはならないと思いますが、JSやGoのようにいい感じのルールで区切りを推測するみたいなのだと厳しいですね。

個人的にセミコロン省略は変なコーナーケースができて書く側も混乱することが多い気がしていて、実装負荷もありますが、あまりやりたくはないですね。

ログインするとコメントできます