【低レイヤ入門】C言語で自作VMに計算・比較・分岐・CALL/RETを追加する | UNIX Cafe

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

System Note $ cat /proc/ai-disclosure

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

C言語で作る小さな自作仮想マシンの技術記事用アイキャッチ。黒に近い落ち着いた背景、中央にシンプルなCPUチップ、周囲にレジスタ、PC、zero flag、スタック、分岐矢印を表す小さな発光ライン。画面内にコード風の短い文字列「ADD」「CMP」「JZ」「CALL」「RET」が控えめに表示されている。レトロな低レイヤ開発の雰囲気。 COFFEE & CODE のテキストはなし。 小さな喫茶店の一角。 warm brown の木製カウンター、 soft amber lighting の落ち着いた照明。 初心者向けで親しみやすく、 知的で静かな空気感。 少しレトロな UNIX の雰囲気。 背景に人物は無し。 写真とイラストの中間の上品なビジュアル。 軽いアニメ調。 リアルすぎない。 構図はシンプルで、 主題が一目で分かる。 横長16:9 キャラクターの特徴は、次のとおりです。 ミナちゃんは、白と淡いグリーンの花柄シャツ、深いグリーンのカフェエプロン、ショートパンツ、髪に白い花飾り。 ユニ先生は、丸メガネをかけた可愛いペンギン先生のマスコット。淡いグリーン系の薄い夏用ストールを身につけている。

自作VM開発記 3

この連載では、C言語で小さな仮想マシン(VM)を作っています。ここでいうVMは、「数値を入れる」「足す」といった独自の命令を読み取り、実行するプログラムです。今回は、計算結果に応じて処理を選んだり、別の処理を呼び出して戻ったりできるようにします。

前回までに、値を保存したりコピーしたりする仕組みを用意しました。値の置き場所は、計算中の数値を入れる「レジスタ」と、命令やデータを並べて保存する「メモリ」です。例えば、レジスタR0に10を入れ、それをメモリへ保存できます。

ただし、この段階のVMは、まだ基本的には上から順番に命令を実行するだけです。

そこで、まず「10と3を足す」「2つの値が等しいか調べる」を実現します。続いて、比較結果によって次に実行する命令を変える「分岐」を追加します。最後に、ひとまとまりの処理である「サブルーチン」を呼び出し、その処理が終わったら元の続きへ戻れるようにします。例えば「別の場所でR0に42を入れてから、呼び出した場所の次の命令へ戻る」という動きです。

練習ノートでは、次の順に命令を追加しました。ADDとSUBは加減算、CMPは比較、JUMP・JZ・JNZは実行位置の変更、CALLIとRETはサブルーチンの呼び出しと復帰を担当します。それぞれの動きは、このあと具体例で確認します。

Day 16: ADD / SUB
Day 17: CMP
Day 18: JUMP / JZ
Day 19: JNZ
Day 20: CALLI / RET

最後に、これまでの命令を1つにまとめた「統合VM」のnotes/vm.cへ、文字列を表示する機能も追加します。画面にVM flow complete.と表示できれば、数値の操作に加えて、メモリに置いた文章も扱えるようになります。

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

目次

今回のゴール

今回のゴールは、VMに次の4つの力を足すことです。

1. レジスタ同士で計算する
2. レジスタ同士を比較する
3. 比較結果によって、次に実行する命令を選ぶ
4. サブルーチンを呼び、終わったら呼び出し元の続きへ戻る

ここまで入ると、VMの中で「処理の流れ」を作れるようになります。

例えば、2つの値が等しければ「R2に99を入れる命令」を飛ばして、「R2に7を入れる命令」へ進めます。この記事では、こうした小さな命令列と、その実行後の値を対応させて確認していきます。

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

ここから先では、前回までに用意した仕組みを使います。まず、命令の書き方と実行の流れだけ確認しておきます。

このVMにはR0〜R7の8個のレジスタがあります。Cでは配列regsで表し、R0の中身はregs[0]です。番号の0と、そこに入っている値の10は区別して読みます。

メモリ内の場所を示す番号を「アドレス(番地)」と呼びます。例えばmemory[16]は16番地の内容です。本文の0xで始まる数値は16進数で、0x10は十進数の16、0x20は32を表します。16進数では9の次をA〜Fで表すため、0x0Aは10、0x0Dは13です。

メモリは1 byte(バイト)ごとに番地が付きます。1 byteは、0か1を表す単位であるbit(ビット)8個分です。このVMの命令は、どれも4 byte、つまり32 bitです。0番地から命令を隙間なく置くと、先頭の番地は0、4、8……となります。

次に読む命令の番地を覚えておくのが、PC(プログラムカウンタ)です。例えばPC = 0x04なら、次は4番地から命令を読みます。このVMは、命令を読み取るfetch、命令の種類や対象を取り出すdecode、実際に処理するexecuteの順で動きます。fetchで4 byte読むと、PCも4進みます。

本文では、数値で保存されている命令を、人が読みやすい「アセンブリ」という表記で示します。例えばMOVI R0, 10は「R0に10を入れる」、HALTは「VMの命令実行を止める」という意味です。命令に直接書く10のような数値を「即値」と呼び、Cコードではimmという名前で扱います。

以下のアセンブリ例は、テストコードがメモリへ置く命令を読みやすく示したものです。この回では、アセンブリの文字列を直接入力して実行する仕組みはまだ用意していません。また、Cコードは各処理の要点を抜き出しています。vm->pcはVMのPC、vm->regsはレジスタ配列、inst.rdなどは読み解いた命令の各項目を表します。

ADD / SUB でレジスタ同士を計算する

まず、2つのレジスタの値から新しい値を計算できるようにします。ADD R0, R1なら、R0 = 10、R1 = 3を足してR0 = 13にします。SUB R2, R3なら、R2 = 10からR3 = 3を引いてR2 = 7にします。どちらも、結果は左側に書いたレジスタへ保存します。

この動きをVMが見分けられるように、命令へ番号を割り当てます。typeは命令の大きな分類、opはその分類内の操作番号です。例えば、同じtype=2でも、op=2なら足し算、op=3なら引き算と判断します。これらは、このVMで決めた番号です。

ADD: type=2, op=2
SUB: type=2, op=3

type=2は、レジスタの計算や比較に使う分類です。前回のINC(例えば10を11へ、値を1増やす命令)とDEC(10を9へ、値を1減らす命令)も、この分類に入れています。

命令の中には、種類や対象を表す項目を決まった位置に入れます。rdとrsはレジスタ番号で、ADD R0, R1ならrd=0、rs=1です。下の「bits 31..28」は、右端をbit 0と数えたときの左端4 bitを意味します。また、「条件フラグ」は判定結果を覚える値です。例えば「2つの値が等しかった」という結果を保存しますが、今回の加減算では変更しません。

ADD / SUB の仕様

名前: ADD / SUB
分類: レジスタ同士の計算命令
目的: レジスタ同士で足し算、引き算をする
命令長: 4 byte
bit配置:
  bits 31..28: type = 2
  bits 27..24: op = 2 または 3
  bits 23..20: rd
  bits 19..16: rs
読むレジスタ: rd, rs
書くレジスタ: rd
読むメモリ: なし
書くメモリ: なし
PCの変化: fetch時に +4
条件フラグの変化: なし

ADD R0, R1は、R0とR1を足して、結果をR0へ戻します。

ADD R0, R1
-> R0 = R0 + R1

SUB R2, R3も同じ形です。R2からR3を引いて、結果をR2へ戻します。

SUB R2, R3
-> R2 = R2 - R3

ここで、rdは読むレジスタでもあり、書くレジスタでもあります。rsは読むだけです。

テストする命令列

テストでは10と3の加減算を行います。停止後にR0 = 13、R2 = 7となり、読み取るだけのR1とR3は3のままなら、期待どおりです。

MOVI R0, 10
MOVI R1, 3
ADD R0, R1

MOVI R2, 10
MOVI R3, 3
SUB R2, R3
HALT

期待する結果は、R0 = 13とR2 = 7です。

R0: 10 + 3 = 13 = 0x0000000D
R2: 10 - 3 = 7  = 0x00000007

まずADD R0, R1をdecodeすると、type=2、op=2、rd=0、rs=1になります。executeでは、その番号を使ってレジスタの中身を読み書きします。次の2行は、上がADDの場合、下がSUBの場合の処理です。続けて両方を実行するわけではありません。

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

今回のADD R0, R1なら、decodeした結果は次のようになります。

rd = 0
rs = 1

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

regs[0] += regs[1];

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

R0=0x0000000D
R2=0x00000007
ADD/SUB test passed.

これでVMの中で、レジスタ同士の基本的な計算ができるようになりました。

CMP と zero_flag で比較結果を残す

次は、値によって処理を選ぶための準備です。CMP R0, R1で2つの値が等しいか調べ、その結果をあとから分岐命令が使えるように残します。例えば10と10なら「等しい」、10と3なら「等しくない」と覚えます。

比較結果の保存先が、条件フラグのzero_flagです。等しいときはtrue(真)、等しくないときはfalse(偽)になります。「フラグが立つ」はtrueのことです。名前のzeroは、等しい2つの値を引けば差が0になることに対応します。ただし、この実装では引き算の結果を保存せず、等しいかどうかを直接判定します。比較したレジスタの中身は変えません。

CMP: type=2, op=4

比較結果を覚えるため、VMの状態をまとめたCの構造体に次のメンバーを追加します。boolはtrueかfalseを保持する型です。例えば10と10を比較したあと、このメンバーがtrueになります。

bool zero_flag;

CMP の仕様

名前: CMP
分類: レジスタ同士の比較命令
目的: 2つのレジスタを比較し、等しければzero_flagをtrueにする
命令長: 4 byte
bit配置:
  bits 31..28: type = 2
  bits 27..24: op = 4
  bits 23..20: rd
  bits 19..16: rs
読むレジスタ: rd, rs
書くレジスタ: なし
読むメモリ: なし
書くメモリ: なし
PCの変化: fetch時に +4
条件フラグの変化: 等しければtrue、等しくなければfalseに更新

CMP R0, R1を読み解くと、type=2、op=4、rd=0、rs=1です。Cでは==で「等しいか」を調べ、結果をzero_flagへ代入します。R0 = 10、R1 = 10なら、下の式は10 == 10となり、trueが保存されます。

vm->zero_flag = vm->regs[inst.rd] == vm->regs[inst.rs];

ここで大事なのは、CMPがレジスタの値を変えないことです。

R0 = 10
R1 = 10
CMP R0, R1

R0は10のまま
R1は10のまま
zero_flag = true

比較結果だけをzero_flagへ残します。この結果を、次に追加するJZやJNZが使います。

テスト結果

テストでは、10と10の比較でtrueになり、続く10と3の比較でfalseへ更新されることを確認します。zero_flagに残るのは直近の比較結果だけなので、テストコードは各比較の直後に結果を別々に記録しています。

MOVI R0, 10
MOVI R1, 10
CMP R0, R1

MOVI R2, 10
MOVI R3, 3
CMP R2, R3
HALT
first_cmp_zero=true
second_cmp_zero=false
R0=0x0000000A
R1=0x0000000A
R2=0x0000000A
R3=0x00000003
CMP test passed.

1回目の比較では10 == 10なのでtrueになり、2回目の比較では10 == 3ではないのでfalseになりました。

JUMP / JZ でPCを書き換える

次に、途中の命令を飛ばして別の場所へ進めるようにします。これを「ジャンプ」と呼びます。JUMP 0x10なら無条件に16番地の命令へ進みます。JZ 0x28なら、zero_flagがtrueのときだけ40番地へ進み、falseならそのまま次の命令へ進みます。

今までのVMは、命令を1つ読むたびにPCが4ずつ進んでいました。

0x00 の命令を読む
-> PC = 0x04

0x04 の命令を読む
-> PC = 0x08

JUMPやJZは、このPCを命令側で書き換えます。

JUMP: type=6, op=8
JZ:   type=6, op=10

fetch後にPCを書き換える

例えば0番地にJUMP 0x10がある場合、まず命令を読み、PCを4番地へ進めます。そのあとでPCを16番地に置き換えると、次の読み取りが16番地から始まります。命令の置き場所を動かさずに、PCだけで実行順を変えられる仕組みです。

read_u32_beは、メモリの4 byteを1つの32 bit値として読む補助関数です。beは、大きい桁から並べるbig-endianという保存順序を表します。例えば12 34 56 78の4 byteを0x12345678として読みます。*instには、読み取った命令の数値を書き込みます。

*inst = read_u32_be(vm->memory, vm->pc);
vm->pc += 4;

そのため、executeでJUMPを実行するときには、PCはすでに次の命令を指しています。

JUMP 0x10を読み解くと、type=6、op=8、imm=0x10です。inst.immは飛び先の番地そのもので、現在の位置から何byte進むかという距離ではありません。次の代入で、PCが0x10になります。

vm->pc = inst.imm;

JZ 0x28ならtype=6、op=10、imm=0x28です。zero_flagがtrueのときだけPCを上書きします。falseなら何も書き換えないため、fetchで4進めた位置から続行します。JZ自身がレジスタを比較し直すわけではありません。

if (vm->zero_flag) {
    vm->pc = inst.imm;
}

ジャンプ先から4 byteを読めることも確認します。MEMORY_SIZEはメモリ全体のbyte数です。例えば末尾の1 byteしか残っていない番地へ飛ぶと、命令を読み切れません。そこで実際のテストコードでは、代入前に次のcan_fetchで範囲を調べ、範囲外なら停止します。

static bool can_fetch(uint32_t address) {
    return address <= MEMORY_SIZE - 4;
}

JUMP / JZ のテスト

テストでは、2回のジャンプで「99を入れる命令」を飛ばします。最後にR0 = 1、R2 = 7となれば成功です。各行の右側の;以降に、その命令を置く番地を添えました。空行はメモリの空きを表す記法ではありません。テストコードでは、各命令を示した番地へ直接書き込んでいます。

JUMP 0x10    ; 0x00
MOVI R0, 99  ; 0x04
HALT         ; 0x08

MOVI R0, 1   ; 0x10(0x0C〜0x0Fには命令を置かない)
MOVI R1, 1   ; 0x14
CMP R0, R1   ; 0x18
JZ 0x28      ; 0x1C
MOVI R2, 99  ; 0x20
HALT         ; 0x24

MOVI R2, 7   ; 0x28
HALT         ; 0x2C

JUMP 0x10が効いていれば、MOVI R0, 99は実行されません。

そのあと、R0とR1に同じ値を入れてCMPします。zero_flagはtrueになるので、JZ 0x28でMOVI R2, 7へ飛びます。

R0=0x00000001
R1=0x00000001
R2=0x00000007
zero_flag=true
JUMP/JZ test passed.

R0が99ではなく1になり、R2も99ではなく7になっています。つまり、JUMPとJZの両方で実行位置を変えられています。

JNZ で「等しくないなら飛ぶ」を作る

今度は「値が等しくなければ別の処理へ進む」ために、JNZを追加します。例えばR0 = 1とR1 = 2を比較すると、zero_flagはfalseです。そのあとJNZ 0x18を実行すれば24番地へ進み、途中の「99を入れる命令」を飛ばしてR2 = 7にできます。

JNZ: type=6, op=11

JZは、zero_flagが立っているときにジャンプします。JNZは、zero_flagが立っていないときにジャンプします。

JZ:
  同じだったら飛ぶ

JNZ:
  違っていたら飛ぶ

JNZ 0x18は、type=6、op=11、imm=0x18と読み解きます。Cの!は真偽を反転する記号なので、!vm->zero_flagは、フラグがfalseのときに条件を満たします。trueならPCを変更せず、次の命令へ進みます。

if (!vm->zero_flag) {
    vm->pc = inst.imm;
}

JNZ のテスト

MOVI R0, 1   ; 0x00
MOVI R1, 2   ; 0x04
CMP R0, R1   ; 0x08
JNZ 0x18     ; 0x0C
MOVI R2, 99  ; 0x10
HALT         ; 0x14

MOVI R2, 7   ; 0x18
HALT         ; 0x1C

R0は1、R1は2なので、CMP R0, R1の結果はfalseです。

そのため、JNZ 0x18でジャンプし、MOVI R2, 99は実行されません。

R0=0x00000001
R1=0x00000002
R2=0x00000007
zero_flag=false
JNZ test passed.

これで、Cのif文のように、条件に応じて実行する処理を選べるようになりました。このVMでは、ADDやSUBはzero_flagを更新しません。計算後の値で分岐したい場合も、まずCMPで比較してからJZやJNZを使います。

CALLI / RET でサブルーチンを呼んで戻る

次は、別の場所にまとめた処理を実行し、終わったら元の続きへ戻れるようにします。今回の例では、CALLI 0x20で32番地の処理へ進み、そこでR0に42を入れます。RETで戻ったあと、呼び出し元の続きでR1に7を入れられれば成功です。

CALLIの呼び出し先は即値で指定します。CALLI 0x20ならtype=6、op=9、imm=0x20です。RETは引数を持たず、命令全体の数値が0x02000000かどうかで見分けます。

CALLI: type=6, op=9
RET:   inst == 0x02000000

CALLIは、JUMPに似ています。どちらもPCを別のアドレスへ変えます。

違いは、CALLIが「呼び出した命令の次の番地」を戻り先として保存することです。保存には「スタック」を使います。スタックは最後に保存した値から先に取り出す仕組みで、例えば4、8の順に保存すると、8、4の順に取り出します。呼び出しの中でさらに別の処理を呼んでも、直前の呼び出し元から順に戻れます。

JUMP:
  飛ぶだけ

CALLI:
  戻り先PCをスタックへ積んでから飛ぶ

CALLI の動き

CALLIでは、fetch後のPCを戻り先として使います。

0x00: CALLI 0x20
0x04: MOVI R1, 7

CALLIをfetchした直後のPC = 0x04
戻り先PC = 0x04

その戻り先0x04を、メモリ内のスタック用の場所に保存します。保存位置を管理するのがSP(スタックポインタ)です。このVMでは、保存前にSPを4減らし、そこから4 byteへ1つの値を書きます。例えば初期値が0x00100000なら、最初の保存先は0x000FFFFC〜0x000FFFFFです。

1. fetch後のPCを戻り先として使う
2. SPを4減らす
3. memory[SP..SP+3] に戻り先PCを書く
4. PC = imm

Cコードではこうなります。

write_u32_beは、先ほどの読み取り関数と逆に、32 bit値を大きい桁から4 byteで保存します。戻り先0x00000004なら、保存される並びは00 00 00 04です。保存を終えてから、PCを呼び出し先の0x20へ変えます。

vm->sp -= 4;
write_u32_be(vm->memory, vm->sp, vm->pc);
vm->pc = inst.imm;

RET の動き

RETは、スタックに保存した戻り先を読み、PCへ戻します。今回なら0x04が読み出され、次にMOVI R1, 7を実行できます。戻り先を命令に直接書く必要がないため、同じサブルーチンを別の場所から呼び出しても、それぞれの続きへ戻れます。

1. memory[SP..SP+3] から戻り先PCを読む
2. SPを4増やす
3. PC = return_address

Cコードではこうなります。

return_addressは読み出した戻り先を一時的に保持する変数で、uint32_tは符号なし32 bit整数の型です。SPを4増やすと、取り出した4 byte分をスタックから外したことになります。メモリの内容を0で消す操作は必要ありません。

uint32_t return_address = read_u32_be(vm->memory, vm->sp);
vm->sp += 4;
vm->pc = return_address;

CALLIでSPを4減らし、RETで4増やすので、最後のSPは元の値へ戻ります。前回のPUSH(例えばR0の値をスタックに保存する命令)とPOP(保存した値をレジスタへ取り出す命令)も同じ仕組みでした。サブルーチン内でPUSHした場合は、対応するPOPを済ませてからRETしないと、別の値を戻り先として読んでしまいます。

上のCコードは、保存と取り出しの中心部分です。実際のテストコードでは、保存する4 byte分の空きがあるか、取り出す値があるか、呼び出し先と戻り先から命令を読めるかも調べます。例えば空のスタックに対するRETは、戻り先がないためエラーで停止します。

CALLI / RET のテスト

CALLI 0x20   ; 0x00
MOVI R1, 7   ; 0x04(戻り先)
HALT         ; 0x08

MOVI R0, 42  ; 0x20(この番地へ直接配置)
RET          ; 0x24

最初にCALLI 0x20で、0x20にあるサブルーチンへ飛びます。

サブルーチン側ではR0に42を入れて、RETで戻ります。戻ったあとは、CALLIの次にあるMOVI R1, 7を実行します。

R0=0x0000002A
R1=0x00000007
SP=0x00100000
CALLI/RET test passed.

R0にサブルーチン側の値が入り、R1に戻ったあとの値が入っています。さらにSPも初期値の0x00100000へ戻っています。

これで、VMの中に「呼び出して戻る」という流れを作れました。

統合VMに SYSCALL 1 を入れる

最後に、メモリへ置いた文章を画面に表示できるようにします。使うのは、VM側のプログラムからC側の入出力処理を呼ぶSYSCALL命令です。ここでは命令のあとに書く番号で処理を選び、SYSCALL 0で1文字、SYSCALL 1で文字列を表示します。

前回までのSYSCALL 0は、R0の下位1 byte、つまり値の右端8 bitを文字として表示します。例えば文字Aを表す番号65(0x41)をR0へ入れると、Aが表示されます。この実装では、そのあと改行も表示します。

SYSCALL 0:
  R0の下位1 byteを文字として表示する

SYSCALL 1では、R0に文字そのものではなく、文字列の先頭アドレスを入れます。例えば0x100番地からH、i、数値0の順に保存しておき、R0 = 0x100として実行すると、Hiが表示されます。末尾に数値0を置いて終わりを示す形式を「0終端文字列」と呼びます。この0は表示する文字の0ではなく、読み取りを止める目印です。

SYSCALL 1:
  R0が指す0終端文字列を表示する

期待する動きは、指定された番地から1 byteずつ表示し、数値0に着いたら止まることです。次のprint_stringでは、Cのputcharで1文字を表示するたびにaddressを1増やします。&&で結んだ2つの条件により、メモリの範囲内で、かつ終端の0ではない間だけ繰り返します。

static void print_string(VM *vm, uint32_t address) {
    while (address < MEMORY_SIZE && vm->memory[address] != 0) {
        putchar(vm->memory[address]);
        address++;
    }
}

SYSCALL 1を読み解くと、type=6、op=0、imm=1になります。ここでのinst.immは処理の選択番号で、文字列のアドレスはvm->regs[0]から取り出します。下の& 0xFFは、SYSCALL 0で下位1 byteだけを取り出す操作です。

if (inst.imm == 0) {
    putchar(vm->regs[0] & 0xFF);
    putchar('\n');
} else if (inst.imm == 1) {
    print_string(vm, vm->regs[0]);
}

ここで混同しやすいのは、PCとR0の役割です。

PC:
  次に読む命令の場所

R0:
  SYSCALL 1で表示したい文字列の場所

PCは命令を読むためのアドレスです。SYSCALL 1では、R0に入っている値をデータのアドレスとして使います。

統合VMの実行結果

この回の統合VMでは、0x100番地から次の文字列を置きます。\nは改行、\0は終端の数値0を表す表記です。画面にはこの2文字ずつがそのまま表示されるのではなく、文章のあとで改行し、そこで読み取りが終わります。

VM flow complete.\n\0

そして、R0へ0x100を入れてからSYSCALL 1を実行します。

MOVI R0, 0x100
SYSCALL 1
HALT

上の3命令は、文字列を表示する部分の抜粋です。SYSCALL 1自体は改行を追加しないので、改行したい場合は文字列に\nを含めます。この回の統合VM全体では、先にSYSCALL 0による1文字表示も確認するため、記録した実行結果は次のようになります。

A
VM flow complete.
CPU halted.

最初のAはSYSCALL 0の確認です。そのあとにSYSCALL 1でVM flow complete.を表示しています。最後のCPU halted.は、HALTで命令実行を止めたことを知らせるVM本体のメッセージです。

ここまでの命令一覧

今回追加した命令を一覧にすると、次のようになります。

命令type/op役割
ADDtype=2, op=2レジスタ同士を足す
SUBtype=2, op=3レジスタ同士を引く
CMPtype=2, op=4レジスタ同士を比較してzero_flagを更新する
JUMPtype=6, op=8無条件にPCを変更する
CALLItype=6, op=9戻り先PCをスタックへ積んで呼び出す
JZtype=6, op=10zero_flagがtrueならジャンプする
JNZtype=6, op=11zero_flagがfalseならジャンプする
RET0x02000000スタックから戻り先PCを取り出して戻る

type=2には、レジスタの値を扱う計算・比較命令が増えました。

type=6には、SYSCALLに加えて、PCを変える命令が増えました。ここにJUMP、JZ、JNZ、CALLIが入っています。

今回分かったこと

今回の大きな変化は、VMが「上から順番に実行するだけ」ではなくなったことです。

ADD / SUB:
  レジスタ同士で計算する

CMP:
  比較結果をzero_flagへ残す

JUMP / JZ / JNZ:
  PCを書き換えて実行位置を変える

CALLI / RET:
  戻り先PCをスタックへ積んで、呼び出し元へ戻る

SYSCALL 1:
  メモリ上の0終端文字列を表示する

特にPCを書き換える命令が入ると、VMの見え方が変わります。

命令はメモリ上に順番に置かれています。しかし、実際に実行される順番は、必ずしもメモリ上の順番とは限りません。JUMPやCALLIがPCを変えると、次に読む命令も変わります。

また、CALLIとRETでは、前回作ったスタックが本格的に役割を持ち始めました。

PUSHとPOPの時点では、スタックは値の一時退避場所でした。今回のCALLIでは、戻り先PCを保存する場所として使っています。

まとめ

今回は、Day 16からDay 20までで、計算、比較、分岐、サブルーチン呼び出しを追加しました。

  • ADD / SUBでレジスタ同士の計算ができるようにしました
  • CMPで比較結果をzero_flagへ残せるようにしました
  • JUMP / JZ / JNZでPCを書き換え、実行位置を変えられるようにしました
  • CALLI / RETで、戻り先PCをスタックへ積んで呼び出し元へ戻れるようにしました
  • SYSCALL 1で、メモリ上の0終端文字列を表示できるようにしました

この回の段階では、テストする命令や文字列をCコード内でメモリへ配置しています。実行したいプログラムを変えるには、その配置を変更する必要があります。

次は、命令のbyte列を保存した外部ファイル(例えばprogram.bin)を読み込めるようにします。この読み込みを担当する仕組みが「バイナリローダー」です。./vm program.binの形でファイルを指定できれば、VM本体と実行するプログラムを分けられます。

次に進むこと

今回追加した計算や分岐、呼び出しの仕組みを使いながら、まずはMOVI R0, 65、SYSCALL 0、HALTだけを保存したhello.binを読み込み、Aを表示するところへ進みます。そのあと、人が書いたアセンブリを命令のbyte列へ変換する「アセンブラ」も用意します。

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

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

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

この記事を書いた人

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

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

目次