CPUをつくる ~ FPGA編 ~
概要
FPGA上で動作するMIPSプロセッサを作ったので学習の流れや大まかな作り方をまとめる。
成果物
FPGAでつくったMIPS上でフィボナッチ数の計算が動いた!以前Goで書いたMIPSのエミュレータとも結果が一致している・・・! pic.twitter.com/OX3Vg64LB5
— 0n1shi (@0n1shi) 2021年5月15日
C言語で書いたフィボナッチ数を求めるコードをクロスコンパイルし、それをROMとして読ませてコードを走らせた。
int fib(int n); int main() { int a = fib(10); } int fib(int n) { if (n == 0) return 0; if (n == 1) return 1; return fib(n - 1) + fib(n - 2); }
クロスコンパイルはmultiarch/crossbuild
というDockerイメージを使用。異なるアーキテクチャ向けに複数コンパイラを所持しており大変重宝した。
コンパイルオプションで標準ライブラリを削ぎ、リンカースクリプトでロードするアドレスを変更した。
$(CC) -ffreestanding -nostdlib -mips1 -O0 -c $(SRC)/fib.c -o $(BIN)/fib.o
SECTIONS { . = 0x0000; .text : { *(.text) } . = 0x8000; .data : { *(.data) } .bss : { *(.bss) } /DISCARD/ : { *(.reginfo) *(.MIPS.abiflags) *(.pdr) *(.comment) *(.gnu.attributes) } }
作成したバイナリを16進数の文字としてテキストファイルに変換し
$ objdump -m mips -b binary --endian=little -D $(BIN)/fib | awk 'NR > 7 {printf "%s // %-6s %-10s\n", $$2,$$3,$$4}' > $(BIN)/fib.rom
ROMとして読み込んだ。
module ROM ( input logic [31:0] addr, output logic [31:0] val ); always_comb begin case(addr) // main() 32'h00: val = 32'h27BDFFE0; // addiu sp,sp,-32 32'h04: val = 32'hAFBF001C; // sw ra,28(sp) 32'h08: val = 32'hAFBE0018; // sw s8,24(sp) 32'h0C: val = 32'h03A0F021; // move (addu) s8,sp 32'h10: val = 32'h2404000A; // li (addiu) a0,10 32'h14: val = 32'h0C000011; // jal 0x44 32'h18: val = 32'h00200825; // move (or) at,at 32'h1C: val = 32'h00401821; // move (addu) v1,v0 32'h20: val = 32'h3C020001; // lui v0,0x1 : endcase end endmodule
Modelsim上で実行させた様子。
見づらいがregs[3]
(v1)が55となっておりフィボナッチ数を正しく求められていることがわかる。
これは以前Go言語で書いたエミュレータとも結果が一致している。
論理回路図は以下。
(動く頃にはリファクタリングをする元気はなかった・・・)
実装
実装の流れとしては最初に必要となるコンポーネントの基礎部分を作成し、次に実行される命令をひとつひとつ実装する過程でコンポーネントを繋いでいった。
以下が実装したコンポーネント。
プログラムカウンタやRAM、ROM、レジスタなどは動作がシンプルなので一番先に実装しテストを行った。
プログラムカウンタは32bitでクロック毎に4ずつインクリメント
module PC ( input logic clk, input logic [31:0] next, output logic [31:0] current = 32'd0 ); always_ff @(posedge clk) begin current <= next; end endmodule
RAMは指定のアドレスから4byteを読み込む。
module RAM ( input logic clk, input logic write_enable, input logic [31:0] addr, input logic [31:0] set_val, output logic [31:0] val ); logic [7:0] mem [65535:0] = '{default:'d0}; always_ff @(posedge clk) begin if (write_enable) begin mem[addr+3] <= set_val[31:24]; mem[addr+2] <= set_val[23:16]; mem[addr+1] <= set_val[15:8]; mem[addr+0] <= set_val[7:0]; end end assign val = { mem[addr+3], mem[addr+2], mem[addr+1], mem[addr] }; endmodule
module reg_file ( input logic clk, input logic write_enable_3, input logic [4:0] sel_1, // read input logic [4:0] sel_2, // read input logic [4:0] sel_3, // write output logic [31:0] val_1, // read output logic [31:0] val_2, // read input logic [31:0] val_3, // write output logic [31:0] ra // for debug ); // 32 regiters (32 bit each) logic [31:0] regs [31:0] = { 32'h0000, // 31) RA: Return address for subroutine 32'h0000, // 30) FP: Frame pointer or ninth subroutine variable 32'hFFFF, // 29) SP: Stack pointer 32'h0000, // 28) GP: Global pointer; used to access "static" or "extern" variables 32'h0000, // 27) K1: Reserved for use by interrupt/trap handler; may change under your feet 32'h0000, // 26) K0: Reserved for use by interrupt/trap handler; may change under your feet 32'h0000, // 25) T9: (temporaries) Subroutines can use without saving 32'h0000, // 24) T8: (temporaries) Subroutines can use without saving 32'h0000, // 23) S7: Subroutine register variables, must be restored before returning 32'h0000, // 22) S6: Subroutine register variables, must be restored before returning 32'h0000, // 21) S5: Subroutine register variables, must be restored before returning 32'h0000, // 20) S4: Subroutine register variables, must be restored before returning 32'h0000, // 19) S3: Subroutine register variables, must be restored before returning 32'h0000, // 18) S2: Subroutine register variables, must be restored before returning 32'h0000, // 17) S1: Subroutine register variables, must be restored before returning 32'h0000, // 16) S0: Subroutine register variables, must be restored before returning 32'h0000, // 15) T7: (temporaries) Subroutines can use without saving 32'h0000, // 14) T6: (temporaries) Subroutines can use without saving 32'h0000, // 13) T5: (temporaries) Subroutines can use without saving 32'h0000, // 12) T4: (temporaries) Subroutines can use without saving 32'h0000, // 11) T3: (temporaries) Subroutines can use without saving 32'h0000, // 10) T2: (temporaries) Subroutines can use without saving 32'h0000, // 09) T1: (temporaries) Subroutines can use without saving 32'h0000, // 08) T0: (temporaries) Subroutines can use without saving 32'h0000, // 07) A3: (arguments) First four parameters for a subroutine 32'h0000, // 06) A2: (arguments) First four parameters for a subroutine 32'h0000, // 05) A1: (arguments) First four parameters for a subroutine 32'h0000, // 04) A0: (arguments) First four parameters for a subroutine 32'h0000, // 03) V1: Value returned by subroutine 32'h0000, // 02) V0: Value returned by subroutine 32'h0000, // 01) AT: (assembler temporary) Reserved for use by assembler 32'h0000 // 00) Zero: Always returns 0 }; always_ff @(posedge clk) begin if (write_enable_3 && sel_3 != 0) regs[sel_3] <= val_3; end assign val_1 = regs[sel_1]; assign val_2 = regs[sel_2]; assign ra = regs[31]; // for debug endmodule
デコーダはフェッチした命令から前述のコンポーネントやマルチプレクサへ制御信号を送理、各出力を決定する。最初はデコーダからALU向けに3bitの制御信号のみを伸ばし命令を実装する過程でALUの機能やマルチプレクサを追加しながら開発を進めた。
module decoder ( input logic [5:0] opcode, input logic [5:0] func, output logic write_reg = 1'b0, output logic write_mem = 1'b0, output logic use_imm = 1'b0, output logic read_ram = 1'b0, output logic [1:0] dst_reg = 2'b0, output logic [1:0] jmp = 2'b0, output logic branch = 1'b0, output logic [3:0] alu_ctrl = 4'b0 ); always_comb begin case (opcode) /* type R */ op_type_r: begin case (func) // addu rd, rs, rt => rd = rs + rt; func_addu: begin write_reg = 1'b1; write_mem = 1'b0; use_imm = 1'b0; read_ram = 1'b0; dst_reg = dst_reg_rd; jmp = jmp_not; branch = 1'b0; alu_ctrl = ALU.ctrl_addu; end :
ALUは算術論理演算ユニットで入力を受けて論理演算や論理演算、ビットシフトなどを行う。
module ALU ( input logic [3:0] ctrl, input logic [31:0] arg1, input logic [31:0] arg2, output logic zero = 'd0, output logic [31:0] result = 32'd0 ); always_comb begin zero = arg1 == arg2 ? 1'b1 : 1'b0; case (ctrl) /* addiu */ ctrl_addiu: begin result = arg1 + arg2; end ctrl_or: begin result = arg1 | arg2; end ctrl_lui: begin result = arg2 << 16; end : default: begin zero = 1'bx; result = 32'bx; end endcase end endmodule
各コンポーネントをある程度テストし正常な動作を確認した後に、上位のモジュールで各コンポーネントを繋ぎ実際に実行される命令をROMから読み出していった。
実行される命令を順番に実装し過程でマルチプレクサなどを追加していったので論理回路自体はリファクタリングの余地を多く残していると思う(特にプログラムカウンタ周り)。
途中で複雑になった論理回路を把握しきれなくなりそうだったので、Draw.ioのプラグインをインストールしVSCode上で論理回路図を書いてから命令の実装ひとつずつを行うといった流れで進めた。
https://t.co/tnBDa9hbThのvscodeプラグインとても良い、Webと比べても遜色ないクオリティ pic.twitter.com/Zi2MWgdeNy
— 0n1shi (@0n1shi) 2021年4月26日
学習
今回改めて基本情報ぶりに論理回路を復習し、且つFPGAは完全に初見だったので学習の過程もまとめておく。
まずモチベーションが”CPUを作りたい”だったこともあり特に何も考えず以下の書籍を購入した。
論理回路の基礎など前半は読み進められたもののFPGAやSystem Verilogについての知識が浅かったので、実際に手を動かしてコードを書いたりデバッグしたりということができずCPUを実装する章の直前で一旦読むのをやめた。
次にFPGAやHDL、IDEなどの理解を深めるため購入したのが以下のDE0-CVとIntel FPGAの入門書。
Verilog HDLやIDEであるQuartusの基礎(プロジェクトの作成やピンアサイン、コンパイル)などを学び、第4章までは書籍内で用意されている発展的な課題まで取り組んだ。5章以降は内臓CPUを使った組み込み開発がメインのコンテンツとなっていたので手をつけなかった。
ただVerilog HDLを書いてデジタル時計などは動作したものの、いまひとつHDLがどういった論理回路に変換されているのか実感が湧かなかったので以下の書籍を購入した。
この書籍の素晴らしいところは図式した論理回路を実際のICを使って組むことが可能な点で、紹介されていたICを購入し回路を組んだ。
論理ゲートから始まり論理式や真理値表の復習を行い、真理値表を積和標準形に直したりカルノー図を用いた簡略化、組み合わせ回路・順序回路などを学びカウンターや半加算機・全加算機などの設計を行ったことで簡単な論理回路であれば論理ゲートで組めるようになった。加えて自身がHDLで書いていた回路に対しても改めて理解が深まった。
ここで改めて【作ろう! CPU ~基礎から理解するコンピューターのしくみ】を読み直し4 bit CPUであるTD4をつくった。
https://github.com/0n1shi/fpga-td4
TD4はほぼ書籍通りに作ったこともあり、これといった試行錯誤も特になく割とあっさりとできてしまった。
本来の目標である”CPUをつくる”というのは達成したので終わってもよかったのだけど、ここで昔読んだ記事を思い出してしまう。
ハード素人が32bit CPUをFPGAで自作して動かすまで読んだ本のまとめ
改めて読むとMIPS実装へのモチベーションが湧いてきたので追加でまた書籍を何冊か購入した。
【動かしてわかる CPUの作り方10講】は論理回路やFPGA、CPU実装以外にもアセンブリやCPUエミュレータの作成など書籍としてカバーする範囲が広い。個人的にクロックの生成に関して言及している書籍が他になく(もしかしたら"コンピュータの構成と設計"では言及されているかも)非常に助かった。
【ディジタル回路設計とコンピュータアーキテクチャ 第2版】ではMIPSの実装が内容としてカバーされており大変参考になった。今回自身が実装した命令のほとんどは書籍でカバーされていないものの実装の進め方などに関してはとても学びが多かった。
【コンピュータの構成と設計 第5版 上下】は言わずと知れた良書で、MIPSの実装について触れる部分については読み込んだ。内容としては【ディジタル回路設計とコンピュータアーキテクチャ 第2版】とかぶる部分も多く改めて実装方法を確認するという感じだった。
結局ここ2~3ヶ月で読んだ書籍はこんな感じ。
ここ2~3ヶ月で購入して読んだ書籍たち. pic.twitter.com/mVvavmolAc
— 0n1shi (@0n1shi) 2021年5月5日
まとめ
CPUをつくる過程で論理回路やFPGA、QuartusやModelsimなど初めてのことばかりだったがその分学びが多かった。CPUエミュレータはファミコンやApple 2などで採用されていたMOS Technology 6502やMIPSなど、いくつか書いたことはあったので処理の流れや命令のフォーマットなどは理解していたが、実際に今回の開発でプロセッサがバイナリを回路レベルでどう食っていくのかを知ることができたのは大きかった。また少し計算機への理解が深まったように思う。
エミュレータは何個か書いたことがあってプロセッサの命令フォーマットやおおまかな流れは理解していたけど、FPGAで回路からつくったことで実際にプロセッサがどうアセンブリを食うのか見えたので、また少し計算機への理解が深まった。
— 0n1shi (@0n1shi) 2021年5月15日