自作OSへの第一歩: 写経からVMを作る人の目線へ | UNIX Cafe

* 当サイトでは、コンテンツの一部に広告を掲載しています。

System Note $ cat /proc/ai-disclosure

本記事の構成および論理分析にはAI(人工知能)を使用しています。情報の正確性は、システム管理者(UNIXユーザー)による手動検証済みです。

自作OSへの第一歩:CでVMを作ってCPU命令を学ぶ | UNIX Cafe

先日から自作OSを作り始めました。

最終的には、自作CPUの上に自作OSを載せ、その上で自作シェルを動かし、最後には自作エディターまで開けるようにしたいです。

ただし、いきなりOS全体を作るのは大きすぎます。そこでまずは、小さなVMをCで手書きしながら、CPUの命令を1つずつ実装しています。

今やっていることは、完成コードを急いで作ることではなくて、命令の意味を自分の言葉で説明できるようにしながら、自分の手でVMを育てていくことです。

この記事では、第1回として、今の進め方と、LDB命令で分かった「アセンブリを書く人の目線」と「VMを作る人の目線」の違いを整理します。

今回の練習ノートとテストコードは、GitHubの handmade-vm-os に置いています。

目次

今回作りたいもの

目標は、次のような構成の小さな自作マシンです。

host macOS/Linux
  -> 自作VM本体 C
  -> 自作CPU ISA
  -> 自作OS
  -> 自作shell
  -> 自作editor

ただ、いきなりここまで作るのは無理がありますので、最初は自作VMの中にあるCPU命令だけを扱います。

現在の学習用VMでは、次のような部品を少しずつ決めています。

  • 1MBのmemory
  • 32bit命令
  • R0からR7までの汎用レジスタ
  • PC
  • SP
  • HALTMOVISYSCALLLDBなどの命令

この記事では、その中からLDBLoad Byte)を例にします。

いきなり実装せず、仕様カードを書く

今回の進め方では、コードを書く前に小さな仕様を書きます。

大きな仕様書ではなく、1命令だけの小さなカードです。例えば、LDBなら次のように書いています。

名前: LDB
分類: memory-register instruction
目的: VM内メモリから1 byteを読み、レジスタへ入れる
命令長: 4 byte
bit配置:
  bits 31..28: type = 3
  bits 27..24: op = 0
  bits 23..20: rd
  bits 19..16: rs
  bits 15..0 : unused = 0
読むレジスタ: rs
書くレジスタ: rd
読むメモリ: memory[regs[rs]] から1 byte
書くメモリ: なし
PCの変化: fetch時に +4
条件フラグの変化: なし
エラー時: register number が範囲外、または address が memory 範囲外なら停止
手作りテスト: LDB R0, [R1] -> 0x30010000
成功条件: R1 = 0x10, memory[0x10] = 0x41 のとき、実行後 R0 = 0x41

最初から全部を埋める必要はないので、分からないところは未定のままにします。

今回のテーマは、コードを書く前に「この命令は何をするのか」「何を読んで、何を書き換えるのか」を整理して、順番に理解することです。

仕様書からテストコードを作る

仕様書を書いたら、それを元に手作りのテストプログラムを作ります。

今回のテーマはLDBです。テスト用のアセンブリは次の形にします。

MOVI R1, 0x10
LDB R0, [R1]
SYSCALL 0
HALT

LDBLoad Byte)は、メモリから1バイト(8ビット)のデータを読み込む指示なので、メモリには、読みたい1文字を置いておきます。

0x00000010: 41

0x41はASCIIでAなので、期待する出力はこうなります。

A
CPU halted.

これをCのテストコードにして、実際にコンパイルして動かします。

GitHubに置いたファイルで試す場合は、リポジトリのルートで次のように実行します。

cc notes/008-ldb-test.c -o /tmp/008-ldb-test
/tmp/008-ldb-test

実行結果は期待通りでした。

A
CPU halted.

小さい確認ですが、この確認は大事です。「仕様を書く」「テストコードにする」「実行して結果を見る」という流れを、1命令ずつ繰り返します。

写経でアセンブリの読み方が少し分かってきた

これまでに学習した、「LC-3仮想マシン」や「いちばんやさしい!OS自作超入門」では、既存の教材を見ながらコードを写経をしてきました。

これらの作業を通じて、テキストを見ながら毎日手を動かしている中で、これまであまり馴染みのなかったアセンブリにも少しずつ目が慣れて、読めるようになってきました。

例えば、次の命令です。

MOVI R1, 0x10

このコードは、次のように読めます。

R1に0x10を入れる

また、次のような命令もあります。

LDB R0, [R1]

このコードは、次のように読めます。

レジスタ R1 が指す memory から1 byte読んで、R0へ入れる

このあたりは、アセンブリで見るとかなり素直です。R0R1という名前がそのまま出てきますし、[R1]も「R1の中身をアドレスとして使い、その場所を読む」という意味に見えます。

CでVMを書く目線になると難しくなる

難しくなるのは、同じことをCでVMとして実装するときです。

アセンブリでは、命令の形がそのまま読めます。

LDB R0, [R1]

ところが、VMのCコードでは次のようになります。

regs[rd] = memory[regs[rs]];

同じことをしているはずなのに、急に読みにくく感じます。アセンブリでは1つの命令として見えていたものが、Cでは「配列を使った値の移動」として書かれるからです。

まず、CのコードにはR0R1という名前が直接出てきません。VM内部では、レジスタを1つずつ別の変数にするのではなく、配列として持っています。

R0 = regs[0]
R1 = regs[1]
R2 = regs[2]
...
R7 = regs[7]

つまり、R0regs配列の0番目、R1regs配列の1番目です。アセンブリではR1と書けますが、CでVMを実装するときはregs[1]として扱います。

次に出てくるのが、rdrsです。これはレジスタそのものではなく、命令をdecodeして取り出した「レジスタ番号」です。

今回のLDB R0, [R1]なら、decode結果は次のようになります。

type = 3
op   = 0
rd   = 0
rs   = 1

つまり、アセンブリに出てくるR0rd = 0になり、R1rs = 1になります。rdは「どのレジスタへ書くか」、rsは「どのレジスタをアドレスとして使うか」を表しています。

ここまで分けてから、もう一度Cのコードを見ます。

regs[rd] = memory[regs[rs]];

この1行は、右側から読むと分かりやすくなります。

memory[regs[rs]] を読む
       ↓
regs[rd] に入れる

今回のLDB R0, [R1]では、rd0rs1です。番号を入れると、次の形になります。

regs[0] = memory[regs[1]];

まだ少し読みにくいので、ここでregs[1]が何かを考えます。先にMOVI R1, 0x10を実行しているため、regs[1]には0x10が入っています。

regs[1] = 0x10

regs[1]は「R1というレジスタ名」ではありません。「R1の中に入っている値」です。今回その値は0x10なので、さらに置き換えるとこうなります。

regs[0] = memory[0x10];

次に、memory[0x10]を見ます。これは「メモリの0x10番地にある1 byte」という意味です。今回のテストでは、そこに0x41を置いています。

memory[0x10] = 0x41

そのため、最後は次の状態になります。

regs[0] = 0x41;

ここで扱っているのは、Cのポインタそのものではなく、VMのmemory配列の番地です。R1の中に入っている数値を、VM内メモリの番地として使います。

まとめると、LDB R0, [R1]は次の順番で動きます。

  • R1の中身を見る
  • その値をメモリのアドレスとして使う
  • そのアドレスにある1 byteを読む
  • 読んだ値をR0へ入れる

Cのregs[rd] = memory[regs[rs]];は、この4段階を1行にまとめたものです。慣れるまでは、頭の中だけで追うより、番号と値を1つずつ置き換えて読むほうが分かりやすいです。

fetch、decode、executeを分けて見る

VMを作る人の目線では、1つの命令をfetchdecodeexecuteに分けて見ます。

fetch

fetch(取ってくる)とは、CPUが実行するための命令をメモリから取り出す、すべての処理のスタート地点です。

具体的には、PC(プログラムカウンタ)が指すmemoryから32bit命令を読みます。例えば、今回の LDB R0, [R1] という命令は、コンピュータの内部では 0x30010000 という32bitの数値データ(命令コード)として読み込まれます。

memory[PC + 0] = 0x30
memory[PC + 1] = 0x01
memory[PC + 2] = 0x00
memory[PC + 3] = 0x00

この4 byteを読んだあと、PCは次の命令へ進みます。

decode

decode(解読)は、CPUが「次に何をすべきか」を理解するために命令を分解する工程です。

具体的には、32bitの命令データからそれぞれのfieldを取り出します。

type = 3
op   = 0
rd   = 0
rs   = 1

rdは書き込み先のレジスタ番号です。rsは、読み取り元アドレスが入っているレジスタの番号です。ここではrd = 0なのでR0へ書き込み、rs = 1なのでR1の中身をアドレスとして使います。

execute

execute(実行)は、decodeで解読した命令をベースに、実際の計算や処理を行う工程です。

具体的には、取り出したfieldを使ってVM(仮想マシン)の状態を変えます。

regs[rd] = memory[regs[rs]];

今回の実装で一番つまずいた「アドレスの指定方法」について

ここで大事なのは、アクセス先が memory[rs] ではなく、memory[regs[rs]] になるという点です。

rsはレジスタ番号なので、今回なら1になります。しかし、読みたいのはmemory[1]ではありません。読みたいのは、R1の中に入っているアドレス、つまりregs[1]が指す先の中身です。

  • rsはレジスタ番号です
  • regs[rs]は、そのレジスタに入っているアドレスです
  • memory[regs[rs]]は、そのアドレスにある1 byteのデータです

この「番号」「アドレス」「中身(データ)」の階層の区別が、今回いちばん大きな壁であり、最大の学びになりました。

アセンブリを書く人の目線と、VMを作る人の目線

今回、自分が苦戦している原因は、まさにこの「視点の違い」にあるのだと分かりました。

アセンブリを書く人の目線では、命令は単なるパラメータ(field)として見えています。

LDB R0, [R1]

このコードの意味は次のようになります。

レジスタ R1 が指す memory から1 byte読んで、R0へ入れる

しかしVMを作る人の目線では、これらの部品を使って、以下のように「VMの状態(メモリやレジスタ)」を変化させる必要があります。

type = 3
op   = 0
rd   = 0
rs   = 1

その部品を使って、VMの状態を変えます。

regs[rd] = memory[regs[rs]];

アセンブリ(書く人の目線)C言語による実装(VMを作る人の目線)

この関係を対応表にまとめると、次のようになります。

Assembly        C
R0              regs[0]
R1              regs[1]
[R1]            memory[regs[1]]
LDB R0, [R1]    regs[0] = memory[regs[1]]

この「アセンブリからfield(パラメータ)へ分解し、そこからC言語のデータ構造へ落とし込む」という変換作業が、VMを開発する上で最も脳のメモリを使う部分なのだと思います。

慣れている人は、これを頭の中で一瞬でやってのけるのかもしれませんが、慣れていないうちは、紙やノートにこうして1つずつ書き出して置き換えていくのが、確実で一番の近道だと実感しています。

毎回同じ手順で変換できる「練習ガイド」の作成

そこで、毎回迷わずに「アセンブリ目線」から「VMを作る人の目線」へと切り替えられるよう、練習ガイドを作成しました。

用意したファイルは次の2つです。

あわせて、今回の確認に使った notes/008-ldb-test.c と、Day 8時点の最小VMである notes/vm.c も同じリポジトリに置いています。

3つの目線で見る「練習フォーム」

1. アセンブリを書く人の目線
2. 命令をdecodeするVMの目線
3. 実際に値を動かすCコードの目線

新しい命令に出会ったら、毎回必ず以下のフォームに沿って書き出していきます。

命令:

アセンブリ目線:

decode目線:
type =
op   =
rd   =
rs   =
imm  =

C目線:

今回の値で置き換える:

※ 命令によって使わないfield(例:MOVIならrsLDBならimm)は、空欄のままで構いません。

「いきなりC言語のコードを読もうと(書こうと)しないこと」です。

  1. まずアセンブリで命令の本来の意味を確認する
  2. 次にdecode(分解)の結果を書き出す
  3. 最後にC言語へと置き換える

この3ステップを愚直に繰り返すことが、VMの構造を脳内に定着させる一番の近道になります。

今回分かったこと

「自作OS(VM)を作っている」と言うと、とても大きな、難解なことをしているように見えるかもしれません。

しかし、実際にいま自分がやっていることは、驚くほど小さなことの積み重ねです。

  1. 1命令の仕様を書く
  2. 手作りの memory 配置を作る
  3. fetch(読み込み)する
  4. decode(解読)する
  5. execute(実行)する
  6. 出力(結果)を見る

これまでの「写経」を通じて、アセンブリ言語の読み方は少しずつ分かってきました。

一方で、VMを作る人の目線、つまり、R0regs[0] に変換したり、LDB R0, [R1]regs[rd] = memory[regs[rs]] に落とし込んだりする部分では、今まさに苦戦しています。

でも、「自分がどこで苦戦しているのか」が分かれば、出口は見つかります。なぜなら、原因さえ分かれば、あとは練習して克服できるからです。

次に進むこと

次はSTBに進みます。

LDBmemoryから読んでregsへ入れる命令でした。STBは逆に、regsの値をmemoryへ書き込む命令になります。

ブログの更新は不定期になりますが、また何か気づいたことがあればまとめてみます。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

のいのアバター のい UNIX Cafe マスター

Macintosh Color Classicから始まった旅は、長いWindows時代を経て、Windows10のサポート終了をきっかけにUNIXの世界へ戻ってきました。UNIX Cafeでは、UNIX・Linux・そしてMacな世界を、むずかしい言葉を使わず、物語のように書いています。プログラミングは、アイデアをコンピューターに伝えるための言葉です。簡単な単語と文法を覚えれば、誰でもコマンドを使えます。ぜひ一度、やさしいプログラミングの世界をのぞいてみてください。

目次