1. 前言 #
实验目的: 实现一个简易的 shell 程序,功能包括:
- 执行外部程序
- 支持四个内建命令:
quit
:退出终端jobs
:列出所有后台作业bg <job>
:继续在后台运行一个处于停止状态的后台作业fg <job>
:将后台作业转移到前台继续运行
- 支持以下快捷键:
Ctrl + C
:终止前台作业Ctrl + Z
:暂停前台作业
需要的前置知识:
- 进程:
- 进程的概念
- 进程如何抽象为逻辑控制流
- 多个逻辑控制流的并发性
- 进程控制:
- 获取进程 ID
- 创建和终止进程
- 回收子进程
- 加载并运行程序
- 信号:
- 信号的发送与接收
- 信号的阻塞
准备工作:
- 解压数据包:
tar xvf shlab-handout.tar
- 阅读
shelab-overview.pdf
实验环境:
WSL2 (Ubuntu 20.04)
2. 代码实现 #
2.1 eval #
定义: void eval(char *cmdline);
功能: 分析用户输入的命令并执行。
实现:
void eval(char *cmdline) {
char *argv[MAXARGS]; /* argument list execve() */
sigset_t mask; /* signal mask to block signals */
pid_t pid; /* process id */
int bg = parseline(cmdline, argv);
if (argv[0] == NULL) /* Ignore empty lines */
return;
if (builtin_cmd(argv)) /* Execute built-in commands immediately */
return;
sigemptyset(&mask);
sigaddset(&mask, SIGCHLD);
sigprocmask(SIG_BLOCK, &mask, NULL); /* Block SIGCHLD */
if ((pid = fork()) == 0) { /* Child process */
setpgid(0, 0); /* Set the child process group ID */
sigprocmask(SIG_UNBLOCK, &mask, NULL); /* Unblock SIGCHLD */
if (execve(argv[0], argv, environ) < 0) { /* Execute the command */
printf("%s: Command not found\n", argv[0]);
exit(0);
}
}
/* Parent process */
addjob(jobs, pid, bg ? BG : FG, cmdline);
if (!bg) /* Foreground job */
waitfg(pid); /* Wait for it to terminate */
else /* Background job */
printf("[%d] (%d) %s", pid2jid(pid), pid, cmdline);
sigprocmask(SIG_UNBLOCK, &mask, NULL); /* Unblock SIGCHLD */
}
说明:
- 屏蔽信号: 在
fork
前阻塞SIGCHLD
信号,避免竞态条件:- 主进程可能在调用
addjob
前切换到子进程。 - 子进程可能快速结束并发送
SIGCHLD
信号。 - 主进程调用
sigchld_handler
删除作业后,addjob
会因作业已被删除而导致混乱。
- 主进程可能在调用
- 设置进程组: 在
execve
前调用setpgid(0, 0)
:- 子进程成为新进程组的组长(进程组 ID 等于其 PID),确保信号正确转发。
- 支持前台进程创建子进程,形成进程树。
- 通过进程组 ID 向前台作业发送信号,可一次性覆盖所有相关进程。
2.2 builtin_cmd #
定义: int builtin_cmd(char **argv);
功能: 执行内置命令。
实现:
int builtin_cmd(char **argv) {
if (!strcmp(argv[0], "quit"))
exit(0);
if (!strcmp(argv[0], "jobs")) {
listjobs(jobs);
return 1;
}
if (!strcmp(argv[0], "bg") || !strcmp(argv[0], "fg")) {
do_bgfg(argv);
return 1;
}
return 0; /* Not a built-in command */
}
2.3 do_bgfg #
定义: void do_bgfg(char **argv);
功能: 执行 bg
和 fg
命令。
实现:
void do_bgfg(char **argv) {
if (argv[1] == NULL) { /* No argument provided */
printf("%s command requires PID or %%jobid argument\n", argv[0]);
return;
}
struct job_t *job;
pid_t pid;
int jid;
if (argv[1][0] == '%') { /* Job ID provided */
if ((jid = strtol(argv[1] + 1, NULL, 10)) == 0) {
printf("%s: argument must be a PID or %%jobid\n", argv[0]);
return;
}
if ((job = getjobjid(jobs, jid)) == NULL) {
printf("%s: No such job\n", argv[1]);
return;
}
} else { /* PID provided */
if ((pid = strtol(argv[1], NULL, 10)) == 0) {
printf("%s: argument must be a PID or %%jobid\n", argv[0]);
return;
}
if ((job = getjobpid(jobs, pid)) == NULL) {
printf("(%s): No such process\n", argv[1]);
return;
}
}
if (!strcmp(argv[0], "bg")) { /* bg command */
job->state = BG;
kill(-job->pid, SIGCONT);
printf("[%d] (%d) %s", job->jid, job->pid, job->cmdline);
} else { /* fg command */
job->state = FG;
kill(-job->pid, SIGCONT);
waitfg(job->pid);
}
}
说明:
由于发送信号时需要发送给当前job
对应的所有进程, 因此在 kill
函数中使用负 PID 值将信号发送给整个进程组
2.4 waitfg #
定义: void waitfg(pid_t pid);
功能: 阻塞直到前台作业完成。
实现:
void waitfg(pid_t pid) {
sigset_t mask;
sigemptyset(&mask);
while (pid == fgpid(jobs))
sigsuspend(&mask); /* Wait for foreground job to terminate */
}
说明:
- 为什么不使用简单的自旋:
- 自旋
while (pid == fgpid(jobs));
会导致 CPU 占用过高 - 不断检查条件的循环会浪费处理器资源
- 在多任务系统中表现不佳,影响其他进程的性能
- 自旋
sleep
函数的局限:- 虽然
sleep
可以减少 CPU 占用 - 但难以确定最佳睡眠时间:太短仍会浪费资源,太长则会导致响应延迟
- 无法准确响应子进程状态变化
- 虽然
pause
函数的风险:pause
会挂起进程直到收到信号- 存在竞态条件:如果在检查条件和调用
pause
之间信号已到达, 将导致pause
永久阻塞(信号已被处理但进程仍在等待)
sigsuspend
的优势:- 提供原子操作:在一个不可中断的步骤中同时解除信号屏蔽并挂起进程
- 避免了
pause
的竞态条件问题 - 等价于原子执行以下三个操作:
sigprocmask(SIG_SETMASK, &mask, &prev); // 临时设置信号掩码 pause(); // 等待信号 sigprocmask(SIG_SETMASK, &prev, NULL); // 恢复原信号掩码
- 确保高效等待且不会错过信号或产生死锁
2.5 sigchld_handler #
定义: void sigchld_handler(int sig);
功能: 处理 SIGCHLD
信号。
实现:
void sigchld_handler(int sig) {
struct job_t *job;
int status;
pid_t pid;
while ((pid = waitpid(-1, &status, WNOHANG | WUNTRACED)) > 0) {
job = getjobpid(jobs, pid);
if (WIFSTOPPED(status)) { /* Child stopped by signal */
job->state = ST;
printf("Job [%d] (%d) stopped by signal %d\n", job->jid, pid, WSTOPSIG(status));
continue;
}
if (WIFSIGNALED(status)) /* Child terminated by signal */
printf("Job [%d] (%d) terminated by signal %d\n", job->jid, pid, WTERMSIG(status));
deletejob(jobs, pid);
}
}
说明:
- 为何使用
while
循环回收子进程:- Linux 系统中,信号是不排队的。由于信号的传递仅仅是将对应的位置为1,因此如果在
sigchld_handler
执行期间,有多个子进程终止或停止,SIGCHLD
信号将会被丢弃而不是排队。 - 因此,信号处理函数需要循环调用
waitpid
,以确保所有已改变状态 (终止或停止) 的子进程都被正确处理和回收。
- Linux 系统中,信号是不排队的。由于信号的传递仅仅是将对应的位置为1,因此如果在
- 为何使用
WNOHANG | WUNTRACED
:WNOHANG
: 此选项使waitpid
成为非阻塞调用。- 如果当前没有子进程终止或停止,
waitpid
会立即返回 0,而不是挂起等待。 while
循环的条件(pid = waitpid(...)) > 0
会在没有更多子进程需要处理时终止循环。
- 如果当前没有子进程终止或停止,
WUNTRACED
: 此选项使得waitpid
不仅在子进程终止时返回,在子进程被信号停止 (例如SIGTSTP
,SIGSTOP
) 时也会返回。- Shell 需要跟踪作业的状态,包括它们是被终止了还是仅仅被停止了。通过
WUNTRACED
,处理函数可以捕获到子进程的停止事件,并相应地更新作业状态 (如代码中的job->state = ST;
)。
- Shell 需要跟踪作业的状态,包括它们是被终止了还是仅仅被停止了。通过
2.6 sigint_handler #
定义: void sigint_handler(int sig);
功能: 转发 SIGINT
信号给前台进程。
实现:
void sigint_handler(int sig) {
pid_t pid = fgpid(jobs); /* Get foreground job PID */
if (pid != 0)
kill(-pid, SIGINT); /* Send SIGINT to foreground job */
}
2.7 sigtstp_handler #
定义: void sigtstp_handler(int sig);
功能: 转发 SIGTSTP
信号给前台进程。
实现:
void sigtstp_handler(int sig) {
pid_t pid = fgpid(jobs); /* Get foreground job PID */
if (pid != 0)
kill(-pid, SIGTSTP); /* Send SIGTSTP to foreground job */
}
3. 测试 #
3.1 单个测试 #
- 每次更改
tsh.c
后, 使用make
编译新的可执行文件 - 使用
make test*
查看测试样例的输出结果 - 使用
make rtest*
查看参考样例的输出结果, 并与测试样例对比
查看Makefile
文件, 可以看出执行make test01
时, 实际上执行了以下命令:
./sdriver.pl -t trace01.txt -s ./tsh -a -p
即使用提供的perl
脚本, 在./tsh中执行trace01.txt中的命令, 并读取输出
为什么要使用Makefile?
考虑以下场景: 你正在维护一个多文件的工程, 对某些文件的代码进行了改动. 如果没有Makefile, 你每次都需要需要手动对每个文件重新输入数个参数的编译命令, 然后手动对目标文件进行链接, 这通常是无法接受的.
3.2 全部测试 #
- 在
Makefile
中添加:
test-all: test01 ... test16
rtest-all: rtest01 ... rtest16
- 执行以下命令:
make test-all > test_output.txt
make rtest-all > rtest_output.txt
- 在vscode中右键第一个文件, 选择
select for compare
; 再右键第二个文件, 选择compare with selected
, 即可对比全部输出结果.
4. 总结 #
从整体的角度来看, shell的核心逻辑是十分简单的, 无非就是先fork
, 后exec
, 外加处理一些内置命令. 下方的伪代码展示了一个最简单的shell:
while true:
command = read()
if command == "quit":
break
pid = fork()
if pid == 0:
exec(command)
else:
wait(pid)
但是实际的代码要更加复杂一些. 事实上, 所有的代码都是源于功能的需要而产生的, 因此我们可以从需求的角度来回顾代码.
4.1 任务 #
本质上, 引入job
的目的在于我们希望shell能够同时管理多个进程, 而不是只运行一个进程.
- 首先, 我们需要保存每个进程的
pid
- 为了便于用户操作和shell内部管理, 我们再为每个任务分配一个
jid
- 此时一个shell可以管理多个进程, 但是我们一般希望将需要频繁交互的任务放在前台, 而需要长时间运行的任务需要处于后台, 有时我们还会挂起某些任务, 因此还需要维护每个任务的
state
- 最后, 为了方便调试, 我们为每个任务保存运行它的指令
cmdline
这样我们就得到了tsh.c中的job_t
结构:
struct job_t { /* The job struct */
pid_t pid; /* job PID */
int jid; /* job ID [1, 2, ...] */
int state; /* UNDEF, BG, FG, or ST */
char cmdline[MAXLINE]; /* command line */
};
struct job_t jobs[MAXJOBS]; /* The job list */
代码实现:
eval
中加入addjob
builtin_cmd
中支持jobs
命令do_bgfg
中能够修改任务的状态waitfg
中能够获取pid
是否为前台任务sigchld_handler
时对于暂停的信号修改对应任务状态sigint_handler
和sigtstp_handler
中要将信号发给前台任务
4.2 信号 #
信号的引入可能是更加必要且有用的. 有以下原因
- 管理多个进程时, 我们不能对每个进程都显式地使用
wait
, 尤其因为后台进程是非阻塞的, 我们不知道其何时会完成. 因此我们需要通过其完成时发送的SIGCHLD
进行回收 - 有时我们会希望暂停或是终止某个任务, 或者将某个挂起的任务继续运行, 这些都需要通过信号来实现
代码实现
eval
中为防止与job
的竞态, 需要阻塞信号do_bgfg
时发送SIGCONT
信号waitfg
中使用sigsuspend
来等待前台进程- 增加三个对应的
sig_handler
4.3 进程组 #
引入进程组的原因与信号的发送密切相关.
在发送信号时, shell需要正确的转发信号. 在linux中, 信号的发送是通过进程组实现的. 如果不设置进程组, 子进程会默认与父进程同属一个进程组, 因此需要为每个新进程单独设立进程组.
代码实现
eval
中在exec
前setpgid
- 使用
kill
发送信号时pid
参数需要取负值