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

今回は、自作VMで入力を受け付ける前に > を表示し、そのあとで入力した1文字を表示するプログラムを用意します。> のように入力を促す目印を「プロンプト」、入力した A をそのまま A と表示する動きを「エコー(echo)」と呼びます。
VM(仮想マシン)は、ここではC言語で作った小さなコンピューターの仕組みです。「数値を保存する」「文字を表示する」といった命令を読み、Cの処理でその動きを再現します。VMを動かしている実際のPC側を「ホスト」と呼びます。
前回のプログラムは、1文字を読み、その文字を表示して終了するものでした。今回は、その前にプロンプトの表示を加えます。例えば A を渡すと、次の順番で表示されるのが今回のゴールです。
>
A
CPU halted.このVMの文字表示は、1文字を出力するたびに改行します。そのため > と A は別々の行になります。最後の CPU halted. は、VMが命令の実行を停止したことを知らせるメッセージです。実際のPCを停止するわけではありません。
今回確認したいのは、「目印を表示する → 1文字読む → 読んだ文字を表示する → 終了する」という順番です。入力を何度も受け付ける繰り返し処理や、複数文字をまとめて読む処理は、次の段階で扱います。
タイトルの「OSらしくする」は、入力の目印を付けるという意味です。OSはコンピューターの資源を管理する基本ソフトウェアですが、今回のプログラムにその機能が加わるわけではありません。例えば ls と入力してファイル一覧を表示させるような、コマンドを受け付けて処理する「シェル」の入口に、画面の見た目を少し近づけます。
今回の練習ノートとコードは、GitHubの handmade-vm-os に置いています。開発記録では「Day 26」に当たる内容です。本文では、この段階の仕様をもとに説明します。
前回までの仕組みを確認する
まず、今回扱うファイルの役割を整理します。VM本体と、そのVMで実行するプログラムは別のファイルです。
| ファイル | 役割 |
|---|---|
notes/vm.c | VM本体のCソースです。ファイルから命令を読み、実行します。 |
programs/prompt-echo.asm | 今回書くプログラムです。MOVI など、人が読める命令名で処理を記述します。 |
programs/prompt-echo.bin | .asm を変換して生成する、VMで実行するためのファイルです。 |
tools/small-asm.c | .asm から .bin へ変換するツールのCソースです。 |
MOVI R0, 62 のように命令名と数値で処理を書く表記を「アセンブリ言語」と呼びます。一方、VMが直接読むのは、命令を数値で表した「マシン語」です。その数値をバイト単位で保存したものが、今回のバイナリファイル .bin です。
アセンブリをマシン語へ変換する作業が「アセンブル」、そのためのツールが「アセンブラ」です。前回までに用意した小さなアセンブラ small-asm を今回も使います。例えば MOVI R0, 62 という1行を、このVMの決まりに従って4バイトの命令へ変換します。
1バイトは、0か1を表す単位であるビットを8個まとめたもので、0〜255の数値を表せます。今回読むのは1バイトだけなので、A のように1バイトで表せる文字を使います。日本語の あ など、1文字に複数バイトを使う文字は今回の対象に含めません。
入力元はホストの「標準入力」です。これはプログラムが入力を受け取る窓口で、例えばターミナルで入力した文字や、別のコマンドから渡した文字を受け取ります。出力先の「標準出力」は、通常はターミナルの画面につながっています。Cではそれぞれ stdin、stdout と呼びます。
VM内で数値を一時的に置く場所が「レジスタ」です。今回は R0 というレジスタを使います。例えば、文字 > に対応する数値を R0 に入れ、その値を文字として出力すれば、画面に > を表示できます。
文字と数値の対応を決めた規則の1つが「ASCII」です。この規則で文字を表す数値をASCIIコードと呼び、> は62、A は65に対応します。レジスタに入るのは文字の形そのものではなく、この数値です。
ホストの入出力機能を呼び出すために、このVMでは SYSCALL(システムコール)命令を使います。後ろに書く番号で処理を選び、SYSCALL 0 なら文字の出力、SYSCALL 2 なら1バイトの入力を行います。この番号は自作VM内で決めたものです。
| 命令 | このVMでの動き |
|---|---|
MOVI R0, 62 | 命令に直接書いた数値62を R0 に入れます。 |
SYSCALL 0 | R0 の下位8ビットを文字として出力し、改行します。 |
SYSCALL 2 | 標準入力から1バイト読み、その値で R0 を上書きします。 |
HALT | VMの命令実行を停止します。 |
「下位8ビット」は、数値を2進数で表したときの右端8桁です。例えば R0 が65なら、下位8ビットも65なので A が表示されます。R0 自体は32ビットの値を保持できますが、文字表示に使うのはそのうちの1バイト分です。
前回の echo-char.asm は、「入力 → 出力 → 停止」の順に、次の3命令を並べていました。入力命令が R0 に値を入れ、出力命令が同じ R0 を読むことで、入力した文字をそのまま表示できます。
SYSCALL 2
SYSCALL 0
HALTプロンプトを表示する5命令を書く
今回は、前回の3命令の手前に「R0 に > の値を入れる」「その値を文字として表示する」という2命令を加えます。VM本体へ新しい種類の命令を追加する必要はありません。すでにある命令を使い、VM上で動くプログラムの内容を変更します。
作業には、前回までの入力処理を含む notes/vm.c、アセンブラの tools/small-asm.c、Cのコンパイラが必要です。以降のコマンドは、notes、tools、programs があるリポジトリの最上位ディレクトリで入力します。
まず、テキストエディターで programs/prompt-echo.asm を開き、次の内容を保存してください。すでにファイルがある場合は、内容を確認します。ここに書くのはホストのターミナルへ直接入力するコマンドではなく、VMで実行する命令です。
MOVI R0, 62
SYSCALL 0
SYSCALL 2
SYSCALL 0
HALT5命令を読むときのポイントは、R0 の中身が途中で変わることです。最初はプロンプト表示用の62が入り、入力を読むと、その文字の値へ上書きされます。A を渡した場合を、順番に追ってみます。
| 順番と命令 | R0と画面の変化 |
|---|---|
1. MOVI R0, 62 | R0 に62が入ります。まだ文字は表示されません。 |
2. SYSCALL 0 | 62を文字として扱い、> と改行を出力します。R0 は62のままです。 |
3. SYSCALL 2 | 入力の A を読み、R0 を65へ上書きします。 |
4. SYSCALL 0 | 今度は65を文字として扱い、A と改行を出力します。 |
5. HALT | 命令実行を停止し、VM本体が CPU halted. を表示します。 |
2回の SYSCALL 0 は、どちらも同じ処理です。表示する文字が変わるのは、呼び出した時点の R0 の値が違うためです。入力前の62を別の場所へ保存しておく必要はありません。> の表示は、その時点ですでに終わっています。
また、SYSCALL 0 の 0 は「処理を選ぶ番号」であり、表示する文字の値ではありません。表示する値は R0 に入れます。同じように、SYSCALL 2 の 2 は入力処理の番号で、「2文字読む」という意味ではありません。
small-asmで実行用のファイルへ変換する
書いた5命令をVMで実行できる形にするため、アセンブルします。期待する結果は、5命令分のデータを持つ programs/prompt-echo.bin が生成されることです。今回使う MOVI、SYSCALL、HALT は、第4回までに用意した small-asm で変換できるため、ツールの機能追加は不要です。
最初に、Cソースからアセンブラの実行ファイルを生成します。この変換作業を「コンパイル」と呼びます。cc はCコンパイラを呼ぶコマンドで、-o の後ろに生成先を指定します。
cc tools/small-asm.c -o /tmp/small-asmエラーが表示されずに終了したら、生成したアセンブラを使います。1つ目の引数が読み込む .asm、2つ目が書き出す .bin です。/tmp は、ここではツールの実行ファイルを一時的に置く場所として使っています。
/tmp/small-asm programs/prompt-echo.asm programs/prompt-echo.bin成功すると、5命令を変換したことを示す次のメッセージが表示されます。
assembled 5 instructions to programs/prompt-echo.binここまでで、VMに読み込ませるファイルができました。Cコンパイラが生成した /tmp/small-asm はホストで動くツールで、そのツールが生成した programs/prompt-echo.bin は自作VMで動くプログラムです。
VMで実行し、表示の順番を確認する
次に、VM本体もコンパイルします。これで、先ほど生成した .bin を読み込んで実行するための /tmp/handmade-vm ができます。
cc notes/vm.c -o /tmp/handmade-vmまずは自分で入力してみます。次のコマンドでVMを起動すると、プログラムが > を表示し、その次の行で入力を待ちます。
/tmp/handmade-vm programs/prompt-echo.binA を入力し、Enterキーを押してください。通常のターミナルでは、Enterを押すまで入力がプログラムへ渡らないためです。今回のVMプログラムは、渡された入力の先頭1バイトである A だけを読み、表示して終了します。Enterの改行を判定する処理はありません。
この操作では、ターミナル自身が入力中の A を表示することもあるため、A が2回見える場合があります。VMの出力だけをはっきり確認するには、次のように別のコマンドから A を渡します。
printf A | /tmp/handmade-vm programs/prompt-echo.binprintf A は、改行を付けずに A を出力するコマンドです。間の | は「パイプ」と呼ばれ、左側の標準出力を右側の標準入力へつなぎます。この例では、キーボードで入力する代わりに、printf が出力した A をVMへ渡しています。
結果が次の3行になれば、プロンプトの出力、入力した文字の表示、停止まで確認できています。
>
A
CPU halted.この方法では A があらかじめ渡されるため、入力待ちの時間はほとんどありません。それでもVMは、命令の順番どおりに > を出力してから A を読みます。
読み込む文字を変えても同じ仕組みで動くことを確認するなら、printf A を printf B に変えて実行します。表示は >、B、CPU halted. の順になります。.asm の書き換えや再アセンブルは不要です。表示した B は、命令内に固定した値ではなく、実行時に入力から受け取った値だからです。
もし input EOF と表示された場合は、読み取る文字がないまま入力が終わっています。EOFは入力の終わりを表す状態です。例えば空の入力を渡すと起こります。今回の確認では、printf A のように1バイト以上を渡してください。
文字の出力と改行をCコードで確かめる
ここからは、動作を確認したプログラムの内部を見ます。まず、なぜ > と A が別の行になるのかを、VM本体の出力処理で確かめます。期待する動きは、R0 の値を1文字として出力し、続けて改行することです。
Cのコードでは、R0 に対応する保存場所は vm->regs[0] です。vm はVMの状態を指し、regs はレジスタの値をまとめて持つ配列、[0] はその先頭を表します。SYSCALL 0 を実行すると、notes/vm.c の次の処理へ進みます。
putchar(vm->regs[0] & 0xFF);
putchar('\n');putchar は、指定した値に対応する1文字を標準出力へ書くCの関数です。1行目は R0 の値から表示用の1バイトを取り出して出力し、2行目は改行を表す
を出力します。
0x で始まる数値は16進数です。16進数では、0〜9に加えてA〜Fを使い、Aが10、Fが15を表します。例えば 0x3E は3×16+14で62、0x41 は4×16+1で65です。0xFF は255で、2進数では8個の1が並びます。
& は、2つの値で同じ位置のビットがどちらも1のときだけ1を残す演算です。& 0xFF とすると右端8ビットだけが残ります。例えば R0 が 0x0000003E なら、取り出した値も 0x3E、つまり62なので > が表示されます。
そのあとで改行を出力するため、次の文字は別の行に表示されます。今回はこの仕様のまま、入力より先にプロンプトを表示する順番を確認しています。> A のように同じ行へ並べる変更は、別の段階で扱います。
5命令がどのようなバイト列になるか確認する
実行結果だけでなく、アセンブラが何を生成したかも確認しておきます。このVMでは、1命令を必ず32ビット、つまり4バイトで表します。そのため、今回の5命令を保存したファイルは、5×4で20バイトになるはずです。
命令を表す数値は、このVMで決めた形式に従います。例えば MOVI R0, 62 は 0x4000003E です。MOVI を表す 0x40000000 に、レジスタ番号0と、命令内に直接書いた値62(0x3E)を組み込んでいます。このように命令へ直接書く数値を「即値」と呼びます。
ファイルへ保存するときは、大きい桁のバイトから順に並べる「ビッグエンディアン」という方式を使います。例えば 0x4000003E は 40 00 00 3E の順です。16進数で書いた値を左から2桁ずつ読めば保存順になるため、今回のように手で対応を追いやすくなります。
| アセンブリ命令 | 命令を表す数値 | ファイルへ保存する4バイト |
|---|---|---|
MOVI R0, 62 | 0x4000003E | 40 00 00 3E |
SYSCALL 0 | 0x60000000 | 60 00 00 00 |
SYSCALL 2 | 0x60000002 | 60 00 00 02 |
SYSCALL 0 | 0x60000000 | 60 00 00 00 |
HALT | 0x01000000 | 01 00 00 00 |
この並びがファイルに入っているか、xxd で確認します。xxd は、ファイルの内容を16進数で表示するコマンドです。
xxd programs/prompt-echo.bin00000000: 4000 003e 6000 0000 6000 0002 6000 0000 @..>`...`...`...
00000010: 0100 0000 ....左端の 00000000 と 00000010 は、各行がファイルの先頭から何バイト目に当たるかを16進数で表しています。0x10 は10進数で16なので、2行目は16バイト目から始まります。右端は同じデータを文字として表示した欄です。命令を確かめるときは中央の16進数を見ます。
中央では2バイトずつ空白で区切られていますが、VMの1命令は4バイトです。先頭の 4000 003e が MOVI R0, 62、次の 6000 0000 が SYSCALL 0 に対応します。大文字の 3E と小文字の 3e は同じ値です。
1行目に4命令分の16バイト、2行目に HALT の4バイトがあり、合計20バイトです。これで、書いた5命令が意図した順番で保存されていると確認できます。
VMがファイルを読み、実行する流れ
最後に、前回までに用意した「外部ファイルを実行する仕組み」と、今回の5命令をつなげて整理します。VMは .asm の文字を直接読むのではなく、アセンブル済みの .bin をメモリへ読み込みます。
メモリは、命令やデータを番号付きの場所へ保存する領域です。このVMではCの配列 memory で表し、memory[0] が先頭の1バイトに当たります。この場所の番号を「アドレス」と呼びます。今回のファイルはアドレス0から順番に読み込まれます。
次に読む命令のアドレスを保持するのが「プログラムカウンタ(PC)」です。今回の命令は4バイトずつなので、PCが示す読み取り位置は0、4、8、12、16と進みます。例えばアドレス0には MOVI R0, 62、アドレス4には最初の SYSCALL 0 があります。
VMは、PCの位置から命令を読む「fetch」、読んだ数値から命令の種類や引数を取り出す「decode」、その内容を実行する「execute」を繰り返します。最初の命令なら、4バイトを読む → MOVI、保存先 R0、値62だと解釈する → R0 へ62を入れる、という流れです。
アドレス0 MOVI R0, 62 → R0へ62を入れる
アドレス4 SYSCALL 0 → > と改行を出力する
アドレス8 SYSCALL 2 → Aを読み、R0を65へ上書きする
アドレス12 SYSCALL 0 → Aと改行を出力する
アドレス16 HALT → VMの命令実行を停止する入力文字に注目すると、データの通り道は次のようになります。プロンプトの表示が終わったあと、この流れで A を受け取り、表示しています。
ホストの標準入力(A)
→ SYSCALL 2で1バイト読む
→ R0へ65を保存する
→ SYSCALL 0で文字として出力する
→ ホストの標準出力(Aと改行)今回分かったこと
今回の変更は、前回の「1文字読む → 表示する → 終了する」というプログラムの先頭に、MOVI R0, 62 と SYSCALL 0 を加えたことです。> も A と同じように文字コードで扱えるため、既存の出力処理をそのまま利用できました。
R0 は、最初にプロンプト用の62を保持し、入力を読んだあとはその文字の値を保持します。同じ SYSCALL 0 でも、実行時の R0 の値によって表示する文字が変わることを確認できました。
VM本体やアセンブラを変更しなくても、命令の組み合わせを変えると、VM上で動くプログラムの振る舞いを変えられます。今回はその小さな例として、入力の前に目印を表示できるようになりました。
次に進むこと
次の第7回では、入力した文字に応じて処理を切り替えます。例えば h なら案内の目印として H を表示し、q なら終了し、それ以外なら ? を表示する、最初のコマンド判定です。
そのためには、入力値が h と同じかを調べる「比較」と、その結果によって実行する場所を変える「条件分岐」が必要です。このVMでは、値を比較する CMP や、比較結果が等しいときに指定位置へ移る JZ などを使います。
前回までのVM本体には比較や分岐の処理がありますが、この段階の small-asm が変換できるのは MOVI、SYSCALL、HALT の3種類です。VMが命令を実行できることと、アセンブラがその命令をファイルへ書き出せることは、別々に用意する必要があります。次回は、文字の判定に必要な命令をアセンブラへ追加するところから進めます。
おまけ:マルチタスクと仮想メモリをコントで読む
マルチタスクや仮想メモリの仕組みを、散髪屋コントにたとえて整理した読み物です。「CPUの切り替え」や「ページテーブル」のイメージを、もう少し感覚的につかみたい方におすすめです。











