【低レイヤ入門】C言語で作る自作VMを少しOSらしくする: 1文字コマンド判定 | UNIX Cafe

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

System Note $ cat /proc/ai-disclosure

本記事の構成および論理分析には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
HALT

MOVI 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.asmprograms/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回で実装した CMPJZJNZ といった命令を、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 2

MOVI 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の内部で実際に扱われているのは、すべて「整数値」です。入力された hq といった文字は、すべて1 byteの文字コード(数値)としてレジスタ R0 に格納されます。

文字10進数16進数用途
>620x3Eプロンプト表示
h1040x68入力判定(ヘルプ)
q1130x71入力判定(終了)
H720x48ヘルプの応答出力
?630x3Fエラーの応答出力

たとえば、次のように実行した場合を考えてみます。

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 は、レジスタ R0R1 の値を比較する命令です。裏側では次のような判定が行われています。

R0 == R1 なら zero_flag = true
R0 != R1 なら zero_flag = false

ここで重要なのは、CMP 命令自体は、レジスタの値を一切変更しない」という点です。

ADDSUB は、計算結果をレジスタへ書き戻します。たとえば ADD R0, R1 なら、結果として R0 の値が変わります。

一方、CMP R0, R1 は、R0R1 も書き換えません。値を比べた結果だけを 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: プログラムカウンタ)を切り替えるのが JZJNZ 命令の役割です。

CMP R0, R1
JZ 0x1C
JNZ 0x28

1. 入力が h だった場合(JZ が成立)

JZは、zero_flagtrue のときだけ指定のアドレスへジャンプする命令です。ここでは「入力が h と一致したとき」に使っています。

  • レジスタの状態R0 = 104 / R1 = 104
  • フラグの変化CMP により zero_flag = true となる
  • 分岐の挙動JZ 0x1C が成立する

この場合、次に実行されるアドレスは 0x1C になります。そこには、あらかじめ H を表示する処理を置いてあります。

2. 入力が h 以外だった場合(JNZ が成立)

逆に、入力が h ではなかった場合、zero_flagfalse になります。

  • レジスタの状態R0 != R1
  • フラグの変化CMP により zero_flag = false となる
  • 分岐の挙動JZ 0x1C は成立せず、JNZ 0x28 が成立する

JNZは、zero_flagfalse のときだけジャンプする命令です。この場合はアドレス 0x28 へとジャンプし、次の q コマンドの判定処理へ移ります。

C言語コード側での見方

VMのエミュレータ内部(C言語側)で見ると、JZJNZ の本質部分は、それぞれ次のようなシンプルな if 文に対応しています。

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

CMP が条件を作り、JZJNZ がその条件を使って PC (プログラムカウンタ)を変える。この2段階の処理で、VM上のプログラムは処理の流れを変えられます。

分岐先アドレスを手動で計算する

次に、JZ 0x1CJNZ 0x280x1C0x28 がどこから出てきたのかを見ていきます。

今回のVMでは、1命令は4 byteです。つまり、命令はメモリ上で 0x000x040x080x0C のように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  HALT

h 判定のジャンプ先

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 helpJNZ unknown のようには書けないので、ジャンプ先のアドレスを自分で数えています。

少し不便ですが、ここは大事な練習でもあります。命令が4 byteずつ並び、ジャンプ先には命令のアドレスを直接書く。この感覚を手で確認しておくと、あとでラベル付きのassemblerを作る意味も分かりやすくなります。

small-asmを分岐命令に対応させる

ここまで見てきたプログラムを .bin にするには、small-asm 側も CMPJUMPJZJNZ を扱える必要があります。

Day 26までの small-asm は、次の3種類だけを扱う最小ツールでした。

MOVI Rn, imm
SYSCALL imm
HALT

今回の one-char-command.asm では、ここに比較と分岐が加わります。

CMP R0, R1
JUMP 0x10
JZ 0x20
JNZ 0x30

JUMP は今回のプログラム本体では使いません。ただ、次回のコマンドループでは、プロンプトへ戻るために必要になります。そこで、分岐命令を増やすこのタイミングで一緒に対応を済ませておきます。

命令値(バイナリ)の組み立て方

命令値は、これまでの仕様に合わせて次のように組み立てます。

CMP R0, R1 -> 0x24010000
JUMP 0x10 -> 0x68000010
JZ 0x20   -> 0x6A000020
JNZ 0x30  -> 0x6B000030

各命令のエンコード仕様

CMP は、type = 2op = 4rdrs に比較するレジスタ番号を入れます。

JUMPJZJNZ は、type = 6 の制御系命令として、opimm を使います。

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.bin
00000000: 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_flagtrue になり、条件付きジャンプ命令である 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

ここでは R0R1 の値はともに 113 です。そのため、CMP 命令によって zero_flagtrue に変わり、条件分岐 JZ 0x44(ゼロならジャンプ)が成立します。 

0x44 にある命令は HALT です。そのため、q を入力した場合は、H? のような応答文字は出力せず、そのまま停止します。

それ以外を入力した場合

最後にhでもqでもない文字を入力して実行した場合の動作を追ってみます。 

printf x | /tmp/handmade-vm programs/one-char-command.bin

実行結果:

>
?
CPU halted.

xh と一致しないので、最初の判定では JNZ 0x28 が成立し、q の判定処理へと進みます。

しかし、xq とも一致しません。

q判定:
  R0 != R1
  zero_flag = false
  JNZ 0x38 が成立する

0x38 には、未知のコマンド用として ? を表示する処理を置いてあります。

MOVI R0, 63
SYSCALL 0
HALT

63? の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_flagtrueのときだけ指定のアドレスへジャンプする
    • JNZ 命令は、zero_flagfalseのときだけジャンプする

これらの実装により、VM上の外部プログラムが「入力された一文字に応じて処理を分岐させる」という、実用的な処理を実現できるようになりました。

まとめ

今回は、入力された1文字を見て、hq、その他に分ける one-char-command サンプルを扱いました。

中心になる流れは、次の部分です。

SYSCALL 2
MOVI R1, 104
CMP R0, R1
JZ 0x1C
JNZ 0x28

このように、SYSCALL 2 で読み込んだ文字を MOVI で用意した文字コードと CMP で比較し、その結果をもとに JZJNZ がプログラムの流れをコントロールします。 

ここまでの実装によって、VM上の外部プログラムは単に値を表示するだけでなく、入力に反応して別の処理へ進めるようになりました。

次は、1文字入力したら終了する現在の形から、入力を待ち続ける形へと進めます。

具体的には、JUMPを使って処理をプロンプトへ戻し、qが入力されたときだけ HALT してプログラムを終了する、コマンドループへ進みます。 

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

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

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

この記事を書いた人

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

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

目次