【低レイヤ入門】C言語で仮想マシン自作:命令追加とスタック操作、vm.c 整理まで進める | UNIX Cafe

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

System Note $ cat /proc/ai-disclosure

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

【低レイヤ入門】C言語で仮想マシン自作:命令追加とスタック操作、vm.c 整理まで進める | UNIX Cafe

自作VM開発記 2

この連載では、C言語で小さな仮想マシン(VM)を作っています。ここでいうVMは、「数値を入れる」「値を保存する」といった独自の命令を読み取り、実行するプログラムです。前回までに、数値の読み書きや1文字の表示ができるようになりました。

今回は、値を保存したり、一時的に預けて取り出したりする操作を増やします。例えば、計算に使っている値を別の場所へ保存しておけば、あとで必要になったときに取り出せます。

まず、値を保存するSTDI、値を1増やすINCと1減らすDEC、値をコピーするMOVを追加します。続いて、値を一時的に保存するPUSHと、保存した値を取り出すPOPへ進みます。最後に、各命令をまとめたVM本体のnotes/vm.cを、役割ごとに読みやすく整理します。

この記事で扱う命令は次の5日分です。

Day 11: STDI
Day 12: INC / DEC
Day 13: MOV
Day 14: PUSH
Day 15: POP

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

目次

今回のゴール

前回までにできたのは、指定した数値をVM内へ入れたり、保存済みの値を読み出したりすることです。例えば、文字Aを表す番号65を入れて、画面へAを表示できます。

指定した数値を入れる
保存されている値を読む
小さな単位で値を保存する
1文字を表示する

次に欲しいのは、入れた値を変えたり、その値を残しておいたりする操作です。

例えば、16を17へ増やす、元の値を残しながらコピーする、一時的に保存した値を取り出す、といった操作です。これらをそれぞれ1つの命令でできるようにします。

そこで今回は、次の3つを増やします。

1. 1つの値を丸ごと保存する
2. 値を1増減したり、別の置き場所へコピーしたりする
3. 値を一時的に保存し、あとで取り出す

この3つが入ると、VMの中で「値を置く」「値を動かす」「一時的に退避する」という基本動作がかなり見えてきます。

命令を読むために必要な前提

まず、値の置き場所を確認します。「レジスタ」は、計算などに使う値を入れておく場所です。このVMにはR0〜R7の8個があり、Cでは配列regsで表します。例えばR0に16を入れると、regs[0]の中身が16になります。

一方、「メモリ」は命令やデータを並べて保存する場所です。各場所に付いた番号を「アドレス(番地)」と呼びます。Cのmemory[16]なら16番地の内容を表します。レジスタ番号の0、番地の16、そこへ保存する値は、それぞれ別のものです。

メモリの1番地には1 byte(バイト)を保存します。1 byteは、0か1を表すbit(ビット)8個分で、0〜255を表せます。このVMのレジスタは32 bit、つまり4 byte分の値を保持するので、レジスタの値を丸ごと保存するにはメモリの4番地分を使います。

本文では数値を16進数でも書きます。0xがその目印で、0x10は十進数の16です。16進数では9の次をA〜Fで表すため、0x0Fは15、0x11は17になります。16進数の2桁が1 byteに対応し、0x12345678なら12 34 56 78の4組に分けて考えられます。

命令は、人が読みやすい「アセンブリ」という表記で示します。例えばMOVI R0, 0x10は「R0に16を入れる」、HALTは「VMの命令実行を止める」です。0x10のように命令に直接書く数値を「即値」と呼びます。

実際のメモリには、アセンブリの文字列ではなく、命令を表す数値を置きます。このVMでは1命令が4 byteです。次に読む命令の番地を保持するPC(プログラムカウンタ)が0なら、0〜3番地から1命令を読み、そのあとPCを4へ進めます。

VMの処理は、命令を読むfetch、種類や対象を取り出すdecode、実際に値を動かすexecuteの順です。例えばMOVI R0, 0x10なら、「命令を読む → 入れる先はR0、値は16と読み解く → R0へ16を入れる」と進みます。今回追加する命令も、この流れに組み込みます。

以下のアセンブリ例は、Cのテストコードがメモリへ置く命令を読みやすく表したものです。この回では、アセンブリの文章を直接入力して実行する仕組みはまだありません。Cコードも、説明する操作の中心部分を抜き出して示します。

STDI で32bit値をメモリへ書く

まず、レジスタの値を丸ごとメモリへ保存できるようにします。STDI R0, 0x10なら、R0の値を16番地から4 byteで保存します。例えばR0 = 0x12345678なら、16〜19番地へ12 34 56 78が並び、R0の値はそのまま残るのが期待する動きです。

保存先は、命令の中に直接書いたアドレスで指定します。STDI R0, 0x10の0x10は保存する値ではなく、保存先の番地です。

前回のLDDIは、メモリから4 byteを読み、レジスタへ入れる命令です。例えばLDDI R1, 0x10なら、16〜19番地に保存されている値をR1へ読み出します。保存するSTDIと読み出すLDDIを組にすると、値をメモリへ残してあとで取り出せます。

LDDI: memory -> register
STDI: register -> memory

つまり、STDIはLDDIの逆向きです。

この動きを数値の命令として表すため、命令内の項目(field)に番号を割り当てます。typeは大きな分類、opは分類内の操作です。今回はtype=5、op=1でSTDIを表します。対象レジスタの番号はrd、即値はimmに入れます。STDI R0, 0x10ならrd=0、imm=0x10です。

STDIの仕様

下の「bits 31..28」は、32 bitの右端をbit 0と数えたときの、左端4 bitを意味します。各項目の位置を決めておくことで、decode時に同じ位置から番号や即値を取り出せます。

名前: STDI
分類: 命令に書いた番地でメモリへアクセスする命令
目的: レジスタの32bit値を、即値アドレスで指定したVM内メモリへ4 byteで書く
命令長: 4 byte
bit配置:
  bits 31..28: type = 5
  bits 27..24: op = 1
  bits 23..20: rd = 書き込み元レジスタの番号
  bits 19..0 : imm = 保存先の先頭アドレス
読むレジスタ: rd
書くレジスタ: なし
読むメモリ: なし
書くメモリ: memory[imm] から memory[imm + 3] までの4 byte
PCの変化: fetch時に4増える
エラー時: レジスタ番号が範囲外、または4 byteを書けない番地なら停止

ここで大事なのは、rdの意味です。

rdは、destination register(書き込み先レジスタ)を表す名前として使います。例えばMOVI R0, 16では、rd=0が値の書き込み先を示します。ただし、このVMでは同じ項目を、命令によって別の役割にも使います。

STDIでは、レジスタへ書くのではなく、レジスタからメモリへ書きます。そのため、この命令ではrdを「書き込み元レジスタ」として使っています。

STDI R0, 0x10

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

R0の32bit値を
memory[0x10] から4 byteで書く

32bit値を4 byteに分ける

4 byteへ分けるときは、どのbyteから並べるかも決める必要があります。このVMでは、大きい桁から並べる「big-endian」を使います。例えば0x12345678なら12、34、56、78の順です。16進数で書いた値とメモリ上の並びを同じ順に読めるので、手で確認しやすくなります。命令を読む場合も、この順序にそろえます。

例えば、R0に次の値が入っているとします。

R0 = 0x12345678

これをメモリに4 byteで書くと、次の順番になります。

memory[0x10] = 0x12
memory[0x11] = 0x34
memory[0x12] = 0x56
memory[0x13] = 0x78

STDI R0, 0x10は、命令の数値では0x51000010です。これをdecodeするとtype=5、op=1、rd=0、imm=0x10になります。続くCコードでは、regs[rd]が保存する値の0x12345678、immが保存先の0x10に対応します。

memory[imm] = (regs[rd] >> 24) & 0xFF;
memory[imm + 1] = (regs[rd] >> 16) & 0xFF;
memory[imm + 2] = (regs[rd] >> 8) & 0xFF;
memory[imm + 3] = regs[rd] & 0xFF;

>>は、bitを右へずらす「右シフト」です。例えば0x12345678 >> 24は0x12になります。& 0xFFは、右端の8 bitだけを残す「マスク」です。16 bitずらした0x1234にこのマスクをかけると0x34だけが残ります。こうして4つのbyteを取り出し、順に保存します。

テストでは、Cコードであらかじめregs[0] = 0x12345678と設定してから、STDI R0, 0x10とHALTを実行します。memory[0x10..0x13]という表示は、16〜19番地の4 byteをまとめて示すものです。

テスト結果はこうなりました。

R0=0x12345678
memory[0x10..0x13]=12 34 56 78
STDI test passed.

INC / DEC でレジスタの値を1つ増減する

次は、個数を数えるときなどに使えるように、レジスタの値を1ずつ増減できるようにします。INC R0はR0の値を1増やし、DEC R1はR1の値を1減らします。両方に16を入れて試すと、R0 = 17、R1 = 15になるのが期待する結果です。

ここまでの命令は、メモリを読んだり書いたりするものが中心でした。INCとDECは、レジスタの中身そのものを変更します。

命令番号は次のようにしました。

INC: type=2, op=0
DEC: type=2, op=1

INC R0: rd=0 -> 0x20000000
DEC R1: rd=1 -> 0x21100000

type=2を、レジスタの値を計算する命令の分類として使うことにしました。

テストする命令列

MOVI R0, 0x10
MOVI R1, 0x10
INC R0
DEC R1
HALT

最初にR0とR1へ同じ値を入れます。

そのあと、INC R0でR0を1増やし、DEC R1でR1を1減らします。

R0: 0x10 -> 0x11
R1: 0x10 -> 0x0F

INC R0を読み解くとtype=2、op=0、rd=0です。DEC R1ならop=1、rd=1になります。Cでは、取り出したレジスタ番号を使って値を更新します。下の1行目がINCの処理、2行目がDECの処理です。1つの命令で両方を実行するわけではありません。

regs[rd] += 1;
regs[rd] -= 1;

+= 1は元の値に1を足して保存し、-= 1は1を引いて保存する書き方です。今回ならregs[0]が16から17へ、regs[1]が16から15へ変わります。操作対象を指定する項目はrdだけで、命令に即値を入れる必要はありません。

テスト結果はこうなりました。

R0=0x00000011
R1=0x0000000F
INC/DEC test passed.

小さい命令ですが、これでVMの中でレジスタの値を変化させられるようになりました。

MOV でレジスタからレジスタへコピーする

次は、元の値を残しながら別のレジスタでも使えるようにします。MOV R2, R0なら、R0の値をR2へコピーします。例えばR0 = 0x12345なら、実行後はR0とR2の両方が0x12345になります。

MOVでは、左側がコピー先、右側がコピー元です。MOVI R2, 16は命令に書いた数値16を入れますが、MOV R2, R0は実行時点でR0に入っている値を読み取ります。

ここで、コピー元レジスタの番号を表すrsを使います。MOV R2, R0なら、コピー先のrdは2、コピー元のrsは0です。rs=0は「値0をコピーする」という意味ではなく、「R0の中身を読む」という意味です。

MOV: type=1, op=0
MOV R2, R0: rd=2, rs=0 -> 0x10200000
メモリに置く4 byte      -> 10 20 00 00

type=1は、レジスタ同士で値を動かす命令の分類として使うことにしました。

MOVは「移動」ではなくコピー

MOVという名前を見ると、元の場所から値が消えて、別の場所へ移動するように感じるかもしれません。

しかし、今回のMOVはコピーです。

MOVI R0, 0x12345
MOV R2, R0
HALT

この命令列では、まずR0に0x12345を入れます。そのあと、MOV R2, R0でR0の値をR2へコピーします。

MOV R2, R0なら、decodeした結果は次のようになります。

rd = 2
rs = 0

この番号を使い、Cでは右側のレジスタの中身を左側へ代入します。

regs[rd] = regs[rs];

したがって、Cコードに当てはめるとこうなります。

regs[2] = regs[0];

regs[0]の値は消えません。同じ値がregs[2]にも入ります。

テスト結果はこうなりました。

R0=0x00012345
R2=0x00012345
MOV test passed.

PUSH でスタックへ値を積む

ここからは、値を一時的に保存し、あとで取り出す「スタック」を扱います。スタックは、最後に保存した値から先に取り出す仕組みです。例えば10、20の順に保存すると、取り出す順序は20、10になります。このVMでは、メモリの一部をその保存場所として使います。

まず、値を保存するPUSHを追加します。R0 = 0x12345678の状態でPUSH R0を実行すると、スタックに12 34 56 78が保存され、R0の中身は変わりません。この保存操作を「スタックへ積む」と呼びます。保存後にR0を別の用途に使っても、元の値をあとで取り出せます。

命令番号は次のようにしました。

PUSH: type=7, op=0
PUSH R0: rd=0 -> 0x70000000
メモリ上の並び -> 70 00 00 00

type=7は、スタックを扱う命令の分類として使います。

このVMのスタックは下方向に伸びる

スタックに最後に保存した値の場所を管理するのが、SP(スタックポインタ)です。Cでは変数spとして持ちます。保存位置をSPで管理すれば、PUSH R0の命令に保存先の番地を毎回書かずに済みます。

このVMのメモリは1024 × 1024 byteです。番地は0から始まるので、有効な範囲は0x00000000〜0x000FFFFFになります。SPの初期値は、その末尾の1つ先の0x00100000です。

SP = 0x00100000は、ちょうどメモリの末尾の1つ外側を指しています。ここを「空のスタック」として扱います。

このVMでは、値を積むたびに小さい番地へ進む方式にします。これが「下方向に伸びる」という意味です。最初のPUSHではSPを先に4減らし、0x000FFFFC〜0x000FFFFFの4 byteを保存先にします。減らす前の番地へ書くと、メモリの範囲外になってしまいます。

初期SP: 0x00100000
PUSH後: 0x000FFFFC

PUSH R0を読み解くとtype=7、op=0、rd=0です。executeではspを4減らしてから、regs[0]を4 byteに分けて保存します。値の分け方はSTDIと同じで、保存先がimmからspに変わります。

sp -= 4;
memory[sp] = (regs[rd] >> 24) & 0xFF;
memory[sp + 1] = (regs[rd] >> 16) & 0xFF;
memory[sp + 2] = (regs[rd] >> 8) & 0xFF;
memory[sp + 3] = regs[rd] & 0xFF;

例えばregs[0] = 0x12345678なら、更新後のsp = 0x000FFFFCからbig-endianで次のように保存します。テストコードでは、実行前にC側でregs[0]へこの値を設定しています。

0x12345678
-> 12 34 56 78

テスト結果はこうなりました。

R0=0x12345678
SP=0x000FFFFC
memory[SP..SP+3]=12 34 56 78
PUSH test passed.

もう1回PUSHすると、SPはさらに4減って0x000FFFF8になります。前に保存した4 byteを残したまま、その手前へ次の値を保存するわけです。実際のテストコードでは、sp < 4なら4 byte分を確保できないため、stack overflow(スタックの保存範囲を超えるエラー)として停止します。

POP でスタックから値を取り出す

次は、保存した値をレジスタへ取り出すPOPを追加します。SP = 0x000FFFFCで、その場所に12 34 56 78が保存されているとします。POP R1を実行すると、R1 = 0x12345678となり、SPが空の状態の0x00100000へ戻るのが期待する動きです。

取り出す先は命令で指定します。PUSH R0で保存した値でも、POP R1ならR1に入ります。スタックが覚えているのは保存した値とその順番で、元のレジスタ番号ではありません。

POP: type=7, op=1
POP R1: rd=1 -> 0x71100000
メモリ上の並び -> 71 10 00 00

PUSHがop=0、POPがop=1です。同じtype=7の中に、スタック操作の命令を並べています。

POPは読んでからSPを戻す

POPでは、SPが指す場所から先に4 byteを読みます。今回なら0x000FFFFC〜0x000FFFFFです。先にSPを4増やすと、値がある場所を通り過ぎてしまいます。

値を読み終えてからSPを4増やし、その4 byteを使用済みのスタックから外します。メモリの内容を0で消す必要はありません。どこまでが保存中の値かは、SPで管理します。

POP前: SP = 0x000FFFFC
POP後: SP = 0x00100000

POP R1を読み解くとtype=7、op=1、rd=1です。保存時には値を4つに分けましたが、ここでは4 byteを1つの値に組み立て、regs[1]へ入れます。

regs[rd] =
    ((uint32_t)memory[sp] << 24) |
    ((uint32_t)memory[sp + 1] << 16) |
    ((uint32_t)memory[sp + 2] << 8) |
    ((uint32_t)memory[sp + 3]);
sp += 4;

uint32_tは符号なし32 bit整数の型です。(uint32_t)で各byteを32 bitとして扱ってから、<<(左シフト)で元の桁へ戻します。例えば0x12 << 24なら0x12000000です。|はbit単位のORで、桁が重ならない4つの部分を1つに合わせます。こうしてbig-endianの並びから元の値を復元します。

12 34 56 78
-> 0x12345678

このテストでは、取り出し処理だけを確認するため、Cコードでスタックへ4 byteを置き、SP = 0x000FFFFCに設定してからPOP R1を実行します。実行後の値は次のようになりました。

R1=0x12345678
SP=0x00100000
POP test passed.

PUSHとPOPがそろうと、「値を一時的に保存して、あとで取り出す」という動きができます。空の状態でさらにPOPすると取り出す値がないので、テストコードではstack underflow(空のスタックから取り出そうとしたエラー)として停止します。

ここまでの命令一覧

ここまでの命令をまとめます。前回のLDBは、例えばLDB R0, [R1]で「R1の値を番地として、そこから1 byteをR0へ読む」命令です。STB [R1], R0は逆に、R0の右端1 byteをその番地へ書きます。[R1]はR1が指すメモリを表します。

SYSCALLは、VMの命令からC側の入出力処理を呼ぶ命令です。例えばR0 = 65としてSYSCALL 0を実行すると、文字Aを表示します。このように、命令のあとに書く番号で利用する処理を選びます。

それぞれの命令の詳しい説明は、GitHubの HANDWRITING_GUIDE.md に置いています。

命令type/op役割
MOVtype=1, op=0レジスタ間コピー
INCtype=2, op=0レジスタを1増やす
DECtype=2, op=1レジスタを1減らす
LDBtype=3, op=0レジスタが指すメモリから1 byte読む
STBtype=3, op=1レジスタが指すメモリへ1 byte書く
MOVItype=4, op=0即値をレジスタへ入れる
LDDItype=5, op=0即値アドレスから4 byte読む
STDItype=5, op=1即値アドレスへ4 byte書く
SYSCALLtype=6, op=0VM外側の機能を呼ぶ
PUSHtype=7, op=0スタックへ積む
POPtype=7, op=1スタックから取り出す

こうして一覧にすると、typeごとに命令の分類が少し見えてきます。

type=3は、レジスタが指すメモリを読む・書く命令です。type=5は、命令の中に入っている即値アドレスを使ってメモリを読む・書く命令です。type=7は、スタックを扱う命令です。

最初はただの数字だったtypeとopが、だんだん命令表らしくなってきました。

命令が増えたので vm.c を整理した

各命令を1つにまとめたnotes/vm.cでは、Cプログラムの実行開始点であるmain()が長くなってきました。そこで、命令を読む処理と実行する処理などを、役割ごとの関数に分けます。例えば保存命令を直したいときは、命令の実行を担当する関数を見ればよい形にします。

最初は、全部がmain()に入っていても気になりませんでした。小さいコードなら、上から順番に読めるからです。

しかし、命令が増えてくると、次の処理が混ざって分かりにくくなります。

memoryへの命令配置
fetch
decode
execute
エラーチェック
実行ループ

整理の目標は、同じテストプログラムを実行したときの値や表示を保ちながら、処理の場所を見つけやすくすることです。この回のテストでは、整理後もAを表示して停止することを確認します。

VM状態を構造体にまとめる

まず、メモリやレジスタなど、実行中に変わる値を1つにまとめます。Cの「構造体」は、関連する変数をまとめて扱う仕組みです。例えばPCとSPを別々に関数へ渡す代わりに、両方を含むVMを渡せるようにします。

uint8_tは符号なし8 bit整数で、メモリの1 byteに使います。MEMORY_SIZEはメモリのbyte数、REGISTER_COUNTはレジスタの個数8です。boolはtrue(真)かfalse(偽)を保持する型で、runningがtrueの間は命令実行を続け、HALTでfalseにします。

typedef struct {
    uint8_t memory[MEMORY_SIZE];
    uint32_t pc;
    uint32_t sp;
    uint32_t regs[REGISTER_COUNT];
    bool running;
} VM;

今までは、memory、pc、sp、regs、runningを、それぞれ別の変数として持っていました。

関数の引数に書くVM *vmは、VMの保存場所を指す「ポインタ」です。構造体を丸ごとコピーせず、同じVMの状態を読み書きできます。例えばvm->pcは、そのVMのpcを表します。vm->regs[0]なら、そのVMのR0の中身です。

decode結果も構造体にする

次に、読み解いた命令の項目をDecodedInstという構造体にまとめます。例えばSTDI R0, 0x10なら、元の数値0x51000010と、そこから取り出したtype=5、op=1、rd=0、imm=0x10をまとめて実行処理へ渡せます。

typedef struct {
    uint32_t raw;
    uint8_t type;
    uint8_t op;
    uint8_t rd;
    uint8_t rs;
    uint32_t imm;
} DecodedInst;

rawには、fetchした32bit命令そのものを入れます。

残りのメンバーには、命令から取り出した各項目を入れます。すべてを毎回使うわけではありません。例えばMOVはrdとrsを使い、STDIはrdとimmを使います。instという変数にこの構造体を入れた場合、inst.rdでレジスタ番号を参照できます。

これで、execute()側では「decode済みの命令」として扱えます。

big-endianの読み書きを共通化する

今回のVMでは、32bit値を読むところ、書くところが何度も出てきます。

命令fetchでも4 byteを読みます。LDDIやPOPでも4 byteを読みます。STDIやPUSHでは4 byteを書きます。

同じシフトやマスクを何か所にも書くと、修正時に見落としやすくなります。そこで、読み取りをread_u32_be()、書き込みをwrite_u32_be()にまとめます。名前のu32は符号なし32 bit、beはbig-endianです。例えば16番地から12 34 56 78を読むと、0x12345678を返す関数にします。

static uint32_t read_u32_be(const uint8_t *memory, uint32_t address) {
    return ((uint32_t)memory[address] << 24) |
           ((uint32_t)memory[address + 1] << 16) |
           ((uint32_t)memory[address + 2] << 8) |
           ((uint32_t)memory[address + 3]);
}

書き込み側は、対象のメモリ、先頭番地、保存する値を受け取ります。例えばaddress=0x10、value=0x12345678なら、16〜19番地へ12 34 56 78を書きます。

static void write_u32_be(uint8_t *memory, uint32_t address, uint32_t value) {
    memory[address] = (value >> 24) & 0xFF;
    memory[address + 1] = (value >> 16) & 0xFF;
    memory[address + 2] = (value >> 8) & 0xFF;
    memory[address + 3] = value & 0xFF;
}

これらの補助関数を使うと、命令の実行処理では「どこから読み、どこへ入れるか」が見やすくなります。LDDI R1, 0x10なら、inst.rd=1、inst.imm=0x10なので、次の1行で16番地から読んだ値をR1へ入れられます。

vm->regs[inst.rd] = read_u32_be(vm->memory, inst.imm);

STDI R0, 0x10なら、inst.rd=0、inst.imm=0x10です。次の呼び出しは「このVMのメモリの16番地から、R0の値を保存する」という意味になります。なお、補助関数自体には範囲の確認がないので、呼び出す前にレジスタ番号と4 byte分のメモリ範囲を確認します。

write_u32_be(vm->memory, inst.imm, vm->regs[inst.rd]);

fetch / decode / execute / run に分ける

VMの実行は、基本的に次の繰り返しです。

fetch
decode
execute

この3段階を、停止するまで繰り返すのがrun()です。例えばfetchが命令を読めなければ、その時点で終了します。読めた場合はdecodeし、その結果をexecuteへ渡します。

static void run(VM *vm) {
    while (vm->running) {
        uint32_t inst;

        if (!fetch(vm, &inst)) {
            return;
        }

        execute(vm, decode(inst));
    }
}

&instは、読み取った命令を書き込む変数instの場所を渡す書き方です。!fetch(...)は、読み取りが失敗してfalseになった場合を表します。続くexecute(vm, decode(inst))では、内側のdecodeの結果を外側のexecuteへ渡します。

これでmain()には、VMの準備と実行開始だけを残せます。VM vm = {0}でメモリやレジスタなどを0にそろえ、PCを0番地、SPを空の位置へ設定します。load_test_program()は、この回ではCコード内で用意した命令をメモリへ置く関数です。&vmで、準備したVMの場所を渡します。

int main(void) {
    VM vm = {0};

    vm.pc = 0x00000000;
    vm.sp = 0x00100000;
    vm.running = true;

    load_test_program(&vm);
    run(&vm);

    return 0;
}

main()だけを見ると、VMを初期化して、テストプログラムを読み込み、実行する、という流れが分かりやすくなりました。

命令の細かい処理はexecute()へ、命令の配置はload_test_program()へ移しています。

動作確認

整理後は、コンパイル(Cのソースコードを実行できるプログラムへ変換する作業)を行い、表示が変わらないことを確認します。以下は、この回の段階のnotes/vm.cを使う手順です。リポジトリの先頭ディレクトリで実行します。後の回まで更新されたvm.cでは、追加機能によって表示が増えることがあります。

cc notes/vm.c -o /tmp/handmade-vm-day15
/tmp/handmade-vm-day15

ccでコンパイルし、-oのあとに実行ファイルの保存先を指定しています。2行目でそのファイルを実行します。この回のテストでは、SYSCALL 0によるAと、HALTで停止したことを知らせるCPU halted.が表示されれば、期待する結果です。

A
CPU halted.

続いて、間違いの可能性がある箇所を知らせる警告も有効にして確認します。-Wallと-Wextraで警告を増やし、-Werrorで警告もコンパイル失敗として扱います。正常に終われば、通常は何も表示されません。

cc -Wall -Wextra -Werror notes/vm.c -o /tmp/handmade-vm-day15

表示の確認だけでは、今回追加した各命令の動きまでは確認できません。各節のテストでも、保存した4 byte、レジスタの値、SPの位置を確認します。例えばSTDIのテストなら、次のように実行します。

cc notes/011-stdi-test.c -o /tmp/011-stdi-test
/tmp/011-stdi-test

期待した値が表示され、最後にSTDI test passed.と表示されれば成功です。同様に、012-inc-dec-test.c、013-mov-test.c、014-push-test.c、015-pop-test.cで、それぞれの操作を確認できます。いずれもnotesディレクトリにあります。

今回分かったこと

Day 11からDay 15までで、VMの命令はかなり増えました。

メモリを読む
メモリへ書く
レジスタを変更する
レジスタ間で値をコピーする
スタックへ積む
スタックから取り出す

最初はHALTで止まるだけだったVMに、値を保存・変更する操作がそろってきました。このように、VMが実行できる命令の集まりを「命令セット」と呼びます。今回なら、MOVやPUSHもその一員です。

特にPUSHとPOPが入ったことで、SPが意味を持ち始めました。スタックは、関数呼び出しや一時退避を考えるときに避けて通れないので、ここで小さく動かせたのは大きいです。

一方で、まだ足りないものもあります。

2つの値を足す・引く
2つの値が等しいか調べる
比較した結果を覚えておく
次に実行する命令の場所を変える
条件に応じて処理を選ぶ

今のVMは、上から順番に命令を実行するだけなので、条件によって処理を変えたり、同じ場所へ戻ってループしたりすることはできません。

例えば「10と3を足す」にはADD、引くにはSUBを使えるようにします。「2つの値が等しいか」を調べるCMPや、別の番地へ進むJUMP、比較結果が等しい場合だけ進むJZも、次に追加する命令です。

まとめ

今回は、Day 11からDay 15までの命令追加と、vm.cの整理を行いました。

  • STDIで32bit値をメモリへ書けるようにしました
  • INC / DECでレジスタの値を1つ増減できるようにしました
  • MOVでレジスタ間コピーができるようにしました
  • PUSH / POPでスタックへ値を積み、取り出せるようにしました
  • 命令が増えたので、vm.cをfetch、decode、execute、runへ分けました

完成したVMから見ると、まだ本当に小さい一歩です。

ただ、命令を1つずつ仕様にし、テストし、Cコードに落とし込む流れは少しずつ見えてきました。

次に進むこと

次回は、計算や比較に加えて、結果に応じて実行する処理を選ぶ「分岐」を追加します。例えば、2つの値が等しい場合だけ別の命令へ進めるようにします。さらに、ひとまとまりの処理である「サブルーチン」を呼び出し、終わったら元の続きへ戻る仕組みも追加します。今回のスタックを、戻る場所の保存に使っていきます。

おまけ:マルチタスクと仮想メモリをコントで読む

マルチタスクや仮想メモリの仕組みを、散髪屋コントにたとえて整理した読み物です。「CPUの切り替え」や「ページテーブル」のイメージを、もう少し感覚的につかみたい方におすすめです。

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

この記事を書いた人

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

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

目次