🥗

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

2024/04/21に公開

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

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

(定期宣伝)
ちなみにこの記事は、
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.23Vが来たら23を表示しておく、なんてことができるの、すごいですよね。)
上の2.で01(2進数)を使ったのですが、
そのbitに0か1を表示するときもほぼ同じで、
例として閾値2.5V(FPGA「Tang nano 9K」はUSB接続だから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が割り振られているイメージですね。
    val command  = Input(UInt(5.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と同じ)を足す、という感じですね。
  // 次回の記事で詳細を説明しますが、足し算が2種類ある理由として、
  // 1.「レジスタA」の値aと「レジスタB」の値bを足す
  // 2.「レジスタA」の値aに「値(即値)」bを足す
  // という2種類があるからです。
  io.out := MuxCase(0.U(32.W), Seq(
    (io.command === 1.U(5.W)) -> (io.a + io.b),
    (io.command === 2.U(5.W)) -> (io.a + io.b),
  ))
}

  • 筆者の個人的ポイント:
    「(前記事より)ハードウェア記述言語なので、、。」
    「実機で動かす際に割り当てた箇所にはんだ付けして、、。」
    →→aとかoutとかには数値が入っているのではなく、あくまでも
    「数値が入る場所」を定義しているだけで、
    「数値そのもの」が入っているわけではないです。
    「魔法はイメージの世界」なんて話もありますが、CPUも
    「イメージの世界」なのかもしれませんね。
    私もこの記事書くのに5カ月かかっているし、、。
  1. Core.scalaをChiselで書く
package core

import chisel3._
import chisel3.util._

// 前回はテストで直接メモリに値を入れていましたが、今回はファイルから読み込むようにします。
import chisel3.util.experimental.loadMemoryFromFile

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

  // CPU-2/src/main/resources/のファイルを置いておきます。
  loadMemoryFromFile(mem, "src/main/resources/bootrom.hex")

  // 実行する命令の位置を保持しておくためのレジスタ(プログラムカウンタ)を用意。
  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)分の命令を読み込む
  // 今回はCPU-2/src/main/resources/bootrom.hexを6行ずつ読み込む
  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)


  // レジスタファイルの定義と初期化
  // 増やすと値をたくさん保持できる・メモリアクセス頻度が減るなどもメリットに加えて、
  // 回路が複雑になりレイテンシが増加するデメリットもついてきます。
  // 本質からそれてしまうのでとりあえずRISC-V系と同じ32個に、、。
  val regs = RegInit(VecInit(Seq.tabulate(32) { i => 0.U(32.W) }))

  // ALUのインスタンス化
  val alu = Module(new Alu)
  alu.io.command := opcode
  alu.io.a := regs(rs1)
  alu.io.b := MuxCase(0.U(32.W), Seq(
    // 1.「レジスタA」の値aと「レジスタB」の値bを足す
    // 2.「レジスタA」の値aに「値(即値)」bを足す
    // をここで分けていますね。
    (opcode === 1.U(5.W)) -> regs(rs2),
    (opcode === 2.U(5.W)) -> imm,
  ))


  // 書き戻し
  // ALUで計算した結果を「書き戻す(write-backする)」処理。
  // CPU内のレジスタに結果を保存しておくことで、後続の命令が結果を利用できるようになります。
  when(opcode === 1.U(5.W)) {
    regs(rd) := alu.io.out
  }
  when(opcode === 2.U(5.W)) {
    regs(rd) := alu.io.out
  }

  // Toptest(後述)用の出力
  io.out := regs(2)

  // プログラムカウンタの更新、6Byte(48bit)だから+6。
  pc := pc + 6.U
}
  

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

  • 先ほど 1回目が足し算、3回目が引き算なら、
    01:010-001、11:010-010、みたいな感じとしたのですが、
    今回のプロジェクトでは
    pc:opcode-opecode_sub-rd-rs1-rs2-imm
    の順で命令を読み込んでいます。
    (詳細はCPU-2/src/main/resources/memo.txtにもあります。)

  • 命令をメモリにロードする際、逆順(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チップとか)が、
    軒並みリトルエンディアンを採用しているので、とかありまして、、。

  • なんで5bitだったり3bitだったりで分けているのか、ですが、、
    経験則とか冗長させとこう、みたいなところがあるようです。
    ただ、色んな所で定められている「intは32bit整数」みたいな
    決まりに合わせたくて即値(imm)は32bitにしたいという理由はありました。
    この辺りは、また別の記事(99-01_森羅万象プロジェクトができるまで、、。)にて、、。

  1. TopTest.scala(Alu.scalaをテストするやつ)をChiselで書く
package core

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

class TopTest extends AnyFlatSpec with ChiselScalatestTester {
  "Core" should "execute instructions correctly" in {
    test(new Core).withAnnotations(Seq(WriteVcdAnnotation)) { dut =>

      // 前回までは命令と値までこのテストで用意しましたが、
      // ・命令自体のテストはやってられない(48bit目視で確認はしんどい)
      // ・そのうち命令のテストをしなくてよくなる(コンパイラ・アセンブラ、、。)
      // ・命令はファイルから読み取るのが一般的である
      // (import chisel3.util.experimental.loadMemoryFromFileがあるんだもん)
      // などの理由から、クロックと結果だけをここに書くようにします。
      // 1つの命令を読み込むのに1クロックを要するので、3命令で3サイクルですね。

      // クロックを3サイクル進める
      dut.clock.step(3)

      // 結果の検証
      dut.io.out.expect(8.U)
    }
  }
}


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

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

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

担当:Astalisks

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

森羅万象プロジェクト

Discussion