🐌

自作bash shellを作っていくう🐚

2023/12/27に公開

はじめに

なんと、C言語のみで、bash shellを実装します。
大変骨の折れる作業ですが、順を追って完成させました。

全体を超大雑把に分けると以下のようなコンポーネントに分解できます.

  • 入力処理(readline)
  • 字句解析、構造分析(Lexer & Parser)
  • 命令実行(CommandExecution)

1. bashとは何か、、、?

Bash はシェルの種類のひとつでありshをパワーアップしたシェル。 GNU オペレーティングシステムで使われているシェルであり、一般的には Linuxカーネル上で実装されている。また、Mac OS X などその他の主要 OS 上でも動く。過去の歴史上のバージョンである sh に対して、対話的な操作においてもプログラミング機能においても改良が施されている。
名前の由来は Bourne-Again SHell の頭文字をとったもので、Stephen Bourne(現在の Unixシェルの先祖である/bin/shの作者。

2. Shellとは何か、、、?

ユーザーからの指示を受けて解釈し、 プログラムの起動や制御などを行うプログラムのことを指します。このような機能はどのOSにもありますが、UNIX系OSのシェルの特徴はカーネルから完全に独立している点にあります。人間がOSのカーネル(核)を直接いじって致命的なエラーを出さないように、貝の殻のように『OSを包み込んで守る』という役割からシェルと呼ばれている。

3. 入力処理 ~readline~

readlineは、プロンプトとして prompt を使用し、端末から一行を読み込み、その値を返します。 promptがNULLである場合、プロンプトは出力されません。 返された行には malloc(3) を使ってメモリが割り当てられているので、 呼び出し側は終了時にメモリを解放しなければなりません。 返された行では末尾の改行は削除されており、 行のうちのテキストのみが残ります。

readline は GNU Readline ライブラリの一部であり、ユーザーからの入力をインタラクティブに読み取るための関数です。この関数は主にコマンドラインインターフェイスやシェル環境で使用され、ユーザーが入力した文字列を取得し、編集機能や履歴管理などを提供します。

  • コード内での readline の使用
    コードの main 関数内で、ms_readline 関数を使って、ユーザーの入力を待機し取得しています。ms_readline は内部で readline 関数を呼び出し、ユーザーがプロンプトに入力したコマンド行を取得します。
line = readline(PROMPT_MINISH);

ここで、PROMPT_MINISH はプロンプトに表示される文字列で、ユーザーに入力を促します(例:minishell $ )。ユーザーがコマンドを入力してエンターキーを押すと、その入力された行が line に格納されます。

  • Readline ライブラリの特徴
    • コマンドライン編集: ユーザーはカーソル移動、文字の挿入・削除など、コマンドライン上でテキストを編集できます。
    • 履歴管理: 入力されたコマンドは履歴に保存され、ユーザーは以前のコマンドに簡単にアクセスできます(例:add_history(line);)。
    • 自動補完: ユーザーがコマンドの一部をタイプしたとき、関連するコマンドやファイル名を自動的に提案する機能を追加できます。
    • カスタマイズ可能: プロンプトの見た目や動作は、設定によりカスタマイズ可能です。
  • シェルプログラムにおける役割
    readline 関数はシェルプログラムにおいて重要な役割を果たします。ユーザーが入力したコマンド行を取得し、シェルが解析(レキシング、パーシング)して実行する流れの最初のステップです。また、ユーザーフレンドリーなインターフェースを提供することで、より快適なコマンドライン操作を実現します。

4. Exec Path

/bin/pwdや /bin/ls などが実行できるようにする。

minishell$ /bin/pwd
/Users/hhhhhh/Desktop/42/minishell_v
minishell$ /bin/echo

minishell$ /bin/ls
LICENSE		README.md	include		minishell	test.sh
Makefile	src

パースされたコマンドは、内蔵コマンド(ビルトインコマンド)または外部プログラムとして実行されます。外部プログラムの実行には通常、フォーク(fork)とエグゼク(exec)システムコールが使われます。
fork で子プロセスを作成し、子プロセス内で execve を使用して外部コマンドを実行し、親プロセスは wait を使用して子プロセスの終了を待機します。

fork

  • 役割: 現在のプロセス(親プロセス)の複製(子プロセス)を作成。
  • 使い方: pid_t pid = fork();
  • fork() 関数は、子プロセスが作成された場合、親プロセスには子プロセスのPID(プロセスID)を返し、子プロセスには0を返します。エラーが発生した場合は、-1を返します。

execve

  • 役割: 現在のプロセスのイメージを新しいプログラムで置き換える。つまり、現在のプロセス内で新しいプログラムを実行します。
  • 使い方: int status = execve(const char *path, char *const argv[], char *const envp[]);
    path は実行するプログラムのパス(例:/bin/ls)。
    argv はプログラムに渡す引数の配列。
    envp は新しいプログラムに渡す環境変数の配列。
  • execve は成功した場合には戻りません。エラーが発生した場合にのみ戻り値が返されます(-1)。

wait

  • 役割: 子プロセスの終了を親プロセスが待機するために使用されます。
  • 使い方: pid_t pid = wait(int *status);
  • wait() 関数は、子プロセスが終了するのを待ち、そのプロセスの終了ステータスを status に格納します。
    終了した子プロセスのPIDを返します。子プロセスがない場合やエラーが発生した場合は、-1を返します。

5. Exec Filename

  • シェルは環境変数(例えば PATH, HOME など)を管理し、これらの変数をコマンドの実行やパスの解決に使用します。 環境変数の取得にはgetenv、実行可能かどうかを確認するためにaccessを使用する。
minishell$ pwd
/Users/shunusami/Desktop/42/minishell_v2
minishell$ echo

minishell$ ls
LICENSE		README.md	include		minishell	test.sh
Makefile	src

6. コマンドライン解析 ~Lexing(字句解析)~

  • Lexing: ユーザーからの入力(コマンドライン)をトークンに分割するプロセスです。このステップでは、入力文字列を単語、オペレータ(例えば |>)、引用符で囲まれた文字列などのトークンに分解します。

    レキシングの段階では、このコードを個別のトークンに分割します。このプロセスをレキサーが行います。

    • 入力: x = 10 + 5
    • 出力 (トークンのリスト):
    • 変数名: x
    • 等号: =
    • 数値: 10
    • 加算演算子: +
    • 数値: 5
      このトークンのリストは、コード内の個々の要素を意味的に区別しています。例えば、10+ はそれぞれ数値と演算子として認識されます。

7. コマンドライン解析 ~Parsing(構文解析)~

  • パーシング: レキシングで生成されたトークンを解析し、コマンドの意味を理解するプロセスです。例えば、パイプ | によってコマンドを連結したり、リダイレクト > で出力先を変更するなどの処理が含まれます。

    次に、パーシングの段階で、このトークンのリストを取り、それらが形成する構造を構築します。このプロセスをパーサーが行います。

    • 入力: 上記のトークンのリスト
    • 出力 (抽象構文木 - AST):
    • 代入式のノード:
      • 左辺: 変数 x
      • 右辺: 加算式のノード
        • 左辺: 数値 10
        • 右辺: 数値 5
        • 演算子: +

8. Redirection

OS とのインターフェイスとしてのシェルの役割のひとつを反映しているのが、起動したコマンドの入出力に対するリダイレクト機能である。リダイレクトは、現在のシェル実行環境 または任意のコマンドのファイルを開いたり閉じたりするために使用される。リダイレクト演算子は、ファイル記述子を表す数値とともに使用できる。

# 入力
command < file   # ファイルの内容をコマンドの標準入力に渡す

#-----------------------------------------------------------
# 出力
command >&2      # 標準出力を標準エラー出力にリダイレクト

command > file   # ファイル作成 or 上書き
command >> file  # 追加出力。ファイルがなければ作成
command 2> file  # 標準エラー出力をファイルにリダイレクト(作成 or 上書き)

command &> file      # 標準出力/エラー出力を同一ファイルにリダイレクト
command > file 2>&1  # 同上
command &>> file     # 標準出力/エラー出力を同一ファイルに追加書き込み
command >> file 2>&1 # 同上

command > file1 2> file2   # 標準出力,エラー出力を別々のファイルにリダイレクト
command >> file1 2>> file2 # 標準出力,エラー出力を別々のファイルに追加書き込み

字句解析器からパーサを通してコマンド実行まで情報を渡していくとき、、、
解析器が変数代入を含むリダイレクトとして単語を識別し、パーサは構文の構築時にリダイレクトオブジェクトを作る。このオブジェクトには代入を要するという意味のフラグを立てる。そして、リダイレクトのコードがそのフラグを読み取り、ファイルディスクリプタの番号を正しい変数に代入する。

9. PIPE

パイプラインは、制御演算子「|」で区切られた 1 つ以上のコマンドのシーケンス。
コマンドの標準出力を次のコマンドの標準入力に接続する。パイプのを次のコマンドの標準入力として使用する。

minishell$ ls | grep .c
minishell$ cat Makefile | grep minishell | sort 

シェルがコマンドを実行する際には、ビルトイン以外のコマンドに関してはコマンド一つにつき一つのプロセスを立ち上げます。つまり、 cat Makefile | grep minishell | sortというコマンドを実行するときには、3つの新しいプロセスが立ち上がり、その中でそれぞれのcat, grep, sortのコマンドが実行されることになります。そしてbashのコマンドパイプラインで最も重要なのが、これら3つのプロセスのコミュニケーションです。catの出力はgrepの入力となり、grepの出力はsortの入力となります。(そしてもちろん、端末への入力がcatへの入力となり、sortの出力が端末への出力となります。)このプロセス間通信を実現するために役に立つのが pipe システムコールです。

10. SIGNAL

Ctrl-C や Ctrl-D を押すことによってシグナルを送ることができます。または、ターミナルを別タブで開いて kill -SIGINT や kill -SIGQUIT kill -SIGTERM などを試すことができます。

シグナルが送られるタイミングによって、いくつか挙動が異なることに気づくかも。
代表的なものとしては下記のようなもの。

  • コマンド入力の受付中(入力文字列がない時)
  • コマンド入力の受付中(入力文字列がある時)
  • here documentの受付中(入力文字列がない時)
  • here documentの受付中(入力文字列がある時)
  • here documentの受付中(すでに何行か入力がある時)
  • コマンドを実行中の時( ./inf_loop
  • 複数のコマンドを実行中の時 (./inf_loop | ./inf_loop)など
  • 複数のhere documentを実行中の時( cat <<EOF1 <<EOF2 )など
  • 複数のコマンドが標準入力をblockingしている時( cat | cat | ls)など

12. Builtin関数

  • ビルトイン関数もいくつか実装

  • echo

  • cd

  • pwd

  • export

  • unset

  • env

  • exit

bash には シェル変数 と 環境変数 があります。シェル変数はそのシェルの中だけで使用できる変数、環境変数は子プロセスにも引き継がれる変数です。環境変数として定義された値はシェル変数としても参照できます。

set			# シェル変数を一覧表示する
FOO=xxx			# シェル変数を設定する
echo $FOO		# シェル変数を参照する
unset FOO		# シェル変数をクリアする

env			# 環境変数を一覧表示する
export BAR=xxx		# 環境変数を設定する
echo $BAR		# 環境変数を参照する
unset BAR		# 環境変数をクリアする

主な環境変数には下記などがあります。

PATH	コマンドの検索パス(例:/usr/local/sbin:/usr/local/bin...)
HOME	ホームディレクトリ(例:/home/noda)
LANG	言語情報(例:en_US.UTF-8)
PWD	カレントディレクトリ(例:/home/noda/tmp)
_	前回実行したコマンドの最後の引数

13. 「'」 と 「"」の違い

シングルクオーテーションは文字列をそのまま扱い、変数展開やエスケープシーケンスを無視する。ダブルクオーテーションは変数展開やエスケープシーケンスを認識し、それに基づいた処理を行う。これにより、シェルスクリプト内で文字列をどのように扱いたいかに応じて、適切なクオーテーションを選択することができる。

  • シングルクオーテーション(')
    リテラル: シングルクオーテーション内の内容はリテラル(そのままの文字列)として扱われます。つまり、変数の展開やエスケープシーケンス(例: \n での改行)は行われません。
    使用例:
    'Hello USER' は Hello $USER として扱われ、USER は変数として展開ない。
  • ダブルクオーテーション(")
    変数展開: ダブルクオーテーション内の変数は展開されます。つまり、変数に格納されている値に置き換えられます。
    エスケープシーケンスの使用: 特定のエスケープシーケンス(例: \n、\t など)は、意図した特別な文字(改行、タブなど)として解釈される。
    使用例:
    "Hello $USER" は、もし $USER の値が Alice ならば Hello Alice として扱われます。
  • 比較例
    シングルクオーテーションの使用: 'My name is $NAME'
    出力: My name is NAME(NAME は文字列としてそのまま表示される)
    ダブルクオーテーションの使用: "My name is $NAME"
    出力: My name is Alice($NAME が Alice に展開される)

14. 作ってみた感想

今回のminishell制作において、たくさんの要素から学びを得られた。他人との共同開発、設計、スケジュール、処理方法、エラー処理、、、。当たり前だが一口にshellを実装すると言っても、そこにはさまざまな要素が介在している。c言語でbashを作るというのは、まさに非常に古典的なアプリを作ることと捉えられる のではないだろうか。必要なのは、技術力も然り、忍耐力と継続力。どれだけの熱量で続けられるかがパフォーマンスに顕著に現れるので、そこの自己管理は極めて重要であると考える。今回の実装を通して、若干なりともレベルアップできたと思う。
このような一つのプログラムを作るときに、はじめ方がとても大事だと思った。初めに、全体の完成図とそこに行くまでの道のりを立てる。目的地をどこに設定するかを決める。要素を洗い出して全部書き出す。役割分担して、それぞれが目的地に向かって走り出す。随時出てくるエラーには、優先順位をつけて対処する。
SEというのは、常に全体が見て現状把握できていなければならない。もっともっと、技術力を磨いてスピード自走できるエンジニアになりたいと思った。

参考資料

Discussion