しかくいさんかく

解答略のメモ

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で回路を組む手順をすべて書くと

  1. FPGAを買う
  2. 買ったやつにあわせて制約ファイルを書く
  3. シミュレーション用のモジュールを書く(HDLでコーディング)
  4. そこにassertテストコードを追加
  5. シミュレーションして、各レジスタの電圧波形を見る。テストを走らせる
  6. テストが通る
  7. 論理合成用のモジュールを書く
  8. 論理合成する
  9. 論理合成結果をインプリメントする
  10. ビット列に変換する
  11. FPGA本体にビット列を書き込む
  12. 動作する。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モジュールの引数をいかにして与えるか、という問題が残っている。 合成後は

  • CLK100MHZ : FPGAに備え付けのクロック回路を使う
  • RESET : FPGAに備え付けのボタンを使う
  • OUT : FPGAに備え付けのLEDを使う

としたいわけだが、これは制約ファイル 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なのだが、それらにCLK100MHZRESETのようなエイリアスを貼って、topモジュールの引数にしている。

ビット列ファイルの在処

必要なファイルは揃ったので、Vivadoで論理合成してインプリメントしてビット列を生成し、FPGAに書き込めば実機動作する。めでたい。 ここで困るのがビット列ファイルの所在なのだが、Vivadoの場合はプロジェクト以下の simple_cpu.runs/impl_1/***.bitに生成される。

これをOpen Hardware ManagerからFPGAに書き込めば良い。

どうでもいい話

FPGAを使う際の一番高いハードル、Vivadoのインストールだと思う。 次のハードルはVivadoの使い方を知ることだと思う。がんばれ~

明日は、今日飛ばしたmake_next_regモジュールの説明をします。