概要#
対応章:第八章
実験内容:タスク制御をサポートするシンプルな Unix シェルを書く
実験目的:プロセス制御と信号処理に慣れる
実験講義:http://csapp.cs.cmu.edu/3e/shlab.pdf
Unix シェルとは何か#
ウィキペディアにおける Unix シェルの説明:
A Unix shell is a command-line interpreter or shell that provides a command line user interface for Unix-like operating systems.
実験講義の説明に従い、Unix シェルには以下の特性があります:
- シェルはインタラクティブなコマンドラインインタプリタであり、ユーザーの代わりにプログラムを実行します。
- シェルは繰り返しプロンプトを表示し、ユーザーがコマンドラインを入力するのを待ち、その後対応する操作を実行します。
- コマンドラインは空白文字で区切られた ASCII テキストの単語の列です。最初の単語は組み込みコマンド名または実行可能ファイルのパス名であり、残りの単語はコマンドライン引数です。
- 最初の単語が組み込みコマンドである場合、シェルはそのコマンドを即座に実行します。そうでない場合、シェルは子プロセスを作成し、その子プロセス内でプログラムを読み込み実行します。これらの子プロセスは総称してジョブと呼ばれます。
- コマンドラインが
&
で終わる場合、ジョブはバックグラウンドで実行され、シェルはそれが終了するのを待たずに次のプロンプトを表示します。そうでない場合、ジョブはフォアグラウンドで実行され、シェルはそれが終了するのを待ちます。 - Unix シェルはジョブ制御をサポートしており、ユーザーがジョブをフォアグラウンドとバックグラウンドの間で移動させたり、ジョブ内のプロセスの状態(実行、停止、または終了)を変更したりすることを可能にします。
- Ctrl-C を押すと、フォアグラウンドジョブに SIGINT 信号が送信され、デフォルトの操作はプロセスを終了させることです。Ctrl-Z を押すと、フォアグラウンドジョブに SIGTSTP 信号が送信され、デフォルトの操作はプロセスを停止状態にすることです。
- Unix シェルは、ジョブ制御をサポートするために、
jobs
、bg
、fg
、kill
などのいくつかの組み込みコマンドを提供します。
私たちの tsh とは#
今回の実験では、tsh という名前のシンプルなシェルを実装します。これは以下の特性を持っています:
- プロンプトは文字列 "tsh>" であるべきです。
- ユーザーが入力するコマンドラインは、1 つの名前と 0 個以上の引数で構成され、すべての要素は 1 つ以上の空白で区切られます。名前が組み込みコマンドである場合、tsh はそれを即座に処理し、次のコマンドを待つべきです。そうでない場合、tsh は名前が実行可能ファイルのパスであると仮定し、初期子プロセスのコンテキスト内でそれを読み込み実行します。この場合、"ジョブ" はこの初期子プロセスを指します。
- tsh はパイプ (|) または I/O リダイレクト (< と >) をサポートする必要はありません。
- Ctrl-C (Ctrl-Z) を押すと、現在のフォアグラウンドジョブおよびそのすべての子プロセスに SIGINT (SIGTSTP) 信号を送信する必要があります。フォアグラウンドジョブがない場合、信号は無効であるべきです。
- コマンドラインが
&
で終わる場合、tsh はジョブをバックグラウンドで実行するべきです。そうでない場合、フォアグラウンドでジョブを実行するべきです。 - 各ジョブはプロセス ID (PID) またはジョブ ID (JID) で識別でき、JID は tsh が割り当てる正の整数であり、コマンドライン上でプレフィックス
%%
で示されます(例: "%5")。 - tsh は以下の組み込みコマンドをサポートするべきです:
quit
: シェルを終了します。jobs
: すべてのバックグラウンドジョブをリストします。bg <job>
: SIGCONT 信号を送信して<job>
を再起動し、バックグラウンドで実行します。<job>
は PID または JID です。fg <job>
: SIGCONT 信号を送信して<job>
を再起動し、フォアグラウンドで実行します。<job>
は PID または JID です。
- tsh はすべてのゾンビ子プロセスを回収するべきです。もし任意のジョブが捕捉されていない信号を受け取って終了した場合、tsh はこのイベントを認識し、ジョブの PID と問題を引き起こした信号の説明を含むメッセージを表示するべきです。
具体的には、実験コードフレームワークの基礎の上に以下の関数を実装する必要があります:
eval
:コマンドラインを解析する主要なプログラム。builtin_cmd
:組み込みコマンドを認識し実行します(quit
,fg
,bg
とjobs
)。do_fgbg
:bg
とfg
の 2 つの組み込みコマンドを実装します。waitfg
:フォアグラウンドタスクが完了するのを待ちます。sigchld_handler
:SIGCHILD 信号を処理します。sigint_handler
:SIGINT (ctrl-c) 信号を処理します。sigtstp_handler
:SIGTSTP (ctrl-z) 信号を処理します。
次に、これらの関数を合理的な順序で完成させ、解析します。
tsh の完成#
tsh を完成させる際には、公式に提供されたトレースファイルを読み、機能を一つずつ実装し、最後にエラーハンドリングなどの文を整え、ここで全体の結果を説明します。
タスク管理#
急がないでください。これらの関数を完成させる前に、まずコードフレームワークが提供するタスクリスト管理ツールを理解する必要があります。
シェル内のタスクの状態には以下の種類があります:
- FG(フォアグラウンド):フォアグラウンドで実行中
- BG(バックグラウンド):バックグラウンドで実行中
- ST(停止):停止中
すべてのタスクの中で、FG 状態にあるのは 1 つだけであり、タスクの状態の変化条件は以下の図に示されています:
tsh タスクのデータ構造は以下の通りです:
struct job_t { /* ジョブ構造体 */
pid_t pid; /* ジョブPID */
int jid; /* ジョブID [1, 2, ...] */
int state; /* UNDEF, BG, FG, または ST */
char cmdline[MAXLINE]; /* コマンドライン */
};
タスクリストはグローバル変数として保存されます:
struct job_t jobs[MAXJOBS]; /* ジョブリスト */
tsh は以下の関数を提供してタスクを管理します:
void clearjob(struct job_t *job);
void initjobs(struct job_t *jobs);
int maxjid(struct job_t *jobs);
int addjob(struct job_t *jobs, pid_t pid, int state, char *cmdline);
int deletejob(struct job_t *jobs, pid_t pid);
pid_t fgpid(struct job_t *jobs);
struct job_t *getjobpid(struct job_t *jobs, pid_t pid);
struct job_t *getjobjid(struct job_t *jobs, int jid);
int pid2jid(pid_t pid);
void listjobs(struct job_t *jobs);
これらの名前、パラメータ、戻り値を組み合わせることで、その機能を理解しやすくなりますので、ここでは説明しません。
waitfg#
最初に実装する関数は、pid がフォアグラウンドプロセスでなくなるまで現在のプロセスをブロックする機能を持っています。
void waitfg(pid_t pid) {
while (pid == fgpid(jobs)) {
sleep(0);
}
return;
}
builtin_command#
ユーザーが入力したのが組み込みコマンドである場合は即座に実行し、そうでない場合は 0 を返します。
int builtin_cmd(char **argv) {
char *cmd = argv[0];
if (strcmp(cmd, "quit") == 0) {
exit(0); /* 即座に実行 */
} else if (strcmp(cmd, "fg") == 0 || strcmp(cmd, "bg") == 0) {
do_bgfg(argv);
return 1; /* 組み込みコマンド */
} else if (strcmp(cmd, "jobs") == 0) {
listjobs(jobs);
return 1;
}
return 0; /* 組み込みコマンドではない */
}
入力のarg[0]
(すなわちプログラム名)を認識するだけで、3 つの組み込みコマンドのいずれかであれば、対応する関数を実行し、そうでなければ 0 を返します。
do_bgfg#
組み込みコマンド fg と bg を実行します。
void do_bgfg(char **argv) {
struct job_t *jobp = NULL;
if (argv[1] == NULL) {
printf("%s コマンドにはPIDまたは%%jobid引数が必要です\n", argv[0]);
return;
}
if (isdigit(argv[1][0])) { /* fg/bg pid */
pid_t pid = atoi(argv[1]);
if ((jobp = getjobpid(jobs, pid)) == 0) {
printf("(%d): そのようなプロセスはありません\n", pid);
return;
}
} else if (argv[1][0] == '%') { /* fg/bg %jid */
int jid = atoi(&argv[1][1]);
if ((jobp = getjobjid(jobs, jid)) == 0) {
printf("%s: そのようなジョブはありません\n", argv[1]);
return;
}
} else {
printf("%s: 引数はPIDまたは%%jobidでなければなりません\n", argv[0]);
return;
}
if (strcmp(argv[0], "bg") == 0) {
jobp->state = BG;
kill(-jobp->pid, SIGCONT);
printf("[%d] (%d) %s", jobp->jid, jobp->pid, jobp->cmdline);
} else if (strcmp(argv[0], "fg") == 0) {
jobp->state = FG;
kill(-jobp->pid, SIGCONT);
waitfg(jobp->pid);
}
return;
}
まず、パラメータargv
は二次元ポインタ配列であり、各要素は文字列へのポインタです。最初の要素argv[0]
はコマンドの名称(例:"fg")であり、続く要素argv[1]
、argv[2]
などはそのコマンドの引数であり、最後の要素は NULL で終了を示します。
- ユーザーが fg %1 を入力した場合、argv 配列は {"fg", "%1", NULL} になります。
- ユーザーが bg 2345 を入力した場合、argv 配列は {"bg", "2345", NULL} になります。
ここには 3 つの if 文があります:
最初の if 文:入力パラメータが空でないかを確認します。
2 番目の if 文:入力パラメータが pid 形式か % jid 形式かを確認し、現在のタスクポインタを取得します。
3 番目の if 文:現在実行しているのが fg か bg コマンドかを確認し、対応するようにタスクの state フィールドを設定し、プロセスに属するプロセスグループに SIGCONT 信号を送信します。fg コマンドの場合は、その終了を待つ必要があります。
eval#
ユーザーが入力したコマンドラインを実行します。
void eval(char *cmdline) {
char *argv[MAXARGS];
int bg = parseline(cmdline, argv);
pid_t pid;
sigset_t mask, prev_mask;
if (builtin_cmd(argv) == 0) { /* 組み込みコマンドでない場合 */
// SIGCHLD信号をブロック
sigemptyset(&mask);
sigaddset(&mask, SIGCHLD);
sigprocmask(SIG_BLOCK, &mask, &prev_mask);
pid = fork();
if (pid == 0) {
// 子プロセス内でSIGCHLD信号のブロックを解除
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
setpgid(0, 0);
if (execve(argv[0], argv, environ) < 0) {
printf("%s: コマンドが見つかりません\n", argv[0]);
exit(1);
}
} else {
if (!bg) {
addjob(jobs, pid, FG, cmdline);
} else {
addjob(jobs, pid, BG, cmdline);
struct job_t *job = getjobpid(jobs, pid);
printf("[%d] (%d) %s", job->jid, pid, cmdline);
}
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
if (!bg) {
waitfg(pid);
}
}
}
return;
}
parseline
を使用してコマンドラインを解析し、argv 配列と bg フラグを取得します。builtin_cmd
を使用して、組み込みコマンドであれば直接実行します。- 組み込みコマンドでない場合は、
fork
を使用して子プロセスを作成し、tsh のコピーを作成します。 - 子プロセス内で、
setpgid(0, 0)
を使用して子プロセスのプロセスグループ ID を自分のプロセス ID に設定します。これにより、子プロセスは自分をリーダーとするプロセスグループを作成します。そうでない場合、子プロセスは親プロセスのユーザーグループにデフォルトで参加します。子プロセスに信号を送信する際に何が起こるか考えてみてください。 - 親プロセス内でタスクリストにタスクを追加し、フォアグラウンドプロセスであればその終了を待ち、バックグラウンドプロセスであれば関連情報を表示します。
- 子プロセスを作成する前に SIG_CHILD 信号をブロックし、親プロセスが addjob を実行した後にブロックを解除します。そうでなければ、どうなるか考えてみてください。また、子プロセスは親プロセスのブロックベクタを引き継ぐため、子プロセス内でもブロックを解除する必要があります。
信号処理#
私たちの tsh は SIGINT または SIGTSTP を受け取ったときに終了すべきではなく、子プロセスに転送すべきです。また、SIG_CHILD を受け取ったときにはタスクシステムから子プロセスを削除する必要があります。そのため、signal 関数を使用して信号に関連付けられたデフォルトの動作を変更し、私たちのハンドラに置き換える必要があります。ハンドラを作成する際には、安全性を確保するためにいくつかの原則に従う必要があります:
- G0. ハンドラはできるだけシンプルにする。
- G2. errno を保存して復元する。
- G3. 共有グローバルデータ構造にアクセスする際にはすべての信号をブロックする。
sigchld_handler#
プロセスが SIGCHILD 信号を受け取ったときのハンドラ
void sigchld_handler(int sig) {
int olderrno = errno;
pid_t pid;
int status;
sigset_t mask, prev_mask;
sigfillset(&mask);
while ((pid = waitpid(-1, &status, WNOHANG | WUNTRACED)) > 0) {
if (WIFEXITED(status)) {
sigprocmask(SIG_BLOCK, &mask, &prev_mask);
deletejob(jobs, pid);
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
} else if (WIFSIGNALED(status)) {
sigprocmask(SIG_BLOCK, &mask, &prev_mask);
struct job_t *job = getjobpid(jobs, pid);
printf("ジョブ [%d] (%d) が信号 %d によって終了しました\n", job->jid, pid, WTERMSIG(status));
deletejob(jobs, pid);
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
} else if (WIFSTOPPED(status)) {
sigprocmask(SIG_BLOCK, &mask, &prev_mask);
struct job_t *job = getjobpid(jobs, pid);
job->state = ST;
printf("ジョブ [%d] (%d) が信号 %d によって停止しました\n", job->jid, pid, WSTOPSIG(status));
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
}
}
errno = olderrno;
return;
}
このプログラムには以下のいくつかの重要なポイントがあります:
- 開始時に errno を保存し、終了時に復元します。
- グローバル変数 jobs を操作する際には、
sigprocmask
を使用してすべての信号をブロックし、操作が完了した後にブロックベクタを復元します。 - SIGCHLD 信号がブロックされる可能性があるため、終了した子プロセスを取得するために while ループを使用します。
WIFEXITED()
、WIFSIGNALED()
、およびWIFSTOPPED()
を使用して子プロセスの状態を確認し、対応する操作を実行します。
sigint_handler#
プロセスが SIGINT 信号を受け取ったときのハンドラ
void sigint_handler(int sig) {
pid_t pid;
int olderrno = errno;
sigset_t mask, prev_mask;
sigfillset(&mask);
sigprocmask(SIG_BLOCK, &mask, &prev_mask);
pid = fgpid(jobs);
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
if (pid > 0) {
kill(-pid, sig);
}
errno = olderrno;
return;
}
- errno を保存し復元することに注意してください。
- グローバル変数を操作する際には、すべての信号をブロックします。
sigtstp_handler#
プロセスが SIGTSTP 信号を受け取ったときのハンドラ
void sigtstp_handler(int sig) {
pid_t pid;
int olderrno = errno;
sigset_t mask, prev_mask;
sigfillset(&mask);
sigprocmask(SIG_BLOCK, &mask, &prev_mask);
pid = fgpid(jobs);
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
if (pid > 0) {
kill(-pid, sig);
}
errno = olderrno;
return;
}
前のものと似ています。
まとめ#
この実験を通じて、タスク制御を持つシンプルなシェルを実装し、異常制御フローと信号についての理解を深めました。