Programming Languages

シェルを自作するとUnixの本質が見えてくる

開発者なら誰でも毎日シェルを使っている。でも、シェルが実際に何をしているかを理解している人は少ない。シェルはアプリケーションのように見える——コマンドを入力すれば実行してくれる——が、実態はOSの根幹をなすUnixプリミティブの薄いラッパーにすぎない。シェルをゼロから自作することは、プロセス、ファイルディスクリプタ、パイプ、シグナルを理解する最良の方法のひとつだ。これらの概念は、WebサーバーからDockerコンテナまで、あらゆるものの基盤となっている。

基本的なシェルは驚くほどシンプルだ。コアのループは、入力を1行読み取り、コマンドと引数にパースし、子プロセスをforkし、子プロセスでコマンドを実行し、終了を待つ。Cで書けばたった50行程度。複雑になるのは、普段当たり前に使っている機能——パイプ、リダイレクト、バックグラウンドプロセス、シグナル処理、ジョブ制御——を実装するときだ。

Read-Eval-Printループ

シェルの本質はREPLだ。入力を読み取り、評価(実行)し、結果を出力し、繰り返す。「出力」の部分はコマンド自身が担当する——シェルはコマンドが実行される環境を提供するだけだ。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
// The simplest possible shell
int main(void) {
char line[1024];
while (1) {
printf("$ ");
if (!fgets(line, sizeof(line), stdin))
break;  // EOF (Ctrl+D)
// Remove trailing newline
line[strcspn(line, "\n")] = '\0';
// Fork a child process
pid_t pid = fork();
if (pid == 0) {
// Child: execute the command
execlp(line, line, NULL);  // This only handles single-word commands
perror("exec");
exit(1);
}
// Parent: wait for child to finish
waitpid(pid, NULL, 0);
}
return 0;
}

この25行のシェルは実際に動作する。lspwddateといったコマンドを実行できる。引数、パイプ、リダイレクトなど、期待するような機能は一切ない。だが、根本的なパターンを示している:fork、exec、waitだ。

ForkとExec:Unixのプロセスモデル

fork/execの分離はUnix最大の特徴的な設計判断であり、シェルを自作するとその存在理由が腑に落ちる。

fork()は現在のプロセスの完全なコピーを作る。子プロセスは同じメモリ、同じオープンファイル、同じ環境変数を持つ。exec()は子プロセスのプログラムを新しいものに置き換える。これらが別の操作になっているのは、その間——forkの後、execの前——がシェルが子プロセスの環境をセットアップする場所だからだ。

これが核心的な洞察だ。ls > output.txtと入力すると、シェルはforkし、子プロセス内で(execの前に)output.txtを開いてstdoutをそこにリダイレクトし、それからlsをexecする。lsプログラムはリダイレクトのことを知らない——いつも通りstdoutに書き込むだけで、forkとexecの間で行われたファイルディスクリプタの操作が出力をファイルに導く。

// How 'ls > output.txt' works
pid_t pid = fork();
if (pid == 0) {
// Child process — between fork and exec
// Open the output file
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
// Redirect stdout (fd 1) to the file
dup2(fd, STDOUT_FILENO);  // Now fd 1 points to output.txt
close(fd);                 // Close the original fd (no longer needed)
// exec ls — it writes to stdout, which now goes to output.txt
execlp("ls", "ls", NULL);
perror("exec");
exit(1);
}
waitpid(pid, NULL, 0);

この設計が美しいのは、合成可能な点だ。子プロセスはexecの前にどんな環境でもセットアップできる——ファイルのリダイレクト、ディレクトリの変更、環境変数の修正、リソース制限の設定、ユーザーIDの変更——そして実行されるプログラムはそのいずれも知る必要なく、その環境を引き継ぐ。すべてのコマンドは事前設定された環境を受け取り、シェルがその設定を行う役割を担う。

パイプ:プロセスをつなぐ

パイプはUnixで最も強力な合成メカニズムであり、実装してみるとその仕組みがいかにシンプルかが分かる。

pipe()システムコールは一対のファイルディスクリプタを生成する。一方が読み取り用、もう一方が書き込み用だ。書き込み側に書かれたデータは読み取り側に現れる。ls | grep fooを実装するには、シェルがパイプを作成し、2回forkし、lsのstdoutを書き込み側に、grepのstdinを読み取り側に接続する。

// How 'ls | grep foo' works
int pipefd[2];
pipe(pipefd);  // pipefd[0] = read end, pipefd[1] = write end
pid_t pid1 = fork();
if (pid1 == 0) {
// First child: ls
close(pipefd[0]);              // Don't need read end
dup2(pipefd[1], STDOUT_FILENO); // stdout → pipe write end
close(pipefd[1]);
execlp("ls", "ls", NULL);
exit(1);
}
pid_t pid2 = fork();
if (pid2 == 0) {
// Second child: grep
close(pipefd[1]);              // Don't need write end
dup2(pipefd[0], STDIN_FILENO);  // stdin → pipe read end
close(pipefd[0]);
execlp("grep", "grep", "foo", NULL);
exit(1);
}
// Parent: close both pipe ends and wait
close(pipefd[0]);
close(pipefd[1]);
waitpid(pid1, NULL, 0);
waitpid(pid2, NULL, 0);

未使用のファイルディスクリプタの端を注意深く閉じている点に注目してほしい。これは極めて重要だ。親プロセスがパイプの両端を閉じなければ、grepはstdinでEOFを受け取れない(書き込み側が親プロセスでまだ開いているため)。結果、永遠にハングする。パイプチェーンにおけるファイルディスクリプタのリークは、シェル自作時に最も多いバグのひとつだ。

組み込みコマンド

一部のコマンドは外部プログラムとして実装できない。cdが典型例だ。シェルが子プロセスをforkし、子プロセスがchdir()を呼んでも、変わるのは子プロセスの作業ディレクトリだけ——親の作業ディレクトリは影響を受けず、子プロセスが終了した後もシェルは同じディレクトリにいる。cdを機能させるには、forkせずにシェル自身のプロセス内で実行する必要がある。

他の組み込みコマンドには、export(シェルの環境変数を変更)、exit(シェルプロセスを終了)、source(現在のシェルコンテキストでスクリプトを実行)がある。これらはすべてシェル自身の状態を変更するもので、シェル自身のプロセス内でしか実行できない。

どのコマンドが組み込みで、なぜそうなのかを理解すると、プロセス分離について根本的なことが分かる。子プロセスは親プロセスを変更できない。これはセキュリティ機能であり、信頼性の機能であり、ときに不便でもある——だが、Unixプロセスの動作原理の核心だ。

シグナルとジョブ制御

ターミナルでCtrl+Cを押すと実行中のコマンドが停止する。単純に見えるが、実際にはターミナル、シェル、シグナル、プロセスグループの間の驚くほど複雑なやり取りが関わっている。

Ctrl+CはフォアグラウンドプロセスグループにSIGINTを送る。これを処理するのはターミナルドライバであり、シェルではない。シェルの役割は、各コマンドを独自のプロセスグループに配置して、SIGINTがシェルではなくコマンドに届くようにすることだ。プロセスグループの設定を正しく行わないと、Ctrl+Cが実行中のコマンドではなくシェル自体を殺してしまう。

ジョブ制御——バックグラウンドプロセス(&)、fgbg、Ctrl+Z——はさらに別のレイヤーを追加する。シェルはどのプロセスがどのジョブに属するかを追跡し、フォアグラウンドとバックグラウンドのグループを管理し、SIGTSTP(Ctrl+Z、サスペンド)やSIGCHLD(子プロセス終了)などのシグナルを処理する必要がある。これを正しく実装するのが、シェル自作で最も難しい部分だ。

シェル自作で学べること

シェルを自作すると、システムコードを二度と書かないとしても、ソフトウェア開発で常に出てくる概念が身につく。

  • ファイルディスクリプタは万能のインターフェース。ファイル、パイプ、ソケット、ターミナル——すべてファイルディスクリプタだ。リダイレクト、パイプ、ネットワーク通信はすべて同じ基盤メカニズムを使っている。これを理解すれば、DockerのネットワーキングからUnixドメインソケット、Linux システムAPIまで、あらゆるものが明快になる。
  • プロセス分離は基本原則。子プロセスは親プロセスを変更できない。環境変数、作業ディレクトリ、オープンファイルは継承されたコピーであり、共有参照ではない。だからサブシェルでのcdは親に影響せず、Dockerコンテナはホストの環境を継承するが共有はしない。
  • 合成は機能に勝る。Unixには「パターンに一致するファイルを見つけて数える」コマンドはない。findgrepwcがあり、パイプでつなぐ。シェルのパイプ機構がシンプルなツールを複雑なワークフローに組み合わせることを可能にする。この設計哲学——標準インターフェースで接続された小さなツール群——は、マイクロサービス、Unixソケット、API設計の知的先祖だ。
  • エラー処理の本質はファイルディスクリプタ。パイプが壊れたとき、子プロセスがクラッシュしたとき、stdinが尽きたとき——根底にあるメカニズムは常にファイルディスクリプタのクローズ、シグナルの配信、またはプロセスのステータスコード付き終了だ。ファイルディスクリプタモデルを理解すれば、Unixシステム全体のエラー処理パターンが直感的に分かるようになる。

どこから始めるか

シェルを自作したいなら、上の25行バージョンから始めて、段階的に機能を追加しよう。まず引数のパース(入力をスペースで分割)。次にI/Oリダイレクト(><)。それからパイプ。その後に組み込みコマンド(cdexit)。そして環境変数。各機能が新しいUnixの概念を教えてくれるし、それぞれが独立した演習になっている。

システムコールとの対応が最も直接的なCを使おう。PythonやRustでもシェルは作れるが、C実装なら背後のシステムコールが明示的に見える——fork()exec()dup2()pipe()が何をしているかが、直接呼び出しているからこそ分かる。

本番用シェルを作る必要はない。基本的なコマンド、パイプ、リダイレクトを処理するだけのおもちゃシェルでも、何年もシェルを使い続けるより多くのことをUnixについて教えてくれる。シェルは最も重要なOSインターフェースを行使する最もシンプルなプログラムだ——そしてそれらのインターフェースを理解することは、どの言語やプラットフォームで仕事をしていようと、より優れた開発者になる助けとなる。