バニキPの気ままに

気ままにやったこととか紹介していきます

C言語で自作shell作ってみた(コマンド実行とリダイレクト)

この記事は,Akatsuki Advent Calendar 2021 の3日目の記事です.

はじめに

11月半ばに急にslackでメンションが飛んできて,アドベントカレンダーの空き枠後少ししかないよ!と. 毎回アドベントカレンダー然り,何かしらのイベント然りで何を喋るかネタに困る. キラーパスがよく飛んできて,毎回なんとかなってるので今回もまあいっかということで引き受けました. 投稿1週間前の土日に頑張るぞと思ってたら,ライブがあってその夢は打ち砕かれました. なので,という訳ではないですが,そこまでやったぜ感はないです.(続報としてもっと良い感じに作って行こうかなとかは思ってます)

経緯

最近,Shell Scriptを読んで割と学ぶ機会がありました. まだまだ,Linux関連知識をもっと深めていった方がいいなと感じる場面がありました. じゃあ,勉強になる何かを作って勉強しつつアドベントカレンダーのネタにしようという作戦で,脳内会議では合意が取れました. 何か良い感じの学習の流れとか書いてないかなと探していた時に,このブログに出会いました. Linuxの基礎を理解する的なやつだったのですが,shellの欄があってbashとかの理解の結果として自作shellを作っていると書いてました. これだと思ってやってみるかということでネタを決めました.

本編

shellって何?

bash」や「zsh」などの種類が存在する,プログラムのことで,入力デバイスから何かしらのコマンドを受け取って,OSに渡して実行し,その結果をユーザに示すユーザインタフェースである. ユーザは,shellを使ってOSを操作したり,他のプログラムを実行したりする.

実装していくにあたって知っておいた方がいいこと

基礎として,以下の3つが挙げられる.

プロセス

Wikipedia先輩によるとプログラムの動作中インスタンスであるらしい. プログラムを実行するとプロセスが起動して,プログラムに従って動作する. かく言うshellもプロセスになる. 注意するべき点は,プロセスには制約があると言う点である. プロセスは,自分の「外部」にアクセスできない. つまり,ファイルの読み書きや自分以外のプロセスの起動などが「外部」に当たる. そうしたできないことは,カーネルに処理を委譲することになる.

カーネル

OSの中核にある特権を持つプログラムのことである. その特権には,ファイルの読み書きやプロセスの起動など,プロセスができないことを可能としている. このカーネルに処理をお願いする場合に使うのがシステムコールである.

システムコール

プログラムがOSの特権機能を呼び出す仕組みのことである. OSによって数が異なり,Linuxだと300個程度で,FreeBSDだと500個程度ある. 主要なシステムコールは,ファイル操作のopen(2),read(2),write(2),close(2),プロセスを起動するfork(2)などがある. プロセスは,システムコールを利用して,OSの特権を行使することで,本来できないことを実現している.

shellはどうやって動作してるの?

先ほどいったようにshellもプロセスの一つである. ということは,プロセスの制約を受けるということになる. shell自身は何かのコマンドを実行した後も他のコマンドを受け付ける必要がある. ということは,コマンド自体はshellと同一のプロセスとは別のプロセスで実行される必要がある. つまり,shellは別プロセスを起動してコマンドを実行するためのプログラムとなる. shellの実行の流れを説明する.

  1. コマンドを受け付ける
  2. fork(2)を実行しOSに別プロセスを起動してもらい,shell自身は新しく起動したプロセスの終了を待つ
  3. 新たに起動したプロセスでは,実際にexec(2)を用いて,コマンドを動作させる
  4. コマンドの結果をユーザに返した後,コマンド実行のために起動したプロセスは終了する
  5. shell自身のプロセスは,終了を確認すると次のコマンドを待つ

基本的には,1~5を繰り返すのがshellの動作となる.

実装してみた

今回は,久々にCを書きたくなったという雑な理由ではありますが,C言語を選択してます. (久々すぎて,色々includeしないと使いたい関数が使えないというのに戸惑いましたw)

簡単な解説

(1)のところで,入力を受け付けてから,入力された文字列をbufferに詰めてます. その後,読み取ったコマンドの列をパースしてtokenに入れます, (2)のところでは,入力されたコマンドを実行するためにforkしてshellとは別のプロセスを起動してます. (3)のところで,入力されたコマンドを実行して,(4)のところで,実行の終了を待機するという一連の流れを実装しました,

#include <stdio.h>
#include <stdbool.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
#include <string.h>

#define BUF_SIZE 1024

char** eliminateToken(char **token, int size, int pos1, int pos2);

int main(int argc, char **argv) {
  char *buffer = malloc(sizeof(char) * BUF_SIZE);
  char **token = malloc(sizeof(char*) * BUF_SIZE);
  int c, status, position;
  pid_t pid;

  do {
    position = 0;
    printf("$ "); // 入力受付(1)
    while (true) {
      c = getchar();

      if (c == EOF || c == '\n') {
        buffer[position] = '\0';
        break;
      } else {
        buffer[position] = c;
      }
      position++;
    }
    token[0] = strtok(buffer, " ");
    for(position = 1; position < BUF_SIZE; position++) {
      token[position] = strtok(NULL, " ");
      if (token[position] == NULL) { break; }
    }
    token = realloc(token, sizeof(char*) * position);

    pid = fork(); // forkして別プロセスの起動(2)
    if (pid < 0) {
      perror("fork error");
      exit(EXIT_FAILURE);
    } else if (pid == 0) {
      if (execvp(token[0], token) == -1) { // コマンドの実行(3)
        perror("exec error");
        exit(EXIT_FAILURE);
      }
    } else {
      waitpid(pid, &status, WUNTRACED); // コマンドの終了を待機(4)
    }

    puts("");
    token = calloc(position, sizeof(char*) * BUF_SIZE);
  } while (!status);

  free(buffer);
  free(token);
  return EXIT_SUCCESS;
}

困りポイント

最初,実装した時は入力はgetcharではなく,fgetsを使っていたのですが,_や.といった記号のところで入力内容がおかしくなってしまう現象が発生しました. 結局,調査してもすぐに結論はでなさそうだし,時間もないということで一文字ずつbufferに格納する実装で動作させるようにしました. 普段rubyをメインで扱っていることもあり,すごく慣れない時間が長かったです. 学生時代はあんなに書いてたのに...使わないとすぐに錆びつきますね.

機能追加してみた(リダイレクト)

本当は,色々機能追加したかったのですが,ネタを決めるまでに時間がかかりすぎたので,そこまで時間がないから本当はコマンドが実行できたらいいかくらいでいました. しかし,いつものようにネコさんが,課題を設定してくれました. それが,リダイレクトです. 当初の予定では,リダイレクトとパイプくらい実装できればいいかと思ってましたのでいけるだろう.(そんな風に思って時期が自分にもありました.)

簡単な解説

コメントの間が追加した処理になります. 今回の実装で対応したのは,「>」と「>>」だけで,かつ必ず記号の後ろに空白を入れないと動作しません...(そこまで対応が間に合わなかったです...) 動作としては,入力された文字列にリダイレクトのマークがあったら処理をします. 確認方法は,strncmpを使って最初から指定文字が指定のマークか見てます. 「>>」が先なのは,「>」の場合「>>」の可能性もあるため先に確認してます. 「>>」の場合,ファイルを開く時,読み書きモードで開いて(O_RDWR),ファイルが存在していたらファイルに追加するようにAppendモードで開いて(O_APPEND),存在しない場合には新しく作成します(O_CREAT). 「>」の場合には,基本的に動作が同じで,ファイルが存在する場合の動作が異なります. ファイルが存在している場合は,中身を空にしてから書き込めるようにファイルを開きます(O_TRUNC). ファイルが開けたら,dup2を使って標準出力の先を開いたファイルに書き込むように変更します. つまり,ファイルディスクリプタをゴニョゴニョ弄っている訳です.(dup2の引数の2つ目のWRITEは標準出力を示すファイルディスクリプタ1番が入ってます.) 実行前に,eliminateTokenでリダイレクト処理の文字を削った実際に実行する必要のあるコマンドとオプションだけにしてexec(2)することで,リダイレクトを実現します.

#include <stdio.h>
#include <stdbool.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

#define BUF_SIZE 1024
#define READ     0
#define WRITE    1

char** eliminateToken(char **token, int size, int pos1, int pos2);

int main(int argc, char **argv) {
  char *buffer = malloc(sizeof(char) * BUF_SIZE);
  char **token = malloc(sizeof(char*) * BUF_SIZE);
  int c, status, position, fd;
  pid_t pid;

  do {
    position = 0;
    printf("$ ");
    while (true) {
      c = getchar();

      if (c == EOF || c == '\n') {
        buffer[position] = '\0';
        break;
      } else {
        buffer[position] = c;
      }
      position++;
    }
    token[0] = strtok(buffer, " ");
    for(position = 1; position < BUF_SIZE; position++) {
      token[position] = strtok(NULL, " ");
      if (token[position] == NULL) { break; }
    }
    token = realloc(token, sizeof(char*) * position);

    pid = fork();
    if (pid < 0) {
      perror("fork error");
      exit(EXIT_FAILURE);
    } else if (pid == 0) {
      // ここからしたがリダイレクトの処理
      for (int i = 0; i < position; i++) {
        if (!strncmp(token[i], ">>", 2)) {
          if ((fd = open(token[i+1], O_RDWR | O_APPEND | O_CREAT, 0666)) < 0) {
            perror("open error");
            close(fd);
            exit(EXIT_FAILURE);
          }
          dup2(fd, WRITE);
          token = eliminateToken(token, position, i, i+1);
          break;
        } else if (!strncmp(token[i], ">", 1)) {
          if ((fd = open(token[i+1], O_RDWR | O_TRUNC | O_CREAT, 0666)) < 0) {
            perror("open error");
            close(fd);
            exit(EXIT_FAILURE);
          }
          dup2(fd, WRITE);
          token = eliminateToken(token, position, i, i+1);
          break;
        }
      }
      // ここまでがリダイレクトの処理
      if (execvp(token[0], token) == -1) {
        perror("exec error");
        exit(EXIT_FAILURE);
      }
      close(fd);
    } else {
      waitpid(pid, &status, WUNTRACED);
    }

    puts("");
    token = calloc(position, sizeof(char*) * BUF_SIZE);
  } while (!status);

  free(buffer);
  free(token);
  return EXIT_SUCCESS;
}

char** eliminateToken(char **token, int size, int pos1, int pos2) {
  char **eliminate_buffer = malloc(sizeof(char*) * BUF_SIZE);
  int position = 0;

  for (int i = 0; i < size; i++) {
    if (i != pos1 && i != pos2) {
      eliminate_buffer[position] = token[i];
      position++;
    }
  }

  return eliminate_buffer;
  }

実際に動かしてみた

まずは,lsやcatなどの基本的なコマンド実行しました. f:id:shivashin495:20211203163956p:plain ちゃんと,動作して必要な情報を吐き出してくれてますし,オプションを指定しても動作してくれてます.

次にリダイレクトを実行してみます. まずは,「>」から実行しました. f:id:shivashin495:20211203164335p:plain

きちんとlsの結果がhogeというファイルに書き込まれています. また,リダイレクトの位置は真ん中にあっても良いのですが,それにも対応しています.

続いて,「>>」を実行してみます. f:id:shivashin495:20211203164742p:plain

もちろん元々書き込まれている内容に追記もできてますし,リダイレクトの位置が真ん中にあっても動作しています.

感想

今回は,勉強の一環も兼ねてC言語でshellを作ってみました. 正直,セキュリティ的にまずいところもあるだろうし,まだまだ効率的でなかったり,ほとんど関数に分割してないので可読性が低いと思います. しかし,勉強という点ではかなり良かったかなと思います. 身近に使っているbashzshというのがかなり高性能でよくできていると感じられますし,どんな動作をしているのかの理解度が上がりました. ただ,C言語で書いたのですか,パースする部分だったり,書くのが大変だったので,まだまだ勉強はしたほうが,今後のためにもなると思いました. まだshell作ったことがない方は,このブログが参考になるかはわかりませんが,誰でも調べたりすればできるので,勉強や趣味がてらやってみてはいかがでしょうか.