前言

整个实验做了四天,也有很多东西想聊的。主要是分享我对其中的一些系统调用的理解,和整个shell是如何工作的,可能会有一些代码上的剧透,未完成该实验的朋友慎重观看。

理解Unix Shell

在Linux中我们打开了一个终端,内核就会执行一个fork()来启动一个shell,之后我们在这个shell中输入指令来达成我们想要的功能.
我们可以在shell中输入一行命令,一行命令可能启动一个进程,如ls,也可能启动多个进程如cat Makefile | wc(这里启动了两个进程,分别是cat Makefilewc).我们称这样一行命令为一个Job

job和进程组

在一个job中可能有多个进程,linux把一个job里的进程归为一个进程组,每个进程组都有一个id: pid_t pgid,可以使用getpgid函数查看一个进程属于哪个进程.进程组会有一个进程组长,进程组长的进程id等于进程组的id即:pgid == pid.默认情况下一行命令中的第一个进程即为进程组长.有了进程组之后我们就可以向一个进程组发送信号.进程组的每个成员都会收到这个信号.
tips: 当进程组组长进程结束后,进程组并不会结束,只有当所有进程组中的成员都结束了,进程组才算结束
相关系统调用:

  • int setpgid(pid_t pid, pid_t pgid): 把进程id为pid的进程加入到进程组id为pgid的进程中
  • pid_t getpgid(pid_t pid):返回进程id为pid的进程所在的进程组的id

会话(session)

我们现在知道每个进程属于一个进程组,类似的,每个进程组又属于一个会话(Session),每个session也有属于自己的id,我们称之为SID.如下图所示:

我们可以在一个进程中调用setsid函数来创建一个新的session,如果这个进程不是进程组组长,该函数会创建一个新的session,并产生以下结果:

  1. 该进程变为新的session的首进程(即session leader,对话期首进程是创建该对话期的进程).此进程成为新session中的唯一进程
  2. 此进程成为一个新进程组的组长进程.新进程组ID是此调用进程的进程ID
  3. 此进程没有控制终端.如果在调用setsid之前此进程有一个控制终端,那么这种联系也被解除.(控制终端随后讲解)
    当我们启动一个shell时,内核fork出一个子进程,并在该子进程中调用了setsid函数使他成为了session的首进程,也就是如上如所示.

控制终端和控制进程

当然,想要shell达到我们平时用的shell的效果,shell只成为session的首进程是不够的.我们在Session中还需要一个控制终端.
控制终端(controlling terminal)是与用户交互的主要终端设备,它提供了标准输入(stdin),标准输出(stdout)和标准错误(stderr)的交互界面.一个Session可以有一个独立的控制终端
建立与控制终端连接的session首进程,称之为控制进程(controlling process)
由此我们可以窥见shell启动时发生了什么:shell先创建了一个session,然后与控制终端进行了连接(一般使用open函数来进行连接(获取I/O设备的文件描述符),一些操作系统把设备抽象成文件描述符,然后通过这些文件描述符来和I/O设备进行交互,想要了解更多的自行Google,这里就不多讲了),这时shell既是session首进程又是控制进程.
因为控制终端在进程中表现为类文件描述符的抽象,所以用fork创建的子进程从父进程继承控制终端.通过这种方式,会话中的所有进程都从会话领导端继承控制终端(通俗的讲就是fork时父进程打开的文件描述符,子进程也会有,父进程还可以通过ioctl函数把终端(全部或部分)分配给其他进程).

前台进程和后台进程

当一个session拥有了控制终端,那么这个session就会有前台进程组和后台进程组

  • 前台进程组: 控制终端的前台作业中的进程可以不受限制地访问该终端(即对终端可读可写)
  • 后台进程组: 一般可以对终端进行写操作。当后台作业中的进程试图从其控制终端读取数据时,通常会向进程组发送SIGTTIN信号。这通常会导致该组中的所有进程停止(除非它们处理了该信号而不停止自己)。但是,如果读取进程忽略或阻塞了该信号,则读取失败并出现EIO错误.(这个理论可以在实验中得到验证)
    一个session只能有一个前台进程组和多个后台进程组

当在shell中执行一条命令时,shell会把这条命令设置为前台进程组(之所以要设置为前台进程组,是因为有些进程需要接收终端标准输入),该命令的所有命令结束后,shell又会把自己再次设置为前台进程组

shell如何使前台进程组和后台进程组拥有不同的终端权限(猜测)

就如上面所说,session首进程可以通过关闭文件描述符,ioctl()等函数,使其fork出来的子进程无法进行读操作.

总结

到此为止就是Unix大致的工作方式了,可能讲的不全,但有上述理论基础加上实验里的提示应该可以完成一个shell,接下来的内容可能涉及一些实验代码,再次提醒未完成的朋友谨慎观看

竞态冲突问题

在cs110的课程中给了如代码来回收子进程:

static pid_t fgpid = 0; // 0 means no foreground process
static void reapProcesses(int sig) {
	pid_t pid;
	while (true) {
		pid = waitpid(-1, NULL, WNOHANG);
		if (pid <= 0) break;
		if (pid == fgpid) fgpid = 0;
	}
	
	exitUnless(pid == 0 || errno == ECHILD, kWaitFailed, stderr, "waitpid function failed");
}

static void waitForForegroundProcess(pid_t pid) {
	fgpid = pid;
	sigset_t empty;
	sigemptyset(&empty);
	while (fgpid == pid) {
		sigsuspend(&empty);	
	}
	unblockSIGCHLD();
}

int main() {
	....
	....
	blockSIGCHLD();
	waitForForegroundProcess(pid);
	....
	....
}

课程代码之所以使用了信号屏蔽是因为当处理函数reapProcess发生waitForForegroundProcess之前就会导致有一次处理函数的信号被‘吞掉’,导致在while循环中fgpid无法被修改,因为修改fgpid是通过reapProcess来实现的,但子进程产生的信号已经在之前就被使用了,所以会陷入死循环中.
同样的在我们实现的shell中可能会出现子进程执行结束,把修改了任务列表的状态,但此时我们的前台任务还未加入到任务列表中,导致这个前台进程组没有相应的信号处理程序来结束他,此时就产生了一个竞争冲突,导致程序卡住.

环境配置问题

还是老生常谈的环境问题,这个实验的环境问题较为简单就是缺少了readline库文件,这个解决还是比较简单的,这里不给出解决方案,希望朋友们可以问问GPT,自行解决