UNIXコマンドのソースコード読んでみた
はじめに
最近積んでた『UNIXという考え方: その設計思想と哲学』という書籍を読んだんですが、その中に以下のような心に刺さったいくつかの考え方が記載されていました。
- スモール イズ ビューティフル
- 1つのプログラムには1つのことを上手くやらせる
- ソフトウェアの梃子を有効に活用する
etc...
そんな考え方の元で作られたUNIXというOSは実際どんなコードなんだろう。
いちソフトウェアエンジニアの端くれとして読んでみたい。
ただOSのコードなんて簡単に読めるわけがありません。現在の自分の実力では相当難しいです。しかし簡単に諦めるのも面白くありません。
何か方法はないかと考えた結果、UNIXコマンドの中でできるだけシンプルなコマンドのソースコードを読んでみよう! という案を思いつきました。
これなら流石に頑張ればなんとかなるだろ!ということで、早速やっていきたいと思います。
どのコマンドの実装を見ていくか
今回は、最も実装が簡単であろうという観点で厳選した以下の3つのコマンドを扱います。
- yes
- echo
- cat
yes
まずはyes
コマンドについてみていきます。
どんなコマンドか
無限にy
を出力するコマンドです。
yes
コマンドの後に、任意の文字を入れることで、任意の文字を無限に出力することもできます。
yes <任意の文字>
どんな時に使うコマンドか
自動入力
一部のUNIXコマンドは、実行中にユーザーに「yes/no」の入力を求めます。例えば、rm -i
(インタラクティブモードでファイル削除)やパッケージのインストールなどでは、ユーザーが手動で「y」や「n」を入力しなければならない場面があります。
yes
コマンドを使うと、これらのプロンプトに自動的に「y」や他の指定した文字列を送り続けることができ、コマンド実行が途切れずスムーズに進行します。
yes | rm -i *.txt
上記の例では、rm -i
が削除確認を求めたときにyesコマンドが「y」を送り続け、すべての削除確認に自動的に「yes」で応答します。
負荷テスト
yes
コマンドはCPUやI/O性能をテストするために使用されることもあります。例えば、yes > /dev/null
とすると、CPUが絶え間なく「y」を出力し続け、どの程度の負荷に耐えられるかを確認できます。
yes > /dev/null
どんな実装か
単純に無限にprintf
しているだけです。すごくシンプルですね。
main(argc, argv)
char **argv;
{
for (;;)
printf("%s\n", argc>1? argv[1]: "y");
}
ソースコードのリンクはこちら
所感
最初ソースコード見た時に「無限にy出力するだけのコマンドになんの意味が?」という疑問が止まらなかったのですが、手動のy入力を自動化するために使う用途を知った時にすごく納得しました。
あとは自分が知る限り、最もシンプルなソースで、まさしく スモール イズ ビューティフル なコードで感動です。
echo
つぎはecho
コマンドをみていきます。
どんなコマンドか
echo
コマンドは、指定された文字列をそのまま出力するコマンドです。
例えば、以下のように入力した文字列がそのまま出力されます。
echo "Hello, World!"
結果
Hello, World!
引数として渡された文字列を出力し、場合によっては特定のオプションを利用して動作を変更することもできます。
どんな時に使うコマンドか
テキストの出力
スクリプトやコマンドラインでの操作中、テキストを出力するために使用します。
例えば、簡単なメッセージや変数の内容を表示する場合です。
echo "処理が完了しました"
ファイルへの書き込み
リダイレクトを利用してファイルに文字列を書き込む用途でも利用します。
echo "この内容をファイルに保存" > file.txt
どんな実装か
基本的には、fputs
で引数の値を出力しているコマンドですが、オプションの処理が入っていてyes
に比べると少し複雑です。
#include <stdio.h>
main(argc, argv)
int argc;
char *argv[];
{
register int i, nflg;
nflg = 0;
if(argc > 1 && argv[1][0] == '-' && argv[1][1] == 'n') {
nflg++;
argc--;
argv++;
}
for (i = 1; i < argc; i++) {
fputs(argv[i], stdout);
if (i < argc - 1)
putchar(' ');
}
if (nflg == 0)
putchar('\n');
exit(0);
}
※ソースコードのリンクはこちら
ちょっとややこしいので、少し詳しく見ていきましょう。
register int i, nflg;
上記のコードは単純な変数の宣言です。register
という見慣れないコードがあるので軽く深堀りしてみます。
register
は、これらの変数の値はCPUレジスタに置いてね、という命令です。
CPUレジスタはメモリより高速にアクセスされるので、頻繁に使用する変数をレジスタに置くことでパフォーマンスの向上に期待ができます。
近年では意味が薄いとされ非推奨とされている処理であり、例えばC++17ではregisterコマンドは削除されています。
nflg = 0;
if(argc > 1 && argv[1][0] == '-' && argv[1][1] == 'n') {
nflg++;
argc--;
argv++;
}
-n
というオプションが設定されていれば、フラグを立てるという処理です。
引数の数を減らしたり、引数が入っている配列のインデックスを進めたりしています。
次のコードです。
for (i = 1; i < argc; i++) {
fputs(argv[i], stdout);
if (i < argc - 1)
putchar(' ');
}
引数の1番目以降の値をfputs
で標準出力に出力しています。
出力する引数の値が複数ある場合には、半角スペースを入れて分けてくれています。
次のコードです。
if (nflg == 0)
putchar('\n');
exit(0);
フラグが立っていなければ、最後に改行を改行を出力します。-n
オプションは改行のon/offに使うオプションだということが実装から読み取れます。
以上でecho
の処理は終了です。
所感
文字列を出力するだけの処理でもちろんシンプルなんですが、オプションの有無で急に複雑さが増したな、という印象でした。
ちなみに今回ls
コマンドを採用しなかった理由は、オプションが多すぎて処理が複雑だからです。コードを軽く眺めるとわかるんですがフラグが山ほどあって挙動がぱっと見でよくわかりません。
フラグが増えると、可読性が大きく下がるということを実感しました。
cat
さいごはcat
コマンドをみていきます。
どんなコマンドか
cat
はファイルの内容を標準出力(ターミナル)に表示するコマンドです。
また、複数のファイルを結合して表示する機能もあります。
どんな時に使うコマンドか
ファイルの内容を確認する
ファイルの中身をターミナルで表示したいときに使います。
cat example.txt
これで、example.txt
の内容が表示されます。
複数のファイルを結合して表示する
複数のファイルを連続的に出力することで、内容を結合した形式で確認できます。
cat file1.txt file2.txt
上記のコマンドは、file1.txt
とfile2.txt
の内容を順番に表示します。
ファイルの内容を新しいファイルに書き出す
リダイレクトを使えば、複数のファイルを結合して新しいファイルに保存できます。
cat file1.txt file2.txt > combined.txt
これで、file1.txt
とfile2.txt
の内容が結合され、combined.txt
に保存されます。
※リダイレクト自体はcat
コマンドとは関係ない機能ですが、cat
にはリダイレクトに配慮した実装が組み込まれているので紹介しました。どういった実装なのかはこの後みていきます。
どんな実装か
これまでのコードに比べると最もコード量が多いです。頑張ってみていきましょう。
/*
* Concatenate files.
*/
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
char stdbuf[BUFSIZ];
main(argc, argv)
char **argv;
{
int fflg = 0;
register FILE *fi;
register c;
int dev, ino = -1;
struct stat statb;
setbuf(stdout, stdbuf);
for ( ; argc > 1 && argv[1][0] == '-'; argc--, argv++) {
switch (argv[1][1]) {
case 0:
break;
case 'u':
setbuf(stdout, (char *)NULL);
continue;
}
break;
}
fstat(fileno(stdout), &statb);
statb.st_mode &= S_IFMT;
if (statb.st_mode != S_IFCHR && statb.st_mode != S_IFBLK) {
dev = statb.st_dev;
ino = statb.st_ino;
}
if (argc < 2) {
argc = 2;
fflg++;
}
while (--argc > 0) {
if (fflg || (*++argv)[0] == '-' && (*argv)[1] == '\0')
fi = stdin;
else {
if ((fi = fopen(*argv, "r")) == NULL) {
fprintf(stderr, "cat: can't open %s\n", *argv);
continue;
}
}
fstat(fileno(fi), &statb);
if (statb.st_dev == dev && statb.st_ino == ino) {
fprintf(stderr, "cat: input %s is output\n",
fflg ? "-" : *argv);
fclose(fi);
continue;
}
while ((c = getc(fi)) != EOF)
putchar(c);
if (fi != stdin)
fclose(fi);
}
return (0);
}
※ソースコードのリンクはこちら
では、コードの中身をみていきます。
char stdbuf[BUFSIZ];
setbuf(stdout, stdbuf);
for ( ; argc > 1 && argv[1][0] == '-'; argc--, argv++) {
switch (argv[1][1]) {
case 0:
break;
case 'u':
setbuf(stdout, (char *)NULL);
continue;
}
break;
}
setbuf
を使用して、オプションの有無によって出力の挙動を変える設定をしています。
-u
が指定されている場合、バッファリングを行わずに即時出力するような挙動になるように設定しています。
次のコードです。
fstat(fileno(stdout), &statb);
statb.st_mode &= S_IFMT;
if (statb.st_mode != S_IFCHR && statb.st_mode != S_IFBLK) {
dev = statb.st_dev;
ino = statb.st_ino;
}
出力先が通常のファイルなのか、特殊デバイスなのかを確認しています。
出力先がファイルだった場合のみ、ファイルの情報を変数に記録しています。これがどういう意図のコードなのかは後ほど出てきます。
次のコードです。
if (argc < 2) {
argc = 2;
fflg++;
}
引数にファイル名が指定されていない場合にフラグを立てます。ファイル名が指定されている時とされていない時でcat
コマンドは大きく挙動が変わります。
次のコードです。
while (--argc > 0) {
if (fflg || (*++argv)[0] == '-' && (*argv)[1] == '\0')
fi = stdin;
else {
if ((fi = fopen(*argv, "r")) == NULL) {
fprintf(stderr, "cat: can't open %s\n", *argv);
continue;
}
}
fstat(fileno(fi), &statb);
if (statb.st_dev == dev && statb.st_ino == ino) {
fprintf(stderr, "cat: input %s is output\n",
fflg ? "-" : *argv);
fclose(fi);
continue;
}
while ((c = getc(fi)) != EOF)
putchar(c);
if (fi != stdin)
fclose(fi);
}
return (0);
大きく見ると「引数で指定されたファイルを開き、1文字ずつ出力する」という処理ですが、色々細かい処理があります。
少し詳しくみてみましょう。
if (fflg || (*++argv)[0] == '-' && (*argv)[1] == '\0')
fi = stdin;
else {
if ((fi = fopen(*argv, "r")) == NULL) {
fprintf(stderr, "cat: can't open %s\n", *argv);
continue;
}
}
fflg
が立っている場合や、引数が特定の条件だった場合は、標準入力を入力元として設定します。
それ以外の場合は、入力元をファイルに設定し、指定されたファイルを開きます。
次のコードです。
fstat(fileno(fi), &statb);
if (statb.st_dev == dev && statb.st_ino == ino) {
fprintf(stderr, "cat: input %s is output\n",
fflg ? "-" : *argv);
fclose(fi);
continue;
}
入力元のファイルと出力元のファイルが同じかどうか判定し、同じだった場合は処理をスキップしています。
なぜこの処理が必要なのかというと、shell
のリダイレクトを使って入力元と出力元に設定した場合、この処理がないと無限に出力がされてしまうからです。
それを防ぐために、このような処理を実装しています。
次のコードです。
while ((c = getc(fi)) != EOF)
putchar(c);
if (fi != stdin)
fclose(fi);
入力元から1文字ずつ文字を出力しています。
全て出力が終わったら、入力元をfclose
します(標準入力はcloseする必要がないため、if文で判定処理を入れています)。
以上でcat
の処理は終了です。
所感
オプションやバッファリングの設定、特殊デバイス出力など、知らない機能だらけで知的好奇心がめちゃくちゃ刺激されました。
リダイレクトなどshellでの使われ方を意識しているコードがあることにも感心しました。
無限出力の制御はcat
コマンド側で対応するか、リダイレクトを使う側で対応するのか、仕様を決める側としてはちょっと悩ましい部分ではありますが、ユーザーにとっての使いやすさという意味ではコマンドに入れる方がいいのかなと、UXに関しても考えさせられました。
おわりに
これで「UNIXのコマンドのソースコード読んだことあるぜ」と胸を張って語れるようになりました。
ただ、これで終わりにするのはもったいないので、また機会があったらどんどんコードを読んでみたいと思います。
Discussion