10日目: [CPU] FPGAでCPUを自作 (完成)
この記事はひとりでCPUとエミュレータとコンパイラを作る Advent Calendar 2017の10日目の記事です。
昨日に引き続きFPGAでCPUを作成していく。
復習
昨日のメインは test.sv の解説だった。 ここにはシミュレーション用のモジュールが書かれていた。
こいつを論理合成用に書き換えたのが top.sv だった。
これらはCPUの本体を構成するモジュールで
という機能をもっていた。
ここで昨日の記事のワイヤの更新のセクションを思い出してほしいのだが、 CPU本体のモジュールには演算回路が含まれず、外部の演算モジュールに繋いでいた。
この外部演算モジュールの入出力は
入出力 | 線の種類 | 4日目のコード(改変版)の対応素子 |
---|---|---|
入力 | メモリの出口のワイヤ | a の出口 |
入力 | レジスタの出口のワイヤ | a の出口 |
出力 | 次回のクロック立ち上りでメモリを上書きするワイヤ | b の入口 |
出力 | 次回のクロック立ち上りでレジスタを上書きするワイヤ | b の入口 |
つまりワイヤをワイヤに変換する組み合わせ回路が、演算モジュールだった。
4日目のコードとの対応
基本(超重要)の4日目の記事に出てきたコードを改変すると
module cpu( input CLOCK); // 昨日作った logic a; // レジスタaを用意 logic next_a; // ワイヤnext_aを用意 // 外部演算モジュールに繋いで、aからnext_aの電圧を作る make_next_a make_next_a_0(a, next_a); // レジスタaをワイヤnext_aで上書き always_ff @(posedge CLOCK) a <= next_a; endmodule module make_next_a( input a, output next_a); // 今日作る // always_comb中でnext_aに線を繋ぐのは無理(仕様)なので // ワイヤのb_endを作り、その出口をbの入口に繋ぐ logic end_a; assign next_a = end_a; always_comb // aを使ってend_aを作る組み合わせ回路 end_a = ~a; // aの出口を反転してend_aに繋いだ endmodule
このコードを4日目のコード(改変版)と呼ぶことにする。
cpu
モジュールは昨日作った
test.sv
に対応し、
make_next_a
モジュールは今日作る
make_next_reg
に対応する。
make_next_a
の内側では
- まず
end_a
というワイヤを宣言 - 次に
end_a
の出口をnext_a
の入口に繋ぐ - 最後にレジスタ
a
の出口電圧を反転し、end_a
の入口に繋ぐ
ここまでの話が理解できれば、後の話は難しくない。
4日目のコードとの今日の回路の違いは
極論すると、レジスタの数が増えただけだ。恐れる必要は全く無い。
準備が整ったので、本日のメインテーマの 演算モジュール本体 の解説に移ろう。
引数
まず この部分 に着目する。
これはモジュールの冒頭の引数部分だが
module make_next_reg( input reg [7:0] memory [`MEMSIZE-1:0] , output wire write_flag , output wire [`MEMSIZE-1:0] write_addr , output wire [7:0] write_value , input reg [7:0] a // (inputがたくさん。省略) , input reg [7:0] ip , input reg zf , output wire [7:0] next_a // (outputがたくさん。省略) , output wire next_zf );
要するにmemory
やa
等のレジスタの出口電圧をもとに、いい感じの電圧を作り、write_flag
やnext_a
等のワイヤの入口に繋ぐ組み合わせ回路だ。
4日目のコード(改変版)に対応させると、next_a = ~a
のような組み合わせ回路を作れば良い。
ちなみにwrite_***
のワイヤはメモリの更新用だった。忘れた人は
昨日の記事の「クロックの立ち上り」の辺りを読み直して欲しい。
ワイヤの宣言と接続
この部分 に着目する。
4日目のコード(改変版)に
- まず
end_a
というワイヤを宣言 - 次に
end_a
の出口をnext_a
の入口に繋ぐ
という部分があった。この宣言と接続に対応するのが
// 出力をendのワイヤーに変換 logic [7:0] end_a; // (宣言文たくさん。省略) logic end_zf; assign next_a = end_a; // (assign文たくさん。省略) assign next_zf = end_zf ; logic end_write_flag ; logic [`MEMSIZE-1:0] end_write_addr ; logic [7:0] end_write_value; assign write_flag = end_write_flag ; assign write_addr = end_write_addr ; assign write_value = end_write_value;
まずレジスタ用のワイヤend_a
を宣言して、モジュールの出力ワイヤnext_a
接続している。
次にメモリ用のワイヤend_write_***
を宣言して、モジュールの出力ワイヤwrite_***
に接続している。
メモリからオペコード類をフェッチ
この部分 に着目する。
4日目のコード(改変版)では、ワイヤの宣言と接続を行った直後に、always_comb
でend_a
を作成していた。
今回はメモリが存在している。
そこに書かれた機械語命令や即値、スタックの値を使って、end_a
を作ることになる。
従ってalways_comb
の前にこれらの電圧値を準備する必要がある。
logic [7:0] ope // 機械語命令のワイヤを準備 assign ope [7:0] = memory[ip]; logic [7:0] imm; // 即値のワイヤを準備 assign imm [7:0] = memory[ip+1]; logic [7:0] stack; // スタックのワイヤを準備 assign stack = memory[sp];
実はこの辺の処理、かなりヤバい問題を抱えている。記事の最後で言及しよう。
オペランドのワイヤーの宣言
この部分 に着目する。
機械語命令の中には
add x, y
のようにレジスタでレジスタを上書きする処理があるけど、このy
レジスタに対応するワイヤをあらかじめ準備しておく。
// オペランドのワイヤ logic [7:0] y;
さっき機械語命令や即値を準備した時と違ってassign
文がない。
ここでは、機械語命令に応じて
機械語の下2桁 | ワイヤy の接続先 |
---|---|
00 | a の出口 |
01 | b の出口 |
10 | c の出口 |
11 | d の出口 |
このように接続(assign
)したいのだが、System Verilogの言語仕様上、always
の外側ではcase
文やif
文を使うことができないのだ1。
あとでalways
が出たとき、レジスタの出口をy
につなごう。
end_a
ワイヤーの電圧を作成
この部分 に着目する。
いよいよ最後の処理だ。4日目のコード(改変版)でいうと
always_comb // aを使ってend_aを作る end_a = ~a; // aの出口を反転してend_aに繋いだ
の部分にあたる。
今回はレジスタの数と演算の数が大幅に増えたので、always_comb
の中身が350行ぐらいに膨らんでしまった。
処理ごとに分割して説明しよう。
オペランドのワイヤの作成
この部分 に着目する。
オペランドを表すy
のワイヤは、まだレジスタに繋がってなかった。
現在はalways
の中なのでcase
文が使える。さっそく繋ごう。
// yをあらかじめ定義 case (ope[1:0]) 2'b00: y = a; 2'b01: y = b; 2'b10: y = c; 2'b11: y = d; endcase
計算命令とメモリ関連命令の分離
このcase文 に着目する。
機械語命令を実際に処理する部分に入った。
2日前に決めた命令セット表を再掲すると
計算命令 | 機械語(2進数) | メモリ関連命令 | 機械語(2進数) | |
---|---|---|---|---|
mov x, y | 0000 xx yy | push x | 1000 xx 00 | |
add x, y | 0001 xx yy | pop x | 1001 xx 00 | |
sub x, y | 0010 xx yy | jmp imm | 1010 00 00 imm | |
cmp x, y | 0011 xx yy | jz imm | 1100 00 00 imm | |
mov x, imm | 0100 xx 00 imm | jnz imm | 1101 00 00 imm | |
add x, imm | 0101 xx 00 imm | hlt | 1111 00 00 | |
sub x, imm | 0110 xx 00 imm | |||
cmp x, imm | 0111 xx 00 imm |
機械語の左端が0なら計算命令で、1ならメモリ関連の命令になっている。
HDLの言葉に直すと、ope[7:0]
の左端、ope[7]
のbitで分岐すればよい。
case (ope[7]) 1'b0: begin // 計算関連命令 end 1'b1: begin // メモリ関連の命令 end endcase
計算とメモリ操作に分離できた。 この後も同じ感じで分離操作を繰り返していく。
計算命令全体
計算命令の全範囲 でメモリやスタックの更新がないので、最初に一括して
end_sp = sp; end_write_flag = 0; end_write_addr = 0; end_write_value = 0;
としている。メモリの書き込みフラグを0にしたので、次回のクロック立ち上がりでメモリの値は変更されない。
残された作業は、計算レジスタend_a
等とプログラムカウンタend_ip
の電圧値を作成するだけだ。
残りの部分は
case (ope[6]) 1'b0: begin end_ip = ip + 1; case (ope[5:4]) 2'b00: // mov x, y の処理 2'b01: // add x, y の処理 2'b10: // sub x, y の処理 2'b11: // cmp x, y の処理 1'b1: begin end_ip = ip + 2; case (ope[5:4]) 2'b00: // mov x, imm の処理 2'b01: // add x, imm の処理 2'b10: // sub x, imm の処理 2'b11: // cmp x, imm の処理 endcase
重要なのはend_ip
をつくるところ。
次回クロックでフェッチする機械語のアドレスをend_ip
に入れたいので、
即値(imm
)が含まれるかどうかでプログラムカウンタ(ip
)の増分を変える必要がある。
以下では各機械語命令の実装を順に見ていく。
mov x, y (0000 xx yy)
この部分 に着目する。
まずmov x, y
に着目すると
end_zf = zf; case (ope[3:2]) 2'b00: begin // mov a, y end_a = y; end_b = b; end_c = c; end_d = d; end 2'b01: begin // mov b, y end_a = a; end_b = y; end_c = c; end_d = d; end 2'b10: begin // mov c, y end_a = a; end_b = b; end_c = y; end_d = d; end 2'b11: begin // mov d, y end_a = a; end_b = b; end_c = c; end_d = y; end
まず初めに、mov命令ではゼロフラグが変わらないのでend_zf
を現在値と同じにしている。
次のcase (ope[3:2])
の分岐でmov x, y
のx
を特定している。
この辺の処理は5日目のマルチプレクサの辺りを読めば、回路実装がわかると思う。
mov x, y
のy
については、あらかじめワイヤを準備していたので、ここで分岐する必要はない。
cmp x, y (0011 xx yy)
add x, y
とsub x, y
は今説明したmov
とほぼ同じ2なので、毛色の違うcmp命令を見てみる。
この部分
に着目する。
cmp x, y
の処理は
end_a = a; end_b = b; end_c = c; end_d = d; case (ope[3:2]) 2'b00: end_zf = a-y ? 0 : 1; // cmp a, y 2'b01: end_zf = b-y ? 0 : 1; // cmp b, y 2'b10: end_zf = c-y ? 0 : 1; // cmp c, y 2'b11: end_zf = d-y ? 0 : 1; // cmp d, y endcase
この命令はフラグだけを更新し、他のレジスタは変更しないので、最初にend_a
からend_d
に前回の値を繋いでいる。
その次のcase (ope[3:2])
はcmp x, y
のxを特定するための分岐だ。
その次の三項演算子はif
文の省略形で
// 例 end_zf = a-y ? 0 : 1; // cmp a, y // これは以下のif文と等価 if (a-y) end_zf = 0; // if (a-y) は if(a-y != 0) の意味 else end_zf = 1;
つまりa == y
ならフラグが立つわけだ。
ope x, imm (01?? xx 00 imm)
ここまでope x, y
型の命令を見てきた。
最後にope x, imm
型の命令を見てもよいのだが、これは今まで y だったところを imm に変更するだけだ。
ほぼ同じなので説明は略す。
メモリ関連の命令
メモリ関連命令の全範囲 でゼロフラグの更新がないので、最初に一括して
end_zf = zf;
としている。
続いて
case (ope[6:4]) 3'b000: // push x の処理 3'b001: // pop x の処理 3'b010: // jmp imm の処理 3'b100: // jz imm の処理 3'b101: // jnz imm の処理 default: // hlt の処理
これらの命令を順に見ていく。
push x (1000 xx 00)
この部分 に着目すると
end_a = a; end_b = b; end_c = c; end_d = d; end_ip = ip + 1; end_sp = sp - 1; end_write_flag = 1; end_write_addr = sp-1; case (ope[3:2]) 2'b00: end_write_value = a; // push a 2'b01: end_write_value = b; // push b 2'b10: end_write_value = c; // push c 2'b11: end_write_value = d; // push d endcase
まず初めに計算用レジスタ更新用のワイヤを、現在のレジスタの出口に繋いでいる。
次に、この命令は即値を含まないので、end_ip
は普通に1つ増やしている。
スタックポインタ(sp
)については、スタックが変化するので値を変える必要がある。
スタックはメモリの最後尾から最前列に向かって伸びているので、push
する度にsp
の値が1つ減る。
続いて、スタックメモリに値を書き込むのでend_write_flag
を立てている。
書き込むアドレスはend_write_addr
で指定しているが、sp
は最新のスタックが詰まったアドレスを指すので、ここで書き込むべきアドレスはsp-1
としなければならない。
続いてcase (ope[3:2])
でpush x
のx
を特定し、メモリに書き込むためend_write_value
に繋いでいる。
pop x (1001 xx 00)
この部分 に着目する。
push
命令の逆をやればよい。
end_ip = ip + 1; end_sp = sp + 1; end_write_flag = 0; end_write_addr = 0; case (ope[3:2]) 2'b00: begin end_a = stack; // pop a end_b = b; end_c = c; end_d = d; end 2'b01: begin end_a = a; end_b = stack; // pop b end_c = c; end_d = d; end 2'b10: begin end_a = a; end_b = b; end_c = stack; // pop c end_d = d; end 2'b11: begin end_a = a; end_b = b; end_c = c; end_d = stack; // pop d end
スタックから1つ値を取り出すので、end_sp
に1加えている。
なおpop
操作はメモリの論理削除であり、メモリをゼロで埋める物理削除はしない。
そういう「必要かどうかわからない操作」は、ハードウェアで自動実行すべきではなく、ソフトウェア(つまりメモリに載った機械語)に判断を仰ぐべきである。
無意味なメモリアクセスはすべきではない。
最後にcase (ope[3:2])
でpop x
のx
を特定している。
分からなければ、mov
命令の説明を読み直して欲しい。
jmp imm (1010 00 00 imm)
この部分 に着目すると
end_a = a; end_b = b; end_c = c; end_d = d; end_sp = sp; end_ip = (ip + 2) + imm; end_write_flag = 0; end_write_addr = 0; end_write_value = 0;
要するにジャンプ操作とはプログラムカウンタの書き換えであり、他のレジスタにはタッチしなかった。
従って次回の機械語をフェッチするメモリアドレスを指定する、end_ip
だけに着目すればよい。
ややこしいので表にまとめると
値 | 意味 |
---|---|
ip |
さっき読んだ機械語のアドレス |
ip+2 |
ジャンプ命令でなかった場合に、次回読む機械語のアドレス |
imm |
ジャンプ命令でなかった場合に、次回読むはずだったアドレスからの差分 |
ip+2+imm |
ジャンプし、次回読む機械語のアドレス |
なお条件付きジャンプ命令は、jmp
命令のend_ip
を以下のように変更すれば作れる。
命令 | end_ip の右辺値 |
---|---|
jz imm |
zf ? ip+2+imm : ip+2 |
jnz imm |
zf ? ip+2 : ip+2+imm |
三項演算子を使ってジャンプの有無を判断している。
hlt (1111 00 00)
この部分 に着目する。
この命令が来たらプログラムを停止する。これを簡単に実装するには、
end_a = a; end_b = b; end_c = c; end_d = d; end_sp = sp; end_ip = ip; end_write_flag = 0; end_write_addr = 0; end_write_value = 0;
つまり全レジスタとメモリを前回と同じ値にすればよい。
特にプログラムカウンタを前回と同じ値にするのがミソだ。
こうすることで、CPUは延々とhlt
命令を実行し続けることになる。
なお本物のhlt
命令を実装するにはメモリへのアクセスを停止しなければならない。面倒くさいので安直に実装した。
以上で、 make_next_reg.sv の全部分を説明し終えた。 ここまで読んでくださった方、誰もいないと思いますが、どうもありがとうございました。
このCPUのよくないところ
最後に注意を述べる。
今回作ったCPUは、ひとつだけ猛烈にヤバい処理をしている。どこかわかるだろうか?
答えはメモリからオペコード類をフェッチするところだ。 あんな処理は現実のCPUだとありえないと思う。
あそこではクロックの立ち上がり毎に、ip, ip+1, spで指定されるメモリアドレスの3つの値を一斉にフェッチしていた。 これを実現するには、データバスとアドレスバスを3倍に増やすなど、いろいろ気持ち悪いことをしなければならない。 今回は話を簡単にして1クロックで処理を終了させたかったので、ズルをしたのだ。
もっと言うと、演算モジュールの引数にメモリ全体が入ってるのが強烈にヤバい。 これではクロック立ち上がり毎に、全てのメモリの値をCPUにフェッチすることになる。 データバスもアドレスバスもあったもんじゃない。
この辺をちゃんとやりたければ、
- 全機械語命令を、即値使用、スタック使用、オペコードのみ使用、の3つに分類
- 各々の命令に対しステートマシンを描く
- 1クロックでメモリを8bitだけ演算モジュールに渡すように、
cpu
モジュールを改変
という作業が必要になる。
まぁでもいいじゃん。FPGAでフィボナッチ動いたんだし。 初日に書いたように、「とりあえず動く」「作り込まない」をモットーにしているので、多少のズルは許してもらおう。
どうでもいい話
今日で回路から見たCPUの話は一旦終了だ。お疲れ様でした。
ところで読者の中には「x86のCPUをFPGAで作るんちゃうんか??ああん??」と怒ってる方がいらっしゃると思いますが、それはx86の命令セットを解説し、エミュレータを作った後で取り組もうと思います。 物事には順序ってものがある。
でもなぁ。日数が厳しいんだよな。予想外に回路パートに時間を取られてしまった。 x86をFPGAで作ると、クリスマスまでにコンパイラ編を終えるのが不可能になってしまう。どうしたものか。