7日目: [CPU] メモリとプログラムカウンタ
この記事はひとりでCPUとエミュレータとコンパイラを作る Advent Calendar 2017の7日目の記事です。
昨日は計算の回路実装をやった。四則演算ができるようになった。
ところで昨日までは「どのレジスタにどの演算を実行するか」という、命令を与える方法について深く考えてこなかった。 今日はその辺をやる。
メモリの必要性
昨日も一昨日も出てきたMOV
命令だけのCPUだが
今までは、図の下の方のSx
やSy
の電圧を人力で調整し、クロックのたびに演算が行うという体だった。
今日はこの操作を自動化したい。
つまりSx
とSy
への信号を、どこかから自動で取得する仕組みが欲しい。
これを実現するには、メモリ上に機械語(つまりSx
とSy
の電圧値)をあらかじめ格納しておき、クロック立ち上り毎に値を持ってきて、Sx
とSy
に繋げばよい。
ここでメモリは電圧値を保存(セーブとロード)できるデバイスを意味している。
メモリの役割を果たす回路は色々あるのだが、たとえばDフリップフロップをたくさん並べた配列は、電圧値の保持と上書きが可能なのでメモリとして使うことができる1。 電気屋で売られている普通のメモリはDRAMと呼ばれるもので、Dフリップフロップより値段が安く、動作も遅い。 メモリの実装にはあまり興味がないので、とりあえずDRAM素子(もしくはDフリップフロップ)がたくさん並んだやつがCPUにつながってるんだな、と思って欲しい。
機械語の読み込み
昨日と一昨日のMOV
命令回路とメモリを連動させるには、クロックが立ち上がる度にメモリから情報を取得し、それに応じてSx
とSy
の値を変えればよかった。
その回路を図示すると
まず左下を見ると、汎用レジスタがいる。
こいつは最初の画像、つまりMOV
命令のレジスタ転送回路を表している。
汎用レジスタは計算用レジスタだと思って欲しい2。
[^1]スタックポインタなど、計算用以外の汎用レジスタもある。
汎用レジスタの下から出ている線(4本束ねて書いてある)が、さっきのSx
とSy
に対応している。
この4本の線はデータバスを経由して仮想メモリの値(4bit)に繋がっている。 ここにはCPUが実行すべき機械語が順番に詰まっている。 バスという用語は初耳かもしれないが、ただのワイヤだ。
読むべきメモリのアドレスは、プログラムカウンタ(図のカウンタのところ)で指定されている。
これはDフリップフロップ8個で構成されており、1クロック毎に加算器で+1
されている。
この8bitカウンタの電圧値は8本線の赤いアドレスバスを通り、デマルチプレクサに入る。
そこで2^8=256
本の線に分裂し、1本だけ電圧1、他の254本は電圧0になる。
このようにして8bitのプログラムカウンタから256種のメモリアドレスが選出される。
このような仕組みで、CPUはクロックが変わるたびに新しい命令をメモリから取得してきている。 ここで出てきたプログラムカウンタはレジスタだが、計算用ではなく機械語取得用であり、汎用レジスタではない。
他のデバイス
さっきの画像の右側を見ると、物理メモリ、HDD、ディスプレイ、キーボードがいる。 CPUはこれらのデバイスを仮想メモリという単一の記憶素子として見ている(ということにしてくれ)。
仮想メモリのアドレスの大半は物理メモリに由来している。物理メモリとは4GBや8GBの普通のDRAMメモリのことだ。 CPUが実行すべき機械語命令のレシピは、全てここに置かれている。
ディスプレイを光らせたい時は、仮想メモリのディスプレイに該当する箇所に値を書き込めばいい。 キーボードの値を読む時は、仮想メモリのキーボードに該当する箇所の値を読めばいい[^2]。 だいたいそんな感じでPCは動いている。
いちいち仮想メモリと書くのは煩わしいので、今後は単にメモリと書く。 デバイスのことを深刻に考えない限り、メモリと言えば物理メモリを想像してもらって差し支えない。
メモリへの書き込み
さっきの回路図では
プログラムカウンタ -> アドレスバス -> メモリ -> データバス -> 汎用レジスタ
という方向に電圧信号が伝わることで、CPUはクロック毎に新しい命令を受け取ることができていた。
次はメモリに値を書き込んでみたいのだが
汎用レジスタ -> アドレスバス & データバス -> メモリ
という方向に電圧信号を伝えることで、汎用レジスタの値をメモリに書き込むことができる。
では、メモリは読み込み命令と書き込み命令をどのようにして判別しているのだろうか。答えはコントロールバスだ。 さっきの図では簡単のために省略したのだが、CPUメモリ間のバスをちゃんと描くと
名称 | from | to |
---|---|---|
アドレスバス | CPU | メモリ |
書き込み用データバス | CPU | メモリ |
読み込み用データバス | メモリ | CPU |
コントロールバス | CPU | メモリ |
の4種類があると(多分)。コントロールバスは「メモリに値を書き込め」「読み込め」「何もするな」等の命令をCPUから送るためのものだ。
これらのバスをうまく操作し、CPUとメモリが協調しながら処理が進んでいく。
完全にマジでホントのCPU
メモリの読み書きを説明したので、ちょっと本格的なCPUをスケッチしてみる。具体的には
という命令が可能なCPUだ。 一見高度なCPUに見えるが、実はさっきの回路にALUとデコーダを追加するだけで実現できる。今から詳しく見ていこう。
ちなみに、最新のPCに搭載されているCPUもこれと似たようなものだ。なんだ大した事ねーなと思ってほしい3。 これから、各命令発動時のCPUの様子を図で説明していく。
メモリから機械語命令を読む
次回実行するための機械語命令を読み込む時の回路の様子は
赤い部分だけを見ると、一つ上の画像とほとんど変わらない。 プログラムカウンタがインクリメントされ、機械語がメモリからフェッチされている。
少し違うのはALUとデコーダが追加されたところだが、大したことはない。
ALUはArithmetic Logic Unit(算術論理演算装置)の略称で、昨日出てきた四則演算器を用いて汎用レジスタを更新するユニットを表している。
デコーダは機械語命令を読み込んで、CPUの動作を取り仕切るユニットだ。
昨日MOV
だけの回路に算術演算を組み込んだが、そこではT
やS
の電圧を変えることで演算の種類を変更することができた。
デコーダは、フェッチした機械語をT
やS
にうまく差し込むための素子だと考えればよい。
デコーダの中身は、おおむねデマルチプレクサと思ってよい。処理手順をざっくり書くと
- あらかじめ汎用レジスタ間の加減乗除を全パターン計算しておく
- メモリから機械語命令(8bit)を読む
- 機械語命令がデコーダに入る
- デマルチプレクサで256本に分岐し、そのうち1本の電圧が1、他は0になる
- その結果とマルチプレクサを組み合わせて、加減乗除の結果を一つ選び、汎用レジスタを更新する
手順5の意味が分からない場合、昨日の記事の最初の方を読めばわかると思う。
ジャンプ命令
この命令は、要するにプログラムカウンタの上書きだ。
デコーダから左に伸びた線がカウンタを上書きし、最終的にアドレスバスに到達している。 つまり、次に読み込む機械語のアドレスをデコーダが上書きしたわけだ。 さっきの命令ではメモリに並んだ機械語が順番に実行されていたが、カウンタが上書きされると、次回読み込まれるはずだった機械語がスキップされたり巻き戻されたりする。 こうした命令をジャンプ命令という。
今の説明をニーモニックで忠実に書くと
mov プログラムカウンタのレジスタ, 値
となるのだが、この命令は使いにくい。 むしろプログラムカウンタからの差分値でジャンプ先を指定するのが分かりやすいので
add プログラムカウンタのレジスタ, 差分 ; プログラムカウンタ = プログラムカウンタ + 差分
という操作を行うことが多い。
ところでプログラムカウンタを「計算」するのは行儀が悪いとされている。
昨日までに出てきた汎用レジスタ(計算用レジスタ)とは存在意義が異なるので、add
のような計算まるだしの書き方は推奨されない。
そこで
jmp 差分
と書くのが普通だ。 要するに、差分で指定された数だけメモリ上の機械語をスキップしますよ、という命令だ。
蛇足だが、jmp
命令をC言語に翻訳するとifやwhileといった分岐処理になる。
さらに蛇足だが、ジャンプ命令は昨日説明した計算フラグと関係が深い。
例えばゼロフラグを見れば、直前のa-b
の計算結果がゼロかどうか分かる。
そこでゼロフラグが立っていればジャンプする jz 命令を用意すれば、
C言語のif (a==b) {...}
のような処理が実現できる。
後日FPGAでCPUを作る際はjmp
とjz
を実装する予定。
メモリからデータを読む
「さっき計算結果をメモリに書き込んだのだが、それを取り出して使いたい」という要望を叶える命令だ。
汎用レジスタの値がアドレスバスを経由してメモリに入っている。 つまり、汎用レジスタに書かれた番地に行って、値をとってこいという命令が実行されている。
メモリからのデータの読み込みは、メモリ上にスタックを構築した際のpop命令の実装に必要となる。pop
の詳細は以下のpush
のところにまとめておいた。
汎用レジスタの値をメモリに書く
アセンブラで書くと
mov [0x12], 汎用レジスタ ; [0x12]は0x12番地のメモリを表す
のような操作だ。
アドレスバスに加え、メモリへの書き込み用のデータバスにも値が流れていることに注意して欲しい。 汎用レジスタの値を用いて、書き込むべきアドレスと値のペアをメモリに送信しているわけだ。
ちなみに、メモリに計算結果を書き込んだり読み込んだりする場合はスタックポインタを用いるのが便利だ。 その仕組みを解説すると、まずメモリの使用法として
- 機械語はメモリの最初(
0x0000
付近)に置くことにする - 計算結果のデータはメモリの最後(
0xFFFF
付近)に置くことにする
このルールに基いてメモリを使うことにする。次に
- 次のデータ書き込むためのメモリアドレスを表す、
esp
という汎用レジスタを用意しておく - つまりデータを一切書き込んでない状況では、
esp
は0xFFFF
という値になる - push操作:
esp
の指すアドレスに値を書き込んで、esp
を-1
する - pop操作:
esp+1
の値を読み込んで、esp
を+1
する。この操作でメモリからデータが論理削除されたと考える
このようなスタック操作でメモリに値を出し入れするのが便利だ。 これらの命令は、後日FPGAでCPUを作る時に実装する予定。
汎用レジスタの値を更新
さっきは値をメモリに書き込んだが、今回は汎用レジスタに書き込みたい。 ニーモニックで書くと
add 汎用レジスタa, 汎用レジスタb ; 汎用レジスタa = 汎用レジスタa + 汎用レジスタb
これは昨日までに散々やった内容だし、メモリも絡んでないので説明を飛ばす。
停止
最後はメモリの読み込みをストップする操作だ。
全処理が終わってCPUを停止(halt
)する際には、メモリの読み込みを止める必要がある。
後日FPGAでCPUを作るときには、メモリアクセスを停止する代わりにプログラムカウンタのインクリメントを止めて、CPUを停止させる予定。 この方法はクロックの度に無駄にメモリにアクセスするのでよくないのだが、実装が簡単なので。。。。
処理終了以外のシチュエーションでも命令の読み込みを一時停止することがある。 例えば浮動小数点の計算などの重い処理は、一回のクロックだと終了しないので、計算が終わるまで次の命令の読み込みを停止する必要がある。
ここまでで、メモリに関する話は一旦終了だ。やれやれだぜ。
どうでもいい話
この記事では、メモリは1クロックで値を返すことを暗に仮定していたが、実際のDRAMメモリでは遅延が生じる。百億近くのアドレスの中から特定のアドレスを見つける必要があるので、時間がかかって当然だろう。またCPUからメモリへの物理的な(センチメートル単位の)距離による遅延も無視できない。
あらためて読み返すと、初日に「概論は嫌い(キリッ」などと言っておきながら、今日の記事は概論臭がすごい。あーあ。
今日でカレンダー開始一週間か。毎日書くのはかなりキツイな。