XMU《UNIX 系统程序设计》第六次实验报告(信号处理)
实验六 信号处理
完整程序可以在这里下载:点击下载。
一、实验内容描述
实验目的
学习和掌握信号的处理方法,特别是 sigaction,alarm,sigpending,sigsetjmp 和 siglongjmp 等函数的使用。
实验要求
-
编制具有简单执行时间限制功能的shell:
myshell [ -t <time> ]这个测试程序的功能类似实验 3,但是具有系统
shell(在cs8服务器上是bash)的全部功能。<time>是测试程序允许用户命令执行的时间限制,默认值为无限制。当用户命令的执行时间限制到达时,测试程序终止用户命令的执行,转而接收下一个用户命令。 -
myshell只在前台运行。 -
按
Ctrl-\键不是中断myshell程序的运行,而是中断当前用户命令的接收或终止当前用户命令的执行,转而接收下一个用户命令。 -
注意信号
SIGALRM和SIGQUIT之间嵌套关系的处理。 -
2023年12月30日23:59为实验完成截止期。
二、设计与实现
由于我们没有做实验三,因此这个实验其实是两个任务的合并。第一个任务是实现一个 shell,第二个任务是实现时间限制功能。
实现 shell
比起实验三,这个实验有关 shell 的任务要容易得多。因为不需要像实验三那样去模拟命令的执行,只需要按照提示使用系统 /bin/sh 来调用即可。
execl("/bin/sh", "sh", "-c", buf, (char *) 0)
命令提示符的打印和命令的读取
这一步很简单,只需要打印一个 > 作为命令提示符,然后等待用户输入命令即可。
我使用 fgets 来读入用户输入的命令,然后调用 eval 函数来处理有关命令执行的事情。
// in main
char cmdline[MAXLINE];
while (1) {
sigsetjmp(jmpbuf, 1);
printf("> ");
fgets(cmdline, MAXLINE, stdin);
if (feof(stdin)) exit(0);
eval(cmdline);
}
这里如果读取到了 EOF,那我就让这个 shell 退出(注意要求:要有系统 shell 的全部功能,因此这个细节也要实现上去),这样就可以通过 Ctrl-D 来退出 shell 了。
命令执行
我在 eval 函数中执行命令。函数接受命令内容为 cmdline 参数。
首先为了确保执行结果不出错,我将 cmdline 中头尾的空白字符全部删除,其结果我用 buf 变量表示:
char *buf = cmdline;
while (*buf != 0 && isspace(*buf)) ++buf;
for (char *p = buf + strlen(buf) - 1; p >= buf && isspace(*p); --p) *p = 0;
然后,我会调用 fork 来创建一个子进程,在子进程中,我使用 execl("/bin/sh", "sh", "-c", buf, (char *) 0) 来执行这个命令,并做相关的错误处理。
值得注意的是,这里的 pid 是一个全局变量,而且使用 volatile 修饰,以便在后续信号处理中可以直接判断当前执行的进程。
if ((pid = Fork()) == 0) {
if (execl("/bin/sh", "sh", "-c", buf, (char *) 0) < 0) {
printf("%s: Command not found.\n", buf);
exit(0);
}
}
接着,使用 waitpid(pid, &status, 0) 来等待子进程退出。如果这个函数的返回值小于零,说明子进程退出异常。这可能是因为在这个函数被调用前子进程就已经退出了(ECHILD),还有可能是子进程是由于后面会加上的 alarm 导致系统调用被打断而退出,这种情况下 errno 是 EINTR。我对这两种异常退出进行了特判,其余的异常则进行报错:
int status;
if (waitpid(pid, &status, 0) < 0) {
if (errno != ECHILD && errno != EINTR)
fprintf(stderr, "waitpid error: %s\n", strerror(errno));
}
随后,将 pid 设置为 \(0\),代码当前没有命令在执行。
quit 和 exit 的特殊处理
现在我们的模拟 shell 已经可以执行很多命令了,但是部分和 shell 本身相关的命令还没法正确执行。
其中就包括 quit/exit 这一组用来退出 shell 的命令,以及 cd 这个用来切换工作目录的命令。
对于 quit 和 exit 的判断很简单,只要 buf 的内容是 quit 或者 exit 我们就退出程序。
if (!strcmp(buf, "quit") || !strcmp(buf, "exit"))
exit(0);
cd 的特殊处理
在 /bin/sh 中执行 cd 并不会改变父进程 myshell 的工作目录,因此我们要直接在父进程切换工作目录。
这里可以使用 chdir 系统调用来实现。
具体地,首先判断命令是否为 cd ... 或者 cd 这样的命令。如果是单个 cd 就直接切换到 HOME 目录;如果还接了字符,那么就切换到后续字符对应的目录中。
if (!strncmp(buf, "cd", 2) && (buf[2] == 0 || isspace(buf[2]))) {
if (buf[2] == 0) {
Chdir(getenv("HOME"));
} else {
char *path = buf + 3;
while (*path != 0 && isspace(*path)) ++path;
Chdir(path);
}
return;
}
最终的 eval 函数
void eval(char *cmdline) {
char *buf = cmdline;
while (*buf != 0 && isspace(*buf)) ++buf;
for (char *p = buf + strlen(buf) - 1; p >= buf && isspace(*p); --p) *p = 0;
if (!strcmp(buf, "quit") || !strcmp(buf, "exit"))
exit(0);
if (!strncmp(buf, "cd", 2) && (buf[2] == 0 || isspace(buf[2]))) {
if (buf[2] == 0) {
Chdir(getenv("HOME"));
} else {
char *path = buf + 3;
while (*path != 0 && isspace(*path)) ++path;
Chdir(path);
}
return;
}
if (timelimit) alarm(timelimit);
if ((pid = Fork()) == 0) {
if (execl("/bin/sh", "sh", "-c", buf, (char *) 0) < 0) {
printf("%s: Command not found.\n", buf);
exit(0);
}
}
int status;
if (waitpid(pid, &status, 0) < 0) {
if (errno != ECHILD && errno != EINTR)
fprintf(stderr, "waitpid error: %s\n", strerror(errno));
}
if (timelimit) alarm(0);
pid = 0;
}
实现时间限制
参数判断
首先当然是要正确地读取命令行参数来获得时间限制啦。
if (argc != 1 && (argc != 3 || strcmp(argv[1], "-t"))) {
printf("Usage: %s [-t <time>]\n", argv[0]);
exit(0);
}
if (argc == 3 && (timelimit = atoi(argv[2])) <= 0) {
printf("<time> should be a positive integer.\nUsage: %s [-t <time>]\n", argv[0]);
exit(0);
}
在 eval 中调用 alarm
很容易想到的思路是,在 fork 进行之前,调用 alarm 设置时间限制。在 waitpid 返回后,也就说明子进程结束了,那么我们就再调用 alarm 取消时间限制。
// in eval function
if (timelimit) alarm(timelimit);
if ((pid = Fork()) == 0) {
if (execl("/bin/sh", "sh", "-c", buf, (char *) 0) < 0) {
printf("%s: Command not found.\n", buf);
exit(0);
}
}
int status;
if (waitpid(pid, &status, 0) < 0) {
if (errno != ECHILD && errno != EINTR)
fprintf(stderr, "waitpid error: %s\n", strerror(errno));
}
if (timelimit) alarm(0);
pid = 0;
理清 SIGALRM 和 SIGQUIT 的嵌套关系
SIGALRM 是 alarm 设置的定时时间到达的信号,而 SIGQUIT 是手动 Ctrl-\ 产生的信号。
首先,很重要的一点是,在执行其中任意一个信号的处理函数的过程中,都不能接受另一个信号的处理函数。因为任意一个信号的处理函数,都会关闭当前执行的进程,而我们不需要重复关闭。因此,这两个信号的处理函数执行过程中,都必须屏蔽另一个信号。
假设在一个信号处理函数的执行过程中屏蔽了另一个信号,但是屏蔽过程中另一个信号被发出,那么会在解除屏蔽后立刻执行另一个信号的信号处理函数。那么这是不是我们希望的呢?
如果一个在处理 SIGALRM 信号的过程中,用户手动通过 Ctrl-\ 发出了 SIGQUIT 信号,那么我们其实是不需要在 alarm 处理后再调用 SIGQUIT 处理函数重新关闭进程的,因此此时我们应该忽略未决的 SIGQUIT 信号。
如果一个在处理 SIGQUIT 信号的过程中,收到了 SIGALRM 信号,我们也是不需要再 SIGQUIT 关闭子进程后重新关闭的,因此我们也应该忽略未决的 SIGQUIT 信号。
也就是说,在任意一个信号处理函数解除对另一个信号的屏蔽之前,都应该忽略掉此时未决的另一个信号。
值得注意的是,如果在 SIGQUIT 函数,那么会关闭当前命令的子进程,而当前命令可能会带着 alarm 设置的定时,应该要把这个 alarm 清空。
设置 SIGALRM 和 SIGQUIT 的信号处理函数
我会使用 alarm_handler 和 quit_handler 来分别处理 SIGALRM 和 SIGQUIT 两个信号,因此我要在 shell 打印命令提示符之前设置它们为对应的信号处理函数。
sigset_t sset;
sigemptyset(&sset), sigaddset(&sset, SIGQUIT);
Signal_mask(SIGALRM, alarm_handler, sset);
sigemptyset(&sset), sigaddset(&sset, SIGALRM);
Signal_mask(SIGQUIT, quit_handler, sset);
// in main, befor while
Signal_mask 是我编写的函数,其作用是不仅可以设置信号对应的处理函数,还能设置处理函数执行过程中屏蔽的信号。其原型为:
void (*Signal_mask(int signo, void (*func)(int), sigset_t mask))(int) {
struct sigaction act, oact;
act.sa_handler = func;
act.sa_mask = mask;
act.sa_flags = 0;
if (signo == SIGALRM) {
#ifdef SA_INTERRUPT
act.sa_flags |= SA_INTERRUPT;
#endif
} else {
#ifdef SA_RESTART
act.sa_flags |= SA_RESTART;
#endif
}
if (sigaction(signo, &act, &oact) < 0) {
perror("sigaction error");
exit(0);
}
return oact.sa_handler;
}
这里,我设置在 alarm_handler 执行过程中,屏蔽 SIGQUIT 信号;在 quit_handler 执行过程中,屏蔽 SIGALRM 信号。
alarm_handler
alarm_handler 做的事情很简单。
一旦调用 alarm_handler,只可能是定时器时间到了,那么只需要判断如果当前有正在执行的命令,就可以终止掉。
if (pid > 0) {
kill(-pid, SIGKILL);
printf("%d: Time limit Exceeded.\n", pid);
}
pid = 0;
在终止掉子进程之后,还应该记得设置 pid = 0,代表当前没有正在执行的子进程。
如上面分析的,在 alarm_handler 退出之前,要忽略掉所有的未决的 SIGQUIT 信号。可以如下处理:
sigset_t pendmask;
sigemptyset(&pendmask);
sigpending(&pendmask);
if (sigismember(&pendmask, SIGQUIT)) {
Signal(SIGQUIT, SIG_IGN);
sigset_t sset;
sigemptyset(&sset), sigaddset(&sset, SIGALRM);
Signal_mask(SIGQUIT, quit_handler, sset);
}
在这个代码中,我们调用 sigpending 获取了当前未决的信号,判断 SIGQUIT 信号是否在其中,然后通过调用 Signal(SIGQUIT, SIG_IGN) 置 SIGQUIT 信号的处理函数为空(即忽略信号),来忽略掉未决的 SIGQUIT 信号,随后又调用 Signal_mask(SIGQUIT, quit_handler, SIGALRM) 来重新设置 SIGQUIT 信号的处理函数。
完整的 alarm_handler 如下:
void alarm_handler(int sig) {
if (pid > 0) {
kill(-pid, SIGKILL);
printf("%d: Time limit Exceeded.\n", pid);
}
pid = 0;
sigset_t pendmask;
sigemptyset(&pendmask);
sigpending(&pendmask);
if (sigismember(&pendmask, SIGQUIT)) {
Signal(SIGQUIT, SIG_IGN);
sigset_t sset;
sigemptyset(&sset), sigaddset(&sset, SIGALRM);
Signal_mask(SIGQUIT, quit_handler, sset);
}
}
初始的 quit_handler
quit_handler 与 alarm_handler 类似,只是在 pid = 0 前后还应该调用 alarm(0) 清除掉定时器。
void quit_handler(int sig) {
if (pid > 0) {
kill(-pid, SIGQUIT);
printf("%d: Quit.\n", pid);
}
pid = 0;
alarm(0);
sigset_t pendmask;
sigemptyset(&pendmask);
sigpending(&pendmask);
if (sigismember(&pendmask, SIGALRM)) {
Signal(SIGQUIT, SIG_IGN);
sigset_t sset;
sigemptyset(&sset), sigaddset(&sset, SIGQUIT);
Signal_mask(SIGQUIT, alarm_handler, sset);
}
}
中断当前用户命令的接收
根据实验要求:
按
Ctrl-\键不是中断myshell程序的运行,而是中断当前用户命令的接收或终止当前用户命令的执行,转而接收下一个用户命令。
因此,在 quit_handler 中,不应该仅仅关闭子进程。
在没有子进程(pid == 0)的时候,说明现在正在输入命令,那么此时我们应该让程序忽略掉这一行命令的接收,重新开始接受下一行命令。
这里可以使用 siglongjmp 实现。
我们在 main 函数中,每次 while 循环开始前,调用 sigsetjmp 设置一次 siglongjmp 的返回点:
while (1) {
sigsetjmp(jmpbuf, 1);
printf("> ");
fgets(cmdline, MAXLINE, stdin);
if (feof(stdin)) exit(0);
eval(cmdline);
}
注意,sigsetjmp 的第二个参数是 \(1\),代表每次 jmp 到这里的时候,应该恢复 set 时的屏蔽信号集。
然后,我们需要修改一下 quit_handler。在这个函数中,当我们判断到 pid == 0 时,就调用 siglongjmp 跳转到循环开始。为了让 pid 不被影响,我将 pid = 0 的修改移动到了函数结束:
void quit_handler(int sig) {
if (pid > 0) {
kill(-pid, SIGQUIT);
printf("%d: Quit.\n", pid);
}
alarm(0);
sigset_t pendmask;
sigemptyset(&pendmask);
sigpending(&pendmask);
if (sigismember(&pendmask, SIGALRM)) {
Signal(SIGQUIT, SIG_IGN);
sigset_t sset;
sigemptyset(&sset), sigaddset(&sset, SIGQUIT);
Signal_mask(SIGQUIT, alarm_handler, sset);
}
if (pid == 0) {
putchar('\n');
siglongjmp(jmpbuf, 1);
}
pid = 0;
}
值得一提的是,我在 siglongjmp 之前还打印了一个换行符,因为直接直接跳转的话,下一次的输入会黏在上一行的末尾,很难看。
三、实验结果
1. 编译程序
gcc myshell.c -o myshell
2. 运行截图
正常运行命令:

测试时间限制:

卡着时间限制 Ctrl-\:

打断用户输入:

四、实验体会
这次实验是一次对信号处理和 shell 构建的深入探索。任务很明确:实现一个带有执行时间限制功能的 shell,这对我来说是一个挑战,因为它要求我熟练使用 sigaction、alarm、sigpending、sigsetjmp 和 siglongjmp 等函数,同时合并两个任务:一个是实现一个简单的 shell,另一个是给 shell 添加执行时间限制的功能。
首先,我专注于构建一个基本的 shell。相较于先前的实验,这次任务要简单得多。通过使用系统的 /bin/sh 来调用命令,而无需模拟命令的执行,我能够完成命令提示符的打印和命令的读取。这一步非常简单,只需使用 fgets 来读取用户输入的命令,然后调用 eval 函数来处理这些命令。
在 eval 函数中,我处理了命令的执行。为了保证执行结果正确,我删除了命令中头尾的空白字符,并在子进程中使用 execl("/bin/sh", "sh", "-c", buf, (char *) 0) 来执行这些命令,并进行相关的错误处理。此外,我还特殊处理了 quit/exit 和 cd 这两个与 shell 相关的命令,确保其正确执行。
但是,实验的关键部分在于实现时间限制功能。我首先处理了命令行参数的判断,并在 eval 中调用 alarm 设置时间限制。同时,我理清了 SIGALRM 和 SIGQUIT 两个信号的嵌套关系。在 alarm_handler 和 quit_handler 函数中,我处理了这两个信号的情况,保证在信号处理函数执行过程中不会被另一个信号的处理函数打断,从而确保程序的稳定性。
另外,在实验中,我实现了中断当前用户命令的接收的功能。当用户手动中断命令接收时,通过 siglongjmp 跳转到循环开始,重新开始接收下一个用户命令。
在测试阶段,我展示了程序的正常运行、时间限制、中断命令和中断用户输入等情况的截图,证明了程序在各种情况下的有效运行。
总体而言,这次实验提供了一个很好的机会,让我更深入地理解了信号处理和 shell 构建的原理。我学会了如何使用信号来控制程序的执行,以及如何处理不同信号之间的交互关系。这次实验也增强了我对系统编程的理解,让我更加熟悉和自信地处理类似任务。

浙公网安备 33010602011771号