0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自作PCを作る8 ~CALL関数とRET関数を実装する~

0
Posted at

前回までのあらすじ

  • 自作PCを作ろう!
  • まずメモリを作ったよ!
  • ISAを作ったよ!(コンパイラはまだ)
  • アセンブリ言語を作ったよ!(コンパイラはまだ)
  • CPUを作ったよ!
  • 任意のプログラムを実行できるようになったよ!
  • キーボード入力を受け付けられるようになったよ!(ただし独力ではない)
  • 複数桁+複数桁の足し算が行えるようになったよ!

今回の目標

今回の目標を立てるにあたって,ロードマップについてClaudeに相談しました.
最終目標を改めて整理してみますと,シェル上でコマンドを実行してlscdmkdirを動かすことです.
その途中経過として,機械語で何か任意のプログラムを動かす的なことを今までやってきました.

これからは以下のロードマップで進めていきます.

  1. CALL/RET命令を実装する(既存のjmp命令はpcを変更するだけだが,これらの命令は同時にスタックポインタなどもいじる)
  2. RAM上のファイルシステムとそれを操作するシェルを実装する(まずは揮発性RAMで簡単に実装する)
  3. SDカードに対応する(ファイルシステムを不揮発性にする)

という事でこの記事では,CALL/RET関数を実装していきます.
ここで実装するCALLやRETは,あとでlsを呼び出すときとか,あるいはls内部で関数を呼び出すときに使います.

ここで実装するもののイメージとしては,ROMに以下のプログラムが全部入っている状態です.

  • 初期化処理.BIOS的な
  • シェルコマンド.入力を待機して,入力が行われたら以下の処理を実行する.実行が終わったらまた次の入力が来るまで待機する
  • lsコマンド
  • cdコマンド
  • mkdirコマンド

ちなみに今現在の設計ではカーネルモードやユーザーモードの区別はなく,全部の命令を直接実行できる状態になっています.
これもいずれ改善したいと思っているところです.

ここから本題

まずは,ram.svhに定義されているstate_enumutil.svhに移動させました.
RAM以外でもこの列挙体を使用しているためです.

ではCALLRETを実装していきます.
この二つの命令はジャンプ系として追加します.

machine.svh
    // ジャンプ系(J系)
    function machine_t jmp(
        input addr_t rs1,
        input imm_t imm
    );
        jmp = {3'h5, JMP, 4'h0, rs1, 6'h00, 6'h00, imm};
    endfunction
    // 関数呼び出し(戻り先をスタックに保存してジャンプ)
    function machine_t call(
        input addr_t rs1,
        input imm_t imm
    );
        call = {3'h5, CALL, 4'h0, rs1, 6'h00, 6'h00, imm};
    endfunction
    // 関数リターン(スタックから戻り先を復元してジャンプ.引数なし)
    function machine_t ret(

    );
        ret = {3'h5, RET, 4'h0, 6'h00, 6'h00, 6'h00, 33'h000000000};
    endfunction

CPUは,フェッチ,実行前確認,実行の三つのフェーズに分かれていましたね.
まず実行前確認に追加.

これらの関数はスタックポインタを読み書きしますが,スタックポインタに対しての読み書き権限はチェックしません.
もちろんMOVなどからスタックポインタを読み書きすることは禁止です.

alu_sv.sv
                        // ジャンプ系(J系)
                        J_TYPE: begin
                            // 命令ごとのチェック項目
                            unique case (command.func)
                                // ジャンプ・関数呼び出しはジャンプ先の指定方法を確認する
                                JMP, CALL: begin
                                    // イミディエイトデータを使用しないなら
                                    if (!command.imm[32]) begin
                                        // 指定されているアドレスが全て使用可能な場合のみ,処理を実行する
                                        if (is_readable(command.rs1)) begin
                                            cpu_phase <= CPU_EXECUTE;
                                        end
                                        else begin
                                            force_reset <= 1'b1;
                                        end
                                    end
                                    // イミディエイトデータを使用するならチェック不要
                                    else begin
                                        cpu_phase <= CPU_EXECUTE;
                                    end

                                    // 実行前準備
                                    rs1_val_r <= register[command.rs1];
                                    imm_r <= command.imm;
                                    func_r <= command.func;
                                end

                                // 関数リターンは引数なしなのでチェック不要
                                RET: begin
                                    cpu_phase <= CPU_EXECUTE;

                                    // 実行前準備
                                    func_r <= command.func;
                                end

                                // それ以外はオミット
                                default: begin
                                    force_reset <= 1'b1;
                                end
                            endcase
                        end

次に実行フェーズです.
スタックポインタに保存されている基準となるメモリ番地があり,関数呼び出しが深くなると番地を小さくしながらスタックを積み上げていきます.
逆に関数呼び出しが終わって呼び出し元関数に戻るときは,スタックポインタをインクリメントしていきます.

…の予定だったのですが,それは将来的に余裕があればやることにします.
いったん,レジスタ上に用意したスタック領域に保存していくことにします.
これだと関数呼び出しが十階層までしか対応していませんが,メモリ呼び出しよりは処理がだいぶ楽になります.
ちなみに関数呼び出しが深くなるとレジスタの番地はインクリメントされます(RAMの時と逆).

alu_sv.sv
                        // ジャンプ系
                        J_TYPE: begin
                            // 命令ごとに処理実行
                            unique case (func_r)
                                // ジャンプ
                                JMP: begin
                                    // イミディエイトデータを使用する?
                                    if (imm_r[32]) begin
                                        register[PC_ADDR] <= imm_r[31:0];
                                    end
                                    else begin
                                        register[PC_ADDR] <= rs1_val_r;
                                    end

                                    // フェッチに戻る
                                    cpu_phase <= CPU_FETCH;
                                end

                                // 関数呼び出し
                                CALL: begin
                                    // 戻り先(PC+1)を次の戻り先レジスタに保存する
                                    register[register[SP_ADDR] + 1] <= register[PC_ADDR] + 1;
                                    // スタックポインタを進める
                                    register[SP_ADDR] <= register[SP_ADDR] + 1;
                                    // ジャンプ先へ移動する
                                    if (imm_r[32]) begin
                                        register[PC_ADDR] <= imm_r[31:0];
                                    end
                                    else begin
                                        register[PC_ADDR] <= rs1_val_r;
                                    end

                                    // フェッチに戻る
                                    cpu_phase <= CPU_FETCH;
                                end

                                // 関数リターン
                                RET: begin
                                    // スタックポインタが指す戻り先レジスタの値へジャンプする
                                    register[PC_ADDR] <= register[register[SP_ADDR]];
                                    // スタックポインタを戻す
                                    register[SP_ADDR] <= register[SP_ADDR] - 1;

                                    // フェッチに戻る
                                    cpu_phase <= CPU_FETCH;
                                end

                                // それ以外はオミット
                                default: begin
                                    force_reset <= 1'b1;
                                end
                            endcase
                        end

それじゃあプログラムを書いてみて,CALLRETを試してみましょう.

rom_sv.sv
`include "rom.svh"
`include "machine.svh"
`include "register.svh"

module rom_sv (
    // メモリデータ読み出し
    rom_read_if.slave rom_read
    );
    // import文
    import machine_p::*;

    localparam integer ROM_SIZE = 10;

    // ===== 定数 =====
    // プログラムアドレス
    localparam integer FUNC1_ADDR = 32'h04;  // func1の先頭PC
    localparam integer FUNC2_ADDR = 32'h08;  // func2の先頭PC
    localparam integer HALT_ADDR  = 32'h03;  // 停止用の無限ループのPC
    // 文字コード
    localparam integer CHAR_A = 32'h41;  // 文字「A」
    localparam integer CHAR_B = 32'h42;  // 文字「B」
    localparam integer CHAR_C = 32'h43;  // 文字「C」
    localparam integer CHAR_D = 32'h44;  // 文字「D」
    localparam integer CHAR_E = 32'h45;  // 文字「E」

    // CALL/RET動作確認プログラム
    // 2階層のネスト呼び出しを行い,正常なら「ABCDE」を出力する
    machine_t machines[0:ROM_SIZE - 1] = {
        // ===== main =====
        print(0, {1'b1, CHAR_A}),            // PC=0: 「A」を出力
        call(0, {1'b1, FUNC1_ADDR}),         // PC=1: func1を呼び出す
        print(0, {1'b1, CHAR_E}),            // PC=2: func1から戻ったら「E」を出力
        jmp(0, {1'b1, HALT_ADDR}),           // PC=3: 無限ループで停止する

        // ===== func1 =====
        print(0, {1'b1, CHAR_B}),            // PC=4: 「B」を出力
        call(0, {1'b1, FUNC2_ADDR}),         // PC=5: func2を呼び出す
        print(0, {1'b1, CHAR_D}),            // PC=6: func2から戻ったら「D」を出力
        ret(),                               // PC=7: mainへ戻る

        // ===== func2 =====
        print(0, {1'b1, CHAR_C}),            // PC=8: 「C」を出力
        ret()                                // PC=9: func1へ戻る
    };

    // メモリデータの読み出し
    always_comb begin
        if (rom_read.pc >= ROM_SIZE) begin
            rom_read.machine = nop();
        end else begin
            rom_read.machine = machines[rom_read.pc];
        end
    end

endmodule

あとは,このプログラムを起動する側のC++プログラムも必要ですね.

run.cpp
#include <iostream>
#include <string>
extern "C" {
#include <pynq_api.h>
}

int main(void) {
    char bit_path[] = "./bit/top_wrapper.bit";

    // AXI DMAのベースアドレス (Vivado Address Editorで /axi_dma_0/S_AXI_LITE に割り当てたアドレス)
    // PSはこのアドレスを通じてDMAの制御レジスタを読み書きし,転送を命令する
    const int ADDR = 0x40400000;

    const int BURST_SIZE = 100;   // 共有メモリに確保する要素数
    const int OUTPUT_SIZE = 5;    // FPGAが出力する文字数 (ABCDEの5文字)

    // ビットストリームをFPGAに書き込む.これによりPL側のカスタムCPUが起動する
    PYNQ_loadBitstream(bit_path);

    // PS-PL間でDMAが直接アクセスできる共有DDRメモリ領域を確保する.
    // 通常のmallocと異なり,DMAがアクセスできる物理アドレスが保証された領域になる.
    // write_memory: PSが値を書き込み,DMAがPLへ送り出す領域 (PS→PL方向,今回は未使用)
    // read_memory:  DMAがPLからの値を書き込み,PSが読み出す領域 (PL→PS方向)
    PYNQ_SHARED_MEMORY write_memory, read_memory;
    PYNQ_allocatedSharedMemory(&write_memory, sizeof(int) * BURST_SIZE, 1);
    PYNQ_allocatedSharedMemory(&read_memory, sizeof(int) * BURST_SIZE, 1);
    int *write_data = (int *)write_memory.pointer;  // write_memoryをint配列として扱うポインタ
    int *read_data  = (int *)read_memory.pointer;   // read_memoryをint配列として扱うポインタ
    for (int i = 0; i < BURST_SIZE; i++) {
        write_data[i] = 0;
        read_data[i] = 0;
    }

    // DMAを初期化する.ADDRのレジスタをメモリマップし,DMAを使える状態にする
    PYNQ_AXI_DMA dma;
    PYNQ_openDMA(&dma, ADDR);

    // メイン部分
    {
        // FPGAからの出力を1文字ずつ受信する
        // FPGAのCPUはPRINT命令で1文字ずつstdout_tdataに出力するため,
        // 1文字ごとに受信・完了待ちを繰り返す
        for (int i = 0; i < OUTPUT_SIZE; i++) {
            // DMAに「PLからsizeof(int)バイト受け取ってread_memoryに書け」と命令する
            // FPGAがPRINT命令でstdout_tdataに値を出力するまでここで待機する
            PYNQ_readDMA(&dma, &read_memory, 0, sizeof(int) * 1);
            PYNQ_waitForDMAComplete(&dma, AXI_DMA_READ);

            // 受信した文字を即座に表示する(どこまで出力されたか確認するため)
            std::cout << (char)read_data[0] << std::flush;
        }
        std::cout << std::endl;
    }

    // 使用したリソースを解放する
    PYNQ_closeDMA(&dma);
    PYNQ_freeSharedMemory(&write_memory);
    PYNQ_freeSharedMemory(&read_memory);

    return 0;
}

実行結果はこちら.と言っても豪華なものではないですが.

ABCDE

今後の展望

ということでCALLRETの実装が完了しましたが,これはあまりにも使いづら過ぎます.
というのも,機械語を直接記述した場合はジャンプ先をハードコーディングしなければならず,命令が少しでも変わればその後のCALLの引数を全部作り直しになるからです.
という事で次回の記事では,ずっと放置してきたアセンブラ作成をちゃんと完了させようと思います.

ちなみに今回作成した命令にも残件はあります.

  • 関数呼び出しが十階層までしかできない(RAMにスタックを積めばもっと行ける)
  • スタックオーバーフローを検知していない
  • ソース内に二重インデックスがある(`register[register[SP_ADDR]]').現状は問題になっていないが,将来的には解消した方がいい
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?