本記事の構成および論理分析にはAI(人工知能)を使用しています。情報の正確性は、システム管理者(UNIXユーザー)による手動検証済みです。
【低レイヤ入門】C言語で作る自作VMを少しOSらしくする: 1文字コマンド判定 | UNIX Cafe

前回は、小さな自作VMにおいて、外見を少しだけ「入力待ち(プロンプト)」の状態に近づけました。
その前の段階では、host側の標準入力から1 byteを読み、読み込んだ値をそのまま画面に表示するだけでした。
SYSCALL 2
SYSCALL 0
HALT前回の prompt-echo.bin では、この入力処理の前にプロンプト文字 > を表示するように改良しました。
MOVI R0, 62
SYSCALL 0
SYSCALL 2
SYSCALL 0
HALTMOVI R0, 62 で > のASCIIコードを R0 に入れ、SYSCALL 0 で画面に表示します。そのあと SYSCALL 2 で1 byte読み、もう一度 SYSCALL 0 を呼んで、読み込んだ文字を出力します。
例えば、標準入力に A を渡すと、画面には次のように表示されます。
cc notes/vm.c -o /tmp/handmade-vm
printf A | /tmp/handmade-vm programs/prompt-echo.bin実行結果:
>
A
CPU halted.今回は、ここからもう一歩進めます。
読み込んだ文字をそのまま返すのではなく、「どの文字が入力されたか」を判定し、それに応じて処理を分岐させてみます。
hが入力されたら、Hを表示するqが入力されたら、そのまま終了(HALT)する- それ以外の文字なら、
?を表示する
ただし、今回はまだ本格的な help文字列までは表示しません。まずは「1文字を読み込む」「期待する文字と比較する」「その結果に応じて特定の処理へジャンプする」という、条件分岐の骨格(ロジック)を実装することに集中します。
今回扱う外部プログラムは、programs/one-char-command.asm と programs/one-char-command.bin です。
Day 27: one-char-command.asm / one-char-command.bin今回の練習ノートとコードは、GitHubの handmade-vm-os に置いています。
今回の区切り
今回の記事では、まだコマンドループ(入力の繰り返し)までは実装しません。
例えば、h を入力した後に再びプロンプト > へ戻るようなループ処理は次回以降に回します。今回はシンプルに「1回だけ入力し、1回だけ判定して、そのまま終了する」という流れを作ります。
今回見たいのは、次の流れです。
SYSCALL 2で1文字読むCMPで期待する文字と比較するJZで一致した場合の処理へジャンプするJNZで一致しなかった場合の処理へ進む
第3回で実装した CMP、JZ、JNZ といった命令を、VM本体のテストコード内ではなく、「VM上で動く独立した外部プログラム(アセンブリ)」の中で実際に使ってみることが今回のテーマです。
今回のゴール
まずは、今回の完成形となる動作を画面で確認します。
1. h を入力した場合(ヘルプ)
h を入力すると、簡易的なhelp反応として H を表示してから停止します。
cc notes/vm.c -o /tmp/handmade-vm
printf h | /tmp/handmade-vm programs/one-char-command.bin実行結果:
>
H
CPU halted.2. q を入力した場合(終了)
quit(終了)コマンドとして扱い、追加の文字は何も出力せずにそのまま停止します。
printf q | /tmp/handmade-vm programs/one-char-command.bin実行結果:
>
CPU halted.3. それ以外の文字を入力した場合(未対応コマンド)
h でも q でもない文字(例:x)が入力された場合は、未対応のコマンドとして ? を表示します。
printf x | /tmp/handmade-vm programs/one-char-command.bin実行結果:
>
?
CPU halted.表示に関する補足
現在の SYSCALL 0 は、1文字を表示したあとに自動で改行も出力する仕様になっています。そのため、プロンプトの > と結果の文字(H や ?)は別々の行に表示されます。
本物のシェルの見た目とはまだ少し異なりますが、「入力された文字に応じて、VM上のプログラムの分岐(進み方)が変わる」ということさえ確認できれば、今回の目的は達成です。
1文字コマンド判定の全体像
まず、今回の programs/one-char-command.asm 全体を確認します。
MOVI R0, 62
SYSCALL 0
SYSCALL 2
MOVI R1, 104
CMP R0, R1
JZ 0x1C
JNZ 0x28
MOVI R0, 72
SYSCALL 0
HALT
MOVI R1, 113
CMP R0, R1
JZ 0x44
JNZ 0x38
MOVI R0, 63
SYSCALL 0
HALT
HALT前回までのシンプルなサンプルに比べると、急にコードが長くなったように見えるかもしれませんが、ブロックごとに分解して見ると、やっていることは次の4つのステップだけです。
>を表示する(プロンプト)- 1文字読み込む
hかどうかを調べるhでなければ、qかどうかを調べる
前半:プロンプト表示と文字入力
まず、最初の3命令は前回の prompt-echo とほとんど同じです。
MOVI R0, 62
SYSCALL 0
SYSCALL 2MOVI R0, 62 で > の文字コードを用意し、続く SYSCALL 0 で画面に表示します。
そのあと SYSCALL 2 でhost標準入力から1 byteを読みます。ここで読み込まれた値は R0 に格納されます。
後半:今回追加する条件分岐
ここから先が、今回新しく追加された判定処理の部分です。
MOVI R1, 104
CMP R0, R1
JZ 0x1C
JNZ 0x28まず MOVI R1, 104 で、比較対象となる値をレジスタ R1 に用意します。この 104 は、文字 h のASCIIコードです。
この時点で、レジスタ R0 にはユーザーが入力した文字コードが、レジスタ R1 には期待する文字コード(h)が入っている状態になります。この2つを準備した上で、CMP R0, R1 を実行して値を比較します。
2つの値が一致していれば、JZ 0x1C によって h 用の処理へ進みます。一致していなければ、JNZ 0x28 によって次の q 判定へ進みます。
- 2つの値が一致している場合(ゼロフラグが立つ):
JZ 0x1Cが成立し、hが入力されたとき用の処理へジャンプします。 - 一致していない場合(ゼロフラグが立たない):
JNZ 0x28が成立し、次のqの判定処理へ進みます。
文字は整数として比べる
アセンブリだけを見ると、文字そのものを直接比べているように見えるかもしれません。
しかし、VMの内部で実際に扱われているのは、すべて「整数値」です。入力された h や q といった文字は、すべて1 byteの文字コード(数値)としてレジスタ R0 に格納されます。
| 文字 | 10進数 | 16進数 | 用途 |
|---|---|---|---|
> | 62 | 0x3E | プロンプト表示 |
h | 104 | 0x68 | 入力判定(ヘルプ) |
q | 113 | 0x71 | 入力判定(終了) |
H | 72 | 0x48 | ヘルプの応答出力 |
? | 63 | 0x3F | エラーの応答出力 |
たとえば、次のように実行した場合を考えてみます。
printf h | /tmp/handmade-vm programs/one-char-command.binこのとき SYSCALL 2 は標準入力から h を読み込みますが、R0 に入るのは文字としての h ではなく、ASCIIコードである 104 という数値です。
そのため、「入力された文字が h かどうか」を調べたいときは、あらかじめ別のレジスタに 104 を用意しておき、R0 と比較する必要があります。
MOVI R1, 104
CMP R0, R1これは q かどうかを調べる場合もまったく同じです。q のASCIIコードである 113 を用意して、入力された値と比較します。
MOVI R1, 113
CMP R0, R1こうして見ると、1文字コマンド判定の正体が少し見えてきます。VMが何か特別な文字処理を行っているわけではありません。「入力された1バイトを整数として捉え、その整数値を CMP 命令でただ比較しているだけ」です。
CMPで比較結果をzero_flagに残す
ここで、CMP の動きをもう一度確認しておきます。
CMP R0, R1 は、レジスタ R0 と R1 の値を比較する命令です。裏側では次のような判定が行われています。
R0 == R1 なら zero_flag = true
R0 != R1 なら zero_flag = falseここで重要なのは、「CMP 命令自体は、レジスタの値を一切変更しない」という点です。
ADD や SUB は、計算結果をレジスタへ書き戻します。たとえば ADD R0, R1 なら、結果として R0 の値が変わります。
一方、CMP R0, R1 は、R0 も R1 も書き換えません。値を比べた結果だけを zero_flag に残します。
vm->zero_flag = vm->regs[inst.rd] == vm->regs[inst.rs];アセンブリを書く人の目線と、Cコード側の見方を並べると、次の対応になります。
Assembly C側の見方
R0 regs[0]
R1 regs[1]
CMP R0, R1 zero_flag = regs[0] == regs[1]SYSCALL 2 で読み込んだ文字は、レジスタ R0 に格納されたままです。CMP 命令で R0 を比較しても、R0 の値そのものが消えてしまうことはありません。
ですので、h の判定で一致しなかった後も、まったく同じ R0 の値を使って、次の q の判定処理へと進めます。
JZとJNZで処理を分ける
CMP 命令によって比較結果を作ることはできました。しかし、CMP 命令を実行しただけでは、まだプログラムの処理の流れは変わりません。
CMP が行うのは、zero_flag を更新するところまでです。その結果を見て、実際に次に実行するプログラムの位置(PC: プログラムカウンタ)を切り替えるのが JZ と JNZ 命令の役割です。
CMP R0, R1
JZ 0x1C
JNZ 0x281. 入力が h だった場合(JZ が成立)
JZは、zero_flag が true のときだけ指定のアドレスへジャンプする命令です。ここでは「入力が h と一致したとき」に使っています。
- レジスタの状態:
R0 = 104/R1 = 104 - フラグの変化:
CMPによりzero_flag = trueとなる - 分岐の挙動:
JZ 0x1Cが成立する
この場合、次に実行されるアドレスは 0x1C になります。そこには、あらかじめ H を表示する処理を置いてあります。
2. 入力が h 以外だった場合(JNZ が成立)
逆に、入力が h ではなかった場合、zero_flag は false になります。
- レジスタの状態:
R0 != R1 - フラグの変化:
CMPによりzero_flag = falseとなる - 分岐の挙動:
JZ 0x1Cは成立せず、JNZ 0x28が成立する
JNZは、zero_flag が false のときだけジャンプする命令です。この場合はアドレス 0x28 へとジャンプし、次の q コマンドの判定処理へ移ります。
C言語コード側での見方
VMのエミュレータ内部(C言語側)で見ると、JZ と JNZ の本質部分は、それぞれ次のようなシンプルな if 文に対応しています。
if (vm->zero_flag) {
vm->pc = inst.imm;
}if (!vm->zero_flag) {
vm->pc = inst.imm;
}CMP が条件を作り、JZ と JNZ がその条件を使って PC (プログラムカウンタ)を変える。この2段階の処理で、VM上のプログラムは処理の流れを変えられます。
分岐先アドレスを手動で計算する
次に、JZ 0x1C や JNZ 0x28 の 0x1C、0x28 がどこから出てきたのかを見ていきます。
今回のVMでは、1命令は4 byteです。つまり、命令はメモリ上で 0x00、0x04、0x08、0x0C のように4 byteずつ並びます。
プログラム全体のアドレス表を整理すると、次のようになります。
0x00000000 MOVI R0, 62
0x00000004 SYSCALL 0
0x00000008 SYSCALL 2
0x0000000C MOVI R1, 104
0x00000010 CMP R0, R1
0x00000014 JZ 0x1C
0x00000018 JNZ 0x28
0x0000001C MOVI R0, 72
0x00000020 SYSCALL 0
0x00000024 HALT
0x00000028 MOVI R1, 113
0x0000002C CMP R0, R1
0x00000030 JZ 0x44
0x00000034 JNZ 0x38
0x00000038 MOVI R0, 63
0x0000003C SYSCALL 0
0x00000040 HALT
0x00000044 HALTh 判定のジャンプ先
h と一致したときに進みたいのは、H を表示する処理です。
MOVI R0, 72
SYSCALL 0
HALTアドレス表を見ると、この処理は 0x0000001C から始まっています。そのため、h 判定の直後には JZ 0x1C と書きます。
h と一致しなかった場合は、次に q かどうかを調べたいです。
MOVI R1, 113
CMP R0, R1この q 判定は 0x00000028 から始まっています。そのため、h と一致しなかった場合の分岐先は JNZ 0x28 になります。
q 判定のジャンプ先
q 判定にも、同じように2つの行き先があります。
0x38:
q と一致しなかったとき、?を表示する
0x44:
q と一致したとき、そのまま停止するこの段階では、まだラベルを使いません。JZ help や JNZ unknown のようには書けないので、ジャンプ先のアドレスを自分で数えています。
少し不便ですが、ここは大事な練習でもあります。命令が4 byteずつ並び、ジャンプ先には命令のアドレスを直接書く。この感覚を手で確認しておくと、あとでラベル付きのassemblerを作る意味も分かりやすくなります。
small-asmを分岐命令に対応させる
ここまで見てきたプログラムを .bin にするには、small-asm 側も CMP、JUMP、JZ、JNZ を扱える必要があります。
Day 26までの small-asm は、次の3種類だけを扱う最小ツールでした。
MOVI Rn, imm
SYSCALL imm
HALT今回の one-char-command.asm では、ここに比較と分岐が加わります。
CMP R0, R1
JUMP 0x10
JZ 0x20
JNZ 0x30JUMP は今回のプログラム本体では使いません。ただ、次回のコマンドループでは、プロンプトへ戻るために必要になります。そこで、分岐命令を増やすこのタイミングで一緒に対応を済ませておきます。
命令値(バイナリ)の組み立て方
命令値は、これまでの仕様に合わせて次のように組み立てます。
CMP R0, R1 -> 0x24010000
JUMP 0x10 -> 0x68000010
JZ 0x20 -> 0x6A000020
JNZ 0x30 -> 0x6B000030各命令のエンコード仕様
CMP は、type = 2、op = 4、rd と rs に比較するレジスタ番号を入れます。
JUMP、JZ、JNZ は、type = 6 の制御系命令として、op と imm を使います。
JUMP: op = 8
JZ: op = 10
JNZ: op = 11まだ本格的なアセンブラではなく、ラベル機能もありませんが、「今必要になった命令だけを、今のVMが理解できる命令形式へ変換できるようにする」ことでVMとアセンブラを少しずつ育てていきます。
one-char-command.binを生成する
それでは、small-asm をビルドし、programs/one-char-command.asm から programs/one-char-command.bin を生成します。
cc tools/small-asm.c -o /tmp/small-asm
/tmp/small-asm programs/one-char-command.asm programs/one-char-command.binうまく変換できると、次のように表示されます。
assembled 18 instructions to programs/one-char-command.bin生成されたbyte列も確認します。分岐先アドレスを手で数えたので、ここで実際の並びも確認しておきます。
xxd programs/one-char-command.bin00000000: 4000 003e 6000 0000 6000 0002 4010 0068 @..>`...`...@..h
00000010: 2401 0000 6a00 001c 6b00 0028 4000 0048 $...j...k..(@..H
00000020: 6000 0000 0100 0000 4010 0071 2401 0000 `.......@..q$...
00000030: 6a00 0044 6b00 0038 4000 003f 6000 0000 j..Dk..8@..?`...
00000040: 0100 0000 0100 0000 ........18命令なので、全体は 18 * 4 = 72 byteです。最後の 0x44 にある HALT まで含めて、期待した位置に命令が並んでいることが確認できます。
hを入力した場合
まずはhを入力して実行してみます。
cc notes/vm.c -o /tmp/handmade-vm
printf h | /tmp/handmade-vm programs/one-char-command.bin実行結果:
>
H
CPU halted.このとき、VMの中では次のように進みます。
SYSCALL 2でhを読む
R0 = 104
MOVI R1, 104
CMP R0, R1
zero_flag = true
JZ 0x1C が成立する
Hを表示してHALTするhのASCIIコードは 104 です。今回、比較用としてレジスタ R1 に格納した値も同じ 104 なので、CMP 命令による比較結果は「一致」になります。
これによって zero_flag が true になり、条件付きジャンプ命令である JZ 0x1C が成立します。その結果、プログラムは「H」を表示する処理へと進みます。
qを入力した場合
次に、qを入力して実行した場合の動作を追ってみます。
printf q | /tmp/handmade-vm programs/one-char-command.bin実行結果:
>
CPU halted.q の場合、最初の h 判定では一致しません。
R0 = 113
R1 = 104
CMP R0, R1
zero_flag = false
JNZ 0x28 が成立するそのため、処理は 0x28 に置いた q 判定へ進みます。
MOVI R1, 113
CMP R0, R1
JZ 0x44
JNZ 0x38ここでは R0 と R1 の値はともに 113 です。そのため、CMP 命令によって zero_flag が true に変わり、条件分岐 JZ 0x44(ゼロならジャンプ)が成立します。
0x44 にある命令は HALT です。そのため、q を入力した場合は、H や ? のような応答文字は出力せず、そのまま停止します。
それ以外を入力した場合
最後にhでもqでもない文字を入力して実行した場合の動作を追ってみます。
printf x | /tmp/handmade-vm programs/one-char-command.bin実行結果:
>
?
CPU halted.x は h と一致しないので、最初の判定では JNZ 0x28 が成立し、q の判定処理へと進みます。
しかし、x は q とも一致しません。
q判定:
R0 != R1
zero_flag = false
JNZ 0x38 が成立する0x38 には、未知のコマンド用として ? を表示する処理を置いてあります。
MOVI R0, 63
SYSCALL 0
HALT63 は ? のASCIIコードです。これによって、想定外の1文字コマンドが入力された場合に ? を出力し、その後 HALT によってプログラムが停止する動きになります。
今回はループを入れない
ここまで実装が進むと、次は「コマンドを実行した後、もう一度プロンプト(>)に戻って次の入力を待ち受けたい」と感じるかもしれません。
たとえば、次のような挙動です。
- 「h」を入力したら「H」を表示し、再び
>を表示して待機する - 「x」を入力したら「?」を表示し、再び
>に戻る - 「q」が入力されたときだけ、プログラムを終了する
この形になれば、より実用的なシェルに近い「コマンドループ」が完成します。
しかし、今回はループ処理を入れません。
今回の目的は、コマンドループではなく、「コマンドの判定処理そのもの」を理解することだからです。
- 値を読み込む
- 値を比べる
- 一致したら特定の分岐へ進む
- 一致しなければ別の分岐へ進む
- 最後に停止する
ここにループまで入れると、「JUMP でプロンプトへ戻る処理」、「q のときだけループを抜ける処理」、「分岐先アドレスの再計算」が同時に発生してしまいます。
特に今の段階では、まだラベル(名前付きの分岐先アドレス)を使っていません。 分岐先のアドレスをすべて手動で計算している状態です。この段階でループまで組み込んでしまうと、どの命令がどこにジャンプしているのかを追う負担が一気に増えてしまいます。
そこで第7回では、1回だけ入力して、1回だけ判定して、停止するというシンプルな構造に留めています。
今回分かったこと
今回は、一文字コマンドを処理するプログラムone-char-command.asmを作成しました。これに伴い、自作アセンブラsmall-asmを拡張して分岐命令に対応させ、生成したバイナリファイルを仮想マシン(VM)上で実行しました。
今回確認した内容は次の通りです。
- 文字の入力と格納:
SYSCALL 2を実行すると、入力された1バイトのデータがR0に格納される - 比較用データの準備:
MOVIを使うことで、比較対象となる文字コードを任意のレジスタに用意できる - レジスタの比較:
CMPは2つのレジスタの値を比較し、その結果をzero_flagに反映させる - 条件付きジャンプ:
JZ命令は、zero_flagがtrueのときだけ指定のアドレスへジャンプするJNZ命令は、zero_flagがfalseのときだけジャンプする
これらの実装により、VM上の外部プログラムが「入力された一文字に応じて処理を分岐させる」という、実用的な処理を実現できるようになりました。
まとめ
今回は、入力された1文字を見て、h、q、その他に分ける one-char-command サンプルを扱いました。
中心になる流れは、次の部分です。
SYSCALL 2
MOVI R1, 104
CMP R0, R1
JZ 0x1C
JNZ 0x28このように、SYSCALL 2 で読み込んだ文字を MOVI で用意した文字コードと CMP で比較し、その結果をもとに JZ や JNZ がプログラムの流れをコントロールします。
ここまでの実装によって、VM上の外部プログラムは単に値を表示するだけでなく、入力に反応して別の処理へ進めるようになりました。
次は、1文字入力したら終了する現在の形から、入力を待ち続ける形へと進めます。
具体的には、JUMPを使って処理をプロンプトへ戻し、qが入力されたときだけ HALT してプログラムを終了する、コマンドループへ進みます。
おまけ:マルチタスクと仮想メモリをコントで読む
マルチタスクや仮想メモリの仕組みを、散髪屋コントにたとえて整理した読み物です。「CPUの切り替え」や「ページテーブル」のイメージを、もう少し感覚的につかみたい方におすすめです。








