simpleTD4
TD4は『CPUの創りかた』で設計されたシンプルな4bitのCPUです。『CPUの創りかた』ではICで実装していますが、simpleTD4ではTD4をVerilogで60行程度の1ファイルで実装しました。
『作ろう! CPU』という書籍ではSystemVerilogでTD4を実装していて、SystemVerilogの方が色々な点でVerilogより優れていると思いますが、2021/4/24時点ではsimpleTD4で使用しているIcarus VerlogのSystemVerilog対応が不十分なのでVerilogで実装しています。
前半でsimpleTD4の動かし方を説明し、後半でsimpleTD4の実装を行単位で解説していきます。
simpleTD4の動かし方
PCでシミュレーションする場合
Icarus verlogでシミュレーションします。Linuxなら大抵のディストリビューションでバイナリが用意されていると思います。Windows版もあります。
ダウンロード
gitでファイルをダウンロードしてください。
$ git clone https://github.com/asfdrwe/simpleTD4.git
論理合成
Icarus verilogで論理合成します。
$ iverlog -o TD4 TD4.v TD4_test.v
シミュレーション実行
simleTD4はROM.binを命令メモリとして読み込み実行します。
$ ./TD4
gtkwaveでsimpleTD4の信号線の状態を調べることができます。./TD4実行後にgtkwaveでTD4.vcdを読み込んでください。
$ gtkwave TD4.vcd
ROM.bin
simpleTD4ではROM.binを差し替えることで動作が変わります。
まずはあらかじめ用意してある3つの例、ROMLED.bin、ROMRAMEM.bin、ROMINOUT.binのファイル名をROM.binに変えてTD4を実行してみてください。
-
ROMLED.bin
TD4のLED点滅プログラム(外部出力)です。
0011, 0110, 1100, 1000, 1000, 1100, 0110, 0011, 0001の順に繰り返します。 -
ROMRAMEM.bin
TD4のラーメンタイマープログラムです。
0111のあと少し経つと0110になりそのあと0000と0100を繰り返し最後1000になります。
1サイクル1秒にすれば3分後1000になるはずです。 -
ROMINOUT.bin
TD4の入力と出力のテストプログラムです。
入力内容をBレジスタに入れBレジスタの内容を出力します。
自分でハンドアセンブルしてROM.binを作成することもできます。
ROM.binは1命令を1行8bitの2進数で記述します。_は区切りで無視されます。 // 以下はコメントです。 以下の仕様と命令一覧を参考にしてください。
TD4の仕様
TD4は1クロック1秒(1Hz)で動作し1クロックですべての処理を行うシングルサイクルのCPUです。
- 命令メモリ(16バイト)
- レジスタ(4bit)
- Aレジスタ
- Bレジスタ
- OUTレジスタ
- プログラムカウンタ(1サイクルごとに通常+1)
- フラグレジスタ
- キャリーフラグ(1bit)
- 算術論理演算装置(ALU)
- 4bit+4bitの加算演算のみ
- 入出力
- 4bit入力ポート
- 4bit出力ポート(OUTレジスタ接続)
- 命令フォーマット
- 8bit固定長 上位4bitが命令・下位4bitが即値
TD4の命令一覧
(A => Aレジスタ、B => Bレジスタ、OUT => OUTレジスタ(出力ポート)、PC => プログラムカウンタ、IN => 入力ポート、 IM => 即値)
命令 | ニモニック | 意味 |
---|---|---|
0000 | ADD A, Im | A + 即値(Im)をAに代入 (A + Im => A) |
0001 | MOV A, B | BをAに代入(B + 0000 => A) |
0010 | IN A | 入力ポートの値をAに代入(IN + 0000 => A) |
0011 | MOV A, Im | 即値(Im)の値をAに代入(Im + 0000 => A) |
0100 | MOV B, A | AをBに代入 (A + 0000 => B) |
0101 | ADD B, Im | B + 即値(Im)をBに代入 (B + Im => B) |
0110 | IN B | 入力ポートの値をBに代入 (IN + 0000 => B) |
0111 | MOV B, Im | 即値(Im)の値をBに代入 (Im + 0000 => B) |
1001 | OUT B | BをOUTレジスタに代入 (B + 0000 => OUT) |
1011 | OUT Im | 即値(im)をOUTレジスタに代入 (Im + 0000 => OUT) |
1110 | JNC Im | キャリーフラグが立っていないならImにジャンプ (Im + 0000 => PC if carry) |
1111 | JMP Im | Imにジャンプ (Im + 0000 => PC) |
FPGAの場合
[Sipeed Tang Nano] (https://tangnano.sipeed.com/en/)で動作確認しています。2021/4/24時点で[shigezone](https://www.shigezone.com/?product=tang-nano)で800円で買えます。
ダウンロード
PCでシミュレーションする場合と同様にgitでファイルをダウンロードしてください。
$ git clone https://github.com/asfdrwe/simpleTD4.git
Tang Nanoでの実行
[Sipeed Tang Nanoで遊んでみる (Linux版)] (https://qiita.com/ciniml/items/bb9723673c91d8374b63) や [SiPeed Tang Nanoの環境構築(Windows編)] (https://qiita.com/tomorrow56/items/7e3508ef43d3d11fefab) を参考に環境設定を行ってTang Nanoの動作確認をしてください。
nano_simple
Tang Nanoのみで動作させる場合はnano_simpleを使ってください。USB端子を右とした場合、上のボタンがリセット、TD4の出力ポートのうち下位3bitをTang Nano内蔵LEDの青、緑、赤に割り振り、TD4の入力ポートの最下位ビットが下のボタンです。それ以外は、出力ポートの最上位は38番(左下)、入力ポートの残りは下位から40番41番42番(左下の3番目から5番目)です。
Open Projectからnano_simple/TD4_nano1.gprjを開き、Processから Synthesize => Place & Route => Program Deviceで書き込みしてください。Synthesizeできない場合はTD4.vを開いて適当に改行を入れて保存してからSynthesizeしてください。
Tang Nanoのピンアサインは Tang Nano のPinout Diagram を参照してください。
ROMLED.bin
nano_breadboardやnano_uart
ブレッドボードを組み合わせる場合はnano_breadboard、UARTでPCに出力する場合はnano_uartを使用してください。詳しくはREADME_jp.mdや下のリンクからsimpleTD4の動作動画を参照してください。
simpleTD4の設計と実装
必要な知識は論理回路、組み合わせ回路、順序回路、Verilog HDL等です。以下のページなどを参考にしてください。
- 論理回路、組み合わせ回路、順序回路
- Verilog
ブロックダイアグラム
やまもとみのる氏の電子工作作品集を参考に次のブロックダイアグラムを作りました。このダイアグラムに従ってsimpleTD4を実装しています。
構成ユニット
- 命令メモリ
- rom
- レジスタとフラグ
- reg_a (Aレジスタ)
- reg_b (Bレジスタ)
- reg_out (OUTレジスタ)
- pc (プログラムカウンタ)
- cflag (キャリーフラグ)
- ALU (加算演算)
- セレクタ
- alu_sel(ALU入力用selector)
- next_pc(次のPC用)
- バス
- レジスタ用バス(load_selで出力先選択)
- 入出力
- in_port(入力用)
- out_port(出力用)
各ステージの入力と出力
1サイクルの間に、命令読み出し(Fetch)・解読(Decode)・演算(Execute)・レジスタへの書き出し(Write Back)・次のPC書き出し(Next PC)の順に各ステージを実行しています。
- Fetch
- 入力: pc
- 出力: opcode
- Decode
- 入力: opcode
- 出力: alu_sel, load_sel, jmp, im
- Execute
- 入力: reg_a, reg_b, in_port, 4'b0000, alu_sel
- 出力: alu_out, nextcflag
- Write Back
- 入力: alu_out, load_sel
- 出力:reg_a, reg_b, reg_out
- Next PC
- 入力: load_sel, jmp, cflag, pc, alu_out
- 出力: next_pc
コードの解説
TD4のソースを行ごとに解説していきます。
1行目はmodule TD4の定義、2~3行目は著作権表示です。
module TD4 (input wire clock, input wire reset_n, input wire [3:0] in_port, output wire [3:0] pc_out, output wire [7:0] op, output wire [3:0] out_port, output wire [3:0] alu_data);
// Copyright (c) 2020 asfdrwe (asfdrwe@gmail.com)
// SPDX-License-Identifier: MIT
レジスタは4〜8行目で定義されています。
reg [3:0] reg_a, reg_b, reg_out;
reg [3:0] pc = 4'b0;
reg cflag = 1'b1;
assign out_port = reg_out;
assign pc_out = pc;
命令メモリは10~11行目で定義されています。ROM.binを読み込んでromの内容とします。
reg [7:0] rom[0:15];
initial $readmemb("ROM.bin", rom);
Fetchステージは13~15行目で、現在のpcのアドレスにある命令をromから取り出し、opcodeに出力します。
wire [7:0] opcode;
assign opcode = rom[pc];
assign op = opcode;
Decodeステージは17~23行目です。
即値(im)はopcode[3:0]を出力します。
ALUへの入力を選択するalu_selはJNC命令やJMP命令のときは2'b11でそれ以外はopcode[5:4]に対応付けし、出力レジスタを選択するload_selはopcode[7:6]に対応付けし、JMP命令かどうかを示すjmpはopcode[4]を出力します。
wire [1:0] alu_sel, load_sel;
wire jmp;
wire [3:0] im; // IMMEDIATE
assign alu_sel = (opcode[7:6] == 2'b11) ? 2'b11 : opcode[5:4];
assign load_sel = opcode[7:6];
assign jmp = opcode[4];
assign im = opcode[3:0];
Executeステージは25~34行目です。
ALUへの入力は25~29行目でalu_selに基づきreg_a(2'b00)かreg_b(2'b01)かin_port(2'b10)か0入力(2'b11)を決定し、33行目でalu_selの
選択した値とimを加算し、alu_outとnextcflagに出力します。
wire [3:0] alu_in;
assign alu_in = (alu_sel == 2'b00) ? reg_a : // from A
(alu_sel == 2'b01) ? reg_b : // from B
(alu_sel == 2'b10) ? in_port : // from input port
4'b0000; // zero
wire [3:0] alu_out;
wire nextcflag;
assign {nextcflag, alu_out} = alu_in + im;
assign alu_data = alu_out;
Write Backステージは36~39行目でload_selからデータバスの出力先を決定します。
53~56行目でクロックごとにreg_a、reg_b、reg_out、cflagを設定しています。
wire load_a, load_b, load_out, load_pc;
assign load_a = (load_sel == 2'b00) ? 1'b0 : 1'b1; // negative logic
assign load_b = (load_sel == 2'b01) ? 1'b0 : 1'b1; // negative logic
assign load_out = (load_sel == 2'b10) ? 1'b0 : 1'b1; // negative logic
Next PCステージは40~43行目です。
load_selからジャンプ命令であるか調べ、無条件ジャンプまたはキャリーフラグが立っていない(負論理)なら次のpcを選択するload_pcを決め、43行目でジャンプ命令ならalu_out、そうでないならpc+1を次のpc(next_pc)にします。
assign load_pc = (load_sel == 2'b11 && (jmp == 1'b1 || cflag)) ? 1'b0 : 1'b1; // negative logic
wire [3:0] next_pc;
assign next_pc = (load_pc == 1'b0) ? alu_out : pc + 1;
45~51行目でリセットが押されていればreg_a、reg_b、reg_out、cflag、pcを
初期化しています。
always @(posedge clock or negedge reset_n) begin
if (!reset_n) begin
reg_a <= 4'b0;
reg_b <= 4'b0;
reg_out <= 4'b0;
cflag <= 1'b1;
pc <= 4'b0;
最後に52~58行目で次のクロックごとにreg_a、reg_b、reg_out、cflag、pcの
値を設定しています。
end else begin
reg_a <= #1 (load_a == 1'b0) ? alu_out : reg_a;
reg_b <= #1 (load_b == 1'b0) ? alu_out : reg_b;
reg_out <= #1 (load_out == 1'b0) ? alu_out : reg_out;
cflag <= #1 ~nextcflag; // negative logic carry
pc <= #1 next_pc;
end