【低レイヤ入門】C言語で作る自作VMを少しOSらしくする: echoコマンドで次の1行を表示する | UNIX Cafe

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

System Note $ cat /proc/ai-disclosure

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

【低レイヤ入門】C言語で作る自作VMを少しOSらしくする: echoコマンドで次の1行を表示する | UNIX Cafe

自作VM開発記 16

前回は、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-command

Command> が出たら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_unknown

R1には入力バッファの先頭、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 3

SYSCALL 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.bin
Command> 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.bin
Command> 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_equal

Day 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 = 0x100000

0x088 は 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 passed

tools/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.bin
Command> 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の対応命令を増やすことを考えます。まず命令の変換から進め、ラベルや文字列データの扱いは段階を分けます。

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

この記事を書いた人

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

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

目次