CPU実験とは
東大の3年生はCPU実験(プロセッサ コンパイラ実験)なるものをやるらしい。今の私の理解では、CPUをFPGAを用いて実装し、その上で動作するプログラムを出力するコンパイラも実装するというもので、コンパイラはMLという言語で書かれたレイトレーシングと言われる類のプログラムをコンパイルする必要があり、それを自作のCPU上で動作させることを目標としているっぽい。
筆者はもう20年近くCやC++でプログラムを書いて、プログラマーとして仕事をしてきているにもかかわらず、まともな情報科学の勉強をしなかったためにこのあたりの話は全く理解できていない。一応、形式言語やらコンパイラの基礎部分ぐらいは勉強したが実装したこともないし、CPUも「CPUの創り方」という本を読んだことがある程度の知識しかない。
ずっと流石にこの状態はまずいし、勉強しなくてはと思ってはいたが、ずるずると気づけば40間近になってしまった。もう一度大学や大学院で学び直そうかと思ったりもしたが、なかなかその勇気も出ず、なるほどこうしてちゃんとした勉強をした人との差は開く一方なのだなぁと実感していた。
そんな中、最近このCPU実験の話をネット上で読んだ。こんな楽しそうなことを大学でやるなんて、俺にも参加させろと言いたところだが、それは無理なので、じゃあ一人で似たようなことをやってみるかと思い立った。
幸い最近はFPGAなどで回路なども比較的簡単に実装できる(らしい)し、分からないことも多いがまあちょっとずつやってけば、その入口部分ぐらいは理解できるんじゃないかという期待のもと初めて見ることにした。
これは自分の理解をきちんとアウトプットするための場所であり、記録である。もしかすると同じような状況にいて、なにか似たようなことをやってみたいと思う人がいるかも知れないと思い、そういう人の役にも立ったら良いと思う。
2019/2/23 スタート地点
さて、スタートしたいのだが、何しろ右も左も分からない。一応筆者のスタート時点の知識を簡単に記録しておくと
- 基本的なプログラマーとしての知識は持っている
- 簡単なLinux Kernel Moduleが書ける程度の知識はある
- x86のディスアセンブルコードは読めなくもない
- ハードウェアのことはほとんど知らない、実践的な経験は0
- ハードウェアに近い層の一応読んだことがある本として「CPUの創り方」「構造化コンピュータ構成」「コンピュータの構成と設計(ヘネパタ本)」がある、がもう10年近く前の話。「CPUの創り方」はほぼ全部、残りは50%ぐらいは読んだ気がする
- コンパイラ関連としては「計算理論の基礎」を途中までと、あとはコンパイラ入門的な本を少し読んだ程度
どうしたらよいか分からないので、とりあえず、いろいろな人が書いたCPU実験の記録などを参考に、それっぽい方向に進んでみようかと思う。
参考資料
とりあえず、いくつか参考になりそうな記録をあさってみた
- https://www.is.s.u-tokyo.ac.jp/isnavi/practice01-01.html
- http://progrunner.hatenablog.jp/entry/2017/12/01/235250
- http://www.yl.is.s.u-tokyo.ac.jp/raw-attachment/wiki/lectures/CompilerEnshu2011.ja/
- http://eguchishi.hatenablog.com/entry/2017/09/09/150229
- https://adventar.org/calendars/1056
- https://www.mtl.t.u-tokyo.ac.jp/~jikken/cpu/wiki/
- http://is2011.2-d.jp/moin/moin.cgi/CPU%E5%AE%9F%E9%A8%93%E3%82%92%E8%AA%9E%E3%82%8B%E4%BC%9A
- https://gist.github.com/buko106/707a66515ea277f8dbc80dc5a1cd7807
- https://github.com/esumii/min-caml
- https://www.mtl.t.u-tokyo.ac.jp/~jikken/cpu/wiki/
他にもたくさんありそうだが、ひとまずこんな感じ。
最初のステップ
とにかく全体像が掴めないので、とりあえず全体的なことは考えず、まずは全体像を掴むためにそれぞれ少しずつ勉強してみようと思う。
まず、そもそもFPGAが何かがよく分かってない。なんかいろいろ回路を自分で作れるものっぽいということだけは分かった。イメージ的にはこの中にCPUに必要になる回路を自作して、動かすとCPUになるってことでいいんじゃないかな。
とりあえずFPGAを使ってなにか作ってみようかと思う。とにかくウェブに載っている人の真似をしてどんな感じかだけでもつかんでみよう。
少し探していたら同じようなことをしている人がいた。
これは参考になりそうだ。というか私がやりたいことそのまんまだ。見ながらやってみよう。
飛び込んでみる
FPGAボードみたいなのを買ってみることにしてみた。ピンきりみたいで、値段もいろいろ。東大の実験では結構高いのを使っているのかな?http://progrunner.hatenablog.jp/entry/2017/12/01/235250 の記録を見ると、
ボード: Xilinx社製Kintex Ultrascale(KCU105)
とのこと。調べてみると30万ぐらいだった。これは流石に手が出ないので(でも、結婚する前だったら買ってたかも)、そもそも何が必要かも分からないし、まずは入門的なものを買ってみて、どんなことができるのか試してみることにした。
いろんなメーカーがFPGAのキットみたいなのを売っているみたいなんだけど、これを買ってみた。
- Artix-7 35T Arty FPGA評価キット【AES-A7MB-7A35T-G】
なんの根拠もない。FPGAって書いてあるのと、値段が15000円ぐらいだったのと、なんかVivadoっていう開発環境的なのが使えるっぽかったので、それで選んでみた。後で大きな間違いであると気づくかもしれない。
2019/2/24
FPGAキットが届いた
USBで接続するとLEDが光る。サンプルプログラムが実装されているらしく、右下のボタンを押すといろいろ光ったりとかする。まだ、これ以上どうしていいか分からないが、とりあえずVivadoという開発ソフトを入れてみる。
Vivado Design SuiteのLinux版をダウンロードしてみた。ユーザー登録とか少し面倒だった。
サイズが14GBほどあって、これが我が家の遅い回線ではなかなかつらい。終わるまで5時間以上かかりそう。経済的な理由で先日解約したAU光に戻りたい・・・。
その間に少し回路のことなどを勉強しておこうと思う。
2019/2/26
Vivado Design Suiteのインストールが完了し、まあ使ってみるかということで、プロジェクトを作成するウィザードへ突入。
http://progrunner.hatenablog.jp/entry/2017/12/01/235250 を参考にディレクトリの指定とかいろいろ適当に進めていってBoardの指定をしようとしたらリストに私のFPGAボードがない。
うーん。
FPGAボードはDIGILENTというメーカー?のものらしく、検索してみたらそれ用のVivadoのインストール方法が載っていた。
Board情報みたいなのは別に入れなきゃいけないっぽい。ついでにUSBドライバみたいなのもLinux版の場合は別に入れなきゃいけないっぽいのでそれもやってみた。
無事にFPGAボードが表示された。
プロジェクトは無事に作れたので、何かを実装してみよう。LED表示させるだけとかそんなサンプルないかなと思って探していたらちゃんとチュートリアルっぽいのがあった。
https://timetoexplore.net/blog/arty-fpga-verilog-01
スイッチに対応してLEDが光るらしい。
始めてVisual Studio使ったときみたいな、この意味のわからなさがたまらない。きっとたくさん機能があるのだけれど、この壮大な開発環境を使ってHello Worldを作っていく的な感じがいい。
基本的には左ペーンが一つの流れになっているみたいで、上から順に作業をしていくっぽい。チュートリアルどおりやっていくと、ちゃんとできた。
一番下に並んでいる4つのスイッチのうち一番右を上に上げると、4つあるLEDの一番右側が付く、というプログラム(回路?)になったっぽい。
もう少し自由に
チュートリアルだとコピペで終わってしまう感じがするので、もう少し自分の発想で、いろいろ試行錯誤しながら作ってみたい。
最初の題材は、LEDを1秒おきにちかちかさせる。あわよくばスイッチで点滅間隔を変える。こんな感じにしてみよう。
この前の、http://sikakuisankaku.hatenablog.com/entry/2017/12/01/204847 を少し読み進めてなんとなく回路の構造とHDLでどんな感じで書けるのかが分かった。雰囲気的には
- Vivadoでxxxx.vというファイル作る
- そこに回路を表現するようなプログラム?を書く
- おそらく、それぞれの回路はCで言うところの関数みたいな感じで、入力と出力を定義する
- それぞれの回路の入力と出力をつなげると全体的な回路になる
- 最後に、top?と呼ばれるっぽい回路の入り口のところに、実ハードウェアのスイッチなどを割り付ける(constraitという?)
- 実ハードウェアはスイッチ、Clock、ボタン、LEDなど
- スイッチとかClockは入力側につないで、LEDは出力側につなぐような感じかな
- 出来上がったらVivadoでコンパイル的なことをする(SynthetisとかImplementationとか書いてあるけどなんのことか分からん。コンパイルとリンク的なことか?)
- 最後にできあがったプログラム(bitstreamという?)をハードウェアに流せばFPGAがその回路になってくれる
という流れな気がする。
http://sikakuisankaku.hatenablog.com/entry/2017/12/09/192238
http://progrunner.hatenablog.jp/entry/2017/12/01/235250
このあたりを参考にしながらとりあえず1秒点滅の回路にしてみたつもりだけど、なんか違うような気もする。
module top( input CLK100MHZ, output reg [3:0] led );
reg [26:0] counter;
wire CLK1HZ;
assign CLK1HZ = counter < 100_000_000 / 2;
always @(posedge CLK1HZ) begin
if (led[0] == 0) begin
led[0] <= 1'b1;
end
else begin
led[0] <= 1'b0;
end
end
always @(posedge CLK100MHZ) begin
counter <= counter < 100_000_000 ? counter + 1 : 0;
end
endmodule
なんかきれいに点滅せずに、点灯するタイミングで少しチカチカしてる。
その後、いろいろ試行錯誤した結果、以下のコードできれいにチカチカするようになった。
module top( input CLK100MHZ, output reg [3:0] led );
reg [26:0] counter;
wire CLK1HZ;
assign CLK1HZ = counter < 100_000_000 / 2;
always @(posedge CLK100MHZ) begin
led[0] <= CLK1HZ;
counter <= counter < 100_000_000 ? counter + 1 : 0;
end
endmodule
constraintなる、物理ハードウェアとHDLとの接続を定義するファイルはこんな感じ
## Clock
set_property -dict {PACKAGE_PIN E3 IOSTANDARD LVCMOS33} [get_ports {CLK100MHZ}];
create_clock -add -name sys_clk_pin -period 10.00 \
-waveform {0 5} [get_ports {CLK100MHZ}];
## Switches
set_property -dict {PACKAGE_PIN A8 IOSTANDARD LVCMOS33} [get_ports {sw[0]}];
set_property -dict {PACKAGE_PIN C11 IOSTANDARD LVCMOS33} [get_ports {sw[1]}];
set_property -dict {PACKAGE_PIN C10 IOSTANDARD LVCMOS33} [get_ports {sw[2]}];
set_property -dict {PACKAGE_PIN A10 IOSTANDARD LVCMOS33} [get_ports {sw[3]}];
## LEDs
set_property -dict {PACKAGE_PIN H5 IOSTANDARD LVCMOS33} [get_ports {led[0]}];
set_property -dict {PACKAGE_PIN J5 IOSTANDARD LVCMOS33} [get_ports {led[1]}];
set_property -dict {PACKAGE_PIN T9 IOSTANDARD LVCMOS33} [get_ports {led[2]}];
set_property -dict {PACKAGE_PIN T10 IOSTANDARD LVCMOS33} [get_ports {led[3]}];
真ん中のSwitchesは使ってない
2019/2/27
ところで、なんか調べたところによると、HDL = ハードウェア記述言語にはメインで使われているのが2種類あるらしく、それがVHDLとVerilog-HDLらしい。私みたいな何も知らない人には超ややこしい。Verilog-HDLって略したらVHDLだし・・・。両方同じものかと思うじゃん。
ここで使っているのはVerilog-HDLらしい。
さて、そろそろ簡単な回路はできたので、少しCPUに近づいていきたいが、その前にもう少し簡単な回路を試しておこう。基本的なAND, ORなどをつなげてみようと思う。あとHDLでmoduleを複数作ってつなげてみたい。
2つのスイッチでAND, OR回路を作ってLEDを光らせたりしてみる。
module top( input CLK100MHZ, input [3:0] sw, output wire [3:0] led );
assign led[0] = sw[1] & sw[0];
endmodule
AND回路を使ってLEDを付けてみる。sw[0]とsw[1]がONになるとled[0]が付く。よくわからなかったのが、ポート指定部分のoutput wire [3:0] led
の部分。昨日まではoutput reg [3:0] led
だった。このままだとLEDがレジスタ?として認識されるらしく、LEDが状態保存をする回路のように扱われるらしい。これだとCLOCKに反応する部分にしかassign led[0] <=...
というのが書けなかった。
今回は単なる組み合わせ回路でいいと思ったので、単純に書けるはずだと思ったんだけど、この部分を変えてあげないとだめだった。いまいちよく分かってない。
とはいえ、やりたいことは実現できた。回路図をvivadoで見るとこんな感じになっていた。
まあ、想像してた感じだけど、なんかいろんな数字が書いてある。その辺の意味はわからないけどいいんじゃないかな。CLK100MHZは使ってないけどinputに指定したから図には出てる。
続いて、OR演算
今度は4つのスイッチのうちどれかがONなら、led[0]を点灯させる。
多分単純に書けるけど、今回はあえてORというmoduleを作成してそのmoduleをtopから使うようにした。
module OR(input IN1, input IN2, output OUT);
assign OUT = IN1 | IN2;
endmodule
module top( input CLK100MHZ, input [3:0] sw, output wire [3:0] led );
wire w1, w2;
OR or1(sw[0], sw[1], w1);
OR or2(w1, sw[2], w2);
OR or3(w2, sw[3], led[0]);
endmodule
ORを階層的に作って、それを用意したwire w1, w2
を作って数珠つなぎにしていく。まあ、下の回路図を見てもらったほうが早いと思う。
うんうん、この辺はスムーズにイメージしてHDLで書けるようになった。
フリップフロップ
クロックに合わせて値を保持したり、更新したりする回路らしく、これは少し書き方が違うっぽい。
ANDとかORは入力が決まれば瞬時に出力が決まるような回路で、こういうのを組み合わせ回路と言うらしい。一方で、入力が変わっても出力が瞬時には変わらず、CLOCKなどの他のイベントによって変わったりするものを順序回路というらしい。
どちらも基本的なNAND回路とかから構成されるけど、順序回路は自分自身の出力が自分自身の入力につながるみたいな部分があって、どうやらそれはHDL上では組み合わせ回路としては書けないらしい(本当か?)。
以下のようなサンプルを作ってみた。
module top( input CLK100MHZ, input [3:0] sw, output wire [3:0] led );
reg [0:0] flag;
initial begin
flag <= 0;
end
assign led[0] = flag[0];
always @(posedge sw[0]) begin
flag[0] <= 1'b1;
end
endmodule
sw[0]がonになると、flag[0]というレジスタに1を格納しする。flag[0]はled[0]につないでおく。flag[0]は一度1を格納されると0になることはないので、一度スイッチを入れてledが付くと、あとは何をしてもledは切れない。initial begin
から始まるブロックでflagの初期値を決めている。
としたかったんだけど、以下のようなエラーでImplementationが失敗した
[Place 30-876] Port 'sw[0]' is assigned to PACKAGE_PIN 'A8' which can only be used as the N side of a differential clock input.
Please use the following constraint(s) to pass this DRC check:
set_property CLOCK_DEDICATED_ROUTE FALSE [get_nets {sw_IBUF[0]}]
よくわからないけど、やっぱり物理SwtichをCLOCKの代わりに使うのがだめなのかな、と勝手に理解した。
always @(posedge sw[0])...
の部分は、普通はalways @(posedge CLK)...
みたいにCLOCKを入力に反応するようになっているけど、これだとこちらでOn,Offを制御できないからsw[0]に変えてみた。で、それがエラーのもとかな?
でもなんか、set_property...
って書けって書いてあるから、とりあえずこれをconstraitsに入れてみた。
そしたら、一応期待通り動いた。
なんか、間違っている気もしなくもないが、とりあえずflipflopの基本動作は体験できた。
2019/2/28
いろいろ試しながらも、少しずつCPUに必要になる回路の勉強も進めている。HDLの記述も確認しながら進めているのだけど、だんだん難しくなってきた。今一番頭のなかで整理できていないのは、いろいろな回路を実現するための方法(HDL記述)がたくさんあるんじゃないかってこと。1bitずつ回路を組んでもいいし、HDLの記述を用いて数bitまとめて処理してもいいし。感覚的には標準ライブラリを使ってしまうか、標準ライブラリが行っていることも自分で書くか、というような違い。
最初はなるべく標準ライブラリを使わない方向で書いたほうが勉強にはなりそうだけど、ちょっと時間もかかりそう。内部の実装がどうなっているかは無視して必要なことができる回路を簡単に書いてしまうほうがいいのだろうか。
2019/3/1
そろそろ小さなCPU作りにはいりたい。とりあえずレジスタ2つ用意して、1を足しながら交互に代入していくようなものを作ってみたい。メモリから命令を読み込んでとかはまだ難度高いので、そこはハードコードする感じで。
雰囲気としては、レジスタr0,r1を用意しておいて、add r0, 1
mov r0, r1
, add r1, 1
, mov r1, r0
を繰り返す感じ。こんな命令が実際にあるのか(必要なのか)はしらないけど。
つまり今回サポートする命令はadd
とmov
の2種類。今回はi
というレジスタを用意して、それを毎クロックにOn/Off切り替えることで対応(スーパーハードコード)。対象レジスタ示すのにt
というレジスタを用いて、t=0
ならr0,t=1
ならr1にしてみたい(movに関してはsrcを指定することにする)。
以下のような感じ
i | t | instruction |
---|---|---|
0 | 0 | add r0, 1 |
0 | 1 | add r1, 1 |
1 | 0 | mov r0, r1 (src:r0, dst:r1) |
1 | 1 | mov r1, r0 (src:r1, dst:r0) |
r0の値をLEDに表示して中身が変わっていることを確認する。ただ100MHzクロックとかだと早すぎてわからないので、LEDを点滅させたときに使った1Hzのクロックを使ってやってたい。
まずはadd r0, 1
を実装してみよう。1Hzのクロックを生成する部分をtopに作って、実際のcpu部分は分けて1Hzのクロックを入力として生成する。
ちょっとまず雰囲気だけ書いてみる。多分コンパイル通らない
module cpu( input CLK1HZ, output reg [3:0] );
// 命令用のレジスタ2つ(本来はメモリとかから読み取らないといけない)
reg [0:0] i; // 今回は無視。
reg [0:0] t;
// CPUの持つレジスタ2つ。とりあえず8bit
reg [7:0] r0;
reg [7:0] r1;
assign led[3:0] = r0[3:0]; // r0の下位4bitをLEDに出力
always @(posedge CLK1HZ) begin
if ( i[0] == 0 ) begin
r0 <= r0 + 1;
else
r1 <= r1 + 1;
end
end
endmodule
module top( input CLK100MHZ, output reg [3:0] led );
reg [26:0] counter;
wire CLK1HZ;
assign CLK1HZ = counter < 100_000_000 / 2;
cpu cpu1(CLK1HZ, led);
always @(posedge CLK100MHZ) begin
counter <= counter < 100_000_000 ? counter + 1 : 0;
end
endmodule
2019/3/4
ちょっと更新していなかったが、ちょっとイマイチCPUの理解に苦しんだ部分があって、それを少しずつクリアにしていた。3/1に書いたコードはコンパイル通らないので、ちょっと書き直す。
CPUで分からなかったところというのは、CPUの構造の説明読んでると、命令フェッチがあってデコードがあって、みたいな感じで何段回かで処理が行われているみたいに書いてあることが多い。というかそう書いてある。
でも、今の僕のシンプル回路の頭では、そんなことできない。毎クロックごとに回路によってなにかが計算されて、それがどこかに出力される、という形だけ。
1クロックごとに、CPUの状態は変わるけど、それで1命令は終わる。複数クロックに渡って状態が伝搬して最終的に処理が終わるみたいな感じではない。その辺の感じがまだ良くわかららない。とりあえず1命令=1クロックでいいのだろうか。
このパイプラインの説明(色付きで回路がどのステップを実行しているか書いてある部分)が分かりやすい感じ。
CPUの処理をレジスタで区切って、それぞれの部分の処理を順に行っていく感じなんだな、きっと。
とりあえず、初歩的なCPUでは1サイクル=1命令でもいいのかな。むしろそっちの方が設計が難しくなったりするんだろうか。パイプライン化はしなくても、複数クロックで1命令実行するようにしたほうが簡単なんだろうか。
2019/3/7
本ばかり読んでいてアウトプットが最近なくなってしまったので少しやろう。
とりあえず、この前の適当CPUを完成させたい。iとtという2つのbitで4つの命令を実行しようと思った。ただ、HDLでいろいろ試した結果以下のようにしたほうが簡単だったのでちょっと方針変更。
3つのbitで考える。i,s,d。それぞれinstruction,src,dstを表す。 レジスタは8bitを2つAとBを用意する。
i | s (src) | d (dst) | |
---|---|---|---|
0 | mov | A | A |
1 | add (+1) | B | B |
つまり、001
なら mov A, B
みたいな感じ。 100
ならadd A, A
(addは+1限定。A = A+1という感じ)。
一応以下のように実装して、でもメモリから命令を読み込めないからひたすらadd A, A
を繰り返すだけのコードになってる。
module cpu( input CLK1HZ, output wire [3:0] out);
reg [0:0] i; // 0 : MOV, 1 : ADD1
reg [0:0] s; // 0 : A 1 : A
reg [0:0] d; // 0 : B 1 : B
// 8 BIT 2 REGISTERS
reg [7:0] A;
reg [7:0] B;
initial begin
i[0] = 1'b1;
s[0] = 1'b0;
d[0] = 1'b0;
end
assign out[3:0] = A[3:0]; // Connect A to LED to debug
wire [7:0] src;
assign src = (s[0]==1) ? B : A;
wire [7:0] result;
assign result = (i[0]==1) ? src+1: src;
always @(posedge CLK1HZ) begin
case(d[0])
0: A <= result;
1: B <= result;
endcase
i[0] <= 1;
s[0] <= 0;
d[0] <= 0;
end
endmodule
module top( input CLK100MHZ, output wire [3:0] led );
reg [26:0] counter;
wire CLK1HZ;
cpu cpu1(CLK1HZ, led);
assign CLK1HZ = counter[26];
always @(posedge CLK100MHZ) begin
counter <= counter + 1;
end
endmodule
動いたんだけど、結構これだけでも大変だった。i,s,dのレジスタに対してはinitialで値入れればいいだけかと思ったんだけど、alyways @...
ブロック内にi[0] <= 1;...
を書いておかないと、なんか0になってしまうようだった。ちょっとよく分からない。
Cでメモリのことわからずにポインタとか適当にいじってたらうまく動いた感じと同じような気分だ。よく分かってない。
これを動作させるとAレジスタの下位4ビットがLEDに表示される。これで足し算の結果がAに代入されていることが分かった。
次はそうだなぁ、メモリから命令読み込んで、いくつか違う命令を連続で実行させてみたい。
と、思ったけど、なんか最近効率悪い。特に実機にいきなりコードを流し込んで動かしてるんだけど、デバッグのやり方がわからないから、ちょっと直しては試し、ちょっと直しては試しを繰り返してる。HDLのコンパイル(論理合成って言うらしい)って結構時間がかかって、こんな小さなコードでもそれだけで数分取られる。ということでここらでシミュレーターを使ったデバッグみたいなのを習得したい。たぶん、そんなことが出来るはず。他のサイトでもやってたから。テストベンチとかいうのを書けばいいはずなんだけど、それがまだ良くわからない。
test bench
このあたりを参考にしつつ、https://qiita.com/mmitti/items/633900ad1d0c1673f413
シミュレーションをしてみた。実機で動かすためにクロックを激遅にしていたわけですが、そうすると今度シミュレーターで見にくかったので、元に戻して、以下のようなテストコードと、実際のCPUのコード、それからその結果を表示しているWave Window?の様子
module test();
reg CLK;
wire [3:0] LED;
parameter CYCLE = 2;
top top_test( CLK, LED);
always #(CYCLE/2) begin
CLK = ~CLK;
end
initial begin
CLK = 0;
end
endmodule
module cpu( input CLK, output wire [3:0] out);
reg [0:0] i; // 0 : MOV, 1 : ADD1
reg [0:0] s; // 0 : A 1 : A
reg [0:0] d; // 0 : B 1 : B
// 8 BIT 2 REGISTERS
reg [7:0] A;
reg [7:0] B;
initial begin
i[0] = 1'b1;
s[0] = 1'b0;
d[0] = 1'b0;
A = 0;
B = 0;
end
assign out[3:0] = A[3:0]; // Connect A to LED to debug
wire [7:0] src;
assign src = (s[0]==1) ? B : A;
wire [7:0] result;
assign result = (i[0]==1) ? src+1: src;
always @(posedge CLK) begin
case(d[0])
0: A <= result;
1: B <= result;
endcase
i[0] <= 1;
s[0] <= 0;
d[0] <= 0;
end
endmodule
module top( input CLK, output wire [3:0] led );
cpu cpu1(CLK, led);
endmodule
Aレジスタがクロックごとに1足されているのがわかる。
階層化されたモジュール内の変数(レジスタ?)をWave Windowで見る方法とかがちょっと分かりづらかったけどなんとかなる。適当に弄ってればなんとなく検討は付く感じ。ただ未だに手際が悪い。
シミュレーションできると遥かにこっちのほうが楽なので、これからはだいたいこっちを使って見ていこう。
メモリの利用
メモリを使って上の4つの命令を順に出してレジスタA,Bを操作するというのをやってみた。
メモリの作り方はここを参考に。
https://timetoexplore.net/blog/initialize-memory-in-verilog
以下のような感じのファイルrom.mem
を16バイト分作ってプログラムとした。
これをメモリとしてHDLで読み込ませる。そしてそれに従ってCPUを動作させる。PCレジスタを追加して現在実行中のアドレスを表して、それを元に上と同様i,s,dを解釈して命令を実行する。
module memory(input wire [3:0] addr, output wire [7:0] data);
reg [7:0] block [0:15];
initial begin
$display("Loading rom.");
$readmemb("rom.mem", block);
end
assign data = block[addr[3:0]];
endmodule
module cpu( input CLK, output wire [3:0] out);
wire [1:0] i; // 0 : MOV, 1 : ADD1
wire [1:0] s; // 0 : A 1 : A
wire [1:0] d; // 0 : B 1 : B
// 8 BIT 2 REGISTERS
reg [3:0] PC; // Program Counter
reg [7:0] A;
reg [7:0] B;
initial begin
PC = 0;
A = 0;
B = 0;
end
wire [7:0] instruction;
memory memory1( PC, instruction );
assign i[0] = instruction[2];
assign s[0] = instruction[1];
assign d[0] = instruction[0];
assign out[3:0] = A[3:0]; // Connect A to LED to debug
wire [7:0] src;
assign src = (s[0]==1) ? B : A;
wire [7:0] result;
assign result = (i[0]==1) ? src+1: src;
always @(posedge CLK) begin
case(d[0])
0: A <= result;
1: B <= result;
endcase
PC <= PC+1;
end
endmodule
module top( input CLK, output wire [3:0] led );
cpu cpu1(CLK, led);
endmodule
シミュレーターで実行すると、メモリ上に書いた命令どおりに実行されてレジスタが期待通りの値になっていることが分かる。A,Bの値が各クロックごと変わっていることが分かる。
まだ、いまいちHDLの書き方が分かってない。考え方は合ってる気がするけど、それをHDLにしたときに間違っているっぽい。wireのサイズとか指定しないと行けない部分を指定してなかったりしてうまく動かない場合もあった。まだ感覚が掴めてないので何が悪いのか、考え方が間違ってるのか、書き方が間違ってるのか判断するのに時間がかかる。
2019/3/10
CPUの原型は出来た気がするので、もう少しちゃんと命令を考えて実装してみたい。・・・がどんな命令を実装すればいいのだろう。ぱっと思いつくのは四則演算とPUSH
,POP
,JMP
あたりかな。ただ、PUSH
,POP
に関してはまだそのためのメモリが存在しない。とりあえずはスタック用のメモリとプログラム用のメモリは分けておいてもいいのかな。
ちなみに、最終的に実装したい命令セットはRISC-Vを考えている。なんか最近流行りっぽいし、比較的簡単らしいし。簡単と言っても今の私には荷が重いので、もう少し練習してから。
そうそう、昨日の動作させたプログラムはメモリ上にあるけど、このメモリは僕らが普段使ってるメモリじゃない(はず)。FPGA上に実装されたメモリであって、いわゆるメモリモジュールの中にあるわけではない。なんか、ちょっと読んだ感じだと今使っているARTY Artix-7というボードにはDDR3メモリというのが256MBぐらい載っている。載ってるってことは使えるってことなんだろうけど、よく使い方は分からない。ちょっと読んだ感じだとMIG(Memory Interface Generator)というのを通さないと(使わないと?)行けないらしい。FPGAから見るとこのメモリは完全に外部デバイスであって、すぐアクセス出来るものじゃないよってことなのかな?と考えている。
この辺に少し情報がありそうな気もするのでメモ。
http://dora.bk.tsukuba.ac.jp/~takeuchi/?%E9%9B%BB%E6%B0%97%E5%9B%9E%E8%B7%AF%2FHDL%2FXilinx%20Memory%20Interface%20Generator%20%28MIG%29%20%E3%81%AB%E3%82%88%E3%82%8B%20DDR2%20SDRAM%20%E3%81%AE%E3%82%A2%E3%82%AF%E3%82%BB%E3%82%B9
命令セットの話に戻ろう。また適当に以下のようなCPUを考えてみた。
- 汎用レジスタ4つ (A,B,C,D)
- PCレジスタ1つ
- スタックポインタレジスタ1つ (SP)
サポートする命令は以下の通り。Rxはレジスタ、Imは即値。
MOV Rd, Rs ( Rd = Rs )
MOV Rd, Im ( Rd = Im )
ADD Rd, Rs1, Rs2 ( Rd = Rs1 + Rs2 )
ADD Rd, Rs, Im ( Rd = Rs + Im )
SUB Rd, Rs1, Rs2 ( Rd = Rs1 - Rs2 )
SUB Rd, Rs, Im ( Rd = Rs - Im )
DIV Rd, Rs1, Rs2 ( Rd = Rs1/Rs2 )
DIV Rd, Rs, Im ( Rd = Rs/Im )
MUL Rd, Rs1, Rs2 ( Rd = Rs1*Rs2 )
MUL Rd, Rs, Im ( Rd = Rs*Im )
JMP Rs ( PC = Rs ) // 絶対JUMP
JMP Im ( PC = Im ) // 相対JUMP
JZ Rs1, Rs2, Im ( JMP Im if Rs1 == Rs2 )
JNZ Rs1, Rs2, Im ( JMP Im if Rs1 != Rs2 )
PUSH Rs
POP Rd
ちょっといろいろ変なところがあるきはするけど、まあ、とりあえずね。JZ
とかはBEQ
命令なのかな。x86とRISC系がごっちゃになってる気はする。あと割り算のところ。商とあまりを考えないといけないけど、どうするかは未定。あとでその場で問題になりそうだったら考える。一応命令数は15だから4bitで収まるけど、一応将来的にRISC-Vにしたいということもあって、ちょっとそこは真似て、6bit使って命令部分を作ってみようと思う。
RISC-V命令のbitの割付(エンコーディング?)はこんな感じらしい
いろいろタイプがあるけど、6bitでopcodeを定義しているので、それに従う。レジスタは僕のCPUは4つしかないので、それぞれ2bit使う。1命令あたり16bitを使って行ってみようかと思う。つまり、16bit CPUということかな。レジスタも全部16bit
。
なんとなく上位bitにopcodeを持ってきたくなるけど、下位にもってくるのか。反転すりゃ同じなんだから同じことなんだろうけど、なんとなくね。僕もこれに従って下位にopcodeを持ってくる。
雰囲気的には以下のような感じにエンコードにしてみよう。やっぱり命令によって形を変えないといけそうな気がしたので、いくつか必要そうなのを作った
4bit | 2bit | 2bit | 2bit | 6bit |
---|---|---|---|---|
- | Rs2 | Rs1 | Rd | opcode |
| 6bit | 2bit | 2bit | 6bit |
|---|---|---|---|---|
| Im | Rs | Rd | opcode |
10bit | 6bit |
---|---|
Im | opcode |
なんかだんだんハードル高くなってきた・・・。できるかな。
まずはMOV, ADDあたりから作っていってみようと思う。
メモリ配置
プログラムとデータのメモリを完全に分けてもいいのかなと思ったけど、Linuxとかのプロセス同様にプログラムの位置と読み書きデータとスタックを別々の領域に置くようにすればいいかと思って以下のようにしてみようかと思う。16bitでアドレス管理なので64Kbyteの領域がある。
0x0000 プログラム開始位置 (4KB)
0x1000 データの開始位置 (下方向に使う)
0xFFFE スタック開始位置 (2byteずつ上に移動。SPで管理)
64KBもあれば、今書こうとしているサンプルコード程度じゃ使い切れないので十分なサイズになる。
今使ってるボードの場合Block Ramというのがあるらしくそれを使えば225Kbyteまでは使えるっぽい。
ここを参考に使い方を勉強してみる。なんか、Synchronousにしないといけない、みたいなことが書いてある。僕の理解だと、アドレスのセットとデータの読み出しを同じクロックではできないっていうことみたいなんだけど・・・。今の僕にはこれは難しいぞ・・・。2クロックでデータ読み出せってことか。この場合どうするんだ。
2019/3/15
しばらくメモリの同期読み書きみたいな話を調べていた。ブロックRAMはやっぱりアドレスを指定するタイミングと、実際にその値が読まれたり書かれたりするタイミングが1クロックずれるらしい。
多分この辺に書いてある話なんだと思う。参考に読んでいる本にも似たような記述があったけど、それは書き込みが1クロックずれて、読み込みすぐ出力されるタイプだった。なんか、これはまた違うメモリっぽい。
うーん、なかなか難しいなぁ、どうしようかなぁ。容量するなくていいならわざわざBloc Ramにする必要ないみたいなんだけど。もう少し勉強してみるか。
Block Ramの動作確認
一応、Block Ramの動作をきちんと動きを確認するために、Block Ram内に0,1,2,3...という連番のバイト列を作成し、それをPC
レジスタで読み出すというのをシミュレーションした。1クロックでPC
が+1される。読み出された値はINST
に出力される。
module top( input CLK, output wire [3:0] led );
reg [7:0] PC;
wire [7:0] INST;
sram memory( CLK, PC, 0, 0, INST );
initial begin
PC = 0;
end
always @(posedge CLK) begin
PC <= PC+1;
end
endmodule
そうすると以下のようになる。
PC
とINST
が1クロックずれている。PC
が指したメモリの値は次のクロックでINST
に出力される。
確かに予想通りなんだけど・・・。じゃあ、PC
が指したメモリの内容をその場で実行するみたいなことが出来ないじゃないか・・・。もちろん次のクロックで実行してもいいけど、そしたら、JMP命令とかどう実装したらいいんだ、PC
変更しても1クロック待たないといけないな・・・。待てってことなのか。
2019/3/18
少し「デジタル回路設計とコンピュータアーキテクチャ 」の、「7.4マルチサイクルプロセッサ」を読んでいた。やっぱりブロックRAMを使うには複数サイクルで1命令じゃないと難しそうな気がする。
少なくとも、シングルサイクルで1命令の場合、命令のメモリからの読み込みと、その命令によるメモリの読み書きを1サイクルで終わらせないといけない。そうするとメモリを2つに分けなくてはいけないことになるらしい。
一つのメモリ領域を命令用にもデータ用にも使おうとすると、命令の読み出しをまず行って、それに従って必要な処理を次の数サイクルで行って(ここでデータメモリの読み書きも行う)、全部終わったらPC
をインクリメントする(実際には前の命令が終わる前にインクリメントしておいて、命令をフェッチしないようにするっぽい)。
それぞれの部品がレジスタから値を取るところとか、計算するところ、それからメモリに書き込むところが、それぞれ一つの命令が始まってからどのタイミングで動作するべきなのか、またはずっと動作するべきじゃないのかなどを制御するために、読み出した命令を「制御ユニット」につないで、そこから各部品に命令に応じて司令を送る感じになるっぽい。
2019/4/6
だいぶ間が空いてしまった。でもいろいろ教科書を読んで感じは掴んだ。
ちょっと難しそうだけど、たぶんBlock RAMを利用するには複数サイクルで1命令を実行みたいな形にしたいとできなさそうなので、その方針でやってみようかと思う。
命令セットは3/10に書いたとおり。たぶんいろいろ問題のある命令セットなんだと思うけど。ぱっと思いつくところだとJMP命令あたりは即値の場合はビット数がそもそも足りない。とりあえずいいことにしよう。あとLoadとStore系の命令がない。「デジタル回路設計とコンピュータアーキテクチャ」は、lwとsw命令から設計していたから、それからやったほうが良さそうということでそれも追加してみよう。
なんかうまくいかない雰囲気が満載だけど、理屈ばっかり考えてても進まないので実装してみようかと思う。
まずは基本的な回路を。まず、やってみたことは、2クロックサイクルでPCをインクリメントして、命令を取ってくるというだけ。これでだけでもまあまあ大変。
`define BITWIDTH 16
module ProgramCounter(
input CLK,
input wire we,
input wire [`BITWIDTH-1:0] offset,
output wire [`BITWIDTH-1:0] out
);
reg [`BITWIDTH-1:0] PC;
initial begin
PC = 0;
end
assign out = PC;
always @ (posedge CLK)
begin
if( we ) begin
PC <= PC+offset;
end
end
endmodule
// 128KB (16bit * 64k)RAM
module RAM #(parameter ADDR_WIDTH = `BITWIDTH, DATA_WIDTH = `BITWIDTH, DEPTH = 2**`BITWIDTH) (
input wire i_clk,
input wire [ADDR_WIDTH-1:0] i_addr,
input wire i_write,
input wire [DATA_WIDTH-1:0] i_data,
output reg [DATA_WIDTH-1:0] o_data
);
reg [DATA_WIDTH-1:0] memory_array [0:DEPTH-1];
initial begin
$display("Loading rom.");
$readmemb("ram.mem", memory_array);
o_data <= memory_array[i_addr];
end
always @ (posedge i_clk)
begin
if(i_write) begin
memory_array[i_addr] <= i_data;
end
else begin
o_data <= memory_array[i_addr];
end
end
endmodule
module Controller(
input CLK,
input wire [`BITWIDTH-1:0] inst,
output wire pcwe
);
reg pc_write_enable;
initial begin
pc_write_enable = 0;
end
assign pcwe = pc_write_enable;
always @ (posedge CLK)
begin
pc_write_enable <= !pc_write_enable;
end
endmodule
module Top( input CLK, output wire [3:0] led );
wire [`BITWIDTH-1:0] addr;
wire [`BITWIDTH-1:0] inst;
wire pcwe;
ProgramCounter pc(CLK, pcwe, 1, addr[`BITWIDTH-1:0]);
RAM memory( CLK, addr[`BITWIDTH-1:0], 0, 0, inst );
Controller controller(CLK, inst, pcwe);
endmodule
ちょっと複雑になってきた。順番に説明すると、
一番下のTopモジュールが全体の回路を表していて、その中にProgramCounter,RAM,Controllerが一つづつある。
ProgramCounterは現在の命令を読み出すアドレスを持つ。4番目の引数のaddr
にそのアドレスが出力される。1クロックサイクルCPUの場合、毎クロックインクリメントすればよいけど、マルチサイクルの場合、インクリメントするべきタイミングはその命令が終わるタイミング(または、その途中)なので、いつ更新するべきかというフラグを入力に持たないと行けない。それが2番めの引数pcwe
になる。これがtrueだとProgramCounterはCLKの立ち上がりで、offset
分だけ命令アドレスを保持する内部レジスタPC
を更新する。offset
にしてあるのは、なんとなくこのあとJMP命令とか実装するときにこのほうがいいのかなと思ったから。ひとまず今は固定で1をoffset
にしているのでpcwe
がtrueのタイミングで1だけインクリメントされる。
pcwe
はControllerから供給される。Controllerは本来は命令を入力に受け取って、その命令ごとに各モジュールに司令を出して命令がスタートしてからの各クロックのタイミングでどのモジュールがどのように動作するべきかを定義しておくようなもの。これから命令を追加していくと、このControllerにたくさんのことを書いていかないといけない。今回はまだ命令は実装していないのでシンプルにpcwe
を制御するだけ。そして今回はControllerはpcwe
を2クロックごとにtrueにするようにした。つまり2クロックごとにProgramCounterが更新される。
ProgramCounterからの出力addr
はRAMに入る。そして次のクロックでRAMから命令がinst
として取り出される。これまでさんざん書いてきたように、addr
が更新されたタイミングではまだRAMからそのアドレスの命令は読み出されていない。その次のクロックで読み出される。
シミュレーションをしてみると以下のような感じ。
addr[15:0]
が命令アドレス。inst[15:0]
が命令。分かりやすいようにメモリの中身はアドレスとその値が一致するようにしている(0番地なら0, 1番地なら1が入っている)。
上の図から、addr
に対応する命令inst
が1クロック遅れて取得されているのが分かる。さらにpcwe
は2クロックごとにtrue,falseを繰り返している。
ちなみに回路図は以下のような感じ
2019/7/10
めっちゃ間空いてしまった。転職したり家族のことでだいぶ何もできなかった。再開しよう。
前に考えていたCPUを再度作り始めようと思う。