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

前回は、小さな自作VMに「外から1文字を受け取る入口」を作りました。
それまでのプログラムは、MOVI R0, 65 のように、表示したい値をあらかじめ命令の中に書いていました。つまり、VMが表示している A は、プログラム内に固定で埋め込まれた値でした。
前回作った echo-char.bin では、この仕組みを変更しました。プログラムの中に A を直接書くのではなく、host側の標準入力から1 byteを受け取り、その値を表示するようにしています。
SYSCALL 2
SYSCALL 0
HALT最初の SYSCALL 2 が、host標準入力から1 byteを読みます。読み込んだ値は R0 に入ります。次の SYSCALL 0 は、その R0 の下位8bitを文字として表示します。
前回は、標準入力に A を渡して、次のように確認しました。
cc notes/vm.c -o /tmp/handmade-vm
printf A | /tmp/handmade-vm programs/echo-char.binこのときは、入力した A がそのまま表示されます。
A
CPU halted.今回は、この echo-char.bin の表示を少しだけ変更します。
新しいVM命令は追加しません。small-asm も拡張しません。これまで作った部品だけを使って、入力を読む前に > を表示します。
今回作る外部プログラムは、programs/prompt-echo.asm と programs/prompt-echo.bin です。
Day 26: prompt-echo.asm / prompt-echo.bin今回の練習ノートとコードは、GitHubの handmade-vm-os に置いています。
今回の区切り
今回の記事では、OSらしい画面の入口を、ごく小さく作っていきます。
ここで言う「OSらしさ」は、まだ大げさなものではありません。行入力やEnterキーの判定、Backspaceの処理は扱いませんし、コマンドループも作りません。
今回のゴールは、次の4つだけです。
>を表示する- host標準入力から1バイト読み込む
- 読み込んだ文字を表示する
- HALT(停止)する
これだけを見ると、とても小さな変化に思えるかもしれません。実際、今回追加する命令もわずか2つだけです。
ただ、この小さな変化には意味があります。前回の echo-char.bin は、入力された文字をそのまま返すだけでした。今回の prompt-echo.bin は、入力を受け付ける前に > を表示します。
- 第5回: 入力された1文字をそのまま表示する
- 第6回: 入力前に
>を表示してから、入力された1文字を表示する
見た目はまだ本物のシェルとは言えません。それでも、画面に > が先に表示されるだけで、VM上のプログラムが「ユーザーの入力を待っている」雰囲気に一歩近づきます。
ここから先は、VM本体に命令を追加するだけではなく、VMの上で動く外部プログラムも育てていきます。
今回のゴール
今回動かしたいプログラムは、次の5つの命令で構成されています。
MOVI R0, 62
SYSCALL 0
SYSCALL 2
SYSCALL 0
HALT使用している命令は、すべてこれまでに登場したものばかりです。
MOVI
SYSCALL 0
SYSCALL 2
HALTまず MOVI R0, 62 で、レジスタ R0 に > の文字コード(ASCIIコード: 62)を格納します。
続く SYSCALL 0 で、その > を画面に表示します。
そのあと SYSCALL 2 でhost標準入力から1バイトを読み、もう一度 SYSCALL 0 で読んだ文字を表示します。
動作確認では、前回と同じように printf A | ... の形で標準入力に A を渡します。
cc notes/vm.c -o /tmp/handmade-vm
printf A | /tmp/handmade-vm programs/prompt-echo.binこのプログラムが正しく動作すると、出力は次のようになります。
>
A
CPU halted.表示に関する注意点
ここで、少し注意するポイントがあります。
現在の SYSCALL 0 は、「1文字を表示した後に改行も出力する」仕様(実装)になっています。そのため、> と A は同じ行に並ばず、別々の行に表示されます。
本物のシェルのように、次のように同じ行に表示させる実装は、今回は扱いません。
> A今回はまず、「入力を受け付ける前に、プロンプト用の文字(>)が先に出力されること」を確認できればゴールです。
prompt echo のアセンブリを見る
今回の programs/prompt-echo.asm は、次の5つの命令で構成されています。
MOVI R0, 62
SYSCALL 0
SYSCALL 2
SYSCALL 0
HALTこの5命令の中では、レジスタ R0 の役割が途中で変わるのがポイントです。
最初は > を表示するための値を持ち、途中からは標準入力から読み込んだ文字を保持します。この役割の変化を意識しながら、1つずつ命令を追っていきます。
MOVI R0, 62この命令で、R0 に数値 62 を代入します。62 は、文字 > のASCIIコードです。つまり、この時点で R0 には「画面に表示したいプロンプト文字」が入ったことになります。
この段階では、まだユーザーからの入力は読み込んでいません。まずはプロンプトとして使う文字をレジスタに準備しているだけです。
SYSCALL 0SYSCALL 0 は、R0 の下位8ビットを文字としてホストの標準出力へ表示するシステムコールです。
ここでは直前の命令で R0 に 62 を入れているため、画面に > が表示されます。
これで画面にプロンプト用の > が出ました。ここから、いよいよ入力を読み込みます。
SYSCALL 2SYSCALL 2 は、ホストの標準入力から1バイトを読み込み、その値を R0 に格納します。
たとえば、次のように実行した場合を考えてみます。
printf A | /tmp/handmade-vm programs/prompt-echo.binprintf A から渡された文字 A を、この SYSCALL 2 が読み込みます。A のASCIIコードは 0x41 なので、この命令が実行された後、R0 の中身は 0x41 に上書きされます。
つまり、ここで R0 の中身は > の文字コードではなくなり、入力された A の文字コードへと役割が切り替わったことになります。
SYSCALL 0その状態でもう一度 SYSCALL 0 を呼び出します。
今度の R0 には、先ほど標準入力から読み込んだ A の値が入っているため、ここでは A が表示されます。
HALT最後に HALT を実行し、VMを安全に停止します。
全体の流れのまとめ
処理の流れを整理すると、次のようになります。
MOVI R0, 62\(\rightarrow \)R0に>の文字コードを入れるSYSCALL 0\(\rightarrow \)>を画面に表示するSYSCALL 2\(\rightarrow \) ホストの標準入力から1バイト読み込み、R0へ入れる(上書き)SYSCALL 0\(\rightarrow \) 読み込んだ文字(R0)を画面に表示するHALT\(\rightarrow \) VMを停止する
前回の echo-char.asm と比べると、違いは最初の2命令があるかどうかだけです。
前回:
SYSCALL 2
SYSCALL 0
HALT今回:
MOVI R0, 62
SYSCALL 0
SYSCALL 2
SYSCALL 0
HALT先頭に MOVI R0, 62 と SYSCALL 0 を足しただけで、入力の前に > を表示できるようになりました。
重要なのは、VM本体に新しい機能を足したわけではないという点です。すでに用意してある既存の命令を組み合わせたり並べ替えたりするだけで、外部プログラムの振る舞いをここまで変えられるのが、ソフトウェアのおもしろいところです。
> のASCIIコードを確認する
今回の重要なポイントの1つは、プロンプトの > も「ただの数値」として扱うという点です。
文字 > のASCIIコードは、10進数で 62、16進数で 0x3E です。
| 項目 | 値 |
|---|---|
| 対象の文字 (character) | > |
| 10進数 (decimal) | 62 |
| 16進数 (hex) | 0x3E |
このVMでは、SYSCALL 0 を呼び出すと「R0 の下位8ビットを文字として表示する」仕様になっています。
そのため、> を表示したいときは、まず MOVI R0, 62 で R0 に 62 を格納し、その後に SYSCALL 0 を呼び出します。
MOVI R0, 62
SYSCALL 0ちなみに、現在の統合VMのC言語ソースコード側では、SYSCALL 0 を次のように処理しています。
putchar(vm->regs[0] & 0xFF);
putchar('\n');1行目で R0 の下位8ビットを文字として出力し、2行目で改行(\n)を出力しています。
そのため、R0 に 62 が入っている状態で SYSCALL 0 を呼ぶと、次のように改行付きで表示されるわけです。
>この「1文字ごとに改行されてしまう」という挙動は、本物のシェルっぽくしたくなったタイミングで、変更したくなる部分かもしれません。
ただ、今の時点では、R0 に入れた数値を文字として表示できること、そしてその文字をプロンプトとして使えることを確認しています。表示の細かい見た目を整えるのは、それからでも間に合います。
命令値とバイト列を確認する
次に、prompt-echo.asm が具体的にどのような命令値(マシン語)に変換されるかを確認していきましょう。
対象となるアセンブリは、先ほどの5つの命令です。
MOVI R0, 62
SYSCALL 0
SYSCALL 2
SYSCALL 0
HALTそれぞれの命令をマシン語に変換すると、次のような 32ビット(4バイト)の数値になります。
MOVI R0, 62 -> 0x4000003E(※62 は16進数で 0x3E)
SYSCALL 0 -> 0x60000000
SYSCALL 2 -> 0x60000002
SYSCALL 0 -> 0x60000000
HALT -> 0x01000000この自作VMの命令長は、すべて 32ビット(4バイト)固定 です。
また、命令をメモリに配置する際や、外部のバイナリファイル(.bin)として書き出す際は、すべて ビッグエンディアン(Big-Endian) で統一しています。そのため、先ほどの16進数の値がそのままの順番でバイト列として並びます。
| アセンブリ命令 | 16進数表記 | メモリ上のバイト列 |
|---|---|---|
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 |
5つの命令がそれぞれ4バイトずつなので、完成するバイナリファイル(programs/prompt-echo.bin)のファイルサイズは、合計で 20バイト になります。
今回のプログラムには、まだラベル(ジャンプ先)もなければ、条件分岐も、埋め込まれた文字列データ(データセクション)もありません。
上から順に1つずつ実行していくだけの、非常に素直でシンプルな命令列です。だからこそ、前回作成した最低限の機能しか持たない小さなアセンブラでも、問題なくバイナリへと変換できます。
small-asm で .bin にする
Day 23 では、最小限のアセンブリ表記をバイナリ(.bin)に変換するツールとして、tools/small-asm.c を作成しました。
現在の small-asm が解釈できる命令は、まだ以下のようにごくわずかです。
MOVI Rn, imm
SYSCALL imm
HALTしかし、今回の prompt-echo.asm で使っている命令は、ちょうどこの3種類だけです。
MOVI R0, 62
SYSCALL 0
SYSCALL 2
SYSCALL 0
HALTそのため、今回は small-asm 自体を拡張する必要はありません。今ある機能のままで、十分に今回の .bin ファイルを生成できます。
リポジトリのルートディレクトリで次のコマンドを実行し、アセンブルします。
cc tools/small-asm.c -o /tmp/small-asm
/tmp/small-asm programs/prompt-echo.asm programs/prompt-echo.binアセンブルが成功すると、次のようなメッセージが表示されます。
assembled 5 instructions to programs/prompt-echo.bin続いて、生成されたバイナリファイルの中身を xxd コマンドで確認してみます。
xxd programs/prompt-echo.bin生成されたファイルの中身は、次のように確認できます。
00000000: 4000 003e 6000 0000 6000 0002 6000 0000 @..>`...`...`...
00000010: 0100 0000 ....4バイト(32ビット)単位で切り分けて見ると、正しく次の5つの命令が並んでいることが分かります。
40 00 00 3E MOVI R0, 62
60 00 00 00 SYSCALL 0
60 00 00 02 SYSCALL 2
60 00 00 00 SYSCALL 0
01 00 00 00 HALTアセンブリ言語で記述した5つの命令が、意図した通りの順番で、20バイトの外部バイナリファイルになりました。
VMで実行する
それでは、生成した programs/prompt-echo.bin を実際に自作VMで動かしてみます。
cc notes/vm.c -o /tmp/handmade-vm
printf A | /tmp/handmade-vm programs/prompt-echo.bin実行すると、画面には次のように出力されます。
>
A
CPU halted.狙い通り、最初に > が表示され、その後に A が表示されました。
内部でどのような実行が行われているのか、一連の流れを追ってみます。
- プログラムの読み込み: VMが
programs/prompt-echo.binをmemory[0]からロードする - 実行開始: プログラムカウンタ(
PC)が0からフェッチ(命令の読み込み)を始める MOVI R0, 62: レジスタR0に>のASCIIコードを格納するSYSCALL 0: 画面に>を表示するSYSCALL 2: ホストの標準入力からAを読み込む- 値の上書き: レジスタ
R0にAの文字コード(0x41)が格納される SYSCALL 0:R0の下位8ビット(A)を画面に表示するHALT: VMが安全に停止する
ここで注目したいのは、画面に表示された A はプログラムの中に固定で書いた値(即値)ではないという点です。
printf A | ... によってホストの標準入力から渡されたデータを、SYSCALL 2 が実行時に読み込み、レジスタ R0 にセットしています。そして、続く SYSCALL 0 がその同じ R0 を参照して画面に出力しています。
データの流れをシンプルに図に表すと、次のようになります。
host stdin
-> SYSCALL 2
-> R0
-> SYSCALL 0
-> host stdout前回の echo-char.bin では、この「入力された文字をそのまま流して表示する」という流れだけを検証しました。
今回は、その処理が走る手前に、次の「プロンプト表示」の流れを付け足した形になります。
MOVI R0, 62
-> SYSCALL 0
-> `>` を表示この2つの流れが綺麗に1本の命令列として繋がったことで、「入力を読む前にプロンプトを提示する」という、まさにOSのシェルのような画面の入り口が完成しました。
今回は新しい命令を追加していない
今回の作業では、VM本体に新しい命令を一切追加していません。notes/vm.c の命令セットはそのままの状態です。
使ったのは、すでに実装済みの以下の命令だけです。
MOVI
SYSCALL 0
SYSCALL 2
HALT新しい機能を追加していないという意味では地味に思えるかもしれませんが、ここから先の開発を進める上で、これは非常に重要なマイルストーンになります。
第1回から第5回までは、どちらかと言えば「VM本体を育てること」に大きな比重が置かれていました。
命令を追加する
fetch / decode / execute を確認する
外部バイナリを読み込む
SYSCALLを追加するそれに対して今回は、VM本体の手入れではなく、「VMの上で動く外部プログラム」を少しだけ育てました。
echo-char.bin から prompt-echo.bin へと進化したことで、VM上で動くプログラムが、「OSの入り口」らしい姿になっています。
もちろん、まだ本物のOSやシェルには程遠い段階です。起動メッセージもなければ、入力されたコマンドの判定もありません。プロンプトへ戻るためのループ処理も、複数文字を受け付ける行入力すらもありません。
それでも確実に、次の形に一歩近づいています。
[自作CPU / VM本体の構築]・・・第1回〜第5回のメイン
↓
[VM上で動くOS風プログラムの開発]・・・★今ここ
↓
[本格的な shell風コマンドループの実装]・・・次のゴールこれからも画面上の目に見える小さな変化を楽しみながら、一歩一歩着実に完成に近づけたいと思います。
次に足りないもの
今回実装した prompt-echo は、「1回入力を受け付けて、1回表示したらそこで終わり(HALT)」という非常にシンプルなものでした。
これが動くと、次に欲しくなるのは「入力された文字(コマンド)に応じて処理を切り替える機能」です。
たとえば、次のような挙動です。
hが入力されたら →helpと表示するqが入力されたら →quit(終了)する- それ以外の文字なら →
?と表示してエラーを示す
これができるようになれば、見た目も振る舞いも、かなり本物のシェルの入り口に近づきます。
ただし、これを実現するためにはプログラムの中に「条件比較」と「条件分岐(ジャンプ)」を取り入れなければなりません。
これまでの実装で、VM本体(C言語側)には、すでに以下の命令が追加してあります。
MOV
CMP
JUMP
JZ
JNZ自作CPU(VM)としては、すでに文字コードをレジスタ値として比較し、その結果に応じてジャンプする準備はできている状態です。
ただし、現在手元にあるアセンブラ(tools/small-asm.c)は、まだ次の3種類の命令しか解釈できません。
MOVI
SYSCALL
HALTつまり、VM本体は CMP や JZ を実行できる能力を持っているのに、外部の .asm ファイルからそれらの命令を簡単に書き出す手段がまだない状態です。
そこで次のステップとして、この「1文字コマンドの判定」に必要となる命令に絞って、small-asm へと追加していくのが良さそうです。
最初からすべての命令をサポートする本格的なアセンブラを一気に作ろうとすると大変ですが、「必要になった命令だけを、その都度アセンブラに足していく」ようにするとスムーズに進めることができます。
今回分かったこと
今回は「Day 26」として、prompt-echo.asm の作成と prompt-echo.bin へのアセンブル、そしてVMでの実行を行いました。
今回の開発を通じて分かったことを整理します。
>はASCIIコード62/0x3Eとして扱えます。MOVI R0, 62とSYSCALL 0で、>を表示できます。SYSCALL 2はhost標準入力から1 byteを読み、R0へ入れます。SYSCALL 2のあとにSYSCALL 0を呼ぶと、読んだ文字をそのまま表示できます。- 今回は新しいVM命令を追加せず、既存命令の組み合わせだけで外部プログラムを少し育てました。
まとめ
今回は、入力を受け付ける前に > を表示してから1文字echoする prompt-echo サンプルを作りました。
プログラムは、次の5つの命令です。
MOVI R0, 62
SYSCALL 0
SYSCALL 2
SYSCALL 0
HALTMOVI R0, 62 で > の文字コードを入れ、SYSCALL 0 で表示します。
そのあと SYSCALL 2 で1 byte読み、もう一度 SYSCALL 0 で表示します。
実際の画面出力は次のような形になります。
>
A
CPU halted.小さな変化ですが、VM上で動く外部プログラムが少しOSの入口らしくなりました。
次は、入力された1文字を見て、処理を分ける準備へ進みます。
そのために、small-asm が扱える命令を必要な分だけ増やしていきます。
候補は次の命令です。
MOV
CMP
JUMP
JZ
JNZこれができると、h ならhelp、q なら終了、それ以外なら ? を表示する、という最初のコマンド判定へ進めます。
マルチタスクと仮想メモリの正体:ドリフの散髪屋コントで見るOSの舞台裏
CPUは自分が騙されていることに気づかない!?「マルチタスクは高速な客のすり替え」「仮想メモリは回転床」。OSの裏で起きている超高速のドタバタ劇を覗けば、低レイヤの仕組みがスッキリ理解できます。ラストの「金だらい」の正体とは?








