17日目: [x86] CPU (IA-32) をFPGAで製作
この記事はひとりでCPUとエミュレータとコンパイラを作る Advent Calendar 2017の17日目の記事です。
ちょうど1週間前に 単純なCPUをFPGAで作った。
そして昨日は x86のエミュレータを完成させた。
この2つを組み合わせて、FPGA上で動くx86のCPUを作ろう。 いよいよ本丸に切り込むぞ!!!
x86をHDLで記述する日本語書籍や解説記事は、いまのところ存在しないと思う。 先陣を切った。
仕様
全命令を実装するのは大変なので、以下の表の赤い命令にしぼろう。
これらの命令さえあれば、再帰呼出しでフィボナッチ数が計算できる。
基本戦略
負担を軽減するため、 前作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も今作のx86もmake_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でオペランドを指定
opecode
をmake_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 );
mod
とr
とm
は分かるだろう。
r_reg
は3bitのr
で指定されるレジスタの出口電圧を表している。m_reg
も同様。
m_reg_plus_imm8
はm_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
に繋ぐモジュールだと思ってほしい。
処理を追うと
- 入力の
modrm
から、出力のmod
とr
とm
を切り出す - ワイヤの
end_r_reg
を宣言し、r_reg
の入力につなぐ - 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文を使うことなく分岐することが可能になる。
つまり
- クロック毎に全種類の演算を行う
- 結果を「演算処理のワイヤの配列」に格納する。添字は計算種別に対応する1byteのオペコードにする
- メモリから取得したオペコードをもとに、「欲しい計算結果のワイヤ」を得る
- 取得したワイヤの電圧値で、次回クロック立ち上がりの瞬間にレジスタを上書き
こうすることで「演算処理のワイヤを作る」部分を命令毎にモジュール化できて、可読性が向上する。
説明が抽象的でわかりにくいかもしれない。コードを見よう。
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>b
かa<b
か)が定まっている。
これを実現するために
- メモリには何も書き込まない
eax
,ecx
等のレジスタも前回の値を保持- フラグも変更しない
- プログラムカウンタ
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_flag
やe_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で実装したし、一区切りつけるにはちょうどいい。 明日からはコンパイラの自作だ。 製作時の記憶がかなり失われているので、頑張らなきゃな。