しかくいさんかく

解答略のメモ

17日目: [x86] CPU (IA-32) をFPGAで製作

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

ちょうど1週間前に 単純なCPUをFPGAで作った

そして昨日は x86のエミュレータを完成させた

この2つを組み合わせて、FPGA上で動くx86のCPUを作ろう。 いよいよ本丸に切り込むぞ!!!

x86をHDLで記述する日本語書籍や解説記事は、いまのところ存在しないと思う。 先陣を切った。

仕様

全命令を実装するのは大変なので、以下の表の赤い命令にしぼろう。

f:id:kaitou_ryaku:20171217222358p:plain

これらの命令さえあれば、再帰呼出しでフィボナッチ数が計算できる。

基本戦略

負担を軽減するため、 前作CPU を改良し、機能を追加する形で実装しようと思う。

前作CPUの命令セットはこの記事に書いた。 こいつとx86の相違点をまとめると

解説順 前作CPU これから作るx86命令セットのCPU
1 レジスタが8bit。フラグはゼロフラグのみ レジスタが32bit。フラグは4種類
2 クロック毎に全メモリを読む 必要な部分だけ読む
3 機械語の下位4bbitでオペランドを指定 ModRMでオペランドを指定
4 全ての演算処理をalways_comb内で実行 演算処理を各モジュールに分離
5 条件付きジャンプ命令はjzだけ フラグが増えたので、条件付きジャンプ命令も増える
6 関数呼出は未サポート。ebpレジスタは無い ebpレジスタが存在し、call, leave, retの3命令を含む
7 全演算が簡単につくれる ModRMの入った演算がしんどい

相違点を中心に、今からソースコードを解説していく。

1. レジスタとフラグの拡張

レジスタの32bit化とフラグの拡張は簡単だ。 前作CPUの7だったインデックスを31に変えるだけで済む。 この処理は top.svの最初の方 で行っている。

// レジスタの宣言
logic [31:0] eax; // FF
// ecx, edx, ebx, esp, ebp, esi, ediを略

logic [31:0] eip; // FF
logic cf, zf, sf, of; // wire

// レジスタ直前のワイヤの宣言
logic [31:0] next_eax; // wire
// ecx, edx, ebx, esp, ebp, esi, ediを略

logic [31:0] next_eip; // wire
logic next_cf, next_zf, next_sf, next_of;

2. メモリフェッチの改善

CPUで演算を行いたい。 つまりクロックの立ち上がりでレジスタeaxを上書きしたい。 そのためには、更新用のワイヤnext_eaxの電圧を作る組み合わせ回路が必要だった。

この組み合わせ回路は、前作CPUも今作のx86make_next_regというモジュール名で統一した。 前回はこいつにメモリ全体を渡していた。 前回CPUを作ったときの記事 の下の方に書いたが、あまりにひどい処理だった。

メモリフェッチ処理を改善し、make_next_regに渡すメモリのサイズを小さくしたい。 そのためには top.svの中でオペコードのワイヤを作って

// オペコードのワイヤを作成
logic [7:0] opecode;
assign opecode = memory[eip];

make_next_regの引数に入れて 渡せば良い。

make_next_reg make_next_reg_0(
  // 略
  , opecode, imm8, imm32
  , mod, r, m, r_reg, m_reg
  // 略
  , eax, ecx, edx
  // 略
  , next_eax, next_ecx, next_edx
  // 略
);/*}}}*/

ここではopecode以外に、即値やらレジスタやら色々渡している。 前回はopecodeを渡す代わりにmemoryを渡していた。とんでもないことだ。

3. ModRMでオペランドを指定

opecodemake_next_regに渡し、メモリフェッチを改善することができた。 しかしメモリフェッチはopecodeと即値だけではない。ModRMを考慮しなければならない。

ModRMはややこしいので、make_next_regに渡す前に多数のワイヤを作成する必要がある。

そのため、まず top.svの中でModRM関連のワイヤを一挙に宣言 する。 これらをmake_modrmモジュールに渡すことで、ModRMに関する電圧値を得ている。

logic [ 1:0] mod;
logic [ 2:0] r;
logic [ 2:0] m;
logic [31:0] r_reg;
logic [31:0] m_reg;
logic [31:0] m_reg_plus_imm8 ; // M+imm8
logic [31:0] m_reg_plus_imm32; // M+imm32

logic [ 7:0] around_eip [6:0];
assign around_eip[0] = memory[eip+0];
assign around_eip[1] = memory[eip+1];
assign around_eip[2] = memory[eip+2];
assign around_eip[3] = memory[eip+3];
assign around_eip[4] = memory[eip+4];
assign around_eip[5] = memory[eip+5];

make_modrm make_modrm_0(
  around_eip,
  eax, ecx, edx, ebx, esp, ebp, esi, edi,
  // 略
  mod, r, m, r_reg, m_reg,
  m_reg_plus_imm8, m_reg_plus_imm32
);

modrmは分かるだろう。

r_regは3bitのrで指定されるレジスタの出口電圧を表している。m_regも同様。

m_reg_plus_imm8m_regに1byteの即値imm8を足したものだ。 ModRMの絡んだ命令では、add [m_reg+imm8], Rのようにm_reg+imm8が頻出するので、あらかじめ作っておいた。

これらのワイヤの電圧値を、eip周辺のメモリの値around_eipを元に作成するモジュールがmake_modrmである。

make_modrmモジュールの実装 を見てみると

module make_modrm (
// 略
);

  logic  [7:0] modrm;
  assign modrm = memory_eip[1];
  assign mod = modrm[7:6];
  assign r   = modrm[5:3];
  assign m   = modrm[2:0];

  // 略

  // always_comb内では、モジュールのoutputに直接接続できない。
  // なのでワイヤの end_r_reg を宣言して r_reg にかます
  logic [31:0] end_r_reg;
  assign r_reg = end_r_reg;

  // 略

  // end_r_reg に線をつなぐ
  always_comb begin
    case(r)
      3'b000: end_r_reg = eax;
      3'b001: end_r_reg = ecx;
      // 以下、edx, ebx, esp, ebp, esi, ediを同様に処理
    endcase

    //略

  end
endmodule

簡単のために省略改変して記載した。 このコードは、ModRMの3bitのRに対応するレジスタを取得し、ワイヤのr_regに繋ぐモジュールだと思ってほしい。 処理を追うと

  1. 入力のmodrmから、出力のmodrmを切り出す
  2. ワイヤのend_r_regを宣言し、r_regの入力につなぐ
  3. case文を使いたいので、always_combの中でend_r_regの入口に線をつなぐ

もちろんr_reg以外にも(m_regなど)作成すべき電圧値はある。 実際のmake_modrmモジュールの中では、それらの必要な電圧値を全て作成している。

4. 演算処理を各モジュールに分離

前回作ったCPUのmake_next_regモジュールでは、always_combの中でオペコードによる場合分けを行っていた。 System Verilogの仕様上、alwaysの中ではモジュールを呼び出せないので、必要な演算処理を全てalways_comb中にべた書きする必要があった。 いかにも不格好だ。

今回はスマートにやろう。命令毎にモジュールに分割する。

昨日のx86エミュレータ製作を思い出してほしい。 そこでは「演算処理の構造体を作り、配列にして、添字をオペコードにする」というテクニックを使っていた。

これと同様に 「演算処理のワイヤを作り、配列にして、添字をオペコードにする」というテクニックを使えば、always_comb内のcase文を使うことなく分岐することが可能になる。

つまり

  1. クロック毎に全種類の演算を行う
  2. 結果を「演算処理のワイヤの配列」に格納する。添字は計算種別に対応する1byteのオペコードにする
  3. メモリから取得したオペコードをもとに、「欲しい計算結果のワイヤ」を得る
  4. 取得したワイヤの電圧値で、次回クロック立ち上がりの瞬間にレジスタを上書き

こうすることで「演算処理のワイヤを作る」部分を命令毎にモジュール化できて、可読性が向上する。

説明が抽象的でわかりにくいかもしれない。コードを見よう。

make_next_reg.sv の全体像を見ると

// opecode, eax等からwrite_flag, next_eax等を作る組み合わせ回路

module make_next_reg(
  output   wire        write_flag
  , output wire [31:0] write_addr
  , output wire [31:0] write_value
  // 略
  , output wire [31:0] next_eax
  // 略
);

  // next 更新用の多重ワイヤを宣言
  logic        end_write_flag[255:0];
  logic [31:0] end_write_addr[255:0];
  logic [31:0] end_write_value[255:0];
  logic [31:0] end_eax [255:0];
  // 略

  // 多重ワイヤのうち、opecodeに合致するものをnextに繋ぐ
  assign write_flag  = end_write_flag[opecode];
  assign write_addr  = end_write_addr[opecode];
  assign write_value = end_write_value[opecode];
  assign next_eax    = end_eax[opecode];
  // 略

  // 全種類の計算を実行。まずはaddの結果をend_***[01]に入れる
  inst_01_add_M_imm_R inst_01_add_M_imm_R_0(
    , mod, m, r_reg, m_reg                // modRM関連の他の引数を略
    , end_write_flag[8'h01], end_write_addr[8'h01], end_write_value[8'h01]
    , eax, ecx                            // レジスタ関連の引数を略
    , end_eax[8'h01], end_ecx[8'h01]      // レジスタ更新用ワイヤの引数を略
  );

  // 略 (jmpやmovの結果も、end_***[***]に入れる)
endmodule

「演算処理のワイヤの配列」はend_eax [255:0]などの、endで始まるワイヤ配列を指している。 配列サイズを256にしたのは、オペコードが0x00から0xffまでの256種類(1byte)だからだ。

5. 条件付きジャンプ命令

さっきのコードの最後で、inst_01_add_M_imm_Rというモジュールをよtl出していた。 こいつはadd [M+imm], Rという命令を処理するモジュールなのだが、ModRMが入っていてややこしい。 詳細は記事の最後に回そう。

変わりに jcc imm32の命令を処理するモジュール の実装を見てみる。

// jcc imm32
module inst_0f_jcc_imm32(
  input    wire [31:0] imm8
  , input  wire [31:0] modrm_imm32
  , output wire        next_write_flag
  , output wire [31:0] next_write_addr
  , output wire [31:0] next_write_value
  , input  reg  [31:0] eax      // input ecx, edx, ... , of を略
  , output wire [31:0] next_eax // output next_ecx, next_edx, ... , next_of を略
);

  // eip以外は前回の値を保持
  assign next_write_flag  = 0;
  assign next_write_addr  = 0;
  assign next_write_value = 0;

  assign next_eax = eax;
  // ecx, edx, ... , of も同様に前回の値を保持する。略

  logic [31:0] e_eip;
  assign next_eip = e_eip;

  logic [31:0] no_jump;
  assign no_jump = eip + 6;

  logic [31:0] jump;
  assign jump = eip + 6 + modrm_imm32;

  always_comb begin
    case(imm8)
      8'h80: e_eip = (of == 1) ? jump : no_jump;
      8'h81: e_eip = (of == 0) ? jump : no_jump;
      8'h82: e_eip = (cf == 1) ? jump : no_jump;
      8'h83: e_eip = (cf == 0) ? jump : no_jump;
      // 他の条件付きジャンプは略
      default: e_eip = no_jump;
    endcase
  end
endmodule

jmp imm32機械語命令を復習しよう。 例えばメモリアドレス 0x12345678 に条件付きジャンプする命令だと

アドレス eip no_jump
名前 opecode imm8 modrm_imm32[3] [2] [1] [0]
中身 0f 80から8fの間 78 56 34 12 次の命令

このimm8の値に応じて、ジャンプの条件式(a>ba<bか)が定まっている。

これを実現するために

  1. メモリには何も書き込まない
  2. eax, ecx 等のレジスタも前回の値を保持
  3. フラグも変更しない
  4. プログラムカウンタeipを、imm8とフラグを比較して書き換える

という処理を行っている。

6. 関数呼出

関数呼出の解説記事 を読み返してほしいのだが、関数を呼ぶにはcallしてleaveしてretすればよかった。

これらの実装をメモしておく。

call

実装はこれ。 この命令はpushで「callの次の命令」をスタックに積んだあと、jmp imm32するのと等価だった。

// module文を省略

assign next_write_flag  = 1;
assign next_write_addr  = esp-4;
assign next_write_value = eip+5; // call命令の一個下のeipを入れる

assign next_esp         = esp-4;
assign next_ebp         = ebp;
assign next_eip         = (eip+5) + imm32;
// 他のnext_***は前回の値を保持
leave

実装はこれ。 この命令はmov esp, ebpしたあとpop ebpするのと等価だった。

// module文を省略
// next_write_***は前回の値を保持

assign next_esp = ebp+4;
assign next_ebp = ebp_leave_value;
assign next_eip = eip+1;
// 他のnext_***は前回の値を保持
ret

実装はこれ。 この命令はpop eipと等価だった。

// module文を省略
// next_write_***は前回の値を保持

assign next_esp = esp+4;
assign next_ebp = ebp;
assign next_eip = stack_value;
// 他のnext_***は前回の値を保持

7. ModRMの入った演算

最後にadd [M+imm], R命令を処理するモジュールinst_01_add_M_imm_Rを説明する。

今日の記事の「4. 演算処理を各モジュールに分離」のところで出てきた命令だが、だいぶややこしい。 ModRMが入るとしんどいのだ。

inst_01_add_M_imm_Rの実装 を見てみると

module inst_01_add_M_imm_R(
  input    wire [ 1:0] mod              // modRMのmod (0,1,2,3)
  , input  wire [ 2:0] m                // modRMのM (0~7)
  , input  wire [31:0] r_reg            // modRMのRで指定されるレジスタの出口
  , input  wire [31:0] m_reg            // modRMのMで指定されるレジスタの出口
  , input  wire [31:0] m_reg_plus_imm8  // M+imm8のメモリアドレス
  , input  wire [31:0] m_reg_plus_imm32 // M+imm32のメモリアドレス
  , input  wire [31:0] memval_m_reg     // [M] のメモリの値
  // modrm系のinputを略

  , output wire        next_write_flag  // メモリに書き込む時は1, さもなくば0
  , output wire [31:0] next_write_addr  // メモリに書き込むアドレス
  , output wire [31:0] next_write_value // メモリに書き込む値
  , input  reg  [31:0] eax              // eaxレジスタの出口
  // レジスタ系のinputを略

  , output wire [31:0] next_eax         // 次回クロックのeaxレジスタの入口
  // 更新用ワイヤのoutputを略
);

  // always_comb内で直接next_***に繋ぐことはできないので、e_***をかます
  logic        e_write_flag;
  logic [31:0] e_write_addr;
  logic [31:0] e_write_value;
  logic [31:0] e_eax;
  // 略

  assign next_write_flag  = e_write_flag;
  assign next_write_addr  = e_write_addr;
  assign next_write_value = e_write_value;
  assign next_eax         = e_eax;
  // 略

  // e_***に線を繋いで、間接的にnext_***を書き換え
  always_comb begin
    case(mod[1:0])

      // add [M], R の処理
      2'b00: begin
        e_write_flag  = 1;     // メモリの書き込みフラグを立てる
        e_write_addr  = m_reg; // 書き込むアドレスはレジスタMの値そのもの
        e_write_value = memval_m_reg + r_reg; // [M]=[M]+R

        e_eax = eax;   // eax, ecx, ... は前回の値を保持
        e_ecx = ecx;
        // 略
        e_edi = edi;

        // プログラムカウンタの更新
        // メモリ上の機械語は (HERE_ope) (ModRM) (NEXT_ope) なので+2する
        e_eip = eip+2;

        // 略
      end
      // modが01, 10, 11の処理は略
    endcase
  end
endmodule

混乱した場合は、今日の記事の「5. 条件付きジャンプ命令」のセクションを再読して欲しい。 あそこではe_eipというワイヤを宣言し、always_comb中でその電圧値を作成していた。

そこで出てきたe_eipと全く同様に、e_write_flage_eaxを宣言し、always_comb中で電圧値を更新している。

重要なのはalways_combの中身だが、冗長になるので、mod=00に該当するadd [M], Rの処理だけを載せた。 この場合

e_write_flag  = 1;     // メモリの書き込みフラグを立てる
e_write_addr  = m_reg; // 書き込むアドレスはレジスタMの値そのもの
e_write_value = memval_m_reg + r_reg; // [M]=[M]+R

always_comb中で、e_write_***のメモリ更新用ワイヤに電圧値を設定することで、メモリに値を書き込んでいる。

どうでもいい話

x86のCPUをFPGA上に実装する方法を書いたけど、一回の記事としては分量が多すぎたと思う。

よくわからなかった場合は 前作CPUの作成記(前半) (後半)。 を読み直して欲しい。 複雑になっただけで、難しくなったわけではない。 ModRM周りを除けば前作CPUと全く変わらない。

今日でCPUの話題は終了だ。 x86エミュレータも作ったし、FPGAで実装したし、一区切りつけるにはちょうどいい。 明日からはコンパイラの自作だ。 製作時の記憶がかなり失われているので、頑張らなきゃな。