前回までのあらすじ
- 自作PCを作ろう!
- まずメモリを作ったよ!
- ISAを作ったよ!(コンパイラはまだ)
- アセンブリ言語を作ったよ!(コンパイラはまだ)
- CPUを作ったよ!
- 任意のプログラムを実行できるようになったよ!
前回からの変更
いっぱいあるので,ここでまとめて紹介はしないことにします.
これ以降で少しずつ説明します.
今回の目標
キーボードで何か入力し,その入力された文字をそのまま出力することをひとまず目標とします.
といっても,自作PCに直接キーボードや画面を繋いで…とやるのは難易度が高いので,一旦以下のような方法で妥協したいと思います.
キーボード入力はPYNQ-Z2搭載のCPUから行い,コマンドラインツールはPYNQ-Z2に搭載されているものを使用する.
PYNQ-Z2のCPUはキーボード入力をそのまま自作PCに送信し,自作PCは読み取った値をそのままPYNQ-Z2のCPUに返す.
PYNQ-Z2のCPUは受け取った値を画面出力する.
どう実現するのか
PYNQ-Z2のCPUと自作CPUのデータ通信はDMAを使用し,バッファにはAXI4-Stream Data FIFOを使用します.
標準入出力用のIOをCPUに増設します.
ついでに既存のレジスタも含めて記載すると以下の通りです.
| アドレス | 割り当て |
|---|---|
6'h00~6'h0f
|
CPU内のレジスタ. |
6'h10 |
SP.スタックポインタ.下記戻り先のうち,現在使用しているもの. |
6'h0f~6'h1a
|
呼び出し関数の戻り先を保存. |
6'h1c |
FLG.フラグ.書き込み禁止. |
6'h1d |
RSI.引数が格納されているレジスタの一つ目の番地 |
6'h1e |
RAX.演算結果. |
6'h1f |
PC.プログラムカウンタ.書き込み禁止. |
6'h20 |
タクトスイッチ.上位4ビットは0固定.書き込み禁止. |
6'h21 |
DIPスイッチ.上位6ビットは0固定.書き込み禁止. |
6'h22 |
LED.上位4ビットは0固定. |
6'h23 |
RGB LED.上位2ビットは0固定. |
6'h24 |
Pmod A. |
6'h25 |
Pmod B. |
6'h26 |
AR8~AR13.上位2ビットは0固定. |
6'h27 |
A,AR_SDA,AR_SCL.上位5ビットは0固定. |
6'h28 |
AR0~AR7. |
6'h29 |
AR_RST.上位7ビットは0固定.書き込み禁止. |
6'h2A |
AR_MISO,AR_SCK,AR_MOSI,AR_SS.上位4ビットは0固定. |
6'h2B |
アナログピンはいったんオミット |
6'h2C |
XADCもいったんオミット |
6'h2D |
GPIO0~GPIO7. |
6'h2E |
GPIO8~GPIO15. |
6'h2F |
GPIO16~GPIO23. |
6'h30 |
GPIO24~GPI27.上位4ビットは0固定. |
6'h31 |
標準入力データ |
6'h32 |
標準入力シグナル.{29'h0, tlast, tvalid, tready}
|
6'h33 |
標準出力データ |
6'h34 |
標準出力シグナル.{29'h0, tlast, tvalid, tready}
|
回路
必要なモジュールは以下です.
- キーボードの入力を受け取るデバイスドライバ.ただし,実際にはPYNQ-Z2のCPUからの通信を受け取る仕事をする
- CPUからの標準出力を送信するデバイスドライバ
デバイスドライバと言っても,DMAとのインターフェースとしてFIFOを置くだけなのですが.
ブロックデザインにDMAとFIFOを追加し,マザーボードにそれらとのインターフェースを追加します.回路側の準備としてはこのくらいですね.

※右下のやつはデバッグ用に追加したものなので気にしないでください.
あとは,これらにシグナルを送る部分を作ります.alu_sv.svを修正.
// 組み合わせ回路
always_comb begin
// 省略
// 標準入出力
stdout_tkeep = 4'hf;
stdout_tlast = 1'b1;
end
// 順序回路
always_ff @(posedge clk) begin
// リセット
if (!resetn || force_reset) begin
// 省略
// レジスタ
for (logic [5:0] i = 0; i <= 6'h30; i++) begin // 標準入出力以外を初期化
register[i] <= 0;
end
end
// 命令実行
else begin
// IOからレジスタに値を格納する
register[6'h20] <= {4'b0, btn};
register[6'h21] <= {6'b0, sw};
register[6'h31] <= stdin_tdata;
register[6'h32][2] <= stdin_tlast;
register[6'h32][1] <= stdin_tvalid;
register[6'h32][0] <= stdin_tready;
register[6'h33] <= stdout_tdata;
register[6'h34][2] <= stdout_tlast;
register[6'h34][1] <= stdout_tvalid;
register[6'h34][0] <= stdout_tready;
// 関数タイプごとに実行
unique case (command.m_type)
// 省略
endcase
end
end
それでですね,標準入出力用の命令ももう作っちゃいます.
レジスタへの値コピーとかで標準入出力を代替するの面倒くさいし.
そもそも関数とか命令ってこういう時のためにあるやつですよね.そうですよね?
標準入力はバッファを一文字読み込み,標準出力はバッファに一文字出力で行きたいと思います.
標準入出力は新たにIO系として作ります.本当は今までの形式に倣ってアルファベット一文字にしたかったけど入力と出力がある時点で一文字は無理.
標準入出力命令の機械語への翻訳はこんな感じ.
// 標準入出力系(IO系)
function machine_t scan(
input addr_t rd,
);
scan = {3'h7, SCAN, 4'h0, 6'h00, 6'h00, rd, 33'h000000000};
endfunction
function machine_t print(
input addr_t rs1,
input imm_t imm,
);
print = {3'h7, PRINT, 4'h0, rs1, 6'h00, 6'h00, imm};
endfunction
標準入出力の実際の回路はこんな感じ.
// 関数タイプごとに実行
unique case (command.m_type)
// 省略
// 標準入出力系
IO_TYPE: begin
unique case (command.func)
// 標準入力
SCAN: begin
unique case (stdin_state)
// 待機
IDLE: begin
// rdが書き込み可能であることを確認する
if (is_writable(command.rd)) begin
// 実行を指示
stdin_state <= EXECUTE;
stdin_tready <= 1'b1;
end
else begin
force_reset <= 1'b1;
end
end
// 実行
EXECUTE: begin
// データが送られてきているなら
if (stdin_tvalid) begin
register[command.rd] <= stdin_tdata;
// 一文字ずつ読み込むため,これだけ読みこんだら終了する
stdin_state <= IDLE;
stdin_tready <= 1'b0;
// プログラムカウンタをインクリメント
register[PC_ADDR] <= register[PC_ADDR] + 1;
end
else begin
// 読み取り準備が整っていることを送る
stdin_tready <= 1'b1;
end
end
// その他
default: begin
force_reset <= 1'b1;
end
endcase
end
// 標準出力
PRINT: begin
unique case (stdout_state)
// 待機
IDLE: begin
// イミディエイトデータを使用するなら
if (command.imm[32]) begin
// データを送る
stdout_tdata <= command.imm[31:0];
stdout_tvalid <= 1'b1;
stdout_state <= EXECUTE;
end
// rs1のデータを使用するなら
else begin
// rs1が読み込み可能なら
if (is_readable(command.rs1)) begin
stdout_tdata <= register[command.rs1];
stdout_tvalid <= 1'b1;
stdout_state <= EXECUTE;
end
else begin
force_reset <= 1'b1;
end
end
end
// 実行
EXECUTE: begin
// データの送信に成功したなら
if (stdout_tready) begin
// 一文字ずつ書き込むため,これだけ書き込んだら終了する
stdout_state <= IDLE;
stdout_tvalid <= 1'b0;
// プログラムカウンタをインクリメント
register[PC_ADDR] <= register[PC_ADDR] + 1;
end
else begin
// 書き込み準備が終わっていることを送る
stdout_tvalid <= 1'b1;
end
end
// その他
default: begin
force_reset <= 1'b1;
end
endcase
end
default: begin
force_reset <= 1'b1;
end
endcase
end
default: begin
force_reset <= 1'b1;
end
endcase
プログラム
PL側
標準入力を読み込んで標準出力に書きだす簡単なプログラムを書きました.
ちょくちょく使っていない定数がありますが,気にしないでください.
module rom_sv (
// メモリデータ読み出し
rom_read_if.slave rom_read
);
// import文
import machine_p::*;
localparam integer ROM_SIZE = 8;
localparam integer STATE_ADDR = 6'h00;
localparam integer TDATA_ADDR = 6'h01;
localparam integer TVALID_ON_ADDR = 6'h02;
localparam integer TREADY_ON_ADDR = 6'h03;
localparam integer STDIN_TVALID = 6'h04;
localparam integer PARAM1 = 6'h04;
localparam integer PARAM2 = 6'h05;
localparam integer PARAM3 = 6'h06;
localparam integer STDIN_DATA_ADDR = 6'h31;
localparam integer STDIN_SIGNAL_ADDR = 6'h32;
localparam integer STDOUT_DATA_ADDR = 6'h33;
localparam integer STDOUT_SIGNAL_ADDR = 6'h34;
// キーボード読み取りプログラム
machine_t machines[0:ROM_SIZE - 1] = {
// 現在の状態を保存する
mov(4'hf, 0, STATE_ADDR, {1'b1, 32'b0}),
// 各信号を保存しておく
// tvalidがonの状態
mov(4'hf, 0, TVALID_ON_ADDR, {1'b1, 32'h2}),
// treadyがonの状態 // 使ってない
mov(4'hf, 0, TREADY_ON_ADDR, {1'b1, 32'h1}),
// 標準入力のtvalidを抽出
and_(STDIN_SIGNAL_ADDR, TVALID_ON_ADDR, STDIN_TVALID),
// キーボード入力の割り込みが行われていないなら,最後の命令まで飛ぶ
ne(TVALID_ON_ADDR, STDIN_TVALID, {1'b1, 32'h3}),
// 標準入力から一文字読み込み,一時領域へ書き込む
scan(TDATA_ADDR),
// 標準出力へ出力
print(TDATA_ADDR, {1'b0, 32'h0}),
// pcをレジスタ登録後に戻す
jmp(0, {1'b1, 32'h3})
};
// メモリデータの読み出し
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
PS側
PS側は打ち込まれたキーの文字コードを一文字ずつ送信するだけです.簡単ですね.
別に一文字ずつの送受信じゃなくても行けるはずなんですが,なぜか偶数番目の文字だけ無視される怪現象が発生したため一文字ずつ送るようにしています.
#include <iostream>
#include <string>
extern "C" {
#include <pynq_api.h>
}
int main(void) {
char bit_path[] = "./bit/top_wrapper.bit";
const int ADDR = 0x40400000;
const int BURST_SIZE = 100; // 一度に送れるのは100文字まで
std::string line = "";
// ビットファイルの準備
PYNQ_loadBitstream(bit_path);
// メモリの準備
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;
int *read_data = (int *)read_memory.pointer;
for (int i = 0; i < BURST_SIZE; i++) {
write_data[i] = 0;
read_data[i] = 0;
}
// DMAの準備
PYNQ_AXI_DMA dma;
PYNQ_openDMA(&dma, ADDR);
// メイン部分
while (line != "exit") {
// 一行分の入力を受け付ける
std::cout << ">>> ";
std::cin >> line;
// 標準入出力から読み込んだデータを送る
for (int i = 0; i < line.size(); i++) {
// データコピー
write_data[0] = (int)line[i];
// 文字列を出力する
PYNQ_writeDMA(&dma, &write_memory, 0, sizeof(int) * 1);
PYNQ_waitForDMAComplete(&dma, AXI_DMA_WRITE);
}
int line_size = line.size();
for (int i = 0; i < line_size; i++) {
// 文字列を受け取る
PYNQ_readDMA(&dma, &read_memory, 0, sizeof(int) * 1);
PYNQ_waitForDMAComplete(&dma, AXI_DMA_READ);
// 受け取った文字列を出力する
std::cout << (char)read_data[0];
}
std::cout << std::endl;
}
// DMAを閉じる
PYNQ_closeDMA(&dma);
PYNQ_freeSharedMemory(&write_memory);
PYNQ_freeSharedMemory(&read_memory);
return 0;
}
実行結果
>>> a
a
>>> abcd
abcd
>>> hello,world!!
hello,world!!
>>> exit
exit
次回の目標
次はromに書き込むプログラムを変更して,足し算を行えるようにしていきましょう!!!
おまけ
久々にPYNQ-Z2のjupyter notebookにアクセスして,忘れていることも多かったので備忘録.
PYNQ-Z2とSSH接続する方法
ssh xilinx@(IPアドレス) -o ServerAliveIntrerval=15
# パスワードはxilinx
jupyter notebooksのパス
/home/xilinx/jupyter_notebooks
エクスプローラーからpynqのディレクトリにアクセスするときのパス
\\pynq
名前とパスワードは両方xilinx
LAN内の機器を検索する方法
arp -a
pynq_api.hのインストール方法
git clone https://github.com/mesham/pynq_api.git
cd pynq_api
make
sudo make install
公式サイト