読者です 読者をやめる 読者になる 読者になる

自分を攻略していく記録

自分がやりたいことを達成するには何をすればいいのか、その攻略していく過程をつらつらと

C言語の学習にちょうど良いものを見つけた(Tutorial - Write a Shell in C)

f:id:ngo275:20170331224641j:plain

Tutorial - Write a Shell in Cを読んだ

Cの勉強にと思ってTutorial - Write a Shell in Cを手を動かしながら読み進めてみた。以下のような簡単なbashのようなシェルを作ってみようというもの。非常にわかりやすく書かれており、C言語の勉強にはもってこいだと感じた。(僕はC言語にはあまり触れたことがない。)

f:id:ngo275:20170331225229g:plain

シェルのライフサイクル

  • Initialize 設定ファイルの読み込み等。

  • Interpret ユーザー入力やファイル(stdin)を読み込んで実行。

  • Terminate メモリを解放して終了。

この流れが基本的なライフサイクルだが、今回作るシェルはbashのようなものなので、実行のたびに設定やシェルの終了がないことに注意する。プログラムの概形は以下のようになる。

int main(int argc, char *argv[]) {
    // もしあれば設定を読み込む.

    // loopするコマンドの実行. あとで実装
    lsh_loop();

    // シェルの終了
    return EXIT_SUCCESS;
}

シェルの簡単なループ

まずコマンドを扱う基本的な流れは以下のとおり。

  • Read 入力からコマンドを読み取る。

  • Parse コマンドの文字列をプログラムや引数として解釈する。

  • Execute 解釈したコマンドを実行する。

loopのコマンドの概形は以下のとおり。

void lsh_loop(void) {
    char *line;
    char **args;
    int status;

    do {
        printf("> ");
        line = lsh_read_line();
        args = lsh_split_line(line);
        status = lsh_execute(args);

        free(line);
        free(args);
    } while (status);
}

int main(int argc, char **argv) {
////

プロンプトの入力を読み取って、関数や引数に分割して、実行、そしてメモリ等を解放している。処理を終えるべきタイミングを lsh_execute で管理している。

入力の読み込み(Read the line)

ユーザがどれだけの文字の量を入力してくるのかわからないので、まずある程度の量を見積もっておき、それを超えたら再割り当てするようにする(Cではこれが定石らしい)。lsh_read_line は以下のようになる。

#define LSH_RL_BUFSIZE 1024
char *lsh_read_line(void) {
    int bufsize = LSH_RL_BUFSIZE;
    int position = 0;
    char *buffer = malloc(sizeof(char) * bufsize);
    int c;

    if (!buffer) {
        fprintf(stderr, "lsh: allocation error\n");
        exit(EXIT_FAILURE);
    }

    while (1) {
        c = getchar();

        // 終了まできたらnullにして終わり
        if (c == EOF || c == '\n') {
            buffer[position] = '\0';
            return buffer;
        } else {
            buffer[position] = c;
        }
        position++;

        // もしbufferを超えた時
        if (position >= bufsize) {
            bufsize += LSH_RL_BUFSIZE;
            buffer = realloc(buffer, bufsize);
            if (!buffer) {
                fprintf(stderr, "lsh: allocation error\n");
                exit(EXIT_FAILURE);
            }
        }
    }
}

void lsh_loop(void) {
////

最初の部分は宣言ばかりで、while以下がキモになる。while(1) はひたすら回り続けて、getchar をしているが、これはintであることに気をつける。EOFはcharではなくintだからだ。改行もしくは文字の終端がきたら終了する。それ以外はcharを連結してstringを作る。次にはじめに準備したbufferのサイズを超えていないか、になる。もし超えていたらreallocateする。

結構長めのlsh_read_lineを実装したのだが、実は少し新しいCのライブラリにはこの機能に相当する getline という関数があった。それを使うと以下のように簡単に短縮される。

#define LSH_RL_BUFSIZE 1024
char *lsh_read_line(void) {
    char *line = NULL;
    ssize_t bufsize = 0;
    getline(&line, &bufsize, stdin);
    
    return line;
}

読み込んだものを解釈(Parse the line)

今さっき読み込んだものの引数を解釈していく。ここでは簡単のために、引数の解釈にはスペースを利用する。つまり echo "this message" というコマンドがあったら、 "thismessage" のように分割された引数を持つこととなる。このような条件下ではstrtokトークナイズすれば良い。 lsh_split_line は以下のようになる。

#define LSH_TOK_BUFSIZE 64
#define LSH_TOK_DELIM " \t\r\n\a"
char **lsh_split_line(char *line) {
    int bufsize = LSH_TOK_BUFSIZE, position = 0;
    char **tokens = malloc(bufsize * sizeof(char*));
    char *token;

    if (!tokens) {
        fprintf(stderr, "lsh:allocation error\n");
        exit(EXIT_FAILURE);
    }

    token = strtok(line, LSH_TOK_DELIM);
    while (token != NULL) {
        tokens[position] = token;
        position++;
        
        if (position >= bufsize) {
            bufsize += LSH_TOK_BUFSIZE;
            tokens = realloc(tokens, bufsize * sizeof(char*));
            if (!tokens) {
                fprintf(stderr, "lsh: allocation error\n");
                exit(EXIT_FAILURE);
            }
        }
        token = strtok(NULL, LSH_TOK_DELIM);
    }
    tokens[position] = NULL;
    return tokens;
}

#define LSH_RL_BUFSIZE 1024
char *lsh_read_line(void) {
////

lsh_read_line と同じロジックであるが、先ほどはnullで終わるcharの配列を扱っていたのに対して、今回はnullで終わるポインターの配列を扱っている。はじめのstrtokポインターをはじめのトークンに与えている。またそれ以降の strtok は入力で与えられた文字列に対応するポインターたちを返し、それぞれのトークンの末尾に\0 bytesをつけている。そして文字のポインターの配列のポインターを格納している。言っててわからなくなってきた。最後に、全部の strtok が終わるとnullでトークンの配列作成が終わる。

シェルの開始

シェルの開始がもっともシェルの大切な部分になる。プロセスをUNIX上で動かすには2つしか方法がない。まず1つ目は、初期化(Init)だ。UNIXのコンピューターが起動する時、カーネルがロードされる。一度カーネルがロードされ初期化されると、カーネルはそのたった1つのプロセスのみが動き(それを初期化と言うのだが)、コンピューターが動いている間はそのプロセスはずっと動き続ける。

しかしながら、多くのプログラムはこのように初期化されないためもう一つの方法が出てくる。フォークである。OSがプロセスを複製するのだ(オリジナルが親、コピーが子になる)。fork() は子プロセスに0を返し、子プロセスは親プロセスに自身のプロセスID(PID)を返す。新しいプロセスを開始するには既存のプロセスから複製することが大切なのだ。

ところが、ここで問題がある。欲しいのは既存のプロセスの複製ではなく、違うプロセスなのだ。そこで出てくるのが exec() だ。現在のプロセスを全く異なるプロセスに書き換えることができる。まずフォークすることで2つのプロセスができる。そして、子プロセスは exec を利用していて新しいプロセスに書き換わる。親プロセスはこれをしながらも他のことを行い続けることが可能で、子プロセスとの関連は持ち続ける。

起動のコードはこんな感じになる。

int lsh_launch(char **args) {
    pid_t pid, wpid;
    int status;

    pid = fork();
    if (pid == 0) {
        // 子プロセス
        if (execvp(args[0], args) == -1) {
            perror("lsh");
        }
        exit(EXIT_FAILURE);
    } else if (pid < 0) {
        // フォークでエラー
        perror("lsh");
    } else {
        // 親プロセス
        do {
            wpid = waitpid(pid, &status, WUNTRACED);
        } while (!WIFEXITED(status) && !WIFSIGNALED(status));
    }
    
    return 1;
}

#define LSH_TOK_BUFSIZE 64
#define LSH_TOK_DELIM " \t\r\n\a"
char **lsh_split_line(char *line) {
////

プロセスをまずフォークするが、そのpidが0の時は子プロセスに相当する。この時に、ユーザーからの入力を受けてコマンドを実行したいので、 execvp を利用している(execの仲間)。execvpv はstringの配列(ベクトル: vector)を引数にしているためで、 p はプログラムが実行する時にファイルのフルパスを与えるのではなく、その名前を与えてOSに探してもらうことを表す。

もし exec が-1を返す時はエラーがあったことになる。perror はシステムエラーをプログラムの名前とともにプリントする。エラーがどこからきているか検証できる。

pid < 0 はフォークでエラーがあった時である。

if文の最後のブロックはフォークが成功し、親プロセスの時の処理が対応する。子プロセスの実行完了を親プロセスは待ってあげねばならない。waitpid はプロセスの状態が変わるまで待機する。ただ、この関数には多くのオプションがある(exec と同様に)。プロセスは正常に終了することもあれば、エラーや強制終了で終わることがあって多くのことを想定しないといけない。そこでここでは、waitpid はプロセスが終了するかキルされるまで待機することにする。

シェルのビルトイン

lsh_loop の中で lsh_read_linelsh_split_line を呼び出しているが、lsh_launch は呼び出していない。それは、もしユーザーがディレクトリを移動する時、chdir() を使うことになるが、カレントディレクトリはプロセスのプロパティだからである。親プロセスの持つカレントディレクトリは移動前のままになる。子プロセスも親プロセスの持つカレントディレクトリを継承することになる。

同様に、もしexit というプログラムがあるとして、exit を読んでももともと呼ばれていたシェル自体を終了することができない。終わらせたいシェルの中に組み込む必要がある。多くのシェルは~/.bashrc にそうした設定が書かれている。

いくつかのコマンドをシェルそのものに加えていかねばならない。ここで実装するのはcdhelpexit である。それらは以下のようになる。

/* 
ビルトインシェルのコマンドに対応する関数の宣言
*/
int lsh_cd(char **args);
int lsh_help(char **args);
int lsh_exit(char **args);

/* 
ビルトインシェルの関数に対応するコマンドの宣言
*/
char *builtin_str[] = {
    "cd",
    "help",
    "exit"
};

int (*builtin_func[])(char **) = {
    &lsh_cd,
    &lsh_help,
    &lsh_exit
};

int lsh_num_builtins() {
    return sizeof(builtin_str) / sizeof(char *);
}

/* 
ビルトイン関数の実装
*/
int lsh_cd(char **args) {
    if (args[1] == NULL) {
        fprintf(stderr, "lsh: expected argument to \"cd\"\n");
    } else {
        if (chdir(args[1]) != 0) {
            perror("lsh");
        }
    }

    return 1;
}

int lsh_help(char **args) {
    int i;
    printf("Shuichi Nagao's LSH\n");
    printf("Type program names and arguments, and hit enter.\n");
    printf("The following are built in: \n");

    for (i = 0; i < lsh_num_builtins(); i++) {
        printf(" %s\n", builtin_str[i]);
    }

    printf("Use the man command for information on other programs.\n");

    return 1;
}

int lsh_exit(char **args) {
    return 0;
}

int lsh_launch(char **args) {
////

lsh_cd はまず二つ目の引数が存在するか確認し、なければエラーを出す。あればchdir() を呼んでいる。lsh_help はいい感じのメッセージを出していて、exit は0を返してシェルのループを終了する。

ビルトインとプロセスを組み立てる

あとは残るは lsh_execute() の実装だ。これはビルトインとプロセス両方の起動を行う。

int lsh_exit(char **args) {
    return 0;
}

int lsh_execute(char **args) {
    int i;

    if (args[0] == NULL) {
        return 1;
    }

    for (i = 0; i < lsh_num_builtins(); i++) {
        if (strcmp(args[0], builtin_str[i]) == 0) {
            return (*builtin_func[i])(args);
        }
    }

    return lsh_launch(args);
}


int lsh_launch(char **args) {
////

最後の仕上げ

必要なヘッダーファイルをインクルードする。

#include <stdio.h>
// fprintf()
// printf()
// stderr
// getchar()
// perror()
#include <sys/wait.h>
// waitpid() and associated macros
#include <unistd.h>
// chdir()
// fork()
// exec()
// pid_t
#include <stdlib.h>
// malloc()
// realloc()
// free()
// exit()
// execvp()
// EXIT_SUCCESS, EXIT_FAILURE
#include <string.h>
// strcmp()
// strtok()

あとは gcc -o main main.c して ./main で実行ができる。ここで実装したのは非常に簡単なbashのようなシェルで、以下のようないくつかの問題はある。コードはgithubにあげておいた。

  • スペースで引数を判断するので"“で囲まれた複数の単語までそれぞれが引数として認識されてしまう。

  • パイプライン処理は対応していない

  • ビルトインのコマンドが少なすぎる

  • ワイルドカードで検索(glob)できない

※CのライブラリやUNIXについてわからないことがあればここが非常に参考になるとのこと。