コンポーネントを書く
要件に合う既存のコンポーネントがない場合は、Rust で独自のコンポーネントを作成できます。コンポーネントは cargo でビルドされ、Veryl の組み込みシミュレータで駆動されます。テストベンチでの使い方は コンポーネントを使う を参照してください。
コンポーネントの作成
veryl new --component <path> はコンポーネントの雛形を作成し、実行した場所に応じて振る舞いを変えます。
既存の Veryl プロジェクトの中で実行すると、<path> に cargo パッケージを作成し、プロジェクトの Veryl.toml に登録するので、$comp::<name> が最初から解決できます:
$ veryl new --component my_checker
[INFO ] Created "my_checker" component
[INFO ] Registered $comp::my_checker in Veryl.toml
プロジェクトの外で実行すると、cargo パッケージであり Veryl プロジェクトでもある自己完結型のプロジェクトを作成します。すぐに veryl test でき、依存として publish もできます:
$ veryl new --component my_checker
[INFO ] Created "my_checker" component project
[INFO ] Run `veryl test` in my_checker to try it
my_checker/
├── Veryl.toml # クレートをコンポーネントとして登録
├── component/ # cargo パッケージ(crate-type = ["cdylib"])
│ ├── Cargo.toml
│ └── src/lib.rs
└── examples/
└── my_checker.veryl # 使用例。`veryl test` で実行される
コンポーネントの定義
パッケージは veryl-component クレートに依存する cargo クレートで、3 つのマクロで定義します。構造体に付ける #[derive(Component)] がインターフェースを、(トレイトを伴わない)impl ブロックに付ける #[component_impl] が振る舞いを宣言し、veryl_component_export! がコンポーネント名を付けて型をエクスポートします。
use veryl_component::*;
/// `req` から `LIMIT` サイクル以内に `ack` が続くことを検査します。
#[derive(Component)]
pub struct ReqAckChecker {
/// サンプリングクロック。
clk: ClockPort,
req: InputPort,
ack: InputPort,
/// リクエストからアクノリッジまでに許容するサイクル数。
#[param(name = "LIMIT")]
limit: u64,
waiting: u64,
}
#[component_impl]
impl ReqAckChecker {
fn on_clock(&mut self, ctx: &mut SimCtx) -> Result<()> {
if ctx.read(self.ack).as_bool() {
self.waiting = 0;
} else if ctx.read(self.req).as_bool() {
self.waiting += 1;
if self.waiting > self.limit {
ctx.fail(format!("no ack within {} cycles", self.limit));
}
}
Ok(())
}
}
veryl_component_export!("my_checker" => ReqAckChecker);
#[derive(Component)] は構造体のフィールドを次のように分類します。
InputPort/OutputPort型のフィールドはデータポートになります。その幅は接続された信号の幅に従います。幅に制約を課すコンポーネントはon_buildの中でport.width()を使って自分で検査します。ポート名は既定でフィールド名になり、#[port(name = "...")]で上書きできます。ClockPort/ResetPort型のフィールドはクロック・リセットポートになります(常に 1 ビット)。これらはclock/reset型の式で接続されなければならず、on_clock/on_resetフックを発火させるのはこれらのポートだけです。#[param]が付いたフィールドはエラボレーション時のパラメータになります。サポートされる型は整数、bool、String、Valueで、これらのOptionにするとパラメータは省略可能になります。#[param(name = "LIMIT")]は Veryl 側の名前を上書きします。- その他のフィールドは通常の状態変数で、
Defaultで初期化されます。
宣言されたすべてのポートはインスタンス化箇所で接続されている必要があり、接続漏れは解析時エラーになります。
ClockPort フィールドを持つコンポーネントは clocked コンポーネントで、inst でインスタンス化します。method_only コンポーネントは var で宣言します。任意の #[component(kind = ...)] 属性は種別を明示し、フィールドと一致していなければなりません。ClockPort フィールドのない clocked 宣言や、method_only コンポーネントのクロック/リセットポートはコンパイルエラーです。構造体・ポート・パラメータ・メソッドに付けた doc コメントは、veryl doc とエディタのホバーに表示されます。
#[component_impl] は、予約された関数名 on_build、on_init、on_reset、on_clock、on_finish をシミュレーションのフックに、それ以外のすべての関数をテストベンチメソッドにします。
veryl_component_export! はコンポーネント名を付けて型をエクスポートします。このマクロはライブラリの ABI エントリポイントを生成するため、呼び出すのは crate ごとに 1 回だけです(struct ごとではありません)。複数の型をエクスポートするライブラリは、その 1 回の呼び出しにすべてを列挙します。
veryl_component_export!(
"rv_iss" => RvIss,
"monitor" => Monitor,
);
インターフェースポート
インターフェースの modport 全体を接続するには、そのポート群を VerylInterface を derive した struct として一度だけ宣言し、コンポーネントに #[interface] フィールドとして埋め込みます。
#[derive(VerylInterface)]
#[interface(path = "$std::axi4_if", modport = "monitor")]
pub struct AxiMonitorPorts {
awvalid: InputPort, // メンバ `awvalid` に束縛される
awready: InputPort,
// ... 残りの modport メンバ ...
}
#[derive(Component)]
#[component(kind = clocked)]
pub struct AxiChecker {
clk: ClockPort,
rst: ResetPort,
#[interface]
axi: AxiMonitorPorts, // インターフェースポート `axi`: `axi: bus.monitor` と接続する
}
この struct はインターフェース(path)とその modport の1つ(modport)を指定し、各フィールドは同名のインターフェースメンバに束縛されて、コンポーネントからは階層的に読めます(self.axi.awvalid)。名前が Rust の予約語であるメンバは raw identifier(r#in: InputPort)と書きます。
各 #[interface] フィールドはコンポーネントの 1 つのインターフェースポートになり、その名前はフィールド名——テストベンチが接続する名前——です(axi: bus.monitor)。複数宣言することもできます(例えば上流の slave と下流の master を持つブリッジや、同じ struct を 2 回埋め込むデュアルバスのモニタ axi0, axi1)。クロック・リセット・通常のポートとも共存できます。
struct が宣言したメンバはすべて、接続されたインターフェースに存在しなければなりません(欠けていれば解析時エラー)。一方で、コンポーネントが観測するメンバだけを宣言するのは問題ありません。
名前の対応
コンポーネントの周辺にはいくつもの名前が登場しますが、契約に含まれるのは 1 つだけです。
| 名前 | 記述する場所 | 一致すべき相手 |
|---|---|---|
$comp::foo | Veryl ソース | export 名 veryl_component_export!("foo" => ...)。依存のコンポーネントは [dependencies] の名前 vip を前置した $comp::vip::foo |
Rust の型名(ReqAckChecker) | Rust ソース | なし — 契約になるのはエクスポート名の文字列だけ |
| cargo のクレート名 | コンポーネントの Cargo.toml | なし — 成果物は path = から特定される |
[[components]] エントリ
veryl new --component が [[components]] エントリをすでに追加しており、これによりライブラリが export するすべての名前が $comp::<name> として利用可能になります。手で Veryl.toml を編集するのは、別の方法で作成したパッケージを登録する場合や、コミット済みのプリビルドバイナリを指す場合だけです:
[[components]]
path = "my_checker"
# wasm = "prebuilt/my_checker.wasm" # 任意: コミットするビルド済みバイナリ
path— コンポーネントの cargo パッケージへのパス。Veryl.tomlのあるディレクトリからの相対パスwasm— コミットしておくビルド済み wasm バイナリ(オプション)。veryl publishで生成される
パッケージ自身のコンポーネントを動かすテストベンチは、examples ディレクトリ に置くのが最適です。パッケージ自身の veryl test では実行されますが、利用側で解析されることはありません。通常のソースに置かれたテストモジュールも、パッケージが依存として利用される際にはスキップされます。
フック
フックはすべてオプションで、引数として &mut self, ctx: &mut SimCtx(on_build では &mut BuildCtx)を取ります。
on_build— ポートとパラメータの解決後、インスタンスごとに1回呼ばれるon_init—initialブロックの実行前、時刻0に1回呼ばれる。出力への書き込みは初期値になるon_reset— 接続されたResetPortのリセットアサート時に呼ばれるon_clock— 接続されたClockPortの各エッジで呼ばれるon_finish— テスト終了時(正常終了またはfinish/fail経由)に呼ばれる
SimCtx は、フックとメソッドの中で利用できるホストのサービスを提供します。
ctx.read(port)/ctx.write(port, value)— 入力のエッジ直前の値を読む、出力へ書き込む(フリップフロップと同時にコミットされる)ctx.fail(msg)/ctx.finish()— テストを失敗としてマークする / 現在のサイクル終了時の正常終了を要求するctx.log(msg)— テストの出力にメッセージを書き出しますctx.cycle()/ctx.time()/ctx.clock()— サイクル数、シミュレーション時刻、起動したクロックポートctx.fired(port)— 指定したClockPortが今回のフックを発火させたかどうか。複数のクロックに接続されたコンポーネント向けctx.open(path)/ctx.create(path)/ctx.append(path)— ファイル I/O。返されるハンドルはstd::io::Read/Write/Seekを実装しているので、BufReaderやwriteln!などがそのまま使えますctx.trace(var, value)— 波形トレース変数を更新するctx.is_4state()— シミュレーションが4値(X/Z)を扱うかどうか
シミュレーションは既定で2値です。veryl test --4state(または [test].four_state = true)で4値になります。4値実行ではポート値が X/Z マスクを持ち、value.has_x()・value.has_z()・value.unknown_at(bit) で検査でき、出力書き込みもマスクを駆動します。X/Z チェックは ctx.is_4state() でガードしてください(2値実行では観測すべき X/Z がありません)。メソッドの引数と戻り値は常に2値です。
BuildCtx(on_build で利用可能)は追加で ctx.param(name)、ctx.input(name)、ctx.output(name)、ctx.clock(name)、ctx.reset(name)、ctx.seed()、および波形ダンプ用にコンポーネント内部の信号を登録する ctx.trace_var(name, width) を提供します。
ctx.seed() は、ベースとなるテストシードとインスタンスパスから計算される、インスタンスごとに決定的なシードを返します。乱数を用いるコンポーネントは、そのすべての乱数をこの値から導出してください。そうすれば、ベースシードを固定することで実行を正確に再現できます。ベースシードは既定で実行ごとにランダムで(veryl test が表示し、--seed で再現可能)、[test] セクションの seed フィールドで固定できます。
ctx のファイル I/O は可搬な手段です。std::fs を直接使うコンポーネントは native ライブラリとしてのみ動作し、ビルド済み wasm バイナリでは動作しません。
テストベンチメソッド
#[component_impl] ブロック内のフック以外の関数は、テストベンチから呼び出せるゼロ時間のメソッドになります。メソッドは &mut self, ctx: &mut SimCtx に続けて引数を取り、Result<T> を返します。
#[derive(Component)]
#[component(kind = method_only)]
pub struct Golden {
stored: u64,
}
#[component_impl]
impl Golden {
fn set(&mut self, _ctx: &mut SimCtx, value: u64) -> Result<()> {
self.stored = value;
Ok(())
}
fn get(&mut self, _ctx: &mut SimCtx) -> Result<u64> {
Ok(self.stored)
}
fn check(&mut self, ctx: &mut SimCtx, value: u64) -> Result<()> {
if value != self.stored {
ctx.fail(format!("expected {}, got {value}", self.stored));
}
Ok(())
}
}
サポートされる引数の型は &str、String、整数、bool、Value で、サポートされる戻り値の型は ()、整数、bool、Value です。
Value 引数は呼び出し側の式の幅をそのまま持つため、宣言は不要です。Value 返却値は #[ret_width(...)] で幅を宣言しなければなりません(整数の返却型は幅が自明です)。#[ret_width(...)] に指定する式には、整数・パラメータ名・インターフェースポートで修飾したインターフェース定数(axi.DATA_WIDTH_BYTES)・それらの算術式が使えます:
#[derive(Component)]
#[component(kind = method_only)]
pub struct WideModel {
#[param(name = "WIDTH")]
width: u64,
stored: Option<Value>,
}
#[component_impl]
impl WideModel {
fn put(&mut self, _ctx: &mut SimCtx, v: &Value) -> Result<()> {
self.stored = Some(v.clone());
Ok(())
}
#[ret_width(WIDTH)]
fn get(&mut self, _ctx: &mut SimCtx) -> Result<Value> {
Ok(self.stored.clone().unwrap())
}
}
インターフェースに束縛されたコンポーネントは、冗長なパラメータを設ける代わりに接続先のバス幅に追従できます。<port>.<NAME> は、そのポートが接続されたインターフェースから見える定数(インターフェース本体で宣言された定数、またはジェネリックパッケージ引数のメンバ)を参照します:
#[ret_width(axi.DATA_WIDTH_BYTES * 8)]
fn pop_read(&mut self, _ctx: &mut SimCtx) -> Result<Value> {
/* ... */
}
幅はインスタンスごとに解決されるので、同じコンポーネントが 32 ビットのバスでは 32 ビット、128 ビットのバスでは 128 ビットの値を返します。
MockSim によるユニットテスト
veryl_component::testing モジュールは、シミュレータホストのインプロセスな代替である MockSim を提供します。MockSim は同じ SimCtx/BuildCtx API を通じてコンポーネントを駆動するため、Veryl のシミュレータなしに、通常の cargo test でコンポーネントをユニットテストできます。
#[cfg(test)]
mod tests {
use super::*;
use veryl_component::testing::MockSim;
#[test]
fn fails_without_ack() {
let mut sim = MockSim::new()
.param("LIMIT", 2u64)
.input("clk", 1)
.input("req", 1)
.input("ack", 1);
let mut c = sim.build::<ReqAckChecker>().unwrap();
sim.set("req", 1u64);
for _ in 0..3 {
sim.clock(&mut c).unwrap();
}
assert!(sim.failed());
}
}
MockSim はビルダーメソッドでパラメータとポートを宣言し、build でコンポーネントを構築し、set で入力を、clock/reset/init/finish でフックを駆動し、call でメソッドを呼び出し、get、failed、failures、logs、finish_requested を通じて結果を公開します。
インターフェースマニフェストをコミットする
テストベンチの型検査には、コンポーネントのインターフェース(ポート・パラメータ・メソッド)が必要で、その動作は必要ありません。veryl publish はそのインターフェースを、コンポーネントの Cargo.toml と同じ場所にある veryl.manifest.json ファイルに記録し、コンポーネントのソースが変わると再生成します。ソースと一緒にコミットしてください。
マニフェストをコミットしておくと、利用者はコンポーネントをビルドせずに $comp::<name> の使用箇所を解析できます。特に Rust ツールチェインを持たない利用者にとっては、native コンポーネントの場合これが唯一のインターフェース源になります(ビルド済み wasm バイナリは同じインターフェースを埋め込んでいます)。
ビルド済み wasm を publish する
既定では、利用者はコンポーネントを cargo でソースからビルドします。ビルド済み wasm バイナリをコミットしておくと、Rust ツールチェインを持たない利用者でも使えます。[[components]] エントリに wasm = を宣言すると、パッケージはこの配布形態を選択します。
[[components]]
path = "my_checker"
wasm = "prebuilt/my_checker.wasm"
veryl publish はコンポーネントを wasm32-unknown-unknown ターゲット向けにビルドし、宣言されたパスにバイナリを書き出します。プリビルト wasm のビルドにはそのターゲットのインストールが必要で、rustup target add wasm32-unknown-unknown で追加します。プリビルトがソースと一致しなくなると、veryl publish が再生成し、veryl test は警告を報告します。
バックエンドの選択
テスト時にどちらの形式が使われるかは、次のように選択されます。
- cargo が利用可能な場合、コンポーネントはソースからビルドされます。
- そうでない場合、宣言されていればコミット済みのビルド済み wasm が使われます。
[test] セクションの component_backend フィールドで選択を固定できます。
[test]
component_backend = "wasm" # または "native"
wasm に固定するのは、publish する側が利用者の実行するビルド済みバイナリを検証する手段であり(既定則は cargo がある限り wasm を選びません)、信頼しないコンポーネントをサンドボックス内に留める手段でもあります。native に固定すると、cargo が見つからないときに黙って wasm へフォールバックせず、失敗します。
ネイティブと wasm の比較
API とシミュレーションの意味論は両バックエンドで同一で、違いはコンポーネントに何ができるか、そしてどう実行されるかにあります。
| ソースからビルド(ネイティブ) | ビルド済み wasm | |
|---|---|---|
| 利用者に必要なもの | Rust ツールチェイン | 不要 — コミット済みバイナリ |
| 使える API | 任意の Rust crate(ネットワーク、ネイティブライブラリ、GUI、…) | 計算とホストサービスのみ |
| 隔離 | サンドボックスなし | サンドボックスあり。256 MiB メモリ上限、ファイルアクセスは requires(file) でゲート、暴走したフックはトラップ |
| 速度 | ネイティブコード | フル ISS のような計算量の多いモデルでは遅い |
ホストの capability は #[component] 属性で宣言します。requires(file) はホスト経由のファイル入出力を許可し、requires(native) はコンポーネントをネイティブ専用にします。計算とホストサービスを超えるもの — 直接の std::fs、ネットワーク、ネイティブライブラリ、GUI — はコンポーネントをネイティブ専用にし、wasm として配布できなくなります。
制限事項
- メソッドの引数と戻り値は2値で、X/Z は失われます(ポート値は4値実行では X/Z を運びます)。
- メソッドの戻り値は ABI により512ビットに制限されます。
- インスタンス配列、およびコンポーネントポートへの unpacked 配列の接続はサポートされません。