├── .gitconfig ├── appendixa.md ├── appendixb.md ├── chapter0.md ├── chapter1.md ├── chapter2.md ├── chapter3.md ├── chapter4.md ├── chapter5.md ├── chapter6.md ├── chapter7.md ├── images ├── figure0-01.JPG ├── figure0-02.JPG ├── figure1-01.JPG ├── figure1-02.JPG ├── figure1-03.JPG ├── figure1-04.JPG ├── figure2-01.JPG ├── figure2-02.JPG ├── figure2-03.JPG ├── figure3-01.JPG ├── figure3-02.JPG ├── figure4-01.JPG ├── figure5-01.JPG ├── figure5-02.JPG ├── figure6-01.JPG ├── figure6-02.JPG └── figure6-04.JPG ├── index.md └── redpen-my-conf-ja.xml /.gitconfig: -------------------------------------------------------------------------------- 1 | [user] 2 | email = msyksphinz.dev@gmail.com 3 | name = msyksphinz 4 | -------------------------------------------------------------------------------- /appendixa.md: -------------------------------------------------------------------------------- 1 | # 付録A. PCハードウェア 2 | 3 | 本付録では、xv6が動作するプラットフォームであるパーソナルコンピューター(PC)のハードウェアについて説明する。 4 | 5 | PCは複数の業界標準に従ったコンピュータであり、その目的は複数のベンダから販売されるPC上でソフトウェアが動作することができるというものである。この標準は、現在のPCと見た目は異なるものの、1990年代のPCから徐々に発展してきたものだ。 6 | 7 | PCの外側にはキーボード、画面、そして様々なデバイス(CD-ROMなど)が接続されている。PCの箱の中には回路ボード(マザーボード)、マザーボード上にはCPUチップ、メモリチップ、グラフィックチップ、I/Oコントローラチップ、チップ間を通信するためのバスが接続されている。バスは標準的なプロトコル(PCIやUSBなど)を採用しており、様々なベンダから提供されるPC上で正確に動作するようになっている。 8 | 9 | -------------------------------------------------------------------------------- /appendixb.md: -------------------------------------------------------------------------------- 1 | 付録B ブートローダ 2 | ================== 3 | 4 | x86のPCがブートすると、マザーボードの不揮発性メモリ上に格納されたBIOSというプログラムが実行される。BIOSの仕事は、ハードウェアの準備を行い、制御をオペレーティングシステムに渡すことである。仕様としては、BIOSはブートディスク上の先頭の512バイト、ブートセクタに格納されたコードに制御を移す。 5 | このブートセクタには、ブートローダが格納されている。ブートローダはカーネルをメモリにロードする役割を持っている。BISOはブートセクタをメモリアドレス0x7c00にロードし、ジャンプする(このときに、プロセッサの`%ip`を設定する)。ブートローダが実行し始めると、プロセッサはIntel8088をシミュレーションしている。よってローダの仕事は、ディスクからメモリに格納されたカーネルをロードするために、プロセッサをより現代の動作モードに変更することであり、さらに制御をカーネルに移す。xv6のブートローダは2つのソースファイルから構成されており、一つは16ビットと32ビットのアセンブリで記述されており(`bootasm.S`; 8900行)、 6 | もう一つはC言語で記述されている (``bootmain.c`; 9000行)。 7 | 8 | # コード例: アセンブリ言語のブートストラップ 9 | 10 | ブートローダの最初の命令は、`cli`命令であり(8912行目)、これはプロセッサの割り込みを停止させる命令である。割り込みは、ハードウェアデバイスにとって、割り込みハンドラと呼ばれるオペレーティングシステムの機能を呼ぶための方法である。BIOSは小さなオペレーティングシステムであり、ハードウェアを初期化する中で独自の割り込みハンドラを設定する。しかし、BIOSはそれ以上は動作せず、ブートローダが動作するようになる。従って、ハードウェアデバイスからの割り込みを安全もしくは適切に処理する方法では無い。xv6の準備が整うと(第3章)、再度割り込みが許可される。 11 | 12 | プロセッサがリアルモードである時、Intel8088をシミュレートしている。リアルモードでは、8つの16ビットの汎用レジスタが存在しているが、プロセッサは、20ビットのアドレスをメモリに転送する。セグメントレジスタ`%cs`,`%ds`,`%es`,`%ss`により、20ビットのメモリアドレスを、16ビットのレジスタから生成される。 13 | プログラムがメモリアドレスを参照すると、プロセッサはその値にセグメントレジスタの値を自動的に16回加算する;これらのレジスタの値は16ビット幅である。どのセグメントレジスタが利用されるかについては暗黙的に決められており、それはメモリの参照の種類によって決まる:命令フェッチには`%cs`が使われ、データの読み書きには`%ds`が利用され、スタックの読み書きには`%ss`が利用される。 14 | 15 | xv6はx86の命令がメモリオペランドとして仮想アドレスを利用しているように偽装しているが、x86の命令は実際には「論理アドレス(logical address)(図B-1を参照のこと)」として利用している。論理アドレスはセグメントセレクタとオフセットから構成されており、しばしば`segment:offset`のように表記される。さらに、セグメントは暗黙的であり、プログラムは直接はオフセットしか操作しない。セグメンテーションのハードウェアは「線形なアドレス(linear address)」を生成するために、上記の変換を実行する。ページングハードウェア(第2章を参照のこと)が有効であれば、その線形のアドレスを物理アドレスに変換する;そうでなければ、プロセッサは線形アドレスを物理アドレスとして利用する。 16 | 17 | ブートローダはページングハードウェアを有効にはしない; 論理アドレスはセグメンテーションハードウェアにより線形アドレスに変換され、それがそのまま物理アドレスとして利用される。xv6はセグメンテーションハードウェアを、何の変更もせずに物理アドレスとして取り扱うように設定する;従って、全てが同一のアドレスである。私達が、プログラムによって操作されたアドレスを示すときに「仮想アドレス」という言葉を使う歴史的な理由としては、xv6の仮想アドレスはx86の論理アドレスと同一であり、それはセグメンテーションハードウェアによりマップされる線形アドレスと同一である。 18 | 一度ページングが有効になると、システムのアドレスマッピングは、線形から物理に変更される。 19 | 20 | BIOSは`%ds`,`%es`,`%ss`の値については何の保証もしない。従って、割り込みを設定した後に最初にする仕事は、`%ax`をゼロに設定して、それらを`%ds`, `%es`, `%ss`にコピーすることである(8915-8918行目)。 21 | 22 | 仮想の`segment:offset`のアドレスでは、21ビットの物理アドレスを取り扱うことができるが、Intel 8088では、20ビットのメモリしか扱うことができない。そのため、先頭の0xffff0+0xffff=0x10ffefは破棄される。初期のソフトウェアでは、ハードウェアが21番目のビットを無視することに依存しており、従って、Intelが20ビット以上の物理アドレスを導入したとき、IDBMはPCの互換性のあるハードウェアとしての動作を維持するために、 23 | 互換性のハッキングを提供していた。もしキーボードコントローラの出力ポートの2番目のビットが0であるならば、21番目の物理アドレスビットは常に0となる; もし1であれば、21番目のビットは通常通り処理される。 24 | ブートローダは21番目のアドレスビットを、キーボードコントローラの0x64および0x60ポートを制御することによって有効化しなければならなかった。 25 | 26 | リアルモードの16ビット汎用レジスタおよびセグメントレジスタは、プログラムが65536バイト以上のメモリを使おうとするときには不便であり、またメガバイト単位のメモリを利用することはできない。x86プロセッサは80286から「プロテクトモード」を導入し、物理アドレスにより多くのビットを利用できるようにした。さらに、(80386からは)32ビットモードを導入し、レジスタ、仮想アドレス、そして殆どの整数算術演算を16ビットから、32ビットで処理できるようにした。xv6のブートシーケンスでは、32ビットモードを有効にするために、プロテクトモードを有効にしている。 27 | 28 | プロテクトモードでは、セグメントレジスタは「セグメントデスクリプタテーブル」のインデックスを格納している(図B-2を参照のこと)。各テーブルのエントリは、物理アドレスのベースと、リミットと呼ばれる仮想アドレスの最大値と、セグメントのパーミッションビットが格納されている。これらのパーミッションはプロテクトモード内で保護を行っている: カーネルはこのビットを利用して、プログラムが自分のメモリのみを利用することを保証させることができる。xv6はセグメントを殆ど利用しない; その代わりに、第2章で説明したページングハードウェアを利用する。ブートローダはセグメントデキスクリプタテーブルgdtを設定し(8982-8985行目)、全てのセグメントがベースアドレスがゼロであり、リミットを最大値(4GB)に設定する。 29 | テーブルはエントリが入っておらず、1つのエントリがコード実行のために設定され、もう一つのエントリがデータのために設定されている。コードセグメントディスクリプタはコードが32ビットモードで動作するようにフラグを設定している(0660)。この設定により、ブートローダはプロテクトモードに入り、論理アドレスは物理アドレスに一対一にマップされるようになっている。 30 | 31 | ブートローダは`lgdt`命令を実行し(8941行目)、プロセッサのグローバルディスクリプタテーブル(GDT)レジスタを`gdtdesc`値に設定し、(8987-8989行目)、gdtテーブルを参照するように設定している。 32 | 33 | 一度GDTレジスタがロードされると、ブートローダは`%cr`に1ビット(CR0_PE)に設定し、プロテクトモードを有効にする。プロテクトモードを有効にしても、プロセッサがすぐに論理アドレスから物理アドレスへの変換を行う訳ではない;ある新しい値がセグメントレジスタにロードされ、プロセッサがGDTを読み込むと、内部のセグメンテーションの設定が変更される。直接`%cs`を変更することはできず、従って、コードは代わりにセグメントセレクタの設定を行う命令である`ljmp`(遠い場所へのジャンプ)を実行する(8953行目)。ジャンプは次の行の命令を実行(8956行目)するが、gdt内でコードディスクリプタエントリを参照するように`%cs`を設定する。 34 | このディスクリプタは32ビットのコードをセグメントを設定し、プロセッサは32ビットモードにスイッチする。以上のようにして、ブートローダはプロセッサが8088から80286、80386へと進化するように導いている。 35 | 36 | 32ビットモードになったブートローダの最初の仕事は、データセグメントレジスタをSEG_KDATAに設定することである(8958-8961行目)。論理アドレスは、物理アドレスに直接マッピングされるようになっている。 37 | Cコードに遷移する前にこれを設定する唯一の方法は、スタックを利用していないメモリの領域にセットアップすることである。0xa0000から0x100000のメモリ領域は典型的にデバイスメモリ領域として汚れているため、xv6のカーネルは0x100000を設定する。ブートローダ自身は0x7c00から0x7d00に存在している。基本的に、多のメモリのセクションはスタックには適している場所である。ブートローダは0x7c00(ファイルには`$start`として設定されている)をスタックのトップとして設定する; ブートローダから離れると、スタックはここから下方向に0x0000に向かって伸びていく。 38 | 39 | 最後に、ブートローダはCの関数である`bootmain`を呼び出す(8968行目)。`bootmain`の役割はカーネルをロードし実行することである。何かプログラムに間違いがあれば、単純に戻ってくるだけである。その場合、コードはいくつかの出力ワードをポート0x8a00に出力するだけである(8970-8976行目)。実際のハードウェアでは、このポートには何のデバイスも接続されておらず、何も起こらない。ブートローダがPCシミュレータ上で動作しているときは、0x8a00はシミュレータ自身に接続されており、シミュレータ自身に制御が返されるようになっている。 40 | シミュレータかどうかに関わらず、コードが無限ループに入るようになっている(8977-8978行目)。実際のブートローダは、まず最初にエラーメッセージを出力するようになっている。 41 | 42 | # コード例: Cブートストラップ 43 | 44 | ブートローダのC言語の部分、``bootmain.c`(9000行目)は、ディスクの2番目のセクタに入っているカーネルのコピーをロードする。第2章で説明したように、カーネルはELFフォーマットのバイナリである。 45 | ELFヘッダにアクセスするために、`bootmain`はELFファイルの最初の4096バイトをロードし(9014行目)する。 46 | これらは、メモリの0x10000番地に配置される。 47 | 48 | 次のステップは、ELFバイナリの簡単なチェックであり、フォーマットに従ったバイナリであるかをチェックする。`bootmain`はディスクのoffバイトから先に配置されているELFヘッダ移行を内容を読み込み、paddrから始まるデータをメモリに書き込んでいく。`bootmain`は`readseg`を呼び、ディスクからデータを読み込み(9038行目)、`stosb`を呼んでセグメントの残りにゼロを書き込む(9040行目)。`stosb`(0492行目)はx86のrep `stosb`命令を利用して、メモリ内のブロックを初期化する。 49 | 50 | カーネルはコンパイルされ、リンクされるため、仮想アドレスは0x80100000から始まるように配置される。 51 | 従って、関数コールの命令は0x801xxxxxとなるようにアドレスを設定する必要がある; `kernel.asm`の例を見ることができる。このアドレスは`kernel.ld`に設定されている。0x80100000は32ビットのアドレス空間の中では相対的に高いアドレスである; 第2章では、何故この選択をしたかについて説明をしている。このような高いアドレスには、物理的なメモリは存在しない。一度コーネルが実行し始めると、ページングハードウェアが動作して、0x80100000から始まる仮想アドレスを0x00100000にマッピングする;カーネル物理アドレスがこの低いアドレスに配置されていると想定する。しかしブート処理の時点では、ページングは有効化されていない。その代わりに、`kernel.ld`はELFのpaddrが0x00100000から始めるように指定しており、ブートローダがカーネルを低いアドレスにコピーし、最終的にページングのハードウェアがそこを指すようになっている。 52 | 53 | ブートローダの最後のステップは、カーネルのエントリポイントを呼び出すことである。これにより、カーネルの実行が開始される。xv6のエントリアドレスは0x10000cである: 54 | 55 | ``` 56 | # objdump -f kernel 57 | ``` 58 | 59 | 利便性のため、`_start`シンボルがELFのエントリポイントを示しており、これは`entry.S`により規定されている(1036行目)。xv6は仮想メモリをセットアップしないため、xv6のエントリポイントは、`entry`の物理アドレスである (1040行目)。 60 | 61 | -------------------------------------------------------------------------------- /chapter0.md: -------------------------------------------------------------------------------- 1 | オペレーティングシステムインタフェース 2 | ===================================== 3 | 4 | オペレーティングシステムの仕事は、複数プログラム間でコンピュータを共有し、単体ハードウェアによるサポートよりも便利なサービスセットを提供することである。 5 | オペレーティングシステムは低レイヤのハードウェアを管理、抽象化する。 6 | 例えば、ワードプロセッサを使うために、どのような種類のディスクが利用されているかについて考える必要はない。 7 | オペレーティングシステムはハードウェアを分割し、多くのプログラムがコンピュータを共有し同時動作(もしくは動作しているように見える)を実現している。 8 | さらに、オペレーティングシステムはプログラム同士が相互通信をするための制御された方法を提供している。 9 | これにより各プログラムはデータを共有し協調して動作することができるようになっている。 10 | 11 | オペレーティングシステムはユーザプログラムに対しインタフェースを通じてサービスを提供する。 12 | 良いインタフェースを設計することは難しい。 13 | 一方で、私達はインタフェースをシンプルかつ狭くすることで、実装をより簡単にしたいと思う。 14 | 一方で、インタフェースはアプリケーションの機能により洗練されたものを要求する傾向にある。 15 | この難しい関係を解消するために、少数の汎用性を持つメカニズムを使ってインタフェースを設計することである。 16 | メカニズムの種類を減らすことにより、高い汎用性を提供することができるようになる。 17 | 18 | 本書はひとつのオペレーティングシステムを具体的な例として取り上げ、その概念を説明する。 19 | このオペレーティングシステムはxv6と呼ばれる。 20 | xv6はKen ThompsonとDennis Ritcheにより開発されたUnixオペーレーティングシステムのインタフェースだけでなく、Unixの内部デザインも真似ている。 21 | Unixはインタフェースの種類は少ないが、これらのインタフェースはうまく組み合わせることで、驚くほど程度の高い汎用性を実現することができる。 22 | このインタフェースはBSD、Linux、Mac OS X、Solarisなどの現代のオペレーティングシステムでも採用されており、またMicrosoft WindowsでもUNIX系のインタフェースを僅かながら採用している。 23 | xv6を理解することはこれらのシステムや他のシステムを理解するための良いスタート点になる。 24 | 25 | 図0-1に示すように、xv6は"カーネル"と呼ばれる伝統的な形式を取っており、実行中のプログラムに対してサービスを提供するための特殊なプログラムの形をしている。 26 | 各実行中のプログラムはプロセスと呼ばれ、命令、データ、スタックを含んでいるメモリを持っている。 27 | 命令はプログラムで計算を実行するためのものである。 28 | データは計算するための変数などが入っている。 29 | スタックはプログラムの手続き呼び出しを構成する。 30 | 31 | ![Figure0-01](images/figure0-01.JPG) 32 | 33 | プロセスがカーネルサービスを呼ばなければならない場合、オペレーティングシステムのインタフェースを通じてカーネルサービスの手続き呼出しがなされる。 34 | このような手続きのことをシステムコールと呼ぶ。 35 | システムコールによりカーネルに入り、カーネルはサービスを実行し戻ってくる。 36 | 従って、プロセスはユーザ空間とカーネル空間での実行を往復することとなる。 37 | 38 | カーネルはCPUのハードウェア保護機構を使い、ユーザ空間で実行されている各プロセスが自分のメモリ領域のみアクセスしているかをチェックする。 39 | カーネルはこれらの保護を実装するために必要な権限を持って実行されるが、一方でユーザプログラムはこのような権限を持っていない。 40 | 従ってユーザプログラムがシステムコールを起動すると、ハードウェアが権限レベルを上昇させあらかじめカーネル内で配置された関数が実行される。 41 | 42 | カーネルが提供しているシステムコール群はユーザプログラムがアクセスすることができるインタフェースである。 43 | xv6カーネルはUnixカーネルが伝統的に提供しているサービスとシステムコールの一部を提供している。 44 | 図0-2はxv6のシステムコールの一覧である。 45 | 46 | 本章はこれから、xv6のサービスの概要を示す - プロセス、メモリ、ファイルディスクリプタ、パイプ、ファイルシステム、そして短いコードによるこれらの説明と、シェルがどのようにしてこれらを扱うかについて議論する。 47 | シェルによりシステムコールがどのように利用されているかを観察することにより、これらのサービスがどのように注意深く実装されているかを説明する。 48 | 49 | シェルはユーザのコマンドを読み込み実行するための最初のプログラムであり、伝統的なUnix系システムにおける主たるユーザインタフェースである。 50 | シェルはカーネルの一部ではなくユーザプログラムであり、これがシステムコールインタフェースの能力を説明している、つまりシェルには何も特別な機能はない。 51 | また、これはシェルを簡単に置き換えることができるということを意味している。 52 | 結果として、現代のUnixシステムでは独自のインタフェースやスクリプティングインタフェースを持った多くのシェルが存在し、ユーザは好きなものを選択することができる。 53 | xv6のシェルはUnix Bourne Shellの基本となるシンプルな実装であり、これらの実装は(8350行)で見ることができる。 54 | 55 | | System call | Description | 56 | |---------------------------|------------------------------------------| 57 | | fork() | プロセスの生成 | 58 | | exit() | 現在のプロセスを終了する | 59 | | wait() | 子プロセスが終了するまで待つ | 60 | | kill(pid) | pidのプロセスを終了する | 61 | | getpid() | 現在のプロセスのidを返す | 62 | | sleep(n) | n秒スリープする | 63 | | exec(filename, *argv) | ファイルをロードし実行する | 64 | | sbrk(n) | プロセスのメモリをnバイト増大させる | 65 | | open(filename, flags) | ファイルを開く; flagsはファイルの読み書き属性を示す | 66 | | read(fd, buf, n) | 開いたファイルからnバイトを読み込み、bufに格納する | 67 | | write(fd, buf, n) | 開いたファイルに対してnバイト書き込む | 68 | | close(fd) | ファイルfdを開放する | 69 | | dup(fd) | ファイルfdを複製する | 70 | | pipe(p) | パイプを作成しp内のfdを返す | 71 | | chdir(dirname) | 現在のディレクトリを移動する | 72 | | mkdir(dirname) | 新しいディレクトリを作成する | 73 | | mknod(name, major, minor) | デバイスファイルを作成する | 74 | | fstat(fd) | fdに対する情報を返す | 75 | | link(f1, f2) | ファイルf1に対して新しい名前f2を追加する | 76 | | unlink(filename) | ファイルを削除する | 77 | 78 | # プロセスとメモリ 79 | 80 | xv6のプロセスはユーザ空間メモリ(命令、データ、スタック)とカーネルによるプロセス毎の状態を記録した空間から構成される。 81 | xv6はプロセスを時分割共有、つまり複数の実行待ちのプロセスで透過的にスイッチしながらCPUを利用できるようにする仕組み、を使用することが出来る。 82 | プロセスが実行されていないならば、xv6はCPUレジスタを保存し、次のプロセスのレジスタをリストアする。 83 | カーネルはプロセス識別子、pidを各プロセスに割り当てる。 84 | 85 | プロセスは`fork`システムコールを利用して新しいプロセスを作成する。 86 | `fork`は子プロセスと呼ばれる新しいプロセスを生成する。 87 | 子プロセスは、親プロセスと呼ぶ呼出元のプロセスと全く同一のものである。 88 | `fork`は親プロセスと子プロセスの両方に返される。 89 | 親ならば`fork`は子プロセスのpidを返し、子ならばゼロを返す。 90 | 例えば次のようなプログラムを考えてみる。 91 | 92 | ```cpp 93 | int pid = fork(); 94 | if(pid > 0){ 95 | printf("parent: child=%d\n", pid); 96 | pid = wait(); 97 | printf("child %d is done\n", pid); 98 | } else if(pid == 0){ 99 | printf("child: exiting\n"); 100 | exit(); 101 | } else { 102 | printf("fork error\n"); 103 | } 104 | ``` 105 | 106 | `exit` システムコールは呼び出し元のプロセスの実行を中止し、メモリや開いているファイルなどを開放する。 107 | `wait`システムコールは終了した子プロセスのpidを返す; もし呼び出し元の子プロセスがどれも終了しなかった場合、`wait`システムコールは終了するまで待つ。 108 | このプログラムを実行すると 109 | ``` 110 | parent: child=1234 111 | child: existing 112 | ``` 113 | と出力されるが、どちらの行が先に出力されるかは分からない。 114 | これは親プロセスと子プロセスがどちらが先に`printf`コールを呼び出すかに依存する。 115 | 子プロセスが終了し親プロセスの`wait`が帰ってくると、親プロセスが 116 | 117 | ``` 118 | parent: child 1234 is done 119 | ``` 120 | 121 | と出力する。 122 | ここで親プロセスと子プロセスは別々のメモリと別々のレジスタを用いて実行されたことに注意する; 一方の変数を変更しても、他方には影響していない。 123 | `exec`システムコールは呼び出し元のプロセスのメモリを、ファイルシステム中に格納されている新しいメモリイメージに置き換える。 124 | そのファイルは特定のフォーマットをしていなければならず、どの部分に命令が格納されているか、どの部分がデータか、どこから命令がスタートするか、などが記述されていなければならない。 125 | xv6はELFフォーマットを用い、これについては第2章でより詳細に議論する。 126 | `exec`が成功すると、呼び出し元のプログラムには帰ってこない; その変わりに、ELFヘッダにより宣言されたエントリポイントからファイルがロードされ、命令が実行され始める。 127 | `exec`は2つの引数を取る: 実行ファイルが格納されているファイル名と、文字列で表現されている引数の配列である。 128 | 例えば、 129 | 130 | ```cpp 131 | char *argv[3]; 132 | argv[0] = "echo"; 133 | argv[1] = "hello"; 134 | argv[2] = 0; 135 | exec("/bin/echo", argv); 136 | printf("exec error\n"); 137 | ``` 138 | 139 | 上記のプログラム列は、プログラム`/etc/echo`のインスタンスを呼び出し、引数リストとして、`echo hello`を設定して実行する。 140 | 殆どのプログラムは最初の引数を無視する(伝統的に、ここにはプログラム名を挿入する)。 141 | 142 | xv6シェルはプログラムを上記の呼び出しの方法を用いてユーザの変わりに実行する。 143 | シェルのメイン構造はシンプルである; 8501行目の`main`を参照して欲しい。 144 | `main`ループは`getcmd`を利用してコマンドラインの入力を読み込む。 145 | 次に`fork`を呼び出しシェルプロセスのコピーを生成する。 146 | 親プロセスであるシェルは子プロセスがコマンドを実行している間、`wait`を呼んで待つ。 147 | 例えばユーザがプロンプト上で`”echo hello`”とタイプした場合`、runcm` はd`は”echo hell` o”を引数として呼び出す。 148 | `runcmd`(8406)行目は実際のコマンドを実行する。 149 | `”echo hello` ”を実行するために`、ex`ec(8426行目)が呼び出される。 150 | `exec`の呼び出しが成功すると、子プロセスは`runcmd`の変わりにechoを実行する。 151 | どこかの段階で`echo`が`exit`を呼び出すと、親プロセスが呼び出され、`main`(8501行目)上の`wait`に制御が戻される。 152 | 読者は`fork`と`exec`が何故1つの処理として実行されないのか不思議の思うだろう;プロセスの作成とプログラムのロードを分割することは非常に賢い設計である。 153 | これについては後程見ていく。 154 | 155 | xv6は殆どのユーザ空間メモリを暗黙的に割り当てる: `fork`は子プロセスのコピーに必要なメモリ領域を確保し、`exec`は実行可能なファイルを保持するための十分なメモリ領域を確保する。 156 | プロセスが実行中により多くのメモリが必要であれば(おそらくはmallocなどを使って)、`sbrk(n)`を呼び出してデータメモリのサイズをnバイトまで増やすことができる; `sbrk`は新しいメモリの場所を返す。 157 | xv6はユーザや、あるユーザを他のユーザから保護する機構は持っていない; Unixのターミナルでは、xv6のプロセスはrootとして動作する。 158 | 159 | # I/Oとファイルディスクリプタ 160 | 161 | ファイルディスクリプタとはプロセスが読み書きを行うカーネルが管理するオブジェクトである。ファイルディスクリプタは小さな数字で表現される。 162 | プロセスはファイルやディレクトリ、デバイスをオープンしたり、パイプを作成したり、既存のディスクリプタを複製するためにファイルディスクリプタを獲得する。 163 | 簡単化のために、このファイルディスクリプタというオブジュクトを簡単に「ファイル」と呼ぶことにする; 164 | ファイルディスクリプタのインタフェースは、ファイル、パイプ、デバイスなどの違いを抽象化し、これらを全てバイトストリームのように扱うことができる。 165 | 166 | 内部的には、xv6カーネルはファイルディスクリプタをプロセス毎のテーブルとして取り扱っている。 167 | 従って全てのプロセスはファイルディスクリプタのためのプライベートな空間を持っており、それらはゼロから始まる識別子である。 168 | 慣習として、プロセスはファイルディスクリプタ0(標準入力)から読み込みを行い、ファイルディスクリプタ1(標準出力1)へ書き込みを行い、エラーメッセージをファイルディスクリプタ2(標準エラー出力)へ出力する。 169 | これから私達が見ていくように、シェルはこれらの慣習をうまく用いてI/Oのリダイレクトやパイプラインを実現する。 170 | シェルはこれらの3つのファイルディスクリプタがオープンであることを常に保証し(8507行目)、デフォルトのファイルディスクリプタはコンソールである。 171 | 172 | `read`と`write`システムコールはファイルディスクリプタで指定されたオープンしているファイルから、バイト列を読み込んだり、バイト列を書き込んだりするものである。 173 | `read(fd,buf,n)`はファイルディスクリプタ`fd`から最大でnバイトを読み込み、`buf`にコピーし、読み込んだバイト数を返す。 174 | 各ファイルディスクリプタはファイルの保持しているオフセットを参照している。 175 | `read`は現在のオフセットからデータを読み込み、読み込んだバイト数分だけオフセットを進行させる: 176 | 後続の`read`は最初の`read`が読み込みを完了した場所から読み込みを続ける。 177 | もしこれ以上読み込むデータが存在しない場合、`read`はゼロを返し、ファイルの最後であることを伝える。 178 | 179 | `write(fd,buf,n)`は`buf`からnバイトをファイルディスクリプタに書き込み、書き込まれたバイト数を返す。 180 | nよりも小さな値が返された場合、何らかのエラーが発生したことを示している。 181 | `read`のように、`write`もファイルの保持している現在のファイルオフセットを参照しており、書き込んだバイト数分だけオフセットを進ませる: 182 | 各`write`は前の`write`によりどれだけ進んだかを見て、書き込みを行う。 183 | 以下のプログラム列(このプログラムは`cat`の基本的な構造を示している)は、データを標準入力から読み込んで、標準出力に出力している。 184 | もしエラーが発生すると、標準エラー出力にメッセージを出力する。 185 | 186 | ```cpp 187 | char buf[512]; 188 | int n; 189 | for(;;){ 190 | n = read(0, buf, sizeof buf); 191 | if(n == 0) 192 | break; 193 | if(n < 0){ 194 | fprintf(2, "read error\n"); 195 | exit(); 196 | } 197 | if(write(1, buf, n) != n){ 198 | fprintf(2, "write error\n"); 199 | exit(); 200 | } 201 | } 202 | ``` 203 | 204 | このプログラム列の重要な部分は、ファイルから読み出すか、コンソールか、パイプから読み出すかについてはcatプログラム自身は知らないと言うことである。 205 | 同様にcatはファイルか、それ以外のところに書き込むかについても知ることはない。 206 | ファイルディスクリプタの利用と、ファイルディスクリプタ0が入力、ファイルディスクリプタ1が出力であるという慣習を使うことによりcatをより簡単に実装することができるようになる。 207 | 208 | `close`システムコールはファイルディスクリプタを開放し、未来の`open`,`pipe`,`dup`システムコール(後の章を参照のこと)で再利用できるようにするためのものである。 209 | 新たに割り当てられたファイルディスクリプタは、現在のプロセスで利用されていないディスクリプタの最小値が利用される。 210 | 211 | ファイルディスクリプタと`fork`はI/Oのリダイレクトを簡単に実装するために相互に動作する。 212 | forkは親プロセスのファイルディスクリプタのテーブルをメモリにコピーするため、子プロセスは親と完全に同一なファイルをオープンしていることになる。 213 | システムコールexecは呼び出し元のプロセスのメモリを置き換えるが、ファイルテーブルは維持する。 214 | この動作によりシェルがforkによりI/Oのリダイレクトを実装し、選択したファイルディスクリプタを再度オープンし、新しいプログラムを実行する。 215 | 以下がコマンド列`cat < input.txt`を実行したときのシェルの動作を簡単化したものである。 216 | 217 | ```cpp 218 | char *argv[2]; 219 | argv[0] = "cat"; 220 | argv[1] = 0; 221 | if(fork() == 0) { 222 | close(0); 223 | open("input.txt", O_RDONLY); 224 | exec("cat", argv); 225 | } 226 | ``` 227 | 228 | 子プロセスがファイルnディスクリプタ0を閉じることにより、openがそのファイルディスクリプタを新しいファイルinput.txtに0を使うことを保証している。 229 | 0は最小のファイルディスクリプタなので、close(0)をすると次に必ず使われる。 230 | catはファイルディスクリプタ0(標準入力)をinput.txtの参照として利用する。 231 | 232 | `fork`はファイルディスクリプタのテーブルをコピーするが、内部の各ファイルオフセットは親プロセスと子プロセスで共有している。次の例を考える。 233 | 234 | ```cpp 235 | if(fork() == 0) { 236 | write(1, "hello ", 6); 237 | exit(); 238 | } else { 239 | wait(); 240 | write(1, "world\n", 6); 241 | } 242 | ``` 243 | 244 | このコード列を実行すると、ファイルディスクリプタ1に割り付けらてたファイルにはデータとして"hello world"が出力される。 245 | 親プロセスの`write`は子プロセスのwriteがどこまでオフセットを進めたかを調査してから実行する(この`write`は`wait`のおかげで、子プロセスが完了してから実行される)。 246 | この動作により連続したコマンド列によって、連続した出力を実現することが可能になる(`echo hello; echo world > output.txt`) 247 | 248 | `dup`システムコールは既存のファイルディスクリプタを複製し、同一のI/Oオブジェクトに対して新しいディスクリプタを返す。 249 | どちらのファイルディスクリプタもオフセットを共有しており、あたかもファイルディスクリプタが`fork`により複製されたように動作する。 250 | これがhello worldをファイルに書き込むためのもう一つの方法である: 251 | 252 | ```cpp 253 | fd = dup(1); 254 | write(1, "hello ", 6); 255 | write(fd, "world\n", 6); 256 | ``` 257 | 258 | もしこの2つのファイルディスクリプタが、同じファイルディスクリプタから`fork`と`dup`のシステムコールより生成されたものならば、これらはオフセットを共有している。 259 | そうでなければ、ファイルディスクリプタは同一のファイルをオープンしたとしてもオフセットを共有しない。 260 | `dup`によりシェル上で以下のようなコマンドを実現することができるようになる: `ls existing-file non-existing file > tmp1 2>&1` 261 | `2>&1`はシェルに対してコマンドがファイルディスクリプタの2番目をファイルディスクリプタの1と複製させることを示している。 262 | 既存のファイルの名前と、存在していないファイルを表示しようとしたエラーメッセージはファイルtmp1に出力される。 263 | xv6シェルはエラーファイルのディスクリプタのリダイレクトをサポートしないが、実装の方法を知っておいて損はない。 264 | 265 | ファイルディスクリプタはファイルがどのように接続されているかを隠蔽することができるため、強力な抽象化の手段である: 266 | ファイルディスクリプタ1に書き込んでいるプロセスは、ファイルに書き込みをしているかもしれないし、コンソールのようなデバイスへ書き込みをしているかもしれないし、あるいはパイプに書き込みをしているかもしれない。 267 | 268 | # パイプ 269 | 270 | パイプ(`pipe`)はプロセスから見るとファイルディスクリプタのペアとして見え、ひとつは読み込み用で一つは書き込み用である。 271 | パイプの一方に書き込みを行うと、パイプのもう一方からデータを入手することができ、プロセス間で通信する手段を提供する。 272 | 以下のサンプルコードは、プログラム`wc`の標準入力をパイプの入力側に接続する例である。 273 | 274 | ```cpp 275 | int p[2]; 276 | char *argv[2]; 277 | argv[0] = "wc"; 278 | argv[1] = 0; 279 | pipe(p); 280 | if(fork() == 0) { 281 | close(0); 282 | dup(p[0]); 283 | close(p[0]); 284 | close(p[1]); 285 | exec("/bin/wc", argv); 286 | } else { 287 | write(p[1], "hello world\n", 12); 288 | close(p[0]); 289 | close(p[1]); 290 | } 291 | ``` 292 | 293 | このプログラムは`pipe`を呼び出し、新しいパイプを作成して配列pに読み込み用ファイルディスクリプタと書き込み用ファイルディスクリプタを登録する。 294 | `fork`の実行後、親プロセスと子プロセスはそれぞれそのパイプのファイルディスクリプタを参照する。 295 | 子プロセスは読み込み用のファイルディスクリプタである0を複製し、ファイルディスクリプタ0に設定しp中のファイルディスクリプタを閉じ、`wc`を実行する。 296 | wcが標準入力からファイルを読み込むと、それはパイプから読み込まれたことになる。 297 | 親プロセスがパイプの書き込み側に書き込みを行い、ファイルディスクリプタの両方を閉じる。 298 | 299 | データが取得できなければ、pipe中の`read`はデータが書き込まれるまでか、全ての書き込み用のパイプが閉じるまで待つ; 300 | 後者の場合には、readは0を返し、あたかもデータファイルの最後まで到達したかのように振る舞う。 301 | 302 | `read`が新しいデータが到着不可能になるまで実行をブロックするのは、子プロセスにとって`wc`を実行する前にpipeの書き込み側を閉じることが重要だからである: 303 | もし`wc`のpipeの書き込み側のファイルディスクリプタが参照することが出来るならば、wcは決してend-of-fileに到達しないからである。 304 | 305 | xv6のシェルは`grep fork sh.c | wc -l`のようなパイプラインを上記のコード列のように実現する(8450行)。 306 | 子プロセスがパイプラインの左側と右側を接続するためのパイプを作成する。 307 | 次に、パイプラインの左側のコマンドのために`runcmd`を実行し、次にパイプラインの右側のコマンドのために`runcmd`を実行する。 308 | そして左側のコマンドと右側のコマンドのどちらも終了することを確認するため、waitを2回呼ぶことで待ち合わせをしている。 309 | パイプラインの右側のコマンドそのものにもパイプが含まれていた場合(例えば`a | b | c`)、子プロセスを2回生成する(1回目はb用であり、2回目はc用)。 310 | 従って、シェルはプロセスのツリーを形成することになる。 311 | このツリーの葉はコマンドであり、接続ノードは左側のノードと右側のノードが終了するのを待つプロセスである。 312 | 基本的には、接続ノードがパイプラインの左側を実行する機能を含めることができるのだが、これを正しく実現するためには実装が複雑になる。 313 | 314 | パイプは一時ファイルを利用するのよりもより強力である: 以下のパイプライン 315 | ```sh 316 | echo hello world | wc 317 | ``` 318 | をパイプを使わずに実現しようとするならば、 319 | ```sh 320 | echo hello world > /tmp/xyz; wc < /tmy/xyz 321 | ``` 322 | としなければならない。 323 | 324 | パイプラインと一時ファイルで少なくとも3つの基本的な違いがある。 325 | まず、パイプはリダイレクションを利用することで自動的に一時ファイルを消去できる。 326 | シェルは最後に/tmp/xyzを削除して終了することに気をつけなければならない。 327 | 次に、パイプは非常に長いデータストリームも渡すことができ、一方でファイルのリダイレクトでは全てのデータを格納するための十分に大きいディスク領域が必要になる。 328 | 3番目に、パイプは同期を実現することができる: 329 | 2つのプロセスはパイプのペアを用いることにより、御互いにメッセージを送ることができ、`read`は、他のプロセスが`write`を実行するまでブロックさせることができるようになる。 330 | 331 | # ファイルシステム 332 | 333 | xv6のファイルシステムはデータファイルと呼ばれるバイト列と、データファイルの名前に対する参照と他のディレクトリを参照する情報が含まれているディレクトリを提供する。 334 | xv6はディレクトリを特殊な種類のファイルとして実装している。 335 | ディレクトリはツリーを構成し、特殊なディレクトリであるrootから開始される。 336 | `/a/b/c`のような形式で表現されるパスはルートディレクトリ/に含まれるディレクトリaに含まれるディレクトリbに含まれる名前cのファイル、もしくはディレクトリをrootが参照するため名前である。 337 | /から始まらないパスは現在のディレクトリから始まる相対的なパスとして評価される。 338 | この現在のディレクトリは`chdir`システムコールにより切り替えることができる。 339 | 以下のどちらのコード列も、同一のファイルを呼び出すものである(全てのディレクトリは存在するものとする) 340 | 341 | ```cpp 342 | chdir("/a"); 343 | chdir("b"); 344 | open("c", O_RDONLY); 345 | ``` 346 | 347 | ```cpp 348 | open("/a/b/c", O_RDONLY); 349 | ``` 350 | 351 | 最初のコード列は、プロセスの現在のディレクトリを`/a/b`に変更する; 2番目のコード列はプロセスの現在のディレクトリを変更しない。 352 | 353 | 新しいファイルやディレクトリを作成するためには、複数のシステムコールが存在する: 354 | `mkdir`は新しいディレクトリを作成し、`O_CREATE`フラグつきの`open`は新しいデータファイルを作成する。 355 | `mknod`は新しいデバイスファイルを作成する。以下の例はこれらの全てを説明したものである。 356 | 357 | ```cpp 358 | mkdir("/dir"); 359 | fd = open("/dir/file", O_CREATE|O_WRONLY); 360 | close(fd); 361 | mknod("/console", 1, 1); 362 | ``` 363 | 364 | `mknod`はファイルシステム上にファイルを作成するが、中には何も入っていない。 365 | その変わりに、このファイルのメタデータには、このファイルはデバイスファイルであるということが記録され、メジャーデバイス番号とマイナーデバイス番号が記録される(この2つの番号が`mknod`の引数に指定されている)。 366 | この2つの番号によりカーネルデバイスを識別する。 367 | 後続のプロセスがファイルを開くと、カーネルはファイルシステムを参照する代わりに、カーネルデバイスの実装を参照するように変換処理が入る。 368 | 369 | `fstat`はファイルディスクリプタが参照するオブジェクトの情報を探索する。 370 | `fstat`はその情報を`lstat.h`に定義されている`struct stat`構造体に格納する: 371 | 372 | ```cpp 373 | #define T_DIR 1 // ディレクトリ 374 | #define T_FILE 2 // ファイル 375 | #define T_DEV 3 // デバイス 376 | struct stat { 377 | short type; // ファイルタイプ 378 | int dev; // ファイルシステムが格納されているディスクデバイス番号 379 | uint ino; // inode 番号 380 | short nlink; // ファイルに張られているリンクの数 381 | uint size; // ファイルサイズ(バイト単位) 382 | }; 383 | ``` 384 | 385 | ファイル名は、ファイルそのものとは区別して取り扱われる;内部的には同一のファイルであることはinodeを使って表現され、inodeは`link`を呼ぶことにより複数の名前を持つことができる。 386 | `link`システムコールは同一のinodeに対して他のファイルシステムの名前を付ける。 387 | 次のプログラム列は、新しいファイルを作成して名前として`a`と`b`を付ける。 388 | 389 | ```cpp 390 | open("a", O_CREATE|O_WRONLY); 391 | link("a", "b"); 392 | ``` 393 | 394 | `a`に対して読み書きするのと、`b`に対して読み書きすることは同一である。 395 | どちらのinodeも、内部では同一の"inode番号"として識別される。 396 | 上記のコード列の後に`fstat`を実行することにより、`a`と`b`が内部では同一のファイルを参照していることが分かる: 397 | どちらの`fstat`も同一のinode番号(ino)を返し、`nlink`の番号が2とセットされているからである。 398 | 399 | `ulink`システムコールはファイルシステムから名前を除去する。 400 | ファイルのinodeとその内容を保持していたディスクスペースは、ファイルのリンク番号が0になり、どこからも参照されなくなるときに始めて解放される。 401 | 従って、上記のコード列に以下を追加すると、 402 | 403 | ```cpp 404 | unlink("a") 405 | ``` 406 | により、`b`という名前でのみアクセスできるようになる。さらに、 407 | 408 | ```cpp 409 | fd = open("/tmp/xyz", O_CREATE|O_RDWR); 410 | unlink("/tmp/xyz"); 411 | ``` 412 | 413 | 上記のコード列は、プロセスがfdを消去するときか、終了するときにクリーンすべきである一時ファイルを除去するための慣用句である。 414 | 415 | xv6では、ファイルシステムのためのコマンドは`mkdir,ln,rm`などのようなユーザレベルのプログラムとして実装されている。 416 | この設計では、誰でもシェルを拡張して新しいユーザコマンドを作成することができる。 417 | 後から考えてみると明らかなこのではあるのだが、当時のUnix以外のシステムでは、これらのコマンドはシェルの内部に実装されていることが多かった(そしてシェルはカーネルに内蔵されていた)。 418 | 419 | 一つの例外が`cd`であり、これはシェルに内蔵されている(8516行目)。 420 | `cd`は現在のワーキングディレクトリをシェル自身が変更しなければならない。 421 | もし`cd`コマンドを通常のコマンドとして実行すると、シェルが子プロセスを`fork`し、子プロセスが`cd`を実行しても、`cd`は「子プロセスの」ワーキングディレクトリを変更するだけで終わってしまい、親プロセス(例えば、シェルそのもの)のワーキングディレクトリは変更されない。 422 | 423 | # 現実の世界 424 | 425 | Unixにおいて「標準的な」ファイルディスクリプタ、パイプ、そして便利な文法を活用することで汎用的で再利用可能なプログロムを書くことができるのは大きな強みである。 426 | スパークした「ソフトウェアツール」の全体的な文化のアイデアはUnixの力と人気によるものであり、シェルは、いわゆる最初の「スクリプト言語」であった。 427 | UnixのシステムコールのインタフェースはBSD,Linux,Mac OS X などでも利用されている。 428 | 429 | 現代のカーネルでは、xv6よりもはるかに多くのシステムコールやカーネルサービスを提供している。 430 | 現代のUnixから派生したオペレーティングシステムの殆どは、先に議論したconsoleのようなデバイスのように、デバイスを特殊ファイルとして見せるような初期のUnixのモデルを踏襲していない。 431 | Unixの開発者達はPlan 9をビルドし続けているため、現代の装置や、ネットワークの表現や、グラフィックスや他の資源の操作をファイルやファイルツリーの操作として実現しており、「資源はファイルである」という方針を維持している。 432 | 433 | ファイルシステムの抽象化は強力なアイデアであり、World Wide Webのような現代の殆どのネットワーク資源にも適応されている。 434 | それでも、オペレーティングシステムのインタフェースとして他のモデルも存在する。 435 | MulticsのようなUnixよりも前のシステムではファイルストレージをメモリのように抽象化し、全く異なるインタフェースを作り出していた。 436 | Multicsの設計の複雑性はUnixの設計者達にも直接影響を与え、彼等はなるべく全てをシンプルに作ろうとした。 437 | 438 | 本書はxv6をUnix系のインタフェースとして実装する方法について述べるが、アイデアと概念はUnixだけに適用されるものではない。 439 | 多くのオペレーティングシステムは内部のハードウェアの上で複数のプロセスが走っており、各プロセスが独立して動作しプロセス間の通信を行う機構が提供されている。 440 | xv6を学んだあとは、読者はより複雑なオペレーティングシステムを学ぶことによって、xv6の内部の考え方がそれらのシステムにも同様に存在していることを見ることができるだろう。 441 | -------------------------------------------------------------------------------- /chapter1.md: -------------------------------------------------------------------------------- 1 | オペレーティングシステムの構成 2 | ===================== 3 | 4 | オペレーティングシステムの鍵は、様々な活動をサポートすることである。例えば、第0章で述べたシステムコールを用いて、プロセスは`fork`を呼び出すことにより新しいプロセスを開始することができる。オペレーティングシステムはこれらのプロセスがコンピュータ上の資源を「時分割共有」できるように管理する必要がある。例えば、プロセスがコンピュータ上に存在するプロセッサ数よりも多くのプロセスを生成したとしても、全てのプロセスは正しく動作しなければならない。加えて、オペレーティングシステムは各プロセスが「独立して」動作するための管理を行わなければならない。つまり、あるプロセスにバグがあり異常終了したとしても、そのプロセスと依存関係の無いプロセスに影響を与えてはならないということである。プロセス間で相互作用を可能にするため、完全な独立性は非常に強力な機能である; 例えば、ユーザにとって複雑なタスクを実行するために複数のプロセスを組み合せる(例えばパイプなど)ことができれば便利である。従って、オペレーティングシステムの実装は、「多重化、独立、相互作用」の3つの要件を達成する必要がある。 5 | 本章では、オペレーティングシステムが上記の3つの要件を満たすために、どのように構成されているかについて概要を説明する。これを実現するためには様々な手段が存在するが、本書では多くのUnixオペレーティングシステムでも採用されている「モノリシックカーネル」という方式を中心に、焦点を当てて説明を行う。本章では、この構成をまずxv6が動作を開始し、最初のプロセスが開始するところからトレースして説明する。この中で、本書ではxv6が提供する主たる抽象化について簡単に説明する。つまり、どのようにプロセスが相互作用を行い、どのようにして多重化、独立、相互作用の要求を満たすような構成を取っているのかについて説明する。xv6の殆どの部分は、最初のプロセスについての特別な場合分けについては避けており、xv6が提供する標準的な操作を再利用することで実現している。以降の章では、それぞれの抽象化についてより詳細に説明する。 6 | xv6はPCプラットフォーム上で、Intelの30386よりも後続の("x86")のプロセッサで動作し、多くの低レイヤの機能(例えば、プロセスの実装など)はx86の仕様に則して作られている。本書の読者はいくつかのアーキテクチャについて少しのアセンブリレベルでのプログラミングの経験があり、x86の仕様については、必要なときに随時説明する。付録Aで、PCプラットフォームについて概要を説明している。 7 | 8 | # 物理資源の抽象化 9 | 10 | オペレーティングシステムについて考えるとき最初に思い浮かぶ質問は、何故それが必要なのか?ということである。つまり、誰かが図0-2のシステムコールをライブラリとして実装しておき、アプリケーションとリンクさせておけば良いのではないかと考える訳である。この方法では、各アプリケーションは各々のライブラリを持っており、それぞれのアプリケーションで適切なライブラリを持っている。この方法ではアプリケーションはハードウェアと直接通信を行い、ハードウェア資源を各々のアプリケーションが最適な方法で使用する(例えば、最高の性能を得るための構成や所望の性能を得るための構成でアクセスを行う)。組み込みデバイス向けのいくつかの小さなオペレーティングシステムや、リアルタイムシステム向けのオペレーティングシステムではこのような方法を取っている。 11 | この方式の問題は、アプリケーションが自由にライブラリを使えることであり、つまりは各アプリケーションが「ライブラリを適切に使わない」ということである。もしアプリケーションがオペレーティングシステムのライブラリを利用しなければ、オペレーティングシステムは時分割共有をアプリケーションに強制させることができない。各アプリケーションが正しく動作しているということに依存するしかなくなり、例えば定期的にプロセッサの取得を諦め、他のアプリケーションがプロセッッサを獲得するようにしなければならない。このような「協力作業が必要な」時分割共有の構成は、全てのアプリケーションが正しく動作していることを信用しても大丈夫かもしれないが、各アプリケーションが相互に信用ならないものであると、強力な独立性を提供することができなくなる。 12 | 強力な独立性を実現するためには、各アプリケーションが直接ハードウェア資源にアクセスすることを禁止し、その代わりに各資源をサービスとして抽象化することが有効だと思われる。例えば、各アプリケーションはファイルシステムに対して`open`, `read`, `write`, `close`などのシステムコールでのみアクセスし、直接ディスクセクタは読まないようにする。これによりアプリケーションがパス名を利用することにより、オペレーティングシステムが(インタフェースの実装者として)ディスクを管理することができるようになる。 13 | 同様に、Unixのアプリケーションが`fork`を用いてプロセスとして動作することにより、アプリケーションが異なるプロセス間でスイッチする際の、レジスタのセーブとリストアをオペレーティングシステムが実現できるようになる。これにより、アプリケーションはプロセスのスイッチングについて気に掛ける必要が無くなる。さらに、例えばアプリケーションが無限ループに陥ったとしても、オペレーティングシステムが強制的にアプリケーションをプロセッサの外にスイッチすることができるようになる。 14 | 別の例として、Unixのプロセスは`exec`を用いることにより、直接物理的なメモリを操作する代わりにメモリイメージを構築できるようになる。これにより、オペレーティングシステムはどの領域をどのプロセスが使うかを決定し、メモリ領域が足りなければ領域の移動を行い、これらのイメージを格納しておくためのファイルシステムの利便性をアプリケーションに提供できるようになる。 15 | アプリケーション間で制御された相互作用をサポートするために、Unixのアプリケーションはファイルディスクリプタのみを使うことができる。(例えば、物理的なメモリの一部を予約するといったような)他の共有のための方法を取ることはない。Unixのファイルディスクリプタは共有のための詳細を抽象化し、ターミナル、ファイルシステム、パイプなどを使ってアプリケーションの相互作用が発生したとしても、詳細を隠すことができるようになる。 16 | 図0-2に示すようなシステムコールのインタフェースは注意深く設計され、利便性のためにプログラマに提供されているが、これらのインタフェースを実装することにより強力な独立性を実現できている。Unixインタフェースは資源を抽象化するだけでなく、それが非常に良いものであるということを証明している。 17 | 18 | # ユーザモード、カーネルモード、システムコール 19 | 20 | システムコールを利用するソフトウェアとシステムコールを実装する側のソフトウェアの間で強い独立性を提供するためには、オペレーティングシステムとアプリケーションの間に強力な境界が必要になる。アプリケーションにミスがあった場合にオペレーティングシステムまでもが終了するのは避けたい。その代わりに、オペレーティングシステムはアプリケーションをクリーンアップして、他のアプリケーションを実行し続けるこができる。この強力な独立性は、アプリケーションはオペレーティングシステムによって管理されるデータ構造を書き換えることができてはならないことを意味し、オペレーティングシステムの命令列を書き換えることができてはならないということを意味する。 21 | 22 | このような強力な独立性を提供するために、プロセッサはハードウェア的なサポートを提供する。例えば、x86プロセッサは他のプロセッサと同様に、命令を実行するための2つのモードを提供する:**カーネルモード** と **ユーザモード** である。カーネルモードでは、プロセッサは **権限のある命令** を実行することができる。例えばディスク(か、それ以外のI/Oデバイス)への書き込みを行うのは権限命令である。もしアプリケーションがユーザモードで実行されている中で権限のある命令を実行すると、実行してはならない命令を実行したということでプロセッサはその命令を実行せず、その代わりにカーネルモードに移行してカーネルモード中のソフトウェアはアプリケーションをクリーンアップする。第0章の図0-1ではその構成を示している。アプリケーションはユーザモードでのみ実行することができ(例えば、数を加算する、など)、これを **ユーザ空間** で実行していると呼ぶ。カーネル空間(もしくは、カーネルモード)で実行しているソフトウェアのことを **カーネル **と呼ぶ。 23 | 24 | ユーザモードのアプリケーションがディスクの読み書きをしたい場合、アプリケーション自身はI/O命令を実行することができないため、カーネルモードに以降する必要がある。プロセッサはユーザモードからカーネルモードにスイッチし、カーネルによって指定されるエントリポイントに入ることができる命令を提供している。(x86プロセッサでは`int`命令に相当する。)プロセッサが一度カーネルモードに以降すると、カーネルはシステムコールの引数をチェックし、アプリケーションが要求した操作の実行を許可するか決定する。カーネルモードに遷移した時に、カーネルがエントリポイントを設定することが重要である; もしアプリケーションがカーネルのエントリポイントを決定することができなければ、悪意のあるアプリケーションが引数の判定を行うコードをスキップすることができるからである。 25 | 26 | # カーネルの構成 27 | オペレーティングシステムの鍵となる設計の疑問として、オペレーティングシステムのどのような場所をカーネルモードで実行すれば良いかということがあげられる。シンプルな回答は、システムコールインタフェースがカーネルへのインタフェースである。つまり、`fork`, `exec`, `open`, `close`, `read`, `write` などは全てカーネルコールである。この選択は、オペレーティングシステムの実装は全てカーネルモードで実行されるということを示している。このカーネル構成を**モノリシックカーネル**と呼ばれる。 28 | この構成では、オペレーティングシステムの全ての部分は、フルハードウェア権限を持って実行される。この構成は、OSの設計者がどのオペレーティングシステムの部分でフルハードウェア権限が不要であるか考慮する必要が無いため便利である。さらに、オペレーティングシステムの異なる部分が協調することも簡単である。例えば、オペレーティングシステムはバッファキャッシュと呼ばれるファイルシステムと仮想メモリシステムの間で共有する機能を持つことができる。 29 | モノリシックな構成の弱点は、オペレーティングシステムの異な領域間のインタフェースがしばしば複雑ということである(これについては、本書の後の方で見ていく)。これによりオペレーティングシステムの開発者は間違いを犯しやすくなる。モノリシックカーネルでは、カーネルモードでの異常終了はカーネルが異常終了したことと同じ意味のため、間違いは致命傷となる。もしカーネルが異常終了すると、コンピュータは動作しなくなり、アプリケーションも動作しなくなる。コンピュータは再起動せざるを得なくなるのである。 30 | 31 | カーネルの間違いによるリスクを削減するためには、OSの設計者はなるべくカーネルモードで動作する領域を減らすことを考える。殆どのオペレーティングシステムが権限の必要な命令を実行することがなく、従ってユーザレベルアプリケーションとして動作させることができる。これによりメッセージによりアプリケーション間での通信ができるようになる。このカーネルの構成を**マイクロカーネル**と呼ぶ。 32 | 図1-1は、マイクロカーネルの構成を示している。この図では、ファイルシステムはユーザレベルアプリケーションとして動作している。オペレーティングシステムで、サービスは通常のユーザプログラムとして動作しこれをサーバと呼ぶ。アプリケーション同士がファイルサーバを通じて相互作用を行うために、マイクロカーネルはあるユーザモードのアプリケーションから他方のアプリケーションに向けてメッセージを送信するための最小のメカニズムを提供している。例えば、もしシェルのようなアプリケーションがファイルを読み書きしたければ、ファイルサーバにメッセージを送信してレスポンスを待てば良い。 33 | 34 | ![Figure1-01](images/figure1-01.JPG) 35 | 36 | マイクロカーネルでは、カーネルインタフェースはいくつかの低レイヤの関数で構成されており、これらはアプリケーションを開始したり、I/Oを操作したり、アプリケーションに向けてメッセージを送信したりする。この構成により、オペレーティングシステムの殆どの機能はユーザレベルのサーバとして実装することができるようになるため、カーネルはより少ないコード量により実装することができるようになる。 37 | 現実の世界では、モノリシックカーネルとマイクロカーネルのオペレーティングシステムの両方が存在する。例えばLinuxは殆どがモノリシックカーネルとして実装されているが、いくつかのOSの機能はユーザレベルのサーバとして実装されている(例えば、ウィンドウシステムなど)。xv6は、Unixオペレーティングシステムに習い、モノリシックカーネルとして実装されている。従って、オペレーティングシステムインタフェースはカーネルインタフェースに相当する。xv6は多くの機能をサポートしないため、xv6のカーネルはマイクロカーネルよりも小さい。 38 | 39 | # プロセスの概要 40 | (Unixオペレーティングシステムとしての)xv6の独立性の単位は「プロセス」である。プロセスの抽象化によって、あるプロセスは他のプロセスからメモリの内容、CPUファイルディスクリプタなどの情報を破壊されたり、盗み見られたりすることを防ぐ。また、独立性によりプロセスがカーネルそのものを破壊してしまうことも防ぐ(例えば、独立性を強制することによりカーネルを保護している)。カーネルはプロセス抽象化を実装していなければならず、これによりバグのあるプログラムや悪意のあるプログラムがカーネルもしくはハードウェアに対して攻撃を仕掛ける(例えば独立性を破るような操作)ことを防いでいる。カーネルにより利用されているプロセスを生成するメカニズムには、ユーザ/カーネルモードフラグ、アドレス空間、スレッドのタイムスライスなどが含まれており、これらについては本節にて概要を示す。 41 | 独立性を提供するために、プロセスは固有の抽象化されたマシンを持っているかのように実現される。プロセスはプログラムとプライベートなメモリシステム、もしくは「アドレス空間」を提供し、他のプロセスがその領域を読み書きすることを防ぐ。プロセスは同様にプログラムが自分の命令を実行するための固有のCPUを持っているかのように動作するための抽象化を提供する。 42 | xv6はハードウェアに実装されているページテーブルを利用して、各プロセスが固有のアドレス空間を保有することを実現している。図1-2に示すように、アドレス空間には、仮想アドレスの0番から始まる「ユーザメモリ」の領域が含まれている。まず命令が登場し、次にグローバルな変数、スタック、そして最後にヒープ領域(`malloc`用)が存在している。ヒープ領域はプロセスが必要に応じて拡張できるように配置されている。 43 | 44 | ![Figure1-02](images/figure1-02.JPG) 45 | 46 | 各プロセスのアドレス空間は、カーネルの命令とデータが、ユーザのプログラムメモリ上に同様にマップされている。プロセスがシステムコールを呼び出すと、プロセスのアドレス空間上にマップされたシステムコールが呼び出される。この構成により、カーネルのシステムコールのコードはユーザメモリを参照するようになっている。ユーザメモリ領域が拡張されていくことを想定して、xv6のアドレス空間では、カーネルは0x80100000から始まる高い領域にマップされている。 47 | xv6カーネルは各プロセスの多くの状態を管理しており、それらは``struct proc``(2353行目)により集められている。プロセスが保持するカーネル状態の最も重要な情報はそのページテーブルと、カーネルスタック、実行状態である。私達は`p->xxx`という表記によって、`proc`構造体のメンバ変数を表現することにする。 48 | 各プロセスは実行のスレッド(もしくは単に**スレッド**)を保持しており、これはプロセスの命令を実行するものである。スレッドはサスペンドしたり、再開したりする。プロセスの間を透過的にスイッチするためには、カーネルは現在実行されているスレッドをサスペンドし、他のプロセスのスレッドを再開する。スレッドの非常に多くの状態(ローカル変数、関数コールのリターンアドレスなど)は、スレッドのスタックに保持されている。各プロセスは2つのスタックを持っている: ユーザスタックとカーネルスタックである(`p->kstack`)。プロセスがユーザ命令を実行しているならば、ユーザスタックのみが利用されており、カーネルスタックは空である。もしプロセスが(システムコールや割り込みなどにより)カーネルモードに入った場合、カーネルコードが実行され、プロセスのカーネルスタックが利用される; プロセスがカーネルモード中は、ユーザスタックは現在のデータを保持しているが、実際には使用されていない。プロセスのスレッドは、ユーザモードとカーネルモードを、ユーザスタックとカーネルスタックを利用してアクティブに行き来する。カーネルスタックは分離されており(そしてユーザコードからも保護されている)、カーネルはプロセスのユーザスタックが破壊されていたとしても、カーネルコードを実行ることができるようなっている。 49 | プロセスがシステムコールを生成すると、プロセッサはカーネルスタックに遷移しハードウェアの権限レベルを上昇させる。そしてカーネル命令を実行開始する。システムコールが完了すると、カーネルはユーザ空間に戻ってくる: ハードウェアの権限は再び下がり、ユーザスタックに再びスイッチされ、システムコール命令が実行された直後からユーザ命令が実行される。プロセスのスレッドはI/Oなどを待つために実行を「ブロック」することができ、I/Oが完了すると、再び実行を再開する。 50 | `p->state`はプロセスの現在の実行状態を示す。これは「run:実行状態」「running:実行可能状態」「wait:待ち状態(I/O用)」「exit:終了状態」である。 51 | `p->pgdir`はプロセスのページテーブルを保持しており、これはx86のハードウェアが期待するフォーマットで構成されている。xv6はページングのハードウェアを参照する際にプロセスの`p->pgdir`を活用する。プロセスのページテーブルはプロセスのメモリを割り当て、情報を格納するための物理ページのアドレス情報を保持する役割も担っている。 52 | 53 | # コード例: 最初のアドレス空間 54 | xv6の構造をより具体的に紹介するために、私達は、カーネルがカーネル自身のために、最初のアドレス空間をどのようにして作成するのかについて見ていく。カーネルがどのようにしてアドレス空間を作成し、どのようにして最初のプロセスを開始し、最初のプロセスを作るためのシステムコールをどのようにして呼び出すのかについて見ていく。これらの操作をトレースすることにより、私達はxv6がどのようにプロセスの協力な独立性を提供しているのかを見ることができる。最初のステップとして協力の独立性はカーネルが自分自身のアドレス空間上で実行する状態を構築するところからである。 55 | 56 | PCの電源を入れると、PCは自分自身を初期化して「ブートローダ(boot loader)」をディスクからメモリに展開し、実行する。 57 | 付録Bにその詳細を説明している。 58 | xv6のブートローダはxv6のカーネルをディスクから読み出して、`entry`から実行を開始する(1040行目)。 59 | x86のページングハードウェアはカーネルが実行された時点では有効になっていない; 仮想アドレスは物理アドレスを直接マッピングした状態になっている。 60 | 61 | ブートローダがxv6のカーネルを物理アドレスの0x100000へロードする。 62 | カーネルを0x80100000へロードしない理由は、カーネルは自分自身の命令とデータが、小さなマシンだと大きな物理メモリなアドレス空間に配置できない状況を想定して、このような配置になっている。 63 | カーネルを0x0に配置するのではなく、0x100000に配置する理由は、0xa0000から0x100000にはI/Oデバイスが含まれているからである。 64 | 65 | カーネルの残りが実行できるようにするためには、`entry`が0x80000000から始まる仮想アドレス空間(`KERNBASE`と呼ばれる)を0x0から始まる物理アドレス空間にマッピングする。 66 | 2つの領域の仮想アドレスを1つの物理メモリの領域にマッピングすることは、ページテーブルでは一般的であり、後にもこのような例をいくつか紹介する。 67 | 68 | この`entry`のページテーブルは`main.c`に定義されている(1311行目)。 69 | ページテーブルの詳細は第2章で見ていくが、簡単に説明するとエントリ0は仮想アドレス0:0x400000を物理アドレス0:0x400000にマッピングしている。 70 | このマッピングは`entry`が低いアドレスで実行されている期間は必要な設定であり、しかし最終的には削除される。 71 | 72 | `entry`の512番目は仮想アドレス`KERNBASE`:`KERNBASE`+0x400000を物理アドレス0:0x400000にマッピングしている。 73 | このエントリはカーネルが`entry`を実行し終えたときに利用される; カーネルはより高い仮想アドレスにマッピングされるが、カーネルは命令やデータがブートローダのロードしたより低いアドレスで実行されることを想定している。 74 | このマッピングにより、カーネルの命令とデータは4Mバイト以内である制限が生じる。 75 | 76 | `entry`に戻ると、`entry`は`entrypgdir`の物理アドレスを制御レジスタ`%cr3`にロードする。 77 | ページングハードウェアは`entrypgdir`の物理アドレスを知っていなければならない。 78 | これは、ページングハードウェア仮想アドレスの変換方法をまだ知らないからである; まだページテーブルは存在していないのである。 79 | シンボル`entrypgdir`は高いメモリ空間のアドレスを指し、マクロ`V2P_W0`(0220行目)は物理アドレスを算出するために`KERNBASE`を減算するためのマクロである。 80 | ページングハードウェアを有効にするためには、xv6は`%cr0`レジスタに対して`CR0_PG`をフラグを設定する。 81 | 82 | ページングが有効になったとしても、プロセッサは相変わらず低いアドレス上で実行されている。 83 | これは`entrypgdir`が低いアドレスにマッピングされているからである。 84 | もしxv6が`entrypgdir`からエントリ0を取り除くと、コンピュータは、有効なページの後ろに配置されている命令を実行したときにクラッシュしてしまう。 85 | 86 | さて、`entry`はカーネルのCコードに遷移する必要があり、高いメモリアドレスに遷移して実行する必要がある。 87 | まずスタックポインタ`%esp`を作成し、メモリのスタック領域に設定する(1054行目)。 88 | stackを含む全てのシンボルは高いアドレス空間上に配置されているため、低いマッピンが除去されたとしてもスタックは有効である。 89 | 最後に`entry`はmainにジャンプして、高いアドレスに遷移する。この間接的なジャンプを実現するためにはアセンブラが必要であり、そうでなければ、コンパイラはPC相対の直接ジャンプを生成し、低いメモリ領域のmainを実行してしまう。PCはスタック上に格納されていないため、mainはリターンすることはできない。 90 | ここからは、カーネルは高いアドレスのmainに遷移して実行を開始する。 91 | 92 | # コード例: 最初のプロセスを生成する 93 | ここまでで、カーネルは自分のアドレス空間で実行することができるようになった。次に、カーネルはどのようにしてユーザレベルプロセスを生成し、どのようにしてカーネルプロセスとユーザレベルプロセス、そしてプロセスそのもの同士の独立性を保つのかを見ていく。 94 | main関数がいくつかのデバイスとサブシステムを初期化すると、main関数は`userinit`(1239行目)を呼び出して最初のプロセスを作成する。`userinit`関数の最初の仕事は、`allocproc`を呼び出すことである。`allocproc`(2455行目)の仕事はプロセステーブルのスロット(`struct proc`構造体)とカーネルスレッドが実行可能になるために、プロセスの状態を初期化することである。`allocproc`は新しいプロセスが生成されると呼び出されるが、一方で`userinit`は最初のプロセスでしか呼ばれない。`allocproc`はprocテーブルをスキャンして、UNUSEDな状態のスロットを探索する(2461-2463行目)。利用していないスロットを探索すると、`allocproc`はそのスロットの状態を`EMBRYO`として利用できるようにマークし、プロセスのユニークなpidを割り当てる(2451-2469行目)。次に、`allocproc`はプロセスのカーネルスレッドのためにカーネルスタックを割り当てる。もしメモリ割り当てに失敗すると、`allocproc`はその状態をUNUSEDとし、ゼロを返し失敗であることを通知する。 95 | 96 | さて、`allocproc`は新しいプロセスのためのカーネルスタックを構築しなければならない。`allocproc`は最初のプロセスを生成するのと同様に、`fork`を使って記述することができる。`allocproc`は特別に容易されたカーネルスタックと、最初に実行が開始されたときにユーザ空間に「戻る」ためにいくつかのカーネルレジスタの設定する。図1-4に、準備の完了したカーネルスタックのレイアウトを示す。`allocproc`はこの一連の動作の中の一部分を担い、新しいプロセスのカーネルスレットが、`EMBRYO`され、さらに`trapret`により最初に実行されるときのための戻り値となるプログラムカウンタを設定する役割を担っている(2486-2491)。カーネルスレッドは、`p->context`からコピーされた命令と、レジスタ内容を用いて実行を開始する。よって、`p->context->eip`を`forkret`に設定することによって、`forkret`の先頭からカーネルスレッドが動作するようになる(2783行目)。この関数はスタックの底辺に格納されているアドレスに戻ってくる。コンテキストスイッチを実現するためのコード(2958行目)は、スタックポインタを`p->context`の一つ上を指すように設定する。`allocproc`は`p->context`をスタックの上に載せ、その上に`trapret`を載せる;これにより、`forkret`に戻されることになる。`trapret`はカーネルスタックのトップに格納されているユーザレジスタを書き戻し、プロセスにジャンプする(3277行目)。このセットアップは、オリジナルの`fork`の動作と最初のプロセスを生成する手順と同一であるが、最初のプロセスを生成する手順では、`fork`から帰ってから実行するのではなく、ユーザ空間のアドレス0から実行を開始する。 97 | 98 | ![Figure1-04](images/figure1-04.JPG) 99 | 100 | 第3章でも見るが、ユーザソフトウェアからカーネルへ制御を転送する方法は、システムコール、割り込み、例外などの割り込み機構を用いて実現される。プロセスが実行中に制御がカーネルに遷移しても、ハードウェアとxv6のtrapエントリコードがユーザレジスタをプロセスのカーネルスタック上に保存する。`userinit`は新しいスタックの上に値を書き込み、プロセスが割り込みによりカーネルに入ったときに、あたかもそこに存在していたかのような状況を実現する。これにより、コードがカーネルスタックから戻ってきて、プロセスのユーザコードに遷移しても正しく動作するのである。これらの値は、ユーザレジスタに格納されている`struct trapframe`である。ここで、新しいプロセスのカーネルスタックは図1-4に示すように、完全に準備されたものになっている。 101 | 最初のプロセスは、小さなプログラム(`initcode.S`(8200行目))を実行する。プロセスにはプログラムを格納するための物理的なメモリが必要であり、プログラムはそのメモリにコピーされなければならない。またプロセスはメモリを参照するためにページテーブルを設定しなければならない。 102 | `userinit`は``setupkvm``(1837行目)を呼び出して、(最初に)プロセスのページテーブルを、カーネルが使うようにメモリのみをマッピングする。第2章でこの関数については詳細に学ぶが、大まかに見れば、`setupkvm`と`userinit`は図1-2に示すようなアドレス空間を生成する。 103 | 最初のプロセスのメモリの初期内容は、`initcode.S`からコピーされる。これはカーネルのビルドプロセスの一部であり、リンカがこのバイナリをカーネルに埋め込み、2つの特別なシンボルを定義する。これが`_binary_initcode_start` と `_binary_initcode_size`である。これらはバイナリの場所とサイズを示している。`userinit`は`inituvm`を呼び出すことによりこのバイナリを新しいプロセスのメモリ空間にコピーする。`inituvm`は物理メモリのページを割り当て、そのメモリ空間を仮想アドレスの0番にマッピングする。そしてバイナリをページの領域にコピーするのである(1903行目)。 104 | `userinit`は初期のユーザモードの状態とともに、トラップフレームを設定する:`%cs`レジスタには`DPL_USER`の権限で動作している`SEG_UCODE`セグメントのためのセグメントセレクタが入っており、同様に`%ds`,`%es`,`%ss`も権限`DPL_USER`として`SEG_UDATA`を利用する。`%eflags`の`FL_IF`ビットは、ハードウェアの割り込みを許可するために設定され、これらは第3章で再び調査する。 105 | スタックポインタ`%esp`はプロセスの最も大きな有効仮想アドレスである`p->sz`に設定される。この命令ポインタは初期化コードのエントリポイントである、アドレス0に設定される。 106 | 関数`userinit`は`p->name`を`inicode`に設定する。これはデバッグ用である。`p->cwd`を設定することにより、プロセスの現在のワーキングディレクトリを設定する;第6章では、`namei`について詳細を調査していく。 107 | 一度プロセスが初期化されると、`userinit`は`p->state`を`RUNNABLE`に設定し、スケジュール可能な状態であることをマークする。 108 | -------------------------------------------------------------------------------- /chapter2.md: -------------------------------------------------------------------------------- 1 | ページテーブル 2 | ========= 3 | 4 | ページテーブルは、オペレーティングシステムがメモリアドレスの意味を制御するために必要なメカニズムである。ページテーブルにより、xv6は複数のプロセスが複数のアドレス空間を持つことができるようになり、さらにプロセス間でメモリ領域を犯すことを防ぐことができる。ページテーブルにより提供される間接的なレベル付けにより、多くの巧妙な手段を利用することができるようになる。xv6はページテーブルをアドレス空間の分割とメモリの保護に利用する。それ以外にも、いくつかの簡単なページテーブルのトリックが存在する:いくつかのアドレス空間を同一のメモリにマッピングする、同じメモリ領域を1つ以上のアドレス空間にマッピングする(各ユーザページはカーネルの物理的なメモリ領域にマップされる)、ユーザスタックをマッピングされていないページから守る、などのことが可能になる。本章では、x86ハードウェアが提供するページテーブルについて説明し、xv6がそれをどのように利用しているのかについて見る。 5 | 6 | # ページングハードウェア 7 | まず、x86命令は(ユーザ命令もカーネル命令も)仮想アドレス空間を操作することを覚えておこう。マシンのRAM、物理メモリは物理アドレスによってインデックが付けられている。x86ページテーブルハードウェアは、仮想アドレスから物理アドレスに向けてマッピングをすることにより、これらの2つのアドレスを結び付けている。 8 | x86ページテーブルは、論理的には2^20(1,048,576)個のページテーブルエントリ(PTE)の配列である。各PTEは20ビットの物理ページ番号(PPN)といくつかのフラグを保持している。ページングハードウェアはこの上位の20ビットを使いPTEを探すためにページテーブルのインデックスに変換する。ページングハードウェアは仮想アドレスから物理アドレスの変換において下位の12ビットは変更しない。これにより、ページテーブルはオペレーティングシステムに対して仮想アドレスから物理アドレスに変換する手段を提供する。このときに、4096(2^12)バイトのアラインされた塊が並ぶことになる。このような塊のことを **ページ** と呼ぶ。 9 | 10 | 図2-1に示すように、実際の変換には2ステップが必要である。ページテーブルは2レベル木として物理メモリに格納されている。木のルートは4096バイトの **ページディレクトリ** であり、1024個のPTEのような **ページテーブルページ** を提供している。各ページテーブルページは1024個の32ビットPTEである。ページングハードウェアは、仮想アドレスの上位の10ビットを使ってページディレクトリのエントリを選択する。もしページディレクトリのエントリが存在すれば、ページングハードウェアは次の10ビットを使ってページディレクトリページが参照しているページテーブルページからPTEを選択する。もしページディレクトリエントリかPTEが存在していなければ、ページングハードウェアは失敗を宣告する。この2レベルの構造により、ページテーブルはが非常に幅広いアドレス空間からマッピングの存在しない殆どのケースを除外することができるようになる。 11 | 12 | ![Figure2-01](images/figure2-01.JPG) 13 | 14 | 各PTEにはフラグビットが格納されており、関連付けられたアドレスが利用できるかどうかを示している。`PTE_P`はPTEが存在しているかを示している:もしこれがセットされてなければ、ページの参照によりエラーが発生する(つまり、許可されていない)。`PTE_W`はどの命令がページへ書き込みを発行して良いかを制御してチェックする。もしこのビットがセットされていないと、その領域はページのデータ読み込みとフェッチのみがサポートされていることを意味する。図2-1はこれらがどのように動作するかを示している。フラグと全ての他のページハードウェアは`mmu.h`(700行目)に記述されている。 15 | ここで、いくつか用語について補足しておく。物理メモリはDRAM上の保存用セルを参照している。物理メモリの1バイトは、物理アドレスと呼ばれるアドレスを持っている。命令は常に仮想アドレスしか利用しない。ページングハードウェアはこれを物理アドレスに変換し、これをDRAMに転送しストレージの読み書きを行う。このディスカッションのレベルでは、仮想メモリアドレスだけで、仮想メモリのようなものは存在しないとする。 16 | 17 | # プロセスのアドレス空間 18 | `entry` によって作成されたページテーブルは、カーネルのCコードを実行させるにはまだ十分なマッピングを備えてはいない。しかし、`main`は`kvmalloc`(1857行目)を即座に呼び出して、新しいページテーブルに変更する。何故ならば、カーネルはプロセスのアドレス空間を記述するためのより洗練された計画を持っているからである。 19 | 各プロセスは分離されたページテーブルを保持しており、xv6のプロセスが切り替わるとxv6はページテーブルのハードウェアに対してテーブルをスイッチするように指示する。 20 | 図2-2に示すように、プロセスのユーザメモリは仮想アドレス0番地から始まり、`KERNBASE`まで広がっている。プロセスは最大で2GBまでのメモリを確保している。`memlayout.h`(200行目)によってxv6のメモリレイアウトのための定数を宣言しており、仮想アドレスから物理アドレスへの変換マクロを定義している。 21 | 22 | ![Figure2-02](images/figure2-02.JPG) 23 | 24 | プロセスがより多くのメモリを必要とすると、xv6はより多くのストレージを確保するためにフリーな物理ページを探し、PTEをそのプロセスのページテーブルに追加し、新しい物理ページを指すようにする。xv6はPTEに対して`PTE_U`,`PTE_W`,`PTE_P`フラグを設定する。殆どのプロセッサはプロセスのユーザアドレス空間全体を利用することは無い; xv6は使用していないPTEに対しては、`PTE_P`をクリアにしておく。異なるプロセスのページテーブルがユーザアドレスを異なる物理メモリのページに変換するため、各プロセスはプライベートなユーザメモリを確保することができる。 25 | xv6はカーネルが全てのプロセスのページテーブルで実行するために必要なマッピングも持っている; これらののマッピングは`KERNBASE`の上位に登場する。このマッピングは、仮想アドレスの`KERNBASE`:`KERNBASE`+`PHYSTOP`を物理アドレスの`0:PHYSTOP`までにマッピングする。このマッピングの理由は、カーネルがカーネル自身の命令とデータを利用できるようにするためである。もう一つの理由はカーネルはしばしば与えられた物理メモリのページに書き込む必要がある。例えば、ページテーブルを作成したときは、予測可能な仮想アドレス上に全ての物理ページが保持されていたほうが便利である。この配置の問題は、xv6が物理メモリを2GB以上作成することができないことである。いくつかのデバイスはメモリマップドI/Oを利用しており、物理アドレスが`0xFE000000`から開始するものがある。従って、xv6のページテーブルは、その領域へのダイレクトマッピングも持っている。xv6は`KERNBASE`よりも上位のPTEに対して`PTE_U`フラグを設定せず、カーネルだけがそれを利用することができるのである。 26 | 全てのプロセスが、ユーザメモリとカーネル全体を参照するためのページテーブルを保持することによって、システムコールや割り込みが発生したことによるユーザコードからカーネルコードへの変換が便利になる: このようなスイッチでは、ページテーブルのスイッチが必要無くなる。カーネルの殆どの部分では、自身のページテーブルは不要である;常にいくつかのプロセスのページテーブルを借りている状態である。 27 | まとめると、xv6は各プロセスが自身のメモリしか利用しないことを保証しており、各プロセスのメモリ空間は仮想アドレスの0番地から連続して取られている。xv6は最初にプロセス自身が参照するメモリ領域のみPTEの`PTE_U`ビットを設定するように実装されていぅ。次に、ページテーブルの機能を用いて後続の仮想アドレスを、プロセスに割り当てられた任意の物理ページへと変換する。 28 | 29 | #コード例: アドレス空間の作成 30 | `main`から呼ばれた`kvmalloc`(1857行目)は、カーネルを実行するのに必要な`KERNBASE`よりも上位のマッピングを行うためのページテーブルの作成とスイッチを行う。殆どの処理は`setup-kvm`で実行される(1837行目)。最初にページディレクトリを格納するためのメモリを割り当てる。次に`mappages`を呼び出し、`kmap`(1828行目)配列に記述されているカーネルに必要なアドレス変換処理を設定する。変換する領域は`PHYSTOP`までの領域で、カーネルの命令列とデータが含まれている領域と、実際にはI/Oデバイスが割り当てられている領域である。`setup-kvm`はユーザメモリ向けのマッピングは一切設定しない; この設定は後で行われる。 31 | 32 | `mappages`(1779行目)は仮想アドレスの領域を該当する物理アドレスの領域へのマッピングをページテーブルに設定する。この領域の各仮想アドレスは、ページのインターバルによって別々に設定される。マッピングされた仮想アドレスのために、`mappages`は`walkpgdir`を呼び出して、そのアドレスのためのPTEのアドレスを探索する。次にPTEを初期化して関係のあるページテーブルの番号を設定し、所望のパーミッション( 33 | 34 | `PTE_W`や`PTE_U`など)を設定し、`PTE_P`を設定することでPTEが有効化する(1791行目)。 35 | 36 | `walkpgdir`(1754行目)は、x86のページングハードウェアの動作を模倣しており、仮想アドレス変換のためにPTEを探索する(図2-1を参照のこと)。`walkpgdir`はページディレクトリのエントリを探索するために仮想アドレスの上位10ビットを利用する(1759行目)。もしページディレクトリのエントリが存在しなければ、必要なページテーブルは割り当てられていないということになる; もし`alloc`の引数が設定されていれば、`walkpgdir`はメモリの割り当てを行い、その物理アドレスをページディレクトリに格納する。最後に、仮想アドレスの次の10ビットを利用してページテーブルページ内のPTEのアドレスを探索する(1772行目)。 37 | 38 | # 物理メモリの割り当て 39 | カーネルは実行時のページテーブルやプロセスのユーザメモリ、カーネルスタック、パイプバッファのために、解放されている領域の中から物理メモリを割り当てる必要がある。 40 | xv6はランタイムでの割り当てを実現するために、カーネルの最後の領域から`PHYSTOP`までの物理メモリの領域を。その際に4096バイトのページの割り当てや解放を実行する。解放されているページ自身をリンクリストに繋いで記録しておく; ページを解放すると、このリストに解放されたページが挿入される。 41 | ここで、ブートストラップの問題が発生する:全ての物理メモリはフリーリストを初期化するために順番にマッピングされなければならないが、このようなマッピングが発生するページテーブルには、ページテーブルページの割り当ても必要となる。xv6はこの問題を、xv6が立ち上がっているときには異なるページアロケータを使用することで解決している。このページアロケータは、カーネルのデータセグメントの直後からメモリを割り当てる。このアロケータはメモリの解放はサポートしておらず、`entrypgdir`中の4MBまでのマッピングに制限されている。しかし最初のカーネルのページテーブルを割り当てるのには、これで十分である。 42 | 43 | # コード例: 物理メモリアロケータ 44 | アロケータのデータ構造は、割り当てのための利用可能な物理メモリページの **フリーリスト** である。各フリーページリストの要素は`struct run`である(3014行目)。このデータ構造を保持するためのメモリはどのようにしてアロケータは取得するのだろうか?アロケータは各フリーページの`run`構造体をフリーページのそものに格納している。従って、それ以外に格納場所を確保する必要は無い。フリーリストはスピンロックにより保護されている。リストとロックは構造体によってラップされており、この構造体のフィールドをロックで守っているのだということを明確にしている。今のところは、**acquire** と **release** の呼び出しについては無視し、ロックについては無視することにする; 第4章で、ロックの詳細について見ていくことにする。 45 | 46 | `main`関数が`kinit1`と`kinit2`を呼び出し、アロケータを初期化する。2回に分けて呼び出すのは、`main`の多くが4Mバイトを越えるロックやメモリを扱うことができないからである。`kinit1`は最初の4Mバイトにロックをしないメモリ割り当てを行い、`kinit2`はロックの有効化と他に割り当て可能なメモリを割り当てる。`main`の目的は、物理メモリがどれだけ利用可能かを計ることだが、x86でこれを実現するのは難しい。その代わらに、マシンは240Mバイト(`PHYSTOP`)の物理メモリを持っていると仮定し、フリーメモリのプールを初期化する。`kinit1`と`kinit2`は``freerange``を呼び出し、ページ毎に`kfree`を呼び出すことでフリーリストにメモリを追加していく。PTEは4096バイトの境界にアラインされた物理アドレス(つまり、4096の倍数)しか参照することができないので、`freerange`は`PGROUNDUP`を用いてフリーページが物理アドレスにアラインされていることを保証する。このアロケータはメモリが全く存在しないところからスタートする; ページが`kfree`をコールし管理できるようにするのである。 47 | 48 | アロケータは、高位置のメモリにマップされている仮想アドレスを用いて物理ページを参照するのであり、物理アドレスそのものを使って参照している訳ではない。 49 | これが、`kinit`が`p2v(PHYSTOP)`を使って`PHYSTOP`(物理アドレス)を仮想アドレスに変換している理由である。アロケータは、アドレスの計算をしやすいように時々アドレスを整数として取り扱う(例えば、`kinit`内の全てのページを参照する場合など)、またはメモリの内容を読み書きするためにアドレスをポインタとして取り扱う(例えば、各ページに格納されている`run`構造体の操作など); このアドレスの2つの方法による使い分けが、アロケータのコードが完全なCの型変換となっている理由である。もう一つの理由がは、メモリの割り当てと解放は本質的にメモリのタイプの変更と同一だからである。 50 | 51 | 関数`kfree`(3065行目)は、まずメモリ中の全バイトを値を1に設定するところから始まる。これにより、解放したはずの領域を読み込もうとしたコードは(これは「危険な参照」である)、所望の内容の代わりにゴミが読み込まれることとなる; このようなコードは、本来はなるべく早くブレークされるべきである。次に、`kfree`は`v`を`struct run`へのポインタへキャストし、`r->next`へ前のフリーリストの先頭を設定し、`freelist`そのものを`r`と設定する。`kalloc`はフリーリストの最初の要素を取り出し、フリーリストから除去する。 52 | 53 | # アドレス空間のユーザメモリ部分 54 | 図2-3はxv6で実行されるプロセスのユーザメモリ領域のレイアウトを示している。ヒープ領域はスタックの上側に存在し、ヒープが拡張できるようになっている(これは`sbrk`によって実行される)。スタックは単一のページに収められ、初期化時の内容は`exec`によって作成される。コマンドラインの引数として格納される文字列は、同様にスタックの上位にポインタの配列として格納されている。これでやっと`main`関数を、関数呼び出し`main(argc,argv)`のように呼び出すための準備が整う。スタックがスタックページから飛び出していくことを防ぐために、ガードページが、スタックの下側に配置されている。 55 | ガードページはマップされておらず、従ってスタックがスタックページを越えてしまった場合にアドレスを変換できないことによるページエラーが発生するようになっている。 56 | 57 | ![Figure2-03](images/figure2-03.JPG) 58 | 59 | # コード例: `exec` 60 | `exec`システムコールはアドレス空間のユーザメモリ部分を作成する。`exec`は、ファイルシステムに格納されているファイルでアドレス空間の一部を初期化する。`exec`(6310行目)は、まずは名前で指定されたバイナリのパスを`namei`(6321行目)を用いて開く。 61 | `namei`についいては第6章で説明する。次に、ELFヘッダを読み込む。ELFバイナリは、ELFヘッダ、`struct elfhdr`(0955行目)から始まり、プログラムセクションヘッダ`struct proghdr`(0974行目)が続いている。各`proghdr`は、メモリにロードされなければならないアプリケーションのセクションを記述している; xv6プログラムは1つのセクションヘッダしか保持していないが、他のシステムでは命令とデータなど、複数のセクションを保持している。 62 | 63 | まず最初に、ファイルがELFバイナリを持っているかどうかを簡単にチェックする。ELFバイナリは4バイトの「マジックナンバ」0x7F,'E','L','F'、もしくは`ELF_MAGIC`(0952行目)から始まる。ELFヘッダが正しいマジックナンバであれば、`exec`はこのバイナリは正しく構成されていると仮定する。 64 | 65 | `exec`は`setupkvm`(6334行目)を用いてユーザマッピングがされていない新しいページを確保し、`allocuvm`(6346行目)を用いて各ELFのセグメント向けにメモリを確保し、`loaduvm`(6348行目)を用いて各セグメントをメモリにロードする。 66 | `allocuvm`は要求された仮想アドレスが`KERNBASE`よりも小さいことをチェックする。 67 | `loaduvm`(1918行目)は`walkpgdir`を用いて割り当てられたメモリの物理アドレスを探し、ELFセグメントの各ページに対して書き込みを行う。そして、`readi`によってファイルから読み込みを行う。 68 | 69 | `exec`によって作成される最初のプログラムである`/init`のためのプログラムのセクションヘッダは、以下のようになっている: 70 | 71 | ``` 72 | # objdump -p _init 73 | 74 | _init: file format elf32-i386 75 | 76 | Program Header: 77 | LOAD off 0x00000054 vaddr 0x00000000 paddr 0x00000000 align 2**2 78 | filesz 0x000008c0 memsz 0x000008cc flags rwx 79 | ``` 80 | プログラムのセクションヘッダの`filesz`はおそらく`memsz`よりも小さいため、そのギャップ、ファイルを読み込むのではなく、ゼロを埋め込むことにより埋められる(Cのグローバル変数のため)。 81 | `/init`では、`filesz`は2240バイトであり`memsz`は2252バイトである。従って、`allocuvm`は2252バイトを保持するための十分な物理メモリを確保するが、実際には`/init`は2240バイトしか利用しない。 82 | 83 | 次に、`exec`はユーザスタックの割り当てと初期化を行う。`exec`は1ページ分しかスタックの割り当てを行わない。`exec`は一度スタックのトップに引数の文字列をコピーし、`ustack`にそのポインタの位置を記録する。`main`により渡される`argv`リストの最後には、nullポインタが配置される。`ustack`の最初の3つのエントリは、偽のPC戻り値、`argc`, `argv`である。 84 | 85 | `exec`はスタックページの下にアクセス不可能なページを配置する。これにより、プログラムはスタックをより多く利用しようとすると、異常終了するようになっている。アクセス不可能なページは`exec`の引数が非常に大きなときにも利用される; このような状況では、`copyout`関数により`exec`が引数をスタックにコピーするときに、コピー先のページがアクセス不可能であり、-1が返される。 86 | 87 | 新しいメモリイメージの準備の最中に、もし`exec`が不正なプログラムセグメントのようなエラーを発見した場合は`bad`ラベルに飛び、新しいイメージを廃棄して-1を返す。`exec`はシステムコールが成功することを保証するために、古いイメージが解放されるまで待たなければならない: もし古いイメージが破棄されたら、システムコールは-1を返すことができなくなってしまう。`exec`でエラーが発生する唯一のケースはイメージの生成の途中に発生する。一度イメージが完成すると、`exec`は新しいイメージをインストールすることができ(6394行目)、古いイメージは解放される(6395行目)。最後に、`exec`は0を返す。 88 | 89 | # 現実の世界 90 | 91 | 殆どのオペレーティングシステムと同様に、xv6はメモリの保護とマッピングのためにページングハードウェアを利用する。殆どのオペレーティングシステムはxv6よりも洗練された方法でページングを管理している; 例えば、xv6はディスクからのデマンドページングや、コピーオンライトfork、共有メモリ、遅延割り当てページ、スタックの自動拡張などの処理が実装されていない。x86はセグメンテーションによるアドレス変換もサポートしている(付録Bを参照のこと)が、xv6はセグメントはCPU毎に利用され、固定アドレスだが異なるCPUから異なる値が読み込まれる、`proc`のような変数にのみ利用している(`seginit`を参照のこと)。 92 | セグメントを利用せずにCPU毎(もしくはスレッド毎)のストレージ管理を実装するためには、CPU毎のデータ領域を所望のレジスタに保持することが必要になるが、x86は汎用レジスタの数が少ないため、必要な労力に対してセグメンテーションを利用する価値が無い。 93 | 94 | メモリをより多く積んだマシンでは、4Mバイトのページを扱えるx86の"Super Pages"機構を利用したほうが良い。物理メモリが小さいときは、ディスクへのページアウトなどの割り当てを細粒度に実施できる小さなページを利用した方が良い。例えば、プログラムが8kバイトのメモリしか利用しないのに、4Mバイトのページを割り当てるのは無駄である。大きなページを利用することによって、より多くのRAMを利用することができ、またページテーブルの処理のオーバヘッドを削減することができる。xv6はSuper Pagesを1箇所でのみ: 初期ページテーブルでのみ利用している(1311行目)。配列の初期化処理では、1024個のPDEのうち2つを設定しており、インデックスが0と512(`KERNBASE>>PDXSHIFT`)に相当する場所を設定している。それ以外の場所はゼロに設定している。xv6はこれらの2つのPDEをPTE_PSと設定することにより、これらがSuperPagesであることを設定している。カーネルはさらに、`CR_PSE`ビット(ページサイズ拡張)を`%cr4`に設定することでハードウェアに対してSuperPagesを利用できることを通知している。 95 | 96 | xv6は240MBのRAMの構成を仮定していたが、実際のRAMの構成を決定しなければならない。x86では、少なくとも3つのアルゴリズムが必要である: 1つめは、指定した物理アドレス空間がメモリのように動作する領域であり、値が正しくその領域に保存することができることである; 2つめは、PCが保持している16ビットの不揮発性のRAMの場所以外から、数キロバイトの対を読むことができることである; 97 | 3番目はマルチプロセッサのテーブルの一部として、メモリレイアウトを確認するためにBIOSメモリを参照できることである。メモリレイアウトテーブルを読み込むことは、複雑な処理を必要とする。 98 | 99 | メモリ割り当ては昔からホットな話題であり、問題となるのは、有限のメモリをどのように効率的に利用するか、という問題と、どのように将来の要求に答えるようにレイアウトを準備するか、ということである; Knuhの論文を参照されたい。今日では、空間効率性よりも速度を重視する傾向がある。加えて、より洗練されたカーネルは4096バイトのブロックよりも、より多くの異なるサイズの小さなブロックを割り当てている; 実際のカーネルをアロケータはこのような小さな割り当てを、大きな割り当てと同様に取り扱う必要がある。 100 | 101 | # 演習問題 102 | 103 | 1. 実際のオペレーティングシステムがどの程度のメモリサイズを扱うことができるか調査しなさい。 104 | 2. xv6がスーパページを利用しなければ、`entrypgdir`はどのように宣言するのが正しいか? 105 | 3. xv6を変更して、メモリの使用量を削減するために、カーネルとプロセスでページを共有するように変更しなさい。 106 | 4. `exec`のUnixの実装では、伝統的にシェルスクリプトのための特別な処理が追加されている。もしファイルを実行する前に、テキストに#!が含まれていれば、そのファイルを解釈実行するためのプログラムが記述されているものとしている。 107 | 例えば、`exec`が`myprog arg1`を実行するように要求され、`myprog`の先頭に`#!/interp`と記述されていれば、`exec`は`/interp`を `/interp myprog arg1`の形式で実行する。xv6で、このような実行をサポートするように変更しなさい。 108 | 109 | -------------------------------------------------------------------------------- /chapter3.md: -------------------------------------------------------------------------------- 1 | 第3章 トラップ、割り込み、ドライバ 2 | ================================== 3 | 4 | プロセスを実行している際、CPUは通常の処理ループを実行している; 命令を読み込み、プログラムカウンタを進め、命令を実行し続けている。しかし、ユーザプログラムからのカーネルへの遷移の制御要求イベントが発生することがある。このイベントには、CPUに注目をしてもらうためのデバイスからのシグナル要求や、ユーザプログラムの異常(例えば、PTEの存在しない仮想アドレスの参照)、ユーザプログラムのシステムコールによるカーネルへのサービスの要求などが含まれる。これらのイベントをうまく取り扱うためには、以下の3つの困難を解消する必要がある: 1) カーネルはプロセッサをユーザモードからカーネルモードに遷移させる(そして最終的には戻ってくる); 2) カーネルとデバイスは、デバイスが並列動作を制御する; 3) カーネルはデバイスのインタフェースを理解しなければならない。上記の3つの問題をうまく取り扱うために、ハードウェアの深い理解と慎重なプログラミングをすることで、結果的に不透明なカーネルコードになってしまう可能性もある。本章では、xv6が、これらの問題をどのようにして取り組んでいるのかについて説明する。 5 | 6 | # システムコール、例外、割り込み 7 | 8 | 前章の最後に見たように、ユーザプログラムはシステムコールによってオペレーティングシステムにサービスを要求する。**例外**という用語は、プログラムの異常な動作により発生する割り込みのことを指す。プログラムの異常な動作の例として、ゼロによる除算、PTEに存在しないアドレスへのメモリアクセスなどがある。**割り込み**という用語は、ハードウェアデバイスにより発生するシグナルのことを指し、オペレーティングシステムは何か処理をしなければならない。例えば、クロックチップは割り込みを100ミリ秒に1回発生させることで、カーネルが時分割共有を実現できるようになっている。もう一つの例として、ディスクが読み込み動作を行うと、割り込みを発生させることでオペレーティングシステムに対して、読み込んだブロックを取り込む準備ができたことを通知する。 9 | 10 | 全ての割り込みを処理するのは、プロセスではなくカーネルである。それは、殆どのケースにおいて、カーネルのみ必要な権限と状態を持っているからである。例えば、クロックの割り込みに反応してプロセスを切り替える時分割を実現するとき、自分で能動的に立ち上がることの出来ないプロセスをプロセッサにロードするためには、カーネルを起動しなければならない。 11 | 12 | オペレーティングシステムは発生する準備を整えておく必要があるケースとして、以下の3つが挙げられる。まず、システムは次回の回復に備えてプロセッサのレジスタを退避しておかなけばならない。システムはカーネル上で実行の準備をしなければならない。システムはカーネルが実行を開始するための場所を選択しなければならない。カーネルはイベントについての情報(例えば、システムコールの引数など)を入手できなければならない。これらは全て、セキュアな状態で実行される; システムはユーザプロセスとカーネルの独立性を維持していなければならない。 13 | 14 | これらの目標を達成するためには、オペレーティングシステムがハードウェアがどのようにしてシステムコール、例外、割り込みを処理するのかについて理解していなければならない。殆どのプロセッサはこれらの3つのイベントは単一のハードウェア機構によって処理される。例えば、x86では、プログラムは`int`命令を実行して割り込みを発生させ、システムコールを起動し、同様に、例外も割り込みを発生させる。従って、もしオペレーティングシステムが割り込みを処理することができるのであれば、オペレーティングシステムはシステムコールや例外も処理することができる。 15 | 16 | 基本的な考え方は以下の通りである。割り込みによって通常の処理ループが停止し、**割り込みハンドラ**と呼ばれる新しいプログラムのシーケンスが実行される。割り込みハンドラを実行する前にプロセッサは自身のレジスタを退避し、割り込み処理から復帰するときにそのレジスタ値を書き戻せるようにする。割り込みハンドラへ遷移すること、割り込みハンドラから戻ってくることは、プロセッサがユーザモードとカーネルモードを行き来することを意味する。 17 | 18 | 用語について: x86の公式な用語は割り込み(interrupt)であるが、xv6は**トラップ**とも呼ぶ。これはPDP11/40で使われていた言葉であり、伝統的なUnixの用語であるためである。本章ではトラップと割り込みは同じ意味で使用しているが、トラップは現在プロセッサ上で動作しているプロセスによって発生させられるものであり(例えば、プロセスがシステムコールを発生させ、結果としてトラップを発生させる)、割り込みは現在実行されているプロセスとは関係無く、デバイスによって発生させられるものである。例えば、ディスクはあるプロセスによって要求されたブロックの読み込みを完了すると、割り込みを発生させる。割り込みの特徴(つまり、割り込みは他の動作と並列に発生する)により、割り込みはトラップよりもより難しいものとなる。同一のハードウェア機構を使用してユーザモードとカーネルモードをセキュアにセキュアに遷移する方法については、次節で議論する。 19 | 20 | # x86の保護 21 | 22 | x86は4つの保護レベルを持っている。0が最も保護レベルが強く、3が最も保護レベルが弱い。実際には、殆どのオペレーティングシステムは、0か3の2つの保護レベルしか使用しない。0がカーネルモード、3がユーザモードに相当する。x86において、実行している命令の現在の保護レベルは`%cs`レジスタのCPLフィールドに保存されている。x86では、割り込みハンドラは割り込みディスクリプタテーブル(IDT)によって定義されている。IDTは256エントリであり、該当する割り込みを処理する際に`%cs`と`%eip`が利用される。 23 | 24 | x86のシステムコールを生成するために、プログラムは`int n`命令を実行する。nはIDTのインデックスである。`int`命令は次の処理を行う: 25 | 26 | * IDTからn番目のディスクリプタをフェッチする。nは`int`命令のオペランドである。 27 | * `%cs`のCPLフィールドをチェックし、ディスクリプタの保護レベルであるDPL以下であることを確認する。 28 | * ターゲットセグメントセレクタがPLtrappno`を読み込み、このトラップが何故発生したか、そして何をすべきかを決定する。トラップが`T_SYSCALL`であった場合、`trap`はシステムコールハンドラである`syscall`を呼び出す。これらについては、第5章で、`proc->killed`を調査するときに再び見ることにする。 83 | 84 | システムコールをチェックした後は、トラップはハードウェア割り込みをチェックする(これについては以降で議論する)。予想し得るハードウェアデバイスに加えて、トラップは予想外のハードウェアの割り込みによって、異常な割り込みが発生してしまうことがある。 85 | 86 | トラップがシステムコールではなく、ハードウェアデバイスにより注意を払うべきものでもなかった場合、`trap`はこのトラップはトラップが実行される前にコードによって発生した異常な動作であるとみなす(例えば、ゼロ除算など)。もしこのようなトラップがユーザプロセスによって発生した場合、xv6は詳細をプリントし、`cp->killed`を設定しユーザプロセスをクリーンアップする。クリーンアップの動作については、第5章で詳細をチェックする。 87 | 88 | もしカーネルが実行中であった場合には、これはカーネルのバグである; `trap`はこの異常動作について詳細をプリントし、`panic`を呼び出す。 89 | 90 | # コード例: システムコール 91 | システムコール向けには、`trap`は`syscall`(3625行目)を呼び出す。`syscall`は`%eax`に含まれているシステムコールの番号をトラップフレームからロードし、システムコールのテーブルを指すようにする。最初のシステムコールでは、``%eax``は`sys_exec`(3457行目)が入っており、`syscall`は`sys_exec`のシステムコールテーブルのエントリを読み出し、`sys_exec`を実行する。 92 | 93 | `syscall`はシステムコール関数の戻り値を`%eax`に保存する。trapがユーザ空間に戻ってきたときに、この戻り値は`cp->tf`からマシンのレジスタに戻される。従って、`exec`が戻ってくると、システムコールのハンドラの値が返されることになる。システムコールは、伝統的に、エラーが発生すると負の値を返すようにしており、正の値だと成功を示す。もしシステムコールの番号が不正であれば、syscallはエラーを表示し、-1を返す。 94 | 95 | 以降の章では、いくつかのシステムコールの実装について中身をチェックしていく。本章では、システムコールのメカニズムについてを取り扱うことにする。ここでは、もうひとつシステムコールの呼び方について説明しておく: システムコールの引数の設定である。ヘルパー関数の`argint`, `argptr`, `argstr`はn番目のシステムコールを、それぞれ整数、ポインタ、文字列として探索する。`argint`はn番目の引数の位置を特定するために、ユーザ空間の`%esp`レジスタを利用する: `%esp`はシステムコールスタブの戻りアドレスを指している。引数はその上に配置されており、`%esp+4`である。従って、n番目の引数は`%esp+4+4*n`の場所に存在する。 96 | 97 | `argint`は`fetchint`を呼び出し、ユーザメモリからそのアドレス中のデータを読み出し、`\*ip`に書き込む。 98 | `fetchint`はユーザとカーネルが同一のページテーブルを共有するために、アドレスを単純にポインタにキャストするが、カーネルはユーザによって示されたそのポインタがユーザのアドレス空間に含まれているかどうかをチェックしなければならない。カーネルはページテーブルのハードウェアを設定し、プロセスがローカルのプライベートメモリの外にアクセスできないように設定する: もしユーザプログラムがp->szのアドレス以上の場所を参照しようとすると、プロセッサはセグメンテーションフォルトを発生させ、これまでに見てきたように、プロセスを殺す処理に入る。 99 | 100 | これまでで、カーネルはユーザが渡してきた任意のアドレスの値を取得することができるようになるため、カーネルは実行時にそのアドレスが`p->sz`よりも低い場所であることをチェックしなければならない。 101 | 102 | `argptr`は`argint`と同様の目的で利用される: `argptr`はn番目のシステムコールの引数を解釈する。`argptr`は`argint`を呼び出し、まずは引数を整数として読み出す。引数が整数であるかをチェックし、フェッチした整数がユーザポインタであるかどうかをチェックし、確かにアドレス空間のユーザ領域に存在することをチェックする。この2つのチェックは`argptr`を呼び出している最中に実行されることに注意する。まず、ユーザスタックポインタが引数をフェッチしている最中にチェックされる。次に、その引数自身がユーザ空間へのポインタであった場合についてチェックされる。 103 | 104 | `argstr`はシステムコールの引数トリオの中の最後である。`argstr`はn番目の引数をポインタとして受け取る。 105 | この関数はポインタがNULLで終わる文字列であることを保証し、完全な文字列がアドレス空間のユーザ領域の中に存在していることをチェックする。 106 | 107 | システムコールの実装(例えば、sysproc.cやsysfile.c)は典型的なラッパーである: これらの関数は引数を`argint`, `argptr`, `argstr`を用いてチェックし、実際のシステムコールの実装を呼び出す。 108 | 第2章では、`sys_exec`は引数を入手するために実際にこれらの関数を利用している。 109 | 110 | # コード例: 割り込み 111 | 112 | マザーボード上のデバイスは割り込みを発生させることができ、xv6はそれらの割り込みを処理するためのハードウェアを設定しなければならない。デバイスサポートが存在しないとxv6は使い物にならない; ユーザはキーボードをタイプすることができず、ファイルシステムはディスクにデータを保存することもできない。幸運なことに、簡単なデバイスの割り込み処理を追加し、サポートするためにそれほど複雑な処理は必要ない。これから見ていくように、割り込みはシステムコールや例外と同様のコードを利用して実現できる。 113 | 114 | 割り込みはシステムコールと似ているが、デバイスが任意のタイミングで発生させるところが異なる。マザーボード上にハードウェアが存在し、それらのデバイスは必要なときにCPUにシグナルを通知する(例えば、ユーザがキーボードの文字をタイプした、など)。私達は、デバイスの割り込み発生プログラムを記述しなければならず、CPUはその割り込みを受け取らなければならない。 115 | 116 | タイマーデバイスとタイマー割り込みについて考えよう。タイマーのハードウェアが1秒間あたりに100回の割り込みを通知するとすると、カーネルはこれらの時間の通知を記録し、これに従ってカーネルは複数のプロセスの時分割を実現できるようになる。1秒あたりに100回程度であれば、適切な応答性能でプロセッサがハードウェアの割り込みにより圧迫されることは無い。 117 | 118 | x86プロセッサ自身のように、PCのマザーボードは進化し、割り込みを受け渡す方法も進化している。初期のボードはシンプルなプログラマブル割り込みコントローラ(PICと呼ばれる)を利用しており、そのコードはpicirq.cで見ることができる。 119 | 120 | PCボード上でマルチプロセッサが実現できるようになると、それに応じて新しい割り込み処理のための方法が必要になり、各CPUは割り込みを送信するための割り込みコントローラが必要になった。そして、それらの割り込み信号をプロセッサ間でルーティングする方法も必要になる。この方法は2つの部分から構成される: I/Oシステムの部分(IO APIC, `ioapic.c`)と、各プロセッサに付属している部分(ローカルAPIC,`lapic.c`)である。xv6はマルチプロセッサボード向けに設計されており、各プロセッサは割り込みを受け取るためのプログラムを記述しておかなければならない。 121 | 122 | また、単一のプロセッサ上でも正しく動作するように、xv6はプログラマブル割り込みコントローラ(PIC)をプログラムする(7432行目)。各PICは最大で8個の割り込み(例えば、デバイスなど)を処理することができ,それらの信号をプロセッサの割り込み通知ピンに対して時分割で通知する。8個以上のデバイスを利用するために、PICはカスケード接続することができ、典型的にボードには少なくとも2つのPICが搭載されている。`inb`と`outb`命令を利用することにより、xv6はIRQ0からIRQ7を発生させるためにマスタを、IRQ8からIRQ16を処理するためのスレーブをプログラムする。 123 | 124 | 最初に、xv6はPICを全ての割り込みをマスクするようにプログラムする。`timer.c`はタイマー1を設定し、PIC上でタイマ割り込みを有効化する(8074行目)。本書の説明では、PICのプログラミングにおける詳細は省略している。PICについての詳細(およびIOAPICとLAPICの詳細)は本書では重要ではないが、興味のある読者は各デバイスのマニュアルを読むと良い。マニュアルについては、ソースファイルに参照先を記載している。 125 | 126 | マルチプロセッサでは、xv6は各プロセッサにおいてIOAPICとLAPICをプログラムしなければならない。IO APICはテーブルを保持しており、プロセッサは`ina`,`outa`命令を用いる代わりにメモリマップドI/Oを通じてテーブルのエントリにプログラムをすることができる。初期化では、xv6は割り込み0がIRQ0などマップされるようにプログラムしていくが、これらの全てを無効化しておく。特定のデバイスが特定の割り込みを有効化し、どのプロセッサの割り込みをルーティングするべきかについて通知する。例えば、xv6はキーボードの割り込みをプロセッサ0に通知する(8016行目)。以降で見るように、xv6はディスクの割り込みをシステム上のプロセッサの最大番号のものに通知する。 127 | 128 | タイマーチップはLAPICの内部に存在し、各プロセッサはタイマー割り込みをそれぞれ独立に受け取ることができる。xv6は`lapicinit`(7151行目)でそれらを設定する。キーとなるプログラムはタイマーをプログラムする部分である(7164行目)。このコードにより、LAPICは定期的に、IRQ0である割り込み`IRQ_TIMER`を発生させる。7193行目で、CPUのLAPIC上で割り込みを有効化し、この割り込みがローカルのプロセッサに通知されるようになる。 129 | 130 | プロセッサは、eflagsレジスタ中のIFフラグを通じて割り込みを受けるように、制御することができる。命令`cli`はIFをクリーンにしてプロセッサ上の割り込みを無効化し、`sti`命令によりプロセッサの割り込みを有効化する。xv6はメインのCPUがブートしている最中(8912行目)および他のプロセッサ(1226行目)がブートしている最中は割り込みを無効化する。各プロセッサのスケジューラが、割り込みを有効化する(2714行目)。特定のコード列中で割り込みが発生しないように制御するために、xv6はこれらのコードが実行されているときに割り込みを無効化する(例えば、`switchuvm`などを参照のこと)。 131 | 132 | ベクタ32を通じて通知されるタイマ割り込み(xv6はIRQ0として処理する)は、xv6では`idtinit`(1265行目)。ベクタ32とベクタ64(システムコールとして利用される)の唯一の違いは、ベクター32はトラップゲートではなく、割り込みゲートであるということである。割り込みゲートはIFをクリアし、現在の割り込み処理中に、他の割り込み処理が発生してしまうことを防ぐ。ここから先は、`trap`命令までは、割り込み処理はシステムコールと例外のコードと比較して、トラップフレームを作成するところも含めて同一である。 133 | 134 | `trap`がタイマ割り込みを通じて呼び出されると、2つのことが発生する: `tick`変数(3367行目)をインクリメントし、`wakeup`を呼び出す。その後、第5章で見るように、異なるプロセスに戻っていくように割り込みを発生させる。 135 | 136 | # ドライバ 137 | 「ドライバ」はオペレーティングシステムのコードの一部で、特定のデバイスを管理するためのものである: デバイスのための割り込みハンドラを提供し、デバイスを操作し、デバイスが割り込みを発生させるように操作するなどの仕事がある。ドライバのコードは管理するデバイスを並列に実行するため、トリッキーな作り方をしている。加えて、ドライバはデバイスのインタフェース(例えば、どのI/Oポートに対して何をするかなど)を理解しておく必要があり、そのインタフェースは複雑であり、しかも綺麗にドキュメント化されていない。 138 | 139 | xv6は、デバイスドライバの良い例として、ディスクドライバを提供している。ディスクドライバはデータをディスクからコピーするか、ディスクにデータを書き込む。ディスクハードウェアは、伝統的にデータを、ディスク序うの512バイトのブロック列(「セクタ」と呼ばれる)として表現する: セクタ0は最初の512バイトであり、セクタ1は次の512バイト、と続いていく。オペレーティングシステム上でディスクセクタを表現するために、オペレーティングシステムは1つのセクタに相当する構造体を持っている。この構造体に格納されたデータは、しばしばディスクの同期とは外される: 例えば、ディスクがまだ動作しており、まだ読み込みが完了しておらず、構造体にデータが格納されていないといった状態や、構造体内のデータはアップデートされたのだが、まだディスクに書き戻されていない、などといった状態が発生する。ドライバは、xv6上でディスクとの同期が取れていない状態でも、xv6のシステムが混乱することのないように保証してやる必要がある。 140 | 141 | # コード例: ディスクドライバ 142 | 143 | IDEデバイスはPCの標準的なIDEコントローラに接続されているディスクに対してアクセスを提供する。 144 | IDEは現在ではSCSIやSATAと比較して古くなってしまった企画だが、インタフェースはシンプルで特定のハードウェアの詳細について気にすることなく、全体の構造の把握に集中することができる。 145 | ディスクドライバはディスクセクタを`buffer`という構造体で管理する。これは`struct buf`(3750行目)で表現されている。各`buffer`は特定のディスクデバイスの1つのセクタを表現している。`dev`と`sector`フィールドはデバイスとセクタ番号を表現しており、データフィールドはディスクセクタのデータのコピーを保持している。 146 | 147 | flagsはメモリとディスクの関係の記録をしている: `B_VALID`フラグはデータは読み込み済であり、`B_DIRTY`フラグはデータは書き戻す必要があることを示している。`B_BUSY`フラグはロックビットである; あるプロセスがこのバッファを利用しており、他のプロセスはこのセクタを利用してはならないことを意味している。`buffer`が`B_BUSY`フラグを設定していると、これは`buffer`がロックされているものとみなす。 148 | 149 | カーネルは、ブート中に`main`(1234行目)から`ideinit`(4151行目)を呼び出すことでディスクドライバを初期化する。`ideinit`は`pincenable`と`ioapicenable`を呼び出すことで`IDE_IRQ`割り込みを有効化する(4156-4157行目)。`pincenable`関数は単一プロセッサの割り込みを有効化する; `ioapicenable`はマルチプロセッサの割り込みを有効化するが、最後のCPU(ncpu-1)のみを有効化する:つまり、2プロセッサのシステムではCPU1がディスク処理を担当する。 150 | 151 | まず、`ideinit`がディスクハードウェアを検査する。`idewait`(4158行目)を呼び出して、ディスクがコマンドを受け付けられるようになるまで待つ。PCマザーボードはディスクハードウェアの状態を示すビットをI/Oポートの0x1f7に持っている。`idewait`(4133行目)はこの状態ビットを監視し、ビジービット(`IDE_BUSY`)がクリアされ、Readyビット(`IDE_DRDY`)がセットされるまで待つ。 152 | 153 | ディスクコントローラの準備が整うと、`ideinit`はディスクが何個存在しているかを検査する。まずは、ディスク0が存在していると仮定される。何故ならば、ブートローダとカーネルはどちらともディスク0からロードされており、従って、ディスク1からチェックする必要がある。I/Oポート0x1f6に書き込みを行い、ディスク1を選択し、状態ビットを監視して、そのディスクが有効になるまで待つ(4160-4167行目)。もしそうでなければ、`ideinit`はそのディスクが存在していないとする。 154 | 155 | `ideinit`の後は、バッファのキャッシュがフラグにより表現されたロックバッファを更新するために`iderw`を呼び出すまでは利用されない。`B_DIRTY`が設定されると、`iderw`はバッファをディスクに書き戻す; もし`B_VALID`が設定されていなければ、`iderw`はディスクからバッファを読み出す。典型的に、ディスクアクセスには数ミリセカンドが必要とされ、プロセッサにとっては時間が長い。ブートローダはディスク読み出しコマンドを発生させ、データが更新されるまで状態ビットが更新されるのを監視する。 156 | 157 | ブートローダでは、ポーリング(polling)もしくはビジーウェイト(busy waiting)が有効であるが、これは最適な方法ではない。オペレーティングシステムでは、より効率的な方法により、CPUで他のプロセスを実行させ、ディスクの操作が完了すると、割り込みを受け付けるように調整する。`iderw`は後者の方式を取っており、待ち合わせ状態のディスクのリクエストリストのキューを持っており、リクエストが完了すると割り込みを利用して該当するリクエストを探している。`iderw`はリクエストのキューを管理するが、シンプルなディスクコントローラは一度に1つの操作しか実行することができない。ディスクドライバはキューに載せてディスクハードウェアにリクエストを出す前に、不変な値を管理しており、キューの先頭のデータをリクエストとして送信する; そうでなければ、単純にディスクハードウェアの応答を待つ。`iderw`(4254行目)は、キューの最後にバッファbを追加する(4267-4271行目)。もしバッファがキューの先頭に存在すれば、`iderw`は`idestart`を呼び出すことにより、それをディスクハードウェアに転送する; そうでなければ、そのバッファよりも前に存在するリクエストが完了されるまで、そのリクエストは待機される。 158 | 159 | `idestart`(4175行目)はバッファのデバイスとセクタに対して、フラグに応じて読み込みまたは書き込みを発行する。書き込みであれば、`idestart`は現在のデータを渡し(4189行目)、割り込みによってそのデータがディスクに書き込まれたことを示す。もし読み込み操作であれば、割り込みが通知されデータが整い、ハンドラがそのデータを読み出す。`idestart`はIDEデバイスの詳細な知識が無くてはならず、正しいポートに正しい値を書き出す必要がある。もし任意の`outb`値が間違っていれば、IDEは想定とは異なる動作をする。このためのデバイスの詳細な知識を理解するのが、デバイスドライバを記述するのが難しくしている理由の一つである。 160 | 161 | リクエストをキューに追加し必要であればリクエストを発行し、`iderw`は結果を待たなければならない。上記で議論したように、ポーリングして、結果を待つことはCPUにとって効率的ではない。その変わりに、`iderw`はスリープ状態に入り、割り込みハンドラにバッファの操作が完了したことを示すフラグを記録するように操作しておく(4278-4279行目)。このプロセスがスリープ状態に入ると、xv6は他のプロセスを起動してCPUがビジー状態になるようにする。 162 | 163 | そうしてやっと、ディスクが操作を完了して割り込みが発生したとする。 164 | 165 | この割り込みを処理するために、trapが`ideintr`を呼び出す(3374行目)。`ideintr`(4204行目)は、キュー上の最初のバッファを調査し、どの操作が発生したのかを特定する。もしバッファが読み込み操作であり、ディスクコントローラがデータを待っていたらならば、`ideintr`はデータを`insl`を利用してバッファへ読み込む(4215-4217行目)。バッファが有効になると、`ideintr`は`B_VALID`を設定し、`B_DIRTY`をクリアする。そしてバッファ上でスリープ状態に入っていたプロセスを起こす(4219-4222行目)。最後に、`ideintr`は次の待ち合わせ状態のバッファをディスクに渡す(4224ー4226行目)。 166 | 167 | # 現実の世界 168 | 169 | 世の中には非常に多くのデバイスが存在し、それらのデバイスは多くの機能と、デバイスとドライバを通信するための多くのプロトコルを持っているため、PCマザーボード上の全てのデバイスをサポートするのは、非常に多くの作業が必要になる。多くのオペレーティングシステムでは、ドライバはカーネルのコアの量よりも多いのである。 170 | 171 | 実際のデバイスドライバは本章のディスクドライバのものよりもより複雑であるが、基本となる考え方は同一である: 典型的に、デバイスはCPUよりも低速なため、ハードウェアが割り込みを利用してオペレーティングシステムに状態の変化を通知する。現代のディスクコントローラは典型的にディスクリクエストの「バッチ処理」が可能になっており、リクエストの順番を変更するなどしてより効率的にディスクアームを操作できるようにしている。ディスクがよりシンプルであれば、オペレーティングシステムはリクエストキューそのものの並べ替えを行うこともある。 172 | 173 | 多くのオペレーティングシステムは、データアクセスが非常に高速なソリッドステートドライブのドライバを持っている。しかし、ソリッドステートドライブは伝統的なメカニカルなディスクとは異なるため、どちらのデバイスもブロックベースのインタフェースを用意し、ソリッドステートディスク上でブロックベースのインタフェースを提供するのは、RAMを読み書きするよりもコストがかかる。 174 | 175 | 他のハードウェアは、驚くほどディスクと似ている: ネットワークデバイスはパケットを保持するためにバッファリングをしており、オ_ディオデバイスは音のサンプルをバッファリングしており、グラフィックスカードはビデオのデータとコマンド列をバッファリングしている。高バンド幅のデバイス--ディスク、グラフィックスカード、ネットワークカードなど--は、明示的なI/O(`insl`, `outsl`によるもの)の代わりに、しばしばダイレクトメモリアクセス(DMA)を利用する。DMAはディスクや他のコントローラが物理メモリに直接アクセスする手段を提供する。DMAを利用することにより、CPUはデータの転送には関わらなくなり、CPUのメモリキャシュをより効率的に使うことができるようになる。 176 | 177 | 本章における、I/O命令を使用してプログラムを行う殆どのデバイスは、これらのデバイスの古い環境を反映したものになっている。全ての現代のデバイスはメモリマップドI/Oを利用してプログラムされている。 178 | 179 | いくつかのドライバは、ポーリングによるリクエストの完了待機と、割り込みによるリクエストの完了待機の手法を動的に切り替える。これは、割り込みを利用する場合はコストが高いが、ポーリングをする場合は、プロセスのイベントの前に遅延を単に挿入するだけで良いからである。例えば、ネットワークドライバがバーストパケットを受け取るとき、デバイスドライバはさらに多くのパケットを処理しなければならないことを知っているため、割り込みモードからポーリングモードに切り替えることにより、よりコストの低い方法であるポーリングを利用してデータを処理するようになる。全てのパケットを処理した後は、ドライバは再び割り込みモードに遷移することによって、新しいパケットが到着するとすぐにアラートされるようになる。 180 | 181 | 割り込みによって通知されるIDEドライバがどのプロセッサに通知されるかは静的に決定される。いくつかのドライバは洗練されたアルゴリズムによって、割り込みのプロセッサへの通知のルーティングを変更して、パケットを処理する付加をうまくバランスさせながら、局所性をうまく達成する方法を取っている。例えば、ネットワークドライバは、ある接続に対してあるパケットが到着したことを、その接続を管理しているプロセッサに伝え、他の接続についてのパケットが到着するとその通知をその接続の管理をしているプロセッサに通知している。このルーティングが極めて洗練されたものである; 例えば、もしいくつかのネットワーク接続が短命であり、他のネットワーク接続が長命であれば、オペレーティングシステムは高スループットを達成するために、全てのプロセッサをビジー状態で保持していたくなる。 182 | 183 | もしユーザプロセスがファイルを読み込むと、そのファイルのデータは二度コピーされる。最初に、デバイスドライバによりディスクからカーネルメモリにコピーされ、カーネル空間からユーザ空間へ、システムコールを通じてコピーされる。もしユーザプロセスがネットワークにデータを送信したならば、そのデータは再び二度コピーあれう: ユーザ空間からカーネル空間へ、そしてカーネル空間からネットワークデバイスへとコピーされる。アプリケーションによっては低レイテンシをサポートすることが重要になることがあり(例えば、静的なウェブページのウェブサービスなど)、オペレーティングシステムは、このコピーの無駄を回避するために特別なコードパスを利用する。一つの例として、現実世界のオペレーティングシステムは、典型的にハードウェアページサイズと同一のバッファを保持しており、読み込みのみのコピーはページングハードウェアを用いてプロセスのアドレス空間にマップしている。これにより、コピーが不要になる。 184 | 185 | # 練習問題 186 | 187 | 1. `syscall`の最初の命令に対してブレークポイントを針、最初のシステムコールをキャッチせよ(例えば、br `syscall`)。この段階ではスタックには何の値が設定されているか?ブレークポイントでのx/37x $espの出力値について、各値がどのようにラベルされているかについて説明せよ(例えば、トラップのためい保存されている`%ebp`、`trapfram.eip`, スクラッチ空間、など)。 188 | 189 | 2. 新しいシステムコールを追加せよ。 190 | 3. ネットワークドライバを追加せよ。 191 | -------------------------------------------------------------------------------- /chapter4.md: -------------------------------------------------------------------------------- 1 | 第4章 ロック 2 | =========== 3 | 4 | xv6はマルチプロセッサ、複数のCPUが独立してコードを実行する環境下で動作している。これらのマルチプロセッサの環境では、CPUは同一の物理アドレスを利用し、データ構造を共有する; xv6はそれぞれが干渉しないようにするためのメカニズムを導入する必要がある。単一プロセッサでも、xv6はいくつかのメカニズムを利用して、割り込みハンドラが非割り込みハンドラから干渉されることを防ぐために同様のメカニズムを利用する必要がある。xv6は、どちらにも低レベルの同一の考え方を利用している: **ロック**である。ロックは相互実行、つまり複数のCPUのうち、ある時間においてたった一つのCPUがロックを保持するということを保証する機構を提供する。もしxv6は特定のロックを保持している間しかデータ構造にアクセスしないならば、xv6はそのデータ構造に対してたった一つのCPUのみがアクセスしていることを保証することができる。このような状況下のことを、データ構造をロックしている、と呼ぶ。 5 | 6 | 本章では、xv6では何故ロックが必要なのか、どのようにしてロックを実装しているのかについて見ていき、これをどのようにして利用するかについて見る。注目すべきなのは、xv6のコード上でロックを確保するときは、あなたはあなた自身に、他のプロセッサがそのコードの所望の行動を変えられていないかチェックしなければならない、ということである(例えば、他のプロセッサが同一のコード行を実行しているか、他のコード行で変数を変更している可能性がある、ということである)。また、もし割り込みハンドラが実行されると何が起きるか、ということについても考えなければならない。どちらのケースでも、単一のCの文は複数のマシン命令に変換され、他のプロセッサや割り込みによって、そのCの文を実行している途中で分断される可能性があるということである。また、本ページのコード列はシーケンシャルに実行されるとは考えてはいけない、もしくは、単一のCの文がアトミックに実行されるとは考えてはいけない。並列に動作することによって、プログラムの正確性を推測することはより難しくなるのである。 7 | 8 | # レースコンディション 9 | ロックが必要な例として、いくつかのプロセッサが、IDEディスクなどの単一のディスクをxv6上で共有していることを考える。ディスクドライバのメカニズムは、まだ未実行なディスクリクエストのリンクリストを管理しており(4121行目)、プロセッサは新しいリクエストを、並列にそのリストに追加していく(4254行目)。もしここで並列なリクエストが発生しなければ、リンクリストを以下のように実装することができる: 10 | 11 | ```cpp 12 | 1 struct list { 13 | 2 int data; 14 | 3 struct list *next; 15 | 4 }; 16 | 5 17 | 6 struct list *list = 0; 18 | 7 19 | 8 void 20 | 9 insert(int data) 21 | 10 { 22 | 11 struct list *l; 23 | 12 24 | 13 l = malloc(sizeof *l); 25 | 14 l->data = data; 26 | 15 l->next = list; 27 | 16 list = l; 28 | 17 } 29 | ``` 30 | 31 | この実装が正しいことを証明するのは、データ構造とアルゴリズムの授業において、典型的な練習問題である。 32 | この実装が正しいと証明されたとしても、少なくともマルチプロセッサ上では正しくない。もし2つの異なるCPUが`insert`を同時に実行開始すると、どちらのコードも16行目を実行する前に15行目を実行する(図4-1を参照のこと)。もしこのようなこととが発生すると、`list`は2つのノードのnext値として設定される。16行目で`list`への代入の文が同時に実行されると、2番目の文は最初の文を上書きする; 最初に代入を実行した分のノードは消失してしまう。この類の問題は「レースコンディション」と呼ばれる。このレースの問題は、2つのCPUの厳密なタイミングに依存し、メモリ操作がどのような順番で実行されるかに依存するため、再現することが難しい。例えば、`insert`をデバッグ中にprint文を追加すると、タイミングが変わるためこのレース状態が消失してしまう可能性がある。 33 | 34 | レースコンディションを回避するための典型的な方法はロックを使うことである。ロックにより排他実行を保証し、たった1つのCPUが同時に`insert`を実行することを保証する; これにより、上記のシナリオが発生することは不可能となる。以下のコードは、上記のコードにいくつかプログラムを追加した、正しくロック機構を導入したバージョンである(ナンバリングされていない部分が、新たに追加したプログラムである)。 35 | 36 | ![Figure4-01](images/figure4-01.JPG) 37 | 38 | ```cpp 39 | 6 struct list *list = 0; 40 | struct lock listlock; 41 | 7 42 | 8 void 43 | 9 insert(int data) 44 | 10 { 45 | 11 struct list *l; 46 | 12 `acquire`(&listlock); 47 | 13 l = malloc(sizeof *l); 48 | 14 l->data = data; 49 | 15 l->next = list; 50 | 16 list = l; 51 | release(&listlock); 52 | 17 } 53 | ``` 54 | 私達は、データをロックすると言うが、正確にはロックによりデータに適用されるいくつかの不変条件が保護されたというのが正しい。不変条件は、データ操作の間に保持されているべきデータの特性である。典型的には、処理の正しい動作はその不変式がその処理が開始されたときに真であるかに依存する。処理は一時的にその不変条件を侵害するときがあるが、処理が完了する迄には修復できていなければならない。例えば、リンクリストのケースは、不変条件は変数`list`がリストの最初のノードを指しており、各ノードの`next`フィールドが次のノードを指しているといものである。`insert`の実装は、`l`がリストの最初のノードであるが、`l`の次のポインタがリストの次のノードを指していない(これにより不変条件が崩れるが、15行目で復元される)。そして、`list`は`l`を指してはいない(16行目で修復される)。私達がチェックしたレースコンディションは上記の部分で発生する。それは、2番目のCPUがリストの不変条件が一時的に崩されたときに発生するからである。ロックの正しい使い方は、ある時間にたった一つのCPUがあるデータ構造を操作し、そのデータ構造の不変条件が崩れている最中に他のCPUがそのデータ構造を触らないようにすることである。 55 | 56 | # コード例: ロック 57 | xv6はロックを`struct spinlock`として表現する(1501行目)。データにおいてそれが「ロックされている」というのは、そのワードがゼロであればロックが入手可能であるということで、非ゼロであればロックされているということである。論理的には、xv6は次のようなコードを実行してxv6はロックを獲得する。 58 | 59 | ```cpp 60 | 21 void 61 | 22 `acquire`(`struct spinlock` *lk) 62 | 23 { 63 | 24 for(;;) { 64 | 25 if(!lk->locked) { 65 | 26 lk->locked = 1; 66 | 27 break; 67 | 28 } 68 | 29 } 69 | 30 } 70 | ``` 71 | 残念ながら、上記のコードではマルチプロセッサで正しく排他制御を実現できる保証はない。2つ以上のマルチプロセッサが同時に25行目を実行し、`lk->locked`がゼロであることを確認すると、同時に26行目を実行し、27行目に移る。これにより、2つ以上の異なるCPUがロックを獲得し、排他実行の特性が破られることになってしまう。レースコンディションを避ける支援を行うどころか、上位の`acquire`関数の実装自体がレースコンディションを持っている。この問題は、25行目と26行目が別々に実行されることである。上記のルーチンを正しく実行するためには、25行えと26行目が「アトミック(atomic)」(つまり、分割されることなく)実行されなければならない。 72 | 73 | 上記の2つの文をアトミックに実行するためには、xv6は386の特別なハードウェア命令,、`xchg`(0569行目)に頼らなければならない。1つのアトミックな操作によって`xchg`はメモリの内容とレジスタの内容をスワップする。関数`acquire`(1574行目)はこの`xchg`命令をループで繰替えして実行している;各繰り返しでは、`lk->locked`を読み込み、アトミックに1を設定する(1583行目)。もしロックが保持されていれば、`lk->locked`は既に1であるため、`xchg`は1を返してループを継続する。もし`xchg`が0を返したならば、`acquire`はロックを正しく獲得に成功したということであるー`locked`は0から1に変化するーそうして、ループは終了する。 74 | ロックが一度獲得されると`acquire`はデバッグのためにそれを記録し、CPUとスタックトレースはロックを獲得したことを記録する。プロセスがロックを獲得しリリースを忘れると、その情報によりロックの解除を忘れた犯人を特定することが出来る。このようなデバッグフィールドは、ロックによって保護されており、ロックが確保されているときにしか編集することができない。`release`関数(1602行目)は`acquire`の逆である: デバッグフィールドをクリアし、ロックを解放する。 75 | 76 | # モジュール性と再帰ロック 77 | 78 | システムデザインでは、クリーンな、モジュールを用いた抽象化を行わなければならない:つまり特定の機能について、呼び出し元が呼び出し先の実装がどのような実装になっているのかについて知る必要が無いのがベストである。しかしロックはこのモジュール性を邪魔するものである。例えば、CPUが特定のロックを保持していたとすると、そのロックを獲得しようとする任意の関数`f`は呼び出すことができない: 何故ならば、`f`があるロックを獲得しようとすると、呼び出し元は`f`が戻るまで同一のロックを解放することができず、これは永遠にスピンし続けるか、デッドロックを引き起す。 79 | 80 | 呼び出し元と呼び出し先でロックの情報を隠すという方法は透明性の無い解決法である。ある共通の、透明性のあるが、不完全な回答は「再帰ロック(recursive lock)」を用い、呼び出し元により確保されたロックをさらに獲得することができるようにする方法である。この方法の問題は、不変条件の保護には利用できないということである。`insert`が`acquire(&listlock)`を呼び出した後は、このロックを他の関数が確保することはないと仮定しており、他のどのような関数も上記のリスト操作を行っている最中ではないという仮定を行っている。さらに重要なことに、全てのリストが不変条件を保持しているということである。再帰ロックを持ったシステムでは、`insert`は`acquire`を実行した後にどのような仮定を置く必要も無い:おそらく、`insert`の呼び出し元が既にロックを保持しておりリストデータ構造を編集中であるため、`acquire`は成功するしかないのである。不変条件は、保持しているか、そうでないかのどちらかである。リストは、もはやそれらを保護しない。ロックは呼び出し元と呼び出し先が互いに異なるCPUを互いに保護するときに重要である; 再帰ロックはこの特徴を諦める。 81 | 82 | 明快な解決法は存在しないため、ロックは関数の仕様の一部として考えるべきである。プログラマは関数は`f`が必要なロックを保持している関数`f`を起動しないように調整する必要がある。ロックは、これらを強制的に私達の抽象化の世界へ引きずり込むものなのである。 83 | 84 | # ロックの利用 85 | 86 | xv6はレースコンディションを避けるためにロックを利用して注意深くプログラムされている。シンプルな例としてIDEドライバがある(4100行目)。本章の最初に述べたように、`iderw`(4254行目)はディスクリクエストを扱うためのキューを持っており、プロセッサは新しいリクエストを並列にリストに挿入していく(4269行目)。 87 | このリストと他の不変条件をドライバ内で保護するためには、`iderw`は`idelock`(4265行目)を獲得し、関数の最後で解放する。練習問題1では、本章の最初で紹介したレースコンディションにおいて、キュー操作の後に`acquire`を移動することによってどのようにレースコンディションが発生するのかについて見る。レースコンディションを発生させることは簡単なことではなく、だからこそレースコンディションのバグを見つけることは難しく、この練習問題は挑戦する価値のあるものである。xv6にはレース状態は無いと思われる。 88 | 89 | ロックの利用における難しい部分は、いくつのロックを使い、どのデータと不変条件について、ロックで保護するかを決定することである。これにはいくつかの基本的な法則が存在する。まず、他のCPUからいつでも同時に読み書きをすることのできる変数については、ロックを導入して2つの操作がオーバラップすることを防ぐべきである。2番目に、ロックは不変条件を保護するということを思い出して欲しい: もし不変条件が複数のデータ構造について発生するならば、典型的に全てのデータを単一のロックで保護し、不変条件が維持されることを保証しなければならない。 90 | 91 | 上記のルールは、ロックが必要な場合については述べているが、ロックが必要でない場合については説明してない。また、ロックは並列性を減少させるため、ロックをし過ぎるのも効率が良くない。もし効率性が重要ではないのなら、単一のプロセッサだけを利用してロックについて考慮しないようにすれば良い。カーネルのデータ構造を保護するためには、カーネルに入るためのロックを取得して、カーネルから出るときにロックを解放する。大くの単一プロセッサのオペレーティングシステムは、マルチプロセッサに移植するためにこのアプローチが取られ、これは「ジャイアントカーネルロック(giant kernel lock)」と呼ばれる。しかしこのアプローチは真の並列性を犠牲にしている: 一度に、たった一つのプロセッサしかカーネルを実行することができないらである。もしカーネルが重たい計算をしないならば、より細粒度な複数のロックを利用して、複数のCPUが同時にカーネルを実行できるようにすべきである。 92 | 93 | 究極的には、ロックの粒度の選択は並列プログラミングの練習問題になる。xv6は複数の粗粒度のロックデータ構造を持っている; 例えば、xv6はプロセステーブルとその不変条件を保護するために1つのロックを利用しており、これは第5章で説明する。より細粒度のアプローチとしては、プロセステーブルのエントリ毎にロックを持つことによって、別々のエントリで動作するスレッドが並列に動作するようにできる。しかし、これは複数のロックを持つ必要があるためプロセステーブル全体で不変条件を保つために複雑な操作が必要なる。xv6のロックの例が、どのようにしてロックを利用するかについての理解に役に立つことを希望する。 94 | 95 | # ロックのオーダリング 96 | カーネルを通過するコードは、いくつかのロックを獲得する必要があるが、全てのコードパスはロックを同じ順番に獲得していく必要がある。 97 | もしそうでなければ、デッドロックする危険性が生じる。 98 | ロックAとBを獲得する、2つのコードが存在するとするが、1つのパスはロックをA→Bの順番で獲得し、もう一つはロックをB→Aの順番で獲得するとする。 99 | この状況では、コードパスが1ロックAを獲得しロックBを獲得する前に、コードパス2がロックBを獲得する可能性があるため、デッドロックが発生し得る。 100 | これにより、コード1はロックBが必要で、これはコード2が保持しており、コードパス2はロックAが必要で、これはコードパス1が保持しているという状況が発生し、どちらのコードも先に進むことができなくなる。 101 | このようなデッドロックを避けるためには、全てのコードパスはロックを同一の順番で獲得しなければならない。 102 | デッドロックの回避は、何故ロックを関数の使用として含めなければならないかを説明する例となる: 呼び出し元は一貫した順番で関数を呼び出さなければ、関数内で獲得するロックを同じ順番で獲得できなくなるからである。 103 | 104 | xv6は粗粒度のロックを利用し、またxv6がシンプルなため、xv6は短めのロックの順番チェーンしか持っていない。 105 | 最も長いロックでも、2つである。 106 | 例えば、`ideintr`はwakeupを呼び出しているときにideウロックを保持し、wakeupはptableのロックを獲得する。 107 | sleepとwakeupを呼び出す例はいくつか存在する。 108 | このオーダリングは、sleepとwakeupが複雑な不変条件を持っており、これについては第5章で議論する。 109 | ファイルシステムでは、ディレクトリからファイルを正しくアンリンクするために、まずディレクトリのロックを獲得し、その後そのディレクトリのファイルのロックを獲得するため、2つのロックが必要になる。 110 | xv6は、常に最初にディレクトリのロックを獲得し、次にファイルのロックを獲得する。 111 | 112 | # 割り込みハンドラ 113 | 114 | xv6はあるCPUで割り込みハンドラが動作している最中に、同じデータに対して他のCPUからアクセスが発生しないようにロックで保護をしている。例えば、タイマ割り込みハンドラ(3364行目)は`ticks`をインクリメントするが、他のCPUが同時にsys_sleepに入ると、その変数を利用する(3723行目)。`tickslock`ロック変数により、2つのCPUが単一の変数にアクセスすることを防ぐ。 115 | 116 | 単一のプロセッサでも、割り込みは同時に発生することがある: もし割り込みが許可されているならば、カーネルコードは動作を停止し、いつでも割り込みハンドラが呼び出される。`iderw`が`idelock`を保持している最中に、`ideintr`の割り込みが発生したとしよう。`ideintr`は`idelock`ロック変数を獲得しようとするが、それは既に保持されており、それが解放されるまで待つ。この状況では、`idelock`は永遠に解放されない ー `iderw`のみがロックを解放できるが、`iderw`は`ideintr`が終了しない限りは実行されないー従って、プロセッサおよびシステム全体がデッドロックとなる。 117 | 118 | このような状況を避けるためには、もしロックが割り込みハンドラ中で利用されるならば、プロセッサはロックを保持しているときは決して割り込みを許可してはならない。xv6はより保守的である: xv6は割り込みが可能な状態でロックを保持することは無い。`pushcli`(1655行目)と`popcli`(1666行目)を利用して、「割り込み不許可」の操作のスタックで管理している(`cli`はx86の命令で割り込みを不許可にする命令である。) `acquire`はロックを獲得する前に`pushcli`を呼び出し(1576行目)、ロックを解放した後に`popcli`を呼び出す(1621行目)。 119 | `pushcli`(1655行目)と`popcli`(1666行目)は`cli`と`sti`のラッパーとしての役割だけではない: これらはカウントを行っており、`pushcli`を2回呼んだときに、`popcli`を2回呼び出すことによりその操作を取り消すことができる; この方法により、もしコードが2つの異なるロックを獲得すると、どちらのロックも解放されなければ割り込みが有効とはならない。 120 | 121 | `xchg`によりロックを獲得する(1583行目)前に、`acquire`が`pushcli`を実行することが重要である。もしこれらが逆であれば、ロックを獲得してから割り込みが有効な数サイクルの間に、不幸にも割り込みが発生するとデッドロックが発生する。同様に、`release`が`popcli`をする前に、`xchg`によりロックを解放することが重要である。 122 | 123 | 割り込みハンドラと非割り込みコードの相互作用により、再帰ロックが何故問題になるのかについての良い例が得られる。xv6が再帰ロックを利用すると(1番目の`acquire`が許可されてから、同じCPUで2番目の`acquire`が許可される)、割り込みハンドラは、非割り込みコードのクリティカルセクション上で動作する可能性がある。 124 | 割り込みハンドラが動作すると、依存している不変条件がハンドラによって一時的に破壊されるため、システムの破壊を起こす可能性がある。例えば、`ideintr`(4202行目)のリンクリストが正しく実装されているとする。 125 | xv6が再帰ロックを利用すると、`ideintr`は`iderw`が動作している最中に動作することができるようになり、リンクリストを操作している最中に割り込みが入ることにより不定な状態となる可能性がある。 126 | 127 | # メモリオーダリング 128 | 129 | 本章では、プロセッサはプログラムの命令を、そのプログラムの書いてある順番に実行するものとして説明してきた。しかし多くのプロセッサでは、命令をアウトオブオーダに実行して性能を向上させている。もしある命令の実行に長いサイクル数あ必要なのであれば、プロセッサはその命令を速く発行して、他の命令とオーバラップさせることでプロセッサのストールを避けたくなる。例えば、プロセッサが命令AとBを順番に実行し、これらの命令ん依存が無いとすると、まず命令Bを、命令Aを実行する前に発行して命令Aが完了したときに、命令Bも完了させる。しかし同時に、これはソフトウェアのリオーダリングが発生し、誤った動作が発生する可能性がある。 130 | 131 | 例えば、`release`内で`lk->locked`の操作に`xchg`を利用するのではなく、単に0を代入するのではどうだろう。 132 | この答えば不明瞭で、x86のプロセッサの世代によってメモリオーダリングの保証が異なるからである。もし`lk->locked`=0のリオーダが許可され、`popcli`の後に配置されると、ロックが解放される前に他のスレッドの割り込みが許可されるため、`acquire`により破壊されるされる可能性がある。このメモリオーダリングのプロセッサ毎の不明瞭さを避けるためには、xv6はリスクを取らず常に`xchg`を利用し、プロセッサがリオーダリングしないことを保証している。 133 | 134 | # 現実の世界 135 | 136 | 並列性のプリミティブと並列プログラミングはアクティブな研究領域であり、ロックを用いたプログラミングは未だに挑戦的なものである。同期キューなどの、高レベルの構造を元にしてロックを利用するのがベストではあるが、xv6はそれを利用していない。もしロックを利用して読者がプログラムを作るのなら、レースコンディションを認識できるツールを使うのが賢い選択だ。何故ならば、ロックが必要な不変条件のプログラムは簡単にミスを起こしやすいからだ。 137 | 138 | プーザプログラムでも、ロックは必要ではあるが、xv6のアプリケーションは1つのスレッドしか存在せず、プロセスはメモリをシェアしないため、xv6のアプリケーションではロックは必要ない。 139 | 140 | アトミック命令を利用してロックを実装することもできるが、それは高価である。しかし殆どのオペレーティングシステムはアトミック命令を利用している。 141 | 142 | アトミック命令はロックがカウントされる場合は、自由に操作することが難しい。もし1つのプロセッサがローカルキャッシュ上に置かれているロックを持っている場合、他のプロセッサがロックを獲得するためには、ロックを保持しているラインをプロセッサのキャッシュから他のプロセッサのキャッシュに移動しなければならない。これにはキャッシュラインの無効化とキャッシュラインのコピーが必要になる。他のプロセッサのキャッシュへ、キャシュラインをフェッチすることは、ローカルキャシュからフェッチするのよりも高価の操作になる。 143 | 144 | ロックによりこのような高価な操作が発生することを防ぐために、多くのオペレーティングシステムはロックフリーなデータ構造とアルゴリズムを利用しており、アトミックな命令とそのアルゴリズムを避けるようにしている。例えば、本章の最初に説明したリンクリストはリストの検索時にはロックは必要とせず、リストにアイテムを挿入する場合にのみ、アトミック命令を利用している。 145 | 146 | # 練習問題 147 | 148 | 1. `acquire`から`xchg`を除去せよ。xv6を動作させると、何が発生するか? 149 | 2. `iderw`の`acquire`をsleepの前に移動させよ。レースコンディションははっせい するか?xv6をブートし、`stressfs`を実行してみると良い。ダミーループによりクリティカルセクションを増加させると、どのようになるか?説明せよ。 150 | 3. バッファ上のflagsをアトミック操作以外で設定せよ: プロセッサはflagsのコピーをレジスタにロードし、レジスタを操作し、ライトバックする。 151 | 従って2つのプロセスがflagsを同時に書き込まないことが重要である。 152 | xv6は、`B_BUSY`ビットを処理するときのみ`buflock`を確保し、`B_VALID`および`B_WRITE`フラグを操作するときはロックを保持しない。なぜこれでも安全なのか? 153 | -------------------------------------------------------------------------------- /chapter5.md: -------------------------------------------------------------------------------- 1 | 第5章 スケジューリング 2 | ================== 3 | 4 | どのようなオペレーティングシステムも、コンピュータが持っているプロセッサの数以上のプロセスを実行し、従ってプロセス間で時分割共有が必要になる。理想的には、ユーザプロセスからはこの共有は見えないようにするべきである。共通のアプローチとしては、各プロセスが個々に仮想マシンを保持しているように見せかけ、オペレーティングシステムが複数の仮想マシンを単一のプロセッサで時分割共有して実行することである。本性ではxv6がどのようにして複数のプロセスをプロセッサ上で実行しているのかについて説明する。 5 | 6 | # 多重化 7 | 8 | xv6は各プロセッサがあるプロセスから他のプロセスに切り替えることで多重化を行うが、これには2つの序京がありうる。1つ目は、xv6は、あるプロセスがデバイスやパイプI/Oの完了を待つために待ち状態になると、`sleep`と`wakeup`の2つのメカニズムにより切り替えを行うか、子供が終了するのを待つか、`sleep`システムコールによって終了するのを待つ。2番目は、xv6がユーザ命令を実行中に、定期的に強制的に切り替えを行う。 9 | この多重化により、各プロセスは自分のCPUを持っているように見えるが、xv6がメモリアロケータとハードウェアページテーブルにより各プロセスの固有のメモリを持っているように見せ掛けているだけである。 10 | 11 | 多重化を実装するには、いくつか困難な点がある。最初に、あるプロセスからどのようにして別のプロセスに切り替えるのか?xv6はコンテキストスイッチングの標準的なメカニズムを利用している; しかしアイデアはシンプルで、実装はシステムにおいて最も不透明である。2番目に、どのようにして透過的なコンテキストスイッチングを実現するのか?xv6は標準的なタイマ割り込みハンドラによりコンテキストスイッチを駆動している。3番目に、多くのCPUはプロセスを同時に切り替えており、従ってレースコンディションを避けるためにロックの機構も考える必要がある。4番目に、プロセスが終了したときに、そのメモリと資源を開放しなければならないが、しかしそれを自分自身では実行できない。何故ならば、(例えば)自分が利用しているのに自分のカーネルスタックを開放することはできないからである。xv6はこの問題をなるべくシンプルな方法で解決しようとしているが、結果として得られるコードはトリッキーである。 12 | 13 | xv6はプロセスが自分自身を調整することのできる方法を提供しなければならない。例えば、親プロセスはその子プロセスが終了するまで待つか、他のプロセスがパイプへの書き込みを行うのを待たなければならない。プロセスが、所望のイベントが発生しているかチェックするためにCPUを無駄に利用するよりも、xv6はCPUの利用を諦めてイベントが発生するまでは眠っておき、他のプロセスが最初のプロセスを起動したほうが良い。イベントの通知を読み落とすことを避けるために、レースコンディションを避けるためのケアが必要になる。この問題と解答の例として、本章ではパイプの実装について取り扱う。 14 | 15 | # コード例: コンテキストスイッチング 16 | 17 | 図5-1に示すように、プロセス間で切り替えを行うためには、xv6は低レイヤにおいて2種類のコンテキストスイッチを行っている: プロセスのカーネルスレッドから現在のCPUのスケジューラスレッドへの切り替えと、スケジューラスレッドからプロセスのカーネルスレッドへの切り替えである。xv6は、あるユーザ空間のプロセスから他のプロセスへ直接切り替える、ということは決してない;このような直接他のプロセスに切り替わる状況は、ユーザカーネルの変換(システムコールもしくは割り込み)によって発生することはあるが、スケジューラへのコンテキストスイッチ、新しいプロセスのカーネルスレッドへのコンテキストスイッチ、およびトラップリターンの場合のみ発生する。本章ではこのメカニズムの説明として、カーネルスレッドとスケジューラスレッドを取り扱う。 18 | 19 | ![Figure5-01](images/figure5-01.JPG) 20 | 21 | 第2章で見てきたように、全てのxv6のプロセスは自分自身のカーネルスタックとレジスタセットを持っている。各CPUは任意のプロセスのカーネルスレッド向けではなく、スケジューラを実行するに分離したスケジューラスレッドを持っている。ある1つのスレッドから他のスレッドに切り替えるために、古いスレッドのCPUレジスタを対比し、新しいスレッドのレジスタを復帰させるという処理が発生する;`%esp`と`%eip`の保存と回復が実行され、CPUがスタックをスイッチすることで、実行しているコードもスイッチしたことになる。 22 | 23 | `swtch`はスレッドのことを直接知っているわけではない;`contexts`と呼ばれるレジスタセットの保存と復帰を行う処理を実行しているだけである。プロセスがCPUを使うことを諦めると、プロセスのカーネルスレッドが`swtch`を予備、自身のコンテキストを退避してスケジューラコンテキストへと飛ぶ。各コンテキストは`struct context*`として表現されており、関連するカーネルスタックの構造体のポインタとして表現されている。 24 | `swtch`は2つの引数を取る; `struct context **old`と`struct context *new`である。`swtch`は現在のCPUレジスタをスタックに保存して、スタックのポインタを`*old`に保存する。次に、`swtch`はnewを`%esp`にコピーし、前の保存したレジスタをポップしてから関数から戻る。 25 | 26 | `swtch`内を見てスケジューラを追いかける代わりに、私たちのユーザプロセスが復帰するところを見てみよう。第3章において、各割り込みの最後に`trap`が``yield``を呼び出す可能性があることについて触れた。`yield`は`sched`を呼び出し、`sched`は`proc->context`に入っている現在のコンテキストを保存して`cpu->scheduler`によって保存している過去のスケジューラコンテキストにスイッチする(2766行目)。 27 | 28 | `swtch`(2952行目)はまずスタックから引数をロードして、それを`%eax`と`%edx`(2959-2960行目)に格納する;`swtch`はスタックポインタを変更して`%esp`を通じてどこにもアクセスできなくなる前にこれを実行する必要がある。次に、`swtch`はレジスタステートを保存し、現在のスタック上にコンテキスト構造体を作成する。呼び出し先が保存するレジスタは保存する必要がある; x86は`%ebp`,`%ebx`,`%esi`,`%ebp`,`%esp`が対象である。 29 | `swtch`は最初の4つのレジスタを明示的にプッシュする(2963-2966行目); 最後のレジスタは、`*old`に`struct context*`を書き込むことによって暗黙的に保存される。さらに、もう一つ重要なレジスタが存在する: プログラムカウンタ`%eip`は`swtch`を呼び出す`call`により保存され、`%ebp`のスタックの上に格納される。古いコンテキストを保存することによって、`swtch`は新しいコンテキストをロードする準備が整う。`swtch`はポインタを新しいコンテキストのスタックポインタに移す(2970行目)。新しいスタックは`swtch`が保存した古いスタックのもとの構造的には一緒である - 新しいスタックは前の`swtch`が呼ばれたときは古いスタックだったのである - したがって、`swtch`は新しいコンテキストを退避する手順を逆に踏んでいけばよい。`%edi`,`%esi`,`%ebx`,`%ebp`をポップし、買えされた命令アドレスは新しいコンテキストのものである。 30 | 31 | 私たちの例では、`sched`は`swtch`を呼び出して`cpu->scheduler`にスイッチして、CPU毎のスケジューラコンテキストにスイッチする。コンテキストは`scheduler`により保存され、`swtch`が呼ばれる(2728行目)。`swtch`がどこに戻るかをトレースして言ったとき、`sched`には戻らずに`scheduler`に戻る。スタックポインタは現在のCPUのスケジューラタスクを指しており、`initproc`のカーネルスタックを指しているわけではない。 32 | 33 | 34 | # コード例: スケジューリング 35 | 36 | 前章では、`swtch`の低レイヤの詳細について確認した; では、`swtch`を例に取り、あるプロセスからスケジューラに移り、さらにプロセスに移るための方法について見ていこう。CPUの使用を取り止めたいプロセスは、プロセステーブルロックである`ptable.lock`を取得し、現在保持している全てのロックをリリースし、現在の状態(``exit``)を更新し、`sched`を呼ぶ。`yield`(2772)はこの慣習に従い、`sleep`命令と`exit`命令を実行する。これらについては後に見ることにする。`sched`はこれらの状態のダブルチェックを行い(2757-2762行目)、これらの状態のimplication行う:何故ならば、CPUはロックを獲得する場合は割り込みを無効化しておく必要があるからである。 37 | 最後に、`sched`は`swtch`を呼び`proc->context`の現在のコンテキストを保存して、`cpu->scheduler`により保持されているスケジューラコンテキストにスイッチする(2728行目)。スケジューラはforループを実行し続け、実行できるプロセスを見つけ、スイッチングすることを続ける。 38 | 39 | xv6がは`swtch`を呼び出している間、`ptable.lock`を保持するところを見た: `swtch`の呼び出し元は既にこのロックを獲得している必要があり、ロックの制御はコードのスイッチングに渡される。この慣習はロックにとって通常のことではない; 典型的な慣習は、ロックを獲得したスレッドがロックの解放の責任を持つことであり、これは正しさを保証するためには当然のことである。コンテキストスイッチングのためには、典型的な慣習を破壊する必要がある。何故ならば、`ptable.lock`は、`swtch`を実行中には真ではないプロセスの状態とcontextフィールドの不変条件を保護しているからである。`ptable.lock`が`swtch`の間中保持されていなかった場合に発生する問題の例を示す: 異なるCPU上で`yield`が状態を`RUNNABLE`に変更した後に、どのプロセスを実行するかを来める必要があるが、`swtch`を呼ぶ前にカーネルスタックを使うことを止める。この結果により、同一のスタック上で実行している2つのCPUの実行状態を、正しく設定することができなくなる。 40 | 41 | カーネルスレッドは、`sched`の中でいつもプロセッサの利用を止め、スケジューラ中の同一の場所にスイッチし、`sched`内で(殆ど)常にプロセスにスイッチする。従って、もしxv6がスレッドをスイッチした行番号をプリントすると、以下のようなシンプルなパタンが存在するはずである(2728行目),(2766行目)、(2728行目)、(2766行目)である。このような形式で2つのスレッドがスイッチングを発生させることを、「コルーチン」と読んでいる;この例では、`sched`と`scheduler`がそろぞれコルーチンである。 42 | 43 | 新しいプロセスが`sched`内で終了しない例がある。第2章で見たように、新しいプロセスが最初にスケジュールされたときである。新しいプロセスは、`forkret`(2783行目)から実行を開始する。`forkret`は`ptable.lock`を解放することで、この慣習を守るための存在している; そうでなければ、新しいプロセスは`trapret`からスタートすることになる。 44 | 45 | `scheduler`(2708行目)は単純なループを実行する: 実行可能なプロセスを見つけ、それが停止するまで実行することを繰替えす。`scheduler`は殆ど全ての動作中に、`ptable.lock`のロックを保持しているが、各繰り返しにおいて、ループの外に出るときだけロックを解放する(そして、明示的に割り込みを許可する)。これは、CPUがアイドル状態のとき(`RUNNABLE`なプロセスを発見することができなかったとき)に重要である。アイドル状態のスケジューラがロックを保持し続けていると、プロセスを実行している他のCPUがコンテキストスイッチや、システムコールに関連するプロセスを実行したり、さらに特にプロセスを`RUNNABLE`に設定する操作ができず、遊休状態のCPUが二度とスケジューリングできなくなってしまう。定期的に割り込みを許可する理由は、アイドル中のCPUで、例えばシェルのようなI/O待ちの状態で`RUNNABLE`のプロセスが存在しない場合のためである; 46 | もしスケジューラが割り込みを常に不許可にしていた場合、I/Oの割り込みはもう二度と発生しなくなってしまう。 47 | 48 | スケジューラはテーブルを参照しながら、`p->state==RUNNABLE`であるプロセス、つまり実行可能な状態にあるプロセスを探し続ける。 49 | プロセスを発見すると、CPU毎の現在のプロセスの変数であるprocを設定し、プロセスのページテーブルを`switchuvm`によりスイッチし、プロセスを``RUNNING``に設定し、`swtch`を実行してプロセスの実行を開始する(2722-2728行目)。 50 | 51 | スケジューリングのコードの構造について考えるための一つの方法は、各プロセスが常に不変条件を維持するように調整されているとして、その不変条件が真でなくなるときは常に`ptable.lock`が保持されていると考えることである。不変条件の一つは、もしプロセスが`RUNNING`状態であれば、実行状態は整っており、タイマー割り込みの`yield`は正しくプロセスからスイッチすることができる; これは、CPUのレジスタがそのプロセスの値を帆いしており(例えば、それらは実際にはcontextの中には存在しない)、%cr3はプロセスのページテーブルを参照しており、`%esp`はプロセスのカーネルスタックを参照してなければならず、従って、`swtch`はレジスタを正しくプッシュしており、procはプロセスのproc[]スロットを参照していなければならない。 52 | 他の不変条件は、もしプロセスが`RUNNABLE`であれば、アイドル状態のCPUでは、スケジューラを実行することができる; p->contextはプロセスのカーネルスレッドの値を持っており、プロセスのカーネルスタックを実行しているCPUは存在せず、CPUの%cr3はプロセスのページテーブルを参照しておらず、CPUのprocはプロセスを参照してはいない。 53 | 54 | 上記の不変条件を管理することが、xv6が`ptable.lock`を1つのスレッド(しばしば`yield`の中)で獲得し、異なるスレッド(スケーウラスレッドもしくは他の次のカーネルスレッド)で解放する理由である。 55 | 実行しているプロセスの状態を`RUNNABLE`に設定するための変更が始まると、その不変条件が修正されるまでは、lockを保持していなければならない: 最短の正しい解放ポイントは、`scheduler`がプロセスのページテーブルを使用するのを止め、procをクリアするところである。同様に、一度`scheduler`が実行状態のプロセスを`RUNNING`に変更する場合は、カーネルスレッドが完全に実行する状態になるまで(`swtch`をを実行してから、例えば`yield`の中で)ロックは解放することができない。 56 | 57 | `ptable.lock`は同様に、他の部分についても保護を行っている: プロセスのIDの割り当てと、プロセスのテーブルの解放処理と、`exit`と`wait`の相互作用と、`wakeup`のロストを避けるための手続き(次章を参照のこと)と、他にも様々なことに利用される。`ptable.lock`の他の機能について考えることは、明確性については確実に、性能についてはおそらく、分割して考えることが価値のあることになるxxx。 58 | 59 | # `sleep`と`wakeup` 60 | 61 | スケジューリングとロックは、あるプロセスを他のプロセスから存在を隠すことを助けるが、今のところはプロセスが意図的に相互作用することを助けるための抽象化は存在していない。`sleep`と`wakeup`はそれを埋めるものであり、プロセスがイベントを待つためにスリープ状態に入り、イベントが発生すると他のプロセスが置きる、ということができるようになる。`sleep`と`wakeup`は「sequence coordination」もしくは「conditional synchronization」のメカニズムと呼ばれ、オペレーティングシステムの文献には、他にも似たような多くのメカニズムが存在する。 62 | 63 | この構造を説明するために、まずは簡単な生産者と消費者のキューを考える。このキューはプロセスからコマンドも受けとるIDEのドライバと似ている(第3章を参照のこと)が、IDEの特定のコードからは抽象化されている。 64 | キューはあるプロセスが非ゼロのポインタを他のプロセスに送信することを許可している。もし送信者が1つで、受信者も1つであり、それらが異なるCPU上で動作していれば、コンパイラは強力に最適化をすることは無く、以下のような実装で実現することができる: 65 | ```cpp 66 | 100 struct q { 67 | 101 void *ptr; 68 | 102 }; 69 | 103 70 | 104 void* 71 | 105 send(struct q *q, void *p) 72 | 106 { 73 | 107 while(`q->ptr` != 0) 74 | 108 ; 75 | 109 `q->ptr` = p; 76 | 110 } 77 | 111 78 | 112 void* 79 | 113 recv(struct q *q) 80 | 114 { 81 | 115 void *p; 82 | 116 83 | 117 while((p = `q->ptr`) == 0) 84 | 118 ; 85 | 119 `q->ptr` = 0; 86 | 120 return p; 87 | 121 } 88 | ``` 89 | 90 | `send`は、キューが空の間は実行し続け、ポインタpをキューに挿入する。`recv`はキューが空でない間は実行し続け、ポインタを取り出す。プロセスとして実行されているときは、`send`と`recv`はどちらとも`q->ptr`を変更するが、`send`は`q->ptr`がゼロのときだけ書き込み、`recv`は`p->ptr`が非ゼロのときだけ書き込む。従って、更新情報をロストすることはない。 91 | 92 | 上記の実装はコストが高い。もし送信者が殆ど送信をしなければ、受信者はwhileループの中でポインタがやって来るまでスピンしながら待っていなければならない。受信者のCPUは、もし受信者がCPUを消費する他の方法が存在すれば、`send`がポインタを送信するときだけ回復し、それ以外のときは眠っていられる。 93 | 94 | 以下のように動作する、`sleep`と`wakeup`の2つの呼び出しを想像してみよう。`sleep(chan)`は、任意の値`chan`上でスリープ状態に入る。これを`wait`チャネルと呼ぶ。`sleep`はスリープ状態に入るためにプロセスを呼び出し、他の仕事のためにCPUを手放す。`wakeup(chan)`はchan上でスリープ状態に入っている全てのプロセスを呼び出し(もし必要ならば)、戻るためにこれらの`sleep`を呼び出す(xxx)。chan上でプロセスあ待っていなければ、`wakeup`は何もしない。このような`sleep`と`wakeup`を利用するために、以下のようにキューの実装を変更する。 95 | 96 | ```cpp 97 | 201 void* 98 | 202 send(struct q *q, void *p) 99 | 203 { 100 | 204 while(q->ptr != 0) 101 | 205 ; 102 | 206 q->ptr = p; 103 | 207 wakeup(q); /* wake recv */ 104 | 208 } 105 | 209 106 | 210 void* 107 | 211 recv(struct q *q) 108 | 212 { 109 | 213 void *p; 110 | 214 111 | 215 while((p = q->ptr) == 0) 112 | 216 sleep(q); 113 | 217 q->ptr = 0; 114 | 218 return p; 115 | 219 } 116 | ``` 117 | 118 | `recv`はスピン状態に入るのではなく、CPUを手放す。これは良い方法である。しかし、このインタフェースで、図5-2で説明した「ロストした`wakeup`」として知られている問題を解決するためのインタフェースを利用して`sleep`と`wakeup`を実装することは、簡単な話ではない。例えば、`recv`が215行目の`q->ptr==0`であることを検出したとしよう。`recv`は215と216行目の間にいるとき、`send`は他のCPU上で動作している: 119 | `send`は`q->ptr`を非ゼロの値に書き換え、`wakeup`を呼ぶが、スリープ状態に入っているプロセスは存在せず、何も起こらない。`recv`は216行目を実行し、`sleep()`を実行することでスリープ状態に入る。ここで問題が生じる: `recv`はスリープ状態に入り、ポインタを待っているが、それは既に到着している。次の`send`が`recv`が起きてキュー上のポインタを消費するのを待つためにスリープ状態に入り、この時点でこのシステムではデッドロックが発生する。 120 | 121 | ![Figure5-02](images/figure5-02.JPG) 122 | 123 | この問題の原因は、`recv`は`q->ptr==0`が成立しなくなったときにのみスリープ状態に入り、それとは違うタイミングで`send`を実行させるところにある。以下のように、`recv`のコードを変更して不変条件を保とうとするのは間違いである: 124 | 125 | ```cpp 126 | 300 struct q { 127 | 301 struct spinlock lock; 128 | 302 void *ptr; 129 | 303 }; 130 | 304 131 | 305 void* 132 | 306 send(struct q *q, void *p) 133 | 307 { 134 | 308 acquire(&`q->lock`); 135 | 309 while(q->ptr != 0) 136 | 310 ; 137 | 311 q->ptr = p; 138 | 312 wakeup(q); 139 | 313 release(&`q->lock`); 140 | 314 } 141 | 315 142 | 316 void* 143 | 317 recv(struct q *q) 144 | 318 { 145 | 319 void *p; 146 | 320 147 | 321 acquire(&`q->lock`); 148 | 322 while((p = q->ptr) == 0) 149 | 323 sleep(q); 150 | 324 q->ptr = 0; 151 | 325 release(&`q->lock`); 152 | 326 return p; 153 | 327 } 154 | ``` 155 | 156 | `recv`をこのようにして保護すると、ロックが322行目および323行目を実行されることから`send`を防ぐため、`wakeup`がロストすることを回避できる。しかしこれでもデッドロックが発生する: `recv`はスリープ状態に入っている間はロックを保持しており、ロックの解放を待つために送信者が永久に待ち続けることになる。 157 | 158 | 上記の方法を、ロックを`sleep`に渡すことにより、呼び出し元のプロセスがスリープ状態としてマークされ、スリープチャネルを待っている状態になってもロックを解放できるように変更する。ロックは受信者が自分自身をスリープ状態にするまで`send`が実行されるのを防ぎ、従って、`wakeup`はスリープしている受信者を確実に起こすことができる。受信者がスリープ状態から起きると、関数から抜ける前に再びロックを獲得する。 159 | 最終的な、正しいコードは以下のようになる: 160 | 161 | ```cpp 162 | 400 struct q { 163 | 401 struct spinlock lock; 164 | 402 void *ptr; 165 | 403 }; 166 | 404 167 | 405 void* 168 | 406 send(struct q *q, void *p) 169 | 407 { 170 | 408 acquire(&`q->lock`); 171 | 409 while(q->ptr != 0) 172 | 410 ; 173 | 411 q->ptr = p; 174 | 412 wakeup(q); 175 | 413 release(&`q->lock`); 176 | 414 } 177 | 415 178 | 416 void* 179 | 417 recv(struct q *q) 180 | 418 { 181 | 419 void *p; 182 | 420 183 | 421 acquire(&`q->lock`); 184 | 422 while((p = q->ptr) == 0) 185 | 423 sleep(q, &`q->lock`); 186 | 424 q->ptr = 0; 187 | 425 release(&`q->lock`); 188 | 426 return p; 189 | 427 } 190 | ``` 191 | 192 | `recv`が``q->lock``を保持することによって、`send`が`recv`が`q->ptr`をチェックし、`sleep`を呼ぶ前に起きようとすることを防ぐ。もちろん、受信側のプロセスはスリープ中は`q->lock`を解放しなければならず、従って送信者は起きることができる。 193 | 従って、`q->lock`をアトミックに解放、スリープ状態に入るために、受信者のプロセスを起こしてからスリープ状態に入ることができる。 194 | 195 | # コード例: `sleep`と`wakeup` 196 | 197 | xv6の`sleep`と`wakeup`の実装を見てみよう。基本的なアイデアは、`sleep`は現在のプロセスを`SLEEPING`状態に設定し`sched`を呼び、プロセッサを解放する; `wakeup`はwaitチャネル上のスリープ状態のプロセスを探し、`RUNNABLE`に設定する。 198 | 199 | `sleep`(2803行目)はいくつかのチェックから始まる: まず、現在のプロセスである必要があり、`sleep`はロックを渡されてなければならない(2808-2809行目)。次に、`sleep`は`ptable.lock`を獲得する(2818行目)。これで、プロセスは`ptable.lock`と`lk`を獲得したので、スリープ状態に入ることができる。``lk``を獲得しているのは、呼び出し元にとって必要(例えば`recv`プロセス)である: これは、他のプロセス(例えば、動作している`send`プロセス)が`wakeup(chan)`を呼び出し始めてはいない、ということを保証するものである。今、`sleep`が`ptable.lock`を保持しており、`lk`を解放しても安全である: 他のプロセスは`wakeup(chan)`を呼び出し始めても良いが、`wakeup`は`ptable.lock`を獲得できるまで動作しない、従って、プロセスをスリープ状態に設定してから`sleep`が終了しても、`wakeup`が`sleep`を失うことを防いでいる。 200 | 201 | いくつかの複雑な部分も存在する: `lk`は&`ptable.lock`と同一ならば、`sleep`は&`ptable.lock`を獲得しようとして、次に`lk`を解放しようとするため、デッドロックになる。このような場合には、`sleep`は獲得と解放することを止め、全体をスキップする(2817行目)。例えば、wait(2653行目)は`sleep`を&`ptable.lock`を引数にして呼び出す。 202 | 203 | さらにいくつかステップを進めていくと、プロセスは`wakeup(chan)`を呼び出す。`wakeup`(2853行目)は`ptable.lock`を獲得し、`wakeup1`を呼び出す。`wakeup1`が実際に仕事をする関数である。`wakeup`が`ptable.lock`を獲得していることが重要であり、それはこの関数がプロセスの状態を操作し、また、これまでに見てきたように、`ptable.lock`が`sleep`と`wakeup`が互いにミスをしないように保証するものだからである。`wakeup1`は分離した関数であり、スケジューラがしばしば`ptable.lock`を既に獲得したまま`wakeup`を実行しようとするためである; この例については、後に説明する。`wakeup1`(2853行目)はプロセステーブルを順に探索する。`SLEEPING`状態で、chanとマッチングするプロセスを発見すると、プロセスの状態を`RUNNABLE`に変更する。次にスケジューラが実行されたときには、プロセスが実行可能な状態として見えているのである。 204 | 205 | `wakeup`は、`wakeup`がどのような状態であったとしても、ガード変数をロックしている状態で呼ばれなければならない; 例として、ロックが`q->lock`であったとしよう。何故スリープ状態のプロセスが`wakeup`をミスするのかというと、スリープ状態に入る前の全てのタイミングで条件をチェックし、その条件か、`ptable.lock`のどちらか(あるいはその両方)をロックしているからである。`wakeup`はこれらのロックを保持したまま実行するため、`wakeup`は潜在的なスリープ状態のプロセスが状態をチェックする前か、潜在的なスリープ状態のプロセスがスリープ状態に入ったかどうかをチェックした後に実行されなければならない。 206 | 207 | 複数のプロセスが同一のチャネルでスリープ状態になっている可能性がある; 例えば、1つ以上のプロセスがpipeから読み込みをしようとしている場合である。一回の`wakeup`の呼び出しにより、これらのプロセスが全て起きる。そのうちの一つが、`sleep`から呼び出されたロックを獲得し、(パイプの場合は)パイプ上に書き込まれているデータを呼び出す。他のプロセスはこれを発見すると、プロセスを起こしたにも関わらず、何も読むことができない。`wakeup`が「偽装的な」ものであるという観点から、これらのプロセスは再びスリープ状態に入らなければならない。このような理由から`sleep`はプロセスの状態をチェックするループの中で常に呼び出される。 208 | 209 | `sleep`と`wakeup`の呼び出し元は、任意の数字をチャネル番号として利用することができる; 実際には、xv6はディスクバッファなどの待ち状態になっているカーネルのデータ構造のアドレスを利用する。2つの`sleep`/`wakeup`のペアが同じチャネル番号を利用したとしても、問題は発生しない: これらは偽装した`wakeup`を行うが、この問題を許容するためにループ処理を記述している。`sleep`と`wakeup`の魅力の多くはは、軽量であること(スリープチャネルを動作させるために特別なデータ構造が必要無い)と、間接的なレイヤ(呼び出し元は、相互に通信をしている先のプロセスについて知る必要が無い)を提供しているということである。 210 | 211 | # コード例: パイプ 212 | 213 | 前章にて扱った単純なパイプはおもちゃであったが、xv6は実際に`sleep`と`wakeup`により読み込み元と書き込み先の同期を行うキューが2つ存在する。 214 | 1つがIDEドライバである: プロセスがディスクのリクエストをキューに挿入し、`sleep`を呼び出す。 215 | 割り込みハンドラが`wakeup`を利用してプロセスに対してリクエストが完了したことを通知する。 216 | 217 | より複雑な例として、パイプの実装がある。パイプのインタフェースについては、第0章で見た: パイプの終端に書き込まれたバイトがカーネルバッファにコピーされ、他のパイプの終端から読み出される。 218 | 以降の章では、ファイルシステムがパイプ周辺をサポートするが、ここではpipewriteとpipereadの実装について見て行こう。 219 | 220 | 各パイプはstruct pipeとして表現され、lockとdataバッファを備えている。 221 | nreadとnwriteはバッファ上から読み込み、書き込みをされた量をカウントしている。このバッファはラップアラウンドする: buf[PIPESIZE-1]の次に書き込まれるバッファの位置はbuf[0]であるが、カウンタはラップアラウンドしない。 222 | この記法では、バッファがフルであることは、(nwrite==nread+PIPESIZE)の条件でチェックを行い、バッファが空であると、nwrite==nreadであることを区別することができるが、バッファのインデックスは 223 | buf[nread]の代わりに、buf[nread%PIPESIZE]で参照する必要がある。nwriteの場合も同様である。 224 | pipelineとpipereadは同時に、2つの異なるCPUで発生すると仮定しよう。 225 | 226 | pipewrite(6530行目)はパイプのロックを獲得するところから始まり、カウンタとデータをロックして、関連する不変条件を確保する。 227 | piperead(6551行目)は同様にロックを獲得しようとするが、それはできない。 228 | acquire(1571行目)の中でスピンを行い、ロックを待つ。 229 | pipereadが待っている間、pipewriteは書き込むバイトを読み出しながらループする - addr[0],addr[1],...addr[n-1] - それぞれのデータをパイプに書き込む(6544行目)。 230 | このループの間、バッファが一杯になる可能性がある(6536行目)。 231 | この場合には、pipewriteは`wakeup`を実行し、スリープ状態になっている読み込み先のプロセスを起こし、バッファ上にデータが溜っていることを通知し、&p->nwrite上でスリープ状態に入り、読み込みプロセスがバッファをいくらか読み出すのを待つ。 232 | `sleep`はpipewriteのプロセスをスリープ状態に変更する処理の中で、p->lockを解放する。 233 | 234 | 次に、p->lockが獲得可能になると、pipereadはロックを獲得してから、消費を始める: p->nread!=p->nwriteであることを発見すると(6556行目)(pipewriteはp->nwrite==p->nread+PIPESIZE(6536行目)であるから、スリープ状態に入っている)、 235 | ループを通じて、パイプからデータをコピーし(6563-6567行目)、nreadをインクリメントしてコピーしたバイト数をカウントする。 236 | こうして何バイトか書き込みが可能な状態になると、pipereadは`wakeup`を呼び出して(6568行目)、スリープ状態の書き込みプロセスを呼び出し、呼び出し元に戻る。 237 | `wakeup`はバッファが一杯のためスリープ状態に入っているpipewriteを実行しているプロセスを発見し、このプロセスを`RUNNABLE`に設定する。 238 | 239 | パイプのコードは読み込みと書き込みに別々のスリープチャネルを利用する(p->nreadとp->nwrite); これによりシステムは予期しないイベントに対してより効率的に実行することができるようになり、 240 | 多くの読み込みプロセスと書き込みプロセスが存在していても実行できるようになる。 241 | パイプのコードはスリープ状態をチェックするループの中でスリープ状態に入る; もし複数の読み込みプロセスと書き込みプロセスが存在すると、全てのプロセスの中で1つ目のプロセスが起き上がり、状態がまだfalseであることを確認して再びスリープ状態になる。 242 | 243 | # コード例: wait, exit, kill 244 | 245 | `sleep`と`wakeup`は、多くの待ち動作に用いられる。 246 | 面白い例として、第0章でのwaitシステムコールにより、親プロセスが子プロセスが終了するのを待つシステムコールがある。 247 | xv6では、子プロセスが終了すると、それは即時に死ぬ訳ではない。 248 | 代わりに、プロセスは「ゾンビプロセス(ZOMBIE)」というプロセス状態に変更され、親プロセスが終了するためのwaitを呼び出すまで待つ。 249 | 親プロセスは子プロセスのメモリ領域を解放し、struct procを再利用するための準備をする責任を持つ。 250 | もし親プロセスが子プロセスが終了する前に終了すると、initプロセスが子プロセスの終了を待つ。 251 | 従って、全ての子プロセスは終了時のクリーンアップをするために、親プロセスを持っている。 252 | exitとexitのプロセスの間にレースコンディションが存在するように、子プロセスと親プロセスのwaitとexitのレースコンディションの可能性があることに注意しよう。 253 | 254 | waitはまず、`ptable.lock`を獲得するところから始まれう。 255 | 次にプロセステーブルを探索して子プロセスを発見する。 256 | waitが現在のプロセスが子プロセスを持っているが、まだ終了していない場合、`sleep`システムコールを呼び出して、子プロセスの一つが終了するのを待つ(2689行目)ち、それが終了するとさらにスキャンを行う。 257 | ここで、`sleep`内で解放されるロックは`ptable.lock`であり、このときの特殊ケースについては上記で説明した。 258 | 259 | exitは`ptable.lock`ロックを獲得し、waitチャネル上で眠っている任意のプロセスを起こす。これは現在の親プロセスのprocに相当する(2628行目); 260 | もしこのようなプロセスが存在すれば、それはwaitにより待ち状態になっている親プロセスである。 261 | これではまだ準備は未完了のように見える。何故ならば、まだexitは現在のプロセスをZOMBIE状態に変更していないからだが、まだ安全である: 262 | `wakeup`は親プロセスを`RUNNABLE`な状態にするが、wait内でのループはexitがptableを解放するまでは実行することができない。 263 | `ptable.lock`は`sched`によりスケジューラに入った段階で解放されるが、従ってwaitはexitがZOMBIE状態にプロセスを変更するまで、該当する終了プロセスを発見することができない。 264 | exitが再スケジューリングされる前に、終了したプロセスの全ての子プロセスは、親の再設定が行われ、`initproc`に渡される(2630-2637行目)。 265 | 最後にexitは`sched`を呼び出しCPUを手放す。 266 | 267 | 親プロセスがwait中で寝ているならば、いずれはスケジューラは動作していることになる。 268 | `sleep`を呼び出すことにより、`ptable.lock`を保持していることになる; waitはプロセステーブルをスキャンし、state==ZOMBIEな子プロセスを探す(2634行目)。 269 | 子プロセスのpidを記録し、struct procをクリーンアップし、そのプロセスの関連するメモリ領域を解放する(2668-2676行目)。 270 | 271 | 子プロセスはexit中に殆どのクリーンアップを終了することができるが、親プロセスがp->kstackとp->pgdirを解放することは重要である: 272 | 子プロセスがexitを実行すると、そのプロセスのスタックはp->kstackに割り当てられたメモリ上に配置されており、それは自分自身のページテーブルを利用する。 273 | これは、子プロセスが実行を終了し、(`sched`により)`swtch`を呼び出す場合に最後にしか解放することができない(xxx)。 274 | これが、スケジューラが`sched`により呼ばれたスレッドのスタック上で動作するのではなく、自分自身のスタック上で動作する理由である。 275 | 276 | exitがプロセス自身を終了することを許可すると、kill(2875行目)があるプロセスをリクエストし、xxx。 277 | killにより直接犠牲となるプロセスを破壊するのは非常に複雑な処理である。 278 | 何故ならば、犠牲となるプロセスは他のCPUで動作していたり、カーネルデータ構造を書き換えている最中にスリープ状態になっているなどの可能性があるからである。 279 | これらの複雑性を回避するために、killは非常に小さな構造になっている: 単に犠牲となるプロセスのp->killedを設定する。あるいはスリープ状態ならば、それを起こす。 280 | さらに犠牲となるプロセスがカーネルから離れると、そのポイントでtrapが実行され、p->killedが設定されているならば、exitが呼び出される。 281 | もし犠牲となるプロセスがユーザ空間上に存在するならば、システムコールをマスクするか、タイマ割り込みによって(もしくは他のデバイスの割り込みにより)すぐさまカーネル状態に入る。 282 | 283 | 犠牲となるプロセスがスリープ状態ならば、`wakeup`の呼び出しによりそのプロセスは`sleep`から戻ってくる。 284 | これは何かが成立するまで待ち状態であるならば、成立することなく起き上がることになるため、潜在的に危険である。 285 | しかし、xv6は常に`sleep`を呼び出すときはwhileループによりラップしているため、`sleep`から戻ってきたときは常に条件をテストしている。 286 | いくつかの`sleep`の呼び出しはp->killedをループ中でテストしており、もしセットされていたならば、現在の動作をすべて破棄する。 287 | このような破棄の操作が行われるが正しい唯一のケースである。 288 | 例えば、パイプの読み書きのコード(6537行目)は、killedフラグが立ち上がっていると戻ってくる; 289 | いずれはコードはtrapに戻ってきて、再びフラグをチェックして終了する。 290 | 291 | xv6のいくつかの`sleep`のループはp->killedをチェックしておらず、これは複数のシステムコールがアトミックであるようなコードが入っていることによる。 292 | IDEドライバ(4279行目)がその例である: このループはp->killedをチェックしておらず、ディスクの操作は全てがファイルシステム上でインオーダで実行され、正しい状態で就労しなくてはならないためである。 293 | クリーンアップが実行されて一部分の操作が残ったときの複雑性を回避するために、xv6はプロセスのキル操作を遅らせ、IDEドライバがプロセスを殺しても良いポイントに到達するまでキルを回避する 294 | (例えば、ファイルシステムの操作が完了し、プロセスがユーザ空間に戻ってくる時など)。 295 | 296 | # 現実の世界 297 | 298 | xv6のスケジューラはシンプルなスケジューリングのポリシで実装されており、各プロセスを順番に実行していく仕組みになっている。 299 | このポリシを「ラウンドロビン」と読んでいる。 300 | 実際のオペレーティングシステムではより洗練されたポリシを実装しており、例えば、プロセスが優先度を持つことが出来るようになっている。 301 | このアイデアは、実行可能なより高い優先度を持っているプロセスが、スケジューラ上ではより低い優先度のプロセスよりも優先して選択されるという方針である。 302 | これらのポリシにより、急激に実装が複雑になる可能性があり、それはより高度な要求を満たす必要があるからである: 例えば、オペレーティングシステムは公平性と高スループットを保証する必要がある。 303 | 加えて、複雑なポリシにより、「優先度の逆転」や、「コンボイ」といった予期しない相互作用が発生する可能性がある。 304 | 優先度の逆転は、低い優先度のプロセスと高い優先度のプロセスがロックを共有しており、低い優先度のプロセスがロックを獲得すると、 305 | 高い優先度のプロセスが実行できなくなってしまうことである。 306 | 長いコンボイは、多くの高い優先度のプロセスが低い優先度のプロセスが獲得している共有ロックの待ち合わせをしている際に発生する; 307 | 一度コンボイが発生すると、優先度の高いプロセスは長い時間耐える必要がある。 308 | このような問題を避けるためには、洗練されたスケジューラの中に、さらにメカニズムを追加する必要がある。 309 | 310 | `sleep`と`wakeup`はシンプルで効果的な方法であるが、他にも様々な方法が存在する。 311 | 最初に挑戦したことは、本章の最初で見た「ミスした`wakeup`」の問題を避けることである。 312 | オリジナルのUnixカーネルでは、`sleep`は単純に割り込みを禁止にしており、Unixは単一のCPUで動作するためこれで十分であった。 313 | xv6はマルチプロセッサ上で動作するため、明示的なロックにより`sleep`をする必要があった。 314 | FreeBSDのm`sleep`は、同様のアプローチを取っている。 315 | Plan 9の`sleep`はスリープ状態に入る直前にスケジューリングによりロックを獲得するためのコールバック関数を利用している; 316 | この関数はミスした`wakeup`を避けるために、最後の直前のスリープ状態のチェックを実行している。 317 | Linuxカーネルの`sleep`はwaitチャネルの代わりに、明示的なプロセスのキューを利用している; このキューは、自分自身の内部ロックを持っている。 318 | 319 | `wakeup`内での全体のプロセスをスキャンして、chanとマッチングすることは非効率である。 320 | より効率的な方法として、`sleep`と`wakeup`内のchanをスリープ状態のプロセスのリストと置き換える方法があげらる。 321 | Plan 9の`sleep`と`wakeup`はこのような構造を取っており、集合ポイントもしくはrendezという構造体を保持している。 322 | 多くのスレッドライブラリは同様の構造体か、条件変数を参照している; このようなコンテキストでは、`sleep`と`wakeup`の操作はwaitとsignalから呼び出される。 323 | 全てのこのようなメカニズムは同様の特徴を持っている: スリープの条件は、スリープ中にアトミックにドロップされたいくつかのロックによって保護されている。 324 | 325 | `wakeup`の実装により、特定のチャネルを待ち合わせている全てのプロセスが起き上がり、多くのプロセスが特定のチャネルを待っている状態が発生する。 326 | オペレーティングシステムは、スリープ状態をチェックしている多くのプロセスをスケジューリングする。 327 | このような方法で動作するプロセスはthundering headと呼ばれ、これは最も避けやすいものであるxxx。 328 | 殆どの条件変数は、`wakeup`のための2つのプリミティブを持っている:1つのプロセスを起こすためのsignalと、全ての待ち状態のプロセスを起こすためのbroadcastである。 329 | 330 | セマフォは、もう一つの調停のためのメカニズムである。 331 | セマフォは2つの操作により整数をインクリメントとデクリメントする。 332 | セマフォはインクリメントすることができるが、セマフォの値として、デクリメントして0を下回ることは許されない。 333 | ゼロをデクリメントすると、他のプロセスがセマフォをインクリメントするまではスリープ状態になり、これらの2つの操作は全てキャンセルされる。 334 | 整数値は典型的に、実際の値、例えばパイプバッファ中のデータのバイト数や、プロセスが保持しているゾンビ状態のプロセスの数に相当する。 335 | 明示的なカウンタの抽象化により、「ミスした`wakeup`問題」を避けることができる: 明示的に`wakeup`の数を数えるのである。 336 | このカウンタにより、不明な`wakeup`や、thundering herd問題を避けることができる。 337 | 338 | xv6上では、プロセスを殺したり、クリーンアップすることにより、より複雑な問題が生じる。 339 | 殆どのオペレーティングシステムではさらに複雑であるが、何故ならば、例えば、犠牲となるプロセスがカーネルの深いところでスリープ状態であるとして、 340 | そのスタックを巻き戻すためには、注意深いプログラミングが必要である。 341 | 多くのオペレーティングシステムは、longjmpなどの例外ハンドリングにより明示的なメカニズムを使ってスタックを巻き戻していく。 342 | さらに、待っているイベントが生じていないのに、別のイベントが発生することによってスリープしているプロセスが起き上がることがある。 343 | 例えば、プロセスがスリープ状態であり、他のプロセスがsignalを送信する場合である。 344 | この状態では、プロセスが割り込まれたシステムコールに、-1を持って戻っていき、EINTRをエラーコードに設定する。 345 | アプリケーションは、この値をチェックして、何をすべきかを決定する。 346 | xv6はこのシグナルをサポートせず、このような問題は発生しない。 347 | 348 | xv6は完全な要件を満たすkillはサポートしていない: これらはスリープループであり、p->killedをチェックする必要がある。 349 | 関連する問題として、`sleep`のループがp->killedをチェックしていたとしても、`sleep`とkillのレースコンディションが発生する; 350 | 後者がp->killedを設定して犠牲となるプロセスを`wakeup`しようとして、その直前に犠牲となるプロセスがp->killedをチェックして`sleep`を呼び出した場合である。 351 | この問題が発生した婆い、犠牲となるプロセスは、待っている条件が発生しないと、自身がp->killedを設定されていることに気がつかない。 352 | この問題は後に発生する(例えば、IDEドライバが犠牲となるプロセスが待っているディスクの操作から帰ってきたときなど)か、 353 | (例えば、犠牲となるプロセスがコンソールの待ち状態だが、ユーザが全く入力をしなかった場合など)は、永遠に発生しない。 354 | 355 | # 練習問題 356 | 357 | 1. `sleep`はデッドロックを避けるために、`lk`!=&`ptable.lock`をチェックする必要がある(2817-2820行目)。このコードは、以下ののように置き換えることで除去することができる: 358 | ```cpp 359 | if(`lk` != &`ptable.lock`){ 360 | acquire(&`ptable.lock`); 361 | release(`lk`); 362 | } 363 | ``` 364 | を、 365 | ```cpp 366 | release(`lk`); 367 | acquire(&`ptable.lock`); 368 | ``` 369 | これにより`sleep`を抜けられることができるか?どのようにして実現されているか? 370 | 2. 殆どのプロセスのクリーンアップはexitもしくはwaitによって実行されるが、上記により、exitはp->stackを解放してはならない。 371 | またexitは開いているファイルを閉じることができる唯一の関数である。何故か?これはパイプに関連する問題である。 372 | 3. xv6において、セマフォを実装せよ。mutexを用いることができるが、`sleep`と`wakeup`は使ってはならない。 373 | xv6の`sleep`と`wakeup`をセマフォに置き換え、その結果を判定せよ。 374 | 4. killと`sleep`のレースコンディションを修正せよ。プロセスのスリープループはp->killedをチェックし、`sleep`を呼び出す前に発生した 375 | killは、犠牲となるプロセスは、現在のシステム上から削除される結果となる。 376 | 5. 全てのスリープループがp->killedをチェックするデザインを設計せよ。 377 | これにより例えば、IDEドライバが、他のプロセスがそのプロセスを殺したときに迅速に戻ってこれるようになる 378 | -------------------------------------------------------------------------------- /chapter6.md: -------------------------------------------------------------------------------- 1 | 第6章 ファイルシステム 2 | ===================== 3 | 4 | ファイルシステムの目的は、データを構成し保存することである。一般的にファイルシステムはユーザとアプリケーション間でデータを共有すると同時に**データを維持する**。従って、ファイルはコンピュータを再起動しても存在している。 5 | 6 | xv6のファイルシステムはUNIXライクなものを提供しており、ファイル、ディレクトリ、パス名(第0章を参照のこと)をサポートし、IDEディスクに保存することで永続性を確保することができる(第3章を参照)。ファイルシステムを実現するためにはいくつかの困難を解決する必要がある。 7 | 8 | * ファイルシステムはディスク上に、ツリー構造のディレクトリとファイルを表現するためのデータ構造を構成する必要がある。また各ファイルの内容を保持するための識別子と、どのディスク領域が空いているかという情報を保持しておく必要がある。 9 | * ファイルシステムは **クラッシュからのリカバリ** をサポートする必要がある。つまり、(例えば電源遮断などで)クラッシュが発生しても、再起動後にファイルシステムは正しく動作する必要がある。クラッシュのリスクは、データの更新の際に割り込みが発生し、ディスク上のデータの一貫性(例えば、ファイルが存在することと、フリー状態であることを示すブロックのマークなど)に影響が出る場合などがあげられる。 10 | * 異なるプロセスがファイルシステムを同時に扱うため、不変性を維持するようにファイルシステムを操作する必要がある。 11 | * ディスクへのアクセスはメモリへのアクセスに比べて格段に遅く、従ってファイルシステムは頻繁に利用されるブロックについてメモリ上にキャッシュする仕組みを提供する必要がある。 12 | 13 | 本章では、上記の困難をxv6ではどのように解決しているかについて説明する。 14 | 15 | # 概要 16 | 17 | xv6のファイルシステムは図6-1に示すように7つの階層で構成されている。ディスク階層は、IDEハードドライブのブロックを読み書きする。バッファキャッシュ階層はディスクブロックをキャッシュし、ブロックのアクセスの同期を取り、データが格納されている特定のブロックについて、たった一つのブロックのみが変更を行っていることを保証する。ロギング階層は高位の階層のいくつかのブロックの更新操作を**トランザクション**としてラップし、またクラッシュが発生した場合でも、(例えば、全てのブロックがアップデートされたか、または一つもアップデートされていない状態を保持し)ブロックがアトミックに更新されたことを保証する。**inode**階層は個々のファイルを提供し、それぞれのノードはinodeというユニークなi-numberと、ファイルを保持しているいくつかのブロックで構成される。ディレクトリ階層はいくつかのディレクトリを構成し、特別な種類のi-nodeとして構成さえる。ディレクトリのinodeはディレクトリのエントリ列が含まれており、各エントリにはファイル名とi-numberが入っている。パス名階層は、`/usr/rtm/xv6/fs.c`のような階層的なパス構成を提供し、階層的な探索によりそれを解決する。ファイルディスクリプタ階層は多くのUNIXの資源を抽象化する(例えば、パイプやデバイス、ファイルなど)。ファイルのアクセスにはファイルシステムインタフェースを用い、アプリケーションプログラマのプログラミングを簡単化する。 18 | 19 | ![Figure6-01](images/figure6-01.JPG) 20 | 21 | ファイルシステムはinodeをどこに格納し、ディスクのどのブロックにファイルの内容を格納するかについての方針を決める必要がある。これを行うためには、xv6は、図6-2に示すようにディスクをいくつかのセクションに分割する。ファイルシステムはブロック0は使用しない(これはブートセクタを保持している)。ブロック1は「スーパブロック」と呼ばれ、ファイルシステムのメタデータを格納している(ブロックのファイルサイズ、データブロックの数、inodeの数、ログ中のブロックの数)。ブロック2以降はinodeを保持しており、ブロックあたりに複数のinodeを保持している。その後はビットマップブロックであり、使用しているブロックを記録している。殆どの残りのブロックは、データブロックである; それぞれのブロックはビットマップブロック上でフリーであることをマークされており、ファイルもしくはディレクトリが保持されている。ディスクの最後のブロックはロギング階層のログが格納されている。 22 | 23 | ![Figure6-02](images/figure6-02.JPG) 24 | 25 | 本章の以降では、各階層について議論する。まずはバッファキャッシュの階層から始まり、うまく選択された下位階層の抽象化により、上位の階層がより易しくなっていることを見ていこう。 26 | 27 | # バッファキャシュ階層 28 | 29 | バッファキャッシュ階層の仕事は2つある: (1)ディスクブロックのアクセスの同期を行い、たった一つのブロックのコピーがメモリ中に存在し、たった一つのカーネルスレッドがそのコピーを使っていることを保証する; 30 | (2)頻繁に利用するブロックをキャッシュし、低速なディスクから何度も読み出す必要を無くす。コードは`bio.c`に実装されている。 31 | 32 | バッファキャッシュのためにエクスポートされた主のインタフェースは、`bread`と`bwrite`である; 前者はメモリにコピーされた、読み書き可能なブロックの内容のコピーを取得し、後者は更新されたバッファをディスクの適切なブロックに書き込む。カーネルスレッドは、書き込みが完了したときは`brelse`を呼び出して、バッファを解放しなければならない。 33 | 34 | バッファキャシュは各ブロックを同期化し、各ブロックが、最大でも1つのカーネルスレッドしかブロックのバッファを参照を許可されていない状態を作る。もし1つのカーネルスレッドがバッファを参照しており、それを解放していなければ、他のスレッドが`bread`を呼び出して同一のバッファを参照しようとしてもそれは待たされる。より高位のファイルシステム階層はバッファキャッシュブロックの同期により、不変性が保たれることを支援している。 35 | 36 | バッファキャッシュはディスクブロックを保持するための固定数のバッファを持っており、ファイルシステムがキャッシュ上に存在していないブロックを要請した場合は、バッファキャッシュは現在保持しているバッファと交換して、バッファをリサイクルしなければならない。バッファキャッシュは最も最近利用されていないバッファに新しいブロックを挿入する。最も利用されていないバッファは、今後最も利用されないブロックであるという仮定に基いている。 37 | 38 | # コード例: バッファキャッシュ 39 | 40 | バッファキャッシュは双方向のリンクリストのバッファである。 41 | `main`から呼ばれる関数`binit`(1231行目)によってリストが`NBUF`個の静的な配列`buf`を初期化する(4350-4359行目)。全てのリンクリストを参照するバッファキャッシュへのアクセスは``bcache.head``を利用してアクセスされ、`buf`配列は利用されない。 42 | 43 | バッファは3つの状態ビットを持っている。`B_VALID`はバッファ内にブロックのコピーが存在していることを示している。`B_DIRTY`はバッファの内容が変更されており、ディスクに書き戻す必要があることを示している。`B_BUSY`はいくつかのカーネルスレッドがそのバッファを参照しており、まだ解放されていないことを示す。 44 | 45 | `bread`(4402行目)が`bget`関数を呼び、与えられたセクタのバッファを返す(4406行目)。もしバッファをディスクから読む必要があれば、`bread`は`iderw`を呼び出して、バッファを返すようにする。`bget`(4366行目)は与えられたデバイスとセクタ番号からバッファリストをスキャンする(4737-4384行目)。もしそのようなバッファが存在し、バッファがビシーでなければ、`bget`は`B_BUSY`フラグを立てて関数から戻る(4376ー4383行目)。もしバッファが既に使用されていれば、`bget`はバッファ上でスリープ状態に入り、解放されるのを待つ。しかし``sleep``から戻ると、`bget`はバッファが解放されたと仮定することはできない。実際、`sleep`は`buf_table_lock`を解放して再度バッファを取得するが、bが正しいバッファである保証はない : おそらく、異なるディスクセクタによって再利用されているであろう。`bget`は最初からやり直す(4383行目)必要があり、今度はまた別の時間に解決されることを期待している。 46 | 47 | 与えられたセクタがバッファキャッシュに存在しない場合、まず、`bget`はおそらく別のセクタとして利用されている一つのバッファを再利用する必要がある。次に、バッファリストをスキャンして、ビジーではないバッファを探索する: そのようなバッファは、どれでも使用可能である。`bget`はバッファのメタデータを編集し、新しいデバイスとセクタ番号を記録し、バッファから戻る前にバッファがビジーであることをマークする(4393行目)。フラグの設定は,`B_BUSY`ビットだけでなく、`B_VALID`と`B_DIRTY`をクリアすることも忘れないようにする。これにより、`bread`はバッファの過去の古い内容を読まずに、ブロックのデータをディスクから読むように設定される。 48 | 49 | バッファキャッシュは同期のためにも利用されるので、特定のディスクセクタに対してたった一つのバッファが利用されることは重要である。割り当ての方法(4391-4393行目)は、割り当てアルゴリズムは常に安全である。それは`bget`が最初にループに入ってから、それ以降決して`but_table_lock`が解放されるまで諦めないからである。 50 | 51 | もし全てのバッファがビジーであるならば、何かが間違っているため、`bget`はパニックで終了する。 52 | より上品な対応としては、バッファがフリーになるまでスリープすることであるが、しかしそれでもデッドロックする可能性がある。 53 | 54 | `bread`が呼び出し元に帰ると、呼び出し元はバッファを排他的に利用している状態になるためデータバイトの読み込みまたは書き込みができる。もし読み出し元がデータの書き込みを行ったならば、`bwrite`を呼び出して、バッファを解放する前にディスクの書き込み処理を行う必要がある。`bwrite`(4414行目)は`B_DIRTY`フラグを設定し、`iderw`を呼び出してバッファをディスクに書き込む。 55 | 56 | 読み出し元がバッファの処理を完了すると、`brelse`を呼び出して解放しなければならない(`brelse`はb-releaseの略語であり、暗号めいてはいるが、学んでおく価値がある: Unixから利用されている言葉であり、BSD、Linux, Solarisでも利用されている)。`brelse`(4425行目)はバッファをリンクリストの先頭に移動し(4432-4437行目)、`B_BUSY`ビットをクリアし、バッファ上でスリープ状態になっているプロセスを起こす。バッファを移動することによって、どのバッファが最近利用されたかが分かるようになっている(つまり、いつ解放されたかが分かるようになっている)。最初のバッファは最も最近利用されたものであり、存在しているバッファのスキャンではワーストケースで処理する必要がある。しかし、最近利用されたバッファを最初にチェックすることによって(`bcache.head`から始まり、`next`ポインタを辿っていく)、参照局所性を活用してスキャンの時間を削減することができる。最近利用されていないバッファを選択し再利用するためのスキャンでは、バッファを逆方向にスキャンすることで実現できる (`prev`ポインタを辿っていく)。 57 | 58 | # ロギング階層 59 | 60 | ファイルシステムにおける最も興味深い問題は、クラッシュからの回復である。多くのファイルシステムの操作ではディスクへの複数回の書き込みが発生するが、クラッシュは、ある一部分の書き込みがファイルシステム上のディスクに対して実行された後で発生し、その結果一貫性の無い状態が発生する。例えば、ディスクの書き込み順番に依存して、ファイル削除中のクラッシュでは解放されたinodeのディレクトリのエントリポイントが消失したり、割り当てはされたものの解放されないinodeが発生したりする。後者は比較的穏やかであるが、解放されたinodeを参照するディレクトリエントリは、再起動後にシステムに深刻な問題を発生させる可能性がある。 61 | 62 | xv6はこのようなファイルシステム操作中のクラッシュの問題をシンプルなロギングによって解決している。xv6のシステムコールは、ディスク上のファイルシステム構造に対して直接的に書き込みを行わない。その変わりに、全てのディスクへの所望の書き込みは、ディスク上のログとして生成され配置される。システムコールが全ての書き込みをログすると、特別な**コミット**記録がディスクに対して書き込まれ、ログの内容が完全な操作として反映される。その後システムコールが書き込みをディスクのファイルシステムデータ構造に反映する。書き込み処理が完了すると、システムコールはディスク上からログを消去する。 63 | 64 | システムがクラッシュし再起動すると、ファイルシステムはプロセスを実行する前に以下のような順番でリカバリするコードを実行する。もしログが完全な操作として記録されていると、リカバリコードはその書き込みをディスク上のファイルシステムに反映させる。もしログが完全な操作としてマークされていないと、リカバリコードはそのログを無視する。リカバリコードはログを消去して終了する。 65 | 66 | xv6はファイルシステム操作中のクラッシュの問題を解決するのだろうか?もしディスクの操作コミットが完了する前にクラッシュが発生すると、ディスク上のログは完了としてマークさず、リカバリコードがそれを無視し、ディスク上の状態は操作は始まっていないものとなる。もしディスク操作が完了してからクラッシュが発生すると、リカバリコードは、例えディスクへの書き込みの初期の操作が二重に実行されることになったとしても、全ての書き込み操作を再度実行する。どちらの場合にも、ログはクラッシュに関しては操作のアトミックを保つ: 回復の後は、ディスク上の全ての操作は完了しているか、全く実行されていないかのどちらかである。 67 | 68 | # ログの設計 69 | 70 | ログはディスクの最後の既知の領域に配置されている。ログはヘッダブロックと、それに続いて更新されたブロックのコピー(ログされたブロック)が続いている。ヘッダブロックはセクタ番号の配列で、それぞれがログされたブロックに相当する。ヘッダブロックはログされたブロックの数を含んでいる。xv6はトランザクションコミットが発生するとヘッダブロックへ書き込みを行うが、コミットを起こす前ではなく、ログされたブロックがファイルシステムにコピーされた段階でカウントは0に戻される。従って、トランザクションの途中でクラッシュが発生すると、ログヘッダブロックのカウントが0となっている; コミット後のクラッシュは、非ゼロとしてカウントされる。 71 | 72 | 各システムコールのコードは、書き込み列の最初から最後までがアトミックであることを示している。効率化のためと、ファイルシステムコードの並列性を許容するために、ロギングのシステムは複数のシステムコールの各トランザクションをカウントすることができる。従って、単一のコミットが複数の完全なシステムコールの書き込みを誘起させることがある。アトミック性を維持するために、ロギングシステムはファイルのシステムコールが一つも実行中でないときに限ってコミットが行われる。 73 | 74 | 複数のトランザクションをまとめてコミットするというアイデアは、「グループコミット」として知られている。グループコミットは、複数のトランザクションを許容して並列に実行し、ファイルシステムが複数の「バッチ処理」のディスク書き込みを許可するようにし、単一のディスク操作がディスクドライバに発行されるようにしている。 75 | これにより、ディスクがブロックへの書き込みのスケジュールと、ディスクのバンド幅の比率を考慮して書き込むことができるようになる。xv6のIDEドライバはバッチ処理をサポートしてはいないが、xv6のファイルシステムのデザインはそれを実行できるようにしている。 76 | 77 | xv6はログを保持するために固定サイズのディスク領域を確保している。トランザクション中のシステムコールによって書き込まれるブロックの総量は、その領域に収まるサイズでなければならない。これにより、2つの結論が得られる。どのようなシステムコールもログ中に存在するスペースよりも幅広いブロックに書き込むことはできない。これは殆どのシステムコールにとって問題ではないが、システムコールのうちの2つが、多くのブロックにデータを書き込む可能性がある:`write`と`unlink`である。 78 | 大きなファイルの書き込みにより、多くのデータブロックへの書き込みと、多くのビットマップブロックとinodeへの書き込みを発生させる;大きなファイルへの`unlink`は、多くのビットマップブロックとinodeへの書き込みを発生させる。xv6のシステムコールは、このよう大きな書き込みが発生すると、小さな書き込みへと分割し、ログのサイズに合うようにする。`unlink`は実際には問題にはならず、これはxv6のファイルシステムはたっと一つのビットマップブロックしか利用しないからである。もう一つの結論は、ログ領域が制限されることにより、ロギングシステムはシステムコールがログ中の残りの領域にフィットするようになるまで、特定のシステムコールの書き込みを開始することができないという制約である。 79 | 80 | # コード例: ロギング 81 | 82 | システムコール中のログの典型的な利用例は以下のようなものである: 83 | 84 | ```cpp 85 | begin_op(); 86 | ... 87 | bp = `bread`(...); 88 | bp->data[...] = ...; 89 | `log_write`(bp); 90 | ... 91 | end_op(); 92 | ``` 93 | 94 | `begin_op`(4628行目)は、ログシステムがコミットを発生していない状態になり、このシステムコールにより書き込みで発生するログが格納できるまでログ領域が十分確保されるまで待ち合わせを行う。`log.outstanding`はシステムコールの呼ばれた回数をカウントする; インクリメントされることにより、領域を予約し、システムコールが発生している最中のコミットを保護する。xv6のコードは各システムコールが`MAXOPBLOCKS`個のブロックへの書き込みまでしか発生させないという保守的な仮定を行っている。 95 | 96 | `log_write`(4722行目)は`bwrite`の代理として動作する。`log_write`は、ブロックのセクタ番号をメモリ中に記録し、ディスク上のログのスロットを予約し、ブロックキャッシュが強制的に戻されることを防ぐために、`B_DIRTY`のマークが付加される。ブロックはコミットされるまでキャッシュ上に保持されていなければならず、それが終了すると、キャッシュされたコピーが唯一の変更の記録となる; コミットが終了するまで、ディスク上に配置されたその領域には書き込みを発生させることはできない;また、同一のトランザクションにおける読み込み処理は、その変更を参照しなければならない。`log_write`はブロックが単一のトランザクションで何度も書き込まれた場合に通知を行い、ログ中の同一のスロットにブロックを割り当てる。この最適化は、「吸収」としばしば呼ばれる。この最適化は共通の技術であり、例えば、いくつかのinodeが保持されているディスクブロックが、トランザクションにより何度か書き換えられた場合などに有効である。いくつかのディスクの書き込みを1つに吸収させることにより、ファイルシステムはログの領域を節約することができ、ディスクブロックのディスクへの書き込みのための回数を削減することにより性能を向上させることができる。 97 | 98 | `end_op`(4653行目)は、まず全体のシステムコールの回数をデクリメントする。もしカウントがゼロだと、`commit()`を呼び出すことにより、現在のトランザクションをコミットする。この処理には、4つの段階がある。`write_log()`(4683行目)はトランザクションにより変更された各ブロックをバッファキャッシュからディスク上のログスロットにコピーする。`write_head()`(4604行目)はディスクに対してヘッダブロックの書き込みを行う: これがコミットポイントであり、この書き込み移行のクラッシュは、結果としてログからトランザクションの書き込みを再度実行することにより回復される。`install_trans()`(4572行目)はログ中から各ブロックを読み、ファイルシステムの正しい領域に書き込みを行う。最後に、`end_op()`はログヘッダにゼロをカウントする; これは、次のトランザクションによるログのブロックの書き込みが発生するより前に完了する必要がある。 99 | これによりクラッシュによりあるトランザクションのヘッダにより、別のトランザクションのブロックが回復されてしまうことを防ぐ。 100 | 101 | `recover_from_log`(4618行目)は、`initlog`(4556行目)により呼び出され、最初のプロセスが実行されるよりも前にブート中に呼び出される(2794行目)。これはログヘッダを読み込み、ヘッダがコミットされたトラザクションが存在すること示していたならば、`end_op()`と似たような動作を行う。 102 | 103 | ログのを利用した例が`filewrite`(5752行目)に載っている。トランザクションは以下のようなものである。 104 | 105 | ```cppp 106 | begin_op(); 107 | `ilock`(f->ip); 108 | r = writei(f->ip, ...); 109 | `iunlock`(f->ip); 110 | end_op(); 111 | ``` 112 | 113 | このコードはループに囲まれており、大きな書き込みを少数のセクタに書き込むような個々のトランザクションに分割しており、ログのオーバフローを防いでいる。`writei`の呼び出しにより、トランザクションの一部として複数のブロックへの書き込みを行う: ファイルのinodeと、1つ以上のビットマップブロックと、いくつかのデータブロックへの書き込みが行われる。 114 | 115 | # コード例:ブロックアロケータ 116 | ファイルとディレクトリの内容はフリーなプールから割り当てられたディスクブロックに格納される。xv6のブロックアロケータはディスク上のフリーなビットマップによって管理されており、ビットマップの1ビットが1ブロックに相当する。ビットが0の場合はそのブロックがフリーであることを示し、1ビットがそのブロックを使用していることを示す。ブートセクタ、スーパーブロック、inodeブロック、ビットマップブロックに相当するビットは常に1である。 117 | 118 | ブロックアロケータは2つの機能を提供する: `balloc`は新しいディスクブロックを割り当て、`bfree`はブロックを解放する。`balloc`(4804)は`readsb`を呼び出してディスク(もしくはバッファキャッシュ)上からスーパブロックを呼び出して`sb`に格納する。`balloc`はどのフリーなビットマップから、どのブロックをブートセクタ、スーパーブロック、inodeから(`BBLOCK`を使って)何個消費するかを計算する。ループ(4812行目)は全てのブロックを考慮しており、0から始まって`sb.size`までループする。これはファイルシステム上のブロックの数に相当する。ビットマップがゼロのブロック、つまりフリーのブロックを探す。`balloc`がそのようなブロックを発見すると、ビットマップを更新してそのブロックを返す。効率化のために、ループは2つの部分から構成されている。外側のループはビットマップのビットの各ブロックを読み出す。内部ループはブロック内の全ての`BPB`ビットをチェックする。2つのプロセスが同時にブロックを確保しようとするとレースコンディションが発生するため、それは禁止されており、バッファキャッシュはたった1つのプロセスがビットマップブロックを参照できるようになっている。 119 | 120 | `bfree`(4831行目)は正しいビットマップブロックを探索し、そのビットをクリアする。 121 | `bread`と`brelse`を排他的に用いることによって、明示的なロックを避けるようにしている。 122 | 123 | 本章における以降に登場する殆どのコードでは、`balloc`と`bfree`はトランザクションの内部で呼ばれる。 124 | 125 | # inode階層 126 | 127 | inodeという用語は2つの関連する意味を持っている。ディスク上のファイルサイズとデータブロック番号を含むデータ構造のことを指すか、"inode"という用語が、メモリ中のinodeのことを指し、ディスク上のinodeのコピーであり、カーネル中で必要とされる外部情報という意味も持っている。 128 | 129 | ディスク上の全てのinodeはディスク上のinodeブロックと呼ばれる連続した領域にパックされている。全てのinodeは同じサイズであり、inode番号`n`が与えられると、そのinodeの場所をディスク上で探すのはたやすい。実際、この番号`n`はinode番号、もしくはi-numberと呼ばれ、inodeがどのようにして実装によって識別されているのかを示している。 130 | 131 | ディスク上のinodeは、`struct dinode`(3926行目)として定義されている。`type`フィールドがファイルとディレクトリと、特殊ファイル(デバイスなど)とを区別している。`type`フィールドがゼロならば、ディスク上のinodeが解放されていることを示す。`nlink`フィールドが、このinodeエントリを参照しているディレクトリエントリの数を示し、これはディスク上のinodeとそのデータが解放されたことを識別するために利用される。 132 | `size`フィールドはファイルのサイズを格納している。`addr`配列はディスクブロック中のファイルを保持しているブロックの数を記録している。 133 | 134 | カーネルは、メモリ中のアクティブなinodeの集合を保持している; `struct inode`(4012行目)はディスク上の`struct dinode`のメモリコピーである。カーネルは、Cのポインタがそのinodeを参照したときにのみメモリ中にそのinodeを格納する。`ref`フィールドはメモリ中のinodeを参照しているCのポインタの数を示しており、その数が0になると、カーネルはメモリ中からそのinodeを削除する。`iget`と`iput`関数はinodeを確保、解放する関数で、参照カウントを変更する。inodeへのポインタはファイルディスクリプタ、現在のワーキングディレクトリ、`exec`のような一時的なカーネルコードなどから取得することができる。 135 | 136 | `iget`により返されるポインタは該当する`iput`()が呼ばれるまで正しいことが保証されている; inodeは削除されず、ポインタにより参照されるinodeは異なるinodeにより再利用されることは無い。`iget`はinodeへの非排他的アクセスを提供しており、従って、同一のinodeに対して複数のポインタを持つことができる。ファイルシステムのコードはこの`iget`()の動作に依存しており、inodeへの長い期間の参照(ファイルや、現在のディレクトリを開く操作など)や、複数のinodeを操作する場合(パス名の探索など)のコードのデッドロッックを避けるためのレースコンディションの防止に利用されている。 137 | 138 | `iget`が返す`struct inode`には、便利な情報はあまり入っていない。ディスク上のinodeのコピーが保持されていることを保証するためには、コードは`ilock`関数を呼び出さなければならない。 139 | このコードはinodeをロックし(これにより他のプロセスが`ilock`をすることが出来なくなる)、もしそのinodeをまだ読み出していなければ、ディスクからinodeを読み出す。`iunlock`は、そのinodeのロックを解放する。inodeポインタの確保と、inodeのロックの機能を分離することにより、いくつかの状況、例えば、ディレクトリの参照中においてデッドロックを回避することができる。複数のプロセスが`iget`によって返されるinodeへのCのポインタを保持することができるが、たった一つのプロセスのみが、同じ時間にinodeをロックすることができる。 140 | 141 | inodeキャッシュは、カーネルコードもしくは、Cポインタを保持するデータ構造がinodeを利用するときだけキャッシュされる。主たる機能は複数のプロセスがinodeへ参照したときの本当の同期を取るためであり、キャッシュをすることが本来の機能ではない。もしinodeが頻繁に利用されるならば、バッファキャッシュがそれをメモリ上に保持し、inodeキャッシュを保持する訳ではない。 142 | 143 | # コード例: inode 144 | 145 | 新しいinodeを割り当てる(例えば、新しいファイルを作成するときなど)時は、xv6は`ialloc`(4953行目)を呼ぶ。`ialloc`は`balloc`と似ている:ディスク上のinode構造体をループし、一つずつ、フリーな状態のinodeを探していく。フリーなinodeを発見すると、新しいtypeをディスク上に書き込み、inodeキャッシュからエントリを返して、最後の`iget`に渡す(4970行目)。`ialloc`の正しい動作は1つのプロセスが`bp`を参照していることを前提にしている: `ialloc`はいくつかの他のプロセスが同時に動作せずに使用可能であることを前提に動作している。 146 | 147 | `iget`(5004行目)はinodeキャッシュ中を探索して、所望のデバイスと所望のinode番号からアクティブなエントリ(`ip->ref>0`)を見つける。該当するエントリを発見すると、そのinodeへの新しい参照を返す(5013-5017行目)。`iget`がスキャンしたように、最初の~のスロットの場所を記録しておき(5018-5109行目)、キャッシュエントリを確保しなければならないときに利用する。 148 | 149 | inodeの内容やメタデータを読み書きする前に、`iget`は`ilock`を利用してinodeをロックしなければならない。 150 | `ilock`(5053行目)は慣れ親しんだ`sleep`のループを用いて、`ip->flag`の`I_BUSY`ビットがクリアされ、その後セットされるのを待っている(5062-5064行目)。一度`ilock`がinodeに対する排他的なアクセスを確保すると、必要ならばディスク上からinodeのメタデータをロードする(より正確に言えば、バッファキャッシュにロードされる)。`iunlock`(5085行目)関数は`I_BUSY`ビットをクリアし、`ilock`中でスリープ状態になっている任意のプロセスを起こす。 151 | 152 | `iput`はinodeの参照カウンタをデクリメントさせることで、Cポインタの参照を解放する(5124行目)。もしそれが唯一の参照であれば、inodeキャッシュ中のinodeスロットは解放され、別のinodeのために再利用される。 153 | 154 | `iput`がCポインタの参照が存在せず、inodeはリンクを持っていなければ(これはディレクトリの中では発生しないxxx?)、inodeとそのデータブロックは参照されていなければならない。`iput`はinodeを再度ロックする; `itrunc`を呼び出してファイルをゼロバイトになるまで切り取り、そのデータブロックを解放する; inodeタイプを0(非割り当て)に設定する;その変更をディスクに書き込む; 最後にinodeをアンロックする(5111-5123行目)。 155 | 156 | inodeを解放するときの`iput`のロッキングプロトコルはチェックするに値するものである。 157 | 最初に、ipを`I_BUSY`を設定することでロックすると、`iput`はそれがロック解除されたものと仮定する。 158 | これは以下のような場合があると考えられる: 呼び出し元は`iput`を呼び出す前にipをアンロックしておく必要があり、他のプロセスがそのinodeをロックしていない。これは、このコードパス中ではinodeは参照されておらず、リンクも存在しない(つまり、どのようなパス名もそのinodeを参照しておらず)、そして未だ解放のマークが付いていない。チェックすべき2つめの場所は、`iput`がinodeのキャッシュロックを一時的に解放し(5116行目)、再度取得する(5120行目)ことである。これは`itrunc`と`iupdate`がディスクI/Oを行っている間にスリープするからである。しかしこのロックを保持していない間は、何が発生するか分からない。明らかに、一度`iupdate`が終了すると、ディスク上のinodeは解放とマークされており、平行して呼ばれる`ialloc`は`iput`が終了する前にその場所を発見することがあるかも知れない。`ialloc`は`iget`を呼び、キャッシュ中にipを発見し、その`I_BUSY`フラグがセットされているのを確認しスリープ状態に入り、`ialloc`はそのブロックの場所を返すかもしれない。今、コア中のinodeはディスクに対して同期がされていない: `ialloc`はディスクのバーションを再初期化するが、`ilock`中にメモリにロードされる呼び出し元に依存している。常にこのような動作になることを保証するために、`iput`は`I_BUSY`をクリアするだけでなく、inodeのロックを解放する前に、`I_VALID`をクリアする必要がある。これは、`flags`をゼロに設定することで実行される(5121行目)。 159 | 160 | `iput`はディスクへ書き込みができる。これはファイルシステムを利用するシステムコールは、(例えば`read()`のようなread-onlyなシステムコールであっても)、ディスクへの書き込みを発生させることを示している。これはつまり、Read-Onlyなシステムコールも、ファイルシステムを利用する場合はトランザクションをラップしておかなければならないことを示している。 161 | 162 | # コード例: inodeの内容 163 | 164 | ディスク上のinode構造体である`struct inode`には、サイズとブロック番号の配列がはいっている(図6-4を参照のこと)。そのブロックのinodeデータは`dinode`の`addr`配列から探索される。データの最初の`NDIRECT`ブロックは配列中の最初の`NDIRECT`エントリにリスト化されている。; これらのブロックはdirect blocksと呼ばれる。 165 | 次の``NINDIRECT``はinodeが入っている訳ではないが、indirect blockと呼ばれるデータブロックが入っている。 166 | `addr`配列の最後のエントリは間接ブロックのアドレスが入っている。従って、ファイルの最初の6kB(NDIRECT×`BSIZE`)バイトは、inodeのブロックリストからロードすうことができ、一方で次の64kB(`NINDIRECT`×`BSIZE`)バイトは関節ブロックをロードしてから、ロードすることができる。これはディスク上の表現としては良いが、間接ブロックへのアクセスがやや複雑である。`bmap`関数がこの表現を管理し、`readi`や`writei`などのより高位なルーチンが簡単に参照できるようにしている。`bmap`はinode ipのbn番目のデータブロックディスクブロックの番号を返す。もしipがこのようなブロックを持っていなければ、`bmap`はそれを割り当てる。 167 | 168 | ![Figure6-04](images/figure6-04.JPG) 169 | 170 | `bmap`関数(5160行目)は、まず簡単なケースから探っていく: 最初の`NDIRECT`ブロックはinode自身に並んでいる(5165-5169行目)。次の`NINDIRECT`ブロックは`ip->addrs[NDIRECT]`に配置されている間接ブロックに配置されている。`bmap`は間接ブロックを読み込み(5176行目)、ブロック中の右側からブロックの番号を読み込んでいく(5177行目)。もしこのブロック番号が`NDIRECT`+`NINDIRECT`を越えていれば、`bmap`はパニックを起こす; `writei`はこのような状態を防ぐための処理がなされている(5315行目)。 171 | 172 | まはブロックを必要に応じて割り付ける。`ip->addrs[]`もしくは間接エントリがゼロであるこことは、どのようなブロックも割り当てられていないことを意味する。`bmap`がゼロに遭遇すると、新しいブロック番号とそれを取り替え、必要に応じて割り当てを行う(5166-5167, 5174-5175行目)。 173 | 174 | `itrunc`はファイルのブロックを解放し、inodeのサイズをゼロにリセットする。`itrunc`(5206行目)は直接ブロックをまずは解放し(5212-5217行目)、次に間接ブロックのリストを解放していく(5222-5225行目)、そして最後に間接ブロックそのものを解放する(5227-5228行目)。 175 | 176 | `bmap`により、`readi`および`writei`にとってinodeのデータを取得しやすくなる。`readi`(5252行目)はオフセットとカウントがファイルの最後尾を越えることがないことを確認する。ファイルの最後尾を越えて読み込みを行う場合はエラーを返し(5263-5264行目)、ファイルの最後尾から読み込む、もしくはファイルの最後尾を越えて読み込む場合は、要求したサイズよりも少ないサイズを返す(5263-5266行目)。メインループはファイルの各ブロックを処理し、バッファからデータをdstにコピーする(5268-5273行目)。`writei`(5302行目)は`readi`と似ているが、3つの異なる場所がある: `writei`はファイルの最後尾を越えて書き込もうとうると、ファイルを拡大し、最大ファイルサイズまで拡張する(5315-5316行目); ループはoutの代わりににデータをバッファへコピーする(5321行目);そしてもしファイルが書き込みにより拡張されると、`writei`はサイズを更新しなければならない(5326-5329行目)。 177 | 178 | `readi`および`writei`は`ip>type==T_DEV`をチェックするところから開始される。このケースは、ファイルシステム中に存在していない特殊なデバイスなどを操作するときに利用される; ファイル記述階層中でこのような場合に遭遇すると、関数はその場で戻る。 179 | 180 | 関数`starti`(4773行目)はinodeのメタデータを`start`構造体にコピーし、これにより`start`システムコールによりユーザプログラムから参照できるようになる。 181 | 182 | # コード例: ディレクトリ階層 183 | ディレクトリは、内部的にはファイルのように実装される。ディレクトリのinodeのタイプは`T_DIR`であり、そのデータはディレクトリエントリの列である。各エントリは`struct dirent`(3950行目)であり、それぞれには名前とinode番号が入っている。名前は殆どがDIRSIZ(14)である; もしそれよりも短けれれば、NUL(0)により終端される。ディレクトリエントリのinode番号がゼロであると、それは解放されている。 184 | 185 | `dirlookup`(5361行目)は与えられ名前のディレクトリを探索する。もし見つかったならば、該当するinodeのポインタを返し、ロックの解放を行い、呼び出し元が編集を行いたければ、ディレクトリのエントリにバイトオフセットに`*poff`を設定する。`dirlookup`が正しい名前のエントリを派遣すれば、`*poff`を更新し、そのブロックを更新し、`iget`により取得したロックを解放したinodeを返す。`iget`がロックを解放したinodeを返す理由が、`dirlookup`のためである。呼び出し元が`dp`をロックすると、もし探索が.、つまり現在のディレクトリのエイリアスに対するものであるならば、戻り関数が`dp`を再度ロックしデッドロックが生じる前にinodeをロックする(複数のプロセスによる探索と、..、つまり親ディレクトリの探索には、.のみが問題なのではなく、より複雑なデッドロックのシナリオがある。)呼び出し元は`dp`をアンロックすることができ次にipをロックし、一度に一つしかロックを保持していないことを保証する。 186 | 187 | `dirlink`(5402行目)関数は与えられた名前の新しいディレクトリエントリとinode番号をディレクトリdpに書き込む。もしその名前が既に存在すると、`dirlink`はエラーを返す(5408-5412行目)。ループはディレクトリエントリを読み込み割り当てられていないエントリを探索する。そうでなければ、ループは終了し、`dp->size`にoffをセットして終了する。どちらにしても、`dirlink`は新しいエントリに対してオフセットoffを書き込むことでディレクトリに新しいエントリを追加する(5422-5425行目)。 188 | 189 | # コード例: パス名 190 | 191 | パス名の探索には、`dirlookup`を連続して呼び出すことになり、それぞれの呼び出しにより、各パスのコンポーネントを探索することになる。`namei`(5540行目)はpathを評価し、該当するinodeを返す。関数`nameparent`は変数である: これは最後の要素の前で停止し、親ディレクトリのinodeを返し、最後の要素をnameに返す。どちらの呼び出しも、実際に動作させるために、`namex`という関数により一般化される。 192 | 193 | `namex`(5505行目)はパスの評価がどこから開始されるかを決定する。もしパスがスラッシュから始まるのであれば、パスの探索はルートから行われる; そうでなければ、現在のディレクトリから探索が行われる(5509-5512行目)。次に`skipelem`を使ってパスの各要素について調査を行う(5514行目)。各ループのイタレーションでは、現在のinode ipに対してnameを探索しなければならない。イタレーションは、ipをロックするところから始まり、それがディレクトリであるかをチェックする。ディレクトリでなければ、探索は失敗である(5515-5519行目)。(ipをロックするのは、`ip->type`が足元を変更する可能性があるからではない-それは不可能である-しかし、`ilock`が動作すると、`ip->type`はディスクからロードされていたことを保証するものでは無くなる)。 194 | もし呼び出しが`nameiparent`であり、それが最後のパス要素であれば、ループは`nameiparent`の定義の通りに(xxx)、それより前に停止する; 最後のパス要素は既にnameにコピーされているため、`namex`はロックされていないipのみを返す必要がある(5520-5524行目)。 195 | 最後に、ループは`dirlookup`を利用してパス要素を探索し、`ip=next`を設定することで、次のイタレーションに備える(5525-5530行目)。ループがパス要素の最後まで走り切ると、`ip`を返す。 196 | 197 | # ファイル記述階層 198 | 199 | Unixインタフェースのクールな側面の一つは、Unixの殆どの資源はファイルとして表現され、コンソールのようなデバイスやパイプ、そして勿論実際のファイルもファイルとして表現される。ファイル記述階層はこの統一性を実現するための階層である。 200 | 201 | 第0章で見たように、xv6は各プロセスで開いたファイルか、ファイルディスクリプタのテーブルを持っている。それぞれの開いたファイルは`struct file`(4000行目)として表現されており、inodeかパイプ、さらにi/oオフセットのラッパである。`open`を呼び出すことによって、新しいファイル(新しい`struct file`)が開かれる: もし複数のプロセスが同時に同じファイルをそれぞれ開こうとしていれば、異なるインスタンスが異なるi/oオフセットを持っている。一方で、一つのプロセスによって開かれたファイル(同一の`struct file`)は、プロセスのファイルテーブル中に複数回存在し、それは複数のプロセスのテーブルに存在する。これにより、もし一つのプロセスが`ope`nを利用しファイルをオープンし、`dup`を利用してエイリアスを作成するか、`fork`を使って子プロセスと共有することが有り得る。一つのファイルは読み込み用と書き込み用にオープンすることが可能である。`readable`もしくは`writable`フィールドが、それを記録している。 202 | 203 | システム中の全てのオープン中のファイルは、グローバルなファイルテーブル`ftable`に保持されている。 204 | ファイルテーブルは、ファイルを割り当てるための関数を持っており(`filealloc`)、これにより複数の参照を作成し(`filedup`)、`fileclose`により参照を解放し、`fileread`もしくは`filewrite`により読み書きを行う。 205 | 206 | 最初の3つの関数は、今ではおなじみの形式を取っている。`filealloc`(5025行目)はファイルテーブルをスキャンし、割り当てられていないファイル(`f->ref==0`)を探し、新しい参照を返す;`filedup`(5652行目)は参照カウントをインクリメントする; `fileclose`(5664行目)は参照カウントをデクリメントする。ファイルの参照カウントがゼロになると、`fileclose`によりタイプに応じてpipeもしくはinodeを解放する。 207 | 208 | 関数`filestat`,`fileread`,`filewrite`はファイルに対する`stat,read,write`の実装である。`filestat`(5702行目)はinodeに対してのみ操作が許されており、`stati`を呼び出す。`fileread`と`filewrite`は開いているモードに応じてその操作が許されているかどうかをチェックし、パイプかinodeの実装に対して要求を受け渡す。もしファイルがinodeとして表現されていると、`fileread`と`filewrite`はi/oはファイルの操作のためにオフセットを用い、ファイルを進める(5725-5726,5765-5766行目)。パイプはオフセットの概念が無い。inodeの関数には、ロックを持たせる必要があったことを思い出そう(5705-5707,5724-5727,5764-5778行目)。inodeのロックは便利な副作用を持っており、readとwriteのオフセットがアトミックに更新されるということである。従って、同じファイルへ同時に複数の書き込みがあった場合には、それぞれのデータは上書きされず、それぞれの書き込みが最後にインターレースするということになる。 209 | 210 | # コード例: システムコール 211 | 212 | 低階層が殆どのシステムコールを提供する関数を用いるのは普通のことである(`sysfile.c`を参照のこと)。 213 | しかし、いくつかの関数はより調査しなければならないものがある。 214 | 215 | 関数`sys_link`と`sys_unlink`はディレクトリを編集し、inodeの作成、削除参照を行う。トランザクションを利用する力の良い例がもう一つ存在する。`sys_link`(5913行目)はその引数をフェッチするところから始まり、2つの文字列oldとnewを渡す(5918行目)。`old`が存在しており、ディレクトリでは無く(5922-5925行目)、`sys_link`は`ip->nlink`のカウントをインクリメントする。次に、`sys_link`は`nameiparent`を呼び出し、親ディレクトリを探し、newエレントの最終的なパスを探し(5938行目),oldのinodeを指す新しいディレクトリを作成する:inode番号は、1つのディスクにつき1つの意味しか持っていない。もしこのような関係でエラーが発生すると、`sys_link`は戻ってきて、`ip->nlink`をデクリメントする。 216 | 217 | このような実装には、複数のディスクブロックの更新が必要になるため、トランザクションがこの実装を簡単化していが、これらをどのような順番で実行するかについては考慮しなくても良い。全て成功するか、全く成功しないかの2つしか存在しない。例えば、トランザクションが存在しなければ、リンクをさくせ いする前に`ip->nlink`を更新すると、ファイルシステム上に一時的に非安全な状態を作ってしまい、その間にクラッシュが発生すると大損害が発生してしまう。トランザクションでは、そのようなことを考慮しなくても良い。 218 | 219 | `sys_link`は既存のinodeに新しい名前を付ける。`create`関数(6057行目)は新しいinodeに対して新しい名前を付ける。これらは、3つのファイルを作成するシステムコールの一般化である: openは`O_CREATE`フラグ付きの関数で、新しいファイルを作成する。`mkdir`は新しいディレクトリを作成し、`mkdev`は新しいデバイスファイルを作成する。`sys_link`、`create`は`nameiparent`を呼び出す所から始まり、親ディレクトリのinodeを取得する。次に、`dirlookup`を呼び出し、その名前が既に存在しているかをチェックする(6067行目)。もしその名前が存在すると、`create`の動作は、システムコールがどのような目的のために利用されているかによって動作が変わる:openは`mkdir`と`mkdev`の2つの意味を持っている。もし`create`がopenの変わりに呼び出され`(type==T_FILE)`、通常のファイル自身として存在したならば、openは成功として動作し、`create`も同様に動作する(6071行目)。そうでなければ、エラーとして動作する(6072-6073行目)。もしそのファイル名が存在していなければ、`create`は新しいinodeを`ialloc`を利用して割り当てる(6076行目)。もし新しいinodeがディレクトリであれば、`create`は.と..のエントリを初期化する。最後に、データが初期化されるような特性を持っていれば、`create`は親ディレクトリにリンクを行う(6089行目)。`sys_link`のような`create`は2つのinodeを同時にロックする: ipとdpである。inode ipは新たに割り当てられたものであるため、これらはデッドロックする可能性は無い:システム中のそれ以外のファイルipのロックをしようとするかも知れないが、dpをロックすることは無い。 220 | 221 | `create`を利用することで、`sys_open`,`sys_mkdir`,`sys_mknod`の実装が簡単になる。`sys_open`(6101行目)は最も複雑である。何故ならば新しいファイルの作成は、それがすることのできる機能の一部だからである。openが`O_CREATE`フラグと共に渡されれば、`create`(6114行目)を呼び出す。そうでなければ、`namei`(6120行目)を呼び出す。`create`はロックされたinodeを返すが、`namei`はそうではない。従って、`sys_open`は読み出しのためだけに利用され、書き込みには利用されない。inodeが一つの方法かそれ以外でしか入手できなかったと仮定すると、`sys_open`はファイルとファイルディスクリプタを割り当て(6132行目)、ファイルを埋める(6142-6146行目)。現在のプロセステーブルにのみ存在するため、それ以外のプロセスは部分的にしか初期化はされないことに注意する。 222 | 223 | 第5章では、私達がファイルシステムについて学ぶ前にパイプの実装について調査した。`sys_pipe`関数はファイルシステムに実装を接続し、パイプのペアを作成する機能を提供する。その引数は2つの整数のための空間へのポインタであり、2つの新しいファイルディスクリプタを提供する。次に、パイプを割り当て、ファイルディスクリプタを設置する。 224 | 225 | # 現実の世界 226 | 227 | 現実世界のオペレーティングシステムが持っているバッファキャッシュはxv6のものよりも複雑だが、同様に2つの目的のために実装されている:ディスクへのアクセスをキャッシュし、同期アクセスを実現するというものである。xv6のバッファキャッシュはV6のように、シンプルなLeast Recently Used(LRU)のポリシで実装されている;より複雑な入れ替えのポリシで実装そうることもできるが、どの実装が優れているかは状況に依存する。 228 | より効率的なLRUキャッシュはリンクリストを除去し、探索のためにハッシュテーブルを用いて、LRUの置き換えのためにヒープ構造を利用している。現代のバッファキャッシュはメモリマップファイルをサポートするために仮想メモリシステムを統合している。 229 | 230 | xv6のロギングシステムは非効率である。コミットはファイルの対して、システムコールを使って同時に発生させることができない。システムは、ブロック中の僅かなバイトが書き換えられとしても、ブロック全体をログする。同時に同期的なログの書き込みを行い、それぞれはディスク全体を回転される時間を要する。実際のロギングシステムはこのような問題に全て焦点を当てている。 231 | 232 | キャッシュリカバリをサポートするためには、ロギングは唯一の方法ではない。初期のファイルシステムは再起動の間はScavengerを利用していた(例えば、UNIXの`fsck`プログラムなど)。これにより、全てのファイルとディレクトリ、ブロックとinodeフリーリストをチェックし、探索と誤っている部分の修復を行っていた。scanvengingは大きなファイルシステムでは時間がかかってしまい、さらにオリジナルのシステムコールがアトミックな動作をする方法では、システム上の矛盾を解決できないことがあった。ログを使った回復の方法はより高速であり、クラッシュした場合にはシステムコールをアトミックに動作させることができる(xxx)。 233 | 234 | 6は初期のUNIXが採用していたinodeとディレクトリのディスク上基本レイアウトと同じものを利用している; 235 | この構造は長年維持されてきた。BSDのUFS/FFSとLinuxのext2lext3は基本的にこれと同じものを利用している。このファイルシステムのレイアウトの最も効率の悪いところはディレクトリである。ディレクトリの探索には全てのディスクブロックの線形スキャンが必要である。これはディレクトリがディスクブロック中にわずかしか存在しない場合は妥当な構成であるが、ディレクトリ中に複数のファイルが存在する場合は高価な実装である。MicrosoftのNTFS,Mac OX XのHFS, SolarisのZFSは、ディレクトリのバランス木の構造で実装している。 236 | この実装は複雑であるがディレクトリの探索を対数時間で実行することができる。xv6はディスク操作の失敗に対して敏感である: もしディスク操作が失敗すると、xv6はパニックを起こしてダウンする。これが妥当な方式であるかどうかは、ハードウェアに依存する: もしオペレーティングシステムが冗長にディスクの失敗をマスクする特別なハードウェアの上で動作しているのだとしたら、おそらくオペレーティングシステムは失敗することは頻繁ではなくなり、パニックを発生すること自体は問題ではなくなる。 237 | 238 | 一方で、オペレーティングシステムが標準的なディスクを利用している場合は、ディスク操作の失敗を検出する必要があり、より丁寧に対処する必要がある。よって、ファイル中のブロックのロスは残りのファイルシステムの利用には影響を与えない。xv6は、ファイルシステムはディスクデバイスにフィットする必要があり、そのサイズは変化しないことを前提にしている。 239 | 240 | より大きなデータベースやマルチメディアファイルが必要なにり、ストレージのサイズを増加させなければならない場合、オペレーティングシステムは「ファイルシステムあたり1つのディスク」という制約を取り除かなければならない。基本的なアプローチは大くのディスクを集めて、一つの論理的なディスクとすることである。 241 | ハードウェア的な解決法としては、RAIDのような方式が最も有名であるが、現在のトレンドは、可能な限りソフトウェアを用いてディスクのまとめを実現することである。これらのソフトウェアの実装は、典型的に、論理的なディスクの増加や縮退などをオンザフライに実現できるようなリッチな機能を持っている。 242 | もちろん、オンザイフライにディスクの拡張や縮小を実現できるストレージ階層には、同様のことが実現できるファイルシステムが必要である:xv6が利用している固定サイズのinodeブロックでは、このような環境ではうまく動作しない。ディスク管理をファイルシステム自体から分離させるのが、最も綺麗な実装であるが、 243 | インタフェース間が複雑になり、SunのZFSや、それらを組み合せたようないくつかの実装が必要になる。 244 | 245 | xv6のファイルシステムには現代のファイルシステムの多くの機能が欠落している; 例えば、スナップショットと、インクリメンタルなバックアップのサポートが抜けている。現代のUNIXのシステムは、同一のシステムコールを利用してディスク上の多くの種類の資源にアクセスすることができるようにサポートをしている: 246 | 247 | 名前付きパイプや、ネットワーク接続や、リモート接続のネットワークファイルシステムや、/procのような監視と制御インタフェースである。xv6の`fileread`および`filewrite`のif文の代わりに、これらのシステムでは典型的にオープンするファイルの関数ポインタのテーブルを持っている。その関数ポインタを呼び出すことによって、そのinodeの実装に対する機能を呼び出すことができる。ネットワークファイルシステムとユーザレベルファイルシステムでは、それらの呼び出しをネットワークのRPCに変換し、関数から戻る前にレスポンスを待つという機能が追加されている。 248 | 249 | # 練習問題 250 | 251 | 1. 何故`balloc`ではpanicを起こすようになっているのか?xv6は回復できるか? 252 | 2. 何故`ialloc`ではpanicを起こすようになっているのか?xv6は回復できるか? 253 | 3. 何故`filealloc`はファイル外の場所を操作してもpanicを起こさないようになっているのか?何故これは一般的なのか、これはハンドリングする価値があるか? 254 | 4. ipに相当するファイルが他の`sys_link`の呼び出しと`iunlock`(ip)と`dirlink`の間に他のプロセスによりアンリンクを受け付けたとする。リンクは正しく生成されるか?そうでない場合は何故か? 255 | 5. `create`は4つの関数呼び出しを発生させる(1つは`ialloc`から、残りは`dirlink`から)。もし一つが成功しなかったら、`create`はpanicを呼び出す。何故これは妥当な設計なのか?何故これらの4つのいずれかの呼び出しも失敗することができないのか? 256 | 6. sys_chdirは`iput(cp->cwd)`を呼び出す前に`iunlock(ip)`を呼び出し、`cp->cwd`をロックをしようとするが、`iput`の後に`iunlock(ip)`を呼び出そうとするとデッドロックする。何故こうなるのか? 257 | -------------------------------------------------------------------------------- /chapter7.md: -------------------------------------------------------------------------------- 1 | サマリ 2 | ===== 3 | 4 | 本書では、オペレーティングシステムxv6を用いて、1行1行を学ぶことでオペレーティングシステムの基本的なアイデアについて紹介した。 5 | いくつかのコードには基本的なアイデアのエッセンスが埋め込まれている(例えば、コンテキストスイッチ、ユーザ/カーネルの境界、ロックなど)、 6 | また各行が重要なものである; 他のコード行は、オペレーティングシステムの特定の部分を実装するアイデアや、別の方法で簡単に実装する方法などの説明が含まれている 7 | (例えば、スケジューリングのより良いアルゴリズムや、ファイルを表現するためのより良いディスク上の表現や、並列なトランザクションを許可するためのより良いロギングの方法など)。 8 | 全てのアイデアは特定の、非常に成功したシステムコールインタフェースであるUNIXのインタフェースとして表現されている。 9 | しかしこれらのアイデアは他のオペレーティングシステムにそのまま引き継がれている訳ではない。 10 | -------------------------------------------------------------------------------- /images/figure0-01.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msyksphinz-self/xv6_translate/da591c83b17fb5256c963c8e42ffe4596096221e/images/figure0-01.JPG -------------------------------------------------------------------------------- /images/figure0-02.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msyksphinz-self/xv6_translate/da591c83b17fb5256c963c8e42ffe4596096221e/images/figure0-02.JPG -------------------------------------------------------------------------------- /images/figure1-01.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msyksphinz-self/xv6_translate/da591c83b17fb5256c963c8e42ffe4596096221e/images/figure1-01.JPG -------------------------------------------------------------------------------- /images/figure1-02.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msyksphinz-self/xv6_translate/da591c83b17fb5256c963c8e42ffe4596096221e/images/figure1-02.JPG -------------------------------------------------------------------------------- /images/figure1-03.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msyksphinz-self/xv6_translate/da591c83b17fb5256c963c8e42ffe4596096221e/images/figure1-03.JPG -------------------------------------------------------------------------------- /images/figure1-04.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msyksphinz-self/xv6_translate/da591c83b17fb5256c963c8e42ffe4596096221e/images/figure1-04.JPG -------------------------------------------------------------------------------- /images/figure2-01.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msyksphinz-self/xv6_translate/da591c83b17fb5256c963c8e42ffe4596096221e/images/figure2-01.JPG -------------------------------------------------------------------------------- /images/figure2-02.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msyksphinz-self/xv6_translate/da591c83b17fb5256c963c8e42ffe4596096221e/images/figure2-02.JPG -------------------------------------------------------------------------------- /images/figure2-03.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msyksphinz-self/xv6_translate/da591c83b17fb5256c963c8e42ffe4596096221e/images/figure2-03.JPG -------------------------------------------------------------------------------- /images/figure3-01.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msyksphinz-self/xv6_translate/da591c83b17fb5256c963c8e42ffe4596096221e/images/figure3-01.JPG -------------------------------------------------------------------------------- /images/figure3-02.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msyksphinz-self/xv6_translate/da591c83b17fb5256c963c8e42ffe4596096221e/images/figure3-02.JPG -------------------------------------------------------------------------------- /images/figure4-01.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msyksphinz-self/xv6_translate/da591c83b17fb5256c963c8e42ffe4596096221e/images/figure4-01.JPG -------------------------------------------------------------------------------- /images/figure5-01.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msyksphinz-self/xv6_translate/da591c83b17fb5256c963c8e42ffe4596096221e/images/figure5-01.JPG -------------------------------------------------------------------------------- /images/figure5-02.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msyksphinz-self/xv6_translate/da591c83b17fb5256c963c8e42ffe4596096221e/images/figure5-02.JPG -------------------------------------------------------------------------------- /images/figure6-01.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msyksphinz-self/xv6_translate/da591c83b17fb5256c963c8e42ffe4596096221e/images/figure6-01.JPG -------------------------------------------------------------------------------- /images/figure6-02.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msyksphinz-self/xv6_translate/da591c83b17fb5256c963c8e42ffe4596096221e/images/figure6-02.JPG -------------------------------------------------------------------------------- /images/figure6-04.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msyksphinz-self/xv6_translate/da591c83b17fb5256c963c8e42ffe4596096221e/images/figure6-04.JPG -------------------------------------------------------------------------------- /index.md: -------------------------------------------------------------------------------- 1 | # xv6, シンプルで、Unixライクな教育用オペレーティングシステム 2 | 3 | - [第0章. オペレーティングシステムインタフェース](chapter0.md) 4 | - [第1章. オペレーティングシステムの構成](chapter1.md) 5 | - [第2章. ページテーブル](chapter2.md) 6 | - [第3章. トラップ、割り込み、ドライバ](chapter3.md) 7 | - [第4章. ロック](chapter4.md) 8 | - [第5章. スケジューリング](chapter5.md) 9 | - [第6章. ファイルシステム](chapter6.md) 10 | - [第7章. サマリ](chapter7.md) 11 | - [付録A. PCハードウェア](appendixa.md) 12 | - [付録B. ブートローダ](appendixb.md) 13 | -------------------------------------------------------------------------------- /redpen-my-conf-ja.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | --------------------------------------------------------------------------------