🐚

[C言語 shellの再実装] minishell振り返り 後編

に公開

はじめに

前回の続きです。

学んだこと

5.リダイレクトの実行

リダイレクトとは例えば以下のようなコマンドです。

$ echo "Hello" > test.txt
$ cat test.txt
Hello

普通、echoコマンドは標準出力に出力しますが、例では'>'で指定した'test.txt'というファイルに出力をして(書き込んだ)、その後'cat'コマンドでファイルの中身を出力しています。
リダイレクトとはこのように「入出力の示す先を変えること」です。
これがどのようにして実装されるのか以下にその基本を示します。

まずはリダイレクトなしのコードとその実行結果です。

#include <stdio.h>
#include <unistd.h>

int main(void)
{
    printf("Hello\n");

    return 0;
}
$ cc redirect.c
$ ./a.out
Hello

printfで標準出力に文字列を出力しています。

ここからリダイレクトを実装しますが、大事なこととしては「出力先をファイルにする」のではなく、「標準出力の示す先をファイルにする」 ということです。つまり、標準出力に出力したら結果的にファイルに出力されていた、という感覚です。
これがなぜかというと、先ほどの例でいうとあくまでも'echo'コマンドが"標準出力に出力するコマンド"というのは変わらないからです。
リダイレクトはコマンドを変化させるのではなく、出力先の示す先を変化させるものです。
では、その実装と実行結果を以下に示します。

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main(void)
{
    int file_fd = open("test.txt", O_CREAT | O_WRONLY | O_TRUNC, 0644);

    dup2(file_fd, STDOUT_FILENO);

    printf("Hello\n");

    return 0;
}
$ cc redirect.c
$ ./a.out
$ cat test.txt
Hello

printfを使用しているので、先ほど述べたように出力先をファイルにしているわけではありません。
printfは標準出力に出力する関数なので、標準出力の示す先がファイルのfdになっているわけです。
そのために使用しているのが'dup2'関数です。manualには以下のように説明されています。

SYNOPSIS
     int
     dup2(int fildes, int fildes2);

DESCRIPTION
     dup() duplicates an existing object descriptor and returns its value to the calling process
     (fildes2 = dup(fildes)).  The argument fildes is a small non-negative integer index in the
     per-process descriptor table.  The value must be less than the size of the table, which is
     returned by getdtablesize(2).  The new descriptor returned by the call is the lowest
     numbered descriptor currently not in use by the process.

     In dup2(), the value of the new descriptor fildes2 is specified.  If fildes and fildes2 are
     equal, then dup2() just returns fildes2; no other changes are made to the existing
     descriptor.  Otherwise, if descriptor fildes2 is already in use, it is first deallocated as
     if a close(2) call had been done first.

簡単にいうと、第二引数のfdに対してcloseが実行され、第一引数のfdが第二引数に対してコピーされるということです。
これは、ファイルディスクリプタの整数がコピーされるのではなく、その示す先がコピーされるということです。

(間違い)
STDOUT_FILENO=1->標準出力, file_fd=3->test.txt
↓dup2使用
STDOUT_FILENO=3->test.txt, file_fd=3->test.txt

(正しい)
STDOUT_FILENO=1->標準出力, file_fd=3->test.txt
↓dup2使用
STDOUT_FILENO=1->test.txt, file_fd=3->test.txt

これを確認するためにそれぞれの値を出力してみます。

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main(void)
{
    int file_fd = open("test.txt", O_CREAT | O_WRONLY | O_TRUNC, 0644);

    printf("%d, %d\n", STDOUT_FILENO, file_fd);
    dup2(file_fd, STDOUT_FILENO);
    printf("%d, %d\n", STDOUT_FILENO, file_fd);

    printf("Hello\n");

    return 0;
}
$ cc redirect.c
$ ./a.out
1, 3
$ cat test.txt
1, 3
Hello

6.コマンド実行

bashにおけるコマンドは主に3種類に分類されます。

シェル関数

ユーザー定義するもので以下のようなものです。

$ greet() {
  echo "Hello, $1"
}
$ greet "World"
Hello, World

ただ、minishellでは実装しないのでこれ以上の説明はしません。

組み込み関数

bash自体に組み込まれている、実装されているコマンドで、
実際にminishellのソースコードに処理が書かれているということです。
以下では今回組み込みとして実装したコマンド一覧です。

  1. cd
  2. echo
  3. env
  4. exit
  5. export
  6. pwd
  7. unset
    この中で少しトリッキーなのは'cd'コマンドなので、少しだけ解説するとchdir()というシステムコールを使用します。これは現在のプロセスのカレントディレクトリを変更する関数です。
    前提として基本的にプロセスはそのプロセスが実行されたディレクトリで実行されます。chdirはそのプロセス自身の実行されているディレクトリを変更するということです。

実際にchdirを使用してみる(余裕ある人向け)

順序が逆になってしまいますが、execve関数を使用して実際にchdir関数を試してみます。
まずは以下のようなディレクトリ構成にします。

$ tree
TEST
├── TEST2
│   ├── hello
│   └── hello.c
├── a.out
├── chdir_test.c
├── hello
└── hello.c

TESTディレクトリにhello.cという「"I am in TEST!"という文字列を出力するプログラム」とその実行ファイル(hello)、TEST2ディレクトリに「"I am in TEST2!"という文字列を出力するプログラム」とその実行ファイル(hello)を用意します。
ここでポイントなのが異なる結果を出す、同じ名前の実行ファイルを用意していることです。なのでもちろん以下のような結果になります。

/TEST $ ./hello
I am in TEST!
/TEST $ cd ./TEST2
/TEST/TEST2 $ ./hello
I am in TEST2!

ディレクトリを変更すれば同じ"./hello"というコマンドで異なる結果が確認できました。
では、これをCのプログラムで再現してみます。
まずはchdirを使用しないパターンです。

#include <unistd.h>

int main()
{
    char *path = "./hello";
    char *args[] = {path, NULL};
    char *envp[] = {NULL};

    execve(path, args, envp);
     
    return 0;
}
/TEST $ cc chdir_test.c
/TEST $ ./a.out
I am in TEST!

上記の結果から、/TESTディレクトリのhello実行ファイルが実行されたことがわかります。
では、chdirを使用してみます。

#include <unistd.h>

int main()
{
    char *path = "./hello";
    char *args[] = {path, NULL};
    char *envp[] = {NULL};

    chdir("./TEST2");

    execve(path, args, envp);
     
    return 0;
}
/TEST $ cc chdir_test.c
/TEST $ ./a.out
I am in TEST2!

a.out自体は/TESTで実行したのに、helloは/TEST/TEST2のものを使用できました。

外部コマンド

そのminishellのコード自体に処理は書かれていないが、minishell実行しているシェル環境のどこかのディレクトリに実行ファイルが存在して、それを探して実行します。
例えば、'ls'というコマンドは私の環境では、/binディレクトリに存在します。ただそれは私たちユーザーはわかっていても、bashからすると無数に存在するディレクトリの中からlsがどこにあるのか探し出すのはとても大変なことです。そこで、受け取った外部コマンドがどこに存在するのか探し方のルールがあります。それは環境変数PATHの値の順番に探していくことです。逆にいえばPATHの値の中にないディレクトリに実行ファイルを置いても、bashは探し出すことができません。

そして、見つけた外部の実行ファイルをminishell内で実行するためにfork()とexecve()を使用します。
fork関数は以下の順序でプロセスを複製します。一般的に複製元と複製先のプロセスをそれぞれ、親プロセス、子プロセスといいます。

  1. 親プロセスがfork()を呼び出す
  2. 子プロセス用のメモリ領域を確保して、親プロセスのメモリをコピーする
  3. 親プロセスと子プロセスが両方fork()から復帰する
    このように同じプロセスが複製されます。その上で、親プロセスと子プロセスのfork()の違いを利用して、子プロセスのみが外部コマンドを実行することで、minishellを実行したまま外部コマンドを実行できます。

その子プロセスで外部コマンドを実行する方法としてexecve()を利用します。execve関数は以下の順序でプロセスとして置き換わります。

  1. execve()を呼び出す
  2. その引数からそのプログラムを読み出してメモリ上に配置するために必要な情報を得る
  3. 現在のプロセスのメモリを新しいプロセスのデータで上書きする
  4. 上書きしたプロセスを実行する。
    つまり、注意すべきなのがexecve()はshellのようにプロセスを実行して元のプロセスに復帰する関数ではなく、自分自体がそのプロセスに置き換わるので復帰どうこうの話はありません。

7.終了ステータスを収集する

実行したコマンドの終了ステータスを収集します。ここに関しては特別書くことは正直あまりないです。マニュアルのルールに従って収集します。ただ、特別なことはありませんがminishellにおいてかなり厄介な部分です。
bashには様々なパターン(テストケース)が存在して例外に感じるようなものもあるので、細かな再現をしようとして果てしなく時間がかかる部分です。

実装する上での工夫

ここまで大きなプログラムを作成するのは初めてだったので色々コード以外にも学ぶことが多い課題でした。
その中でも特に印象に残っているのはテストの作成です。
それまでもテストを作成することはありましたが、初めてテストのありがたみを身に染みて感じた課題でした。minishellは思わぬミスで思わぬ箇所におかしな挙動が生まれることがあったので、初めになるべくshellとしての最低限の挙動とそれに対するテストを作成して、それからはshellとして壊れることのないように開発していくことで効率よく大きなミスなく開発できたと思います。

おわりに

前後編通してまだ書けていないことがあるぐらい大きな課題でした。終えて一つの自信になったと思います。単純にbash自体の勉強にもなりました。

Discussion