🥗

森羅万象プロジェクト Chapter01-03:命令を送る・受け取る

こんにちは!森羅万象プロジェクトです!
前回は01-02_加算器をつくりました。

今回は 足し算ができ、他の命令もわかるようにするCPU(の一部) を作っていきたいと思います。

(定期宣伝)
ちなみにこの記事は、
CPUを作ったことのなかった人による、
CPUを作ったことのない人のための記事
ですので、CPU入門・初心者の筆者と一緒に勉強していただけたらと思っています!!

今回は、「算術論理ユニット(Arithmetic and Logic Unit)」について、
1.ALUができたら足し算以外も「分かる」らしい、、
2.命令を「分かる」ために必要な要素とは?
3.そもそも、電気だけで命令をどう作っている?
4.ALUをChiselでつくってみた!
に分けて説明していきます。

1.ALUができたら足し算以外も「分かる」らしい、、

・前回の記事では、CPUの機能の一部に

  1. 演算処理ユニット(Arithmetic Logic Unit, ALU)
    • 数値演算: 加算、減算、乗算、除算などの基本的な算術演算を実行。
    • 論理演算: AND、OR、NOT、XORなどのビット単位の論理演算を行う。
      (これらは全部NANDを組み合わせるだけでできる。NAND最強。)

っていう部分があり、ALUを作っているんだぞぉ。と言った気がします。(すみません。筆者引っ越したりしたので
1ヶ月ほど経っていましてあんまり覚えてない、、。)
しかし、足し算「だけ」できるCPUよりは、足し算「も」できるCPUを作りたいですよね。
加算器でええやん、CPUちゃうやん、って言われてしまいそうですし、、。
既に「こんなことやってます!」って取り組みを発表したりしてますし、、。
(おっ、宣伝チャンスか?)
セキュリティキャンプに参加し、
グループワークの活動や個人の活動を続けると、3月に行われるセキュリティキャンプフォーラムで発表の機会があり、
人脈形成、キャリア形成にとっても有効なので、是非参加してみてください!!
ちなみにフォーラムはキャンプ参加者でなくとも参加できますし、発表以外にもためになる講演やトークショーもあるので、
募集要項を確認してみてください!!

(あっ、そろそろ本題に戻りますね)
というわけで、今回は足し算「も」できるALUを作っていきます!!

2.命令を「分かる」ために必要な要素とは?

CPUを作るにあたって、命令を「分かる」ために必要な要素を考えてみましょう。

・まず、「足し算をする」という命令と(ひとまず例として)「引き算をする」という命令を見分けるには、
足し算を命令A, 引き算を命令Bとしておけばよさそうでしょうか。
(ついでにCPUっぽく、命令001と命令010(2進数)にしておきましょうか。)

・足し算引き算だけじゃなく、プログラムを少し勉強するとif文みたいなのが出てきますよね。
「加算減算でない別種の命令」が必要という感じでしょうか。
(if文みたいなのをA-1,足し算をB-1,引き算を(引き算は足し算と似てる感じなので)B-2として、
これもCPUっぽく、001-001、010-001、010-010という風に書いておきますか。)

・命令を出すとなったら、出した命令の順番を覚えておきたくなりませんか?
なりますよね?そう、なるんですよ(ゴリ押し、でもなるでしょ?)。
じゃあ、順番・時系列を「カウント」しておくものが必要になります。
(1回目が足し算、3回目が引き算なら、01:010-001、11:010-010、みたいな感じ。)

・そういえば、足し算や引き算をしたら、結果を保存する必要がありますね。
もし高校・大学の講義を受けているのであれば、「メモリ」にデータを保存し、
メモリが格納されている番地のようなもの(アドレス)が宛先として指定可能である、
って習ったかもしれません。命令をカウントするには命令そのものを保存しておく必要がありますね。
(メモリは市販PCで8GBとか16GBとか言われるくらいなので、今回はめっちゃ大きくて命令は全部保存できるとしましょう。)

・もっと詳しい講義を取っていた方は、CPUがメチャクチャ計算が速い理由として、
CPUが「レジスタ」と呼ばれるメモリぽいものを持っており、保存できる量は少ないものの
爆速で計算できる、ということまで知っているかもしれません。
(レジスタは量↓で速度↑、メモリは量↑で速度↓というイメージです。
気になる方はトランジスタとキャパシタ、SRAMとDRAMについて調べてみてくださいね。)
(「1回目」に、「レジスタ3」に入っている3と「レジスタ5」に入っている5を「足し」て、
結果を「レジスタ7」に入れるとするなら、01:010-001-011-101-111、とかって表せますかね。)

・あぶないあぶない、x=3, y=5にしておいてからx+y=8にしてるなら、3とか5とかの「値そのものを保存する場所」がありませんでした。
(「1回目」に、「レジスタ3」に入っている3に「5」を「足し」て、
結果を「レジスタ6」に入れるとするなら、01:010-001-011-101-110、とかって書けそう?
ってことは、レジスタは2~3個必要で、値そのものは0~1個必要そうですね。)

・さっきから(カッコ)でCPUぽく作っていましたが、0,1だけで命令を作ってるから、1bitでもズレたらヤバそうですね。
じゃあ、「命令全体は○○bit」って決めておいたほうが良さそうですね。
(せっかく010-001-011-101-111とか010-001-011-101-110としたから、この例なら15bitにしておけば良さそう?
1bit左にずらして100-010-111-011-110とかになったら、違う命令になってしまう、、。)

これらをまとめると、
・命令を管理するものとして「カウントするもの」「メモリの容量」は用意しておいて、
・命令そのものには「全体の長さ」「命令を大きく分類」「似た命令をさらに分類」「値を持っておくレジスタ×2~3個」「値そのもの×0~1個」
があれば、命令を正しく「分かる」ことができそうですね。

(もしあなたが「ホントに見落としたもの・不要なものはないんですか?」と疑問を持ったようであれば、問題なく。
後でちゃんと伏線回収(という名の予定調和)してるので、ちゃんと必要十分になってますよ。ちゃんとね、、。)

3.そもそも、電気だけで命令をどう作っている?

基本情報技術者試験(←CBTなのでいつでも受けれる国家資格です、ぜひどうぞ!)を受ける方とか、
CDとかに詳しい方であれば、標本化・量子化・符号化、なんて聞いたことあるかもしれません。
例えば0-1Vの波形が来る時、100段階に量子化したら0.01V刻みで0-1Vの範囲を表現できる、とか。
(0.23Vが来たら23を表示しておく、なんてことができるの、すごいですよね。)
上の2.で01(2進数)を使ったのですが、 そのbitに0か1を表示するときもほぼ同じで、
(FPGA「Tang nano 9K」はUSB接続だから5Vなので)例として閾値2.5Vより下だったら0, 2.5Vより上だったら1とかで表現できそうですね。

01の表示の仕方がわかったところで、じゃあ今回の閾値はなんやねん、って話ですが、
Tang nano 9Kの公式サイトより引用し(て少し手入れし)たものを見ると、

Tang nano 9Kのコンポーネント

こやつはUSBの5V電源から3つの直流電源を生み出すことができ、

Tang nano 9Kのピン

電圧はそれぞれ3.3V, 3.3V, 1.8Vになってるようですね。
つまり、このFPGAは閾値が(3.3Vの半分の)1.65Vより下だったら0, 上だったら1,とかをやっていそうです(たぶん)。
で、その3.3Vやら1.8Vやらを使って、USBもHDMIも使えるように(いい感じに裏で)なっているみたいですね。

あ、そうそう、PCの電源もやっていることはほぼ同じですね。
ピーク時±141V交流(fromコンセント)から、500~1200W直流(デスクトップPC)または90~250W直流(ノートPC)に変換して、
そこからいくつかの12V(for CPU, GPU)とか5V(for USB, SSD)とか3.3V(for RAM, 他のチップ)を作ってくれてるはずです。

4.ALUをChiselでつくってみた!

サンプルコード:筆者のGitHub
今回はCPU-2でやってます。

これが動けばOK!!

これでOK

とりあえず、サンプルコードをgit cloneしてきて、CPU-2中でtestが動けばOKです。
できる方はどんな手を使ってもよいので、どうぞ。

・筆者の個人的ポイント:
実はChiselに必要なJavaの環境が使えるか確かめるプログラムを
少し変えてみました。まあ、ほとんどGPT生成なのですが。筆者のメモ置き場として、ね。

筆者はこうやりました

(参考までに、筆者はWindows11を使っています。 上手くいかない場合はインターネットや友人に相談してみてくださいね。(定期))

  1. Alu.scalaをChiselで書く
package core

import chisel3._
// ↓ココだけ追加
import chisel3.util._

class Alu extends Module {
  val io = IO(new Bundle {
  // command, a, b, outを定義します。
  // 先ほどのTang nano 9Kの図を見ると、周囲に丸いもの(I/Oピン)が48つあるのですが、  
  // (割り当てもできるけどどうせそのうちI/Oピンを使うものが増え場所をズラしたりするので)
  // 48つのうちどこかに4つにそれぞれcommand, a, b, outが割り振られているイメージですね。
  // 実機で動かす際に、割り当てた箇所に銅線でもはんだ付けしてオシロスコープとかで見てみると、
  // a,bの値が出てくるかもしれませんね。  
  val command  = Input(UInt(8.W))
  val a        = Input(UInt(32.W))
  val b        = Input(UInt(32.W))
  val out      = Output(UInt(32.W))
  })

  // Chiselのマルチプレクサ(多路選択機)→いくつかの回路を用意し1つを選んで通れるようにするもの
  // 今回のものであれば、デフォルトのルートは0(符号なし32bit)で、
  // commandが1(符号なし8bit)のときはa(符号なし32bit)とb(aと同じ)を足す、という感じですね。
  io.out := MuxCase(0.U(32.W), Seq(
    (io.command === 1.U(8.W)) -> (io.a + io.b),
  ))
}

・筆者の個人的ポイント:
aとかoutとかには数値が入っているのではなく、あくまでも「数値が入る場所」を定義しているだけで、
「数値そのもの」が入っているわけではないです。
とあるエルフさんが「魔法はイメージの世界」とか言っていましたが、「CPUもイメージの世界」なのかもしれませんね。
なんでオシロスコープで見れるのか、なんでフリップフロップが必要なのかイメージできますか、、?

  1. Core.scalaをChiselで書く
package core

import chisel3._
import chisel3.util._

class Core extends Module {
 
  // 「メモリの容量」は1024 * 6(6KB)で、「カウントするもの」は32bitで用意。
  val mem = Mem(1024 * 6, UInt(8.W))
  val pc = RegInit(0.U(32.W))

  // 命令そのものに関して、「全体の長さ」は48bit(6byte)、
  // 「命令を大きく分類」は5bit、「似た命令をさらに分類」は3bit、
  // 「値を持っておくレジスタ×2~3個」は5bit、
  // 「値そのもの×0~1個」は32bitで用意。
  val instr      = Wire(UInt(48.W))
  val opcode     = Wire(UInt(5.W))
  val opcode_sub = Wire(UInt(3.W))
  val rd         = Wire(UInt(5.W))
  val rs1        = Wire(UInt(5.W))
  val rs2        = Wire(UInt(5.W))
  val imm        = Wire(UInt(32.W))

  // Fetch
  // カウントから6byte(48bit = instr)分の命令を読み込む
  instr := Cat(
    (0 until 6).map(i => mem.read(pc + i.U)).reverse
  )

  // Decode
  // 読み込んだものを分ける
  opcode     := instr( 4,  0)
  opcode_sub := instr( 7,  5)
  rd         := instr(12,  8)
  rs1        := instr(17, 13)
  rs2        := instr(22, 18)
  imm        := instr(47, 16)

}
  

・筆者の個人的ポイント:
適宜このプロジェクトの(今のとこ)公式ドキュメントも見てみてください。

  • さっきは、1回目が足し算、3回目が引き算なら、01:010-001、11:010-010、みたいな感じと例示したのですが、
    今回のプロジェクトでは1回目が足し算、2回目が引き算なら、
    000...0000001(→カウンタは32bitで1bit目から48bit取り出す):00001-001-00010-00011-00100-000...001(→00001001からはじまる48bitの命令)と、
    000...0110001(→次は49から48bit取り出す):00001-010-00010-00011-00100-000...001(→00001010からはじまる48bitの命令(例))がメモリに入っている感じ。
  • 命令をメモリにロードする際、なぜか逆順(reverse)にしてるのは、「リトルエンディアン」「ビッグエンディアン」が関係しています。
    :ビッグエンディアン (Big Endian)→0x12345678 がメモリに [12][34][56][78] のように格納される。
    :リトルエンディアン (Little Endian)→0x12345678 がメモリに [78][56][34][12] のように格納される。
    今回は単純に「リトルエンディアン」や!ってなったと言えばそれまでなのですが、
    もし今後「値だけ取り出す」などをしたい場合に、下位bit([78]とか[56]とか)だけを取り出して処理できるため、とか、
    イマドキのCPU(x86→Intel Core iシリーズ, AMD64→AMD Ryzenシリーズ, ARM→Apple MチップとかSnapdragonシリーズとか)が、
    軒並みリトルエンディアンを採用しているので、とかありまして、、。
  • なんで5bitだったり3bitだったりで分けているのか、ですが、経験則とか冗長させとこう、みたいなところがあるようです。
    ただ、色んな所で定められている「intは32bit整数」みたいな決まりに合わせたくて即値(imm)は32bitにしたいという理由はありました。
    この辺りは、また別の記事にて、、。
  1. Top.scala(Alu.scalaをテストするやつ)をChiselで書く
package core

import chisel3._
import chisel3.iotesters._
import chiseltest._
import org.scalatest.flatspec.AnyFlatSpec

class TopTest extends AnyFlatSpec with ChiselScalatestTester {
      it must "runs Top" in { test(new Alu).withAnnotations(Seq(WriteFstAnnotation)) { dut =>
        dut.io.a.poke(3.U)
        dut.io.b.poke(5.U)
        // 今回はcommandも与えておく。commandの値によって、何の処理をするか決める。
        dut.io.command.poke(1.U)
        // dut.io.command.poke(2.U)
        dut.clock.step(1)
        dut.io.out.expect(8.U)
      } }
}
  

・筆者の個人的ポイント:
commandが(1)のときだけ、a(3) + B(5) = out(8)をする。
やってることとしては前回(CPU-1ディレクトリ参照)と一緒ですね。

  1. 実行(sbt test)したらこんな感じ
    正しく動くと上と同じ、全部「passed」になります。
    commandを「2」にしてみるとこんな感じ。ちゃんと「違う!!」って言ってくれます。
    sbt test(違うとき)

・足し算はできた(後まだ実装してないけど他の命令も実行できそう)でしょうか?
この記事を見てもできなかった・内容がおかしい等あれば、コメントやX(Twitter)で教えていただけると嬉しいです!

次回は、足し算以外もできるよう、命令の追加をしていきます!

担当:Astalisks

次の記事は制作中、しばらくお待ちください!

森羅万象プロジェクト

Discussion