シェルの再実装しましたというお話。

何を作ったか

bashの機能(制限あり)を備えたプログラムを作りました。
言語 : C
実行ファイル名 : minishell render1632878809870

github.com

できること

  • パイプライン、クォート、リダイレクト、ヒアドキュメント、環境変数
minishell$ cat << EOF |  more
minishell$ echo "current path is $PWD" > file
minishell$ echo '$USER (not expanded)' >> file
minishell$ 0<file cat 1>file
minishell$ ./put_something_to_fd42 42>file
  • ビルトイン関数もいくつか実装
    echo , cd , pwd , export , unset , env , exit
  • シグナルハンドル
    ctrl-C ctrl-D ctrl-\
  • 終了ステータス
    echo $?で最後のコマンドの終了ステータスを参照できます。

開発の流れ

42tokyoの学生rsudoさんと以下のツールを使って開発しました。

  • GitHub
    issueを立てコードを作成・修正して、プルリクエストを投げたら相方にレビューしてもらいました。
  • notion
    共有ワークスペースを使って意見・情報・進捗共有に使いました。

初めは以下の資料を見る、bashを触る、とりあえずコードを書いてみるを繰り返しました。
Bash Reference Manual
オープンソースアプリケーションのアーキテクチャ
ある程度把握できたところで、データ構造を決定しコードを書いていきました。

プログラムの流れ

f:id:rakiyama0229:20211005001822p:plain
プログラムの流れとデータ構造
構文解析などはrsudoさんの記事が参考になるかと思います。テストをする上でのTipsも載っています。

細かな実装の話

ビルトイン以外のコマンド(catなど)実行に使うexecve関数・fork関数や、
入出力処理(レダイレクションやパイプラインの処理)で使うdup系関数・pipe関数の使い方はパイプを実装してみたというお話 - rakiyama0229のブログで書いています。

親プロセスで処理するか、子プロセスで処理するか

  • ビルトイン以外のコマンドはサブシェル(子プロセス)で処理
    cat file
    execve()を使用して実行->成功すると実行プロセスが終了するので子プロセスで処理します。
  • コマンド行にパイプが存在するなら、パイプ区切りでそれぞれ子プロセスで処理
    echo aaa | cat -e | cat -n
    shellが行っているようにマルチプロセスで実行するためです。
    コード例:
while(コマンドの数だけ)
{
    pipe(pipefd);
    pid = fork();
    if (pid == 0)
        /* 前のpipefd-(入力)-> コマンド実行 -(出力)-> 新しいpipefd */ ​
}
while(プロセスの数だけ)
    wait(NULl);
  • 単一のビルトインコマンドはminishellのプロセス(親プロセス)で処理
    export ENV=val cd .. echo rakiyama > file などなど
    exportやcdなど結果を親プロセスに反映したいためです。

ヒアドキュメント

コマンド行にヒアドキュメントがあれば、(リダイレクトやコマンド実行処理に入る前に)全てのヒアドキュメントに対する入力内容を受け取ります。
cat << EOF | cat << EOF | cat << EOF f:id:rakiyama0229:20211005103010p:plain
ヒアドキュメントの内容はPIPE(pipe()で生成するPIPE)に書き込み、その後のリダイレクト処理ではPIPEのFD(ファイルディスクリプタ)を参照するようにします。
子プロセスで入力・書き込み処理をした理由は、
ヒアドキュメントを受け取っている最中のシグナルハンドラは親プロセスと違ったのでシグナルハンドラを変えた別のプロセスで処理するようにしたかったからです。

リダイレクション

dup2()を使い、FDが指定されていれば(3>file)そのFDで複製、指定がなければ(>file)デフォルト値(0か1)で複製します。

out_fd = open("file name",  O_WRONLY | O_CREAT | O_TRUNC, 0666);
dup2(out_fd, FD);
  • 親でリダイレクト処理する際の注意点
    親でリダイレクト処理をする=標準入出力FDを書き換えると、その後入出力先が変わってしまうので
    次の入力を受け取るまでに標準入出力FDをデフォルト値に戻す必要があります。
    dup()でFD=0~2をバックアップしておき、一連の処理が終了したら、バックアップFD(標準入出力を指してる)を使って0~2を元の標準入出力に戻します。
    一連の処理中に指定されたFD(3>fileの3)とバックアップFDが被ることが判明すれば、再度バックアップFDを取り直す必要があります。
  • その他注意点
    • 子プロセスでFDを閉じても親で閉じていなかったら開きっぱなしになっていたりなどもあります。
      自分はlsof -p "プロセスID"で開いたまま放置のFDがないか確認してました。
    • 開けるFDの上限を超える場合のエラー処理について、上限をどこに設定するか問題(ulimitで変更すればかなり大きい数字になる)があるので、システムコール(dupなど)の吐くエラーを拾って判断するようにしました。

テスト

minishellの挙動を見る際に、以下の二つのスクリプトを別ターミナルで回していました。

  • リーク検出(nopさんのコードを参考にしました)
    psコマンドでminishellプロセスを見つけ出して、1秒毎にリークを監視します。
    リークが検出されれば何かしら表示されるはずです。
#!/bin/bash
echo ------------ps--------------
ps | tee ps_current_process.txt | sed '/grep/d' | GREP_COLOR='0;35' egrep --color=auto '.*minishel.*|$'
echo ------------ps--------------
pid=`cat ps_current_process.txt | grep minishell | awk '{print $1}'`
echo -e "\033[35mPID->${pid}\033[m"
rm ps_current_process.txt
while [ 1 ]
do
leaks $pid 1>/dev/null
if [ $? -ne 0 ]; then
    leaks $pid
fi
sleep 1
done
  • ファイルディスクリプタの開きっぱなし検出
    1秒毎にminishellプロセスが開いているファイルを列挙するので、ファイルディスクリプタの開閉状態が確認できます。
#!/bin/bash
echo ------------ps--------------
ps | tee ps_current_process.txt | sed '/grep/d' | GREP_COLOR='0;35' egrep --color=auto '.*minishel.*|$'
echo ------------ps--------------
pid=`cat ps_current_process.txt | grep minishell | awk '{print $1}'`
echo -e "\033[35mPID->${pid}\033[m"
sleep 5
rm ps_current_process.txt
while [ 1 ]
do
lsof -p $pid
sleep 1
done

まとめ

  • 学べたこと

  • 反省点
    チーム開発では、初期段階からお互いが何をしていてどこまで進んでいるのかを把握することが完成への近道ということ・またその意見交換の難しさを体感して学べました。
    特に開発初期段階はとにかく手を動かしてみないとわからないことが多かったので、ついつい意見交換より個人的プログラミングに集中してしまって、作業が一旦落ち着いてから意見交換を挟むと膨大な量を説明することになり、相手を疲れさせてしまいました。
    どれだけ個人の作業が途中段階でも、一定の期間でお互いを把握することの方が大切だと思いました。

初めはこのプロジェクトが終わる気がしませんでしたが、チームで話し合うと少しずつ前に進み面白かったです。
最後まで一緒に取り組めたrsudoさんには感謝です。

その他参考文献

チーム開発におけるプルリクの作法 - Qiita
各関数のman