
開発2部の内原です。
シェルで >file、2>&1 のような記号を使ってリダイレクト処理を行うことは多いかと思いますが、なぜこのような書き方をするのか、それが実際にカーネルやプロセスのレベルで何をやっているのか、は意外と説明しづらい、というかなんとなくふわっとした理解のままでいました。
そこでこの記事ではファイルディスクリプタとUnixシステムコールの観点から、これらの記号の意味を考えてみます。
ファイルディスクリプタ(fd)とは
ファイルディスクリプタとは、プロセスごとにカーネルが管理しているファイルテーブルへのインデックス(整数)のことです。プロセスがファイルやソケットを開くと、カーネル側でテーブルにエントリが作られ、その識別子となる整数(0, 1, 2, 3, ...)がプロセスに返されます。
プロセスは以降、この整数を read(2) / write(2) などのシステムコールに渡してファイル操作を行います。
0, 1, 2 という慣習
POSIXにおいてプロセス起動時点で、以下の3つのfdが予め確保されています。
| fd | 名前 | 用途 |
|---|---|---|
| 0 | stdin | 標準入力 |
| 1 | stdout | 標準出力 |
| 2 | stderr | 標準エラー出力 |
これらのファイルディスクリプタは通常、ターミナルのデバイスファイル(/dev/ttys00N など)にOSによって関連付けられています。
$ lsof -p $$ -a -d 0-2 # 今実行しているシェル自身が開いている標準入出力(fd 0〜2)を表示 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME zsh 49106 uchihara 0u CHR 16,6 0t10 1167 /dev/ttys006 zsh 49106 uchihara 1u CHR 16,6 0t10 1167 /dev/ttys006 zsh 49106 uchihara 2u CHR 16,6 0t10 1167 /dev/ttys006
3つともターミナル端末(/dev/ttys006)を指していることがわかります。
open(2) で fd を取得する
新しくファイルを開くと、未使用の最小番号の fd が返却されます。起動時点で 0, 1, 2 が使われているので通常は 3 になります。
#include <fcntl.h> #include <unistd.h> #include <stdio.h> int main(void) { int fd = open("/tmp/hello.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644); printf("fd = %d\n", fd); write(fd, "hello\n", 6); close(fd); return 0; }
$ cc fd_open.c -o fd_open && ./fd_open fd = 3 $ cat /tmp/hello.txt hello
確かに fd = 3 が返ってきています。
dup(2) で fd を複製する
dup(2) はオープン済みの fd を複製し、未使用の最小番号で新しい fd を返します。複製された2つの fd は同じファイルテーブルエントリを指すため、ファイル位置(オフセット)も共有されます。
#include <fcntl.h> #include <unistd.h> #include <stdio.h> int main(void) { int fd1 = open("/tmp/dup.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644); int fd2 = dup(fd1); printf("fd1=%d, fd2=%d\n", fd1, fd2); write(fd1, "via fd1\n", 8); write(fd2, "via fd2\n", 8); close(fd1); close(fd2); return 0; }
$ cc fd_dup.c -o fd_dup && ./fd_dup fd1=3, fd2=4 $ cat /tmp/dup.txt via fd1 via fd2
オフセットが共有されているため、fd1 で書き込んだ続きから fd2 の書き込みが進んでいることがわかります。
dup2(2) で fd 番号を複製する
dup2(oldfd, newfd) は newfd 番がすでに使われていれば一旦close してから、oldfd の複製を newfd 番に作るシステムコールです。これがリダイレクトの実態となります。
#include <fcntl.h> #include <unistd.h> #include <stdio.h> int main(void) { printf("before redirect\n"); fflush(stdout); int fd = open("/tmp/dup2.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644); dup2(fd, 1); // fd 1 (stdout) を fd の指す先に差し替える close(fd); // 元の fd はもう不要なので閉じる printf("after redirect\n"); return 0; }
$ cc fd_dup2.c -o fd_dup2 && ./fd_dup2 before redirect $ cat /tmp/dup2.txt after redirect
dup2(fd, 1) を境にして、printf の出力先が端末からファイルに切り替わっていることがわかります。
なお fflush(stdout) を挟んでいるのはstdioのバッファリングを考慮するためです。バッファに残ったまま fd 1 を差し替えると、後でフラッシュされたタイミングで意図しないファイルに書き込まれてしまいます。
>file の正体
シェルがリダイレクトを行うときの手順は以下の通りです。
- (新たに子プロセスでコマンドを実行する場合)
fork(2)で子プロセスを生成 - 子プロセスで出力先ファイルを
open(2) - その fd を
dup2(2)で 1 番(stdout)に複製 - 元の fd は
close(2)で閉じる execve(2)で実コマンドに置き換える
自前でリダイレクトを再現してみる
./mini_redirect <出力ファイル> <コマンド> [引数...] のように実行すると、指定したコマンドの標準出力をファイルへリダイレクトします。
#include <fcntl.h> #include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> int main(int argc, char *argv[]) { if (argc < 3) { fprintf(stderr, "usage: %s <output_file> <cmd> [args...]\n", argv[0]); return 1; } const char *outfile = argv[1]; pid_t pid = fork(); if (pid < 0) { perror("fork"); return 1; } if (pid == 0) { // 子プロセス: 出力先を open し、stdout (fd 1) に dup2 してから exec int fd = open(outfile, O_WRONLY | O_CREAT | O_TRUNC, 0644); if (fd < 0) { perror("open"); _exit(1); } if (dup2(fd, 1) < 0) { perror("dup2"); _exit(1); } close(fd); execvp(argv[2], &argv[2]); perror("execvp"); _exit(1); } // 親プロセス: 子の終了を待つ int status; waitpid(pid, &status, 0); return WIFEXITED(status) ? WEXITSTATUS(status) : 1; }
実行してみます。
$ cc mini_redirect.c -o mini_redirect $ ./mini_redirect /tmp/mini_out.txt echo hello world from mini_redirect $ cat /tmp/mini_out.txt hello world from mini_redirect
これは以下と同じ動作です。
$ echo hello world from mini_redirect >/tmp/mini_out.txt
2>&1 とはなにか
2>&1 という書き方忘れたりしません?(自分はたまに忘れます)これは実際には dup2(1, 2) の意味で、シェルの記述とシステムコールの引数が逆になっていて混乱しがちではあります。
あと、標準出力と標準エラー出力をまとめてファイルに書き出したい時に、>file 2>&1 と 2>&1 >file どっちだっけ?みたいになることもあります。
結論
| 書き方 | stdout の行き先 | stderr の行き先 |
|---|---|---|
cmd >file 2>&1 |
file | file |
cmd 2>&1 >file |
file | 変化なし(通常はターミナル) |
2>&1 は「fd 2 を fd 1 と同じにする」と説明されますが、これは正確には「2>&1 を実行した時点の fd 1 の指し先を fd 2 にコピーする」という操作で、以後 fd 1 が別の場所に切り替わっても fd 2 は連動しません。(リンクしているわけではないという意味)
シェルでの挙動
$ bash -c 'echo stdout-msg; echo stderr-msg >&2' >/tmp/sh_a.txt 2>&1 $ cat /tmp/sh_a.txt stdout-msg stderr-msg
$ bash -c 'echo stdout-msg; echo stderr-msg >&2' 2>&1 >/tmp/sh_b.txt stderr-msg $ cat /tmp/sh_b.txt stdout-msg
パターンBではなぜ stderr が端末に残ってしまうのか、再現してみます。
C で再現してみる
シェルは > や 2>&1 といったリダイレクト指示を左から右に評価する仕様なので、順序が重要になります。
パターン A: >file 2>&1
#include <fcntl.h> #include <unistd.h> #include <stdio.h> int main(void) { int fd = open("/tmp/order_a.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644); dup2(fd, 1); // ① fd 1 をファイルに差し替え close(fd); dup2(1, 2); // ② fd 2 を「現時点の fd 1」(=ファイル) に差し替え fprintf(stdout, "stdout: hello\n"); fflush(stdout); fprintf(stderr, "stderr: world\n"); fflush(stderr); return 0; }
$ cc order_a.c -o order_a && ./order_a $ cat /tmp/order_a.txt stdout: hello stderr: world
①の時点で fd 1(標準出力)はファイルを指します。
②でその「ファイルを指している fd 1」をコピーして fd 2(標準エラー出力) にセットしているので、fd 2 もファイルを指すことになります。
結果として両方ファイルに書き込まれます。
パターン B: 2>&1 >file
#include <fcntl.h> #include <unistd.h> #include <stdio.h> int main(void) { dup2(1, 2); // ① fd 2 を「現時点の fd 1」(=ターミナル) に差し替え int fd = open("/tmp/order_b.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644); dup2(fd, 1); // ② fd 1 をファイルに差し替え(fd 2 は連動しない) close(fd); fprintf(stdout, "stdout: hello\n"); fflush(stdout); fprintf(stderr, "stderr: world\n"); fflush(stderr); return 0; }
$ cc order_b.c -o order_b && ./order_b stderr: world $ cat /tmp/order_b.txt stdout: hello
①の時点では fd 1(標準出力)はまだターミナルを指しており、fd 2(標準エラー出力)が同じところを指します。
②で fd 1 はファイルに切り替わりますが、fd 2 はすでに「①でコピーされた時点のターミナル」を保持し続けています。
これはシェルの挙動と一致しています。
なぜそうなるのか
カーネル内部の構造で見るとわかりやすいです。
- 各 fd はプロセスごとの fd テーブルから「open file table」のエントリを指している
dup2(oldfd, newfd)は「newfdの指し先をoldfdの指し先と同じにする」というポインタ複製操作- 後から
oldfdの指し先を変更しても、newfdは元の指し先を指したまま
つまり 2>&1 は「fd 2 と fd 1 を以後リンクする」のではなく、「実行時点の fd 1 の指し先を fd 2 にコピーする」ということになります。
覚え方
ログを全部ファイルに落としたいときは、まずファイルリダイレクト >file を先に書いて、その後 2>&1 でまとめる、と考えるのがよさそうです。
$ cmd >file 2>&1 # 両方 file $ cmd 2>&1 >file # stdout だけ file、stderr は端末
まとめ
シェルでよく使う >file や 2>&1 といった記号は、open dup2 close という Unix システムコールの組み合わせとしてみると、その挙動がそのままシステムコールによる実装になっていることが確認できました。
まあとは言え覚えづらいですよね・・・結局のところ慣れでしかないかもしれません。
あと、パイプやヒアドキュメントについてもまたいつか調べてみたいです。
- fd は open 時に未使用の最小番号が返却される整数
>fileはopen→dup2(fd, 1)→close(fd)2>&1は実行時点の fd 1 の指し先を fd 2 にコピーするだけで、以後 fd 1 が変わっても fd 2 は連動しない- 評価順は左から右なので、
>file 2>&1と2>&1 >fileは別物になる