しかくいさんかく

解答略のメモ

10日目: [CPU] FPGAでCPUを自作 (完成)

この記事はひとりでCPUとエミュレータとコンパイラを作る Advent Calendar 2017の10日目の記事です。

昨日に引き続きFPGAでCPUを作成していく。

復習

昨日のメインは test.sv の解説だった。 ここにはシミュレーション用のモジュールが書かれていた。

こいつを論理合成用に書き換えたのが top.sv だった。

これらはCPUの本体を構成するモジュールで

  • 回路素子の宣言
  • 外部から初期メモリの内容を読み込み
  • 外部演算モジュールへの接続
  • レジスタとメモリの初期化
  • レジスタとメモリの更新
  • クロック生成
  • assertテスト

という機能をもっていた。

ここで昨日の記事のワイヤの更新のセクションを思い出してほしいのだが、 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
);

要するにmemorya等のレジスタの出口電圧をもとに、いい感じの電圧を作り、write_flagnext_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_combend_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, yxを特定している。 この辺の処理は5日目のマルチプレクサの辺りを読めば、回路実装がわかると思う。

mov x, yyについては、あらかじめワイヤを準備していたので、ここで分岐する必要はない。

cmp x, y (0011 xx yy)

add x, ysub 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 xxを特定し、メモリに書き込むため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 xxを特定している。 分からなければ、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にフェッチすることになる。 データバスもアドレスバスもあったもんじゃない。

この辺をちゃんとやりたければ、

  1. 機械語命令を、即値使用、スタック使用、オペコードのみ使用、の3つに分類
  2. 各々の命令に対しステートマシンを描く
  3. 1クロックでメモリを8bitだけ演算モジュールに渡すように、cpuモジュールを改変

という作業が必要になる。

まぁでもいいじゃん。FPGAでフィボナッチ動いたんだし。 初日に書いたように、「とりあえず動く」「作り込まない」をモットーにしているので、多少のズルは許してもらおう。

どうでもいい話

今日で回路から見たCPUの話は一旦終了だ。お疲れ様でした。

ところで読者の中には「x86のCPUをFPGAで作るんちゃうんか??ああん??」と怒ってる方がいらっしゃると思いますが、それはx86の命令セットを解説し、エミュレータを作った後で取り組もうと思います。 物事には順序ってものがある。

でもなぁ。日数が厳しいんだよな。予想外に回路パートに時間を取られてしまった。 x86FPGAで作ると、クリスマスまでにコンパイラ編を終えるのが不可能になってしまう。どうしたものか。


  1. 三項演算子cond ? a : bならalwaysの外で使用可能。

  2. addsubの結果で、zfは更新しないことにした。つまりcmp命令のみzfを書き換える。実装が面倒くさかったから。。