My First Unix Shell: C言語で小さなシェルを書いて、UNIXのプロセスを体験する | UNIX Cafe

* 当サイトでは、コンテンツの一部に広告を掲載しています。

System Note $ cat /proc/ai-disclosure

本記事の構成および論理分析にはAI(人工知能)を使用しています。情報の正確性は、システム管理者(UNIXユーザー)による手動検証済みです。

My First Unix Shell: C言語で小さなシェルを書いて、UNIXのプロセスを体験する | UNIX Cafe

C言語で自作シェルに挑戦!コマンド実行の裏側を学んでみた。

今回は、C言語で小さな Unix シェルを作ってみたので紹介します。

普段何気なく使っているターミナルやシェルですが、「裏側では一体どんな動きをしているんだろう?」と疑問があったので、実際に作ってみることにしました。

開発にあたっては、Stephen Brennan 氏の有名なチュートリアル「Write a Shell in C」を参考にさせていただきました。 記事のコード(main.c)を自分で写経しながら、シェルがコマンドを受け取って実行するまでの内部の仕組みを確かめました。

この記事では、実際に写経して学んだシェルの基本プロセスや、面白いと感じたポイントをまとめていきます。

題材は、わずか300行足らずで書かれたC言語製のミニマルなShell『main.c』です。半日ほど手を動かして写経したコードと、自分なりの詳細な学習メモは、現在GitHubにて公開しています。

GitHubではmy-first-unix-shellという名前で公開しています。

Repository:https://github.com/k1117n-cmyk/my-first-unix-shell

Static Badge Static Badge Static Badge

▼ 参考記事:

今回作ったものは、決して本格的なシェルではありません。

  • パイプやリダイレクトの機能はありません
  • クォート(引用符)を解釈することもできません

機能としては非常にシンプルですが、それでも、実際に自分でコードを書いて動かしたシェルから、lscat、そして vi が無事に起動したときは、かなりの感動がありました。

目次

作ったもの(実装した機能)

実装したのは、最小構成の Unix シェルです。

【できること】

  • cdhelpexit組み込みコマンドを実行する
  • lscatvi などの外部コマンドを実行する
  • 入力された文字列を空白で分割し、コマンドと引数として扱う
  • 子プロセスを作り、外部プログラムを実行する

【できないこと】

  • ls | grep main のようなパイプ
  • echo hello > out.txt のようなリダイレクト
  • echo "hello world" のようなクォート解釈
  • sleep 10 & のようなバックグラウンド実行
  • $HOME のような環境変数展開

つまり、普段使っている bashzsh のような高機能なシェルではありません。
シェルのいちばん中心にある「心臓部」だけを取り出した学習用プログラムです。

🔄シェルの基本は Read -> Parse -> Execute

写経してみて驚いたのは、シェルの基本構造がとても素直だったことです。

  1. Read(1行読む)
  2. Parse(コマンド名と引数に分ける)
  3. Execute(プログラムを実行する)

基本はこの3つのステップを繰り返すだけです。

💻 コードの全体的な流れ

実際のコード(関数)は、だいたい次のような階層構造になっています。

main()
  └─ lsh_loop()               // exitが入力されるまでループ
      ├─ lsh_read_line()      // Read: 1行読み込む
      ├─ lsh_split_line()     // Parse: スペースで文字列を分割
      └─ lsh_execute()        // Execute: コマンドを判別して実行
          ├─ ビルトインコマンド ── lsh_cd() / lsh_help() / lsh_exit()
          └─ 外部コマンド ── lsh_launch()
               ├─ fork()      // 分身して子プロセスを作る
               ├─ execvp()    // 子プロセスでプログラムを起動
               └─ waitpid()   // 親プロセスで子の終了を待つ

lsh_loop() が画面にプロンプトを表示し、入力を読み、分割し、実行します。

exit が入力されるまで、このループが続きます。

📥 入力を読む(lsh_read_line)

まずは最初のステップ、標準入力から1行を読み込む処理です。

普段スクリプト言語を書いていると意識しにくい部分ですが、C言語では「ユーザーが何文字入力してくるか」が事前に分かりません。
そのため、次のような手順でメモリをやりくりする必要があります。

char *buffer = malloc(sizeof(char) * bufsize);
  1. 最初に小さめのバッファ(メモリ空間)を確保する。
  2. getchar() で1文字ずつ読み込む
  3. もしバッファが足りなくなったら、realloc() でメモリを拡張する
  4. 改行(Enter)まで来たら、最後に終端文字 '\0' を入れて文字列を完成させる

たった「1行読む」というシンプルな処理だけでも、C言語ではメモリの動的確保、終端文字の処理、EOF(入力の終わり)の判定、エラー処理のすべてを自分の手で泥臭くコントロールする必要があります。

✂️ 入力を分割する(lsh_split_line)

次のステップは、読み取った1行を空白(スペースやタブ)で分割する処理です。

例えば、ユーザーが次のように入力したとします。

ls -l /tmp

これを、プログラムが扱いやすいようにC言語の配列へと変換します。

args[0] = "ls";
args[1] = "-l";
args[2] = "/tmp";
args[3] = NULL;  // ここがポイント!

配列の最後を NULL にしているのには理由があります。
後でプログラムを起動するときに使う execvp() という関数が、「配列の終わりには必ず NULL を入れてね」というルールになっているからです。

文字列の分割には、C言語の標準関数である strtok() を使いました。

⚠️ 空白分割ゆえの限界

ただし、今回の実装は「空白を見つけたら切る」という非常に単純な仕組みです。そのため、クォート(引用符)を解釈することができません。

例えば、普段使っている zsh などの本物のシェルで次のように打つと、クォートが綺麗に消えて中身だけが表示されます。

# 本物のzshの場合
% echo "Hello World"
Hello World

しかし、今回の自作シェルの中で同じように入力すると、クォートを解釈せずに "HelloWorld" という2つの引数に強制分割してしまいます。
その結果、本物の echo にそのまま丸投げされるため、画面にはクォートがついたまま表示されてしまいます。

# 今回の自作シェルの場合
> echo "Hello World"
"Hello World"

普段、何気なく使っているシェルが、スペースを含んだ文字列を「これは1つの塊だよ」と正しく認識してくれる裏側には、高度な構文解析(パース)の仕組みがあるんだなと、身をもって実感しました。

🔄 ビルトインコマンドと外部コマンドの決定的な違い

今回シェルを写経していて、特に面白かったのがビルトインコマンド「外部コマンド」の違いです。

今回の実装では、次のように区別しています。

  • ビルトインコマンド: cdhelpexit
  • 外部コマンド: lscatvi など

外部コマンドは、このあと解説する「子プロセスを作り、その中身を別のプログラムに置き換える」という方法で実行できます。

しかし、cdexit はその方法(外部コマンド)ではうまく動きません。実際にコードを書いてみると、その理由が良くs理解できました。

📁 なぜ cd はビルトインコマンドなのか?

「カレントディレクトリ(現在いるフォルダ)」という情報は、プロセスごとに独立して持っている状態です。

もし cd を外部コマンドと同じように「新しく作った子プロセス」の中で実行してしまうと、子プロセスの場所が変わるだけで、親プロセスであるシェル本体の場所は一歩も動かないということになってしまいます。

だからこそ cd は、シェル自身が直接システムコールを呼び出して、自分の場所を動かす必要があります。

if (chdir(args[1]) != 0) {
    perror("lsh");
}

🚪 exit も理由は同じ

exit(シェルの終了)も全く同じ理由です。
外部プログラムとして exit を起動しても、新しく作った子プロセスがその場で終了するだけで、シェル本体はピンピンしたまま残ってしまいます。

シェルを終わらせるには、シェル自身の実行ループ(lsh_loop)を外から止めるしかありません。

今までターミナルを使っていて「ビルトインコマンド」という言葉自体は知っていましたが、「なぜビルトインコマンドでなければならないのか」をプロセスの仕組みとして肌で理解できたのは、自作シェルならではの大きな収穫でした。

🎭 外部コマンドは fork ➔ execvp ➔ waitpid で動く

外部コマンドを実行する中心部分は、次のような流れるような連携で動いています。

  1. fork() で子プロセスを作る(分身の術)
  2. 子プロセス側で execvp() を呼ぶ(変身の術)
  3. 親プロセス(シェル)が waitpid() で子プロセスの終了を待つ

ひとつずつ、コードと一緒に裏側の動きを見てみましょう。

1. fork() で自分のコピーを作る

fork() は、現在動いているプロセスを丸ごと複製するシステムコールです。
面白いのは、戻り値によって「今動いているのが親(シェル)なのか、生まれた子なのか」を判定する点です。s

pid = fork();
if (pid == 0) {
    /* 子プロセス(ここが変身する) */
} else if (pid < 0) {
    /* エラー処理 */
} else {
    /* 親プロセス(ここで待つ) */
}

2. execvp() で別プログラムに変身する

分身した子プロセスの中では、execvp() を呼び出します。

execvp(args[0], args);

execvp() は、今のプロセスの中身を、まったく別のプログラムにそっくり入れ替える(上書きする)という豪快な関数です。

例えば ls と入力すれば子プロセスが ls に置き換わり、vi と入力すれば子プロセスがその場で vi に生まれ変わります。

3. waitpid() で親が待機する

子プロセスが別のプログラムに変身して働いている間、親プロセス(シェル本体)は waitpid() でじっと待ちます。

waitpid(pid, &status, WUNTRACED);

子プロセス(lsvi など)が終了すると、親プロセスが再び動き出し、おなじみのプロンプト(> )を表示して次の入力を待つ状態に戻ります。

この一連の流れがコードとして繋がったとき、シェルが「コマンドを実行する」という言葉の裏で、一体どんなドラマが起きているのかがかなり具体的に見えてきました。

🎬 自作シェルから vi が起動した瞬間

実際に完成したプログラムを動かしてみると、次のように外部コマンドがしっかりと機能しました。

> ls
main               main.c             my_first_shell.txt
> cat my_first_shell.txt
Hello my own first shell!

自分で作ったシェルから lscat が動いた時点でもかなり嬉しかったのですが、一番驚き、そして感動したのは自作シェルから vi が起動したことです。

自分が書いた、たった数百行の小さなプログラムの中から、普段使っているフル機能のテキストエディタが一瞬で起動する 。

仕組みとしては裏側で fork() して execvp() を呼んでいるだけですが、実際に自分の目の前でパッと動いたときのインパクトは、想像を遥かに超えるものでした。

これは、単に文字列を処理するプログラムを書いたというよりも、自分が書いたコードを通して「OSにプロセス生成を正しく依頼し、歴史あるUnixシステムの『一つのことをうまくやり、互いに連携する』という設計思想に、体験として触れることができた」という、1人のUnixユーザーとしての手応えを感じた瞬間でした。

🧐 できないことが分かるのも面白い

今回のシェルでは、パイプやリダイレクトは使えません。

ls | grep main
cat my_first_shell.txt > out.txt

これらは、本物のシェルなら当たり前のように動くおなじみのコマンドです。

しかし、今回の実装では |> を特別扱いしていません。ただの「引数の文字列」としてプログラムにそのまま渡されてしまうため、エラーになるか、意図しない動きになります。

ここで、普段のシェルがどれだけ多くの仕事を裏でこなしているのかが、はっきりと見えてきます。

  • 単にコマンドを起動するだけではない
  • 入力と出力を裏でつなぎ替えている(リダイレクト)
  • 複数のプロセスを数珠つなぎに接続している(パイプ)
  • 複雑な構文を瞬時に解釈している

最小構成の「心臓部」だけを自分の手で作ったことで、毎日お世話になっている本物のシェルの凄さとありがたさが分かりました。

🏁 まとめ

今回の写経で一番大きかった学びは、Unixにおける「ビルトインコマンド」と「外部コマンド」の違いを、実際のコードと動作で体験できたことです。

  • cdexit シェル自身の状態を変える必要があるので、ビルトインコマンドとして実装する
  • lscatvi 子プロセスを作って execvp() でプログラムを置き換えて実行する

知識として知っていたことが、実際のコードを通じて「こういうことか!」と、腑に落ちました。

小さな最小構成の実装ではありますが、自分で書いたシェルからおなじみの外部コマンドが起動する体験は、本当に新鮮で面白いものでした。

シェルの自作は、Unixのプロセスモデルを低レイヤから理解するための、最高に楽しい題材でした。もし興味が湧いた方は、ぜひStephen Brennan氏のチュートリアルを写経してみてください!

C言語の次にUnityやUnreal Engineを学ぶ選択肢

用途ごとに選ぶ C言語のおすすめ本

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

のいのアバター のい UNIX Cafe マスター

Macintosh Color Classicから始まった旅は、長いWindows時代を経て、Windows10のサポート終了をきっかけにUNIXの世界へ戻ってきました。UNIX Cafeでは、UNIX・Linux・そしてMacな世界を、むずかしい言葉を使わず、物語のように書いています。プログラミングは、アイデアをコンピューターに伝えるための言葉です。簡単な単語と文法を覚えれば、誰でもコマンドを使えます。ぜひ一度、やさしいプログラミングの世界をのぞいてみてください。

目次