オペレーティングシステムの仕事は、複数プログラム間でコンピュータを共有し、単体ハードウェアによるサポートよりも便利なサービスセットを提供することである。 オペレーティングシステムは低レイヤのハードウェアを管理、抽象化する。 例えば、ワードプロセッサを使うために、どのような種類のディスクが利用されているかについて考える必要はない。 オペレーティングシステムはハードウェアを分割し、多くのプログラムがコンピュータを共有し同時動作(もしくは動作しているように見える)を実現している。 さらに、オペレーティングシステムはプログラム同士が相互通信をするための制御された方法を提供している。 これにより各プログラムはデータを共有し協調して動作することができるようになっている。
オペレーティングシステムはユーザプログラムに対しインタフェースを通じてサービスを提供する。 良いインタフェースを設計することは難しい。 一方で、私達はインタフェースをシンプルかつ狭くすることで、実装をより簡単にしたいと思う。 一方で、インタフェースはアプリケーションの機能により洗練されたものを要求する傾向にある。 この難しい関係を解消するために、少数の汎用性を持つメカニズムを使ってインタフェースを設計することである。 メカニズムの種類を減らすことにより、高い汎用性を提供することができるようになる。
本書はひとつのオペレーティングシステムを具体的な例として取り上げ、その概念を説明する。 このオペレーティングシステムはxv6と呼ばれる。 xv6はKen ThompsonとDennis Ritcheにより開発されたUnixオペーレーティングシステムのインタフェースだけでなく、Unixの内部デザインも真似ている。 Unixはインタフェースの種類は少ないが、これらのインタフェースはうまく組み合わせることで、驚くほど程度の高い汎用性を実現することができる。 このインタフェースはBSD、Linux、Mac OS X、Solarisなどの現代のオペレーティングシステムでも採用されており、またMicrosoft WindowsでもUNIX系のインタフェースを僅かながら採用している。 xv6を理解することはこれらのシステムや他のシステムを理解するための良いスタート点になる。
図0-1に示すように、xv6は"カーネル"と呼ばれる伝統的な形式を取っており、実行中のプログラムに対してサービスを提供するための特殊なプログラムの形をしている。 各実行中のプログラムはプロセスと呼ばれ、命令、データ、スタックを含んでいるメモリを持っている。 命令はプログラムで計算を実行するためのものである。 データは計算するための変数などが入っている。 スタックはプログラムの手続き呼び出しを構成する。
プロセスがカーネルサービスを呼ばなければならない場合、オペレーティングシステムのインタフェースを通じてカーネルサービスの手続き呼出しがなされる。 このような手続きのことをシステムコールと呼ぶ。 システムコールによりカーネルに入り、カーネルはサービスを実行し戻ってくる。 従って、プロセスはユーザ空間とカーネル空間での実行を往復することとなる。
カーネルはCPUのハードウェア保護機構を使い、ユーザ空間で実行されている各プロセスが自分のメモリ領域のみアクセスしているかをチェックする。 カーネルはこれらの保護を実装するために必要な権限を持って実行されるが、一方でユーザプログラムはこのような権限を持っていない。 従ってユーザプログラムがシステムコールを起動すると、ハードウェアが権限レベルを上昇させあらかじめカーネル内で配置された関数が実行される。
カーネルが提供しているシステムコール群はユーザプログラムがアクセスすることができるインタフェースである。 xv6カーネルはUnixカーネルが伝統的に提供しているサービスとシステムコールの一部を提供している。 図0-2はxv6のシステムコールの一覧である。
本章はこれから、xv6のサービスの概要を示す - プロセス、メモリ、ファイルディスクリプタ、パイプ、ファイルシステム、そして短いコードによるこれらの説明と、シェルがどのようにしてこれらを扱うかについて議論する。 シェルによりシステムコールがどのように利用されているかを観察することにより、これらのサービスがどのように注意深く実装されているかを説明する。
シェルはユーザのコマンドを読み込み実行するための最初のプログラムであり、伝統的なUnix系システムにおける主たるユーザインタフェースである。 シェルはカーネルの一部ではなくユーザプログラムであり、これがシステムコールインタフェースの能力を説明している、つまりシェルには何も特別な機能はない。 また、これはシェルを簡単に置き換えることができるということを意味している。 結果として、現代のUnixシステムでは独自のインタフェースやスクリプティングインタフェースを持った多くのシェルが存在し、ユーザは好きなものを選択することができる。 xv6のシェルはUnix Bourne Shellの基本となるシンプルな実装であり、これらの実装は(8350行)で見ることができる。
System call | Description |
---|---|
fork() | プロセスの生成 |
exit() | 現在のプロセスを終了する |
wait() | 子プロセスが終了するまで待つ |
kill(pid) | pidのプロセスを終了する |
getpid() | 現在のプロセスのidを返す |
sleep(n) | n秒スリープする |
exec(filename, *argv) | ファイルをロードし実行する |
sbrk(n) | プロセスのメモリをnバイト増大させる |
open(filename, flags) | ファイルを開く; flagsはファイルの読み書き属性を示す |
read(fd, buf, n) | 開いたファイルからnバイトを読み込み、bufに格納する |
write(fd, buf, n) | 開いたファイルに対してnバイト書き込む |
close(fd) | ファイルfdを開放する |
dup(fd) | ファイルfdを複製する |
pipe(p) | パイプを作成しp内のfdを返す |
chdir(dirname) | 現在のディレクトリを移動する |
mkdir(dirname) | 新しいディレクトリを作成する |
mknod(name, major, minor) | デバイスファイルを作成する |
fstat(fd) | fdに対する情報を返す |
link(f1, f2) | ファイルf1に対して新しい名前f2を追加する |
unlink(filename) | ファイルを削除する |
xv6のプロセスはユーザ空間メモリ(命令、データ、スタック)とカーネルによるプロセス毎の状態を記録した空間から構成される。 xv6はプロセスを時分割共有、つまり複数の実行待ちのプロセスで透過的にスイッチしながらCPUを利用できるようにする仕組み、を使用することが出来る。 プロセスが実行されていないならば、xv6はCPUレジスタを保存し、次のプロセスのレジスタをリストアする。 カーネルはプロセス識別子、pidを各プロセスに割り当てる。
プロセスはfork
システムコールを利用して新しいプロセスを作成する。
fork
は子プロセスと呼ばれる新しいプロセスを生成する。
子プロセスは、親プロセスと呼ぶ呼出元のプロセスと全く同一のものである。
fork
は親プロセスと子プロセスの両方に返される。
親ならばfork
は子プロセスのpidを返し、子ならばゼロを返す。
例えば次のようなプログラムを考えてみる。
int pid = fork();
if(pid > 0){
printf("parent: child=%d\n", pid);
pid = wait();
printf("child %d is done\n", pid);
} else if(pid == 0){
printf("child: exiting\n");
exit();
} else {
printf("fork error\n");
}
exit
システムコールは呼び出し元のプロセスの実行を中止し、メモリや開いているファイルなどを開放する。
wait
システムコールは終了した子プロセスのpidを返す; もし呼び出し元の子プロセスがどれも終了しなかった場合、wait
システムコールは終了するまで待つ。
このプログラムを実行すると
parent: child=1234
child: existing
と出力されるが、どちらの行が先に出力されるかは分からない。
これは親プロセスと子プロセスがどちらが先にprintf
コールを呼び出すかに依存する。
子プロセスが終了し親プロセスのwait
が帰ってくると、親プロセスが
parent: child 1234 is done
と出力する。
ここで親プロセスと子プロセスは別々のメモリと別々のレジスタを用いて実行されたことに注意する; 一方の変数を変更しても、他方には影響していない。
exec
システムコールは呼び出し元のプロセスのメモリを、ファイルシステム中に格納されている新しいメモリイメージに置き換える。
そのファイルは特定のフォーマットをしていなければならず、どの部分に命令が格納されているか、どの部分がデータか、どこから命令がスタートするか、などが記述されていなければならない。
xv6はELFフォーマットを用い、これについては第2章でより詳細に議論する。
exec
が成功すると、呼び出し元のプログラムには帰ってこない; その変わりに、ELFヘッダにより宣言されたエントリポイントからファイルがロードされ、命令が実行され始める。
exec
は2つの引数を取る: 実行ファイルが格納されているファイル名と、文字列で表現されている引数の配列である。
例えば、
char *argv[3];
argv[0] = "echo";
argv[1] = "hello";
argv[2] = 0;
exec("/bin/echo", argv);
printf("exec error\n");
上記のプログラム列は、プログラム/etc/echo
のインスタンスを呼び出し、引数リストとして、echo hello
を設定して実行する。
殆どのプログラムは最初の引数を無視する(伝統的に、ここにはプログラム名を挿入する)。
xv6シェルはプログラムを上記の呼び出しの方法を用いてユーザの変わりに実行する。
シェルのメイン構造はシンプルである; 8501行目のmain
を参照して欲しい。
main
ループはgetcmd
を利用してコマンドラインの入力を読み込む。
次にfork
を呼び出しシェルプロセスのコピーを生成する。
親プロセスであるシェルは子プロセスがコマンドを実行している間、wait
を呼んで待つ。
例えばユーザがプロンプト上で”echo hello
”とタイプした場合、runcm
はdは”echo hell
o”を引数として呼び出す。
runcmd
(8406)行目は実際のコマンドを実行する。
”echo hello
”を実行するために、ex
ec(8426行目)が呼び出される。
exec
の呼び出しが成功すると、子プロセスはruncmd
の変わりにechoを実行する。
どこかの段階でecho
がexit
を呼び出すと、親プロセスが呼び出され、main
(8501行目)上のwait
に制御が戻される。
読者はfork
とexec
が何故1つの処理として実行されないのか不思議の思うだろう;プロセスの作成とプログラムのロードを分割することは非常に賢い設計である。
これについては後程見ていく。
xv6は殆どのユーザ空間メモリを暗黙的に割り当てる: fork
は子プロセスのコピーに必要なメモリ領域を確保し、exec
は実行可能なファイルを保持するための十分なメモリ領域を確保する。
プロセスが実行中により多くのメモリが必要であれば(おそらくはmallocなどを使って)、sbrk(n)
を呼び出してデータメモリのサイズをnバイトまで増やすことができる; sbrk
は新しいメモリの場所を返す。
xv6はユーザや、あるユーザを他のユーザから保護する機構は持っていない; Unixのターミナルでは、xv6のプロセスはrootとして動作する。
ファイルディスクリプタとはプロセスが読み書きを行うカーネルが管理するオブジェクトである。ファイルディスクリプタは小さな数字で表現される。 プロセスはファイルやディレクトリ、デバイスをオープンしたり、パイプを作成したり、既存のディスクリプタを複製するためにファイルディスクリプタを獲得する。 簡単化のために、このファイルディスクリプタというオブジュクトを簡単に「ファイル」と呼ぶことにする; ファイルディスクリプタのインタフェースは、ファイル、パイプ、デバイスなどの違いを抽象化し、これらを全てバイトストリームのように扱うことができる。
内部的には、xv6カーネルはファイルディスクリプタをプロセス毎のテーブルとして取り扱っている。 従って全てのプロセスはファイルディスクリプタのためのプライベートな空間を持っており、それらはゼロから始まる識別子である。 慣習として、プロセスはファイルディスクリプタ0(標準入力)から読み込みを行い、ファイルディスクリプタ1(標準出力1)へ書き込みを行い、エラーメッセージをファイルディスクリプタ2(標準エラー出力)へ出力する。 これから私達が見ていくように、シェルはこれらの慣習をうまく用いてI/Oのリダイレクトやパイプラインを実現する。 シェルはこれらの3つのファイルディスクリプタがオープンであることを常に保証し(8507行目)、デフォルトのファイルディスクリプタはコンソールである。
read
とwrite
システムコールはファイルディスクリプタで指定されたオープンしているファイルから、バイト列を読み込んだり、バイト列を書き込んだりするものである。
read(fd,buf,n)
はファイルディスクリプタfd
から最大でnバイトを読み込み、buf
にコピーし、読み込んだバイト数を返す。
各ファイルディスクリプタはファイルの保持しているオフセットを参照している。
read
は現在のオフセットからデータを読み込み、読み込んだバイト数分だけオフセットを進行させる:
後続のread
は最初のread
が読み込みを完了した場所から読み込みを続ける。
もしこれ以上読み込むデータが存在しない場合、read
はゼロを返し、ファイルの最後であることを伝える。
write(fd,buf,n)
はbuf
からnバイトをファイルディスクリプタに書き込み、書き込まれたバイト数を返す。
nよりも小さな値が返された場合、何らかのエラーが発生したことを示している。
read
のように、write
もファイルの保持している現在のファイルオフセットを参照しており、書き込んだバイト数分だけオフセットを進ませる:
各write
は前のwrite
によりどれだけ進んだかを見て、書き込みを行う。
以下のプログラム列(このプログラムはcat
の基本的な構造を示している)は、データを標準入力から読み込んで、標準出力に出力している。
もしエラーが発生すると、標準エラー出力にメッセージを出力する。
char buf[512];
int n;
for(;;){
n = read(0, buf, sizeof buf);
if(n == 0)
break;
if(n < 0){
fprintf(2, "read error\n");
exit();
}
if(write(1, buf, n) != n){
fprintf(2, "write error\n");
exit();
}
}
このプログラム列の重要な部分は、ファイルから読み出すか、コンソールか、パイプから読み出すかについてはcatプログラム自身は知らないと言うことである。 同様にcatはファイルか、それ以外のところに書き込むかについても知ることはない。 ファイルディスクリプタの利用と、ファイルディスクリプタ0が入力、ファイルディスクリプタ1が出力であるという慣習を使うことによりcatをより簡単に実装することができるようになる。
close
システムコールはファイルディスクリプタを開放し、未来のopen
,pipe
,dup
システムコール(後の章を参照のこと)で再利用できるようにするためのものである。
新たに割り当てられたファイルディスクリプタは、現在のプロセスで利用されていないディスクリプタの最小値が利用される。
ファイルディスクリプタとfork
はI/Oのリダイレクトを簡単に実装するために相互に動作する。
forkは親プロセスのファイルディスクリプタのテーブルをメモリにコピーするため、子プロセスは親と完全に同一なファイルをオープンしていることになる。
システムコールexecは呼び出し元のプロセスのメモリを置き換えるが、ファイルテーブルは維持する。
この動作によりシェルがforkによりI/Oのリダイレクトを実装し、選択したファイルディスクリプタを再度オープンし、新しいプログラムを実行する。
以下がコマンド列cat < input.txt
を実行したときのシェルの動作を簡単化したものである。
char *argv[2];
argv[0] = "cat";
argv[1] = 0;
if(fork() == 0) {
close(0);
open("input.txt", O_RDONLY);
exec("cat", argv);
}
子プロセスがファイルnディスクリプタ0を閉じることにより、openがそのファイルディスクリプタを新しいファイルinput.txtに0を使うことを保証している。 0は最小のファイルディスクリプタなので、close(0)をすると次に必ず使われる。 catはファイルディスクリプタ0(標準入力)をinput.txtの参照として利用する。
fork
はファイルディスクリプタのテーブルをコピーするが、内部の各ファイルオフセットは親プロセスと子プロセスで共有している。次の例を考える。
if(fork() == 0) {
write(1, "hello ", 6);
exit();
} else {
wait();
write(1, "world\n", 6);
}
このコード列を実行すると、ファイルディスクリプタ1に割り付けらてたファイルにはデータとして"hello world"が出力される。
親プロセスのwrite
は子プロセスのwriteがどこまでオフセットを進めたかを調査してから実行する(このwrite
はwait
のおかげで、子プロセスが完了してから実行される)。
この動作により連続したコマンド列によって、連続した出力を実現することが可能になる(echo hello; echo world > output.txt
)
dup
システムコールは既存のファイルディスクリプタを複製し、同一のI/Oオブジェクトに対して新しいディスクリプタを返す。
どちらのファイルディスクリプタもオフセットを共有しており、あたかもファイルディスクリプタがfork
により複製されたように動作する。
これがhello worldをファイルに書き込むためのもう一つの方法である:
fd = dup(1);
write(1, "hello ", 6);
write(fd, "world\n", 6);
もしこの2つのファイルディスクリプタが、同じファイルディスクリプタからfork
とdup
のシステムコールより生成されたものならば、これらはオフセットを共有している。
そうでなければ、ファイルディスクリプタは同一のファイルをオープンしたとしてもオフセットを共有しない。
dup
によりシェル上で以下のようなコマンドを実現することができるようになる: ls existing-file non-existing file > tmp1 2>&1
2>&1
はシェルに対してコマンドがファイルディスクリプタの2番目をファイルディスクリプタの1と複製させることを示している。
既存のファイルの名前と、存在していないファイルを表示しようとしたエラーメッセージはファイルtmp1に出力される。
xv6シェルはエラーファイルのディスクリプタのリダイレクトをサポートしないが、実装の方法を知っておいて損はない。
ファイルディスクリプタはファイルがどのように接続されているかを隠蔽することができるため、強力な抽象化の手段である: ファイルディスクリプタ1に書き込んでいるプロセスは、ファイルに書き込みをしているかもしれないし、コンソールのようなデバイスへ書き込みをしているかもしれないし、あるいはパイプに書き込みをしているかもしれない。
パイプ(pipe
)はプロセスから見るとファイルディスクリプタのペアとして見え、ひとつは読み込み用で一つは書き込み用である。
パイプの一方に書き込みを行うと、パイプのもう一方からデータを入手することができ、プロセス間で通信する手段を提供する。
以下のサンプルコードは、プログラムwc
の標準入力をパイプの入力側に接続する例である。
int p[2];
char *argv[2];
argv[0] = "wc";
argv[1] = 0;
pipe(p);
if(fork() == 0) {
close(0);
dup(p[0]);
close(p[0]);
close(p[1]);
exec("/bin/wc", argv);
} else {
write(p[1], "hello world\n", 12);
close(p[0]);
close(p[1]);
}
このプログラムはpipe
を呼び出し、新しいパイプを作成して配列pに読み込み用ファイルディスクリプタと書き込み用ファイルディスクリプタを登録する。
fork
の実行後、親プロセスと子プロセスはそれぞれそのパイプのファイルディスクリプタを参照する。
子プロセスは読み込み用のファイルディスクリプタである0を複製し、ファイルディスクリプタ0に設定しp中のファイルディスクリプタを閉じ、wc
を実行する。
wcが標準入力からファイルを読み込むと、それはパイプから読み込まれたことになる。
親プロセスがパイプの書き込み側に書き込みを行い、ファイルディスクリプタの両方を閉じる。
データが取得できなければ、pipe中のread
はデータが書き込まれるまでか、全ての書き込み用のパイプが閉じるまで待つ;
後者の場合には、readは0を返し、あたかもデータファイルの最後まで到達したかのように振る舞う。
read
が新しいデータが到着不可能になるまで実行をブロックするのは、子プロセスにとってwc
を実行する前にpipeの書き込み側を閉じることが重要だからである:
もしwc
のpipeの書き込み側のファイルディスクリプタが参照することが出来るならば、wcは決してend-of-fileに到達しないからである。
xv6のシェルはgrep fork sh.c | wc -l
のようなパイプラインを上記のコード列のように実現する(8450行)。
子プロセスがパイプラインの左側と右側を接続するためのパイプを作成する。
次に、パイプラインの左側のコマンドのためにruncmd
を実行し、次にパイプラインの右側のコマンドのためにruncmd
を実行する。
そして左側のコマンドと右側のコマンドのどちらも終了することを確認するため、waitを2回呼ぶことで待ち合わせをしている。
パイプラインの右側のコマンドそのものにもパイプが含まれていた場合(例えばa | b | c
)、子プロセスを2回生成する(1回目はb用であり、2回目はc用)。
従って、シェルはプロセスのツリーを形成することになる。
このツリーの葉はコマンドであり、接続ノードは左側のノードと右側のノードが終了するのを待つプロセスである。
基本的には、接続ノードがパイプラインの左側を実行する機能を含めることができるのだが、これを正しく実現するためには実装が複雑になる。
パイプは一時ファイルを利用するのよりもより強力である: 以下のパイプライン
echo hello world | wc
をパイプを使わずに実現しようとするならば、
echo hello world > /tmp/xyz; wc < /tmy/xyz
としなければならない。
パイプラインと一時ファイルで少なくとも3つの基本的な違いがある。
まず、パイプはリダイレクションを利用することで自動的に一時ファイルを消去できる。
シェルは最後に/tmp/xyzを削除して終了することに気をつけなければならない。
次に、パイプは非常に長いデータストリームも渡すことができ、一方でファイルのリダイレクトでは全てのデータを格納するための十分に大きいディスク領域が必要になる。
3番目に、パイプは同期を実現することができる:
2つのプロセスはパイプのペアを用いることにより、御互いにメッセージを送ることができ、read
は、他のプロセスがwrite
を実行するまでブロックさせることができるようになる。
xv6のファイルシステムはデータファイルと呼ばれるバイト列と、データファイルの名前に対する参照と他のディレクトリを参照する情報が含まれているディレクトリを提供する。
xv6はディレクトリを特殊な種類のファイルとして実装している。
ディレクトリはツリーを構成し、特殊なディレクトリであるrootから開始される。
/a/b/c
のような形式で表現されるパスはルートディレクトリ/に含まれるディレクトリaに含まれるディレクトリbに含まれる名前cのファイル、もしくはディレクトリをrootが参照するため名前である。
/から始まらないパスは現在のディレクトリから始まる相対的なパスとして評価される。
この現在のディレクトリはchdir
システムコールにより切り替えることができる。
以下のどちらのコード列も、同一のファイルを呼び出すものである(全てのディレクトリは存在するものとする)
chdir("/a");
chdir("b");
open("c", O_RDONLY);
open("/a/b/c", O_RDONLY);
最初のコード列は、プロセスの現在のディレクトリを/a/b
に変更する; 2番目のコード列はプロセスの現在のディレクトリを変更しない。
新しいファイルやディレクトリを作成するためには、複数のシステムコールが存在する:
mkdir
は新しいディレクトリを作成し、O_CREATE
フラグつきのopen
は新しいデータファイルを作成する。
mknod
は新しいデバイスファイルを作成する。以下の例はこれらの全てを説明したものである。
mkdir("/dir");
fd = open("/dir/file", O_CREATE|O_WRONLY);
close(fd);
mknod("/console", 1, 1);
mknod
はファイルシステム上にファイルを作成するが、中には何も入っていない。
その変わりに、このファイルのメタデータには、このファイルはデバイスファイルであるということが記録され、メジャーデバイス番号とマイナーデバイス番号が記録される(この2つの番号がmknod
の引数に指定されている)。
この2つの番号によりカーネルデバイスを識別する。
後続のプロセスがファイルを開くと、カーネルはファイルシステムを参照する代わりに、カーネルデバイスの実装を参照するように変換処理が入る。
fstat
はファイルディスクリプタが参照するオブジェクトの情報を探索する。
fstat
はその情報をlstat.h
に定義されているstruct stat
構造体に格納する:
#define T_DIR 1 // ディレクトリ
#define T_FILE 2 // ファイル
#define T_DEV 3 // デバイス
struct stat {
short type; // ファイルタイプ
int dev; // ファイルシステムが格納されているディスクデバイス番号
uint ino; // inode 番号
short nlink; // ファイルに張られているリンクの数
uint size; // ファイルサイズ(バイト単位)
};
ファイル名は、ファイルそのものとは区別して取り扱われる;内部的には同一のファイルであることはinodeを使って表現され、inodeはlink
を呼ぶことにより複数の名前を持つことができる。
link
システムコールは同一のinodeに対して他のファイルシステムの名前を付ける。
次のプログラム列は、新しいファイルを作成して名前としてa
とb
を付ける。
open("a", O_CREATE|O_WRONLY);
link("a", "b");
a
に対して読み書きするのと、b
に対して読み書きすることは同一である。
どちらのinodeも、内部では同一の"inode番号"として識別される。
上記のコード列の後にfstat
を実行することにより、a
とb
が内部では同一のファイルを参照していることが分かる:
どちらのfstat
も同一のinode番号(ino)を返し、nlink
の番号が2とセットされているからである。
ulink
システムコールはファイルシステムから名前を除去する。
ファイルのinodeとその内容を保持していたディスクスペースは、ファイルのリンク番号が0になり、どこからも参照されなくなるときに始めて解放される。
従って、上記のコード列に以下を追加すると、
unlink("a")
により、b
という名前でのみアクセスできるようになる。さらに、
fd = open("/tmp/xyz", O_CREATE|O_RDWR);
unlink("/tmp/xyz");
上記のコード列は、プロセスがfdを消去するときか、終了するときにクリーンすべきである一時ファイルを除去するための慣用句である。
xv6では、ファイルシステムのためのコマンドはmkdir,ln,rm
などのようなユーザレベルのプログラムとして実装されている。
この設計では、誰でもシェルを拡張して新しいユーザコマンドを作成することができる。
後から考えてみると明らかなこのではあるのだが、当時のUnix以外のシステムでは、これらのコマンドはシェルの内部に実装されていることが多かった(そしてシェルはカーネルに内蔵されていた)。
一つの例外がcd
であり、これはシェルに内蔵されている(8516行目)。
cd
は現在のワーキングディレクトリをシェル自身が変更しなければならない。
もしcd
コマンドを通常のコマンドとして実行すると、シェルが子プロセスをfork
し、子プロセスがcd
を実行しても、cd
は「子プロセスの」ワーキングディレクトリを変更するだけで終わってしまい、親プロセス(例えば、シェルそのもの)のワーキングディレクトリは変更されない。
Unixにおいて「標準的な」ファイルディスクリプタ、パイプ、そして便利な文法を活用することで汎用的で再利用可能なプログロムを書くことができるのは大きな強みである。 スパークした「ソフトウェアツール」の全体的な文化のアイデアはUnixの力と人気によるものであり、シェルは、いわゆる最初の「スクリプト言語」であった。 UnixのシステムコールのインタフェースはBSD,Linux,Mac OS X などでも利用されている。
現代のカーネルでは、xv6よりもはるかに多くのシステムコールやカーネルサービスを提供している。 現代のUnixから派生したオペレーティングシステムの殆どは、先に議論したconsoleのようなデバイスのように、デバイスを特殊ファイルとして見せるような初期のUnixのモデルを踏襲していない。 Unixの開発者達はPlan 9をビルドし続けているため、現代の装置や、ネットワークの表現や、グラフィックスや他の資源の操作をファイルやファイルツリーの操作として実現しており、「資源はファイルである」という方針を維持している。
ファイルシステムの抽象化は強力なアイデアであり、World Wide Webのような現代の殆どのネットワーク資源にも適応されている。 それでも、オペレーティングシステムのインタフェースとして他のモデルも存在する。 MulticsのようなUnixよりも前のシステムではファイルストレージをメモリのように抽象化し、全く異なるインタフェースを作り出していた。 Multicsの設計の複雑性はUnixの設計者達にも直接影響を与え、彼等はなるべく全てをシンプルに作ろうとした。
本書はxv6をUnix系のインタフェースとして実装する方法について述べるが、アイデアと概念はUnixだけに適用されるものではない。 多くのオペレーティングシステムは内部のハードウェアの上で複数のプロセスが走っており、各プロセスが独立して動作しプロセス間の通信を行う機構が提供されている。 xv6を学んだあとは、読者はより複雑なオペレーティングシステムを学ぶことによって、xv6の内部の考え方がそれらのシステムにも同様に存在していることを見ることができるだろう。