1000行で作るOS - システムコール

  1. はじめに
  2. 開発環境
  3. RISC-V入門
  4. OSの全体像
  5. ブート
  6. Hello World!
  7. C標準ライブラリ
  8. カーネルパニック
  9. 例外処理
  10. メモリ割り当て
  11. プロセス
  12. ページテーブル
  13. アプリケーション
  14. ユーザーモード
  15. システムコール
  16. ディスク読み書き
  17. ファイルシステム
  18. おわりに

前章では、ページフォルトをわざと起こすことでユーザーモードへの移行を確認しました。本章では、ユーザーモードで実行されているアプリケーションからカーネルの機能を呼び出す 「システムコール」 を実装します。

システムコール呼び出し関数 (ユーザーランド側)

まずはシステムコールを呼び出すユーザーランド側の実装から始めましょう。手始めに、文字を出力する putchar 関数をシステムコールとして実装してみます。システムコールを識別するための番号 (SYS_PUTCHAR) をcommon.hに定義します。

common.h
#define SYS_PUTCHAR 1

次にシステムコールを実際に呼び出す関数です。大体は SBIの呼び出し の実装と同じです。

user.c
int syscall(int sysno, int arg0, int arg1, int arg2) { register int a0 __asm__("a0") = arg0; register int a1 __asm__("a1") = arg1; register int a2 __asm__("a2") = arg2; register int a3 __asm__("a3") = sysno; __asm__ __volatile__("ecall" : "=r"(a0) : "r"(a0), "r"(a1), "r"(a2), "r"(a3) : "memory"); return a0; }

syscall関数は、a3にシステムコール番号、a0a2レジスタにシステムコールの引数を設定して ecall 命令を実行します。ecall 命令は、カーネルに処理を委譲するための特殊な命令です。ecall 命令を実行すると、例外ハンドラが呼び出され、カーネルに処理が移ります。カーネルからの戻り値はa0レジスタに設定されます。

最後に、次のように putchar 関数で putcharシステムコールを呼び出しましょう。このシステムコールでは、第1引数として文字を渡します。第2引数以降は、未使用なので0を渡すことにします。

user.c
void putchar(char ch) { syscall(SYS_PUTCHAR, ch, 0, 0); }

例外ハンドラの更新

次に、ecall 命令を実行したときに呼び出される例外ハンドラを更新します。

kernel.h
#define SCAUSE_ECALL 8
kernel.c
void handle_trap(struct trap_frame *f) { uint32_t scause = READ_CSR(scause); uint32_t stval = READ_CSR(stval); uint32_t user_pc = READ_CSR(sepc); if (scause == SCAUSE_ECALL) { handle_syscall(f); user_pc += 4; } else { PANIC("unexpected trap scause=%x, stval=%x, sepc=%x\n", scause, stval, user_pc); } WRITE_CSR(sepc, user_pc); }

ecall 命令が呼ばれたのかどうかは、scause の値を確認することで判定できます。handle_syscall関数を呼び出す以外にも、sepcの値に4を加えています。これは、sepcは例外を引き起こしたプログラムカウンタ、つまりecall命令を指しています。変えないままだと、ecall命令を無限に繰り返し実行してしまうので、命令のサイズ分 (4バイト) だけ加算することで、ユーザーモードに戻る際に次の命令から実行を再開するようにしています。

システムコールハンドラ

例外ハンドラから呼ばれるのが次のシステムコールハンドラです。引数には、例外ハンドラで保存した「例外発生時のレジスタ」の構造体を受け取ります。

kernel.c
void handle_syscall(struct trap_frame *f) { switch (f->a3) { case SYS_PUTCHAR: putchar(f->a0); break; default: PANIC("unexpected syscall a3=%x\n", f->a3); } }

システムコールの種類に応じて処理を分岐します。今回は、SYS_PUTCHAR に対応する処理を実装します。単にa0レジスタに入っている文字を出力するだけです。

システムコールのテスト

システムコールを一通り実装したので試してみましょう。common.cにあるprintf関数の実装を覚えているでしょうか。この関数は文字を表示する際にputchar関数を呼び出しています。たった今ユーザーランド上のライブラリでputcharを実装したのでそのまま使えます。

shell.c
void main(void) { printf("Hello World from shell!\n"); }

次のようにメッセージが表示されれば成功です。

$ ./run.sh
Hello World from shell!

文字入力システムコール (getchar)

次に、文字入力を行うシステムコールを実装しましょう。SBIには「デバッグコンソールへの入力」を読む機能があります。空の場合は-1を返します。

kernel.c
long getchar(void) { struct sbiret ret = sbi_call(0, 0, 0, 0, 0, 0, 0, 2); return ret.error; }

あとは次の通りgetcharシステムコールを実装します。

common.h
#define SYS_GETCHAR 2
user.c
int getchar(void) { return syscall(SYS_GETCHAR, 0, 0, 0); }
user.h
int getchar(void);
kernel.c
void handle_syscall(struct trap_frame *f) { switch (f->a3) { case SYS_GETCHAR: while (1) { long ch = getchar(); if (ch >= 0) { f->a0 = ch; break; } yield(); } break; /* 省略 */ } }

getcharシステムコールの実装は、文字が入力されるまでSBIを繰り返し呼び出します。ただし、単純に繰り返すとCPUを占有してしまうので、yieldシステムコールを呼び出してCPUを他のプロセスに譲るようにしています。

シェルを書こう

文字入力ができるようになったので、シェルを書いてみましょう。手始めに、Hello world from shell!と表示するhelloコマンドを実装します。

shell.c
void main(void) { while (1) { prompt: printf("> "); char cmdline[128]; for (int i = 0;; i++) { char ch = getchar(); putchar(ch); if (i == sizeof(cmdline) - 1) { printf("command line too long\n"); goto prompt; } else if (ch == '\r') { printf("\n"); cmdline[i] = '\0'; break; } else { cmdline[i] = ch; } } if (strcmp(cmdline, "hello") == 0) printf("Hello world from shell!\n"); else printf("unknown command: %s\n", cmdline); } }

改行が来るまで文字を読み込んでいき、入力された文字列がコマンド名に完全一致するかをチェックする、非常に単純な実装です。デバッグコンソール上では改行が ('\r') でやってくるので注意してください。

実際に動かしてみて、文字が入力されるか、そしてhelloコマンドが動くか確認してみましょう。

$ ./run.sh

> hello
Hello world from shell!

プロセスの終了 (exitシステムコール)

最後に、プロセスを終了するexitシステムコールを実装します。

common.h
#define SYS_EXIT 3
user.c
__attribute__((noreturn)) void exit(void) { syscall(SYS_EXIT, 0, 0, 0); for (;;); // 念のため }
kernel.h
#define PROC_EXITED 2
kernel.c
void handle_syscall(struct trap_frame *f) { switch (f->a3) { case SYS_EXIT: printf("process %d exited\n", current_proc->pid); current_proc->state = PROC_EXITED; yield(); PANIC("unreachable"); /* 省略 */ } }

まず、プロセスの状態をPROC_EXITEDに変更し、yieldシステムコールを呼び出してCPUを他のプロセスに譲ります。スケジューラはPROC_RUNNABLEのプロセスしか実行しないため、このプロセスに戻ってくることはありません。ただし念の為、PANICマクロで万が一戻ってきた場合はパニックを起こします。

分かりやすさのためにプロセスの状態を変えているだけで、プロセス管理構造体を開放していません。実用的なOSを目指したい時には、ページテーブルや割り当てられたメモリ領域など、プロセスが持つ資源を開放する必要があります。

最後に、シェルにexitコマンドを追加します。

shell.c
if (strcmp(cmdline, "hello") == 0) printf("Hello world from shell!\n"); else if (strcmp(cmdline, "exit") == 0) exit(); else printf("unknown command: %s\n", cmdline);

実際に動かしてみましょう。

$ ./run.sh

> exit
process 2 exited
PANIC: kernel.c:333: switched to idle process

exitコマンドを実行するとシェルプロセスが終了し、他に実行可能なプロセスがなくなります。そのため、スケジューラがアイドルプロセスを選ぶという流れになります。