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

前回は、helpやunknownを表示した後に Command> へ戻り、続けてコマンドを入力できるようにしました。入力が長い場合も改行(LF)まで読み切ります。改行をCRとLFの2つで表す入力(CRLF)でも、途中で読み取りを終えず、次の入力に改行を残さない形です。これで、1行を読む read_line と、文字列を比較する string_equal を繰り返し使えるようになりました。
今回は、このループにechoコマンドを1つ追加します。echoと入力してEnterを押すと、表示したい内容を次の行に入力できます。この記事では、この表示する内容を「本文」と呼びます。本文を入力してEnterを押すと、その内容を表示してコマンド入力へ戻ります。入力と比較の部品は前回のものを使い、比較する単語と、その後に進む処理を増やします。
Day 36: tools/write-echo-command-bin.c
programs/echo-command.bin今回の練習ノートとコードは、GitHubの handmade-vm-os に置いています。
今回の記事で扱う範囲
今回作るのは、echo と完全一致したら、Text> で次の1行を読むコマンドです。例えば、次のように使います。Command> と Text> は、入力を促す表示(プロンプト)です。
Command> echo
Text> hello
hello
Command> quit
Goodbye from Handmade VM
CPU halted.上の例で Text> hello と見えるhelloは、入力した文字を端末が画面に表示したものです。その次の行のhelloが、今回作るechoコマンドの出力です。普段のシェルで使う echo hello とは入力の形が違います。今回は、コマンド名と引数を同じ行から分ける処理は作りません。まずは既存の比較ルーチンで処理を選び、その処理の中でもう一度入力ルーチンを呼ぶところまで進めます。
新しいVM命令やsyscallを増やす必要はありません。1行を読む処理と、文字列の末尾を値0のバイトで示す「0終端文字列」を表示する処理はすでにあります。この終端0は、数字の文字としての 0 とは別のものです。前回の練習コード(Day 35)を残し、別の echo-command.bin として作ります。
まずechoとhelpを続けて試す
リポジトリのルート(Makefileがあるディレクトリ)で次のコマンドを実行します。必要なファイルがビルドされ、echoを追加したプログラムが起動します。
make run-echo-commandCommand> が出たらecho、Text> が出たらhelloを入力します。どちらも入力の最後にEnterを押します。その後、help、hello、quitを1行ずつ入力してみます。makeのビルド表示を除くと、対話部分は次のようになります。
Command> echo
Text> hello
hello
Command> help
Commands:
help: show help
quit: quit
echo: read and print a line
Command> hello
Unknown command
Command> quit
Goodbye from Handmade VM
CPU halted.本文を表示した後にも Command> が出ています。helpの一覧にはechoが増え、unknownになった場合も入力を続けられます。通常の終了操作は、Command> にquitを入力することです。入力そのものが終わった場合の動作は、後の節で説明します。
VM用プログラムを選んで起動するホスト側の画面(monitor)を使う場合は、make の後に引数なしで ./handmade-vm を実行し、> に programs/echo-command.bin を入力します。VM上のquitでmonitorへ戻り、monitorの > でquitを入力すると終了します。起動の仕組みは前回までと同じです。
コマンドとして読む行と、本文として読む行を区別する
今回は、同じ入力処理を2つの場面で使います。最初は何を実行するかを決めるコマンド行、もう1つはechoが表示する本文行です。先に、それぞれの扱いを決めます。
名前: echo-command.bin
分類: sample program
コマンド: help / quit / echoと完全一致で比較する
echo: Text> を表示し、次の1行を読む
本文表示: 保存した文字列とLFを表示し、Command>へ戻る
入力バッファ: memory[0x500]から最大63 byteと終端0
行末: LFで確定。CRは読み捨てる
上限後: 保存せず、LFまで入力を消費する
空のコマンド行: unknown
空の本文行: 改行だけを表示する
EOF: input EOFを表示してVMを停止するコマンド側の完全一致も前回と同じです。大文字を小文字に直したり、先頭や末尾の空白を削ったりはしません。ECHO、末尾に半角スペースを付けた echo、echo hello はunknownになります。本文側では空白も保存し、そのまま表示します。
入力バッファは、読んだ内容を一時的に保存する64バイトのメモリ領域です。文字列の終わりを示す0に1バイト使うため、コマンドも本文も保存できるのは最大63バイトです。日本語の文字数の目安は、後の「長い本文はどこまで表示されるか」で説明します。
helpとquitに続けてechoを比較する
コマンド行を読んだ後は、help、quit、echoの順に比較します。どれにも一致しなければunknownへ進みます。
Command> を表示
CALLI read_line
helpと比較 → 一致ならhelp表示へ
quitと比較 → 一致なら終了へ
echoと比較 → 一致なら本文入力へ
どれにも一致しない → unknown表示へechoの比較も、既存の string_equal を呼ぶだけです。次のコードでは、処理の位置を string_equal や do_echo という名前(ラベル)で表しています。これは流れを説明するための表記です。自作アセンブラsmall-asmは、現時点ではこのコードをそのまま機械語に変換できません。実際に実行するバイナリは、Cで書いた生成プログラム tools/write-echo-command-bin.c で作ります。以降、この生成プログラムをwriterと呼びます。
MOVI R1, 0x500
MOVI R2, 0x440
CALLI string_equal
MOVI R3, 1
CMP R0, R3
JZ do_echo
JUMP do_unknownR1には入力バッファの先頭、R2には比較する文字列echoの先頭を渡します。0x440 には、e、c、h、o、終端0を置いてあります。比較結果は、一致ならR0に1、不一致なら0です。
注意するのは、比較の前にR1とR2を設定し直すことです。string_equalは比較を進めながら両方のポインタを動かします。直前にquitと比較したときの値をそのまま使うと、文字列の途中から比較してしまいます。
R3も比較ルーチンが使うレジスタなので、戻ってから1を設定します。CMP R0, R3 は、比較結果のR0が1と等しいかを調べます。等しければzero flagが立ち、JZ do_echo でechoの処理へ進みます。JZが見るのはR0が0かどうかではなく、直前のCMPで2つの値が等しかったかどうかです。
比較する単語が増えても、文字列を1 byteずつ調べる処理は増やしていません。第14回で共通化したルーチンに、別の文字列のアドレスを渡す形です。
echoの処理からread_lineをもう一度呼ぶ
echoと一致した後は、次の8命令を実行します。
do_echo:
MOVI R0, 0x460
SYSCALL 1
CALLI read_line
MOVI R0, 0x500
SYSCALL 1
MOVI R0, 10
SYSCALL 3
JUMP 0x000最初の2命令は Text> の表示です。R0に文字列の先頭 0x460 を入れ、SYSCALL 1を呼びます。その後、コマンド入力でも使ったread_lineをCALLIで呼びます。
read_line自身は、呼び出し元がコマンドを欲しいのか、echoの本文を欲しいのかを知りません。改行まで入力できた場合は、1行分を上限まで入力バッファへ保存し、終端0を書いてRETします。RETは、CALLIで呼ばれた位置の次の命令へ戻る命令です。違いは、戻った後に呼び出し元が何をするかです。
コマンド入力から呼ぶ:
read_line → RET → help / quit / echoと比較する
echoの処理から呼ぶ:
read_line → RET → バッファの文字列を表示する入力の仕組みを2つ作る必要はありません。呼び出し元が、入力結果の使い方を決めます。今回はこの違いを、同じルーチンの2回の呼び出しで確認できます。
なぜquitという本文で終了しないのか
echoの本文にquitと入力すると、どうなるでしょうか。
Command> echo
Text> quit
quit
Command> quit
Goodbye from Handmade VM
CPU halted.最初のquitは文字列として表示され、終了しません。本文入力から戻ると、次に実行する命令のアドレスを持つPC(プログラムカウンタ)は、バッファの先頭をR0へ入れる命令を指しています。その次はSYSCALL 1です。helpやquitとの比較へは進まないため、このquitをコマンドとして扱う機会がありません。
一方、最後のquitは Command> の後に入力しています。こちらはコマンド入力から戻った後に比較へ進むので、終了処理が選ばれます。同じ4 byteでも、実行中の命令列によって使い方が違います。
今回は「現在は本文入力中」というフラグを追加していません。どこからread_lineを呼び、どこへRETするかが、処理の区別になっています。本文にhelpやechoと書いた場合も、同じ理由で文字列として表示します。
コマンドと本文で同じバッファを使う
echoの本文は、コマンドを保存したのと同じ 0x500 から書き込みます。コマンド名echoを残すための別バッファは用意しません。
コマンドを読んだ後:
0x500: e c h o 00
本文helloを読んだ後:
0x500: h e l l o 00本文を読む時点では、すでにechoとの比較が終わり、echoの処理へ分岐しています。その後で元のコマンド名を使わないため、上書きできます。比較ルーチンへ渡している最中に書き換えているわけではありません。
read_lineの入口ではR1を毎回 0x500 に戻します。比較がR1を進めていても、次の入力はバッファの先頭から始まります。本文を読み終えた後のR1は末尾を指すので、表示するときはR0へ改めて先頭の 0x500 を入れます。
前より短い文字列になった場合も、末尾に新しい終端0を書けば、古い文字まで表示されることはありません。次の図の 00 は、値が0の1バイトを表します。空白は各バイトを見やすく区切るためのもので、実際には保存されません。
本文abcdefを保存した後:
0x500: a b c d e f 00
次のコマンドechoを保存した後:
0x500: e c h o 00 f 00
次の本文aを保存した後:
0x500: a 00 h o 00 f 00最後の状態には古い文字が残っています。しかし、SYSCALL 1は最初の終端0で表示を止めるため、表示するのはaだけです。毎回バッファ全体を消す処理は追加していません。
この共用ができるのは、本文を表示した後に保持する必要がないからです。今後、編集する行を残したまま次のコマンドを読むなら、入力用と編集対象用の領域を分ける設計が必要になります。今回は表示して戻るところまでにします。
本文の改行は自分で出力する
read_lineが保存するのは、LFより前の文字と終端0です。入力を確定したLFそのものはバッファへ保存しません。また、SYSCALL 1も文字列を表示した後に自動で改行する処理ではありません。
そのため、本文を表示した後にLFを出力します。
MOVI R0, 0x500
SYSCALL 1
MOVI R0, 10
SYSCALL 3SYSCALL 1へ渡すR0は文字列のアドレスです。次のSYSCALL 3へ渡すR0は出力する文字の値です。同じレジスタでも、syscallごとに引数の意味が変わります。
SYSCALL 3はR0の下位8ビットを1バイトとして出力します。値10は改行を表すLFなので、ここで改行が1つ出ます。SYSCALL 0は文字を出した後にも改行する仕様のため、LFを渡すと改行が2つになります。今回は改行なしの1文字出力を使います。
本文が空ならバッファの先頭が0なので、SYSCALL 1では何も表示されません。その後のSYSCALL 3でLFだけが出ます。コマンド側の空行がunknownになるのとは違う動作です。
長い本文はどこまで表示されるか
echoで表示する本文は、最大63バイトまで保存できます。半角英数字だけなら63文字、UTF-8で1文字が3バイトになるひらがなや漢字だけなら21文字です。文字数ではなくバイト数で区切るため、日本語などは文字の途中で切れて、末尾が文字化けする場合があります。
上限を超えた場合は、保存した先頭63バイトだけを表示します。残りは改行(LF)まで読み捨て、長すぎることを知らせるエラーは表示しません。たとえば、本文として半角のxを63個入力し、その直後にquitを続けてから改行すると、次のように動きます。
入力:
echo + LF
xを63個 + quit + LF
help + LF
quit + LF
動作:
xを63個表示する
同じ行の末尾のquitは読み捨てる
次の行のhelpをコマンドとして実行する
最後のquitで終了する末尾のquitも、同じ行に入力した本文の一部です。これを読み残すと、次のコマンド入力でquitとして受け取ってしまい、意図せず終了します。そのため、保存できる上限に達しても、改行までは読み続ける必要があります。本文にも前回と同じ read_line を使うことで、この動作を保っています。
行の終わりとして扱うのはLFです。改行をCRとLFの2つで表す入力(CRLF)では、CRを読み捨て、続くLFで1行の入力を確定します。CRだけでは確定しません。また、本文の途中にあるCRも読み捨てます。
改行する前に入力が終わると、本文は表示されない
ファイルやパイプから入力を渡す場合は、最後まで読み切ると「これ以上入力がない」という状態になります。これをEOFと呼びます。このVMでは、本文を改行で確定する前にEOFになると、途中まで読み取った本文を表示せず、input EOF を表示して停止します。
次の例では、シェルのprintfが作った文字列を、パイプ(|)でVMの入力へ渡します。echoの後には改行(\n)がありますが、helloの後にはありません。helloを読み取った直後に入力が尽きるため、本文は表示されません。
printf 'echo\nhello' | ./handmade-vm programs/echo-command.binCommand> Text> input EOFこれは、入力を読むSYSCALL 2がEOFを検出した時点でVMを停止するためです。read_line から本文を表示する処理へは戻りません。HALT命令も実行しないので、CPU halted. は出ません。
一方、helloの後にも改行を付けると、本文が1行分そろうため、helloを表示します。その後、次のコマンドを読もうとしたところでEOFになり、停止します。
printf 'echo\nhello\n' | ./handmade-vm programs/echo-command.binCommand> Text> hello
Command> input EOF補足として、ファイルなどから値0のバイト(NUL)が入力された場合も、そのまま保存します。ただし、文字列を表示するSYSCALL 1は、そのNULを文字列の終わりとみなします。たとえば、a、NUL、b、改行の順に入力すると、表示される本文はaだけです。読み取りは改行まで続くため、bが次のコマンドになることはありません。
命令が増えるとサブルーチンの位置も変わる
writerでは、echoの比較を6命令、本文入力と表示を8命令追加します。Day 35の56命令から、今回は70命令、280 byteになります。入力と比較のルーチンそのものは変えていませんが、その前に命令を追加したため配置が変わります。
0x000: コマンドループの先頭
0x03C: echoとの比較の準備
0x058: help表示
0x064: quit処理
0x070: unknown表示
0x07C: echo処理
0x09C: read_line
0x0E0: string_equalDay 35のread_lineは 0x064 でしたが、今回は 0x09C です。古いCALLIの即値をそのまま使うと、今回はquitの処理へ飛んでしまいます。処理内容が変わらなくても、前に置く命令数が変われば呼び出し先も変わります。
そこでwriterでは、すでに置いた命令の数を表すcountからアドレスを計算して記録します。このVMの命令は1つ4バイトなので、アドレスは count × 4 です。呼び出し先の位置が決まった後で、CALLIの命令にそのアドレスを書き込みます。本文用の呼び出しも、コマンド用と同じread_lineのアドレスへ向けます。
patch_jump(program, call_read_line, 0x69000000, read_line);
patch_jump(program, call_read_text, 0x69000000, read_line);上の2行は、コマンド入力用と本文入力用のCALLIに、同じread_lineのアドレスを設定しています。0x69000000 はCALLIを表す命令コードです。これはホスト側でバイナリを生成するときの処理です。実行中のVMが名前を解決するわけではありません。生成済みの命令には、すでに数値のアドレスが入っています。
文字列データの位置も確認します。比較語echoを 0x440、Text>を 0x460 に追加します。help表示はechoの説明を含め65 byte、終端0を含め66 byteで、0x340..0x381 に収まります。次のgoodbyeは 0x3A0 なので重なりません。
命令部分の280 byteとファイル全体の大きさは別です。文字列の間には空き領域があり、今回のファイル全体は1127 byteです。入力バッファは 0x500..0x53F で、VMへ読み込んだ後に使います。
本文の入力後、表示処理へ戻る仕組みを追う
CALLIは、処理を呼び出す前に「終わったらどこへ戻るか」をスタックへ保存します。SP(スタックポインタ)は、この保存に使う位置を管理するレジスタです。本文を読むCALLIは 0x084 にあります。この4バイトの命令を読み取った後、PCは次の命令の 0x088 に進み、この値を戻り先として保存します。4バイトの戻り先を保存するため、SPは4減ります。その後、PCをread_lineの 0x09C へ変えます。
本文入力前:
SP = 0x100000
0x084のCALLI:
戻り先 = 0x088
SP = 0x0FFFFC
PC = 0x09C
read_lineのRET:
PC = 0x088
SP = 0x1000000x088 は MOVI R0, 0x500 です。RETした後に本文表示へ進むことを、アドレスでも確認できます。本文を表示したら、0x098 のJUMPで先頭 0x000 へ戻ります。
コマンド入力のCALLI、比較のCALLI、本文入力のCALLIは、順番に呼んでそれぞれRETしています。echoという処理を追加しても、今回はサブルーチンの中から別のサブルーチンを呼ぶ形ではありません。echoの処理へ入るのはJZです。
次のコマンドへ戻る時点ではSPも元へ戻っています。JUMPは戻り先を積まないので、echoを繰り返すたびにスタックが深くなることはありません。
自動テストで本文と次のコマンドを照合する
手で試した後は、次のコマンドで自動テストを実行します。Python 3が必要です。
make test-echo-command成功すると、最後に次の行が出ます。
echo-command: 54 cases passedtools/test-echo-command.py は実際のVMを起動し、標準出力全体、標準エラー、終了コードを期待値と照合します。各起動の待ち時間は5秒までです。前回確認したコマンド入力の上限前後の動作に加え、本文側でも同じように確認します。
本文がquitでも終了しないこと、空行なら改行だけが出ること、前より短い本文で古い末尾を表示しないことを確認します。62・63・64・4096 byteの本文では、表示する長さと次のコマンドへ戻る位置の両方を調べます。CRLF、EOF、NUL、短いUTF-8の本文、1,000回のecho、monitorへの復帰も含みます。
次の入力は個別にも試せます。最初のquitは本文、次のhelpはコマンド、最後のquitは終了用です。
printf 'echo\nquit\nhelp\nquit\n' | ./handmade-vm programs/echo-command.binCommand> Text> quit
Command> Commands:
help: show help
quit: quit
echo: read and print a line
Command> Goodbye from Handmade VM
CPU halted.パイプで入力を渡す場合は、対話時のように入力文字が端末に表示されません。また、プロンプト自体には改行がないため、Command> Text> quit のようにプロンプトと出力が同じ行に並びます。対話時の入力文字を含む画面とは表示の並びが違いますが、VMが出力する内容は同じです。
今回分かったこと
今回は、既存の入力・比較・表示の部品を使い、echoコマンドを追加しました。比較対象を増やし、一致した後の処理からもう一度read_lineを呼ぶことで、次の1行を表示できました。
同じ入力ルーチンでも、RETした後に比較へ進むか、表示へ進むかで入力の意味が変わります。本文にquitと書いても終了しない理由は、文字そのものではなく、その後に実行する命令列で説明できます。
また、元のコマンド名を使い終えた後なら、同じバッファを本文用に再利用できます。新しい末尾へ終端0を書き、表示時には先頭アドレスを渡します。本文のLFは保存していないため、改行は別に出力しました。
コマンドを増やすと、writerで扱う分岐先やデータ配置も増えます。次は、こうしたVM用プログラムをアセンブリで書けるよう、small-asmの対応命令を増やすことを考えます。まず命令の変換から進め、ラベルや文字列データの扱いは段階を分けます。
