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

前回は、小さな自作VM上で動く外部プログラムに、入力を待ち続けるループを追加しました。
command-loop.bin では、まず > を表示し、そのあと1文字だけ入力を読みました。そして、読んだ文字が h なのか、q なのか、Enterやspaceなのか、それ以外なのかで処理を分けました。
hならHを表示してプロンプトへ戻るqならHALTして終了する- Enter や space なら何も表示せず、次の入力を待つ
- その他なら
?を表示してプロンプトへ戻る
ここまで来ると、VM上のプログラムは、ただ上から順番に動いて終わるものではなくなります。入力を見て反応し、必要ならまたプロンプトへ戻るので、少しずつ「小さな対話プログラム」らしくなってきます。
ただし、前回の h は、まだ仮の反応でした。h を入力しても、表示されるのは1文字の H だけです。これでは「helpっぽい入口」はできていますが、まだ本当に説明を出しているとは言えません。
h をhelpコマンドとして扱うなら、次のような説明をまとめて表示したくなります。
h: help
q: quitここで必要になるのは、「1文字を表示する」仕組みではなく、「メモリ上に置いた文字列を、先頭から終わりまで順番に表示する」仕組みです。前回まで使ってきた SYSCALL 0 や SYSCALL 3 は1文字表示には便利ですが、複数行のメッセージを毎回1文字ずつ命令で書いていくのは大変です。
そこで今回は、すでにVM本体に入っている SYSCALL 1 を使います。外部バイナリの中に文字列データを置き、h や q の1文字コマンドから、その文字列を表示するところを確認します。
今回扱う外部プログラムは、programs/boot-message.bin です。今回は小アセンブラだけでは作りにくい内容なので、このバイナリを書き出すための tools/write-boot-message-bin.c も追加します。
Day 29: tools/write-boot-message-bin.c / programs/boot-message.bin今回の練習ノートとコードは、GitHubの handmade-vm-os に置いています。
今回の記事で扱う範囲
今回の記事で実装するのは、「起動時にWelcomeメッセージを表示し、そのあと1文字コマンドループへ入り、h でhelp、q で終了メッセージを表示するところ」までです。
画面上では、次のような流れになります。最初に起動メッセージが出て、h を入力するとhelp用の文字列が出て、q を入力すると終了メッセージが出ます。
Welcome to Handmade VM
>h
Commands:
h: help
q: quit
>>q
Goodbye from Handmade VM
CPU halted.ここで大事なのは、Welcome to Handmade VM や Commands: を、命令列の中で1文字ずつ表示しているわけではないことです。
文字列は、VMの memory の中に「データ」として置きます。そして、表示したい文字列の先頭アドレスを R0 に入れてから SYSCALL 1 を呼びます。
ただし、今回もまだ help や quit という単語そのものは読みません。現在のVMは、入力を1 byteずつ読む段階です。そのため今回は、h と q という1文字コマンドとして扱います。
また、今回は小アセンブラに .string や .byte のようなデータ定義も追加しません。そこまで一気に進めると、今回見たい「VMがメモリ上の文字列をどう表示するか」よりも、アセンブラの構文設計の話が大きくなってしまうためです。
今回は、専用のwriterで boot-message.bin を生成し、命令列と文字列データがバイナリ内のどこに置かれているのかを、見える形にしておきます。
今回のゴール
まずは完成形を実行して、今回の変更で何ができるようになったのかを見ておきます。細かい命令を読む前に、画面上の動きから押さえておくと、あとでコードを追いやすくなります。
make run-boot-message実行したら、キーボードから h、Enter、q、Enter の順に入力します。
Welcome to Handmade VM
>h
Commands:
h: help
q: quit
>>q
Goodbye from Handmade VM
CPU halted.最初に表示される Welcome to Handmade VM が起動メッセージです。そのあと > が表示され、VM上のプログラムが入力を待っている状態になります。
h を入力すると、Commands: から始まるhelp用文字列が表示されます。前回のように H を1文字だけ出すのではなく、複数行のメッセージをまとめて表示できるようになりました。
少し不思議に見えるのは、>>q の部分です。これは、h のあとに押した Enter も、VMから見ると1 byteの入力だからです。今回は Enter を「何もしない入力」として扱い、そのまま次のプロンプトを表示しています。そのため、次に入力した q の前に、もう一つ > が見えます。
最後に CPU halted. が表示されるのは、q の分岐で終了メッセージを表示したあと、プログラムが HALT に到達するためです。
今回のプログラムも、入力そのものはまだ1 byteずつ読みます。今回の目的は、入力処理を本格的にすることではなく、前回作った対話ループに「まとまった文字列表示」を接続することです。
SYSCALL 1を思い出す
このVMでは、SYSCALL の番号によって、host側にお願いする処理を分けています。VMの中だけでは標準出力や標準入力を直接扱えないので、表示や入力のような処理はsyscallとしてhost側のCコードに頼む形にしています。
SYSCALL 0:R0の下位8bitを1文字として表示し、改行も表示するSYSCALL 1:R0が指す0終端文字列を表示するSYSCALL 2: host標準入力から1 byte読むSYSCALL 3:R0の下位8bitを1文字として、改行なしで表示する
前回のプロンプト表示では、改行なしで > を表示したかったので SYSCALL 3 を使いました。SYSCALL 3 は、R0 に入っている値の下位8bitを「1文字」として表示します。
今回使う SYSCALL 1 は、そこが少し違います。R0 の値そのものを文字として表示するのではなく、R0 を「文字列が置かれているアドレス」として扱います。
VM本体の実装では、次のように処理しています。
} else if (inst.imm == 1) {
print_string(vm, vm->regs[0]);
}inst.imm が 1 のとき、VM本体は print_string を呼びます。その第2引数に渡しているのが vm->regs[0]、つまり R0 の値です。
print_string は、次のような関数です。ここでは address という名前からも分かるように、渡された値を「文字」ではなく「メモリ上の位置」として使っています。
static void print_string(VM *vm, uint32_t address) {
while (address < MEMORY_SIZE && vm->memory[address] != 0) {
putchar(vm->memory[address]);
address++;
}
}この関数は、address から1 byteずつ memory を読みます。そして、読んだ値が 0 ではない間だけ、putchar で表示します。
ここが今回の最初のつまずきやすい点です。SYSCALL 0 や SYSCALL 3 では、R0 は表示したい文字そのものでした。一方、SYSCALL 1 では、R0 は文字列が置かれている場所を指すアドレスです。同じ R0 でも、syscallの種類によって意味が変わります。
0終端文字列とは何か
今回の文字列は、0終端文字列として置きます。C言語に慣れている人にはおなじみの形ですが、低レイヤを学び始めたばかりだと少し見落としやすいところです。
0終端文字列とは、文字列の最後に 0x00 を置き、それを「ここで文字列が終わる」という印にする形です。文字列の長さを別に持つのではなく、終端の印を見つけるまで読み進める、という考え方です。
例えば、Welcome to Handmade VM\n という文字列をメモリに置くと、ざっくり次のようになります。
W e l c o m e t o H a n d m a d e V M \n 0x00実際には、各文字はASCIIコードの1 byteとして置かれます。最後の 0x00 は画面に表示する文字ではありません。あくまで「ここで終わり」という目印です。
print_string は、この 0x00 を見つけるまで表示を続けます。
while (address < MEMORY_SIZE && vm->memory[address] != 0) {
putchar(vm->memory[address]);
address++;
}もし最後の 0x00 がなければ、VMはどこまでが文字列なのか分かりません。たまたま次に置かれているデータや、空き領域の中身まで、文字列の続きとして読もうとしてしまいます。
そのため、今回のwriterでは、文字列本体を書いたあとに必ず 0x00 を1 byte追加します。この1 byteがあることで、SYSCALL 1 は安心して「ここまで表示すればよい」と判断できます。
命令列と文字列データを同じバイナリに入れる
今回の boot-message.bin では、1つのバイナリの中に、命令列と文字列データの両方を入れます。
ただし、何も考えずに並べると、VMが文字列データまで命令として実行しようとしてしまいます。そこで今回は、先頭付近に命令列を置き、少し離れた 0x100 以降に文字列データを置きます。
0x00000000: 40 00 01 00 MOVI R0, 0x100
0x00000004: 60 00 00 01 SYSCALL 1
0x00000008: 40 00 00 3E MOVI R0, 62
0x0000000C: 60 00 00 03 SYSCALL 3
0x00000010: 60 00 00 02 SYSCALL 2
...
0x00000064: 40 00 01 40 MOVI R0, 0x140
0x00000068: 60 00 00 01 SYSCALL 1
0x0000006C: 68 00 00 08 JUMP 0x08
0x00000070: 40 00 01 80 MOVI R0, 0x180
0x00000074: 60 00 00 01 SYSCALL 1
0x00000078: 01 00 00 00 HALT
0x00000100: "Welcome to Handmade VM\n\0"
0x00000140: "Commands:\nh: help\nq: quit\n\0"
0x00000180: "Goodbye from Handmade VM\n\0"命令は 0x00 から始まります。VMの PC も起動時には 0x00000000 です。つまり、VMはまずバイナリの先頭を「命令」として読み始めます。
このVMの命令は4 byte固定長です。そのため、通常の実行では PC は 0x00、0x04、0x08、0x0C、0x10 のように進みます。
一方、文字列は 0x100、0x140、0x180 に置いています。ここは、通常の流れでは PC が命令として読みに行く場所ではありません。
文字列データを読むのは、SYSCALL 1 が実行されたときだけです。そのとき、R0 に入っているアドレスを使って、memory の中を文字列として読みます。
この分け方が、今回の一番大事なところです。命令も文字列も同じ memory の中にありますが、どのレジスタを通して読むかによって意味が変わります。
PCで読む => 命令として読む
R0で読む => データとして読むPC が指している場所は「次に実行する命令」として解釈されます。R0 が指している場所は、SYSCALL 1 の中では「表示する文字列」として解釈されます。
この考え方は、あとで小さなOSらしい構造へ進むときにも大事になります。プログラムには命令だけでなく、表示メッセージ、テーブル、定数などのデータも必要になるからです。今回はその最初の例として、起動メッセージやhelp文字列を同じバイナリの中に入れています。
命令列を読む
次に、命令列の流れをざっくり読みます。全体を一度に細かく追うよりも、まずは「どのタイミングで、どの文字列のアドレスを R0 に入れているか」を見ると分かりやすいです。
MOVI R0, 0x100
SYSCALL 1
MOVI R0, 62
SYSCALL 3
SYSCALL 2
...
MOVI R0, 0x140
SYSCALL 1
JUMP 0x08
MOVI R0, 0x180
SYSCALL 1
HALT最初の MOVI R0, 0x100 で、起動メッセージの先頭アドレスを R0 に入れます。ここで入れている 0x100 は文字コードではなく、メモリ上の場所です。
R0 = 0x100次の SYSCALL 1 では、R0 が指す memory[0x100] から0終端文字列を表示します。つまり、0x100 番地に置いた Welcome to Handmade VM\n が画面に出ます。
memory[0x100] から読む
0x00 が出るまで表示するその後、h が入力された場合は MOVI R0, 0x140 で、help用文字列の先頭アドレスを R0 に入れます。
R0 = 0x140続く SYSCALL 1 で、memory[0x140] からhelp用文字列を表示します。q の場合は、同じ考え方で R0 に 0x180 を入れ、memory[0x180] から終了メッセージを表示します。
SYSCALL 1 の処理自体は、起動メッセージでもhelp用文字列でも終了メッセージでも変わりません。変わるのは、直前に R0 へ入れるアドレスだけです。これが分かると、「同じ表示処理を、違う文字列に使い回している」と見えるようになります。
boot-message.binを生成するwriter
今回は、小アセンブラではなく専用のwriterで boot-message.bin を生成します。
追加したファイルは tools/write-boot-message-bin.c です。このプログラムは、VM上で動くプログラムではありません。host側で実行して、VMに読み込ませるためのバイナリファイルを作る小さな生成ツールです。
まず、32bit命令をbig-endianで書き込む関数を用意します。VMが命令を読むときのbyte順に合わせて、1命令を4 byteに分けてファイルへ書き込みます。
static void write_u32_be(FILE *file, uint32_t value) {
fputc((value >> 24) & 0xFF, file);
fputc((value >> 16) & 0xFF, file);
fputc((value >> 8) & 0xFF, file);
fputc(value & 0xFF, file);
}このVMでは、32bit命令をメモリ上にbig-endianで置いています。例えば 0x40000100 という命令値は、ファイル上では次の4 byteとして書き込まれます。
40 00 01 00次に、指定したアドレスへ文字列を書き込む関数を用意します。命令列はファイルの先頭から順番に書けばよいのですが、文字列は 0x100 や 0x140 のような離れた位置に置きたいので、書き込み位置を移動する必要があります。
static int write_string_at(FILE *file, long address, const char *text) {
if (fseek(file, address, SEEK_SET) != 0) {
return 0;
}
while (*text != '\0') {
fputc((unsigned char)*text, file);
text++;
}
fputc(0x00, file);
return 1;
}fseek でファイル内の書き込み位置を移動し、そこから文字列を書き込みます。例えば address が 0x100 なら、ファイル内の 0x100 の位置へ移動してから文字列を書き始めます。
最後の fputc(0x00, file) が、0終端のための1 byteです。文字列本体を書くだけでは、VM側の print_string は終わりを判断できません。この 0x00 を追加しておくことで、SYSCALL 1 が安全に表示を止められます。
実際に命令と文字列を書き込んでいる部分は次の通りです。上側が命令列、下側が文字列データです。
write_u32_be(file, 0x40000100); // MOVI R0, 0x100
write_u32_be(file, 0x60000001); // SYSCALL 1
write_u32_be(file, 0x4000003E); // MOVI R0, 62
write_u32_be(file, 0x60000003); // SYSCALL 3
write_u32_be(file, 0x60000002); // SYSCALL 2
...
write_u32_be(file, 0x40000140); // MOVI R0, 0x140
write_u32_be(file, 0x60000001); // SYSCALL 1
write_u32_be(file, 0x40000180); // MOVI R0, 0x180
write_u32_be(file, 0x60000001); // SYSCALL 1
write_string_at(file, 0x100, "Welcome to Handmade VM\n");
write_string_at(file, 0x140, "Commands:\nh: help\nq: quit\n");
write_string_at(file, 0x180, "Goodbye from Handmade VM\n");命令列はファイルの先頭から順番に書き込まれます。そのあと、write_string_at が 0x100、0x140、0x180 へ移動して、それぞれの文字列を書き込みます。
この形にすると、記事の中でメモリ配置をそのまま追いやすくなります。R0 に入れているアドレスと、writerが文字列を書き込んでいるアドレスが対応しているからです。
Makefileから生成する
Makefile には、boot-message.bin を生成するターゲットを追加しました。毎回writerを手で実行しなくても、make から同じ手順で再生成できるようにするためです。
BOOT_MESSAGE_WRITER := tools/write-boot-message-bin
programs/boot-message.bin: $(BOOT_MESSAGE_WRITER)
$(BOOT_MESSAGE_WRITER) $@これで、次のコマンドからバイナリを再生成できます。writerをビルドしたあと、そのwriterを使って programs/boot-message.bin を作ります。
make programs/boot-message.bin実行用には、次のターゲットも用意しています。これは、生成済みの boot-message.bin をVM本体に渡して実行するためのターゲットです。
run-boot-message: $(VM) programs/boot-message.bin
./$(VM) programs/boot-message.binそのため、確認するときは次のコマンドだけで済みます。バイナリの生成と実行の流れを、Makefile側にまとめておけます。
make run-boot-message実行結果を確認する
実際に実行してみます。ここでは手入力で動かしたときの見え方を確認します。
make run-boot-message手動で h、Enter、q、Enter の順に入力すると、出力は次のようになります。
Welcome to Handmade VM
>h
Commands:
h: help
q: quit
>>q
Goodbye from Handmade VM
CPU halted.まず、起動直後に R0 = 0x100 にして SYSCALL 1 を呼んでいます。そのため、memory[0x100] から Welcome to Handmade VM\n が表示されます。
次に、h を読んだときは R0 = 0x140 にして SYSCALL 1 を呼びます。そのため、memory[0x140] からhelp用文字列が表示されます。q を読んだときは R0 = 0x180 にして、終了メッセージを表示します。
>>q の部分は少し不思議に見えますが、これは h のあとに押した Enter も1 byteとして読まれているためです。Enterを読んだあと、プログラムは何も表示せずにプロンプトへ戻るので、次の q の前にもう一つ > が表示されます。
最後に q の分岐で終了メッセージを表示し、HALT を実行します。そのため、VM本体が CPU halted. を表示して停止します。
なぜまだsmall-asmにデータ定義を入れないのか
今回の内容を見ると、次は小アセンブラに文字列データを書けるようにしたくなります。
例えば、次のような書き方ができると便利です。命令の中で直接 0x100 のような数値アドレスを書くのではなく、message という名前で文字列を参照できる形です。
MOVI R0, message
SYSCALL 1
HALT
message:
.string "Welcome to Handmade VM\n"ただし、この形に進むには、少なくともラベル、データ定義、文字列エスケープ、アドレス解決が必要になります。
ここを一気に入れると、今回の主題である「VMが文字列データをどう読むか」よりも、「アセンブラをどう設計するか」の話が大きくなります。ラベルを見つけて、あとからアドレスを埋めて、\n のようなエスケープも解釈する必要があるからです。
そのため、今回は専用のwriterでバイナリを生成しました。見た目としては少し手作業に近いですが、そのぶん「どのアドレスに何を置いているか」がはっきり見えます。
この方法は遠回りに見えますが、学習用としてはメリットがあります。命令がどのアドレスにあり、文字列がどのアドレスにあり、R0 に何を入れれば表示できるのかを、手で追いやすいからです。
まずメモリ配置を理解し、そのあとでラベルやデータ定義を持つアセンブラへ進む。この順番にすると、後で便利な構文を追加したときにも、その裏側で何が起きているのかを見失いにくくなります。
今回分かったこと
今回は、外部バイナリに文字列データを含め、SYSCALL 1 で1文字コマンドから表示するところを確認しました。
今回確認した内容は次の通りです。
- 外部バイナリには、命令だけでなくデータも置ける
PCは命令を読む場所を指すR0は、SYSCALL 1が読む文字列の先頭アドレスになる- 0終端文字列では、最後の
0x00が終わりの印になる - 専用writerを使うと、命令列と文字列データの配置を明示的に確認できる
今回分かったことを整理すると、SYSCALL 1 は「文字列を表示する命令」というより、R0 に入っているアドレスから 0x00 までを順番に読む仕組みです。そのため、文字列を表示したいときは、命令列とは別の場所に文字列データを置き、その先頭アドレスを R0 に入れる必要があります。
まとめ
前回までの command-loop は、h を入力しても1文字の H を表示するだけでした。今回はそこから一歩進めて、h でhelp用文字列を表示し、q で終了メッセージを表示するところまで進めました。
h を入力する
↓
help用文字列を表示する
↓
プロンプトへ戻るここまで進むと、h は仮の1文字反応ではなく、本当にhelpコマンドらしい役割を持ちはじめます。
また、起動時に Welcome to Handmade VM を表示することで、VM上の外部プログラムは「いきなり入力を待つ小さな処理」から、「起動メッセージを出し、コマンドを受け付け、終了メッセージを出して止まる対話プログラム」に近づきました。
ただし、まだ help や quit という単語は読めません。また、Enterも1 byteとして処理されるため、今回の実行例では >>q のようにプロンプトが余分に見えます。
次に進むこと
第9回では、外部バイナリ内に固定文字列を置き、h でhelp表示、q で終了メッセージを表示するところまで進みました。
次に進むなら、Enterまでの入力をVM内メモリに貯める入力バッファが必要になります。そこまで進むと、h / q の1文字コマンドから、help / quit のような単語コマンドへ進めます。
おまけ:マルチタスクと仮想メモリをコントで読む
マルチタスクや仮想メモリの仕組みを、散髪屋コントにたとえて整理した読み物です。「CPUの切り替え」や「ページテーブル」のイメージを、もう少し感覚的につかみたい方におすすめです。









