【低レイヤ入門】C言語で自作VMに入力系SYSCALLを追加して、1文字echoを動かす | UNIX Cafe

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

System Note $ cat /proc/ai-disclosure

本記事の構成および論理分析にはAI(人工知能)を使用しています。情報の正確性は、システム管理者(UNIXユーザー)による手動検証済みです。

【低レイヤ入門】C言語で自作VMに入力系SYSCALLを追加して、1文字echoを動かす | UNIX Cafe

前回は、小さな自作VMを、少しだけ「本物のVM」らしい形に近づけました。

これまでは、実行対象の命令列を notes/vm.c 内に直接記述していました。単一の命令動作を確認する段階ではこの手法が有用でしたが、命令列を変更するたびにVM自体のソースコードを書き換える必要が生じます。その結果、「VM本体の実装」と「VM上で動作するプログラム」の分離が曖昧になるという問題がありました。

この課題を解決するため、前回は実行プログラムを programs/hello.bin として外部ファイル化しました。VM本体(notes/vm.c)のロジックはそのままに、外部から指定された .bin ファイルを memory[0](メモリの先頭)へ読み込んで実行する設計へと変更しています。

notes/vm.c
  VM本体

programs/hello.bin
  VMで実行する外部プログラム

notes/vm.c(VM本体)と programs/hello.bin(VM上で実行する外部プログラム)を分離したことで、VM本体とプログラムの境界線はかなり明確になりました。

ただ、前回作成した hello.bin は、まだ「あらかじめ決められた文字を表示するだけ」のシンプルなプログラムでした。

具体的には、MOVI R0, 65 によってレジスタ R0 に文字コード ‘A’ を格納し、SYSCALL 0 で出力する処理です。この構成では、出力する値がプログラム内に固定値として埋め込まれていました。

今回は、この入力データのソースを動的に変更します。
プログラム内の固定値ではなく、ホスト側の標準入力から1バイトを読み込み、その値をVM内のレジスタ R0 に代入します。この入力処理の受入口として、新たに SYSCALL 2 を定義します。

この記事のゴールは、外部プログラム programs/echo-char.bin を作成して動作させることです。標準入力から受け取った1バイトをそのまま画面へ出力する、ミニマムなエコープログラムを実装していきます。ここで扱う「1文字」は、A のようなASCIIの1バイト文字を想定します。

Day 24: SYSCALL read_char
Day 25: echo-char.asm / echo-char.bin

今回の練習ノートとテストコードは、GitHubの handmade-vm-os に置いています。

目次

今回の区切り

今回の記事では、入力系 SYSCALL の最初の一歩だけを扱います。

目標は、行入力の実装でも、シェルの作成でも、コマンドループの構築でもありません。まずは「1文字だけ」を対象にします。

1文字だけ読む
読んだ文字をR0へ入れる
SYSCALL 0で同じ文字を表示する

ここで一度区切る理由は、入力処理が加わると、考えなければならないことが急に増えるからです。

  • Enterをどう扱うか
  • Backspaceをどう扱うか
  • 1行分の入力をどこに保存するか
  • 端末を raw mode(生モード)にするか
  • EOF(ファイルの終端)をVM(仮想マシン)プログラムにどう伝えるか

これらを一度に実装しようとすると、バグが出たときにどこで詰まっているのかが分かりにくくなってしまいます。そのため今回は、getchar() で1バイトだけ読み込み、その値をR0に入れるというシンプルな処理だけに絞って進めていきます。

今回のゴール

今回のゴールは、次のプログラムを動かすことです。

SYSCALL 2
SYSCALL 0
HALT

SYSCALL 2 でホストの標準入力から1バイトを読み込み、R0に格納します。その後、SYSCALL 0 を使って、R0の下位8ビットを文字として画面に表示します。

手元で動作確認をする際は、標準入力に「A」という文字を渡して実行します。

cc notes/vm.c -o /tmp/handmade-vm
printf A | /tmp/handmade-vm programs/echo-char.bin

printf A | ... の形で実行すると、標準入力に1バイトだけを渡して確認できます。端末から直接入力する場合は、多くの環境ではEnterを押すまで getchar() が値を受け取らない点に注意してください。

期待する出力は次の通りです。

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 2SYSCALL 0 を続けて実行するだけで、最もシンプルな「エコー(入力された文字をそのまま返す)」プログラムが完成します。

host stdin
  -> SYSCALL 2
  -> R0
  -> SYSCALL 0
  -> host stdout

SYSCALL 2 の仕様カード

いつものように、コードを書く前に仕様カードを作って整理します。

名前: SYSCALL read_char
分類: syscall
目的: host標準入力から1 byte読み、VM内のR0へ入れる
命令長: 4 byte
bit配置:
  bits 31..28: type = 6
  bits 27..24: op = 0
  bits 19..0 : syscall number = 2
読むレジスタ: なし
書くレジスタ: R0
読むメモリ: なし
書くメモリ: なし
PCの変化: fetch時に +4
条件フラグの変化: なし
エラー時: EOFなら "input EOF" を表示して停止
手作りテスト: SYSCALL 2 -> 0x60000002
成功条件: host標準入力から読んだ1 byteがR0に入る

命令のフォーマットとしては、これまでのSYSCALLと同じく type=6op=0 を使用します。そして、下位20ビットの即値(imm)をシステムコール番号として識別します。

SYSCALL 0 -> 0x60000000
SYSCALL 1 -> 0x60000001
SYSCALL 2 -> 0x60000002

SYSCALL 0 はR0の値を読み取ってhost標準出力へ出す処理でしたが、今回の SYSCALL 2 はhost標準入力から読んだ値をR0へ「書き込む」処理になります。このようにレジスタの扱い(読み/書き)の違いを仕様カードで明確に区別しておくことで、この後の実装で迷いにくくなります。

個別テストを書く

いきなり本体の統合VMへと組み込む前に、まずは notes/024-syscall-read-char-test.c を作成して小さな単位で動作を試します。

メモリに配置する命令は、以下の3つだけです。

0x00000000: 60 00 00 02    SYSCALL 2
0x00000004: 60 00 00 00    SYSCALL 0
0x00000008: 01 00 00 00    HALT

C言語のコードでは、次のように配列 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 の見方

VMはいつものように、PC が指す場所から4 byteを読みます。

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

このVMはビッグエンディアン(big-endian)としてバイト列を解釈するため、結合された命令値は 0x60000002 になります。

これをdecodeすると、次のように各フィールドへと分解されます。

type = 6
op   = 0
imm  = 2

type = 6 かつ op = 0 であることから、この命令が SYSCALL であると判別できます。さらに imm = 2(システムコール番号2)を指しているため、今回新しく追加する read_char の処理へと分岐させます。

execute で getchar() を呼ぶ

SYSCALL 2 の実行処理では、ホスト側の getchar() 関数を利用します。

} else if (imm == 2) {
    int ch = getchar();

    if (ch == EOF) {
        printf("input EOF\n");
        running = false;
    } else {
        regs[0] = (uint8_t)ch;
    }
}

ここで大事なのは、取得した文字 chchar 型ではなく、int 型で受け取っている点です。getchar() は、正常に読み込めた文字のデータだけでなく、入力の終端を表す EOF(End Of File)も返します。

EOF は通常の1バイト文字(0〜255)とは重ならない特別な値(多くの環境では -1)として定義されているため、一度 int 型として受け取ってから EOF かどうかの判定を行う必要があります。

文字が正常に読み込めた場合のみ、VM内のR0(すなわち regs[0])に値を格納します。

regs[0] = (uint8_t)ch;

今回のテストでは、標準入力から「A」という文字を渡して実行します。

printf A | /tmp/024-syscall-read-char-test

「A」 のASCIIコードは 0x41 なので、SYSCALL 2 が実行された後のレジスタの状態は次のようになります。

R0 = 0x00000041

SYSCALL 0 で同じ文字を表示する

次の命令は既存の SYSCALL 0 です。

putchar(regs[0] & 0xFF);
putchar('\n');

現在のR0には、直前の SYSCALL 2 で読み込んだ値 0x41 が格納されています。そのため、SYSCALL 0 の処理によって画面に「A」が表示されます。

実際にコンパイルしてテストを実行してみます

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.

これで、ホストの標準入力から読み込んだ1バイトが正しくR0に格納され、さらにその値を SYSCALL 0 を使ってそのまま表示できることが無事に確認できました!

EOF検出時は、いったんVM側で停止させる

標準入力から読み込めるバイトデータがない場合、getchar()EOF を返します。

この段階では、検出した EOF をVM上のプログラムへ戻り値として渡す仕組みまでは作り込みません。まずはシンプルに、VM側でメッセージを表示してシミュレーションを停止させる形にします。

if (ch == EOF) {
    printf("input EOF\n");
    running = false;
}

今後OS風のプログラムへ進むと、EOFやEnterをプログラム側で判定したくなりますが、今回は「1文字読める」という成功条件に集中します。

統合VMへ組み込む

個別テストで動作が確認できたので、これと同じ処理を統合VMである notes/vm.c に組み込みます。

} 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] に、runningvm->running に変わりますが、やっていることは同じです。

ここで、新しく入れた入力処理だけでなく、既存の内蔵テストプログラムや programs/hello.bin も今まで通り動くか見ておきます。

cc notes/vm.c -o /tmp/handmade-vm
/tmp/handmade-vm
A
VM flow complete.
CPU halted.
/tmp/handmade-vm programs/hello.bin
A
CPU halted.

既存の機能やこれまでの動きがそのまま残っていることも確認できました。

echo-char.asm を作成する

続いて、外部プログラムとして programs/echo-char.asm を作成します。

SYSCALL 2
SYSCALL 0
HALT

前回作成した自作アセンブラ tools/small-asm.c は、すでに SYSCALL imm の形式に対応しています。そのため、今回の SYSCALL 2 も特別な修正をすることなく、そのままバイナリへと変換可能です。

さっそくアセンブラをビルドして、プログラムをアセンブルしてみます。

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.bin

生成されたバイナリが意図通りになっているか、念のため xxd コマンドを使って中身(バイナリデータ)を確認しておきます。

xxd programs/echo-char.bin
00000000: 6000 0002 6000 0000 0100 0000            `...`.......

バイト列として分解してみると、確かに以下の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へ渡して実行します。

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 は、実行時にホスト側から受け取った標準入力に応じて動くプログラムです。

この違いが、今回の大事な一歩です。

今回分かったこと

今回の作業で、VMは「決まった文字を表示するだけ」から、「host標準入力から値を受け取って動く」段階へ進みました。

前回:
  外部 .bin を読み込んで実行できる

今回:
  外部 .bin がhost標準入力に反応できる

SYSCALL は、VMの内側とhost環境の外側をつなぐ入口(インターフェース)です。

  • SYSCALL 0 は、VM内の R0 をhost端末へ出力します。
  • SYSCALL 1 は、VM内の memory にある0終端文字列をhost端末へ出力します。
  • SYSCALL 2 は、host標準入力から1 byte読み、VM内の R0 へ格納します。

ここまで来ると、自作OS風のプログラムへ進む入口が見えてきます。まだ本物のシェルではありませんが、少なくとも「外から入力を受けて、それに応じて動く」ための最小部品が入りました。

まとめ

今回は、Day 24 から Day 25 にかけて、入力系 SYSCALL の最初の一歩を追加しました。

  • SYSCALL 2 の仕様を追加
    • host標準入力から1 byte読み、VM内の R0 へ入れる処理として定義しました。
  • 個別テストを作成:
    • notes/024-syscall-read-char-test.c で、printf A | ... による1文字入力を確認しました。
  • 統合VMへ実装
    • notes/vm.cSYSCALL 処理に imm == 2 の分岐を追加しました。
  • 最小echoプログラムを作成:
    • programs/echo-char.asm から programs/echo-char.bin を生成し、VMで実行できることを確認しました。

次回予告:プロンプト付きechoへ進む

次回は、今回の1文字echoに少しだけ見た目を追加します。

具体的には、以下のような流れで動くプログラムを作ります。

  • 画面に > (プロンプト)を表示する
  • ユーザーからの入力を1文字読み込む
  • 読み込んだ文字をそのまま画面に表示する
  • CPU halted.(停止)

まだコマンドループや行編集には進みません。まずは > というプロンプトを出してから1文字を読むことで、少しだけOS風、シェル風の見た目に近づけます。

ドリフの散髪屋コントで見切るマルチタスクと仮想メモリの正体

CPUは自分が騙されていることに気づかない!?「マルチタスクは高速な客のすり替え」「仮想メモリは回転床」。OSの裏で起きている超高速のドタバタ劇を覗けば、低レイヤの仕組みがスッキリ理解できます。ラストの「金だらい」の正体とは?

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

この記事を書いた人

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

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

目次