实验六 信号处理
完整程序可以在这里下载:点击下载。
一、实验内容描述
实验目的
学习和掌握信号的处理方法,特别是 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 构建的原理。我学会了如何使用信号来控制程序的执行,以及如何处理不同信号之间的交互关系。这次实验也增强了我对系统编程的理解,让我更加熟悉和自信地处理类似任务。
标签:sset,SIGQUIT,alarm,pid,UNIX,handler,XMU,实验报告,buf From: https://www.cnblogs.com/hankeke303/p/18162994/XMU-UNIX-Lab6