前回までのあらすじ
- 自作PCを作ろう!
- まずメモリを作ったよ!
- ISAを作ったよ!(コンパイラはまだ)
- アセンブリ言語を作ったよ!(コンパイラはまだ)
- CPUを作ったよ!
- 任意のプログラムを実行できるようになったよ!
- キーボード入力を受け付けられるようになったよ!(ただし独力ではない)
- 複数桁+複数桁の足し算が行えるようになったよ!
今回の目標
今回の目標を立てるにあたって,ロードマップについてClaudeに相談しました.
最終目標を改めて整理してみますと,シェル上でコマンドを実行してlsやcdやmkdirを動かすことです.
その途中経過として,機械語で何か任意のプログラムを動かす的なことを今までやってきました.
これからは以下のロードマップで進めていきます.
- CALL/RET命令を実装する(既存のjmp命令はpcを変更するだけだが,これらの命令は同時にスタックポインタなどもいじる)
- RAM上のファイルシステムとそれを操作するシェルを実装する(まずは揮発性RAMで簡単に実装する)
- SDカードに対応する(ファイルシステムを不揮発性にする)
という事でこの記事では,CALL/RET関数を実装していきます.
ここで実装するCALLやRETは,あとでlsを呼び出すときとか,あるいはls内部で関数を呼び出すときに使います.
ここで実装するもののイメージとしては,ROMに以下のプログラムが全部入っている状態です.
- 初期化処理.BIOS的な
- シェルコマンド.入力を待機して,入力が行われたら以下の処理を実行する.実行が終わったらまた次の入力が来るまで待機する
- lsコマンド
- cdコマンド
- mkdirコマンド
- …
ちなみに今現在の設計ではカーネルモードやユーザーモードの区別はなく,全部の命令を直接実行できる状態になっています.
これもいずれ改善したいと思っているところです.
ここから本題
まずは,ram.svhに定義されているstate_enumをutil.svhに移動させました.
RAM以外でもこの列挙体を使用しているためです.
ではCALLとRETを実装していきます.
この二つの命令はジャンプ系として追加します.
// ジャンプ系(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などからスタックポインタを読み書きすることは禁止です.
// ジャンプ系(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の時と逆).
// ジャンプ系
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
それじゃあプログラムを書いてみて,CALLとRETを試してみましょう.
`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++プログラムも必要ですね.
#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
今後の展望
ということでCALLとRETの実装が完了しましたが,これはあまりにも使いづら過ぎます.
というのも,機械語を直接記述した場合はジャンプ先をハードコーディングしなければならず,命令が少しでも変わればその後のCALLの引数を全部作り直しになるからです.
という事で次回の記事では,ずっと放置してきたアセンブラ作成をちゃんと完了させようと思います.
ちなみに今回作成した命令にも残件はあります.
- 関数呼び出しが十階層までしかできない(RAMにスタックを積めばもっと行ける)
- スタックオーバーフローを検知していない
- ソース内に二重インデックスがある(`register[register[SP_ADDR]]').現状は問題になっていないが,将来的には解消した方がいい