お題:"script(1)"コマンドの仕組みを追跡せよ
※最初に種を明かすと、仮想端末の説明は "APUE: Advanced Programming in the UNIX Environment" に依っています。またAPUEにはscriptコマンドの概要も図入りで載っていたりします。
実行ファイルとソースファイルの位置:
$ which script /usr/bin/script $ cd /usr/src/usr.bin/script/ $ ls CVS/ Makefile script.1 script.c
Makefileもシンプル。libutil.soをリンクしている。
# $NetBSD: Makefile,v 1.3 1994/12/21 08:55:39 jtc Exp $ # @(#)Makefile 8.1 (Berkeley) 6/6/93 PROG= script LDADD= -lutil DPADD= ${LIBUTIL} .include <bsd.prog.mk>
ここで "man 3 util" してみると、libutilに収録されているシステムユーティリティ用の関数の一覧が表示される。
「こんな関数があったんだ~」という感じ。知らないと損かも。
今回のターゲットである script.c に話を戻すと、行数も248行とコンパクトにまとまっている。
$ wc -l script.c 248 script.c
NetBSD1.6でのscriptコマンドの動作をおさらいしておく。
script [-a] [file]
最新のBSDやLinuxでは、他にも幾つかのオプションが使えるようになっている。
NetBSD1.6の段階では、"-a"によるファイル追記モードと、file名指定による保存先ファイル名の2つしかオプションが用意されていない。
fileが指定されない場合は"typescript"というファイル名に保存される。
「デーモン君のソース探検」では早速script.cの解析に入っていくが、今回の読書メモでは先に仮想端末についてまとめてしまう。本の方でも、一旦script.cの解析で手詰まりになり、仮想端末の解説が入った後、不明点を解消していく流れになっている。
仮想端末については "man 4 pty" で調べることができる。
ただしBSD系列の仮想端末と、SUSv2(Unix98)の仮想端末とではデバイスファイル名などの仕様が異なっている点に注意が必要。
参考として、JManからの仮想端末関連manページを2点、また仮想端末のWikipediaへのリンクを載せる。
仮想端末は、物理的な端末の入出力を任意のプログラム(ソフトウェア)で代替・エミュレーション出来るようにする仕組みである。
以下の図は典型的な構成例で、APUEから拝借して枠内のテキストだけ若干調整した。
+------------------+ fork +------------------+ | user process (1) |------------>| user process (2) | +------------------+ exec +------------------+ ^ ^ stdin, stdout, stderr | | ---------|----------------------------------|----------------- | | kernel world v v +------------------+ +------------------+ | read(2)/write(2) | | read(2)/write(2) | +------------------+ +------------------+ | ^ | ^ | | | | | | v | | | +-----------------+ | | | terminal | | | | line descipline | | | +-----------------+ | | | ^ | | | | v | v | +-----------------+ +-----------------+ | pseudo-terminal |--------------->| pseudo-terminal | | MASTER |<---------------| SLAVE | +-----------------+ +-----------------+
"user process (1)"が、端末の入出力をエミュレーションするプログラムに相当する。telnetやsshなど、ネットワーク経由でのターミナル入出力を制御するのがこれに相当する。"user process (2)"がエミュレーション環境で動く、実際にユーザが操作するプログラムに相当する。大抵はシェルが動作することになると思われる。
"terminal line descipline"というのはcanonicalモード用のモジュール。canonicalモードというのは、入出力を行単位でまとめて処理するモードを指す。non-canonicalモードは行単位でまとめずそのまま処理するモードを指す。
"pseudo-terminal {MASTER|SLAVE}"が仮想端末の中心であり、2つに分割されている。ファイルシステム上はデバイスファイルで操作できるようになっている。
仮想端末の仕組みをややこしくしているのは以下の3点であると思われる。
まずMASTER/SLAVEのファイル名の命名ルールについて見てみる。
BSD系列:(NetBSD1.6のmanページより, なお若干Wikipediaの記述とズレがある。)
/dev/pty[p-zP-T][0-9a-zA-Z] master pseudo terminals /dev/tty[p-zP-T][0-9a-zA-Z] slave pseudo terminals
Unix98系列:(Linux JManのpty(7)より)
/dev/ptmx (Unix 98 マスタ・クローン・デバイス) /dev/pts/* (Unix 98 スレーブデバイス)
Unix98系列の場合、"/dev/ptmx"をMASTERデバイスとして開くと、自動的に対応する "/dev/pts/(自動連番)" ノードが生成されるようになっている。
命名ルールが異なるというものの、実際に仮想端末を処理する場合はデバイスファイル名を意識する必要は無い。ライブラリ関数が用意されており、適切なデバイスファイルを自動的に選択してくれるようになっている。BSD系列であれば "openpty(3)", Unix98系列であれば "posix_openpt(3)" がこれに相当する。
続いてMASTER/SLAVEデバイスファイルの操作について見てみる。
デバイスファイルを通常のシステムコールで操作しようとしても仮想端末として利用することは出来ない。MASTER/SLAVEをペアで操作して始めて1セットの仮想端末となるため、前述のように専用のライブラリ関数が用意されておりそれを使うことになる。
ところがBSD系列とUnix98系列で関数名や呼び出すお作法が異なってしまっている。
BSD系列:
openpty(3)/login_tty(3)/forkpty(3)
Unix98系列:
posix_openpt(3)/grantpt(3)/unlockpt(3)/ptsname(3)
これらの詳細までは今回は踏み込まない。
今回の読書メモとしては、仮想端末のまとめをここで一区切りとする。ここまでの知識をベースにすると、script.cも大分読みやすくなる。
ではscript.cの探検を始める。まずはグローバル変数の宣言部:解説は各行末コメント参照。
FILE *fscript; /* 保存先ファイルのFILE構造体ポインタ */ int master, slave; /* openpty(3)で取得されるMASTER/SLAVEのファイル記述子 */ int child, subchild; /* main()内でのfork(2)したプロセスID保存先 */ int outcc; /* バッファフラッシュ判定用出力バイト数 */ char *fname; /* 保存先ファイル名 */ struct termios tt; /* scriptコマンド実行側端末情報 */
各変数の使われ方は、追々出てくるソースコードを参照。
main関数を見てみる。まず冒頭はローカル変数の宣言、オプションの解析、保存用のファイルをオープンしている。"-a"オプションに応じてfopen(3)の引数を調整している。保存先ファイルのFILE構造体ポインタはグローバル変数のfscriptに保存される。ファイル名は同様にfnameに保存される。
int main(argc, argv) int argc; char *argv[]; { int cc; struct termios rtt; struct winsize win; int aflg, ch; char ibuf[BUFSIZ]; aflg = 0; while ((ch = getopt(argc, argv, "a")) != -1) switch(ch) { case 'a': aflg = 1; break; case '?': default: (void)fprintf(stderr, "usage: script [-a] [file]\n"); exit(1); } argc -= optind; argv += optind; if (argc > 0) fname = argv[0]; else fname = "typescript"; if ((fscript = fopen(fname, aflg ? "a" : "w")) == NULL) err(1, "fopen %s", fname);
続いて現在の端末情報とウインドウサイズを取得し、openpty(3)を呼び出している。slave側には現在の端末情報とウインドウサイズがコピーされる。
(void)tcgetattr(STDIN_FILENO, &tt); (void)ioctl(STDIN_FILENO, TIOCGWINSZ, &win); if (openpty(&master, &slave, NULL, &tt, &win) == -1) err(1, "openpty");
この時点でmaster, slaveには仮想端末用のファイル記述子がセットされている。
続いてcfmakeraw(3)により端末の入出力処理をスキップさせ、さらに端末へのECHOを無効化する。もちろんscriptコマンド終了後に戻すので、この処理は上で取得したtermiosのコピーに対して行われる。
(void)printf("Script started, output file is %s\n", fname); rtt = tt; cfmakeraw(&rtt); rtt.c_lflag &= ~ECHO; (void)tcsetattr(STDIN_FILENO, TCSAFLUSH, &rtt);
いよいよfork(2)で子プロセスを生成する。
(void)signal(SIGCHLD, finish); child = fork(); if (child < 0) { warn("fork"); fail(); } if (child == 0) { subchild = child = fork(); if (child < 0) { warn("fork"); fail(); } if (child) dooutput(); else doshell(); }
ここで、fork(2)が2回呼ばれている点がポイントとなる。main関数を最後まで見終わった後に、2段fork(2)について詳しく見てみる。
script自体のプロセスは、保存先ファイルへのFILE構造体ポインタは閉じてしまい、以降はscript自体のプロセスが動作する端末への入力をそのままmaster側へ出力する、ブリッジ動作になる。
(void)fclose(fscript); while ((cc = read(STDIN_FILENO, ibuf, BUFSIZ)) > 0) (void)write(master, ibuf, cc); done(); /* NOTREACHED */ return (0); }
ここまでの流れをまとめると以下の図のようになる。script側のプロセスでは保存先ファイルへの書き込みが一切行われていない。ということは、doshell()またはdooutput()側で書き込み処理が実装されていると予想される。
また、まだこの時点ではforkされたプロセスはslave側とは結びついていない。
+--------+ fork(2) +-----------+ fork(2) +-----------+ | script |------------>| (child-0) |------------>| (child-1) | +--------+ +-----------+ +-----------+ | | | | | | v v v while() dooutput() doshell() / | ^ | | | | | input +--------+ +-------+ | +-------->| MASTER |<---->| SLAVE | STDIN +--------+ +-------+
doshell()という名前から、いかにもシェルをexec(2)するような関数に見える。先にdoshell()を見てみる。
void doshell() { char *shell; /* 環境変数からシェルの実行ファイル名を取得 */ shell = getenv("SHELL"); if (shell == NULL) shell = _PATH_BSHELL; /* 使わないmaster側のファイル記述子をクローズ */ (void)close(master); /* 使わないscript保存先ファイルポインタをクローズ */ (void)fclose(fscript); /* slaveを現在プロセスの制御端末に設定し、諸々準備 */ login_tty(slave); /* シェルの実行 */ execl(shell, shell, "-i", NULL); warn("execl %s", shell); fail(); }
これによりシェルプロセスにslaveが結びつき、前掲の図は次のように変化する。
+--------+ fork(2) +-----------+ fork(2) +-----------+ | script |------------>| (child-0) |------------>| (child-1) | +--------+ +-----------+ | v | | | | doshell() | | | | v | v v | SHELL | while() dooutput() +-----------+ / | ^ ^ | | | | v | | input +--------+ +-------+ | +-------->| MASTER |<------------------>| SLAVE | STDIN +--------+ +-------+
続いてdooutput()を見てみると、ようやく入出力をファイルに保存している処理が見つかった。
void dooutput() { struct itimerval value; int cc; time_t tvec; char obuf[BUFSIZ]; /* 使わなくなったSTDINをクローズ */ (void)close(STDIN_FILENO); tvec = time(NULL); (void)fprintf(fscript, "Script started on %s", ctime(&tvec)); /* 定期的にバッファをフラッシュするための準備 */ (void)signal(SIGALRM, scriptflush); value.it_interval.tv_sec = SECSPERMIN / 2; value.it_interval.tv_usec = 0; value.it_value = value.it_interval; (void)setitimer(ITIMER_REAL, &value, NULL); /* masterデバイスからの出力を・・・ */ for (;;) { cc = read(master, obuf, sizeof (obuf)); if (cc <= 0) break; /* 標準出力へコピー */ (void)write(1, obuf, cc); /* ファイルに保存 */ (void)fwrite(obuf, 1, cc, fscript); outcc += cc; } done(); }
これでようやく、前掲の図が完成する。
+--------+ fork(2) +-----------+ fork(2) +-----------+ | script |------------>| (child-0) |------------>| (child-1) | +--------+ | v | | v | | | dooutput()| | doshell() | | +-----------+ | v | v ^ v | SHELL | while() | +--> FILE +-----------+ / | | +--> STDOUT ^ ^ | ^ | | | |output v | | input +--------+ +-------+ | +------------>| MASTER |<--------------->| SLAVE | STDIN +--------+ +-------+
ところで、MASTER/SLAVE周辺のIOを見てみると次のようになっている。
+--------+ STDIN +-------+ STDIN -->...--->|->---->-|---------->| | | MASTER | | SLAVE | STDOUT<-+------<|-<----<-|<----------| | FILE<---+ +--------+ STDOUT +-------+
MASTERに対するSTDINが、FILE側に保存されるのは何でだろう・・・って思ったんですよ。一瞬。
でもよくよく考えたら、SLAVE側で動作するシェルや端末情報ってデフォルトならECHO有り、つまり標準入力から読み込んだ文字が標準出力側にも出力される設定なので、結局STDOUTに戻ってくるわけです。なのでMASTER側から読み出してFILEに保存出来る仕掛けになってるみたいですね。
特殊な仕掛けも使ってない素直な流れなのですが、これのお陰でgetpass(3)を使ったり端末情報でECHOを落とした場合、ちゃんとFILEには隠したい入力が保存されない仕掛けになるわけです。script(1)で、パスワード入力を伴うプログラムを実行しても問題ないのはこういう仕組みになっているわけですね。
最後にグローバル変数、outccとsubchildの使われどころを見てみる。
outccはdooutput()の中でカウントアップされ、scriptflush()の中でリセットされている。
void dooutput() { /* ... */ for (;;) { cc = read(master, obuf, sizeof (obuf)); if (cc <= 0) break; (void)write(1, obuf, cc); (void)fwrite(obuf, 1, cc, fscript); outcc += cc; } done(); } void scriptflush(signo) int signo; { if (outcc) { void)fflush(fscript); outcc = 0; } }
dooutput()ではfor()ループに入る前に、定期的にscriptflushを呼ぶように調整している。もし最後にscriptflush()されてから入力があれば(=outcc > 0)、fflush()によるバッファをフラッシュするようになっている。
subchildについてはdone()の中で使われている。
done()関数はmain()関数のプロセスと、dooutput()関数のプロセスで呼ばれる。dooutput()関数のプロセスまでsubchildグローバル変数は引き継がれている点に注意すれば、done()の内容も特に難しい所は無い。
void done() { time_t tvec; if (subchild) { /* dooutput()関数のプロセス終了処理 */ tvec = time(NULL); (void)fprintf(fscript,"\nScript done on %s", ctime(&tvec)); /* 保存先ファイルポインタをclose */ (void)fclose(fscript); /* 仮想端末のMASTER側をclose(SLAVE側はdoshell()の方でlogin_tty()内でclose) */ (void)close(master); } else { /* main()関数のプロセス終了処理 */ /* ECHOフラグを落としていた端末設定をバックアップから復元する */ (void)tcsetattr(STDIN_FILENO, TCSAFLUSH, &tt); (void)printf("Script done, output file is %s\n", fname); } exit(0); }
以上でscript(1)コマンドの仕組みが一通り解明出来た。なお、以下の疑問点については「デーモン君のソース探検」に書かれている為そちらを参照のこと。
今回のお題については、ここまで。
コメント