【低レイヤ入門】C言語で作る自作VMを少しOSらしくする: プロンプトへ戻る1文字コマンドループ | UNIX Cafe

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

System Note $ cat /proc/ai-disclosure

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

【低レイヤ入門】C言語で作る自作VMを少しOSらしくする: プロンプトへ戻る1文字コマンドループ | UNIX Cafe

前回は、小さな自作VM上で動く外部プログラムに、最初の「コマンドらしい反応」を実装しました。

それまでのプログラムは、入力された1文字をそのまま表示するだけでした。たとえば A を受け取ったら A を表示する、という確認です。

前回の one-char-command.bin では、そこから一歩進めて、読み込んだ文字に応じて処理を分岐させました。

  • h なら H を表示して停止する
  • q なら、そのまま停止する
  • その他なら ? を表示して停止する

ここまで来ると、VM上のプログラムは、ただ文字を表示するだけではなく、入力された文字によって進む先を変えられるようになります。

ただ、前回の形にはまだ大きな制限がありました。どの分岐へ進んでも、最後は HALT してプログラムが終わってしまうことです。

これでは、h を入力して H が表示された瞬間に終了します。未対応の文字を入力して ? が表示された場合も、そこで終了します。

よりシェルらしい画面に近づけるなら、ここで終了するのではなく、もう一度「>」を表示して次の入力を待ち受けたいところです。

そこで今回は、「処理が終わったらプロンプトへ戻る」というループの流れを追加します。

今回扱う外部プログラムは、programs/command-loop.asm と programs/command-loop.bin です。

Day 28: command-loop.asm / command-loop.bin

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

目次

今回の記事で扱う範囲

今回の記事で実装するのは、「1文字コマンドを繰り返し処理するループ処理」までです。

画面上では、次のような一連の流れを持つ小さなコマンドループを目指します。

  • > を表示する
  • 1文字読む
  • h なら H を表示してプロンプトへ戻る
  • q なら HALT して終了する
  • その他なら ? を表示してプロンプトへ戻る

ここで大事なのは、「すべての入力でプログラムが終了するわけではない」という点です。

qだけを終了コマンドとして扱い、それ以外の入力では、処理が終わったあとに JUMP 命令で先頭のプロンプト表示へ戻します。これにより、プログラムは1回で終わることなく、何度でも次の入力を待ち受けられるようになります。

ただし、今回もまだ本格的な行入力は扱いません。help のような単語を読むことも、Backspaceを処理することも、raw modeでキー入力を制御することも行いません。

今回扱うのは、あくまで「1バイトずつ読み、1文字コマンドとして判定し、必要ならプロンプトへ戻る」という最小限のループです。

ここをはっきり分けておくと、後で行入力や文字列コマンドへ進むときに、何が増えたのかを追いやすくなります。

今回のゴール

まずは、今回の完成形を画面で確認します。コードの細かい話へ入る前に、「何ができるようになったのか」を先に見ておきます。

make
./handmade-vm programs/command-loop.bin

起動後、キーボードから h、x、q を順番に入力します。それぞれの文字を入力したあと、Enterを押します。

>h
H
>x
?
>q
CPU halted.

h を入力すると H を表示し、再び > に戻ります。

x は未対応コマンドとして扱い、? を表示して、また > に戻ります。

最後に q を入力すると、終了コマンドとして HALT に到達し、VMが停止します。

ここで、前回までと少し違うところがあります。今回は、> と入力文字が同じ行に表示されています。

これは、プロンプト表示に改行なしの SYSCALL 3 を使っているためです。プロンプトは「入力を待っている印」なので、入力文字と同じ行に置いた方が自然に見えます。この部分は、あとで詳しく見ていきます。

自動確認(テスト)と手入力による見え方の違い

ここで、今回の環境における「実行方法」についても少し整理しておきます。

これまでは、記事の中で cc notes/vm.c -o /tmp/handmade-vm のように、その場でVMをビルドして実行していました。この方法は単発のテストには便利ですが、「VM本体を起動して外部プログラムを読み込ませている」という実感が少し湧きにくい形でした。

そこで今回は、プロジェクトのルートディレクトリに Makefile を導入し、次の形でスマートに起動できるようにしました。

./handmade-vm programs/command-loop.bin

make コマンドを実行すると、VM本体(handmade-vm)と最小アセンブラ(tools/small-asm)を自動でビルドし、さらに Makefile に登録している programs/*.asm から最新の programs/*.bin を一括で再生成します。

1. 手動で動作を確認する場合

実際に手動でキーボードから入力して試したい場合は、次のコマンドが便利です。

make run-command-loop

※内部的には ./handmade-vm programs/command-loop.bin を実行しています。

2. 自動テストで確認する場合(入力文字の非表示について)

一方で、コードを書き換えるたびに毎回手動でキーボードから入力するのは、確認作業としては面倒です。そこで、自動で動作を確認したい場合は次のコマンドを使います。

make test

このとき、内部では次のように printf を使って、標準入力へパイプ経由で文字を流し込んでいます。

printf 'h \nx\nq\n' | ./handmade-vm programs/command-loop.bin

ここでは h の後ろに space も入れて、space を無視する処理も一緒に確認しています。

ここで一つ注意ですが、パイプで入力を渡す場合、ターミナル側に入力echoは表示されません。
そのため、自動テストでの画面の見え方は、先ほどの手入力のときとは少し違って次のようになります。

>H
>?
>CPU halted.

手入力では、入力した h や x がターミナル側にも表示されます。一方、パイプで渡す自動確認では、入力文字そのものは表示されません。

どちらもVM上のプログラムとしては同じように1 byteずつ読んでいますが、手入力ではターミナルが入力文字を表示し、パイプではそれが表示されないため、画面上の見え方だけが変わります。

前回の one-char-command を復習する

今回のコードは前回より少し長くなります。いきなり全体を見ると、どこが本質的な変更なのか分かりにくくなるので、まず前回の 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

前回のポイントは、SYSCALL 2 で読み込んだ1文字を R0 に入れ、CMP で期待する文字と比較することでした。

h と比較する部分は、次の形です。

MOVI R1, 104     : 104 は 'h' のASCIIコード
CMP R0, R1       : 入力文字(R0)と 'h'(R1)を比較
JZ 0x1C          : 一致すれば zero_flag が立ち、'H' を表示する処理へ
JNZ 0x28         : 一致しなければ、'q' の判定処理へ

104 は h のASCIIコードです。

入力文字が入っている R0 と、比較対象の h が入っている R1 を CMP で比較します。一致すればフラグが立ち、JZ によって「H を表示する処理」へと進みます。一致しなければ、JNZ によって「q の判定処理」へと進みます。

ここまでは今回も同じです。前回と違うのは、分岐した「先」で何をするかです。

前回は、それぞれの処理の最後が HALT で終わっていました。

h      -> H を表示 -> HALT
q      -> HALT
その他 -> ? を表示 -> HALT

今回の目的は、このうち h とその他の処理を、HALT ではなく「プロンプトへ戻る処理」に置き換えることです。前回の比較と分岐の形はそのまま使い、終わり方だけを変える、と考えると追いやすくなります。

改行なしでプロンプトを表示する(SYSCALL 3 の追加)

コマンドループへ進む前に、プロンプト表示も少し直します。

第6回と第7回では、プロンプト文字 > の表示に SYSCALL 0 を使っていました。

MOVI R0, 62
SYSCALL 0

現在の SYSCALL 0 は、R0 の下位8bitを1文字として表示したあと、改行も出力するようになっています。

そのため、プロンプトを表示すると、入力文字は次の行に移動していました。

>
h

動作確認としてはこれでも十分ですが、プロンプトとして見ると少し不自然です。> は「ここに入力してください」という印なので、できれば入力文字と同じ行に置きたいところです。

そこで今回は、改行なしで1文字だけ表示する SYSCALL 3 を追加しました。新しいCPU命令を増やすのではなく、既存の SYSCALL の番号を1つ増やす形です。

MOVI R0, 62
SYSCALL 3

SYSCALL 3 は、R0 の下位8bitを1文字として表示します。ただし、SYSCALL 0 と違って改行は表示しません。

これにより、手入力では次のように見えます。

>h

SYSCALL 0 と 3 の使い分け

ここでのポイントは、すべての文字表示を SYSCALL 3 に置き換える必要はないということです。

プロンプト表示だけを SYSCALL 3 にし、H や ? の表示には従来通り SYSCALL 0 を使います。

こうすると、結果が表示されたあとに改行され、次のプロンプトが読みやすくなります。

>h
H
>x
?
>q
CPU halted.

command-loop.asm 全体を見る

今回の programs/command-loop.asm は次の内容です。

MOVI R0, 62
SYSCALL 3
SYSCALL 2
MOVI R1, 104
CMP R0, R1
JZ 0x5C
JNZ 0x1C
MOVI R1, 113
CMP R0, R1
JZ 0x74
JNZ 0x2C
MOVI R1, 10
CMP R0, R1
JZ 0x08
JNZ 0x3C
MOVI R1, 13
CMP R0, R1
JZ 0x08
JNZ 0x4C
MOVI R1, 32
CMP R0, R1
JZ 0x08
JNZ 0x68
MOVI R0, 72
SYSCALL 0
JUMP 0x00
MOVI R0, 63
SYSCALL 0
JUMP 0x00
HALT

前回より長くなりましたが、中心になる考え方は大きく変わっていません。

  • h と比較する
  • 違ったら q と比較する
  • 違ったら Enter や space を無視するか判定する
  • それ以外なら ? を表示する

この中で、新しく追加した重要なものは「判定そのもの」ではありません。

それぞれの処理が終わったあと、HALT で終了するのではなく、JUMP 0x00 を使ってプログラムの先頭へ処理を戻している部分です。この JUMP 命令が、今回の目的である「コマンドループ」を生み出す鍵となっています。

JUMP 0x00 でプロンプトへ戻る仕組み

では、なぜ JUMP 0x00 でプロンプトへ戻れるのでしょうか。

今回のプログラムでは、メモリの先頭である0x00 番地に次の命令があります。

MOVI R0, 62

これは、プロンプト文字 > を表示する準備です。

その次の SYSCALL 3 で > を改行なしで表示し、その次の SYSCALL 2 で1文字読みます。

つまり、JUMP 0x00 は「もう一度プロンプト表示からやり直す」という意味になります。

h を処理する部分は、次の3命令です。

MOVI R0, 72
SYSCALL 0
JUMP 0x00

72 は H のASCIIコードです。

SYSCALL 0 で H を表示し、そのあと JUMP 0x00 でプログラムの先頭へ戻ります。ここで HALT しないことが、前回との大きな違いです。

これまでは H を表示したところで終了していました。今回は、表示したあとにもう一度 > を表示し、次の入力を待ちます。

その他の文字を処理する部分も同じ構造です。

MOVI R0, 63
SYSCALL 0
JUMP 0x00

63 は ? のASCIIコードです。未対応の文字を読んだら ? を表示し、やはり JUMP 0x00 でプロンプトへ戻ります。

q のときだけ終了する

ループを作るときに、もう1つ決めておく必要があります。どこでループを抜けるかです。

今回のプログラムでは、q だけを終了コマンドとして扱います。

q 判定の部分は次の形です。

MOVI R1, 113
CMP R0, R1
JZ 0x74
JNZ 0x2C

113 は q のASCIIコードです。

入力文字と q が一致した場合、JZ 0x74 によって 0x74 番地へ移動します。

0x74: HALT

ここだけは JUMP 0x00 ではなく HALT に到達します。

このように、h や未対応の文字では先頭へ戻り、q だけで停止するようにしておくと、外部プログラムは q が入力されるまで動き続けます。

Enter と space を無視する

手入力で試していると、もう1つ重要なことに気づきます。

ターミナルで h を入力して Enter を押すと、VM側には h だけでなく、Enterに相当する byte も届きます。

環境によって異なりますが、この改行データは LF(10) や CR(13) というASCIIコードとして扱われます。

もしこれらを無視しないと、Enterそのものが「h でも q でもない文字」として扱われ、余計な ? が表示されてしまいます。

そこで、今回のプログラムでは LF と CR を何も表示せずに無視します。

MOVI R1, 10
CMP R0, R1
JZ 0x08
JNZ 0x3C
MOVI R1, 13
CMP R0, R1
JZ 0x08
JNZ 0x4C

0x08 番地は、SYSCALL 2 がある位置です。

0x08: SYSCALL 2

つまり、Enterを読んだときはプロンプトをもう一度表示せず、そのまま次の入力を読む位置へ戻ります。

また、手入力ではうっかりスペースを入れてしまうことがあります。そこで space(ASCIIコード 32)も同じように無視しています。

MOVI R1, 32
CMP R0, R1
JZ 0x08
JNZ 0x68

この処理によって、h のあとにEnterを押しても、または h のようにスペースが混ざっても、余計な ? は表示されません。

分岐先アドレスを数える

コードを見ると、JZ 0x5C や JZ 0x74 のような数値がいくつも出てきます。

今回の環境でもまだラベル機能を持ったアセンブラは使用していないため、分岐先はすべて即値アドレスで直接指定しています。

命令は1つ4 byteなので、先頭から4 byteずつ増やして数えます。

0x00: MOVI R0, 62
0x04: SYSCALL 3
0x08: SYSCALL 2
0x0C: MOVI R1, 104
0x10: CMP R0, R1
0x14: JZ 0x5C
0x18: JNZ 0x1C
0x1C: MOVI R1, 113
...
0x5C: MOVI R0, 72
0x60: SYSCALL 0
0x64: JUMP 0x00
0x68: MOVI R0, 63
0x6C: SYSCALL 0
0x70: JUMP 0x00
0x74: HALT

例えば、h と一致した場合は JZ 0x5C によって 0x5C 番地へ移動します。0x5C 番地には、「H」を表示するための MOVI R0, 72 が配置されています。

q と一致した場合は JZ 0x74 で HALT へ移動します。

このように、ラベルがない段階では分岐先の命令が何番地にあるかを手動で数える必要があります。

小さなプログラムであれば問題ありませんが、命令数が増えるほど計算ミスが発生しやすくなります。今回のように「h、q、Enter、space、その他」を条件分岐させると、分岐先の数も一気に増えていきます。

今回の command-loop.asm は、この手動でアドレスを計算する限界が見え始める頃です。今後プログラムがさらに長くなった際に、アセンブラにラベル機能が必要となる理由がここにあります。

small-asm によるバイナリ変換

アセンブリを書いたら、small-asm でVMが読み込める .bin に変換します。

make programs/command-loop.bin

変換に成功すると、出力される命令数は30個になります。

assembled 30 instructions to programs/command-loop.bin

生成されたバイナリの中身を xxd コマンドで確認すると、先頭部分は次のようになります。

xxd programs/command-loop.bin
00000000: 4000 003e 6000 0003 6000 0002 4010 0068  @..>`...`...@..h
00000010: 2401 0000 6a00 005c 6b00 001c 4010 0071  $...j..\k...@..q
00000020: 2401 0000 6a00 0074 6b00 002c 4010 000a  $...j..tk..,@...
00000030: 2401 0000 6a00 0008 6b00 003c 4010 000d  $...j...k..<@...
00000040: 2401 0000 6a00 0008 6b00 004c 4010 0020  $...j...k..L@..
00000050: 2401 0000 6a00 0008 6b00 0068 4000 0048  $...j...k..h@..H
00000060: 6000 0000 6800 0000 4000 003f 6000 0000  `...h...@..?`...
00000070: 6800 0000 0100 0000                      h.......

先頭付近の 60 00 00 03 が SYSCALL 3 です。ここでプロンプトを改行なしで表示しています。

今回「行入力」を実装しない理由

今回のプログラムで、画面の見え方はかなりシェルに近づきましたが、まだ行入力は実装していません。

そのため、ここで「もうシェルができた」と考えると、少し先走りになります。

今回の command-loop.bin が読んでいるのは、あくまで1 byteずつです。

h
x
q

help のような複数文字のコマンドは、まだ読めません。今のプログラムは、h なら反応できますが、help という文字列全体をコマンドとして扱うことはできません。

複数文字を扱うには、読み込んだ文字をレジスタだけでなくメモリへ保存していく必要があります。

  • 入力バッファの先頭アドレス
  • 現在の書き込み位置
  • STB による1 byte書き込み
  • Enterまで読み続けるループ
  • 文字列の終端

これらを一度に入れると、今回の「プロンプトへ戻る」というテーマがぼやけてしまいます。

そのため、第8回では「1文字コマンドを繰り返し処理する」ところで区切ります。小さく区切ることで、あとから入力バッファを追加したときに、どの部分が新しく増えたのかを見分けやすくなります。

今回分かったこと

今回は command-loop.asm を作成し、q が入力されるまでプロンプトへ戻り続ける、小さなコマンドループを実装しました。

今回確認した内容は次の通りです。

  • JUMP 0x00 を使うと、プログラム先頭のプロンプト表示へ戻れる
  • h やその他の処理後に HALT しなければ、入力待ちを続けられる
  • q の分岐だけを HALT にすると、終了コマンドを作れる
  • プロンプトには、改行なし表示の SYSCALL 3 が向いている
  • Enterやspaceも1 byteとして届くため、必要なら明示的に無視する

これらの実装によって、VM上で動く外部プログラムは「1回だけ反応して終わるプログラム」から、「終了コマンドが入力されるまで、ユーザーの入力を待ち続けるプログラム」へと進化しました。

まとめ

今回は command-loop.asm を使って、プロンプトへ戻る1文字コマンドループを実装しました。

中心になる変更は、前回 HALT していた場所を JUMP 0x00 に置き換えたことです。

MOVI R0, 72
SYSCALL 0
JUMP 0x00

これにより、「h」を処理したあともプログラムは終了せず、もう一度プロンプトを表示して次の入力を待てるようになります。

未対応の文字も同じです。

MOVI R0, 63
SYSCALL 0
JUMP 0x00

一方で、「q」が入力された場合だけは HALT へ進むようにルートを分けました。

この小さな変更によって、VM上の外部プログラムは「1回だけ動いて終わる」形から、「終了コマンドが入力されるまで動き続ける」形へと進化しました。

次回は、起動メッセージの出力や help コマンドの表示を実現するために、文字列表示の仕組みとデータ配置の整理に進みます。

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

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

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

この記事を書いた人

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

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

目次