▶️

リダイレクションを実装して仕組みを理解する

に公開

はじめに

自作しているOSにリダイレクションの実装をしました。
最低限の動作しか実装していませんが、実装を通じてリダイレクションの仕組みを深く理解できたので、備忘録を兼ねて整理してみます。

リポジトリ

https://github.com/junyaU/uchos

今回のゴール

今回の実装では、以下のコマンドを実行した際に、期待通りの結果が得られることを目標にしています。

実行コマンド

echo hello > aaa.txt
cat aaa.txt

実行結果

hello

リダイレクションについて

実装の説明に入る前に、リダイレクションの基本概念について整理しておきます。

基本概念

リダイレクションは、プロセスの標準入力(stdin)、標準出力(stdout)、標準エラー出力(stderr)の向き先を変更する仕組みです。この機能により、コマンドの実行結果をファイルに保存したり、ファイルの内容をプログラムの入力として使用したりすることが可能になります。

リダイレクションを理解するために、まずは3つの標準ストリームについて説明します。

標準ストリーム

UNIX系システムでは、すべてのプロセスは起動時に3つの標準ストリームを持ちます。

ストリーム ファイルディスクリプタ 通常の入出力先
標準入力(stdin) 0 キーボード
標準出力(stdout) 1 ターミナル画面
標準エラー出力(stderr) 2 ターミナル画面

標準出力(stdout)

プログラムが実行結果を出力する先です。デフォルトではターミナル画面に表示されます。

$ echo "Hello World"
Hello World

echoコマンドの結果がターミナルに表示されるのは、標準出力がデフォルトでターミナル画面に向けられているためです。

標準入力(stdin)

プログラムがデータを読み込む入力元です。デフォルトではキーボードからの入力を受け取ります。

$ cat
Hello from keyboard    # キーボードから入力
Hello from keyboard    # catコマンドがそのまま出力
^C                     # Ctrl+Cで終了

引数なしでcatコマンドを実行すると、標準入力(キーボード)からの入力を待ち受け、入力された内容をそのまま標準出力に表示します。これはcatコマンドがファイルを指定されない場合、デフォルトで標準入力を読み取るように設計されているためです。
結果として、キーボードの入力がそのまま画面に表示される挙動となります。

標準エラー出力(stderr)

プログラムがエラーメッセージを出力する先です。デフォルトでは標準出力と同じターミナル画面に表示されますが、独立したストリームとして管理されています。

$ ls /nonexistent_directory
ls: cannot access '/nonexistent_directory': No such file or directory

このエラーメッセージは標準エラー出力に出力されており、標準出力とは明確に区別されています。

ファイルディスクリプタ

ファイルディスクリプタ(File Descriptor, FD)は、プロセスが開いているファイルやストリームを識別するための非負の整数です。カーネルは、各プロセスごとにファイルディスクリプタテーブルを管理しており、このテーブルを通じてプロセスが使用するファイルやストリームへのアクセスを制御しています。

// ファイルを開くとファイルディスクリプタが返される
int fd = open("example.txt", O_RDONLY);
// fdには通常3以上の値が入る(0, 1, 2は標準ストリーム用に予約済み)

プロセスがファイルを開くたびに、カーネルは利用可能な最小の非負整数から順番に新しいファイルディスクリプタを割り当てます。

echoコマンドの実装例

V7 UNIXのechoコマンドの実装を見ると、標準出力への書き込み処理を確認できます。

https://github.com/v7unix/v7unix/blob/a19130f05356581fe12d635a4cce4d8556a33171/v7/usr/src/cmd/echo.c

// 上記コードから抜粋
fputs(argv[i], stdout);

このfputs()関数は以下の引数を受け取ります。

  • 第一引数:出力したい文字列(argv[i])
  • 第二引数:出力先のファイルポインタ(stdout)

ここで使用されているstdoutは標準出力を表すファイルポインタで、内部的にはファイルディスクリプタ1番に対応しています。つまり、このコードは標準出力に文字列を出力していることがわかります。

リダイレクションの仕組み

リダイレクションは冒頭で説明した通り、標準ストリームの向き先を変更する仕組みです。プロセスのファイルディスクリプタテーブルを操作することで、プログラムのコードを一切変更することなく入出力先を制御できます。

基本的な動作例

リダイレクションにより標準出力が別のファイルに向けられた場合、同じfputs()の呼び出しでも出力先が変わります。

# 通常の実行:ターミナルに出力される
$ echo "Hello World"
Hello World

# リダイレクション:sample.txtに出力される
$ echo "Hello World" > sample.txt

echoコマンド自体のコードは全く変更されていませんが、シェルがプロセス起動前にファイルディスクリプタ1番の向き先をsample.txtに変更しているため、同じfputs(argv[i], stdout)の呼び出しが異なる場所に場所に出力されることになります。

内部処理の流れ

リダイレクションの処理は以下の手順で行われます。

  1. シェルがリダイレクション記号(>)を検出
  2. 対象ファイル(sample.txt)を開く
  3. ファイルディスクリプタ1番(標準出力)の向き先をファイルに変更
  4. echoコマンドを実行

リダイレクション前後でのファイルディスクリプタテーブルの変化
リダイレクション前後でのファイルディスクリプタテーブルの変化

重要なのは、ファイルディスクリプタの番号(0、1、2)自体は変更されないという点です。変更されるのは、その番号が指している先(ターミナルかファイルか)だけです。プログラムは依然としてファイルディスクリプタ1番に書き込んでいますが、その1番が指す先が変わっているのです。
これがリダイレクションの本質です。プログラム側は標準出力に書き込んでいるつもりでも、ファイルディスクリプタの向き先を変更することで、実際の出力先を自由に制御できるわけです。

実装

ここからは自作OSで実装したリダイレクション機能のコードを抜粋して紹介します。実装は以下の流れで行われます。

  1. コマンドライン解析:リダイレクション記号とファイル名の抽出
  2. 子プロセス作成:fork()によるプロセス複製
  3. リダイレクション設定:ファイルディスクリプタの操作
  4. コマンド実行:exec()による実際のプログラム起動

メイン処理

shell.cpp
void Shell::process_input(char *input, Terminal &term) {
  if (input == nullptr || strlen(input) == 0) {
    return;
  }

  // 1. リダイレクトファイル名を抽出
  char *redirect_file = extract_redirect_file(input);
  
  // 2. コマンド名と引数を分離
  char command_name[256];
  const char *args;
  extract_command_and_args(input, command_name, &args);

  // 3. 子プロセスを作成
  pid_t pid = sys_fork();
  
  if (pid == 0) {
    // 子プロセス:リダイレクション設定後にコマンド実行
    if (!setup_redirection(redirect_file)) {
      exit(1);
    }
    
    error_t status = sys_exec(command_name, args);
    exit(status);
  }

  // 親プロセス:子プロセスの完了を待機
  int child_status;
  sys_wait(&child_status);
  
  if (child_status != 0) {
    term.printf("%s: command not found\n", command_name);
  }
}

上記のコードで注目すべきは、setup_redirection関数です。この関数は子プロセス内で呼び出され、実際のコマンド実行前にファイルディスクリプタの操作を行います。

リダイレクション設定の実装

bool setup_redirection(const char *redirect_file) {
  if (redirect_file == nullptr || strlen(redirect_file) == 0) {
    return true;
  }

  fd_t file_fd = fs_create(redirect_file);
  if (file_fd < 0) {
    file_fd = fs_open(redirect_file, 0);
  }

  if (file_fd < 0) {
    return false;
  }

  bool success = fs_dup2(file_fd, STDOUT_FILENO) >= 0;
  fs_close(file_fd);

  return success;
}

このリダイレクション処理の核心となるのがfs_dup2システムコールです。

fs_dup2(file_fd, STDOUT_FILENO)

この関数は以下の引数を受け取ります。

  • 第一引数(file_fd):コピー元のファイルディスクリプタ
  • 第二引数(STDOUT_FILENO):コピー先のファイルディスクリプタ(標準出力=1)

dup2の実行により、ファイルディスクリプタ1番(標準出力)が指していた先(通常はターミナル)が、file_fdが指している先(リダイレクト先ファイル)に変更されます。

dup2実行前後でのファイルディスクリプタテーブルの変化
dup2実行前後でのファイルディスクリプタテーブルの変化

処理の流れは以下の通りです。

  1. ファイル作成/オープン:fs_createまたはfs_openでリダイレクト先ファイルを開く
  2. ファイルディスクリプタ複製:fs_dup2で標準出力をファイルに向ける
  3. 元のファイルディスクリプタ削除:fs_closeで不要になったファイルディスクリプタを閉じる

この処理により、その後に実行されるsys_execで起動されるプログラムは、標準出力への書き込みが自動的にファイルに向けられることになります。プログラム自身は標準出力に書き込んでいるつもりでも、実際の出力先はリダイレクトされたファイルになるのです。

動作確認

実際の動作の様子
実際の動作の様子
想定どおりに動きました。今後は追記リダイレクション(>>)や入力リダイレクション(<)、パイプ(|)などにも対応できるよう拡張していきたいと思います。

おわりに

今回は出力リダイレクションの上書き(>)のみの実装でしたが、実装を通じてリダイレクションの本質は「ファイルディスクリプタの向き先を変更する」というシンプルな操作であることが理解できました。

プログラム自体は何も変更することなく、dup2システムコールによってファイルディスクリプタテーブルを操作するだけで入出力先を自由に制御できるようになるので、個々のプログラムはリダイレクションを意識することなく標準的な入出力インターフェースを使うだけで済みます。これはUNIX哲学の「小さなプログラムを組み合わせて大きな仕事をする」という考え方を具現化した機能だと改めて感じました。

参考

https://www.gnu.org/software/bash/manual/html_node/Redirections.html
https://www.ibm.com/docs/ja/zos/2.5.0?topic=functions-dup2-duplicate-open-file-descriptor-another

Discussion