🙆

シェル自作

2025/03/01に公開

5年ほど前に、シェルを自作しました。その時に作成した記事になります。

実装した機能としては、リダイレクト、パイプ、外部コマンド、一部の内部コマンド、バックグラウンド実行です。READMEにそれらについてもう少し詳しく載せています。
https://github.com/syamamt/mnsh

コマンドの実行

コマンドを実行するには、本体のプロセスをforkしてその子プロセスが受け取ったコマンドを実行します。

int main(void) {
    char cmd[1024];
    int status;
    pid_t cpid;

    for (;;) {
        // プロンプトの表示    
        fprintf(stderr, "$ ");
        scanf("%s", cmd);

        if ((cpid = fork()) == -1) {
            // fork error
            perror("fork");
            exit(1);
        } else if (cpid == 0) {
            // 子プロセス
            execlp(cmd, cmd, (char*)0);
            perror(cmd);
            exit(1);
        }

        // 親プロセス
        wait(&status);
    }
    exit(0);
} 

文字列分割(tokenize.c)

本実装は、スペース区切りで単語を切り分けることを前提とします。そのためトークンは、受け取った文字列をスペースで分割したものになります。

構文解析(parse.c)

構文解析は、Cコンパイラを実装する「低レイヤを知りたい人のためのCコンパイラ作成入門」を参考に作成しました。特に、再帰下降構文解析について述べられている章が参考になりました。
https://www.sigbus.info/compilerbook

このサイトを元に考えた本実装のEBFNは以下の通りです。

  • <i>expr = pipeline '&'?</i>

  • <i>pipeline = redirection ('|' redirection)*</i>

  • <i>redirection = cmd ('<' file | '>' file | '2>' file | '>>' file | '2<<' file)*</i>

  • <i>file = word</i>

  • <i>cmd = word</i>

ここにbashのBFNが載っているので参考にしました。
https://github.com/jalanb/bashrc/blob/main/src/bash/bash.bnf

子プロセスの実行(chexec.c)

パーサーが作成した構文木から、コマンドを実行します。

リダイレクト

標準入出力のファイルディスクリプタを変更します。

ここで使用するシステムコールはdup2()です。このシステムコールを利用すると、あるファイルディスクリプタを他の指定されたファイルディスクリプタに置き換えて使用することができるようになります。以下に標準入力のみをリダイレクトするソースコードを示します。

void do_redirect (Node *node) {
 int fd;
 char *fn;

 // ファイルオープン
 if ((fd = open(fn, O_RDONLY)) == -1) {
  perror("opne");
  exit(1);
 }

 // リダイレクト
 if ((dup2(fd, STDIN_FILENO)) == -1) {
  perror("dup2");
  exit(1);
 }
}

パイプ

パイプで繋がれたコマンドラインにおいて、標準出力を次の標準入力へ繋ぎます。

ここで使用するシステムコールはpipe()とリダイレクトでも使用したdup2()です。pipe()は、大きさが2のファイルディスクリプタの配列(int fd[2])を渡し、その配列にパイプの読み出し側(fd[0])と書き込み側(fd[1])を返すシステムコールです。

パイプ処理の流れを解説します。まず、pipe()で2つのファイルディスクリプタfd[2]を読み出します。次に、forkします。forkされた子プロセスは親プロセスがpipeで得たファイルディスクリプタの情報は継承されるので、この時点の子プロセスと親プロセスのfd[2]はどちらも同じ情報を指している。最後に、子プロセスでfd[1]を子プロセスの標準出力に置き換え、親プロセスではfd[0]を親プロセスの標準入力に置き換えます。こうすることで、子プロセスの標準出力が親プロセスの標準入力に繋がれました。   
見やすくするため、エラー処理を除いたパイプ処理の部分のみのソースコードを以下に示します。

void do_pipeline (Node *node) {
    int fd[2], status;
    pid_t pid;
    pipe(fd);

    if ((pid = fork()) == -1) {
        perror("fork");
        exit(1);
    } else if (pid == 0) {
        // 子プロセス
        dup2(fd[1], 1));
        close(fd[0]);
        close(fd[1]);
    } else {
        // 親プロセス
        if (wait(&status) == (pid_t)-1) {
            perror("wait");
            exit(1);
        }

        dup2(fd[0], 0));
        close(fd[0]);
        close(fd[1]);
    }
}

また、ここでは作成される構文木からのどのようにして木を見ているのかの説明を多段パイプを用いて、説明します。例として以下のコマンドラインを使用します。

  • <i>cmd1 | cmd2 | cmd3 | cmd4</i>

パーサで以下の構文木が生成され、根が渡されます。

構文木

根が渡されるということは、最初pipe(3,4)にいます。今いるノードがpipeを指していた場合、上で述べたパイプの処理において右部分木を親プロセスが実行して、左部分木を子プロセスが実行します。よって、pipe(2,3)の標準出力がcmd4の標準入力に繋がれました。次にpipe(2,3)に注目します。pipe(3,4)と同様に右部分木を親プロセスが、左部分木を子プロセスが実行します。pipe(2,3)の標準出力がcmd3の標準出力となります。しかし、pipe(2,3)の標準出力は以前にcmd4の標準入力に繋がれていたので、cmd3の標準出力とcmd4の標準入力が繋がります。pipe(1,2)でも同じように考えます。構文木を考えることで、多段パイプを扱えるようになりました。

バックグラウンド実行

ここで一番手こずった。バックグラウンド実行は、以下のようなコマンドのあとに「&」を付けることでバックグラウンドで実行できる機能です。

  • <i>sleep 10 &</i>

この機能を実装しているときに、シグナルの処理も追加しました。シェルがSIGINT(<i>^C</i>)で終了しないように、デフォルトで終了するシグナルを無視し、子プロセスでそれをデフォルトに戻します。

実行しているコマンドがある場合は、シェル本体を制御端末を離して^Cや^Zから送られるシグナルを実行されるコマンドのみに対して送られる必要があります。よって、プロセスグループを分ける必要があります。そこで使うのは関数tcsetpgrp()です。この関数をバックグラウンドプロセスグループが呼び出すと、シグナルSIGTTOUがバックグラウンドプロセスグループの全てのメンバに送られます。SIGTTOUシグナルを受け取ったプロセスはデフォルトで中断されるので、バックグラウンドプロセスグループではtcsetpgrp()を呼び出す前にSIGTTOUを無視しておかなくてはなりません。僕はこれに気づかずに悩んでいました。今回はtcsetpgrp()を呼び出す前に、バックグラウンドプロセスグループ、フォアグラウンドプロセスグループ関係なくSIGTTOUを無視するにしました。

通常のコマンド実行におけるフォアグラウンドプロセスグループ

まず、通常(バックグラウンド実行ではない)のコマンド実行についてのフォアグラウンドプロセスグループについて考えます。バックグラウンド実行に限らずどのコマンドを実行する場合においても、子プロセスがコマンドを実行する前に、親プロセス(シェル本体)と子プロセス(コマンド)のtcsetpgrp()を呼び出してプロセスグループを分けます。そうすることで、コマンドに対して<i>^C</i>(終了)や<i>^Z</i>(中断)の操作ができるようになります。子プロセスが終わったあと、親プロセスをフォアグラウンドプロセスグループにする必要があるので、それは親プロセスが子プロセスをwaitしたあとに行います。

バックグラウンド実行におけるフォアグラウンドプロセスグループ

バックグラウンド実行についてです。バックグラウンド実行について考えなければならない点は、実行が終わっていない子プロセスがある状態でいつ親プロセスをフォアグラウンドプロセスグループにするのか(今回のシェルの実装で一番悩んだ)と、バックグラウンド実行されたプロセスをいつwaitするのかについての2点です。

まず、フォアグラウンドプロセスグループの設定についてです。最初に考えた解決策は、親プロセスでtcsetpgrp()せず、子プロセスをforkし、子プロセスがtcsetpgrt()をして孫プロセスをバックグラウンドプロセスグループにし、子プロセスをexitして親プロセスのwait以降を動かします。そして、孫プロセスでコマンドを実行しようとしました。この問題点は、親プロセスが孫プロセスをwaitできないため、ジョブの終了を確認できない点です。なぜwaitできないかというと、子プロセスがforkした孫プロセスは、子プロセスが終了するとinitプロセスの養子になってしますからです。

最終的に、通常のコマンド実行と同じようにまずは親プロセスでtcsetpgrpして、子プロセスで再度tcsetpgrpで親プロセスをフォアグラウンドプロセスグループにしてコマンドを実行するようにしました。また、親プロセスではバックグラウンド実行をする際は、waitしないようにしました。

次にバックグラウンド実行されたプロセスをいつwaitするかについてです。これはプロンプトの表示前に、waitpidのオプションWHOHANGを用いて、子プロセスのどれかが状態変化を起こしたらリソースを解放するようにしました。

ジョブ制御

ここも少し手こずりました。バックグラウンド実行されているいくつかのジョブを、どのように区別すればいいのか悩みました。結局、1つのジョブの構造体を定義しリストで繋ぐことにしました。

// ジョブの状態
typedef enum {
    Runnign,
    Stopped,
} JobState;

// ジョブの単位
typedef struct Job Job;
struct Job {
    struct Job *next;           // 次のジョブ
    char cmd[MAXLINE];   // メッセージに使用
    pid_t pgid;                  // プロセスグループID
    JobState state;            // ジョブの状態
    int job_num;               // ジョブ番号
};

内部コマンド(builtin_command.c)

シェル側で実装される内部コマンドをいくつか実装しました。

exitコマンド

シェルを終了させます。もしジョブが残っていたら、終了出来ないようにしました。

cdコマンド

作業ディレクトリを変更します。chdir()システムコールを利用します。引数が無い場合は、getenv()で環境変数からホームディレクトリを受け取りホームディレクトリに作業ディレクトリを移動します。引数が1つある場合は、そのディレクトリに作業ディレクトリを移動します。2つ以上ある場合は、エラーとしました。

bgコマンド

ジョブ番号で指定されたジョブをバックグラウンド実行します。指定されたジョブ番号が存在したら、killpg()でそのジョブ番号のプロセスグループにSIGCONTを送り、実行を再開させます。

fgコマンド

ジョブ番号で指定されたジョブをフォアグラウンド実行します。指定されたジョブ番号が存在したら、そのプロセスグループをtcsetpgrp()でフォアグラウンドプロセスグループにしてkillpg()でSIGCONTを送り、実行を再開させます。このプロセスで、再開したプロセスグループをwaitpidします。

jobsコマンド

存在するジョブを表示します。これは、リストで繋がれたジョブリストをなぞるだけです。

参考リンク

シェルを作ろうとしたきっかけです。システムコールの勉強をしようと読んでみました。シェルの機能の実装の問題がいくつかあり、どうやって作るのかなんとなくイメージできました。
 これで解決できない場合は、stack overflowで調べました。シェルの実装は、多くの学校で課題にされることが多いらしく、かなり情報を得られました。

Discussion