🦁

完全シェル理解用:最小シェル ush で構造を理解しよう!(UmuOS向け)

に公開

お疲れ様です。
自作OSで UmuOS-0.1.4-base-stable が先日できたんだけど、最小機能OSとしてユーザーランドに BusyBox を使っています。
で、BusyBox の sh を使っている仕様なんだけど……

「シェルって結局なにをしているの?」をちゃんと理解したくて、ush(ウッシュ) という最小シェルを作ってみました。
コード解析すれば シェルの何たるか? が分かるように、意図的にシンプルにしてあります。

通常のコンパイラは gcc を使うと思うんだけど、今回は 環境依存の懸念 から musl-gcc静的リンク(単一バイナリ、動的リンクなし)にしています。
これにより、ターゲット側のライブラリ事情で詰まりにくく、学習用途として扱いやすくなります。

ぜひ中身を見ていただき、学習用として使ってもらえたらうれしいです。

別でUmuOSという構造理解用自作OS上で、動くシェルの位置づけですが、静的ビルドだから、開発環境のUbuntu24.04LTSでも動きました!

リンク


この記事の対象

  • 「シェルを自作してみたい」けど、いきなりPOSIX互換はしんどい人
  • fork() / execve() / waitpid() のつながりを体感したい人
  • BusyBox の世界で「自分のシェル」を差し込みたい人

逆に、最初から以下を求めている人には向いてないです。実用シェルというよりも構造理解に使う感じです!
ただ、現在バージョン0.0.1ですが、育てていきますので、下記の機能は今後対応していく予定になります!

  • クォート、変数展開、パイプ、リダイレクト、ジョブ制御、履歴、補完…(このへんはMVPではやってない)

ush ってなに?(/bin/sh とは役割が別)

UmuOS では /bin/sh(BusyBox sh)を残したまま、対話専用の /bin/ush を追加する方針です。

  • /bin/sh:サーバーソフト・スクリプト実行用(BusyBox)
  • /bin/ush:ユーザー向け対話シェル(今回作るやつ)

「POSIX互換シェルを置き換える」のではなく、役割分担にしてるのがポイントです。


「pwd とかは BusyBox のコマンドだよね?」問題

結論:その通りです。

ush は 最小のシェルなので、cd/exit/help 以外は builtin を持ちません。
だから pwdecho を打つと、ush はそれを 外部コマンドとして execve() で起動します。

UmuOS の /bin/pwd が BusyBox の applet(またはBusyBoxへのリンク)なら、当然 BusyBox の pwd が動きます。
ush はそこを「作り込む」より、まず 実行機構(fork/exec/wait)を理解するための教材として割り切っています。

※いわゆる login shell を置き換える目的ではなく、「仕組み理解のためのシェル」です。

基本検索パスが普通 PATH=/bin:/usr/bin:/sbin:/usr/sbin とかだとおもいますが、今後 BusyBox は残しつつも、
たとえば PATH=/umu_bin:/bin:/usr/bin:/sbin:/usr/sbin みたいに PATH=/umu_bin: を先頭に追加して検索順位を上げて、
/umu_bin に自作コマンドを追加していけば、徐々に自作コマンドへ移行できると判断しています。


ush-0.0.1 の仕様(MVP要点)

仕様書・設計書はこの3つです。

ここでは記事として読みやすい形に、要点だけまとめます。

入力とプロンプト

  • 1行入力(getline()
  • EOF(Ctrl-D)で終了(終了コードは last_status
  • 空行/空白だけ/コメントだけは何もしない
  • プロンプト:UmuOS:ush:<cwd>$getcwd()で取得)

トークナイズ(超シンプル)

  • 区切りはスペース/タブのみ
  • 連続空白はまとめてスキップ
  • 行頭コメント:最初の非空白文字が # ならその行は無視

未対応構文は「検出してエラー」

中途半端に解釈して事故るくらいなら、**「それっぽい文字が出たら実行しない」**に倒します。

検出対象(1文字でも見つけたらエラー):

  • クォート:' "
  • バックスラッシュ:\\
  • 変数展開:$
  • グロブ:* ? [ ]
  • 演算子:| < > ; &

実行(外部コマンド)

  • fork() → 子で execve() → 親で waitpid()
  • コマンドに / が含まれるなら PATH探索しない(そのまま execve
  • / が含まれないなら $PATH: 区切りで探索(未設定なら /bin:/sbin
  • execve()ENOEXEC のときだけ /bin/sh にフォールバック

終了コード(last_status)

  • 外部コマンド:通常終了は WEXITSTATUS、シグナル終了は 128 + signal
  • builtin:成功0、失敗1、引数不正などは2
  • EOF(Ctrl-D)終了時:exit(last_status)

SIGINT(Ctrl-C)

  • 親(ush)は SIGINT を無視(落ちない)
  • 子は SIGINT をデフォルトに戻してから execve(子だけ止まる)

制限値

  • 1行 8192
  • 引数最大 128
  • 1トークン最大 1024

ソース構成(14ファイル)

ヘッダ8個 + 実装6個で、合計14ファイルです。

ush/
  include/
    ush.h
    ush_limits.h
    ush_err.h
    ush_parser.h
    ush_exec.h
    ush_builtins.h
    ush_env.h
    ush_utils.h
  src/
    main.c
    parser.c
    exec.c
    builtins.c
    env.c
    utils.c

「1ファイルで全部」でも動くけど、学習しやすいように 責務で分けています


ビルド手順(開発ホスト)

このMVPは、開発ホストで musl-gcc により静的リンクでビルドして、UmuOSに持ち込む想定です。
※テストでは、Ubuntu上でも動きました。

cd ush-0.0.1/ush

musl-gcc -static -O2 -Wall -Wextra \
  -Iinclude \
  -o ush \
  src/main.c src/parser.c src/exec.c src/builtins.c src/env.c src/utils.c

成果物:ush-0.0.1/ush/ush

UmuOSに入れるときは /bin/ush に配置します。


実行してみる

開発ホストなら、ビルドしたディレクトリでそのまま起動できます。

./ush

試しにこのへんを打つと「おお、これがシェルか」ってなります。

help
pwd
cd ..
pwd
echo hello

未対応構文テスト(わざとエラーにする):

echo $HOME

$ は未対応なので ush: unsupported syntax が出て、その行は実行されないのが正解です。

SIGINT(Ctrl-C)テスト:

sleep 10

ここで Ctrl-C を押すと sleep は止まるけど、ush 自体は落ちずにプロンプトへ戻ります。


コードの読みどころ(ここを追うと“シェル”が分かる)

1) main(src/main.c): シェルの背骨

やってることは大きくこれだけです。

  1. プロンプト表示(getcwd()
  2. 1行読む(getline()
  3. 空行/コメント行を捨てる
  4. トークナイズして argv を作る
  5. builtin なら親プロセスで実行
  6. 外部コマンドなら fork/exec/wait

しかも状態は ush_state_tlast_status だけ。
ここが「小さいけど、ちゃんとシェル」になっているポイントです。

2) parser(src/parser.c): まずは空白区切りだけ

ush_tokenize_inplace() がキモです。

  • 行バッファを破壊的に加工して(空白を \0 にして)
  • argv[] はその行バッファ内へのポインタを並べるだけ

つまり「余計なmallocをしない」ので、構造が追いやすいです。

そしてもう一つキモなのが 未対応記号検出
クォートや $ を見つけた瞬間に PARSE_UNSUPPORTED にして、その行を実行しません。

3) exec(src/exec.c): PATH探索と fork/exec/wait

ここが一番 “シェルっぽい” 部分。

  • / を含むコマンドはそのまま execve()
  • そうでない場合は $PATH: で分割して、dir/cmd を順に試す
  • EACCES を一度でも踏んだら「見つかったけど実行不可」扱いで 126
  • 最後までダメなら 127

あと地味に重要なのが ENOEXEC フォールバック。

  • execve()ENOEXEC を返した時だけ /bin/sh を呼ぶ
  • それ以外のエラーでは呼ばない

このルールを入れておくと、スクリプトファイルっぽいものを叩いた時に“それっぽく”動きます。

4) builtins(src/builtins.c): cd/exit/help

  • cd は親でやらないと意味がない(子で chdir() しても親に戻らない)
  • exitlast_status を使う(MVPのルール)
  • help は「できること/できないこと」を明示する

この3つだけでも、「シェルを作った感」が出ます。

5) utils/env(src/utils.c / src/env.c): 地味だけど大事

  • 空行判定、コメント判定
  • エラー出力の形(ush: 接頭辞)
  • PATH未設定時のデフォルト(/bin:/sbin

“学習用”のコードにするなら、このへんを丁寧にしておくと読みやすさが跳ね上がります。


よくある「おっ?」ポイント

「なんで終了コードが2なの?」

パースエラー(未対応構文、引数多すぎ、入力長すぎ)は last_status=2 にしています。
で、Ctrl-D(EOF)で抜けると exit(last_status) するので、最後にエラーを踏んでいると 2 で終わります。

「外部コマンドがBusyBoxなのは?」

UmuOS実装で、メインとして BusyBox を利用しています。なので、BusyBox の外部コマンドがほとんどです。
ush は「実行する側」であって、「コマンド本体」はOS側が提供します。
しかし。前述しましたが、基本検索パスが普通 PATH=/bin:/usr/bin:/sbin:/usr/sbin とかだとおもいますが、
PATH=/umu_bin:/bin:/usr/bin:/sbin:/usr/sbin みたいに PATH=/umu_bin: を先頭に追加して検索順位を上げて
/umu_bin に自作コマンド追加でイケると判断しています。
徐々に自作コマンドに移行するようなソフトランディングを採用です!


今後やりたいこと(MVP以降)

  • パイプ |
  • リダイレクト < > >>
  • 変数展開 $VAR
  • クォート対応
  • 履歴、補完
  • (やるかは未定だけど)ジョブ制御

苦労したこと

  • やはりAIにやりたいことを構造的に説明して、設計上の曖昧な表現を排除することが重要です
  • AIにお任せしちゃうと意図しない実装になる
  • 要件・仕様設計・基本設計・詳細設計を矛盾なく作成すること

※ここからは個人的な所感です。

上記3点が、ある程度できるとコーディングはAIが、かなりの割合でこなしてくれます(Vibe Coding)
ただし、コードレビューは人間がやることを強くお勧めします

最後に。
ush は「多機能で便利」じゃなくて「読めば分かる」を優先したシェルです。
自作OSのユーザーランドで、まず fork/exec/wait をしっかり理解する教材として、気軽に遊んでもらえたらうれしいです。

Discussion