【低レイヤ入門】C言語で作る自作VMを少しOSらしくする: Welcome表示とhelp/qコマンド | UNIX Cafe

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

System Note $ cat /proc/ai-disclosure

本記事の構成および論理分析には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の切り替え」や「ページテーブル」のイメージを、もう少し感覚的につかみたい方におすすめです。

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

この記事を書いた人

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

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

目次