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

この連載では、C言語で小さなVM(仮想マシン)を作っています。独自の命令でレジスタやメモリを動かし、その上で動くプログラムも自分で書いています。1行の文章を扱えるところまで進んできました。
前回は、set helloでメモリに1行を保持し、showで表示しました。入力用とは別のバッファへコピーしたので、次のコマンドを入力しても内容は残ります。ただし、VMを終了して起動し直すと、編集内容は空に戻ります。
今回は、その1行をファイルへ保存し、読み戻します。saveで残した内容を、loadで編集バッファへ戻せることが目標です。開発手順ではDay 43、第23回にあたります。
新しいサンプルはprograms/line-storage.asmです。保存と読み込みのコマンドをVM上に加え、ファイル操作はCで書いたVM側へ追加します。VM上の命令とhostの機能のつながりも見ていきます。
まず、保存してから読み戻す
Makefileがあるディレクトリで実行します。Cコンパイラとmakeが必要です。後半の自動テストにはPython 3も使います。small-asmでバイナリを作り、VMで起動します。
make run-line-storage今回の練習ノートとコードは、GitHubの handmade-vm-os に置いています。保存先は、VMを実行している作業ディレクトリのline.txtです。同名のファイルがある場合は上書きするので、この練習用の保存先として使ってください。
Command>に次のように入力します。画面例の入力文字は端末が表示したもので、VM自身が出力しているわけではありません。
Command> set hello
Command> save
Saved.
Command> set world
Command> load
Loaded.
Command> show
hello
Command> quit
Goodbye from Handmade VM
CPU halted.saveのあと、メモリの内容をworldへ変えました。それでもloadするとhelloへ戻ります。showが表示しているのは、その時点の編集バッファです。保存したファイルと編集中の内容は、別々に存在します。
同じコマンドでもう一度起動し、今度は最初にshowを実行します。前の内容はメモリには残っていないので、空行が表示されます。その後、明示的にloadすると、ファイルに残したhelloを取り出せます。
Command> show
Command> load
Loaded.
Command> show
hello
Command> quit
Goodbye from Handmade VM
CPU halted.起動時の自動読み込みは行いません。quitや入力途中のEOFでも自動保存しません。いつメモリからファイルへ移し、いつファイルからメモリへ戻すのかを、saveとloadの操作で確かめます。
ファイルを扱う仕事をhost側へ渡す
hostは、このVMを動かしている側のコンピューターです。VM上のプログラムには1 MiBのメモリとR0〜R7のレジスタがあり、そこで独自の命令を実行します。一方、line.txtを作る場所はhost側のファイルシステムです。
これまでのLDBやSTBは、VMのメモリを読み書きする命令でした。STBでmemory[0x600]へ文字を書いても、hostのファイルへ保存されることはありません。ファイルを開く仕事をVMの外側へ依頼する入口が必要です。
今回はSYSCALL 4に保存、SYSCALL 5に読み込みを追加しました。既存のSYSCALL 1で文字列を表示するのと同じように、VM上のプログラムからhost側の処理を呼びます。
Cの共通部品はnotes/line-storage.h、その呼び出しはnotes/vm.cに置きました。コマンドの判定や結果の表示はline-storage.asmの担当です。ファイルを開く、読む、書く、閉じる処理は、Cの標準入出力を使います。
今回はhostのファイルへアクセスする段階です。ディスク上の領域やファイル名の一覧を管理する自作ファイルシステムは、まだ作っていません。
保存するbyteと、保存先を先に決める
保存先はline.txtの1つに固定しました。saveやloadのあとに名前は付けません。save memo.txtやload memo.txtのような非空引数は、Unknown commandとして拒否します。先頭や末尾の半角空白の扱いは、前回のコマンド分割を引き継ぎます。
保存先はVMプロセスの作業ディレクトリです。Makefileがある場所で起動すれば、そこにline.txtができます。別の場所から起動すれば、その場所のファイルを扱います。
編集バッファは前回と同じ64 byteです。本文に使えるのは最大63 byteで、最後に終端0を置きます。ただし、ファイルへ書くのは本文だけです。helloなら、次の違いがあります。
VM memory[0x600]: 68 65 6C 6C 6F 00
line.txt: 68 65 6C 6C 6F終端0も、表示するときの改行もファイルへは追加しません。ファイルの内容は5 byteで、読み込みに成功したとき、VM側のバッファへ終端0を付け直します。
空内容の保存も成功です。本文が0 byteなので、ファイルは0 byteになります。逆に、空ファイルをloadすると編集内容が空になります。内容が空であることと、操作に失敗したことは、戻り値で区別します。
R0にアドレスを渡し、結果を受け取る
保存と読み込みの呼び出しでは、R0へ64 byte領域の先頭アドレスを入れます。今回の編集バッファは0x600です。
MOVI R0, 0x600
SYSCALL 4SYSCALL 4の命令値は0x60000004、SYSCALL 5は0x60000005です。どちらもtype=6、op=0で、下位20 bitのimmが呼び出す番号になります。番号4や5はメモリのアドレスではなく、VM側で選ぶサービスの番号です。
VMはfetchで命令を読み、PCを4進めてからdecodeとexecuteへ進みます。たとえば今回のSYSCALL 4は0xF0にあるので、実行時のPCは0xF4です。ファイル操作のあとも、その次の命令から続きます。CALLIのように戻り先をスタックへ積む処理ではありません。
VM側の呼び出し部分は、次のとおりです。共通部品へVMメモリ、メモリサイズ、R0のアドレスを渡し、返ってきた結果を同じR0へ代入します。
vm->regs[0] = save_stored_line(vm->memory, MEMORY_SIZE, vm->regs[0]);復帰後のR0は成功なら0、失敗なら1です。呼び出し前はアドレス、呼び出し後は結果という約束なので、処理が終わってもR0に0x600が残るとは考えません。R1〜R7、zero flag、SPは保持します。失敗時もrunningは変えず、命令の実行を続けます。
保存は、内容を調べてからファイルを開く
書き始める前に、R0が指す64 byte全体がVMメモリ内に収まるか調べます。共通部品は、メモリサイズが64 byte未満の場合も先に拒否します。
if (memory_size < 64 || address > memory_size - 64) {
return 1;
}上限はmemory_sizeから64を引いて求めます。addressに64を足してから比較すると、極端に大きい値で加算が桁あふれするおそれがあるためです。1 MiBのVMでは、最後に64 byteを確保できる先頭は0xFFFC0です。0xFFFC1からでは1 byte足りません。
次に、64 byte以内に終端0があるか確認します。終端に達する前にCRやLFがあれば失敗です。終端が見つからなければ、本文の長さを確定できないので失敗にします。この検査を終えるまで、ファイルは開きません。
size_t length = 0;
while (length < 64 && memory[address + length] != 0) {
if (memory[address + length] == '\r' || memory[address + length] == '\n') {
return 1;
}
length++;
}
if (length == 64) {
return 1;
}lengthは最初の0までの本文のbyte数です。本文がhelloなら5になります。短い内容へ置き換えたあと、終端より後ろに古いbyteが残っていても、ファイルへは書きません。終端を含めてメモリに置く規則と、終端を除いて保存する規則を、ここでつないでいます。
FILE *file = fopen("line.txt", "wb");
if (file == NULL) {
return 1;
}
size_t written = fwrite(memory + address, 1, length, file);
int close_result = fclose(file);
return written == length && close_result == 0 ? 0 : 1;wbは、バイナリとして書くためにファイルを開く指定です。fwriteには、最初のbyteの場所、1要素の大きさ、書く要素数を渡します。ここでは1 byteをlength個なので、戻ったwrittenがlengthと同じか確認できます。
書き込み数だけで成功とせず、fcloseの結果も調べます。書き出しが最後まで完了しなければ、成功とは返しません。fwriteの結果が短い場合でも、実装ではfcloseを呼んでファイルを閉じます。
この保存方法は直接上書きです。wbで開いた時点で、以前のファイル内容は切り詰められます。開く前の検証失敗なら旧ファイルはそのままですが、書き込みやcloseで失敗した場合に、旧ファイルが残る保証はありません。一時ファイルへ書いてから置き換える方式は、今回の仕様には含めていません。
読み込みは、一時領域で確かめてから置き換える
loadでは、失敗したときに編集中の内容を残したいので、最初から編集バッファへ読み込みません。アドレスを確認し、line.txtをrbで開いたあと、host側の小さな配列へ読みます。
uint8_t content[64];
size_t length = fread(content, 1, sizeof(content), file);
int read_failed = ferror(file);
int at_end = feof(file);
int close_result = fclose(file);
if (read_failed || !at_end || close_result != 0 || length == 64) {
return 1;
}本文の上限は63 byteですが、読むための配列は64 byteです。64 byte読めたら、ファイルが上限より大きいと分かります。63 byteだけ読んで止めると、ファイル全体が63 byteなのか、その後ろにも続きがあるのか区別できません。1 byte多く読もうとして境界を確認します。
0〜63 byteを読み、ファイルの末尾まで達していて、読み取りとcloseにも失敗がなければ、内容の検査へ進みます。ここまででVMのメモリはまだ書き換えていません。
for (size_t i = 0; i < length; i++) {
if (content[i] == 0 || content[i] == '\r' || content[i] == '\n') {
return 1;
}
}
memcpy(memory + address, content, length);
memory[address + length] = 0;ファイル中のNUL、CR、LFは拒否します。0を途中に含むファイルを文字列として読めば、その後ろが見えなくなります。今回の保存形式に合わせ、本文だけの1行として読み戻せる内容を受け入れます。末尾の改行を取り除いて受理する処理も加えていません。
すべての検査を通った最後に、本文をコピーして終端0を書きます。内容が短くなっても、その終端までを有効な文字列として読めば古い末尾は表示されません。空ファイルならコピーする本文は0 byteで、先頭へ終端0だけを書きます。
結果を比べて、コマンド入力へ戻る
VM上のコマンドは、syscallから戻ったR0を比べて表示を選びます。do_saveの抜粋です。読み込みのdo_loadも、SYSCALL 5を呼んだあとに同じ形で結果を判定します。
do_save:
MOVI R0, 0x600
SYSCALL 4
MOVI R3, 0
CMP R0, R3
JNZ save_failed
MOVI R0, saved_text
JUMP storage_notice
save_failed:
MOVI R0, save_failed_text
JUMP storage_noticeCMPは結果と0を比べてzero flagを更新します。JNZで分岐するのは成功値0と一致しなかった場合です。syscall自身がフラグを変えなくても、呼び出し側が結果を比較することで分岐できます。
最後はSYSCALL 1で選んだ通知文字列を表示し、JUMPでcommand_loopへ戻ります。通知のためにCALLIを使う必要はなく、SPへ戻り先を積みません。既存の入力や比較のサブルーチンで使うスタックの約束も変えていません。
たとえばline.txtが存在しない作業ディレクトリで起動し、次の操作をした場合です。これは先ほど保存したファイルがある状態とは別の例です。
Command> set keep
Command> load
Load failed.
Command> show
keep
Command> quit
Goodbye from Handmade VM
CPU halted.読み込みに失敗しても、showでkeepが表示されます。開けない、長すぎる、不正なbyteを含む、読み取りやcloseで失敗する、という経路はいずれも成功前に戻ります。途中までの内容で編集バッファを上書きしないことが、続けて作業するための約束になります。
入力の上限と、ファイルの上限を分けて読む
コマンド入力には、前回からの最大63 byteという上限があります。同じ行にsetと本文を書くと、set の4 byteも含むので、本文は最大59 byteです。set単独の次の行なら、本文へ63 byteを使えます。
一方、ファイルの上限は本文そのものが63 byteです。saveというコマンド名をファイルへ付けるわけではありません。入力行全体と、保存する本文を分けて読みます。
上限を超えた端末入力は、従来どおりLFまで消費して、入力バッファに残した部分を使います。ファイルの読み込みでは、この切り詰め方をしません。64 byte以上のファイルは失敗とし、以前の編集内容を残します。入力とファイルでは、超過時の約束が違います。
CRとLFの扱いも異なります。端末入力ではCRを無視し、LFで1行を確定します。ファイル中のCRやLFは読み込み失敗です。一般のテキストエディターが末尾に改行を付けたファイルも、今回の保存形式から外れる点に注意します。
標準入力のEOFは、SYSCALL 2がinput EOFを表示してVMを停止する合図です。ファイルのEOFは、読み終わった位置を確認する情報です。空ファイルで直ちにEOFになることは、loadの失敗ではありません。同じEOFという名前でも、どの入力元の話なのかを区別します。
UTF-8もbyte列として扱います。日本語の文字境界や文字コードの妥当性を検査する機能は追加していません。端末入力の上限で1文字が途中までになった場合も、その残ったbyte列を保存し、同じbyte列として読み戻します。
部品、VM、コマンド全体の順に確認する
共通部品は、小さなCテスト38ケースで確認しました。通常、空、63 byteと64 byte、終端不足、不正内容、アドレス境界を用意し、操作後のファイルとメモリを期待値へ照合します。
make test-line-storage-parts
make test-line-storageテストでは一時ディレクトリを使い、実ファイルを作って読み書きします。メモリはバッファだけでなく全体を比較し、範囲外への書き込みや失敗時の変更を検出します。保存失敗時に編集内容が保持されることも確かめます。
read、write、closeの失敗は、テスト内で標準入出力の呼び出しを置き換えて再現しました。実ディスクを故障させる確認ではありません。close失敗を返す場合も、本物のファイルは閉じます。部分書き込みの結果や、開けたファイルを閉じることも検査します。
この部品が通過してからVMへ統合し、同じ38ケースを実VMのfetch、decode、execute経由で実行しました。R0の結果に加え、R1〜R7、zero flag、SP、PC、runningの保持を確認します。
コマンド全体の入出力は142ケースです。前回の行編集75ケースを、helpに増えた案内だけ反映して引き継ぎ、保存と読み込みの67ケースを加えました。標準出力全体、終了コード0、空の標準エラー、保存ファイルの有無と全byteを比較します。
別プロセスでの再起動とmonitorからの再起動も確認しています。初期内容は空でloadでだけ復元すること、quitやEOFで勝手に保存しないことが確認点です。1000回の保存・読み込みの反復も通過しています。
生成したバイナリは178命令712 byteと17文字列377 byte、合計1089 byteです。末尾の次は0x441なので、入力バッファ0x500には届きません。編集バッファは0x600で、戻り先スタックはメモリ上端から下向きに使います。
さらに、別途アセンブルしたバイナリとの全byte一致と、命令と文字列の配置も照合しました。この検査は142ケースとは別です。既存のassemblerや行編集、echo、VM自己テストも通過しており、旧サンプルを書き換えずに機能を追加できました。
今回分かったことと、次に進むこと
今回は、メモリに保持していた1行を、VMの終了後にも残せるようになりました。VM上ではアドレスを渡して結果を比べ、host側ではファイルを操作します。SYSCALLを境に、その2つの仕事をつなげました。
保存するbyteの範囲と、読み戻してよい内容を先に決めると、成功と失敗の経路もはっきりします。とくに読み込みでは、一時領域で確認してから編集バッファへ移すことで、失敗しても手元の内容を保てます。保存側の直接上書きとは、保証する範囲が違うことも確認しました。
次の候補は、VM上から別のプログラムを起動するための仕様決めです。読み込む位置、PCやSPの初期状態、使っているメモリ、終了後の戻り方を整理する必要があります。今回のline.txtを読む仕組みを、そのまま実行プログラムの起動に流用するわけではありません。ファイルへ内容を残す段階を区切りとして、次の境界へ進みます。
