hnu 计算机系统 ShellLab
0 准备工作
shellLab有以下三个文件,查看readme
使用命令tar xvf shelab-handout.tar 解压缩文件;
使用命令 make 去编译和链接一些测试例程;
查看ReadMe,以下为中文翻译
1 |
|
查看makefile文件
- test* 指令(* 表示01到16的其中一个数)
运行trace01.txt到trace16.txt,调用我们自己实现的tsh - rtest* 指令
运行trace01.txt到trace16.txt,调用参考实现的tshref,用于对比测试结果 - clean* 指令
删除所有生成的文件($(FILES)
、.o
文件和临时文件*~
)。
通过make all可以编译所有目标文件
通过make test* 或 make rtest* 可以运行单个测试用例
查看trace.txt文件*
查看trace文件看看:
在后台运行myspin程序,并停留1秒。
看到这实际上是一个指令的集合,用于测试我们的tinyshell。
1 实验过程
我们要实现7个函数
1.1 解读未修改的tsh.c函数
- 宏定义
- 核心数据结构
- 定义我们要实现的函数
- 一些辅助函数
- main函数
main函数,作用是初始化(设置stderr重定向,解析命令行参数-v,-h,-p,初始化joblist);配置信号,注册信号处理器SIGINT,SIGTSTP.SIGCHLD,SIGQUIT;并在主循环中实现标准shell的REPL流程(显示提示符->读取命令->执行命令(eval)->刷新输出
1.2 eval函数实现
实现思路:
调用parseline函数,返回bg值,该值表示新进程的前台/后台状态
调用builtin_command函数,检查第一个命令行参数是不是内置的外壳命令,是的话立刻解释该命令并返回,如果是quit命令则立刻终止shell
内置命令如下
如果builtin_command返回0,那么调用fork创建一个子进程,并通过execve在子进程中执行程序;
- 如果bg=1即用户要求在后台运行该程序,则回到循环的顶部,等待下一个命令行;
- 如果bg=0即用户要求在前台运行该程序,则调用waitpid函数等待作业终止;
屏蔽信号量:
- 父进程在fork前要用sigprocmask屏蔽sigchild信号量,然后再调用addjob将子进程加入作业列表,由于子进程继承了父进程的阻塞向量,所以必须先解除阻塞SIGCHLD信号再执行新程序;
- 防止fork之后调度执行子进程并在addjob之前结束子进程,此时SIGCHLD信号使父进程将子进程回收,这会导致 addjob 和 deletejob 函数执行错位,结果是删除一个不存在的进程号,添加一个不存在且永不会被删除的进程号。
1.3 int builtin_cmd(char ** argv)
实现思路:
builtin_cmd函数实现内置命令
- 若命令为quit,调用exit实现退出
- 若命令为bg或fg,调用do_fgbg函数实现前台或后台进程的操作
- 若命令为jobs,调用listjobs函数打印后台作业
- 若都不满足,则不是内置命令,返回0
代码实现:
1.4 void do_bgfg(char ** argv)
这个函数要实现内置命令bg和fg,这两个命令的功能如下“
bg <job>
:通过向<job>
对应的作业发送SIGCONT
信号来使它重启并放在后台运行fg <job>
:通过向<job>
对应的作业发送SIGCONT
信号来使它重启并放在前台运行- 输入时后面的参数有
%
则代表jid
,没有则代表pid
实现思路:
参数检查:如果bg/fg命令后无参数,则无效
参数解析: 通过读取命令行的第一个字符是否为%来判断id类型是jobid还是pid
使用
strtol
安全地转换字符串为数字根据解析出的ID查找对应的作业:
- 对于JID,调用
getjobjid
查找 - 对于PID,调用
getjobpid
查找
- 对于JID,调用
如果找不到对应的作业,输出错误信息并返回
根据命令类型(
bg
或fg
)执行不同操作:-
bg
命令:- 将作业状态设为
BG
(后台) - 向整个进程组发送
SIGCONT
信号继续运行 - 打印作业信息(JID、PID和命令行)
- 将作业状态设为
-
fg
命令:- 将作业状态设为
FG
(前台) - 向整个进程组发送
SIGCONT
信号继续运行 - 调用
waitfg
等待前台作业完成
- 将作业状态设为
-
使用
unix_error
处理系统调用错误对非法命令类型输出内部错误信息并退出
[!NOTE]
strtol函数原型:long int strtol(const char *nptr, char **endptr, int base);strtol函数会将参数nptr字符串根据参数base来转换成长整型数,参数base范围从2至36。
[!NOTE]
kill函数原型: int kill(pid_t pid,int signo)
pid > 0:将信号发送给进程 ID 为 pid 的进程。
pid ==0:将信号发送给与发送进程属于同一进程组的所有进程。
pid < 0:将信号发送给进程组 ID 等于 pid 的绝对值的所有进程。
pid ==-1:将信号发送给系统中所有进程。
代码实现:
1.5 waitfg(pid_t pid)
等待指定进程(pid
)的前台作业(FG
)完成。
实现思路:
- 获取目标作业信息:
- 通过
getjobpid(jobs, pid)
查找pid
对应的作业结构体job
。 - 如果作业不存在(如进程已终止或
pid
无效),直接返回。
- 通过
- 初始化空信号集:
- 使用
sigemptyset(&wait)
初始化一个空的信号集wait
,表示不阻塞任何信号。 - 如果初始化失败,调用
unix_error
报错(通常终止进程)。
- 使用
- 循环等待前台任务完成:
- 核心逻辑:只要作业状态仍为前台运行(
FG
),就调用sigsuspend(&wait)
挂起当前进程,直到收到任意信号。 sigsuspend(&wait)
的作用:- 临时将进程的信号掩码设置为
wait
(空信号集),即允许所有信号中断休眠。 - 暂停进程,直到收到任意信号(如
SIGCHLD
、SIGINT
等)。 - 信号处理函数执行完毕后,恢复原来的信号掩码,并继续执行。
- 临时将进程的信号掩码设置为
- 核心逻辑:只要作业状态仍为前台运行(
- 为什么用
sigsuspend
而不是pause
?pause()
可能因信号竞争(race condition)导致永久阻塞(如信号在检查job->state
后、pause()
前到达)。sigsuspend
是原子操作,确保信号检查与挂起之间不会被中断。
- 退出条件:
- 当
job->state != FG
(如变为BG
后台作业或ST
已终止),循环结束,函数返回。
- 当
代码实现:
1.6 void sigchld_handler(int sig)
sigchld信号处理函数:检测到sigchld信号就调用该函数,非阻塞地回收所有子进程,并跟
实现思路:
- 非阻塞地回收所有子进程
-
waitpid(-1, &status, WNOHANG | WUNTRACED)
:
-
-
-1
:等待任意子进程。 -
WNOHANG
:非阻塞模式,立即返回(无子进程退出时返回0
)。 -
WUNTRACED
:同时检测暂停的子进程(如SIGTSTP
暂停)。
- 检查作业是否存在
- 通过
getjobpid
查找子进程对应的作业结构体job
。 - 如果作业不存在(如已被手动删除或
pid
无效),打印错误信息并返回。
- 通过
- 处理子进程的三种状态:
- 子进程暂停;
-
WIFSTOPPED(status)
:判断子进程是否被暂停(如SIGTSTP
)。 -
WSTOPSIG(status)
:获取导致暂停的信号编号(如SIGTSTP=20
)。 - 更新作业状态:将作业状态设为
ST
(挂起),供后续恢复(如 Shell 的fg
命令)。
-
- 子进程正常退出;
-
WIFEXITED(status)
:判断子进程是否正常退出(如调用exit
或return
)。 -
WEXITSTATUS(status)
:获取子进程的退出状态码(exit(123)
中的123
)。 - 删除作业:调用
deletejob
从作业列表中移除该进程,并打印详细信息(若verbose
为真)。
-
- 子进程被信号终止;
- 默认情况:子进程因未捕获的信号终止(如
SIGKILL
、SIGSEGV
)。 -
WTERMSIG(status)
:获取导致终止的信号编号(如SIGKILL=9
)。 - 删除作业:同正常退出逻辑,但额外打印终止信号信息。
- 默认情况:子进程因未捕获的信号终止(如
- 子进程暂停;
[!NOTE]
waitpid函数原型: pid_t waitpid(pid_t pid , int *status , int options)
总体:
如果没有子进程或其它错误原因,则返回-1;
如果成功回收子进程,则返回回收的那个子进程的ID;
如果第三个参数为WNOHANG,且子进程都在运行,则返回0
参数:
pid:从参数的名字上可以看出来这是一个进程的ID。但是这里pid的值不同时,会有不同的意义。
1.pid > 0时,只等待进程ID等于pid的子进程,只要该子进程不结束,就会一直等待下去;
2.pid = -1时,等待任何一个子进程的退出,此时作用和wait相同;
3.pid = 0时,等待同一个进程组中的任何子进程;
4.pid < -1时,等待一个指定进程组中的任何子进程,这个进程组的ID等于pid的绝对值。
options:options提供了一些额外的选项来控制waitpid
WNOHANG : 若子进程仍然在运行,则返回0
(注意只有设置了这个标志,waitpid才有可能返回0)
WUNTRACED : 如果子进程由于传递信号而停止,则马上返回。
(只有设置了这个标志,waitpid返回时,其WIFSTOPPED(status)才有可能返回true)
&status参数:
WIFEXITED(status)
如果正常退出(exit)返回非零值;这时可以用WEXITSTATUS(status) 得到退出编号(exit的参数)
WIFSIGNALED(status)
如果异常退出 (子进程接受到退出信号) 返回非零值;使用WTERMSIG (status) 得到使子进程退出得信号编号
WIFSTOPPED(status)
如果是暂停进程返回的状态,返回非零值;使用WSTOPSIG
代码实现:
1.7 void sigint_handler(int sig)
处理shell/前台进程发出的异常,这里我们只实现ctrl-c,将任务结束
实现思路:
- 获取前台进程的pid
- 若pid合法,调用kill函数终止所有前台进程
代码实现:
1.8 void sigtstp_handler(int sig);
处理由终端输入ctrl-z引起的异常,将前台作业暂停
实现思路跟以上差不多:
- 获取前台进程的pid
- 若pid合法,调用kill函数暂停前台进程并输出相关信息
代码实现:
2 测试
我们利用trace0*.txt和参考tsh对以上代码的准确性进行测试
命令
1 |
|
trace01 中终止eof
输出如下
trace02-进程内置命令测试(quit)
输出:
trace03-run一个前台job
eval函数解析txt文件第一行,发现不是内置命令,于是创建子进程,通过execve函数查找/bin/echo并运行该可执行文件,输出tsh> quit
输出:
trace04-运行后台job
先在前台执行echo命令,等待进程运行完回收子进程。&代表是一个后台程序,myspin睡眠1秒,然后停止。输出后台程序运行信息。
trace05-jobs内置命令
依次运行:前台任务cheo,后台任务myspin 2,前台任务echo,后台任务myspin 3,前台任务jobs,后台任务jobs,应该输出正在运行的两个后台任务
trace06-ctrl-c中断前台进程
接收到了中断信号SIGINT(即CTRL_C)那么结束前台进程
trace07-中断前台而不中断后台作业
依次运行后台作业myspin4 前台作业myspin5,发送中断信号,用jobs查看当前工作信息,对比是否只中断了前台作业
trace08-暂停前台而不暂停后台作业
依次运行后台作业myspin4 前台作业myspin5,发送sigtstp信号,用jobs查看当前工作信息,对比是否只暂停了前台作业
trace09-进程bg内置命令
依次运行后台作业myspin4 前台作业myspin5,暂停前台作业myspin5后,使用bg内置命令查看能否唤醒进程2,也就是被挂起的程序,此时Sigcont被发送到被挂起的任务,唤醒该任务,使任务在后台运行
trace10-进程fg内置命令
测试fg能否让一个被挂起的程序继续在前台运行。
trace11- 将SIGINT转发给前台进程组中的每个进程
创建前台任务mysplit,然后将SIGINT发给前台进程组中的每个进程,使用ps -a查看正在运行的所有进程,因为int信号被发送给前台进程组的每一个进程,所以所有进程停止
trace12-将SIGTSTP转发到前台进程组中的每个进程
跟上一题差不多。将SIGTSTP转发给前台进程组中的每个进程。
trace13-重新启动每个已暂停的进程
测试fg能否唤醒整个已暂停的进程组
trace14-简单的错误处理
测试一些简单错误
trace15-综合测试
trace16-其他进程信号测试

测试shell能否出来自其他进程的信号

3 实验总结
进程的创建和销毁由内核完成,我们没有权限操作,只能通过信号等对内核发起请求;我们可以通过shell沟通用户和内核进行进程的创建,销毁,中断,结束等。tsh是shell的简化版,维护4个信号(SIGINT,SIGTSTP,SIGCHLD,SIGCONT),只有4个内置命令(bg,fg,jobs,quit)。
阻塞信号集:当前进程要阻塞的信号的集合
未决信号集是当前进程中还处于未决状态的信号的集合,这两个集合存储在内核的PCB中。
[!NOTE]
下面以SIGINT为例说明信号未决信号集和阻塞信号集的关系:
当进程收到一个SIGINT信号(信号编号为2),首先这个信号会保存在未决信号集合中,此时对应的2号编号的这个位置上置为1,表示处于未决状态;在这个信号需要被处理之前首先要在阻塞信号集中的编号为2的位置上去检查该值是否为1:
如果为1,表示SIGNIT信号被当前进程阻塞了,这个信号暂时不被处理,所以未决信号集 上该位置上的值保持为1,表示该信号处于未决状态;
如果为0,表示SIGINT信号没有被当前进程阻塞,这个信号需要被处理,内核会对SIGINT信号进行处理(执行默认动作,忽略或者执行用户自定义的信号处理函数),并将未决信号集中编号为2的位置上将1变为0,表示该信号已经处理了,这个时间非常短暂,用户感知不到。
当SIGINT信号从阻塞信号集中解除阻塞之后,该信号就会被处理。
signal函数用sigaction系统进行封装,更优雅的进行信号处理
我们使用四个原子操作函数,对信号集set进行设置,修改
sigemptyset: int sigemptyset(sigset_t *set);
sigaddset: int sigaddset(sigset_t *set,int signum);
sigprocmask: int sigprocmask(int how, const sigset_t *restrict set, sigset_t *restrict oldset);
sigsuspend: int sigsuspend(const sigset_t *mask);
并发问题:
jobs数组为临界缓冲区,addjob和deletejob操作需要锁
sigchld处理函数处理SIGCHLD信号的时候,同时至多处理两个子进程的删除。此时需要用while信号处理同时并发的多个子进程
4 心得体会
实验过程中出现的问题
- 一开始在编写待实现函数时没什么思路,参考了网上的资料后茅塞顿开;
- 要注意父子进程的信号阻塞问题,不正确的信号阻塞会导致子进程无法创建或者创建后被kill等
- 在实现shell的信号处理时,最初没有正确处理SIGINT和SIGTSTP信号,前台进程组和后台进程组的信号传递容易混淆。
- waitpid的使用和WNOHANG选项的理解不够深入
体会
实验四主要是对信号这一章的考察,考察我们对信号量以及其在不同进程间的是如何进行沟通的,并能熟练运用,编写简单的Shell。这对我们的编程能力和实操能力都是一种考验。
通过亲手实现shell,我更加深入地理解了进程创建、终止和作业控制的机制。认识到了异步事件处理的重要性,掌握了更多Unix系统调用以及库函数的使用;对shell的工作原理有了更深入的认识,不再是命令行用户。
这个实验虽然挑战性很大,但通过解决这些问题,我对计算机系统底层机制的理解有了质的飞跃,也大大提升了系统编程和调试能力。