9日目: [CPU] FPGAでCPUを自作 (前半)
この記事はひとりでCPUとエミュレータとコンパイラを作る Advent Calendar 2017の9日目の記事です。
今日から2日かけて、FPGA上に昨日の命令セットのCPUを実装する。 言語はSystem Verilogを用いる。 完成品のリポジトリはこれ。 こいつでフィボナッチ数を計算すると
2進数で、1, 2, 3, 5, 8, 13, 21, 34, 55 を計算しているのが分かるだろう。
全体の流れ
FPGAで回路を組む手順をすべて書くと
- FPGAを買う
- 買ったやつにあわせて制約ファイルを書く
- シミュレーション用のモジュールを書く(HDLでコーディング)
- そこにassertテストコードを追加
- シミュレーションして、各レジスタの電圧波形を見る。テストを走らせる
- テストが通る
- 論理合成用のモジュールを書く
- 論理合成する
- 論理合成結果をインプリメントする
- ビット列に変換する
- FPGA本体にビット列を書き込む
- 動作する。LEDがチカチカ光る
今日と明日解説するのは、工程3, 4, 7のモジュールを書くところだ。 テキストエディタでSystem Verilogを書くことになる。
その他の工程ではIDEを使う。僕はVivadoというやつを使っている。 Vivadoはインストールがクソゲボみたいに難しいのだが、慣れると使いやすい。
今日はまず工程3, 4のシミュレーション用モジュール test.sv を全行解説する。 その後、工程7の論理合成モジュール top.sv を説明する。
シミュレーション用モジュールの全容
この続きを読む前に4日目の記事を読み直して欲しい。 あそこに書いたHDLコードが十分に理解できてないと、この先は意味不明だと思う。
今から test.sv を見ていくのだが、全部で130行ほどのコードだ。 処理内容をざっと書くと
順番 | 処理の概要 | 注意点 | 4日目のコードの対応箇所 |
---|---|---|---|
1 | シミュ用の外部素子を宣言 | クロック、リセットボタン、出力の3つを用意する | module cpu( input CLOCK); |
2 | メモリとレジスタの宣言 | メモリはDフリップフロップ的な記憶素子がたくさん並んだものと考える | logic a; |
3 | 更新用ワイヤの宣言 | 次回のクロック立ち上がりでレジスタとメモリに入れるやつ | logic b; |
4 | ワイヤの更新 | 外部のmake_next_reg モジュールを呼び出す。詳細は明日 |
always_comb b = ~a; |
5 | クロック立上がりで更新 | リセットがOFFの場合に更新 | always_ff @(posedge CLOCK) a <= b; |
6 | クロック立上がりで初期化 | リセットがONの場合に初期化 | なし |
7 | シミュ用クロック作成 | 論理合成の際は消去する | なし |
8 | assertテスト | 論理合成の際は消去する | なし |
参考用に4日目のHDLコードを再掲しておくと
module cpu( input CLOCK); logic a; // 素子aを用意 logic b; // 素子bを用意 always_comb // 電圧変化が即座に伝わる繋ぎを書く b = ~a; // bの入口に、aの出口を反転して繋いだ // bの出口は即座に電圧が変わる。 // つまりbはワイヤーだ always_ff @(posedge CLOCK) // 電圧変化がクロック立上りの瞬間だけ伝わる繋ぎを書く a <= b; // aの入口に、bの出口を繋いだ // aの出口はクロック立上りの瞬間だけ電圧が変わる。 // つまりaはDフリップフロップだ endmodule
以下では、ファイルの上から順にコードを説明していく。
外部素子を宣言
まずファイルの冒頭。 から説明する。
cpuモジュールの一番最初に
logic CLOCK; logic RESET; logic [7:0] OUT;
としてクロックとリセット(ボタンのつもり)とアウトプット(LEDのつもり)が宣言されている。
- クロックは、ファイルの最後の方でシミュレーションの設定を書く時に使う。
- リセットボタンは、0(推されてない)なら普通にレジスタとメモリを更新し、1(押されている)ならレジスタとメモリを初期化する。詳細は
always_ff
のところで説明する。 - アウトプットは、とりあえずaレジスタの値を表示するのに使っている。深い意味はない。
メモリとレジスタの宣言
着目してる場所はここ。
外部素子の次は、CPU内のレジスタと、CPU外部(のつもり)のメモリを宣言している。
4日目のコードのlogic a;
にあたる部分だ。
これらは全てDフリップフロップの集合だとみなせる。
// メモリの宣言 logic [7:0] memory [`MEMSIZE-1:0]; // レジスタの宣言 logic [7:0] a; // reg logic [7:0] b; // reg logic [7:0] c; // reg logic [7:0] d; // reg logic [7:0] sp; // reg logic [7:0] ip; // reg logic zf; // reg
memoryの配列を定義する際に、MEMSIZE
はマクロを使っている。この値はファイルの先頭で64と定義した。
2次元配列の宣言がややキモいが、慣れるしかない。
zfは1bitのゼロフラグを表している。
更新用ワイヤの宣言
着目してる場所はここ。
CPUを一言でいうと、クロック毎にメモリやレジスタに値を代入する回路であった。
この「代入する値」を格納するワイヤーをここで宣言する。
4日目のコードのlogic b;
にあたる部分だ。
// メモリ直前のワイヤの宣言 logic write_flag; logic [`MEMSIZE-1:0] write_addr; logic [7:0] write_value; // レジスタ直前のワイヤの宣言 logic [7:0] next_a; // wire logic [7:0] next_b; // wire logic [7:0] next_c; // wire logic [7:0] next_d; // wire logic [7:0] next_sp; // wire logic [7:0] next_ip; // wire logic next_zf; // wire
メモリについては、全アドレスに毎クロック代入するのは筋が悪いので、変更すべき部分を指定して代入するようにしている。
write_flag
: 0なら全メモリを保持、1なら1byte分のメモリを変更write_addr
: 変更するメモリのアドレスwrite_value
: 変更後のメモリの値
レジスタを変更するためのワイヤーは、説明いらないと思う。
ワイヤの更新
着目してる場所はここ。
現在のレジスタの出力を元に、今宣言したワイヤの値を作成しなければならない。
要するに、現在のメモリの現在値を表すmemory
とレジスタの現在値を表すa
等を元に、メモリ更新用ワイヤのwrite_flag
やレジスタ更新用ワイヤのnext_a
等に値を入れれば良い。
4日目のコードのalways_comb b = ~a;
にあたる部分だが、今回は巨大な組み合わせ回路になる。
この部分はややこしいので、
別のモジュール
に切り出した。
make_next_reg make_next_reg_0( memory, write_flag, write_addr, write_value , a, b, c, d, sp, ip, zf , next_a, next_b, next_c, next_d, next_sp, next_ip, next_zf );
make_next_reg
がモジュール名(別ファイルで定義されている)で、
make_next_reg_0
がインスタンス名(ここで適当につける)だ。
make_next_reg
モジュールの中身については、明日解説する予定。
クロックの立ち上り
着目してる場所はここ。
今作ったnext_a
のワイヤの電圧値で、クロックの立ち上りの瞬間にaレジスタを上書きしたい。
4日目のコードのalways_ff @(posedge CLOCK) a <= b;
にあたる部分だ。
always @(posedge CLOCK) begin case(RESET) // リセットOFFで通常更新 1'b0: begin // メモリの更新 case(write_flag) 1'b1: begin memory[write_addr] <= write_value[7:0]; end default:; endcase // レジスタの更新 OUT <= next_a[7:0]; a <= next_a; b <= next_b; c <= next_c; d <= next_d; sp <= next_sp; ip <= next_ip; zf <= next_zf; end // リセットONの場合 1'b1: begin $display("RESET ON"); // メモリの初期化 $readmemb("test.txt", memory); // レジスタの初期化 OUT <= 8'h00; a <= 8'h00; b <= 8'h00; c <= 8'h00; d <= 8'h00; sp <= `MEMSIZE; ip <= 8'h00; zf <= 1'b0; end default: $display("Undefined Reset Button"); endcase end
コードをよく見ると、RESET
の値で場合分けされている。
RESET
が0、つまりリセットボタンが押されていなければ、メモリとレジスタはnext_***
のワイヤーの値で更新される。
RESET
が1の場合は、メモリとレジスタを初期化している。
特にメモリの初期化では外部ファイルの
test.txt
を読み込んでいる。
Vivadoの場合、このファイルはプロジェクトディレクトリ以下の
***.sim/sim_1/behav/test.txt
に置けば良い。
ちなみに外部ファイルの読み込みはシミュレーションでしか使えない(多分)ので、論理合成する場合は
memory[0] <= 8'b10100000; memory[1] <= 8'b11011000; ...
のように書かなければならない。
シミュレーション用のクロック生成
着目してる場所はここ。
ここから先は完全にシミュレーションの話だ。
まずクロックを5単位時間毎に反転させる。 つまり、クロックの立ち上りは10単位時間毎にやってくる。
initial begin CLOCK = 1'b0; forever begin #5; CLOCK = ~ CLOCK; end end
このようにinitial
で指定されるブロックは、シミュレーションの開始とともに手続き的に実行され、論理合成の際には無視される。
#5;
は5単位時間の経過を待つという意味で、foever
は無限ループ。
assertテスト
着目してる場所はここ。
クロックを作ったので、次はレジスタとメモリを初期化したい。 そのためにはリセットボタンを押せば良かった。 まず、シミュレーションの開始時点でリセットを1にしておく。 しばらく経ったら0に切り替えて、レジスタとワイヤを通常更新するモードに移ろう。
initial begin RESET = 1'b1; # 6; RESET = 1'b0; #8; #10;$display("0100 00 00 00000001 // mov a, imm -- a=01"); assert(a === 1); #10;$display("0101 00 00 00000001 // add a, imm -- a=10"); assert(a === 2); // 略 $stop; end
CPUは10単位時間毎に新たな機械語命令をメモリからフェッチして実行し、レジスタの値が更新される。
このレジスタの値が予想と整合するか、シミュレーション時にassertで調べている。
display関数はtclコンソールに結果が出力される。
ちなみにメモリの内容は、リポジトリ内のtest.txt
に用意した。
論理合成
以上でHDLのシミュレーションの説明は終了だ。 これを論理合成してFPGAに書き込めば、実機で動作するわけだが、今解説したテストコードのままでは動かない。
HDLの書き換え
論理合成するためには、まずテストコードを以下のように変更する
- CLOCK, RESET, OUTをcpuモジュールの引数にする
- メモリの初期化を、外部ファイル読込ではなくHDLコードに直書きする
- initial構文の部分を全削除
クロックを間引く
これでも良いのだが、FPGAのクロックは100MHzぐらいなので、test.txt
を実行するとマイクロ秒以下で終了してしまう。
見栄えが良くないので、クロックを1Hzに変更しよう。そのためには
このような処理
が必要になる。
module top( input CLK100MHZ, input RESET, output reg [7:0] OUT); reg [26:0] counter; wire CLOCK; assign CLOCK = counter < 100_000_000 / 2; cpu cpu_0(CLOCK, RESET, OUT); always @(posedge CLK100MHZ) begin counter <= RESET | counter < 100_000_000 ? counter + 1 : 0; end endmodule
まずtopモジュールの中でCLOCKというワイヤをつくり、1秒毎に反転させる。 そして引数にCLOCKを入れて、cpuモジュールを呼び出している。
制約ファイル
最後に、topモジュールの引数をいかにして与えるか、という問題が残っている。 合成後は
としたいわけだが、これは制約ファイル constrain.xdc を書くことで実現できる。 FPGAによって制約ファイルの中身は変わるわけだが、Xilinxのartyであれば
set_property -dict {PACKAGE_PIN E3 IOSTANDARD LVCMOS33} [get_ports {CLK100MHZ}]; create_clock -period 10.0 -name sys_clk_pin -waveform {0.0 5.0} -add [get_ports {CLK100MHZ}]; set_property -dict {PACKAGE_PIN F6 IOSTANDARD LVCMOS33} [get_ports {OUT[0]}]; ... 略 ... set_property -dict {PACKAGE_PIN T10 IOSTANDARD LVCMOS33} [get_ports {OUT[7]}]; set_property -dict {PACKAGE_PIN D9 IOSTANDARD LVCMOS33} [get_ports {RESET}];
こんな感じになる。
FPGAのピンの正式名称はE3
とかD9
なのだが、それらにCLK100MHZ
やRESET
のようなエイリアスを貼って、topモジュールの引数にしている。
ビット列ファイルの在処
必要なファイルは揃ったので、Vivadoで論理合成してインプリメントしてビット列を生成し、FPGAに書き込めば実機動作する。めでたい。
ここで困るのがビット列ファイルの所在なのだが、Vivadoの場合はプロジェクト以下の
simple_cpu.runs/impl_1/***.bit
に生成される。
これをOpen Hardware ManagerからFPGAに書き込めば良い。
どうでもいい話
FPGAを使う際の一番高いハードル、Vivadoのインストールだと思う。 次のハードルはVivadoの使い方を知ることだと思う。がんばれ~
明日は、今日飛ばしたmake_next_reg
モジュールの説明をします。