第2章
ハードウェア記述言語 Veryl
CPU (Central Proccessing Unit, 中央演算処理装置)は、コンピュータを構成する主要な部品の1つであり、電気で動くとても複雑な回路で構成されています。
本書では「ハードウェア記述言語」によってCPUをの回路を記述します。回路を記述するといっても、いったい何をどうやって記述するのでしょうか?
まずは、論理回路を構成する方法から考えます。
2.1 ハードウェア記述言語
2.1.1 論理回路の構成
論理回路とは、デジタル(例えば0と1だけ)なデータを利用して、データを加工、保持する回路のことです。論理回路は、組み合わせ回路と順序回路に分類できます。
組み合わせ回路とは、入力に対して、一意に出力の決まる回路[2]のことです。例えば、1ビット同士の加算をする回路は図2.1、表2.1のように表されます。この回路は半加算器と呼ばれていて、1ビットのXとYを入力として受けとり、1ビットの和Sと桁上げCを出力します。入力(X、Y)が決まると出力(C、S)が一意に決まるため、半加算器は組み合わせ回路です。
X | Y | C | S |
---|---|---|---|
0 | 0 | 0 | 0 |
0 | 1 | 0 | 1 |
1 | 0 | 0 | 1 |
1 | 1 | 1 | 0 |
順序回路とは、入力と回路自身の状態によって一意に出力の決まる回路[2]です。例えば、入力が1になるたびにカウントアップして値を表示するカウンタを考えます(図2.2)。カウントアップするためには、今のカウンタの値(状態)を保持する必要があります。そのため、このカウンタは入力と状態によって一意に出力の決まる順序回路です。
1ビットの値はフリップフロップ(flip-flop, FF)という回路によって保持できます。フリップフロップをN個並列に並べると、Nビットの値を保持できます。フリップフロップを並列に並べた記憶装置のことをレジスタ(register, 置数器)と呼びます。基本的に、レジスタの値はリセット信号(reset signal, reset)によって初期化し、クロック信号(clock signal, clock)に同期したタイミングで変更します。
論理回路を設計するには、真理値表を作成し、それを実現する論理演算を構成します。入力数や状態数が数十個ならどうにか人力で設計できるかもしれませんが、数千、数万の入力や状態があるとき、手作業で設計するのはほとんど不可能です。これを設計するために、ハードウェア記述言語を利用します。
2.1.2 ハードウェア記述言語
ハードウェア記述言語(Hardware Description Language, HDL)とは、デジタル回路を設計するための言語です。例えばHDLであるSystemVerilogを利用すると、半加算器はリスト2.1のように記述できます。
半加算器(HalfAdder)モジュールは、入力としてxとyを受け取り、出力cとsにxとyを使った演算を割り当てています。
また、レジスタを利用した回路をリスト2.2のように記述できます。レジスタの値を、リセット信号rst
が0
になったタイミングで0
に初期化し、クロック信号clk
が1
になったタイミングでカウントアップします。
HDLを使用した論理回路の設計は、レジスタの値と入力値を使った組み合わせ回路と、その結果をレジスタに格納する操作の記述によって行えます。このような、レジスタからレジスタに、組み合わせ回路を通したデータを転送する抽象度のことをレジスタ転送レベル(Register Transfer Level, RTL)と呼びます。
HDLで記述されたRTLを実際の回路のデータに変換することを合成と呼びます。合成するソフトウェアのことを合成系と呼びます。
2.1.3 Veryl
メジャーなHDLといえば、Verilog HDL、SystemVerilog, VHDLなどが挙げられます。
Verilog HDL(Verilog)とVHDLは1980年代に開発された言語であり、最近のプログラミング言語と比べると機能が少なく、冗長な記述が必要です。SystemVerilogはVerilogのスーパーセットです。言語機能が増えて便利になっていますが、スーパーセットであることから、あまり推奨されない古い書き方が可能だったり、バグの原因となるような良くない仕様*1を受け継いでいます。
[*1] 例えば、未定義の変数が1ビット幅の信号線として解釈される仕様があります
本書では、CPUの実装にVerylというHDLを使用します。Verylは2022年12月に公開された言語です。Verylの抽象度は、Verilogと同じくレジスタ転送レベルです。Verylの文法や機能は、VerilogやSystemVerilogに似通ったものになっています。しかし、if式やcase式、クロックとリセットの抽象化、ジェネリクスなどの痒い所に手が届く機能が提供されており、高い生産性を発揮します。
Verylのソースコードはコンパイラ(トランスパイラ)によって、自然で読みやすいSystemVerilogのソースコードに変換されます。そのため、Verylは旧来のSystemVerilogの環境と共存でき、SystemVerilogの資産を利用できます。
本書は2024/11/3時点のVeryl(バージョン0.13.2)を、本書で利用する範囲の文法と機能を解説しています。Verylはまだ開発中(安定版がリリースされていない)状態の言語であるため、破壊的変更が入り、記載しているコードが使えなくなる可能性があります。
2.2 Verylの基本文法、機能
それでは、Verylの書き方を学んでいきましょう。Verylのドキュメントはhttps://doc.veryl-lang.org/book/ja/に存在します。また、Veryl Playgroundでは、VerylのSystemVerilogへのトランスパイルをウェブブラウザ上でお試しできます。
2.2.1 コメント
Verylでは次のようにコメントを記述できます(リスト2.3)。
2.2.2 値、リテラル
論理回路では、デジタルな値を扱います。デジタルな値は0
と1
の二値(2-state)で表現されますが、一般的なハードウェア記述言語では、0
と1
にx
とz
を加えた四値(4-state)が利用されます(表2.2)。
値 | 意味 | 真偽 |
---|---|---|
0 | 0 | 偽 |
1 | 1 | 真 |
x | 不定値 | 偽 |
z | ハイインピーダンス | 偽 |
不定値(unknown value, x
)とは、0
か1
のどちらか分からない値です。不定値は、未初期化のレジスタの値の表現に利用されたり、不定値との演算の結果として生成されます。ハイインピーダンス(high-inpedance, z
)とは、どのレジスタや信号とも接続されていないことを表す値です。物理的なハードウェア上では、全ての値は0
か1
の二値として解釈されますが、信号の状態としてハイインピーダンスを持ちます。不定値はシミュレーションのときに利用します。
1ビットの四値を表現するための型はlogicです。Nビットのlogic型はlogic<N>
と記述できます。1ビットの二値を表現する型はbitです。基本的に、レジスタや信号の定義にbit型は利用せず、logic型を利用します。
logic型とbit型は、デフォルトで符号が無い型として扱われます。符号付き型として扱いたいときは、型名の前にsignedキーワードを追加します(リスト2.4)。
32ビットと64ビットのbit型を表す型が定義されています(表2.3)。
型名 | 等価な型 |
---|---|
u32 | bit<32> |
u64 | bit<64> |
i32 | signed bit<32> |
i64 | signed bit<64> |
数値はリスト2.5のように記述できます。
文字列はstring型で表現できます。文字列の値はリスト2.6のように記述できます。
2.2.3 module
論理回路はモジュール(Module)というコンポーネントで構成されます。例えば、半加算器のモジュールは次のように定義できます(リスト2.7).
HalfAdderモジュールには、入力変数としてx
とy
、出力変数としてs
とc
が宣言されています。入出力の変数のことを接続ポート、または単にポートと呼びます。
入力ポートを定義するとき、モジュール名の後の括弧の中に、変数名 : input 型名
と記述します。出力ポートを宣言するときはinput
の代わりにoutput
と記述します。複数のポートを宣言するとき、宣言の末尾にカンマ(,
)を記述します。
変数のブロッキング代入
HalfAdderモジュールでは、always_combブロックの中で出力変数s
とc
に値を代入しています。変数への代入は変数名 = 式;
で行います。always_combブロック内での代入のことを、ブロッキング代入(blocking assignment)と呼びます。
通常のプログラミング言語での代入とは、スタック領域やレジスタに存在する変数に値を格納することです。これに対してalways_combブロック内での代入は、式が評価(計算)された値が変数に1度だけ代入されるのではなく、変数の値は常に式の計算結果になります。
具体例で考えます。例えばalways_combブロックの中で、1ビットの変数x
に1ビットの変数y
を代入します(リスト2.8)。
y
の値が時間経過により0
→1
→0
→1
→0
と変化したとします。このとき、x
の値はy
が変わるのと同時に変化します(図2.3)。図2.3は、時間を横軸、x
とy
の値を線の高低で表しています。図2.3のような図を波形図(waveform)、または単に波形と呼びます。
x
にy
ではなくa + b
を代入すると、a
かb
の変化をトリガーにx
の値が変化します。
always_combブロックには複数の代入文を記述できます。このとき、代入文は上から順番に実行(逐次実行)されます。
例えばリスト2.9では、a
にはX
が代入されますが、b
にはY
が代入されます。変数a
とb
とs
は、変数X
かY
の変化をトリガーに値が更新されます。
1つの変数にしかブロッキング代入しないとき、assign文でもブロッキング代入できます(リスト2.10)。
always_combブロック内での代入と同じように、リスト2.10ではb
の変化をトリガーにa
の値が変化します。
ブロッキング代入は論理回路の状態(レジスタ)を変更しません。そのため、ブロッキング代入文は組み合わせ回路になります。
変数の宣言
モジュールの中では、var文によって新しく変数を宣言できます(リスト2.11)。
var文で宣言した変数に対してブロッキング代入できます。
let文を使うと、変数の宣言とブロッキング代入を同時に行えます(リスト2.12)。
レジスタの定義と代入
変数を宣言するとき、変数に式がブロッキング代入されない場合、変数はレジスタとして解釈できます(リスト2.13)。
本書ではレジスタのことを変数、または変数のことをレジスタと呼ぶことがあります。
レジスタの値はクロック信号に同期したタイミングで変更し、リセット信号に同期したタイミングで初期化します(図2.4)。本書では、クロック信号が立ち上がる(0
から1
に変わる)タイミングでレジスタの値を変更し、リセット信号が立ち下がる(1
から0
に変わる)タイミングでレジスタの値を初期化することとします。
レジスタの値は、always_ffブロックで初期化、変更します(リスト2.14)。always_ffブロックには、値の変更タイミングのためのクロック信号とリセット信号を指定します。
if_reset文の中の文は、リセット信号のタイミングで実行されます。if_reset文にelse文を付けることで、クロック信号のタイミングで処理を実行できます。レジスタの値をリセットしない場合、リセット信号とif_reset文を省略できます。逆に、リセット信号を指定する場合は必ずif_reset文を書かなければいけません。
クロック信号はclock型、リセット信号はreset型で定義します。モジュールのポートに1組のクロック信号とリセット信号が定義されているとき、always_ffブロックのクロック信号とリセット信号の指定を省略できます(リスト2.15)。
レジスタの値は、同じタイミングで動くalways_ffブロックの中の全ての代入文の右辺を評価した後に変更されます(リスト2.16)。この代入はブロッキング代入と違って逐次実行されないので、ノンブロッキング代入(non-blocking assignment)と呼びます。
2つ以上のalways_ffブロックで、1つの同じレジスタの値を変更することはできません。
リスト2.16のA
とB
の代入文は、1つのalways_ffブロックにまとめて記述できます(リスト2.17)。この場合もリスト2.16と同様に、A
とB
の代入文の右辺を評価した後に、レジスタの値が変更されます。
本書ではブロッキング代入とノンブロッキング代入を区別せず、どちらも代入と呼ぶことがあります。
変数への代入方法と動作を表2.4にまとめます。大変間違えやすいため、気を付けてください。
代入場所 | 代入文の名称 | 更新タイミング |
---|---|---|
always_comb | ブロッキング代入 | ブロック内の式で参照されている変数が更新されたとき。 上から順に実行される。 |
always_ff | ノンブロッキング代入 | クロック信号、リセット信号のタイミング。 同じタイミングで実行される全ての代入文の右辺を評価した後 にレジスタの値が変更される。 |
モジュールのインスタンス化
あるモジュールを利用したいとき、モジュールをインスタンス化(instantiate)することにより、モジュールの実体を宣言できます。
モジュールは、instキーワードによってインスタンス化できます(リスト2.18)。
インスタンス名が違えば、同一のモジュールを2つ以上インスタンス化できます。
パラメータ、定数
モジュールには、インスタンス化するときに変更可能な定数(パラメータ)を用意できます。
モジュールのパラメータは、ポート宣言の前の#()
の中でparamキーワードによって宣言できます(リスト2.19)。
モジュールをインスタンス化するとき、ポートの割り当てと同じようにパラメータの値を割り当てられます(リスト2.20)。
パラメータに指定する値は、合成時に確定する値(定数)である必要があります。
モジュール内では、変更不可能なパラメータ(定数)を定義できます。定数を定義するにはconstキーワードを使用します(リスト2.21)。
2.2.4 ユーザー定義型
構造体型
構造体(struct)とは、複数のデータから構成される型です。例えば、リスト2.22のように記述すると、logic<32>
とlogic<16>
の2つのデータから構成される型を定義できます。
構造体の要素(フィールド, field)には.
を介してアクセスできます(リスト2.23)。
列挙型
複数の値の候補から値を選択できる型を作りたいとき、列挙型(enumerable type)を利用できます。列挙型の値の候補のことをバリアント(variant)と呼びます。
例えば、A、B、C、Dのいずれかのバリアントをとる型は次のように定義できます(リスト2.24)。
enum型の値は型名::バリアント名
で利用できます(リスト2.25)。
バリアントを表す値や、バリアントを保持できるだけのビット数は省略できます(リスト2.26)。
配列
<>
を使用することで、多次元の型を定義できます(リスト2.27)。<>
を使用して構成される型の要素は、連続した領域に並ぶことが保証されます(図2.5)。
[]
を使用することでも、多次元の型を定義できます(リスト2.28)。ただし、[]
を使用して構成される型の要素は、連続した領域に並ぶことが保証されません。
型に別名をつける
typeキーワードを使うと、型に別名を付けられます(リスト2.29)。
2.2.5 式、文、宣言
ビット選択
変数の任意のビットを切り出すには[]
を使用します(図2.6)。範囲の選択には[:]
を使用します。最上位ビット(most significant bit, MSB)はmsbキーワード、最下位ビット(least significant bit, LSB)はlsbキーワードで指定できます。選択する場所の指定には式を使えます。
よく使われる範囲の選択には、別の書き方が用意されています(リスト2.30)。
演算子
Verylでは、表2.5の演算子を使用できます。ほとんどの演算子と優先度は通常のプログラミング言語と同じですが、ビット演算の種類が多かったり、x
とz
を考慮した演算があるなどの違いがあります。
SystemVerilogとの差異を説明すると、++
、--
、:=
、:/
、<=
(代入)、?:
(三項演算子)が無く、<
と>
がそれぞれ<:
と>:
に変更されています。また、inside
と{{}}
の形式が変更され、if式、case式、switch式が追加されています。
単項、二項演算子の使用例は次の通りです(リスト2.31)。
演算子 | 結合性 | 優先順位 |
---|---|---|
() [] :: . | 左 | 高い |
+ - ! ~ & ~& | ~| ^ ~^ ^~ (単項) | 左 | |
** | 左 | |
* / % | 左 | |
+ - (二項) | 左 | |
<< >> <<< >>> | 左 | |
<: <= >: >= | 左 | |
== != === !== ==? !=? | 左 | |
& (二項) | 左 | |
^ ~^ ^~ (二項) | 左 | |
| (二項) | 左 | |
&& | 左 | |
|| | 左 | |
= += -= *= /= %= &= ^= |= <<= >>= <<<= >>>= | なし | |
{} inside outside if case switch | なし | 低い |
if、switch、case
条件によって動作や値を変えたいとき、if文を使用します (リスト2.32)。if文は式にできます。if式は必ず値を返す必要があり、elseが必須です。
always_combブロック内で変数に代入するとき、if文の全ての場合で代入する必要があることに注意してください(v
は常に代入されています)。
リスト2.32と同じ意味の文をswitch文で書けます(リスト2.33)。どの条件にも当てはまらないときの動作はdefaultで指定します。switchは式にできます。switch式は必ず値を返す必要があり、defaultが必須です。
リスト2.32のように1つの要素(WIDTH
)の一致のみが条件のとき、同じ意味の文をcase文で書けます(リスト2.34)。式にできたり、式にdefaultが必須なのはswitch文と同様です。
連結、repeat
ビット列や文字列を連結したいとき、{}
を使用できます(リスト2.35)。+
では連結できない(値の足し算になる)ことに注意してください。同じビット列、文字列を繰り返して連結したいときはrepeatキーワードを使用します(リスト2.36)。
for
for文はループを実現するための文です。for文はリスト2.37のように記述できます。例えばループ変数が0から31になるまで(32回)繰り返すなら、範囲に0..32
、または0..=31
と記述します。範囲には定数のみ指定できます。
break文を使うとループから抜け出せます。例えばリスト2.38ではx
の値は256になります。
inside、outside
値がある範囲に含まれているかという条件を記述したいとき、inside式を利用できます。inside 式 {範囲}
で、式の結果が範囲内にあるかという条件を記述できます(リスト2.39)。逆に、範囲外にあるという条件はoutside式で記述できます。
function
何度も記述する操作や計算は、関数(function)を使うことでまとめて記述できます(リスト2.40)。関数は値を引数で受け取り、return文で値を返します。値を返さないとき、戻り値の型の指定を省略できます。
引数には向きを指定できます。functionの実行を開始するとき、input
として指定されている実引数の値が仮引数にコピーされます。functionの実行が終了するとき、output
として指定されている仮引数の値が実引数の変数にコピーされます。outputを使用することで、変数に値を割り当てることができます。
2.2.6 interface
モジュールに何個もポートが存在するとき、ポートの接続は非常に手間のかかる作業になります。例えばリスト2.41では、向きが対になっているポートがModuleAとModuleBに定義されており、これを一つ一つ接続しています。
モジュール間のポートの接続を簡単に行うために、インターフェース(interface)という機能が用意されています。リスト2.41のModuleAとModuleBを相互接続するようなインターフェースは次のように定義できます(リスト2.42)。
iff_abインターフェースを利用すると、リスト2.41を簡潔に記述できます(リスト2.43)。
インターフェースはポートの宣言と接続を抽象化します。インターフェース内に変数を定義すると、modport文によってポートと向きを宣言できます。モジュールでのポートの宣言は、ポート名 : modport インターフェース名::modport名
と記述できます。modportで宣言されたポートにインターフェースのインスタンスを渡すことにより、ポートの接続を一気に行えます。
モジュールと同じように、インターフェースにはパラメータを宣言できます(リスト2.44)。
インターフェース内には関数の定義やalways_combブロック、always_ffブロックなどの文を記述できます。
2.2.7 package
複数のモジュールやインターフェースにまたがって使用したいパラメータや型、関数はパッケージ(package)に定義できます(リスト2.45)。
パッケージに定義した要素には、パッケージ名::要素名
でアクセスできます(リスト2.46)。
import文を使用すると、要素へのアクセス時にパッケージ名の指定を省略できます(リスト2.47)。
2.2.8 ジェネリクス
関数やモジュール、インターフェース、パッケージ、構造体はジェネリクス(generics)によってパラメータ化できます。
例えば、要素に任意の型TやWビットのデータを持つ構造体は、次のようにジェネリックパラメータ(generic parameter)を使うことで定義できます(リスト2.48)。ジェネリックパラメータに渡される値は、ジェネリクスの定義位置からアクセスできる定数である必要があります。
2.2.9 その他の機能、文
initial、final
initialブロックの中の文はシミュレーションの開始時に実行されます。finalブロックの中の文はシミュレーションの終了時に実行されます(リスト2.49)。
SystemVerilogとの連携
SystemVerilogのモジュールやパッケージ、インターフェースを利用できます。SystemVerilogのリソースにアクセスするには$sv::
を使用します(リスト2.50)。
SystemVerilogのソースコードを直接埋め込み、展開できます(リスト2.51)。
システム関数、システムタスク
SystemVerilogに標準で用意されている関数(システム関数、システムタスク)を利用できます。システム関数(system function)とシステムタスク(system task)の名前は$
から始まります。本書で利用するシステム関数とシステムタスクを表2.6に列挙します。
関数名 | 機能 | 戻り値 |
---|---|---|
$clog2 | 値のlog2のceilを求める | 数値 |
$size | 配列のサイズを求める | 数値 |
$bits | 値の幅を求める | 数値 |
$signed | 値を符号付きとして扱う | 符号付きの値 |
$readmemh | レジスタにファイルのデータを代入する | なし |
$display | 文字列を出力する | なし |
$error | エラー出力する | なし |
$finish | シミュレーションを終了する | なし |
それぞれの使用例は次の通りです(リスト2.52)。システム関数やシステムタスクを利用するときは、通常の関数呼び出しのように使用します。
アトリビュート
アトリビュートを使うと、宣言に注釈をつけられます。例えばリスト2.53は、リスト2.54にトランスパイルされます。
#[sv()]
は、宣言にSystemVerilogの属性を付けられます。属性は使用するときに説明します。#[ifdef(マクロ名)]
をつけられた宣言は、マクロが存在するときにのみ定義されるようになります。#[ifndef(マクロ名)]
はその逆で、マクロが存在しないときにのみ定義されるようになります。
アトリビュートはポートやパラメータ、ブロック、モジュール、インターフェース、パッケージなど、どの宣言にも付けることができます。
標準ライブラリ
Verylには、よく使うモジュールなどが標準ライブラリとして準備されています。標準ライブラリはhttps://std.veryl-lang.org/で確認できます。
本書では標準ライブラリを使用していないため、説明は割愛します。