本記事の構成および論理分析にはAI(人工知能)を使用しています。情報の正確性は、システム管理者(UNIXユーザー)による手動検証済みです。
【低レイヤ入門】C言語で自作VMに外部バイナリローダーを追加して hello.bin を実行する | UNIX Cafe

前回は、小さな自作VMにADD、SUB、CMP、JUMP、JZ、JNZ、CALLI、RETといった命令を追加しました。
これにより、VMはただ上から順番に命令を処理するだけでなく、CMP の結果に応じた条件分岐(ジャンプ)や、CALLI と RET によるサブルーチン呼び出しができるまでに進化しました。
ただ、これまでは実行するプログラム(命令列)を notes/vm.c 中に直接書いていました。つまり、VMの本体プログラムと、その上で動かしたい命令列が同じファイルに混ざっている状態です。
そこで今回は、この設計から一歩進めて、VM本体はnotes/vm.cに残したまま、実行するプログラムを外部の.binファイルとして読み込めるように、バイナリローダーを実装していきます。
Day 21: 外部バイナリローダー
Day 22: hello.bin 作成ツール
Day 23: small assembler今回の練習ノートとテストコードは、GitHubの handmade-vm-os に置いています。
今回のゴール
今回のゴールは、VM本体と実行するプログラムを分けることです。最終的には自作OSや自作シェルを外部ファイルとして読み込ませたいので、まずは一番小さいhello.binから始めます。
Cコード内のテストプログラム
-> 外部 .bin ファイル
-> 最小アセンブリから .bin を生成するこの段階では、まだ自作OSは起動しません。いきなり大きなプログラムを読み込むと、うまく動かなかったときに原因を切り分けにくくなるからです。
そこで今回は、次の3命令だけで作った小さなhello.binを外から読み込みます。
MOVI R0, 65
SYSCALL 0
HALTこのプログラムが正しく読めていれば、R0に入れた65が文字Aとして表示され、そのあとVMが停止します。
A
CPU halted.なぜ外部バイナリローダーに進むのか
これまでは、VMで実行したい命令をnotes/vm.cに直接書いていました。
例えば、統合VMではload_test_program()の中で、memoryに命令を書き込んでいます。
write_inst(vm, 0x00000000, 0x40100080); // MOVI R1, 0x80
write_inst(vm, 0x00000004, 0x40000041); // MOVI R0, 65
write_inst(vm, 0x00000008, 0x31100000); // STB [R1], R0
write_inst(vm, 0x0000000C, 0x30010000); // LDB R0, [R1]
write_inst(vm, 0x00000010, 0x60000000); // SYSCALL 0この方法は、命令の動きを1つずつ確認する段階では分かりやすいです。C言語のコード上で「どのアドレスに、何の命令を置いたか」がひと目で分かるからです。
「VM本体」と「プログラム」の分離
しかし、このままだと、実行するプログラムを変更するたびにVM本体のCコードを書き換えて再コンパイルしなければなりません。これでは「VM自体のバグを修正しているのか」それとも「VM上で動かすプログラムを修正しているのか」の境界が曖昧になってしまいます。
今後、自作OSや自作シェルへ進むためには、VM本体と実行プログラムの分離が必要です。「ホスト環境(PC)側にはVMという基盤があり、そのVMが外部ファイルを読み込んで実行する」という形に、少しずつ近づけます。
- これまで(密結合):
notes/vm.c(VM本体 + テストプログラム)
- 今回から(疎結合):
notes/vm.c(VM本体。引数なしでは内蔵テストも実行)programs/hello.bin(実行プログラムを外部化)
この分離ができると、./vm programs/hello.binのように、VMへプログラムファイルを渡せるようになります。これは、あとでos.binやshell.binのようなファイルを読み込むための最初の足場になります。
外部バイナリローダーの仕様
まずは、今回実装する外部バイナリローダーの仕様を小さく定義します。ここでは「ファイルを読む」「memoryへ置く」「PC=0から実行する」だけに絞ります。
名前: 外部バイナリローダー
分類: VM起動機能
目的: 外部ファイルをVMのmemory[0]から読み込んで実行する
命令長: なし。命令ではなくVM起動機能
読むレジスタ: なし
書くレジスタ: なし
読むメモリ: なし
書くメモリ: memory[0] から読み込んだbyte数分
PCの変化: 読み込み後、PC=0から通常通りfetchする
エラー時: ファイルを開けない、読めない、大きすぎる場合は終了する
成功条件: programs/hello.bin を実行して A と CPU halted. が表示されるここで大事なのは、このローダーはVMの命令(オプコード)ではなく、VMを起動するための「ホスト側の機能」だということです。
MOVIやJUMPのように、VMの中でfetchされる命令ではなく、VMが動き出す前に、ホスト(PC)側のC言語プログラムが事前にファイルを読み込み、仮想メモリ上にデータを配置するための処理です。
program.bin を memory[0] から読み込む
実装では、外部ファイルを読み込むためにload_program_file()という関数を追加しました。この関数は、指定されたファイルを開き、その中身(バイナリデータ)をVMの仮想メモリへ直接展開します。
static bool load_program_file(VM *vm, const char *path) {
FILE *file = fopen(path, "rb");
if (file == NULL) {
perror(path);
return false;
}
size_t bytes_read = fread(vm->memory, 1, MEMORY_SIZE, file);
if (ferror(file)) {
perror(path);
fclose(file);
return false;
}
if (bytes_read == MEMORY_SIZE && fgetc(file) != EOF) {
fprintf(stderr, "program too large: %s\n", path);
fclose(file);
return false;
}
fclose(file);
return true;
}fopen(path, "rb")で、ファイルをバイナリとして開きます。ここで"rb"を使うのは、テキストではなくbyte列そのものとして読みたいからです。
そのあと、fread()でVMのmemoryへ読み込みます。今回のVMではmemoryが1MBなので、最大でそのサイズまで読み込めるようにしています。
size_t bytes_read = fread(vm->memory, 1, MEMORY_SIZE, file);読み込み先はvm->memory(配列の先頭アドレス)です。つまり、外部ファイルの先頭1バイト目が memory[0] に入り、2バイト目が memory[1]、3バイト目が memory[2]……という順に、そのままメモリへ展開されます。
VMのPCは、これまで通り0から始めます。PCは「次に読む命令の場所」を表すので、memory[0]に置いた最初の命令から実行が始まります。
vm.pc = 0x00000000; // プログラムカウンタを0に
vm.sp = 0x00100000; // スタックポインタを設定
vm.running = true; // 実行フラグを立てるPC(プログラムカウンタ)は「次に実行する命令のアドレス」を指すレジスタです。初期値が 0 ということは、VMは起動した瞬間に memory[0] を見に行きます。
すると、最初のフェッチ(命令の取り出し)から実行までは、次のような一本の流れでつながります。
データの流れ(ロードから実行まで)
program.binの先頭4バイトfreadによってmemory[0]〜memory[3]へ配置されるPC = 0なので、最初のfetch()がこの4バイト(32bit命令)を読み出すdecode()(命令の解釈)execute()(命令の実行)
ここがつながると、「外部ファイルを読み込む処理」と「CPUが命令をフェッチする処理」が、同じ memory の上でつながって見えます。
ファイルを読んだあとに特別な実行方法を用意するのではなく、これまで作り込んできた「いつもの fetch ループ」が、そのまま外部プログラムを動かし始めます。
引数ありなら外部ファイル、引数なしなら内蔵テスト
main() 関数側では、コマンドライン引数(argc, argv)の数に応じて「外部ファイルを動かすか」「内蔵のテストプログラムを動かすか」の処理を条件分岐させます。
if (argc > 2) {
fprintf(stderr, "usage: %s [program.bin]\n", argv[0]);
return 1;
}
vm.pc = 0x00000000;
vm.sp = 0x00100000;
vm.running = true;
if (argc == 2) {
if (!load_program_file(&vm, argv[1])) {
return 1;
}
} else {
load_test_program(&vm);
}
run(&vm);1. 引数なし:内蔵テストプログラムの実行
引数なしで実行した場合は、これまで通り内蔵テストプログラムを実行します。これまで作ってきた統合VMの確認方法を残しておくためです。
cc notes/vm.c -o /tmp/handmade-vm
/tmp/handmade-vm2. 引数あり:外部ファイルの読み込みと実行
引数ありで実行した場合は、指定したファイルをmemory[0]から読み込みます。次の例では、programs/hello.binがVMのメモリ先頭に配置されます。
/tmp/handmade-vm programs/hello.bin変わったのは「命令をメモリに置く方法」だけ
ここで注目したいのは、VM本体の fetch、decode、execute ループには一切手を加えていないという点です。外部ファイルから読み込まれたバイト列も、メモリ上に配置されてしまえば、VMにとっては「ただのいつもの命令列」として扱えるからです。
変わったのは、実行前にメモリへ命令を配置するアプローチだけです。C言語のコード上で write_inst() を1行ずつ手書きする代わりに、ファイルから読み込むようになりました。
hello.bin の中身を覗いてみる
今回読み込む hello.bin は、非常にシンプルなプログラムです。まずは「1文字を表示して終了する」という最小限の処理から始めましょう。
アセンブリ言語で書くと、以下の3行になります。
MOVI R0, 65 ; R0レジスタに 'A' の文字コード(65)を代入
SYSCALL 0 ; R0の文字を出力
HALT ; VMを停止MOVI R0, 65
R0レジスタに文字Aのアスキーコードである65を格納します。ここではR0をシステムコールの引数として扱っています。SYSCALL 0
R0の下位1バイトを文字として標準出力に表示します。これにより、直前で入れた65が画面にAとして出力されます。HALT
VMの実行を停止(ホルト)します。この3命令だけであれば、万が一動かなかったときも原因の切り分けが容易です。
アセンブリから「マシン語(バイナリ)」への変換
この3つの命令は、VMの内部ではすべて 32bit(4バイト)の数値(マシン語) として扱われます。アセンブリ表記と、対応する命令値(16進数)を並べると次のようになります。
MOVI R0, 65 -> 0x40000041
SYSCALL 0 -> 0x60000000
HALT -> 0x010000001命令が4バイトなので、今回の hello.bin は3命令で 合計12バイト という極小のファイルになります。
エンディアンとファイル上の並び
この自作VMは、32bit命令を ビッグエンディアン(Big-Endian) で読み込みます。そのため、ファイル上のバイト列(データが並ぶ順番)も、16進数の表記通りに並べる必要があります。
0x40000041 -> 40 00 00 41
0x60000000 -> 60 00 00 00
0x01000000 -> 01 00 00 00実際にxxdで見ると、次のようになります。
xxd programs/hello.bin00000000: 4000 0041 6000 0000 0100 0000 @..A`.......VMがこのバイナリを解釈すると?
VMが起動し、メモリの先頭(PC=0)から最初の4バイト 40 00 00 41 をフェッチすると、値はそのまま 0x40000041 になります。
これをVMのデコーダに通すと、以下のように分解されます。
0x40000041
type = 4
op = 0
rd = 0
imm = 65これはVMにとって、まさに今まで内蔵テストで動かしてきた MOVI R0, 65 そのもの です。「外部のバイナリファイルから読んだデータ」であっても、メモリに載せてしまえば、VMは同じ命令として扱えます。
hello.bin をVMで実行する
それでは、作成したprograms/hello.binをVMに渡して実行してみましょう。
cc notes/vm.c -o /tmp/handmade-vm
/tmp/handmade-vm programs/hello.bin実行すると、hello.binの先頭から順に命令が読み込まれます。画面に「A」と表示された後、VMが停止します。
A
CPU halted.これで、VM本体の中に命令を直接書かなくても、外部の.binを読み込んで実行できるようになりました。ここから先は、VM本体を育てる作業と、「VM上で動かすプログラムの作成」を切り離して進めることができます。
固定の hello.bin 作成ツールを作る
次に、programs/hello.bin をいつでも再生成できるようにするための小さなツールを作ります。
最初から本格的なアセンブラを作るのではなく、まずはすでに判明している以下3つの命令値をファイルへ書き出すだけのシンプルなものにします。
0x40000041
0x60000000
0x01000000このツールも、先に小さな仕様を決めてから作ります。VM命令ではなく、ホスト側で動く開発補助ツールとして扱います。
名前: hello.bin 作成ツール
分類: 開発補助ツール
目的: 最小プログラムを表す3つの32bit命令を、big-endianの外部バイナリとして書き出す
命令長: なし。命令ではなくホスト側の作成ツール
読むレジスタ: なし
書くレジスタ: なし
読むメモリ: なし
書くメモリ: なし
PCの変化: なし
条件フラグの変化: なし
エラー時: 出力ファイルを開けない、書けない場合は終了する
成功条件: programs/hello.bin をVMで実行すると A と CPU halted. が表示される32bitの値をビッグエンディアンで書き出す関数は、以下のように実装します。
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);
}例えば 0x40000041 を書き出す場合、最上位バイト(MSB)から順に処理されるため、ファイル上には 40 00 00 41 というバイト列で並びます。
この並び順は、VMが命令をフェッチ(fetch)する際の読み込み順と一致させています。
static uint32_t read_u32_be(const uint8_t *memory, uint32_t address) {
return ((uint32_t)memory[address] << 24) |
((uint32_t)memory[address + 1] << 16) |
((uint32_t)memory[address + 2] << 8) |
((uint32_t)memory[address + 3]);
}書き込み側(ツール)と読み込み側(VM)の双方でビッグエンディアンに統一しておくことで、ファイル上のバイト列とVM内の命令値が直感的に対応付けられるようになります。これにより、xxd コマンドなどで確認したバイト列を、そのままフェッチ時の命令値として解釈できるようになります。
それでは、このツールをコンパイルして実行し、生成された hello.bin の中身を xxd で確認してみましょう。
cc tools/write-hello-bin.c -o /tmp/write-hello-bin
/tmp/write-hello-bin
xxd programs/hello.bin実行結果は以下のようになり、先ほど確認した hello.bin と全く同じバイト列が生成されていることが分かります。
wrote programs/hello.bin
00000000: 4000 0041 6000 0000 0100 0000 @..A`.......small assembler に進む
先ほどの write-hello-bin.c では、C言語のソースコード内に直接命令値を書き込んでいました。つまり、0x40000041 といった数値を、人間が事前に計算しておく必要があったわけです。
最初の動作確認としてはこれで十分ですが、もう少しプログラム開発らしく進めるのであれば、以下のようなアセンブリ表記から自動で .bin ファイルを生成したくなります。
MOVI R0, 65
SYSCALL 0
HALTそこで Day 23 では、この変換を行うための小さなアセンブラ tools/small-asm.c を作成しました。
最初にサポートする命令は、以下の3種類のみに絞っています。
MOVI Rn, imm
SYSCALL imm
HALTこの段階では、ラベルや式、分岐命令、複数ファイルの読み込みといった複雑な機能は扱いません。最初から機能を増やしすぎると、アセンブラの実装自体が肥大化し、本質的な部分が見えなくなってしまうからです。
まずは、programs/hello.asm から hello.bin 相当のバイナリを正しく生成できれば十分です。ここでは、アセンブリ表記と命令値の対応を確認することを優先します。
MOVI を命令値へ変換する
MOVI R0, 65 という命令は、内部的には type=4、op=0、rd=0、imm=65(0x41)という構成になります。アセンブリ表記では R0 と書きますが、実際の命令(バイナリ)の中では rd という フィールド(ビット域) に割り当てられる 0 という値になります。
命令値は次の式で作れます。type=4とop=0の部分を先に置き、そこへレジスタ番号と即値を重ねています。
*inst = 0x40000000 | ((uint32_t)reg << 20) | imm;ここで reg が 0(R0)、imm が 65(0x41)であれば、計算結果は 0x40000041 になります。これは、先ほど hello.bin の中身として確認した命令値と完全に一致します。
0x40000000 | (0 << 20) | 0x41
= 0x40000041SYSCALL と HALT を変換する
SYSCALL imm は、内部的に type=6、op=0 として扱います。
*inst = 0x60000000 | imm;例えば SYSCALL 0 の場合は imm=0 となるため、命令値はそのまま 0x60000000 になります。
一方、HALT は即値を持たない固定命令として特別に扱います。命令全体が 0x01000000 になったときにVMが停止する仕様です。
*inst = 0x01000000;この3つだけでも、hello.asmを.binに変換できます。小さいですが、「文字で書いた命令を、VMが読めるbyte列へ変換する」というアセンブラの入り口にはなっています。
hello.asm から .bin を作る
ソースファイル(programs/hello.asm)には、先ほど解説した3つの命令をそのまま記述しておきます。
MOVI R0, 65
SYSCALL 0
HALTこれを、先ほど実装した自作アセンブラ small-asm で変換(アセンブル)してみましょう。
cc tools/small-asm.c -o /tmp/small-asm
/tmp/small-asm programs/hello.asm /tmp/hello-small-asm.bin
xxd /tmp/hello-small-asm.bin正常に変換されると、以下のように3命令分のバイト列が出力されます。
assembled 3 instructions to /tmp/hello-small-asm.bin
00000000: 4000 0041 6000 0000 0100 0000 @..A`.......以前に作成した programs/hello.bin と全く同じバイト列になりました。つまり、テキストで書いた hello.asm から、VMが解読・実行できる外部バイナリを自力で生成できたことになります。
最後に、この生成した .bin ファイルをVMに渡して実行してみます。
cc notes/vm.c -o /tmp/handmade-vm
/tmp/handmade-vm /tmp/hello-small-asm.binアセンブラで生成したバイナリも、VMから見ればただのプログラムファイルに過ぎません。実行すると、これまでと同様に A を表示して正常に停止します。
A
CPU halted.ここで重要なのは、「VM本体は、そのバイナリが手書きなのか、ツールで出力されたのか、あるいはアセンブラで作られたのかを一切気にしない」 という点です。
VMの視点から見れば、それらはすべて「メモリ上に配置されたバイト列」でしかありません。生成の手法がどうあれ、最終的に全く同じバイト列にさえなっていれば、VMは完全に同じように処理します。
そのバイト列がVMの定義する命令フォーマットにさえ従っていれば、VMはいつも通りにフェッチ(fetch)、デコード(decode)、実行(execute)を進めます。
今回分かったこと
今回の開発における最大の成果は、「VM本体」と「実行するプログラム」を分けて扱えるようになったことです。
Day 20まで:
notes/vm.c の中に命令を書く
Day 21以降:
notes/vm.c はVM本体
programs/*.bin が実行するプログラム
tools/* が .bin を作る補助ツール外部バイナリを読み込めるようになると、自作OS、自作シェル、自作エディターへ進むための形が少し見えてきます。VM本体の外側に、VMで動かすプログラムを置けるようになったからです。
ただし、ここで一気に本格的なアセンブラへ進まないことも大事です。今はまだ、命令、byte列、memory、fetchの関係を確かめる段階です。
そのため今回は、次の3命令だけに絞りました。
MOVI R0, 65
SYSCALL 0
HALT開発のステップを小さく区切ることで、「メモリ」「バイト列」「32bit命令」「フェッチ」の相互関係が非常にクリアに見えてきます。
ここが直感的に理解できるようになると、この小さなVMにおける「ファイルを実行する」という行為の最小形が、少し具体的に見えてきます。
まとめ
今回は、Day 21 から Day 23 にかけて、「外部の .bin ファイルを読み込んで実行する」流れを作りました。
- 外部バイナリの読み込み: 外部の
.binファイルを、VM上のメモリの先頭(memory[0])から実行開始前に読み込める仕組みを実装しました。 - 命令構造の検証: 最小プログラムである
hello.binが、正確に3つの32bit命令のみで構築されていることをバイナリレベルで確認しました。 - 手動生成ツールの作成:
write-hello-bin.cを作成し、事前に計算した固定の命令値をビッグエンディアンで再生成できるようにしました。 - 最小アセンブラの実装:
small-asm.cを実装し、テキストで書かれた最小限のアセンブリコードから自動で.binをビルドできる環境を整えました。
次回予告:OSへの第一歩、入力系システムコールの追加
次回からは、さらに「OS風のプログラム」へと進化させるため、キーボードから1文字だけ入力を受け付ける入力系システムコール(SYSCALL)を新たに追加します。
最初のターゲットは、以下のようなインタラクティブな動作の実現です。
- キーボードから1文字読み込む
- 読み込んだ文字のデータをレジスタ
R0に格納する - 既存の
SYSCALL 0を呼び出し、R0の文字をそのまま画面にエコーバック(表示)する
これまではあらかじめ決められた値を表示するだけでしたが、次からは「ユーザーの入力に応じて動くプログラム」を作れるようになります。
ドリフの散髪屋コントで見切るマルチタスクと仮想メモリの正体
CPUは自分が騙されていることに気づかない!?「マルチタスクは高速な客のすり替え」「仮想メモリは回転床」。OSの裏で起きている超高速のドタバタ劇を覗けば、低レイヤの仕組みがスッキリ理解できます。ラストの「金だらい」の正体とは?








