執筆: H.T
はじめに
皆様こんにちは。
CPUについてのお話、いよいよ最終章です。前回の予告通り簡単なメモリを作成後、今まで作った回路を一つのCPUとしてつなぎ合わせます。最後に簡単なプログラムを作成して動作をテストしてみましょう。
メモリ
主記憶装置(今回は命令メモリとデータメモリ)に使われているDRAMは、AND、OR、NOTだけで作られているわけではなく、実際にはキャパシタという小さなバッテリーに充電することでデジタル信号の0と1を表現しています。定期的にバッテリーに充電を繰り返さねばならず速度はSRAMに劣るのですが、回路が単純になり、面積あたりの容量や容量あたりのコストが優れています。
今回はCircuitVerseが標準で用意しているRAMとROMを、それぞれデータメモリ、命令メモリとして利用したいと思います。
データメモリは命令によって書き込みと読み込みが発生するため、入力としてアドレス指定用のAddress、データ入力用のWriteData、書き込みを行うか否かを決めるMemWriteという3つが必要です。出力はデータ用の一つのみです。CircuitVerseで用意されているものは丁度こちらの想定と同じ数の入出力を備えているため、そのまま利用しましょう。
ROMは16bit命令長のプログラムを格納する命令メモリとして利用します。標準では8bitしか保存できないため、すこし細工が必要です。
2つ繋げて16bitとしました。これでは16個の命令しか格納できないのですが、今回そこまで大きなプログラムを動かす予定がないため、このままとします。
仮組み
複雑なユニットは大体揃ってきました。制御装置に入る前に、一度ユニット同士をつなげて全体のイメージを固めましょう。
まずは上図のように、各命令ごとにSplitterをつかってビットを分割しました。根本の青部分で、16bitの命令を上位4bitのOPCode(命令種別コード)とそれ以外に分け、のこり12ビットをそれぞれI形式(緑)、R形式(赤)用に分割しています。
これらを、それぞれのユニットに接続します。
どこに接続するか迷ったら、以下の命令形式を参考にします。
宛先レジスタ(Dest 3bit)は、どこのレジスタアドレスにデータを格納するか指定する信号なので、RegisterFileのWriteRegisterに対応します。R形式とI形式で位置が異なるため、マルチプレクサ(赤)をおいて場合ごとに切り替えます。
2つのソースレジスタ(Src 3bit)は、どのレジスタアドレスにある値をALU InputAに送るか指定する信号ですので、それぞれReadRegister1、2に接続します。レジスタファイルの出力はALUのInputA、Bへそれぞれ繋ぎましょう。また、R形式命令とI形式命令では、2つ目のソースレジスタが異なります。R形式ではレジスタからの値、I形式では即値を利用したいので、ここにマルチプレクサ(青)を設置しましょう。
本来ALUのInputBでは8ビット長のデータを受け付けているのですが、I形式命令の即値フィールドは6bitしか持たないため、6to8という回路でビット長を揃えています。
また、lw命令では演算結果でなくメモリの出力をレジスタに渡したいので、最後にもマルチプレクサ(緑)を追加しています。
これに手動の制御信号を追加したのが以下の回路です。ある程度形になってきたかと思います。
beqという分岐命令を実装するために、ANDゲートが追加されています。これは、「2つのソースレジスタの値が同値であり、かつ実行命令が beq であるときに分岐フラグを立てる」ことを意味します。
制御装置
さて、いよいよ制御装置を組み立てていきましょう。
上記の回路にて、制御装置のインプットとアウトプットは揃っています。あとはインプットに基づいてアウトプットが自動で設定されるような回路を作ればよいだけです。
手で計算して回路を作ってもよいのですが、CircuitVerseには真理値表から回路を自動生成するツールがあるので、これを利用しましょう。まずは、全インプットに対するアウトプットを表した真理値表を作成します。
Input | 命令 | MemtoReg | Branch | Mem Write | ALU Src | RegDst | Reg Write | ALU Op |
0000 | add | 0 | 0 | 0 | 0 | 0 | 1 | 000 |
0001 | sub | 0 | 0 | 0 | 0 | 0 | 1 | 001 |
0010 | mul | 0 | 0 | 0 | 0 | 0 | 1 | 010 |
0011 | xor | 0 | 0 | 0 | 0 | 0 | 1 | 011 |
0100 | and | 0 | 0 | 0 | 0 | 0 | 1 | 100 |
0101 | or | 0 | 0 | 0 | 0 | 0 | 1 | 101 |
0110 | shiftl | 0 | 0 | 0 | 0 | 0 | 1 | 110 |
0111 | shiftr | 0 | 0 | 0 | 0 | 0 | 1 | 111 |
1000 | addi | 0 | 0 | 0 | 1 | 1 | 1 | 000 |
1001 | subi | 0 | 0 | 0 | 1 | 1 | 1 | 001 |
1010 | lw | 1 | 0 | 0 | 0 | 1 | 1 | – |
1011 | sw | – | 0 | 1 | 0 | – | 0 | – |
1100 | – | – | – | – | – | – | – | |
1101 | – | – | – | – | – | – | – | |
1110 | beq | – | 1 | 0 | 0 | – | 0 | – |
メニューバーの [Tools] から [Combinational Analysis] を呼び出し、真理値表どおりに値を入力していきます。自動生成した回路はInputやALUOpが1ビットずつ別れてしまうので、使いやすいようにSplitterでつなげましょう。
拍子抜けかもしれませんが、制御装置はこれで完成です。あとは制御装置をそれぞれ必要な箇所につなげていきます。
なかなかきれいに繋がりました。BranchおよびBranchAddressは、次に作成するPCレジスタへ入力しますので、まだ接続先はありません。
PCと命令メモリ
最後に、PCを作成し、命令メモリと接続します。
先にご紹介したとおり、PCはレジスタの一種です。ただし、他の汎用レジスタと違い用途が決まっており、ユーザーが直接操作することはできません。PCの用途とは、実行するプログラムのアドレスを指し示すことです。プログラムは基本的に1行目から順番に実行されますので、PCは各クロックサイクルごとに+1した値へ更新されますが、例外として分岐命令が成立した場合は分岐先アドレスが格納されます。
まとめると、PCの中身は次のようなアルゴリズムで更新されます。
- クロック信号がアサートされると、現在の値 + 1に更新される
- Branch信号がアサートされた場合、BranchAddres入力の値へ更新される
これを回路に書き起こすと、次のようになります。
CLKをスイッチすると、出力が1ずつ大きくなるのがわかるかと思います。また、CLKとBranchを同時にアサートすると、出力はBranchAddressの入力値と同じになります。普通、Branchや+1の加算を行う回路は別で考えることが多いですが、今回は最終的な見た目をわかりやすくするために、あえてPCという1つのモジュール内にこれらのロジックをまとめてみました。
PCと命令メモリを接続し、CPUに組み込んだのが次の回路です。
中編で作成した図と比べてみましょう。
実際の動作を見やすくするため、PC、ALUのInputA、InputB、そしてWriteDataの値を7セグで16進数表示できるようにしてみました。これで一通りすべての回路を実装できたはずです。
動作確認
命令メモリにプログラムを書き込み、動作を確認してみましょう。0と1だけでプログラムを組むのは至難の業ですので、以下のような記法を定めます。
- R形式: [命令] [宛先レジスタ], [ソースレジスタ1], [ソースレジスタ2]
- I形式: [命令] ([宛先レジスタ]), ([ソースレジスタ]), [即値]
例えばレジスタ0とレジスタ1の値を加算してレジスタ2に格納するadd命令は、
add R2, R0, R1
とします。
この記法に則って、まずはPCレジスタの動作確認のために単純なループプログラムを書いてみましょう。
add R0, R7, R7 # レジスタ0を 0 に初期化
add R1, R7, R7 # レジスタ1を 0 に初期化
addi R1, R1, 8 # レジスタ1に即値"8"を格納
addi R0, R0, 1 # レジスタ0の値を+1する
beq R0, R1, 6 # レジスタ0が 8 と同値であればループを抜ける
beq R7, R7, 3 # 4行目へジャンプ
beq R7, R7, 6 # 自分自身を呼び出し続ける
プログラムカウンタは0から始まることに注意してください。プログラムの4行目は、実際には3と指定します。命令メモリにプログラムを組み込むために、上記を2進数で表記します。
0000111111000000
0000111111001000
1000000001001000
1000000000000001
1110000001000110
1110111111000011
1110111111000110
これが実際の命令列です。下の回路の命令メモリには、予め上記のプログラムが(16進数で)組み込まれています。Clock信号を有効にして動作を確かめてみましょう。
8回ループした後、ループを抜けてPCが6にセットされて停止するはずです。これで、PCの+1の動作や、分岐命令が正常動作していることが確認できました。
もう少し複雑なプログラムとして、フィボナッチ数を数えさせたいと思います。何度かループした後、結果をデータメモリに格納して停止させます。このCPUが持つレジスタは8bit長なので、最大255までの値を格納できます。すると、フィボナッチ数の13項目の値(233)まではオーバーフロー(桁あふれ)なしで計算できますので、うまくそのあたりで計算を切り上げられるようループ数を調整します。
add R0, R7, R7 # レジスタ0を 0 に初期化
add R1, R7, R7 # レジスタ1を 0 に初期化
add R2, R7, R7 # レジスタ2を 0 に初期化
add R3, R7, R7 # レジスタ3を 0 に初期化
addi R3, R3, 5 # レジスタ3に即値 5 を格納
addi R0, R0, 1 # レジスタ0に即値 1 を格納
# ループ始点
add R1, R0, R1 # レジスタ0とレジスタ1を加算して1に格納
add R0, R0, R1 # レジスタ0とレジスタ1を加算して0に格納
beq R2, R3, 11 # レジスタ2とレジスタ3が同値の場合ループを抜ける
addi R2, R2, 1 # レジスタ2を+1(ループカウンタ)
beq R7, R7, 6 # ループの始点にジャンプ
# ループ終点
sw R0, 0 # レジスタ0の値をデータメモリ0番地へ格納
lw R4, 0 # データメモリ0番地の値をレジスタ4へ格納
beq R7, R7, 12 # 1つ前の命令を繰り返す
これを実装したのが以下です。
Clock信号を有効にすると、ループを6回繰り返したあとで計算結果をデータメモリに保存し、最終的にはメモリに保存された値を読み出し続けます。最終的な値が233、16進数の”E9″ になっていれば正常に計算が行えていることを意味します。メモリのRead、Writeも問題なさそうです。
まとめ
- 真理値表をもとに制御装置を作成した。
- PCと命令メモリによって、複数命令のプログラムを動かせるようになった
- 実際に簡単なプログラムを作成し、動作確認を行った
これにて基本的な演算、制御機能を備えたCPUが完成しました。
昨今のデジタル回路設計は、ハードウェア記述言語と呼ばれる専用の言語と設計ソフトを使って行われています。コンピュータ上で動作をシミュレートすることもできますが、FPGAの開発ボードがあると、実際のハードウェアとして動かすことができます。一家に一台あると便利です。
振り返るとCPUの作成は、命令の仕様である「ISA」の策定と、ISA通りに動作する「回路」の設計という2つの側面を持っていると言えます。どちらも大変な作業ですが、完全オリジナルのCPUを作成するというのはロマンがあります。
一つ注意点として、ISAから自作した場合、既存のソフトウェアを実行することができません。オリジナルISAのCPUでソフトウェアを動かすには、今回のように自分で書くか、大規模なものではコンパイラを自作するなど何らかの対処が必要です。
ともあれ、コンピュータ好きの皆様もそうでない方も、本稿をきっかけにより良いCPUライフをお送りいただけますと幸いです。
ここまでお読みいただきありがとうございました。
参考書籍
David A. Patterson & John L. Hennessy,『コンピュータの構成と設計』.第5版.日経BP.2014