壳实验,对应于csapp第8章,异常控制流;根据其提示可知,该实验要求编写一个完整的简单的壳;

在完成之后,有许多的检测关卡等待壳的成果;

实际上,此实验已经将大部分内容编写完毕,只要求完成7个函数的构造来完整壳即可;

这7个函数都是有关于信号,以及异常处理的;

现在来先说说这7个函数的大致功能以及目的:

  1. eval:解析和解释命令行的主例程;

  2. builtin_cmd:识别并解释内置命令;

    • 内置命令:

    • quit:退出shell;

    • fg:发送 SIGCONT(继续)来重启 job,位于前台运行;(前台只允许1个job运行)

    • bg:发送 SIGCONT(继续)来重启 job,位于后台运行;

    • jobs:列出所有后台作业;

  3. do_bgfg:执行bg和fg指令;

  4. waitfg:等待前台作业完成;

  5. sigchld_handler:SIGCHLD(子程序退出)信号处理;

  6. sigint_handler:SIGINT(中断)信号处理;

  7. sigtstp_handler:SIGTSTP(暂停)信号处理;

辅助的已有函数:

  • parseline:解析命令行构建argv列表;
  • clearjob:清除job结构体中的内容;
  • initjobs:初始化job列表;
  • maxjid:返回允许的最大job ID;
  • addjob:添加一个作业到job列表;
  • deletejob:从job列表中删除pid的作业;
  • fgpid:返回前台job的pid;
  • getjobpid:根据pid从job列表中找到作业;
  • getjobjid:根据job ID从job列表中找到作业;
  • pid2jid:根据pid返回对应jid;
  • listjobs:显示job列表;

之后有经典的实验约束规则:

  • 提示符为:tsh>
  • 用户键入的命令行应包含一个名称和零个或多个参数,所有参数均由一个或多个空格分隔。 如果名称是内置命令,则shell应该立即处理它并等待下一个命令行;否则,shell应该假定名称是可执行文件的路径,它在初始子进程的上下文中加载并运行;
  • shell不用支持管道或I/O重定向;
  • 输入 ctrl-c 导致 SIGINT (输入 ctrl-z 导致 SIGTSTP)发送到当前前台作业以及该作业的任何后代,如果没有前台作业,那么信号没有效果;
  • 如果命令行以 & 结束,则shell应该在后台运行作业,否则它将在前台运行该作业;
  • 每个作业都可以通过进程ID(PID)或作业ID(JID)进行标识,该ID是tsh分配的正整数;
  • shell支持内置命令;
  • shell应该回收所有僵死子进程,如果任何作业由于接收到未捕获到的信号而终止,则shell应该识别此事件并打印一条消息,其中包含该作业的PID和有问题的信号的描述;

先来看Shell的主函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
int main(int argc, char **argv) 
{
char c;
char cmdline[MAXLINE];
int emit_prompt = 1; /* emit prompt (default) */

/* Redirect stderr to stdout (so that driver will get all output
* on the pipe connected to stdout) */
dup2(1, 2);

/* Parse the command line */
while ((c = getopt(argc, argv, "hvp")) != EOF) {
switch (c) {
case 'h': /* print help message */
usage();
break;
case 'v': /* emit additional diagnostic info */
verbose = 1;
break;
case 'p': /* don't print a prompt */
emit_prompt = 0; /* handy for automatic testing */
break;
default:
usage();
}
}

/* Install the signal handlers */

/* These are the ones you will need to implement */
Signal(SIGINT, sigint_handler); /* ctrl-c */
Signal(SIGTSTP, sigtstp_handler); /* ctrl-z */
Signal(SIGCHLD, sigchld_handler); /* Terminated or stopped child */

/* This one provides a clean way to kill the shell */
Signal(SIGQUIT, sigquit_handler);

/* Initialize the job list */
initjobs(jobs);

/* Execute the shell's read/eval loop */
while (1) {

/* Read command line */
if (emit_prompt) {
printf("%s", prompt);
fflush(stdout);
}
if ((fgets(cmdline, MAXLINE, stdin) == NULL) && ferror(stdin))
app_error("fgets error");
if (feof(stdin)) { /* End of file (ctrl-d) */
fflush(stdout);
exit(0);
}

/* Evaluate the command line */
eval(cmdline);
fflush(stdout);
fflush(stdout);
}

exit(0); /* control never reaches here */
}

第一个while是在选择模式;输入h参数显示提示,输入v参数发出附加诊断信息,输入p不显示命令行;

之后需要捕获信号,就需要通过Signal函数将信号和对应处理函数绑定,然后进入第二个while使用eval一条一条地重复解析输入的内容;

I . Eval

第一解析命令行,可以套用parseline函数来帮忙,并根据结尾符号是否为 & 来判断前后台关系;

第二要做到的,查看解析出的 argv[0] 是否为内置命令,是,则转交给builtin_cmd函数,不是则创建子进程来运行;之后在shell中通过 addjob 来添加作业,如果是前台作业,就等待前台作业运行完毕,如果是后台作业,就执行解析下一条命令;

由此:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
void eval(char *cmdline)
{
static char array[MAXLINE];
char *buf = array; //cmdline容器
char *argv[MAXARGS]; //命令行参数
int bg; //前后台?
pid_t pid; //进程id
sigset_t mask, prev, all; //阻塞块

strcpy(buf,cmdline);
bg = parseline(buf,argv);

if(argv[0]==NULL)
return;

if(!builtin_cmd(argv)) //是否内置命令
{
sigemptyset(&mask); //清空mask块
sigaddset(&mask,SIGCHLD); //添加SIGCHLD到mask
sigfillset(&all); //所有信号进入all块
sigprocmask(SIG_BLOCK,&mask,&prev) //阻塞SIGCHLD信号,防止addjob和deletejob竞争

if((pid = fork()) == 0) //子进程
{
fflush(sdout); //printf("in process:%d\n",pid);
setpgid(0,0); //更换子进程进程组,以免和shell冲突
sigprocmask(SIG_SETMASK,&prev,NULL)
if(execve(argv[0],agrv,environ) < 0) //通过execve加载到子进程
{
printf("%s: Command not found\n", argv[0]);
exit(0);
}
}

//printf("parent:%d\n",getpid());
sigprocmask(SIG_BLOCK,&all,NULL) //访问job列表需阻塞所有信号
addjob(jobs,pid,bg?BG:FG,buf);
sigprocmask(SIG_SETMASK,&prev,NULL) //解除阻塞

if(bg)
printf("[%d] (%d) %s", pid2jid(pid), pid, buf);
else
waitfg(pid);
}
}

需要注意的有两点:

  • 阻塞SIGCHLD信号以防止addjob与deletejob竞争;
  • 访问全局数据jobs列表前阻塞所有信号;

为什么要在fork创建进程之前阻塞SIGCHLD呢?因为fork的进程可能在任意时刻暂停或终止;使得Shell跳转通过对应信号去处理程序,并在信号处理中对该作业进行修改;如果在 addjob 之前跳转,则会由于未保存该作业而导致错误,所以需要在fork之前阻塞,并在子进程中取消阻塞;

设置独立的进程组与Shell分开,以免Shell接收到的其他例如 ctrl-c 之类的信号而导致进程受到影响;

其次,从安全信号处理的角度,在修改读取jobs时如果不阻塞所有信号,则会有可能中断而导致jobs的各部分状态不同;

II . builtin_cmd

第一,前面可以知道用这个函数套用在eval里,使得分辨是否为内置命令,所以让内置命令返回1,而非内置命令返回0;

第二,已知这个函数会用于bg和fg的内置命令,所以可以套用do_bgfg的函数;

那么代码就可知了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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],"&"))
return 1;
if((!strcmp(argv[0],"bg")) || (!strcmp(argv[0],"fg")))
{
do_bgfg(argv);
return 1;
}
return 0;
}

III . do_bgfg

根据之前提到的,需要用用到SIGCONT信号,那么也需要使用kill来发送给整个进程组;

在这之前需要修改job结构的状态;

而在修改前台或者后台的再之前,需要寻找到这个工作的ID或者pid;

由此代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
void do_bgfg(char **argv)
{
int jid;
pid_t pid;
struct job_t *job; //单独的job指针
sigset_t mask,prev; //修改job之前阻塞所有信号

if(argv[1] == NULL)
{
printf("%s command requires PID or %%jobid argument\n",argv[0]);
return;
}

if(sscanf(argv[1],"%%%d",&jid) > 0)
{
job = getjobjid(jobs,jid);
if(job == NULL || job->state == UNDEF)
{
printf("%s: No such job\n", argv[1]);
return;
}
}
else if(sscanf(argv[1],"%d",&pid) > 0)
{
job = getjobpid(jobs, pid);
if(job == NULL || job->state == UNDEF)
{
printf("(%s): No such process\n", argv[1]);
return;
}
}
else
{
printf("%s: argument must be a PID or %%jobid\n", argv[0]);
return;
}
//上面的都是在排除异常情况

//修改job状态
sigfillset(&mask);
sigprocmask(SIG_BLOCK,&mask,&prev);

if(!strcmp(argv[0], "fg"))
job->state = FG;
else
job->state = BG;

sigprocmask(SIG_SETMASK,&prev,NULL);

pid = job->pid;
kill(-pid,SIGCONT); //负的则发送给进程组
if(!strcmp(argv[0], "fg"))
waitfg(pid);
else
printf("[%d] (%d) %s", job->jid, pid, job->cmdline);
}

IV . waitfg

等待前台作业完成就使用sleep挂起:

1
2
3
4
5
void waitfg(pid_t pid)
{
while(pid == fgpid(jobs))
sleep(1);
}

V . sigint_handler

当使用 Ctrl+c ,内核发送中断信号给这个Shell程序,而Shell程序通过kill发送信号给子进程,而停止信号也同理;

1
2
3
4
5
6
7
8
void sigint_handler(int sig){
int old_errno = errno; //首先需要保存原始的errno
pid_t pid = fgpid(jobs);
if(pid!=0)
kill(-pid,sig);

errno = old_errno;
}

保存之前的errno并在返回时重新赋值,是为了防止它被改变;

VI . sigstp_handler

1
2
3
4
5
6
7
8
void sigtstp_handler(int sig){
int old_errno = errno; //首先需要保存原始的errno
pid_t pid = fgpid(jobs);
if(pid!=0)
kill(-pid,sig);

errno = old_errno;
}

VII . sigchld_handler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void sigchld_handler(int sig)
{
int old_errno = errno; //首先需要保存原始的errno
pid_t pid;
sigset_t mask, prev;
int state; //保存waitpid的状态,用来判断子进程是终止还是停止
struct job_t *job;

sigfillset(&mask);
//由于信号不存在队列,而waitpid一次只会回收一个子进程,所以用while
while((pid = waitpid(-1, &state, WNOHANG | WUNTRACED)) > 0) //要检查停止和终止的,并且不要卡在这个循环中
{
//对全局结构变量jobs进行修改时,要阻塞所有信号
sigprocmask(SIG_BLOCK, &mask, &prev);
if(WIFEXITED(state)) //子进程通过调用exit或return正常终止,需要从jobs中删除该作业
{
deletejob(jobs, pid);
}
else if(WIFSIGNALED(state)) //子进程因为一个未捕获的信号终止
{
printf("Job [%d] (%d) terminated by signal %d\n", pid2jid(pid), pid, WTERMSIG(state));
deletejob(jobs, pid);
}
else if(WIFSTOPPED(state)) //如果子进程是停止的,需要修改改作业的状态
{
job = getjobpid(jobs, pid);
job->state = ST;
printf("Job [%d] (%d) stopped by signal %d\n", job->jid, pid, WSTOPSIG(state));
}
sigprocmask(SIG_SETMASK, &prev, NULL); //恢复信号接收
}
errno = old_errno;
}

总结:

这个实验主要是考察了安全信号处理内容,以及竞争关系;

帮助疏通了对Shell的理解:接受指令,处理指令,以及增加进程和如何回收进程;

对于小方向的话便是细节的考虑,阻塞顺序以及分类情况;