CSAPP-Lab7-shellLab

实验概览

需要我们实现的函数:

  • eval:解析命令行 [约 70 行]
  • builtin_cmd:检测是否为内置命令quitfgbgjobs[约 25 行]
  • do_bgfg:实现内置命令bgfg[约 50 行]
  • waitfg:等待前台作业执行完成 [约 20 行]
  • sigchld_handler:处理SIGCHLD信号,即子进程停止或者终止 [约 80 行]
  • sigint_handler:处理SIGINT信号,即来自键盘的中断ctrl-c[约 15 行]
  • sigtstp_handler:处理SIGTSTP信号,即来自终端的停止信号 [约 15 行]

作者提供的帮助函数及功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* Here are helper routines that we've provided for you */
int parseline(const char *cmdline, char **argv); //解析命令行参数,如果后台运行则返回 1
void sigquit_handler(int sig);

void clearjob(struct job_t *job); //清除job结构体
void initjobs(struct job_t *jobs); //初始化jobs列表
int maxjid(struct job_t *jobs); //返回jobs列表中jid最大值
int addjob(struct job_t *jobs, pid_t pid, int state, char *cmdline); //在jobs列表中添加job
int deletejob(struct job_t *jobs, pid_t pid); //在jobs列表中删除pid对应的job
pid_t fgpid(struct job_t *jobs); //返回前台运行的job的pid
struct job_t *getjobpid(struct job_t *jobs, pid_t pid); //返回对应pid的job
struct job_t *getjobjid(struct job_t *jobs, int jid); //返回对应jid的job
int pid2jid(pid_t pid); //返回对应pid的job的jid
void listjobs(struct job_t *jobs); //打印jobs列表

void usage(void); //帮助信息
void unix_error(char *msg); //错误信息
void app_error(char *msg);
typedef void handler_t(int);
handler_t *Signal(int signum, handler_t *handler);

回顾

回收子进程

一个终止了但是还未被回收的进程称为僵死进程。对于一个长时间运行的程序(比如 Shell)来说,内核不会安排init进程去回收僵死进程,而它虽不运行却仍然消耗系统资源,因此实验要求我们回收所有的僵死进程。

waitpid是一个非常重要的函数,一个进程可以调用waitpid函数来等待它的子进程终止或停止,从而回收子进程,在本实验大量用到,我们必须学习它的用法:

这个函数用来挂起调用进程的执行,直到pid对应的等待集合的一个子进程的改变才返回,包括三种状态的改变:

  • 子进程终止
  • 子进程收到信号停止
  • 子进程收到信号重新执行

如果一个子进程在调用之前就已经终止了,那么函数就会立即返回,否则,就会阻塞,直到一个子进程改变状态。

等待集合以及监测那些状态都是用函数的参数确定的,函数定义如下:

1
pid_t waitpid(pid_t pid, int *wstatus, int options);

各参数含义及使用

  • pid:判定等待集合成员

    • pid > 0 : 等待集合为 pid 对应的单独子进程
    • pid = -1: 等待集合为所有的子进程
    • pid < -1: 等待集合为一个进程组,ID 为 pid 的绝对值
    • pid = 0 : 等待集合为一个进程组,ID 为调用进程的 pid
  • options:修改默认行为

    • WNOHANG:集合中任何子进程都未终止,立即返回 0
    • WUNTRACED:阻塞,直到一个进程终止或停止,返回 PID
    • WCONTINUED:阻塞,直到一个停止的进程收到 SIGCONT 信号重新开始执行
    • 也可以用或运算把 options 的选项组合起来。例如 WNOHANG | WUNTRACED 表示:立即返回,如果等待集合中的子进程都没有被停职或终止,则返回值为 0;如果有一个停止或终止,则返回值为该子进程的 PID
  • statusp:检查已回收子进程的退出状态

    • waitpid 会在 status 中放上关于导致返回的子进程的状态信息,很容易理解,这里就不再翻译了

并发编程原则

这里仅列出在本实验中用到的原则,后面的解析也会大量提及

  1. 注意保存和恢复 errno。很多函数会在出错返回式设置 errno,在处理程序中调用这样的函数可能会干扰主程序中其他依赖于 errno 的部分,解决办法是在进入处理函数时用局部变量保存它,运行完成后再将其恢复
  2. 访问全局数据时,阻塞所有信号。这里很容易理解,不再解释了
  3. 不可以用信号来对其它进程中发生的事情计数。未处理的信号是不排队的,即每种类型的信号最多只能有一个待处理信号。举例:如果父进程将要接受三个相同的信号,当处理程序还在处理一个信号时,第二个信号就会加入待处理信号集合,如果此时第三个信号到达,那么它就会被简单地丢弃,从而出现问题
  4. 注意考虑同步错误:竞争。我们在下面单独开一节说明

竞争

例子如下所示:

v2-5374b426b86ecb7c912783c964f02a94_720w

这是一个 Unix Shell 的框架,父进程在一个全局列表中记录子进程,并设置了一个 SIGCHLD 处理程序来回收子进程,乍一看没问题,但是考虑如下可能的事件序列:

  • 第 29 行,创建子进程运行
  • 假设子进程在父进程运行到 32 行,即运行addjob函数之前就结束了,并发送一个 SIGCHLD 信号
  • 父进程接收到信号,运行信号处理程序,调用deletejob函数,而这个job本来就没有添加入列表
  • 返回父进程,调用addjob函数,而这个子进程已经终止并回收了,job早就不存在了

也就是说,在这里,deletejob函数的调用发生在了addjos之前,导致错误。我们称addjobdeletejob存在竞争。

解决方法是在父进程folk前就阻塞SIGCHLD信号:

v2-8d7ed8b29310292108044a8f4ab923f8_720w

错误处理函数

当 Unix 系统及函数遇到错误时,它们通常会返回 -1,并设置全局整型变量errno来表示什么出错了。为了能让程序检查错误的同时代码不那么臃肿,按照书本推荐的方式,编写了一套后面将要用到的错误处理包装函数

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
/* Error wrapper function */
pid_t Fork(void);
void Sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
void Sigemptyset(sigset_t *set);
void Sigfillset(sigset_t *set);
void Sigaddset(sigset_t *set, int signum);
void Execve(const char *filename, char *const argv[], char *const envp[]);
void Setpgid(pid_t pid, pid_t pgid);
void Kill(pid_t pid, int sig);
pid_t Fork(void){
pid_t pid;
if((pid = fork()) < 0)
unix_error("Fork error");
return pid;
}
void Sigprocmask(int how, const sigset_t *set, sigset_t *oldset){
if(sigprocmask(how, set, oldset) < 0)
unix_error("Sigprocmask error");
}
void Sigemptyset(sigset_t *set){
if(sigemptyset(set) < 0)
unix_error("Sigprocmask error");
}
void Sigfillset(sigset_t *set){
if(sigfillset(set) < 0)
unix_error("Sigfillset error");
}
void Sigaddset(sigset_t *set, int signum){
if(sigaddset(set, signum) < 0)
unix_error("Sigaddset error");
}
void Execve(const char *filename, char *const argv[], char *const envp[]){
if(execve(filename, argv, envp) < 0){
printf("%s: Command not found.\n", argv[0]);
}
}
void Setpgid(pid_t pid, pid_t pgid){
if(setpgid(pid, pgid) < 0){
unix_error("Setpid error");
}
}
void Kill(pid_t pid, int sig){
if(kill(pid, sig) < 0){
unix_error("Kill error");
}
}

具体实现

trace1

不用写就过了

trace2

trace02.txt文件中只有quit,WAIT两条命令。

程序会首先执行 eval(),在 eval中进行判断(使用buildin_cmp()函数),如果发现命令不是内置命令,则会调用 fork()函数来新建一个子进程,在子进程中调用 execve()函数通过 argv[0]来寻找路径,并在子进程中运行路径中的可执行文件,如果找不到可执行文件,则说明命令为无效命令,输出命令无效,并用 exit(0)结束该子进程即可。

思路:首先从命令中提取参数,然后判断是否为内置命令,如果为内置命令,则直接在当前进程执行即可;如果不是内置命令,则需要新建一个子进程,并利用 execve 来通过参数给出的路径寻找出可执行文件并在子进程中执行,如果找不到该可执行文件,则输出命令未找到,并结束子进程。

可以看到无法正常终止,因为tsh的quit内置命令还未编写,所以不能正常退出

quit

先完成eval函数中和quit相关的部分

lab7p1

然后是builtin_cmd()

lab7p2

test3是运行一个前台job并quit,这两个可以一起验证

trace4

实现eval函数的后台作业功能

  • 在原有的eval函数基础之上添加将作业添加至后台作业管理的函数使用(addjobs())。
  • 加以信号的阻塞和取消阻塞。

为什么要阻塞,复习一下上面所说的竞争:

为了保证处理程序回收终止的子进程(delete job)在父进程(addjob)之后进行,否则父子进程之间会出现经典的同步错误—-竞争

因为当父进程创建一个子进程时,它就会将这个子进程添加到作业列表(addjobs)。当父进程在SIGCHLD处理程序中回收一个僵尸子进程时,就要从作业列表中删除子进程。理想状态下,这个过程很正确,但是往往真实的运行情况下,会出现问题,如下图:

aa287bdb6cc8432a9d231ebf912505e5

实现

使用一个标记符号:bg,标记这个作业是否应该在后台进行

1
2
bg = parseline(buf, argv);  //解析参数
state = bg? BG:FG;
  • wait fg

    这个函数从要求实现阻塞父进程,直到当前的前台进程不再是前台进程了。这里显然要显示地等待信号,我们先回顾一下相关知识

    1
    2
    3
    4
    5
    6
    7
    8
    9
    void waitfg(pid_t pid)
    {
    sigset_t mask;
    Sigemptyset(&mask);
    while (fgpid(jobs) != 0){
    sigsuspend(&mask); //暂停时取消阻塞,见sigsuspend用法
    }
    return;
    }
  • val

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    if(state==FG){
    Sigprocmask(SIG_BLOCK, &mask_all, NULL); //添加工作前阻塞所有信号
    addjob(jobs, pid, state, cmdline); //添加至作业列表
    Sigprocmask(SIG_SETMASK, &mask_one, NULL);
    waitfg(pid); //等待前台进程执行完毕
    }
    else{
    Sigprocmask(SIG_BLOCK, &mask_all, NULL); //添加工作前阻塞所有信号
    addjob(jobs, pid, state, cmdline); //添加至作业列表
    Sigprocmask(SIG_SETMASK, &mask_one, NULL);
    printf("[%d] (%d) %s",pid2jid(pid), pid, cmdline); //打印后台进程信息
    }

    解除阻塞:

    1
    Sigprocmask(SIG_SETMASK, &prev_one, NULL); 

trace 5

直接使用listjob方法

cecbef4b792c4c4fb5b00c2cf461a44b

trace06、trace07

要实现的功能是:trace06->将SIGINT信号转发到前台作业;

​ trace07->仅仅将SIGINT信号转发到前台作业;

因此这里放在一起实现。

即ctrl+C键盘中断

eval

1
2
3
4
5
6
if((pid = Fork()) == 0) {                           //创建子进程
Sigprocmask(SIG_SETMASK, &prev_one, NULL); //解除子进程的阻塞
Setpgid(0, 0); //创建新进程组,ID设置为进程PID
Execve(argv[0], argv, environ); //执行
exit(0); //子线程执行完毕后一定要退出
}

把子进程放到一个新进程组中,进程组ID与子进程PID相同,确保前台进程组只有一个进程,就是shell进程

  • 更改信号处理函数,实现转发信号给前台作业(包括进程组)

    9af4b069b117459f9eebec6091e99455

  • 同时修改sigchld_handler()函数

    为了区分进程终止的原因

    1. 是正常终止(exit或return)

    2. 还是因为收到其他信号如:SIGINT而终止。

      016cdb205d3641e692604f098652dfec


后面的先不做了