日曜日

プロセス管理

プロセスとは?

OSでは実行中のプログラムのことをプロセスと呼ぶ。OSはプロセス単位で保護、リソース割り当て、権限管理を行う。

ユーザーは信頼度の低いプログラムを動かす可能性があり、そのような場合でもシステムを安定動作させるために、OSはそれぞれのプロセスが他のプロセスやOSそのものに影響を与えないように隔離し、保護する。これにより、バグがあるプログラムを動かしてもたいていの場合はそのプロセスだけがクラッシュ(異常終了)するだけですむ。

また、プロセスが動作するには機械語を実行するためのプロセッサと、プログラムやデータを配置するためのメモリが必要であり、OSは各プロセスに対して適切にこれらのリソースを割り当てる。

また、OSにはさまざまなオブジェクト(ファイル、デバイス、ソケット、共有メモリなど)が存在するが、OSはそれぞれのオブジェクトに対して操作を行う権限をプロセスごとに管理し、プロセスが許可されていない操作を行おうとすると、OSはプロセスの実行を停止させたり、ユーザーに許可を求めたりする。

PCB

OSはプロセスを管理するためにそれぞれのプロセスに対してカーネル内にPCB(プロセス制御ブロック)と呼ばれるデータ構造を割り当てる。プロセッサごとに現在実行中のプロセスのPCBへのポインタを持つ。PCBには一般的に下記の内容が含まれる。

  • PID(プロセスID)

  • プロセスを実行しているユーザに関する情報

  • プロセスが使用しているメモリ領域に関する情報

  • プロセスが使用しているリソース(オープンしているファイルなど)に関する情報

  • プロセスが保持する権限に関する情報

  • アカウンティング情報(使用したCPU時間やメモリ使用量、入出力の回数などに関する統計情報)

PCBの実装

PID、プロセスの状態、カーネルスタック(8KB)、スタックポインタ、ページテーブルを持つPCBを定義する。プロセッサがカーネル内のコードを実行しているときは、ユーザープロセスのスタック(ユーザースタック)とは別のスタック(カーネルスタック)を使用する。カーネルスタックはカーネル領域に存在する。カーネルスタックにはコンテキストスイッチ時のCPUレジスタ、どこから呼ばれたのか(関数の戻り先)、各関数でのローカル変数などが入っており、カーネルスタックをプロセスごとに用意することで、別の実行コンテキストを持ち、コンテキストスイッチで状態の保存と復元ができるようになる。

ちなみにプロセスの状態はos/kernel.hで、PROC_UNUSED(未使用)とPROC_RUNNABLE(実行可能)が定義されている。

スレッド

例えばC言語のプログラムはmain関数を起点として、ループを回ったり条件分岐したり関数を呼び出したりしながら動作する。このようなプロセス内のプログラムの実行の流れをスレッドと呼ぶ。スレッドはプロセッサが機械語を実行する流れであるハードウェアスレッドを指す場合と、ここで述べたようなプロセス内のプログラム実行の流れを指す場合がある。そのため後者はユーザースレッドまたはソフトウェアスレッドとも呼ばれる。

同一プロセス内のスレッド同士はアドレス空間を共有し、互いに協調して動作する。これらのスレッドの間には保護は存在しない。あるスレッドがメモリ中のどこかのアドレスに書き込むとその内容は即座に他のスレッドから見える。異なるプロセス間では前述したように仮想メモリ機構によって互いのメモリにアクセスできないように隔離されている。

スレッドは生成時に指定されたアドレス(一般的には関数の先頭アドレス)から実行を開始する。それぞれのスレッドは独立したスタックとプロセッサのレジスタセットを持つ(今回のOSではスレッドは使用しないので、プロセスがスタックを持つ)。スレッドのスタックはアドレスが互いに重ならないように割り当てられる。プロセスの生成にともなって作られる初期スレッドではプロセスのスタック領域を用いるが、他のスレッドではヒープ領域などから割り当てられることが多い。

コンテキストスイッチ

スレッドは自由に生成できるが、コンピュータがある瞬間に実行できるスレッド数はプロセッサ数に制限される。そのためOSはプロセッサを時分割して用いる。つまりプロセッサがあるスレッドを短時間実行したら、次に別のスレッドに切り替えて短時間実行する、というように実行するスレッドを短時間で切り替える。これによってOSはプロセッサの数以上のスレッドを見かけ上、同時(並行)に実行する。

このようにプロセッサが実行するスレッドを切り替える処理をコンテキストスイッチと呼ぶ。コンテキストスイッチはスレッドからは透過であり、それぞれのスレッドからは自分専用のプロセッサがスレッドを実行しているように見える(ユーザースレッドはハードウェアスレッドを仮想化したものと考えることができる)。

コンテキストスイッチの実装

今回のOSではスレッドは使用しないため、プロセスのコンテキストスイッチを実装する。プロセスを作成する関数を実装し、複数のプロセス間でコンテキストスイッチをできるようにしたい。プロセスの最大数はos/kernel.hPROCS_MAXが定義されており、この数以上のプロセスは作成できないようにすること。実装したらプロセスを複数作成し、コンテキストスイッチを行い、正しく動作することを確認すること。

コンテキストスイッチの際に、グローバル変数、スレッドは特にプロセス内で使用しないため、gptpは保存する必要はない。またRISC-Vの呼び出し規約(calling convention)により、t0~t6a0~a7は関数呼び出し間で値が保持されることが保証されないレジスタであるため、これらのレジスタも保存する必要はない。s0~s11は関数内で使用するレジスタであるが、関数呼び出し間で値が保持されることが保証されるため、これらのレジスタの値を保存する必要がある。また関数の実行を正しく再開するために、raも保存する必要がある。

スケジューラ

現代のOSは複数のプログラムを同時に実行できるマルチタスク(マルチプロセス)をサポートしている。スケジューラの役割は、システム全体の効率を最大化し、すべてのプログラムが適切に実行されるようにすることである。マルチスレッドをサポートしない古典的なOSではスケジューリングの対象はプロセスであったが、現代のOSではスケジューリングの対象はスレッドである。スケジューリングの動作には、協調的マルチタスクとプリエンティブマルチタスクの2種類がある。

協調的マルチタスクではそれぞれのプログラムは自発的に他のプログラムにプロセッサを譲る(これをyieldという)操作を行うまで実行を継続する。協調的マルチタスクは単純なので、プリエンプティブマルチタスクに比べて実装が容易であるが、各プログラムは長時間プロセッサを占有しないように定期的にyieldする必要がある。

一方、プリエンプティブマルチタスクでは、プログラムが明示的にyieldしなくても、実行するプログラムがタイムスライス(プリエンプションされずに連続して実行できる時間)を使い切るとカーネルが強制的に切り替える。この強制的な切り替えのことをプリエンプション(横取り)と呼ぶ。プリエンプションされたプログラムはいずれ中断した処理の続きを実行する機会が与えられる。

スケジューラの実装

今回のOSではスレッドを使用しないため、プロセスをスケジューリングの対象とする。コンテキストスイッチの実装では直接コンテキストスイッチを行う関数を呼び出して次に実行するプロセスを指定していたが、この方法ではプロセスの数が増えると次にどのプロセスに切り替えるべきかを決めるのは大変である。そこで、スケジューラを導入し、スケジューラが次に実行するプロセスを決定するようにする。

今回は協調的マルチタスクを行うスケジューラを実装したい。実行可能なプロセスがない場合はidleプロセスを実行するようにすること。プロセスを切り替える際はページテーブルを切り替える必要がある。またプロセスごとに別のカーネルスタックを使うようになったため、トラップハンドラの実装もそれに合わせて変更すること。このトラップハンドラはユーザーモードでトラップが発生した場合にも使用される予定なので、そのことを考慮し、脆弱性に気をつけて実装すること。最後に正しく動作し、ページが正しくマップされていることを確認すること。

satpレジスタは前述したページテーブルベースレジスタに相当する。satpレジスタにセットする値は、MSB(最上位ビット)がSv32方式を有効にするためのビットであり、下位ビットにはページテーブルの物理ページ番号が格納される。ちなみにブート時にはsatpレジスタは0に初期化されているため、ページングは無効になっており、物理アドレスがそのまま仮想アドレスとして扱われる。

ユーザープログラム

ユーザープログラムの実装

os/shell.cですでに定義されているユーザープログラムを実行できるようにしたい。

os/run.shを実行するとshell.bin.oが生成されているのがわかる。shell.bin.oは生バイナリ形式の実行イメージであり、llvm-nmコマンドで中身を見てみると、_binary_shell_bin_start_binary_shell_bin_end_binary_shell_bin_sizeというシンボルが定義されていることがわかる。これらのシンボルはそれぞれ、実行イメージの先頭アドレス、終端アドレス、サイズを表している。

$ llvm-nm shell.bin.o
00010260 D _binary_shell_bin_end
00010260 A _binary_shell_bin_size
00000000 D _binary_shell_bin_start

os/run.shではカーネルのビルド時にClangにshell.bin.oを渡しているため、カーネル内でこれらのシンボルは例えば下記のように使用することができる。つまり、_binary_shell_bin_startは実行イメージのポインタとして、_binary_shell_bin_sizeは実行イメージのサイズとして使用できるのである。

extern char _binary_shell_bin_start[];
extern char _binary_shell_bin_size[];

void main(void) {
  uint8_t *shell_bin = (uint8_t *) _binary_shell_bin_start;
  printf("shell_bin size = %d\n", (int) _binary_shell_bin_size);
  printf("shell_bin[0] = %x (%d bytes)\n", shell_bin[0]);
}

システムコール

ユーザープログラムから自由にプロセッサの設定を変更したり、ハードウェアを直接制御したりできてしまうと、システムの安定性やセキュリティ上重大な問題となるため、カーネルは実行ファイルからロードした機械語プログラムを実行している間、プロセッサをユーザーモードに設定して、ユーザープログラムが問題がある動作を行おうとすると特権違反例外が発生し、カーネルはそのプロセスを強制終了するなどの処置をとる。

しかし、ユーザープログラムもファイルの読み書きやメモリの割り当てなど、リソースを操作したい場面が存在する。そのため、それらの操作をC言語などから呼び出し可能なAPIとして提供する。このAPIをシステムコールと呼ぶ。

ユーザープログラムからシステムコールを呼び出すと、プロセッサのモードをユーザーモードからカーネルモードに切り替え、システムコールの処理を実行した後、カーネルモードからユーザーモードに切り替えてからユーザープログラムに戻る。

システムコールの実行は下記のように行われる。

  1. アプリケーションプログラムがシステムコールのラッパー関数を呼び出す

  2. ラッパー関数はシステムコール番号とシステムコールへの引数をカーネルに渡すために、レジスタやスタックなどに書き込む

  3. ラッパー関数はシステムコールを実行するための特別な機械語命令を実行する。この命令はソフトウェア割り込みを引き起こす

    1. プロセッサをカーネルモードに遷移させる

    2. スタックをカーネルスタックに切り替える

    3. ユーザープロセスの現在のコンテキストをレジスタやカーネルスタックなどに退避させる

    4. カーネル内にあるシステムコールを処理するためのルーチンに制御を移す

  4. 2で渡されたシステムコール番号を読み取り、そのシステムコールを処理する関数を呼び出す

  5. 処理が終了したらシステムコールの返り値をレジスタなどに書き込み、ユーザーモードに戻るための機械語命令を実行する

  6. プロセッサはユーザーモードに遷移し、ユーザープロセスのコンテキストを復元する

  7. ラッパー関数はシステムコールの返り値を返す

システムコールの実装

現在puthchar関数はカーネル内でのみ使用できるが、ユーザープログラムからも使用できるようにしたい。そのため、putcharシステムコールを実装したい。システムコールの番号はos/common.hに定義されている。トラップを発生させることでユーザーモードからカーネルモードに移行することができる。トラップハンドラで保存したコンテキストはos/kernel.hに定義されているtrap_frame構造体と同じ構造であるため、Cコードではtrap_frame構造体を使用して保存したコンテキストを利用できる。os/kernel.hに定義されているSCAUSE_ECALLを使用して、トラップの原因がシステムコールであるかどうかを判定することができる。

Last updated