名著「CPUの創りかた」で作成手順が紹介されている4bit CPU「TD4」をFPGAで作成しました。
FPGAで作成する3つのメリット
1. 部品調達が楽
「CPUの創りかた」では4bit CPU「TD4」を作成するのに10個のIC、押しボタンスイッチやディップスイッチ、抵抗やコンデンサ、LEDなどの部品が必要です。
さらにそれらの部品を載せるユニバーサル基板や配線、はんだ付けの道具やデバッグ用のテスターなども必要です。
一方FPGAの場合は必要なスイッチやLEDなどが搭載されたFPGAボード1枚を購入すれば大丈夫です。
ただ値段は少し高くなります。
私が使用した「Basys2」は秋月電子で11600円です。
2. 作成が楽
「CPUの創りかた」のTD4の配線は全て自分ではんだ付けで行う必要があります。
FPGAを使用すればVHDLはVerilog-HDLなどの言語でコードを書くだけなので大幅に手間を短縮できます。
またミスしたときの修正も容易ですし、今後もし8bit CPUに拡張したいと考えたときも同じFPGAボードや書いたコードをそのまま活用できます。
3. デバッグが楽
バラバラな部品を自分で配線してTD4を作った際にミスがあった場合、デバッグは基本的にテスターで行うことになります。
数十個の部品と百本以上ある配線を自分で検討をつけながらテスターを当てていくのはやはり面倒です。
一方FPGAならソフト上でシミュレーションができます。
FPGAを用いた作成について
開発環境
- 使用ボード:Basys2 (Xilinx)
- ツール:ISE Project Navigator 14.7
- シミュレーション:Isim
- 言語:VHDL
TD4の全体像
4bit CPU「TD4」の全体像をブロック図で作成したものが以下です。
ちなみに矢印の色が青とオレンジの2色ありますが、色の違いに特に意味はなく、単に見やすくしただけです。
作成の流れとしてはTD4で用いる個々のパーツを個別に作成し、最後に統合して一つのモジュールとします。
コードは全てGitHubにアップしています。
https://github.com/hiro1735/TD4
TD4の動作について
全体的な動作の流れをざっくりまとめます。
詳細は「CPUの創りかた」を読んでください。
① 最初にリセット入力(押しボタン)が入ると各信号に初期値を代入
② クロックの入力(押しボタン)が100ms以上だけ押されるとレジスタやALUにクロックを出力
③ プログラムカウンタから出力される信号(address)でROMのアドレスを指定
④ 指定されたアドレスに格納されているデータを出力(rom_dataとop_code)
⑤ デコーダは入力されたop_codeを元に各レジスタにLoad信号を出力し、データセレクタに2bitのセレクト信号を出力
⑥ ALUはデータセレクタから入力される信号とrom_dataを加算してレジスタに結果を出力
⑦ Load信号で指定されたレジスタはALUから送られた入力を出力する
⑧ 次のクロックでプログラムカウンタを+1する(ジャンプ命令の場合は指定されたアドレスを入力)
⑨ ③に戻る
TD4の個々のパーツ作成
ROM
TD4のROMは16byteあり、あらかじめプログラムをROMに格納してから実行します。
実際のTD4は16個のディップスイッチを用いていますが、FPGAボードを用いた場合はROMのコードに直接プログラムを打ち込んでいます。
TD4で用いるプログラムは各8bitです。
ROMは16byteなので全部で16ステップのプログラムを格納できます。
プログラムカウンタからプログラムが格納されているアドレスを指定する4bitの信号が送られ、対応するプログラム8bitを出力します。
8bitのプログラムのうち上位4bitは命令の機能である「オペレーションコード」であり、デコーダに出力されます。
下位4bitは命令に組み込まれたデータであり、「イミディエイトデータ」と呼ばれます。こちらはALUに出力されます。
ちなみに以下のコードでは「CPUの創りかた」297ページにあるサンプルプログラム「ラーメンタイマー」を書き込んでいます。
library IEEE;
use IEEE.STD_LOGIC_1164.ALL;
use IEEE.STD_LOGIC_UNSIGNED.ALL;
entity rom_16byte is
Port ( ADDRESS : in STD_LOGIC_VECTOR(3 downto 0);
ROM_DATA : out STD_LOGIC_VECTOR(3 downto 0);
OP_CODE : out STD_LOGIC_VECTOR(3 downto 0));
end rom_16byte;
architecture RTL of rom_16byte is
signal data : STD_LOGIC_VECTOR(7 downto 0);
begin
ROM_DATA <= data(3 downto 0);
OP_CODE <= data(7 downto 4);
process (ADDRESS) begin
case ADDRESS is
--サンプルプログラム2 ラーメンタイマー
when "0000" => data <= "10110111";
when "0001" => data <= "00000001";
when "0010" => data <= "11100001";
when "0011" => data <= "00000001";
when "0100" => data <= "11100011";
when "0101" => data <= "10110110";
when "0110" => data <= "00000001";
when "0111" => data <= "11100110";
when "1000" => data <= "00000001";
when "1001" => data <= "11101000";
when "1010" => data <= "10110000";
when "1011" => data <= "10110100";
when "1100" => data <= "00000001";
when "1101" => data <= "11101010";
when "1110" => data <= "10111000";
when "1111" => data <= "11111111";
when others => data <= "00000000";
end case;
end process;
end RTL;
一つ注意点ですが、ROMのコードに書き込むプログラムによっては使用しない回路があるため、後で論理合成したときにWarningが発生することがあります。
レジスタ
TD4のレジスタは4種類あり、本書内ではフリップフロップで実現しています。
デコーダから入力されるLoad信号で4つあるレジスタのうち1つを更新し、残りの3つは値を保持します。
各レジスタにはタイミング制御のためのクロックとリセット信号を入力します。
AレジスタとBレジスタはデータセレクタに4bitの信号を送信します。
CレジスタはLEDへの出力ポートとして使い、最後のDレジスタはROMのアドレスを指定するプログラムカウンタとして使用します。
プログラムカウンタはクロック信号が入るたびに+1して次のROMアドレスを指定できるようにしています。
library IEEE;
use IEEE.STD_LOGIC_1164.ALL;
use IEEE.STD_LOGIC_UNSIGNED.ALL;
entity register4 is
Port ( CLK : in STD_LOGIC;
RST : in STD_LOGIC;
LOAD : in STD_LOGIC_VECTOR(3 downto 0);
IN_DATA : in STD_LOGIC_VECTOR(3 downto 0);
OUT_A : out STD_LOGIC_VECTOR(3 downto 0);
OUT_B : out STD_LOGIC_VECTOR(3 downto 0);
OUT_LD : out STD_LOGIC_VECTOR(3 downto 0);
ADDRESS : out STD_LOGIC_VECTOR(3 downto 0));
end register4;
architecture RTL of register4 is
signal reg_a, reg_b, reg_c, reg_d : STD_LOGIC_VECTOR(3 downto 0);
begin
OUT_A <= reg_a;
OUT_B <= reg_b;
OUT_LD <= reg_c;
ADDRESS <= reg_d;
process (CLK, RST) begin
if(RST = '1')then
reg_a <= (others => '0');
reg_b <= (others => '0');
reg_c <= (others => '0');
reg_d <= (others => '0');
elsif(rising_edge(CLK))then
--プログラムカウンタ(reg_d)を+1する
reg_d <= reg_d + 1;
if(LOAD(0) = '0') then
reg_a <= IN_DATA;
elsif(LOAD(1) = '0') then
reg_b <= IN_DATA;
elsif(LOAD(2) = '0') then
reg_c <= IN_DATA;
elsif(LOAD(3) = '0') then
reg_d <= IN_DATA;
end if;
end if;
end process;
end RTL;
データセレクタ
データセレクタは4入力(Aレジスタ、Bレジスタ、スイッチ入力、"0000")のうちから1つを選び、ALUに出力します。
この選択にはデコーダから入力される2bitのセレクト信号を利用します。
library IEEE;
use IEEE.STD_LOGIC_1164.ALL;
use IEEE.STD_LOGIC_UNSIGNED.ALL;
entity data_selector is
Port ( IN_A : in STD_LOGIC_VECTOR(3 downto 0);
IN_B : in STD_LOGIC_VECTOR(3 downto 0);
IN_SW : in STD_LOGIC_VECTOR(3 downto 0);
SEL_A : in STD_LOGIC;
SEL_B : in STD_LOGIC;
OUT_Y : out STD_LOGIC_VECTOR(3 downto 0));
end data_selector;
architecture RTL of data_selector is
begin
process (IN_A, IN_B, IN_SW, SEL_A, SEL_B) begin
if(SEL_A = '0' and SEL_B = '0') then
OUT_Y <= IN_A;
elsif(SEL_A = '1' and SEL_B = '0') then
OUT_Y <= IN_B;
elsif(SEL_A = '0' and SEL_B = '1') then
OUT_Y <= IN_SW;
else
OUT_Y <= "0000";
end if;
end process;
end RTL;
ALUの作成
TD4のALUは全加算器が4個、キャリーフラグ用にフリップフロップを1個使用しています。
データセレクタとROMから送られる4bitの入力を加算し、A~Dレジスタに出力します。
繰り上がりが発生した場合はキャリーフラグに'1'を出力しますが、これを分岐命令に利用します。
ALUは階層設計で作成します。
まず1個の全加算器のコードが以下です。
library IEEE;
use IEEE.STD_LOGIC_1164.ALL;
use IEEE.STD_LOGIC_ARITH.ALL;
use IEEE.STD_LOGIC_UNSIGNED.ALL;
entity f_adder is
Port ( IN_Y : in STD_LOGIC_VECTOR(1 downto 0);
IN_DATA : in STD_LOGIC_VECTOR(1 downto 0);
CIN : in STD_LOGIC_VECTOR(1 downto 0);
CRR : out STD_LOGIC_VECTOR(1 downto 0);
DATA : out STD_LOGIC);
end f_adder;
architecture RTL of f_adder is
signal q : std_logic_vector(1 downto 0);
begin
process (q, IN_Y, IN_DATA, CIN) begin
q <= IN_Y + IN_DATA + CIN;
if(q = 3)then
DATA <= '1';
CRR <= "01";
elsif(q = 2) then
DATA <= '0';
CRR <= "01";
elsif(q = 1) then
DATA <= '1';
CRR <= "00";
else
DATA <= '0';
CRR <= "00";
end if;
end process;
end RTL;
キャリーフラグの実体はただのフリップフロップなので先ほどのレジスタのコードとほぼ同じであるため、この記事では省略します。
全加算器4個とキャリーフラグを組み合わせた上位モジュールのコードは以下です。
library IEEE;
use IEEE.STD_LOGIC_1164.ALL;
use IEEE.STD_LOGIC_UNSIGNED.ALL;
entity ALU is
Port ( CLK : in STD_LOGIC;
RST : in STD_LOGIC;
IN_Y : in STD_LOGIC_VECTOR(3 downto 0);
ROM_DATA : in STD_LOGIC_VECTOR(3 downto 0);
OUT_DATA : out STD_LOGIC_VECTOR(3 downto 0);
C_FLAG : out STD_LOGIC);
end ALU;
architecture RTL of ALU is
component f_adder
Port ( IN_Y : in STD_LOGIC_VECTOR(1 downto 0);
IN_DATA : in STD_LOGIC_VECTOR(1 downto 0);
CIN : in STD_LOGIC_VECTOR(1 downto 0);
CRR : out STD_LOGIC_VECTOR(1 downto 0);
DATA : out STD_LOGIC);
end component;
component carry_flag
Port ( CLK : in STD_LOGIC;
RST : in STD_LOGIC;
CARRY : in STD_LOGIC_VECTOR(1 downto 0);
FLAG : out STD_LOGIC);
end component;
signal in_y0 : STD_LOGIC_VECTOR(1 downto 0);
signal in_y1 : STD_LOGIC_VECTOR(1 downto 0);
signal in_y2 : STD_LOGIC_VECTOR(1 downto 0);
signal in_y3 : STD_LOGIC_VECTOR(1 downto 0);
signal in_data0 : STD_LOGIC_VECTOR(1 downto 0);
signal in_data1 : STD_LOGIC_VECTOR(1 downto 0);
signal in_data2 : STD_LOGIC_VECTOR(1 downto 0);
signal in_data3 : STD_LOGIC_VECTOR(1 downto 0);
signal crr0 : STD_LOGIC_VECTOR(1 downto 0);
signal crr1 : STD_LOGIC_VECTOR(1 downto 0);
signal crr2 : STD_LOGIC_VECTOR(1 downto 0);
signal crr3 : STD_LOGIC_VECTOR(1 downto 0);
signal data0 : std_logic;
signal data1 : std_logic;
signal data2 : std_logic;
signal data3 : std_logic;
begin
in_y0 <= '0' & IN_Y(0);
in_y1 <= '0' & IN_Y(1);
in_y2 <= '0' & IN_Y(2);
in_y3 <= '0' & IN_Y(3);
in_data0 <= '0' & ROM_DATA(0);
in_data1 <= '0' & ROM_DATA(1);
in_data2 <= '0' & ROM_DATA(2);
in_data3 <= '0' & ROM_DATA(3);
U20 : f_adder port map(in_y0, in_data0, "00", crr0, data0);
U21 : f_adder port map(in_y1, in_data1, crr0, crr1, data1);
U22 : f_adder port map(in_y2, in_data2, crr1, crr2, data2);
U23 : f_adder port map(in_y3, in_data3, crr2, crr3, data3);
U24 : carry_flag port map(clk, rst, crr3, c_flag);
OUT_DATA <= data3 & data2 & data1 & data0;
end RTL;
以下のようにALUを構成しました。
デコーダ
デコーダはROMに格納されている命令を指示に変換する回路です。
全部で13ある命令それぞれについて、対応するオペレーションコードや加算器のキャリーフラグを入力とし、データセレクタのセレクト信号やレジスタのLoad信号を出力します。
「CPUの創りかた」ではOR回路4個と3入力NAND回路3個で実現しています。
FPGAで作成するにあたって、入出力の真理値表を元にそのままコード化しました。
回路規模は大きくなってしまいますが、コードの可読性は良くなるので忘れたころに見返しても理解しやすいですし、拡張などもしやすいです。
各命令の処理は入出力の真理値表を元にwhen文を使って場合分けしています。
library IEEE;
use IEEE.STD_LOGIC_1164.ALL;
use IEEE.STD_LOGIC_UNSIGNED.ALL;
entity decorder is
Port ( OP_CODE : in STD_LOGIC_VECTOR(3 downto 0);
C_FLAG : in STD_LOGIC;
LOAD : out STD_LOGIC_VECTOR(3 downto 0);
SEL_A : out STD_LOGIC;
SEL_B : out STD_LOGIC);
end decorder;
architecture RTL of decorder is
signal selecter : STD_LOGIC_VECTOR(1 downto 0);
begin
SEL_A <= selecter(0);
SEL_B <= selecter(1);
--OP_CODE(OP3,OP2,OP1,OP0)
--selecter(B,A) LOAD(LOAD3,LOAD2,LODAD1,LOAD0)
process (OP_CODE, C_FLAG) begin
if (OP_CODE = "1110") then
if (C_FLAG = '0') then
--JNC(C=0)
selecter <= "11"; LOAD <= "0111";
elsif (C_FLAG = '1') then
--JNC(C=1)
selecter <= "11"; LOAD <= "1111";
else
selecter <= "11"; LOAD <= "1111";
end if;
else
case OP_CODE is
--ADD A,Im
when "0000" => selecter <= "00"; LOAD <= "1110";
--MOV A,B
when "0001" => selecter <= "01"; LOAD <= "1110";
--IN A
when "0010" => selecter <= "10"; LOAD <= "1110";
--MOV A,Im
when "0011" => selecter <= "11"; LOAD <= "1110";
--MOV B,A
when "0100" => selecter <= "00"; LOAD <= "1101";
--ADD B,Im
when "0101" => selecter <= "01"; LOAD <= "1101";
--IN B
when "0110" => selecter <= "10"; LOAD <= "1101";
--MOV B,Im
when "0111" => selecter <= "11"; LOAD <= "1101";
--OUT B
when "1001" => selecter <= "01"; LOAD <= "1011";
--OUT Im
when "1011" => selecter <= "11"; LOAD <= "1011";
--JMP
when "1111" => selecter <= "11"; LOAD <= "0111";
when others => selecter <= "11"; LOAD <= "1111";
end case;
end if;
end process;
end RTL;
クロック
「CPUの創りかた」では押しボタンによるクロック、発振回路を用いた1Hzと10Hzのクロックの3種類のクロックがあります。
今回は動作の確認がしやすいように押しボタンによるクロックのみ搭載することにしました。
ここで問題になるのが押しボタンのチャタリング対策です。
本物のTD4では抵抗とコンデンサを使ったローパスフィルタ回路とシュミッドトリガで対応しています。
FPGAボードを使用する場合はアナログ回路をいじるのが面倒なのでソフトウェアでチャタリング対策をしました。
ボタンを押すと最初チャタリングが発生してオンとオフが連続的に出力されますが、その後チャタリングが収まればオン状態のままになります。
オン状態のままでクロックを一定回数カウントしたらパルスを1回出力するという回路を作成します。
ここで出力されたパルスをクロック信号として他の部品に供給します。
Basys2は元々100MHzのクロックがあり、クロックのパルス幅は10nsです。
人がボタンを一回押して離した場合、どんなに素早くオンオフしても、たいてい数百μs程度の時間は押してしまいます。
そこで100μsの時間クロック信号が入力されたら1パルスを出力する回路を作成しました。
1クロックで10nsなので、100μs / 10ns = 10000より1万カウントしたらパルスを出力します。
library IEEE;
use IEEE.STD_LOGIC_1164.ALL;
use IEEE.STD_LOGIC_UNSIGNED.ALL;
entity key_chatter is
Port ( CLK : in STD_LOGIC;
RST : in STD_LOGIC;
SW_I : in STD_LOGIC;
SW_O : out STD_LOGIC);
end key_chatter;
architecture RTL of key_chatter is
signal cnt : integer range 0 to 10000;
constant ct : integer := 9999;
begin
process(CLK , RST)
begin
if(RST = '1')then
cnt <= 0;
elsif(rising_edge(CLK))then
if(SW_I = '1')then
if(cnt <= ct)then
cnt <= cnt + 1;
end if;
else
cnt <= 0;
end if;
end if;
end process;
SW_O <= '1' when cnt = ct else '0';
end RTL;
トップモジュールの作成
個々のパーツの作成は完了したので全体をまとめるトップモジュールを作成します。
トップモジュールの中に個々のパーツを下位モジュールとして組み込みます。
library IEEE;
use IEEE.STD_LOGIC_1164.ALL;
use IEEE.STD_LOGIC_UNSIGNED.ALL;
entity TD4_top is
Port ( CLK : in STD_LOGIC;
RST : in STD_LOGIC;
CLK_SW : in STD_LOGIC;
SL_SW : in STD_LOGIC_VECTOR(3 downto 0);
LED : out STD_LOGIC_VECTOR(3 downto 0));
end TD4_top;
architecture RTL of TD4_top is
component register4
Port ( CLK : in STD_LOGIC;
RST : in STD_LOGIC;
LOAD : in STD_LOGIC_VECTOR(3 downto 0);
IN_DATA : in STD_LOGIC_VECTOR(3 downto 0);
OUT_A : out STD_LOGIC_VECTOR(3 downto 0);
OUT_B : out STD_LOGIC_VECTOR(3 downto 0);
OUT_LD : out STD_LOGIC_VECTOR(3 downto 0);
ADDRESS : out STD_LOGIC_VECTOR(3 downto 0));
end component;
component data_selector
Port ( IN_A : in STD_LOGIC_VECTOR(3 downto 0);
IN_B : in STD_LOGIC_VECTOR(3 downto 0);
IN_SW : in STD_LOGIC_VECTOR(3 downto 0);
SEL_A : in STD_LOGIC;
SEL_B : in STD_LOGIC;
OUT_Y : out STD_LOGIC_VECTOR(3 downto 0));
end component;
component ALU
Port ( CLK : in STD_LOGIC;
RST : in STD_LOGIC;
IN_Y : in STD_LOGIC_VECTOR(3 downto 0);
ROM_DATA : in STD_LOGIC_VECTOR(3 downto 0);
OUT_DATA : out STD_LOGIC_VECTOR(3 downto 0);
C_FLAG : out STD_LOGIC);
end component;
component decorder
Port ( OP_CODE : in STD_LOGIC_VECTOR(3 downto 0);
C_FLAG : in STD_LOGIC;
LOAD : out STD_LOGIC_VECTOR(3 downto 0);
SEL_A : out STD_LOGIC;
SEL_B : out STD_LOGIC);
end component;
component rom_16byte
Port ( ADDRESS : in STD_LOGIC_VECTOR(3 downto 0);
ROM_DATA : out STD_LOGIC_VECTOR(3 downto 0);
OP_CODE : out STD_LOGIC_VECTOR(3 downto 0));
end component;
component key_chatter
Port ( CLK : in STD_LOGIC;
RST : in STD_LOGIC;
SW_I : in STD_LOGIC;
SW_O : out STD_LOGIC);
end component;
signal clk_o : STD_LOGIC;
signal load : STD_LOGIC_VECTOR(3 downto 0);
signal data_AluToRegister : STD_LOGIC_VECTOR(3 downto 0);
signal a_register : STD_LOGIC_VECTOR(3 downto 0);
signal b_register : STD_LOGIC_VECTOR(3 downto 0);
signal address : STD_LOGIC_VECTOR(3 downto 0);
signal sel_a : STD_LOGIC;
signal sel_b : STD_LOGIC;
signal data_SelecterToAlu : STD_LOGIC_VECTOR(3 downto 0);
signal rom_data : STD_LOGIC_VECTOR(3 downto 0);
signal op_code : STD_LOGIC_VECTOR(3 downto 0);
signal c_flag : STD_LOGIC;
begin
U0 : register4 port map(clk_o, rst, load, data_AluToRegister, a_register, b_register, led, address);
U1 : data_selector port map(a_register, b_register, sl_sw, sel_a, sel_b, data_SelecterToAlu);
U2 : ALU port map(clk_o, rst, data_SelecterToAlu, rom_data, data_AluToRegister, c_flag);
U3 : decorder port map(op_code, c_flag, load, sel_a, sel_b);
U4 : rom_16byte port map(address, rom_data, op_code);
U5 : key_chatter port map(clk, rst, clk_sw, clk_o);
end RTL;
TD4の全体構成は以下のようになりました。
実機で確認
これでFPGAによるTD4の作成が完成したので実機で確認します。
ROMのコードで書き込んだラーメンタイマーのプログラムを動かします。
プログラムを書き込んだら最初にリセットボタンを押し、その後はクロックのボタンを自分で押します。
本来は1Hzで自動的にクロック信号が入るため約3分を測定することができ、残り時間によってLEDの点灯状態が変化します。
今回は手動クロックした搭載していないため自分で1秒に1回のペースで約200回クロックのスイッチを押してやると3分が測定できます。
最後に
今回の記事ではFPGAボードBasys2を用いて「CPUの創りかた」の4bit CPU 「TD4」を作成しました。
「CPUの創りかた」は以前から読んではいたものの、実際に作るのが面倒なため読んだだけで終わっていました。
しかし実際に作らないと身に付かないと思い、年末年始の連休を利用して作成しました。
ここでは実際に使用するコードのみ書き連ねましたが、作成の途中ではシミュレーションも行っています。
シミュレーション用のテストベンチコードなど詳細な説明は以下にもまとめています。