Nullable

旧レガシーガジェット研究所

CPUをつくる ~ FPGA編 ~

概要

FPGA上で動作するMIPSプロセッサを作ったので学習の流れや大まかな作り方をまとめる。

mips-fpga

https://github.com/0n1shi/mips-fpga

成果物

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イメージを使用。異なるアーキテクチャ向けに複数コンパイラを所持しており大変重宝した。

https://github.com/multiarch/crossbuild

コンパイルオプションで標準ライブラリを削ぎ、リンカースクリプトでロードするアドレスを変更した。

$(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上で論理回路図を書いてから命令の実装ひとつずつを行うといった流れで進めた。

Draw.io Integration

学習

今回改めて基本情報ぶりに論理回路を復習し、且つ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を購入し回路を組んだ。

f:id:k--onishi:20210516211702j:plain

論理ゲートから始まり論理式や真理値表の復習を行い、真理値表を積和標準形に直したりカルノー図を用いた簡略化、組み合わせ回路・順序回路などを学びカウンターや半加算機・全加算機などの設計を行ったことで簡単な論理回路であれば論理ゲートで組めるようになった。加えて自身が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ヶ月で読んだ書籍はこんな感じ。

まとめ

CPUをつくる過程で論理回路FPGA、QuartusやModelsimなど初めてのことばかりだったがその分学びが多かった。CPUエミュレータファミコンApple 2などで採用されていたMOS Technology 6502やMIPSなど、いくつか書いたことはあったので処理の流れや命令のフォーマットなどは理解していたが、実際に今回の開発でプロセッサがバイナリを回路レベルでどう食っていくのかを知ることができたのは大きかった。また少し計算機への理解が深まったように思う。