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

今回は、自作VMに文字を読み込む機能を追加します。目標は、入力した A をそのまま画面に表示して終了することです。このように入力をそのまま出力する動きを「エコー(echo)」と呼びます。
VM(仮想マシン)は、ここではC言語で作った小さなコンピューターの仕組みです。例えば「数値を保存する」「文字を表示する」という命令を読み、Cの処理でその動きを再現します。VMを動かしている実際のPC側を「ホスト」と呼びます。
前回は、VM本体の notes/vm.c と、VMで動かすプログラムの programs/hello.bin を分けました。.bin は、VM用の命令を数値の並びとして保存したバイナリファイルです。VM本体がファイルを読み込み、そこに書かれた命令を順番に実行します。
読み込み先はVMのメモリです。このVMでは、データを番号付きの場所に保存する配列 memory でメモリを表しています。例えば memory[0] は先頭の保存場所です。前回用意した処理で、ファイルの内容をここから順番に読み込めます。
notes/vm.c
VM本体
programs/hello.bin
VMで実行する外部プログラムnotes/vm.c(VM本体)と programs/hello.bin(VM上で実行する外部プログラム)を分離したことで、VM本体とプログラムの境界線はかなり明確になりました。
ただ、前回作成した hello.bin は、まだ「あらかじめ決められた文字を表示するだけ」のシンプルなプログラムでした。
レジスタは、VMが処理中の数値を一時的に置く場所です。例えば R0 というレジスタへ数値を入れる命令が MOVI R0, 65 です。65は、文字と数値の対応規則であるASCIIで A を表す文字コードです。続けて SYSCALL 0 を実行すると、その値を文字として表示できます。
SYSCALL(システムコール)は、このVMではホスト側の機能を呼び出すための命令です。後ろの番号で処理を選び、例えば 0 なら文字を出力します。今回は 2 を「文字を読み、R0へ入れる」処理の番号として追加します。これらは自作VM内で決めた番号です。
読み込み元はホストの「標準入力」です。これはプログラムが入力を受け取る窓口で、例えばターミナルで入力した文字や、別のコマンドから渡した文字を受け取ります。出力先の「標準出力」は、通常はターミナルの画面につながっています。コード中では、それぞれ stdin、stdout と表します。
今回読む量は1バイトです。1バイトは8個のビット(0か1を表す単位)からなり、0〜255の値を表せます。ASCIIの A は1バイトで表せるので、この記事ではこのような文字を「1文字」と呼びます。日本語の あ などは、文字の保存方式によって1文字に複数バイトを使うため、今回の対象に含めません。
この仕組みを使い、外部プログラム programs/echo-char.bin で「1バイト読む → 同じ文字を表示する → 終了する」という動きを実現します。
Day 24: 1バイトを読む処理 read_char を追加する
Day 25: echo-char.asm / echo-char.bin を用意して実行する今回の練習ノートとテストコードは、GitHubの handmade-vm-os に置いています。本文のCコードは要点の抜粋なので、実行時には対応するソースファイル全体を使います。コマンドは notes、tools、programs があるリポジトリの最上位ディレクトリで入力してください。
今回の区切り
今回の記事では、入力系 SYSCALL の最初の一歩だけを扱います。
まずは A を1文字読み、表示して終了するところまで進めます。何度も入力を受け付けたり、help のような単語を読み取って処理を選んだりする機能は、次の段階で追加します。
1文字だけ読む
読んだ文字をR0へ入れる
SYSCALL 0で同じ文字を表示するここで一度区切る理由は、入力処理が加わると、考えなければならないことが急に増えるからです。
- Enterをどう扱うか
- Backspaceをどう扱うか
- 1行分の入力をどこに保存するか
- Enterを待たずにキー入力を受け取る設定にするか
- EOF(入力の終わり)をどう伝えるか。例えば、入力元のファイルを最後まで読んだ場合です。
一度に扱う範囲を絞ると、「文字を読めたか」「R0へ入ったか」を順に確認できます。入力には、Cの標準ライブラリにある getchar() を使います。これは標準入力から1バイトを読む関数で、例えば A を読むと文字コード65を返します。
今回のゴール
まず完成時の動きを確認します。入力した A が表示され、VMの停止を知らせる CPU halted. が続けば成功です。この動きを、命令名で記述する「アセンブリ」という形式で表すと、次の3行になります。HALT はVMの命令実行を終了する命令で、実際のPCを停止するものではありません。
SYSCALL 2
SYSCALL 0
HALTSYSCALL 2 で1バイト読み、R0へ保存します。次の SYSCALL 0 はR0の下位8ビット、つまり32ビットの値のうち末尾の1バイトを文字として表示します。例えばR0に65が入っていれば A が表示されます。このVMの SYSCALL 0 は、文字の後に改行も出力します。
以下は実装とファイル生成が済んだ後に使う確認コマンドです。まだ echo-char.bin がなければ、後半の生成手順まで進んでから実行してください。cc はCのソースコードをPCで起動できる実行ファイルへ変換するコンパイラです。-o の後ろで保存先を指定し、ここでは一時ファイル用の /tmp にVMの実行ファイルを置きます。
cc notes/vm.c -o /tmp/handmade-vm
printf A | /tmp/handmade-vm programs/echo-char.binprintf A は改行を付けずに A を出力します。|(パイプ)は、左のコマンドの出力を右のコマンドの標準入力へ渡す記号です。この形ならVMへ渡るのは A の1バイトだけなので、入力と出力を追いやすくなります。端末から直接入力する場合は、通常は A の後にEnterを押すまでVMへ届きません。また、入力中の文字を端末自身が表示することもあるため、ここではパイプで確認します。
期待する出力は次の通りです。
A
CPU halted.SYSCALL 0 と SYSCALL 2 の役割
これまでの実装で、VMにはすでに SYSCALL 0 が組み込まれています。
SYSCALL 0 は、R0の下位8ビットをホスト端末に1文字として表示する(出力する)処理です。
SYSCALL 0:
VM内のR0
-> host stdout今回追加する SYSCALL 2 は、その逆向きに近い処理になります。
SYSCALL 2:
host stdin
-> VM内のR0つまり、SYSCALL 2 と SYSCALL 0 を続けて実行するだけで、最もシンプルな「エコー(入力された文字をそのまま返す)」プログラムが完成します。
host stdin
-> SYSCALL 2
-> R0
-> SYSCALL 0
-> host stdoutSYSCALL 2 の仕様カード
次に、入力命令が何を変更するかを「仕様カード」にまとめます。例えば A を読むとR0は65に変わり、VMのメモリには文字を書き込みません。この約束を先に決めてから、命令の数値表現とCコードを対応させます。
このVMの命令は1個が4バイト(32ビット)です。命令の中を用途別に区切った部分をフィールドと呼び、type は命令の大分類、op はその中の種類を表します。type=6、op=0 の組み合わせが SYSCALL です。imm(即値)は命令内に直接書いた数値で、SYSCALL 2 では処理番号の2が入ります。
PC(プログラムカウンタ)は、次に読む命令のメモリ上の番号、つまりアドレスを保持します。最初は0番地で、4バイトの命令を読むと4番地へ進みます。この読み取りを fetch と呼びます。条件フラグは「比較した値が等しい」などの結果を覚える情報ですが、今回の入力では変更しません。
カードの bits 31..28 は、32ビットを末尾の0番から数えたときの31〜28番、つまり先頭の4ビットです。0x は16進数を表す接頭辞で、例えば10進数の65は 0x41 と書きます。16進数の2桁で1バイトを表せます。
名前: SYSCALL read_char
分類: syscall
目的: ホストの標準入力から1 byte読み、VM内のR0へ入れる
命令長: 4 byte
bit配置:
bits 31..28: type = 6
bits 27..24: op = 0
bits 23..20: 今回は使わない(0にする)
bits 19..0 : syscall number = 2
読むレジスタ: なし
書くレジスタ: R0
読むメモリ: なし
書くメモリ: なし
PCの変化: fetch時に +4
条件フラグの変化: なし
エラー時: EOFなら "input EOF" を表示して停止
手作りテスト: SYSCALL 2 -> 0x60000002
成功条件: ホストの標準入力から読んだ1 byteがR0に入る番号は命令の下位20ビットに入ります。例えば番号2なら、SYSCALL を示す 0x60000000 に2を組み合わせて 0x60000002 になります。番号1は、すでに複数の文字をまとめて表示する処理に使っているため、入力には番号2を割り当てます。カードの「読むメモリ: なし」は、入力処理がVMのメモリからデータを読まないという意味です。命令そのものを読む処理は別にあります。
SYSCALL 0 -> 0x60000000
SYSCALL 1 -> 0x60000001
SYSCALL 2 -> 0x60000002SYSCALL 0 はR0の値を読み取ってホストの標準出力へ出す処理でしたが、今回の SYSCALL 2 はホストの標準入力から読んだ値をR0へ「書き込む」処理になります。このようにレジスタの扱い(読み/書き)の違いを仕様カードで明確に区別しておくことで、この後の実装で迷いにくくなります。
個別テストを書く
まず notes/024-syscall-read-char-test.c で入力処理だけを確認します。これまでの命令やファイル読み込み機能をまとめたVM本体を、ここでは「統合VM」と呼びます。そこへ追加する前に、小さなテスト用プログラムで、A を読むとR0が65になることを確かめます。
テストではファイルを使わず、メモリへ3命令を直接置きます。左端は命令を置くアドレス、中央は4バイトの内容、右端はその命令名です。1命令が4バイトなので、開始位置は0、4、8番地と進みます。
0x00000000: 60 00 00 02 SYSCALL 2
0x00000004: 60 00 00 00 SYSCALL 0
0x00000008: 01 00 00 00 HALTC言語のコードでは、次のように配列 memory へ直接バイトデータを書き込んでいきます。
memory[0x00000000] = 0x60; // SYSCALL 2
memory[0x00000001] = 0x00;
memory[0x00000002] = 0x00;
memory[0x00000003] = 0x02;
memory[0x00000004] = 0x60; // SYSCALL 0
memory[0x00000005] = 0x00;
memory[0x00000006] = 0x00;
memory[0x00000007] = 0x00;
memory[0x00000008] = 0x01; // HALT
memory[0x00000009] = 0x00;
memory[0x0000000A] = 0x00;
memory[0x0000000B] = 0x00;今回のテストコードでは MOVI 命令を使いません。R0へ格納する値は、SYSCALL 2 を介してホストの標準入力から読み込まれるためです。
fetch と decode の見方
入力命令を実行するには、まずメモリの4バイトを1つの命令値へ組み立てます。これが fetch です。続いて、その値から命令の種類と番号を取り出す decode(解読)を行います。最初の 60 00 00 02 から SYSCALL 2 と判断できれば、入力処理へ進めます。
CではPCを変数 pc で表します。次のコードの uint32_t は符号なし32ビット整数の型です。<< はビットを左へずらす演算で、例えば先頭の 0x60 を24ビットずらすと 0x60000000 になります。各バイトを所定の位置へ移し、|(ビット単位のOR)で組み合わせます。ここでの | はCの演算子で、先ほどのコマンドのパイプとは用途が異なります。
uint32_t inst =
((uint32_t)memory[pc] << 24) |
((uint32_t)memory[pc + 1] << 16) |
((uint32_t)memory[pc + 2] << 8) |
((uint32_t)memory[pc + 3]);
pc += 4;最初に読み込まれる4バイトは、次の並びになっています。
60 00 00 02このように、上位のバイトから順に並べる方式をビッグエンディアン(big-endian)と呼びます。60 00 00 02 を左から読む順序のまま 0x60000002 にできるので、メモリの内容と命令値を見比べやすくなります。pc += 4 は、次に読む位置を4番地へ進める処理です。
decode では、先頭4ビットを type、続く4ビットを op、末尾20ビットを imm として取り出します。0x60000002 なら次の値になります。
type = 6
op = 0
imm = 2type = 6 かつ op = 0 であることから、この命令が SYSCALL であると判別できます。さらに imm = 2(システムコール番号2)を指しているため、今回新しく追加する read_char の処理へと分岐させます。
execute で getchar() を呼ぶ
命令の解読が済んだら、execute(実行)の段階です。ここで期待するのは、A を読めたらR0へ65を保存し、入力が終わっていたらメッセージを表示して停止する動きです。次の抜粋を、type == 6 かつ op == 0 と判定した中にある、番号別の分岐へ追加します。regs[0] はCで表したR0、running は命令実行を続けるかどうかを表す変数です。
} else if (imm == 2) {
int ch = getchar();
if (ch == EOF) {
printf("input EOF\n");
running = false;
} else {
regs[0] = (uint8_t)ch;
}
}ここで大事なのは、取得した文字 ch を char 型ではなく、int 型で受け取っている点です。getchar() は、正常に読み込めた文字のデータだけでなく、入力の終端を表す EOF(End Of File)も返します。
EOF は通常の1バイト文字(0〜255)とは重ならない特別な値(多くの環境では -1)として定義されているため、一度 int 型として受け取ってから EOF かどうかの判定を行う必要があります。
文字を読めた場合だけ、R0に値を保存します。uint8_t は0〜255を表す符号なし8ビット整数の型で、(uint8_t)ch はその型への変換です。EOFではないことを確かめてから変換すれば、入力終了と文字の値を混同せずに済みます。
regs[0] = (uint8_t)ch;例えば、テストをコンパイルした後に次のように A を渡します。コンパイルから実行までのコマンドは、次の節にまとめています。
printf A | /tmp/024-syscall-read-char-test「A」 のASCIIコードは 0x41 なので、SYSCALL 2 が実行された後のレジスタの状態は次のようになります。
R0 = 0x00000041SYSCALL 0 で同じ文字を表示する
R0へ保存できたら、次の SYSCALL 0 で同じ値を文字として表示します。Cの putchar() は1バイトを標準出力へ書き出す関数です。& 0xFF はビット単位のANDで、R0の下位8ビットだけを残します。例えば 0x00000041 なら 0x41 が残り、A として表示されます。2行目の '\n' は改行を表します。
putchar(regs[0] & 0xFF);
putchar('\n');現在のR0には、直前の SYSCALL 2 で読み込んだ値 0x41 が格納されています。そのため、SYSCALL 0 の処理によって画面に「A」が表示されます。
では、テスト全体をコンパイルして実行します。A と停止メッセージに続き、R0に期待した値が残っていれば test passed. を含む行が表示されます。
cc notes/024-syscall-read-char-test.c -o /tmp/024-syscall-read-char-test
printf A | /tmp/024-syscall-read-char-test実行すると、以下のような結果が得られます。
A
CPU halted.
SYSCALL read_char test passed.最初の A は出力処理、CPU halted. はHALTの実行、最後の行はR0の値の検査結果です。このテストは A が入ることを確認するため、別の文字を渡すと、その文字を表示できてもテスト結果は失敗になります。
EOF検出時は、いったんVM側で停止させる
例えば空のファイルを標準入力へ渡すと、getchar() は入力の終わりを示す EOF を返します。端末でまだ文字を入力していないだけなら、通常は入力を待ちます。「まだ入力されていない」と「入力が終わった」は別の状態です。なお、getchar() は読み取りエラーでも EOF を返しますが、今回の実装では両者を区別せず停止します。
EOFのときはR0へ文字を保存せず、input EOF を表示して running を false(偽)にします。命令を繰り返し実行するループが終わるため、後続の SYSCALL 0 や HALT には進みません。入力の終わりをVM上のプログラム自身が判定する仕組みは、今後の課題にします。
if (ch == EOF) {
printf("input EOF\n");
running = false;
}今後OS風のプログラムへ進むと、EOFやEnterをプログラム側で判定したくなりますが、今回は「1文字読める」という成功条件に集中します。
統合VMへ組み込む
個別テストで確認した入力処理を、notes/vm.c に追加します。ここでも、SYSCALLを扱う既存の処理内に番号2の分岐を加えます。統合VMでは、レジスタや実行中かどうかの状態を構造体というまとまりで管理しています。vm->regs[0] はその中のR0、vm->running は実行を続けるかどうかの値です。inst.imm には、解読済みの処理番号が入っています。
} else if (inst.imm == 2) {
int ch = getchar();
if (ch == EOF) {
printf("input EOF\n");
vm->running = false;
} else {
vm->regs[0] = (uint8_t)ch;
}
}統合VMへの組み込みにあたり、個別テストで使っていた regs[0] は vm->regs[0] に、running は vm->running に変わりますが、やっていることは同じです。
追加後は、既存の表示処理も確認します。この回のVMはファイル名を指定せずに起動すると、内蔵テストを実行し、A、VM flow complete.、CPU halted. の順に表示します。連載の先まで実装が進んだ版では、引数なしの起動動作が変わる場合があります。
cc notes/vm.c -o /tmp/handmade-vm
/tmp/handmade-vmA
VM flow complete.
CPU halted.次に、前回生成した programs/hello.bin を指定します。こちらは A と CPU halted. が表示されれば、ファイルの読み込みと文字出力が保たれています。
/tmp/handmade-vm programs/hello.binA
CPU halted.既存の機能やこれまでの動きがそのまま残っていることも確認できました。
echo-char.asm を作成する
入力処理をVM本体へ追加できたので、今度はVMへ読ませる命令をファイルにします。SYSCALL 2 のように命令名で書く形式をアセンブリと呼びます。次の3行をテキストファイル programs/echo-char.asm に保存します。
SYSCALL 2
SYSCALL 0
HALTアセンブリをVM用のバイト列へ変換するツールが「アセンブラ」です。前回用意した tools/small-asm.c は、例えば SYSCALL 2 を 60 00 00 02 へ変換できます。すでに SYSCALL の後ろの数値を命令へ埋め込めるので、アセンブラへの機能追加は必要ありません。番号2を読んだときに何をするかは、先ほどVM本体へ追加した処理が担当します。
まずCで書かれたアセンブラをコンパイルします。次に、その実行ファイルへ入力元の .asm と出力先の .bin を渡します。この変換作業を「アセンブル」と呼びます。3命令を変換できれば、assembled 3 instructions と表示されます。
cc tools/small-asm.c -o /tmp/small-asm
/tmp/small-asm programs/echo-char.asm programs/echo-char.bin実行すると、以下のように出力されます。
assembled 3 instructions to programs/echo-char.bin3命令はそれぞれ4バイトなので、生成したファイルは12バイトになるはずです。ファイルの内容を16進数で表示する xxd コマンドで、意図した並びになっているか確認します。例えば表示中の 6000 は、2バイトの 60 00 をまとめた表記です。
xxd programs/echo-char.bin00000000: 6000 0002 6000 0000 0100 0000 `...`.......左端の 00000000: はファイル先頭からの位置、中央がバイト列です。右端は文字として表示できる部分の参考表示なので、ここでは中央を4バイトずつ区切って読みます。次の3命令と一致すれば、入力・出力・停止の順で保存できています。
60 00 00 02 SYSCALL 2
60 00 00 00 SYSCALL 0
01 00 00 00 HALTこれで、入力に反応する最小プログラムを外部 .bin として用意できました。
echo-char.bin をVMで実行する
最後に、生成した programs/echo-char.bin をVMで実行します。A を渡して、同じ A と停止メッセージが表示されることを確認します。ファイルを開けない場合は、直前のアセンブルが成功したか、リポジトリの最上位ディレクトリから実行しているかを確認してください。
printf A | /tmp/handmade-vm programs/echo-char.bin出力結果は次の通りです。
A
CPU halted.前回動かした hello.bin では、MOVI R0, 65 を使ってあらかじめプログラム内に「A」という値を固定で埋め込んでいました。
それに対して今回の echo-char.bin は、SYSCALL 2 を介してホストの標準入力からその場で読み込んだ値をR0に格納しています。
hello.bin:
MOVI R0, 65
SYSCALL 0
HALT
echo-char.bin:
SYSCALL 2
SYSCALL 0
HALT画面に表示された結果はどちらも同じ「A」ですが、その意味は大きく異なります。
hello.binは、いつでも決まった固定値を表示するだけのプログラムです。echo-char.binは、実行時にホスト側から受け取った標準入力に応じて動くプログラムです。
さらに、直前のコマンドを printf B | /tmp/handmade-vm programs/echo-char.bin に変えると、表示も B に変わります。VM本体や echo-char.bin を作り直さずに出力が変わることが、入力に応じて動いている確認になります。
今回分かったこと
今回の作業で、VMは「決まった文字を表示するだけ」から、「ホストの標準入力から値を受け取って動く」段階へ進みました。
前回:
外部 .bin を読み込んで実行できる
今回:
外部 .bin がホストの標準入力に反応できる今回の SYSCALL は、VM内の命令からホストの入出力機能を利用する入口です。ここまでの処理を整理すると、次のようになります。
SYSCALL 0は、VM内のR0をホスト端末へ出力します。SYSCALL 1は、VM内のmemoryに保存した文字列をホスト端末へ出力します。ここでは、例えばH、iの文字コードの後ろに値0を置き、終わりの目印にする「0終端文字列」を使います。SYSCALL 2は、ホストの標準入力から1 byte読み、VM内のR0へ格納します。
入力をR0へ保存できるようになったので、今後は読んだ文字を比較し、その値によって実行する処理を選べます。例えば q なら終了する、といった入力に応じた動きへ進めます。
まとめ
今回は、Day 24 から Day 25 にかけて、入力系 SYSCALL の最初の一歩を追加しました。
SYSCALL 2の仕様を追加:- ホストの標準入力から1 byte読み、VM内の
R0へ入れる処理として定義しました。
- ホストの標準入力から1 byte読み、VM内の
- 個別テストを作成:
-
notes/024-syscall-read-char-test.cで、printf A | ...による1文字入力を確認しました。
-
- 統合VMへ実装:
-
notes/vm.cのSYSCALL処理にimm == 2の分岐を追加しました。
-
- 最小echoプログラムを作成:
-
programs/echo-char.asmからprograms/echo-char.binを生成し、VMで実行できることを確認しました。
-
次に進むこと
次回は、文字を読む前に > を表示します。このように「ここで入力できます」と知らせる目印をプロンプトと呼びます。入力前に目印が表示されることで、VMが文字を待っていることを画面から分かるようにします。
おまけ:マルチタスクと仮想メモリをコントで読む
マルチタスクや仮想メモリの仕組みを、散髪屋コントにたとえて整理した読み物です。「CPUの切り替え」や「ページテーブル」のイメージを、もう少し感覚的につかみたい方におすすめです。











