シェルを自作する
bashが毎回やっている「入力を読んでコマンドを起動する」正体を、200行のREPLループで自分の手で再現し、fork/exec・パイプ・cdがなぜあの挙動になるのかを腹落ちで理解できる。
- シェルの本体は プロンプト表示→入力読取→分割→fork→exec→wait を繰り返すREPLループ。子を fork してそこで exec するから、コマンドが終わっても親シェルは生き残る。
- cd や exit を子プロセスにできないのは、子でカレントディレクトリを変えても親には伝播しないから。作業ディレクトリや環境はプロセスごとに独立しており、builtin は親自身が実行するしかない。
- パイプは pipe() で作った読み書き両端を、fork した各子が dup2 で標準入出力に張り替えて実現する。左の stdout と右の stdin を同じパイプに繋ぎ、使わない端は必ず閉じる。
何を作るか
作るのは、プロンプトを出して1行読み、それをコマンドとして起動し、終わったらまたプロンプトを出す——という対話ループだけの最小シェルです。ls -l や grep foo file のような外部コマンド実行、cd と exit の組み込みコマンド、| によるパイプ、> < によるリダイレクトまでを対象にします。これは bash や zsh が起動時から裏でずっと回している中核そのものです。
作ると何が分かるか。ふだん当たり前に使っている挙動の理由が、原理から説明できるようになります。なぜコマンドを実行してもシェルは終わらないのか。なぜ cd だけは外部コマンド(/usr/bin のどこか)ではあり得ず、シェル自身が処理せざるを得ないのか。a | b と書いたとき、a の出力が物理的にどう b の入力へ流れているのか。これらは fork・exec・パイプという3つのシステムコールの性質から機械的に導けます。逆に言えば、この3つを押さえればシェルの骨格は理解できたことになります。
シェル自身は ls の中身を知りません。やっているのは「新しいプロセスを作り、そこに ls というプログラムを載せ替え、終わるのを待つ」だけです。この分業——プロセスを作る fork と、プログラムを載せ替える exec が別々のシステムコールに分かれている——がUnixの設計の要で、パイプもリダイレクトもジョブ制御も、すべてこの隙間に処理を差し込むことで実現されます。
最小実装の全体像
横にスクロール
シェルの本体は無限ループ1つです。1周で「プロンプト表示 → 1行読取 → 空白で分割してトークン列に → 組み込みコマンドなら自分で処理 → そうでなければ子プロセスを作って外部コマンドを exec → 子の終了を待つ」を行い、また先頭へ戻ります。この繰り返し評価ループを対話型言語の世界では REPL(Read-Eval-Print Loop)と呼びます。
構成部品を役割で分けると次の4つに整理できます。
| 部品 | 使うシステムコール等 | 役割 |
|---|---|---|
| 読取と分割 | getline / strtok 相当 | 1行入力し、空白でトークン列 argv に切る |
| 組み込み判定 | 文字列比較 | cd/exit など親自身が処理すべきものを先に捌く |
| プロセス生成と実行 | fork / execvp / waitpid | 子を複製し、そこでコマンドに載せ替え、終了を待つ |
| 接続の張り替え | pipe / dup2 / open / close | パイプやリダイレクトのため標準入出力の向き先を変える |
擬似コードで骨格を示します。まずは1コマンドだけ(パイプ・リダイレクトなし)の版です。
while True:
print_prompt() # 例: "mysh$ "
line = read_line() # EOF(Ctrl-D)なら抜ける
argv = split(line) # 空白で分割 → ["ls", "-l"]
if not argv: # 空行は無視
continue
if is_builtin(argv[0]):
run_builtin(argv) # 親自身が実行(後述の理由で必須)
continue
pid = fork() # ここでプロセスが2つに分かれる
if pid == 0: # 子プロセス側
execvp(argv[0], argv) # 成功すれば戻らない。ここで別プログラムに化ける
perror("exec failed"); exit(127) # execが戻る = 起動失敗
else: # 親プロセス側
waitpid(pid) # 子の終了を待つ
肝は fork の直後です。fork は呼んだ瞬間にプロセスを丸ごと複製し、親には子のプロセスID、子には0を返します。だから同じ1つの if pid == 0 を、親と子が別々の枝で通っていきます。子は execvp で目的のコマンドに中身を入れ替わり、親は waitpid で待つ。exec が成功すると呼び出し元のコードには二度と戻らない(プロセスの中身がまるごと別プログラムに置き換わる)点が、次の行に exit(127) を置く理由です。exec が戻ってきたということは、起動に失敗したことを意味します。
段階を追って作る
ステップ1: 読み取りと分割
まず1行読んでトークンに切ります。ls -l /tmp なら ["ls", "-l", "/tmp"] という配列 argv にします。execvp はこの argv をそのまま受け取り、argv[0] を実行対象、配列全体を新プログラムの引数として渡します。ここで EOF(端末で Ctrl-D)を受け取ったらループを抜けるのを忘れないでください。これが無いと入力が尽きた瞬間に無限ループへ陥ります。空行は何もせず次の周回へ進めます。
ステップ2: fork と exec で外部コマンドを起動
ここが心臓部です。なぜ「fork してから exec」と2段階なのか。もし fork せずシェル自身が exec してしまうと、シェルのプロセスの中身が ls に置き換わり、ls が終わった時点でプロセスごと消滅します。つまりコマンドを1回実行しただけでシェルが死ぬ。だから必ず子を分裂させ、身代わりの子の方を exec で ls に化けさせ、本体(親)は待つ側に回るわけです。「コマンドが終わってもシェルが生き続ける」というごく当たり前の性質は、この fork による分業から出てきます。
execvp は成功時は二度と戻りません。呼び出したプロセスの仮想メモリ空間が新しいプログラムで上書きされるからです。したがって exec の直後の行は「起動に失敗した場合のみ実行される」エラー処理になります。ここを書き忘れると、コマンドが見つからないとき子プロセスがそのままループを続行し、シェルが二重に走る奇妙なバグになります。失敗時は終了コード127(command not found の慣習値)で子を終わらせます。
ステップ3: 組み込みコマンド(builtin)——なぜ子にできないか
cd dir を、他のコマンドと同じく fork した子で実行したらどうなるか。子プロセスは自分のカレントディレクトリを dir に変えて、そのまま終了します。しかしカレントディレクトリはプロセスごとに独立した属性で、子での変更は親には一切伝わりません。結果、待っていた親シェルの居場所は元のまま。つまり「cd したのにディレクトリが変わらない」ことになります。
だから cd は fork せず、親シェル自身が chdir() システムコールを呼ぶしかありません。これが builtin(組み込みコマンド)の本質です。「呼び出したシェル自身の状態を変えるコマンド」は、子に投げると変更が捨てられるので、親が直接実行する必要があります。
def run_builtin(argv):
if argv[0] == "exit":
sys.exit(0) # シェル本体を終わらせる → 子では意味を成さない
if argv[0] == "cd":
target = argv[1] if len(argv) > 1 else home_dir()
if chdir(target) != 0: # 親自身のカレントディレクトリを変更
perror("cd failed")
exit も同じ理屈です。目的は「このシェルを終了する」ことなので、子プロセスで exit しても子が1つ消えるだけでシェル本体は生き残り、無意味です。同様に、環境変数を設定する export や set、ジョブを前面に戻す fg なども、対象が「シェル自身の状態」であるため builtin であらねばなりません。どのコマンドが builtin かは、外部プログラムでは実現不能かどうかで決まります。
「なぜ cd は組み込みコマンドか」は資格試験・面接の定番です。答えは「子プロセスで chdir してもカレントディレクトリの変更が親に伝播しないから」。作業ディレクトリ・環境変数・open済みファイルなどはプロセス固有の属性で、子から親へ逆流しません。シェル自身の状態を書き換えるコマンドは、原理的に親が直接実行するほかない、と一言で言えると強いです。
ステップ4: リダイレクトとパイプ——dup2で向き先を張り替える
外部コマンドは自分が「どこから読み、どこへ書くか」を知りません。標準入力(fd 0)・標準出力(fd 1)という決まった番号のファイル記述子を使うだけです。リダイレクトとパイプは、この番号が指す先を exec の直前に差し替えることで実現します。差し替えの道具が dup2(src, dst) で、dst 番の記述子を src の複製に置き換えます。
ls > out.txt なら、子の中で out.txt を open して得た記述子を dup2(fd, 1) で fd 1(標準出力)に被せてから execvp します。すると ls は何も知らずに「標準出力へ」書いたつもりで、実体は out.txt へ流れます。この張り替えを fork 後・exec 前の子だけで行うのが重要です。親の標準出力はいじらないので、シェル本体は影響を受けません。
パイプ a | b は、pipe() で読み口と書き口の記述子ペアを1本作り、a と b をそれぞれ fork して繋ぎます。a では書き口を fd 1 に、b では読み口を fd 0 に dup2 します。
def run_pipe(left_argv, right_argv):
r, w = pipe() # r=読み口, w=書き口
if fork() == 0: # 左コマンド a
dup2(w, 1) # aのstdout → パイプの書き口
close(r); close(w) # 元の記述子は用済み。必ず閉じる
execvp(left_argv[0], left_argv)
if fork() == 0: # 右コマンド b
dup2(r, 0) # bのstdin → パイプの読み口
close(r); close(w)
execvp(right_argv[0], right_argv)
close(r); close(w) # 親も自分の分を閉じる(これを怠るとhangする)
wait(); wait() # 両方の子を待つ
close の徹底が落とし穴です。パイプの読み口は、その書き口を開いている記述子が全て閉じられて初めて「書き込み側が終わった(EOF)」と判定されます。もし親が書き口を閉じ忘れると、b は「まだ誰かが書くかもしれない」と待ち続け、コマンドが終わらなくなります。各プロセスは自分が使わない側の記述子を確実に close する——これがパイプ実装の鉄則です。
発展と本物との違い
ここまでで REPL・fork/exec・builtin・パイプ・リダイレクトという骨格は揃いましたが、実用シェルはこの上に大きく機能を積んでいます。まずシグナル処理です。実行中のコマンドを Ctrl-C で止めたいとき、本来 SIGINT はシェル本体にも届いてしまいます。本物のシェルは、フォアグラウンドの子だけをプロセスグループとしてまとめ、端末の制御をそのグループへ渡す(tcsetpgrp)ことで、Ctrl-C が親シェルではなく実行中コマンドにだけ効くようにしています。この初歩がジョブ制御で、& によるバックグラウンド実行(waitpid で待たずにプロンプトへ戻る)や fg/bg/jobs へと発展します。
環境変数も本物との差が大きい部分です。exec 時に子へ渡す環境(environ 配列)の管理、$HOME や $PATH の変数展開、PATH を順に辿って実行ファイルを探す解決(execvp が内部でやっている部分を自前で持つ場合もある)などが加わります。さらにワイルドカード展開(*.c をファイル名一覧へ)、引用符とエスケープの解釈、; && || による複数コマンドの連結、if/for などの制御構文、サブシェルとコマンド置換(`cmd` や $(cmd))と、シェルは1つのプログラミング言語処理系へ育っていきます。
実運用シェルの膨大な機能も、その大半は本記事の骨格への追加です。変数展開・ワイルドカード・引用符処理は「分割の前段でトークン列を加工する層」、&& や制御構文は「REPLの1周を複数コマンドへ拡張する層」、ジョブ制御は「waitの仕方とシグナルの配り方を精緻化する層」。まず fork/exec/dup2 の幹を自分の手で通しておくと、あとから読む bash のソースや POSIX 仕様が「どの層の話か」で整理でき、理解が一気に速くなります。
原理をさらに掘るなら、プロセス・シグナル・ファイル記述子の詳細はOS、パイプで繋いだ先がネットワーク越しになるソケットの世界はネットワークの各トピックが土台になります。ここで作った200行が、それらの抽象が実際どう積み上がっているかを読み解く足場になります。
自作で学ぶの記事ガイド
シェルを自作するを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
シェル
比較で見る軸
難易度: advanced / カテゴリ: 自作で学ぶ / タグ数: 6
導入後に効く点
cd や exit を子プロセスにできないのは、子でカレントディレクトリを変えても親には伝播しないから。作業ディレクトリや環境はプロセスごとに独立しており、builtin は親自身が実行するしかない。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- 自作で学ぶ
- タグ数
- 6
判断チェックリスト
- 自社の用途が「シェル / 自作」に近いか確認する。
- 強みである「シェルの本体は プロンプト表示→入力読取→分割→fork→exec→wait を繰り返すREPLループ。子を fork してそこで exec するから、コマンドが終わっても親シェルは生き残る。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。