Steamで配信されるTuring Completeという電子回路のシミュレーションゲームをやってみました。
実際のハードウェアに触らずにCPUをさくっと作れて面白かったので、ゲームの概要と作ってみたCPUの構成についてまとめてみたいと思います。
ざっくり説明
おおまかに2つのモードがあるようです。
ひとつは、問題が出されてステップアップしていくモード(play campaign)、もう一つは、自分で自由に回路を作れるモード(sandbox)です。
ステップアップしていくモードでは、NANDといった基礎的な部品からスタートしてCPUなどの複雑な回路を作成していきます。
たとえば、最初の方は「NANDだけを使ってANDやORを実装してみよう」といった問題が出され、クリアすると作成できた部品ANDやORを次のステップから使えるようになります。
そこから、加算器やマルチプレクサなどを作成していき、やがてはCPUに到達するわけです。
自分は今のところ、8bit CPU(下の画像のWORKING COMPUTERの箇所)を作り、そのあと機械語命令でプログラミングするステージまで到達しましたが、まだステージが残されています。CPUをもう少し進化させるんでしょうか。
感想
何より、ハードウェアに触らずに手を動かしながらCPUの仕組み、ソフトとハードの境目を知ることができるところがすばらしいなぁと思います。
実は以前、CPUの創りかたを読んだ際に、こうしたソフトウェアがあったらいいなぁと思っていました。。
「とても勉強になったんだけど、うーん、読んだだけではまだ仕組みが馴染んだ感じがしないなぁ。何か手を動かしたい。自分で実際にCPUを作ってみたいなぁ。けど、電子工作をしたことの無い自分がいきなりハードに触るのはちょっと遠回りすぎるし、デバッグも辛そうだから何かシミュレーションできるソフトウェアが無いかなぁ。」
そのとき私はCircuit Simulator Appletというブラウザで電子回路をシミュレートできるソフトウェアに出会いました。
そして先の書籍に登場するTD4(とりあえず動作するだけの4bitCPU)を作ってみたのです。
https://github.com/qawatake/cpu/tree/main/td4
ただ、Circuit Simulator Appletにはもう一歩なところがありました。それは「回路が複雑になると動作が重くなってしまう」点です。CPUのbit数を増やしたり、部品を増やしたりするとカクカクしてしまうのです。つまり、4bit CPUくらいがこのソフトで作れる限界だったようなのです。
一方、今回のTuring Completeは8bit CPUを作っても、大量に部品を使ってもサクサク軽快に動作します。どうやら64bitの部品なども用意されているようですから、もっとずっと複雑で現実的なCPUを作ったりもできるのでしょう。
ただ、Circuit Simulator AppletがTuring Completeより優れたところもあります。それは、部品がもう少しハード寄り、別の言い方をすると部品がそれほど抽象化されていないところです。
たとえば、Turing Completeでは、delayed lineやswitch、クロックが所与となります。対して、Circuit Simulator AppletではNANDを使ってフリップフロップを作ったり、ダイオードを使ってswitchを作ったり、コンデンサや抵抗を使ってクロックを作ったりできます。
というわけで、結局Turing CompleteとCircuit Simulator Appletは使い分けをしたらいいのかもしれないですね。たとえば、回路の中でもより低いレイヤはCircuit Simulator Appletで遊んでみて、そのあたりで作ってみた部品を所与のものとしてTuring Completeで複雑な回路を作ってみるといった使い方ですね。
作ったCPUの説明
ここからはTuring Completeを使って作ってみた8bit CPUの仕組みを説明してみます。自分用メモなので説明は飛ばしめですし、設計が理想的になっているわけではありません。
ちなみに、Turing Completeには解説動画(英語)があるのでそっちを参照されてもいいかもしれません。
最初から用意されている部品
- レジスタ0番-5番
- 8bit出力
- 8bit入力
- 機械語命令が記録されたROM(8bit × 8bit): 1命令8bitで命令のアドレスもまた8bitです。
サポートする命令
以下の4種類の命令がサポートされています。ちなみに、これはTuring Completeの問題ですでに設定されていました。なので、オリジナルではありません。
- Immediate(即値)
- Compute
- Copy
- Condition
1. Immediate
上位2bitが00
の場合、Immediate命令となります。下位6bitの値が0番レジスタに書きこまれます。
2. Compute
上位2bitが01
の場合、Compute命令となります。1番レジスタと2番レジスタに演算を適用し3番レジスタに書き込みます。
さらに下位3bitによって演算の種類が決まります。
000
: OR001
: NAND010
: NOR011
: AND100
: ADD101
: SUB
3. Copy
上位2bitが10
の場合、Copy命令となります。下位3番〜5番bitでコピー元、下位0番〜2番bitでコピー先を表します。例えば、機械語命令が10010011
であれば、2番レジスタから3番レジスタにコピーすることになります。
ただし、レジスタは0番から5番までしかないことと、コピー元110
は8bit入力をコピー先110
は8bit出力を表すことに注意が必要です。
4. Condition
上位2bitが11
の場合、Condition命令となります。この命令は3番レジスタを評価し、真であった場合に、0番レジスタの値をプログラムカウンタに書き込みます。
評価に用いる条件式は機械語命令の下位3bitで決まります。
000
:Never
001
:= 0
010
:< 0
011
:<= 0
100
:Always
101
:!= 0
110
:>= 0
111
:> 0
CPUの構成部品
自分の作ったCPUは概ね以下の部品に分けられます。
- 書き込みを制御する部品
- 出力を決める部品
- プログラムカウンタを制御する部品
それぞれ見ていきます。
書き込みを制御する部品
この部品は、命令を入力として、更新すべきレジスタに対応する端子がonになるように出力します。
また、レジスタはSave
の端子がonの場合にのみ、入力を受けつけて内部の状態を更新します。
ですので、この部品とレジスタを結びつけることで、あるレジスタ更新すべき命令が読まれたときだけそのレジスタの書き込みを許可できるようになります。
例えば、レジスタ1を更新すべきなのは、Copy命令でありかつCopy先がレジスタ1にになっているときなので、命令が10xxx001
の場合のみ1番の出力がonになります。
また、レジスタ0を更新すべきなのは、Copy命令に加えて即値を受け取る場合なので、命令が10xxx000
と00xxxxxx
の場合のみ0番の出力がonになります。
出力を決める部品
ここでは「出力」という言葉をだいぶアバウトに使っているので、具体的にしたいと思います。
まず、「何に使うための出力なのか」をはっきりさせます。この部品は以下の用途で使用されます。
- レジスタに書き込む
- 8bit出力に書き込む
- プログラムカウンタに書き込む
この3つの用途はひとつの命令で同時に必要になることはありません。
ですので、1命令で1用途のために出力すればいいわけです。
では、次に「何を出力するのか」をはっきりさせます。この部品は以下の値を出力します。
- 即値
- レジスタの値に算術論理演算を行った値
- レジスタの値
- レジスタ0の値(なぜ他と分けているかは後ほど説明します。)
つまり、ここで取り上げている「出力を決める部品」というのは、レジスタ or 8bit出力or プログラムカウンタに書き込むために、即値 or レジスタの値 or 算術論理演算を行った値のを出力する部品ということになります。
ただ、わかりやすさ?のために出力の用途を書きましたが、実際に使われる用途を決めるのは、あくまで、先に説明した「書き込みを制御する部品」やこの後説明する「プログラムカウンタを制御する部品」であることに注意が必要です。
続いて、出力を決めるための仕組みを見ていきます。部品の中でも↓のDECと4つのswitchがつながっている部品(ここでは門番と読んでみます。)に着目してみます。
DECは入力された命令が4パターンのうちどれかを、4つの端子を使って出力してくれる部品です。
0番がImmediate、1番がALU、2番がCopy、3番がConditionに相当しています。その端子がそれぞれ、switchとつながっているわけです。
つまり、門番は、4種類の個別出力のうち、どれを最終的な出力として利用するかを命令のパターンに応じて切り分ける仕事をしてくれていることになります。
で、その4種類の個別出力はなにかというと、先にあげた4つなわけです。上から順に見ていきます。
1つ目はImmediate(即値)です。8bitの機械語のうち下位6bitが即値となるので、上位2bitを捨てて出力が行われています。例えば、命令が11111111
であれば、00111111
を出力します。
ここで、上位2bitが00
ではなく11
であっても気にしないことに注意してください。
命令のパターンを見るのは先に述べた門の仕事です。
2つ目はレジスタの値に算術論理演算を行った値です。COMPUTE命令はレジスタ1とレジスタ2の値に対して演算を実行するため、ALUの入力にはそれらを利用しています。
ALUは命令の下位3bitから演算の種類を特定し、入力として受け取った2つのレジスタの値に演算を適用した値を出力してくれます。
たとえば、命令の下位3bitが011
= AND
、1番レジスタの値が10100101
、2番レジスタの値が01010101
であれば、00000101
を出力します。
ALU自体の実装はそれほど難しくないのでここでは説明を省略します。
3つ目はレジスタの値です。もう少し具体的に言うと、COPY命令でCOPY元となるレジスタの値を出力します。
つまり、機械語命令の4番、5番、6番のbitを見て、出力すべきレジスタ(or8bit入力)のインデックスを決定し、その値を出力するわけです。
たとえば、命令の下位6bitが101000
であれば、コピー元は101
= 5
のため、5番レジスタの値を出力します。
最後は常にレジスタ0の値を出力する部分です。COND命令ではレジスタ3の値が条件を満たす場合にレジスタ0の値をプログラムカウンタに書き込むため、この固定出力を用意しています。
プログラムカウンタを制御する部品
この部品は↑の「出力を決める部品」からの出力と3番レジスタと命令の3つの入力からプログラムカウンタの値を決定します。この部品は更に2つの部品に分けられます。
1つ目の部品は、レジスタ3の値と機械語命令の下位3bitを受け取って、レジスタが条件を満たすかどうかを評価します。
たとえば、レジスタ3の値が01000000
で機械語命令の下位3bitが111
であれば、10000000 > 0
のため、この部品は真を返します。
2つ目の部品はプログラムカウンタ用のレジスタを内部に持っています。
そのレジスタの値を書き換えるか、それとも一つ分インクリメントするかどうかを、機械語命令の上位2bitと1つ目の部品からの出力をもとに決定します。
当然ながら、レジスタを書き換えるのは、機械語命令の上位2bitが11
で1つ目の部品が真を返した場合です。
試しに命令を動作を追ってみる
さて、回路のおおよその説明が終わったので、具体的に命令を例にとってその挙動を確認してみましょう。
以下のような設定を考えます。
- 現在のプログラムカウンタの値:
00001000
- アドレス
00001000
にある命令:01000100
- レジスタ1の値:
01101111
- レジスタ2の値:
00000001
- レジスタ3の値:
00000000
(他のレジスタなどの値は今回関係ないため無視します。)
命令は01000100
、つまり、足し算命令のため、3番レジスタの値は1番レジスタの値01101111
と2番レジスタの値00000001
の和である01110000
に上書きされるはずです。
実際にこうなることを確認してみます。
まず、「書き込みを制御する部品」の出力を確認します。今回は3番レジスタに書き込みを行うため、3番bitが立っている00001000
が出力されます。
次に、「出力を決める部品」を確認します。そのうち、まずは「門番」に着目してみると、DECは機械語命令01000100
を受け取って、それがCompute命令であると判断し、1番bitが立っている0010
を出力します。
ですので、4種類の個別出力のうちALUの結果が最終的な出力となります。
ALUは1番レジスタの値01101111
、2番レジスタの値00000001
と機械語命令の下位3bit100
= ADD
を受け取るため、和01110000
を出力します。
以上をあわせますと、「出力を決める部品」から出力される値は01110000
となります。
したがって、「書き込みを制御する部品」と「出力を決める部品」の結果をまとめると、3番レジスタに01110000
が書き込まれることになります。
最後に「プログラムカウンタを制御する部品」を確認しておきます。まず、3番レジスタを評価する部品は、機械語命令の下位3bit100
= Always
と3番レジスタの書き込み前の値00000000
を受け取り、真を返します。ただ、機械語命令の上位2bitが11
ではないため、結局、先ほど求めた出力01110000
がプログラムカウンタに書き込まれることはありません。
たしかに、この回路に従うと、(次のクロックの立ち上がりにて)3番レジスタに01110000
が書き込まれ、プログラムカウンタは1インクリメントされるということが分かりました。