hnu 计算机系统 ShellLab

0 准备工作

shellLab有以下三个文件,查看readme
使用命令tar xvf shelab-handout.tar 解压缩文件;
使用命令 make 去编译和链接一些测试例程;

查看ReadMe,以下为中文翻译

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
################  
CS:APP Shell 实验
################

文件说明:

Makefile # 用于编译你的shell程序并运行测试
README # 本说明文件
tsh.c # 你需要编写并提交的shell程序源码
tshref # 参考shell的可执行文件

# 以下文件用于测试你的shell程序
sdriver.pl # 跟踪驱动式shell测试脚本
trace*.txt # 控制测试脚本的15个跟踪文件
tshref.out # 参考shell在所有15个测试用例上的输出示例

# 被跟踪文件调用的微型C程序
myspin.c # 接受参数n,循环运行n秒
mysplit.c # 创建子进程并循环运行n秒
mystop.c # 循环运行n秒后向自身发送SIGTSTP信号
myint.c # 循环运行n秒后向自身发送SIGINT信号

查看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查找
  • 如果找不到对应的作业,输出错误信息并返回

  • 根据命令类型(bgfg)执行不同操作:

    • 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) 的作用:
      1. ​临时将进程的信号掩码设置为 wait(空信号集)​​,即允许所有信号中断休眠。
      2. ​暂停进程​​,直到收到任意信号(如 SIGCHLDSIGINT 等)。
      3. ​信号处理函数执行完毕后​​,恢复原来的信号掩码,并继续执行。
  • ​为什么用 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 为真)。
    • 子进程被信号终止;
      • ​默认情况​​:子进程因未捕获的信号终止(如 SIGKILLSIGSEGV)。
      • 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
2
make trace01
make rtrace01

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的工作原理有了更深入的认识,不再是命令行用户。
这个实验虽然挑战性很大,但通过解决这些问题,我对计算机系统底层机制的理解有了质的飞跃,也大大提升了系统编程和调试能力。