本章讲操作系统中与高级语言设计有关的所有内容,尤其是进程控制有关的内容。
目录
异常控制流
控制流
从给处理器加电开始,到断电为止,程序计数器假设一个值的序列:a0, a1, a2, …, an。其中每个 a(k) 都是某个相应的指令 I(k) 的地址。
每次从 a(k) 到 a(k+1) 的过渡称为控制转移。这样的控制转移序列叫做处理器的控制流(control flow)。
说白了,处理器从加电到断电,处理器只是简单地读取和执行一个指令序列(一次执行一条指令)
最简单的控制流是一个平滑的序列,其中每个 I(k) 和 I(k+1) 都是相邻的。而诸如跳转、调用、返回等指令则会造成平滑流的突变,这些突变是由程序内部变量带来的。
还有一种突变是由程序外部的原因造成的,比如磁盘返回数据,鼠标关闭程序等,这种突变就叫做异常控制流(Exceptional Control Flow, ECF)。
改变控制流
- 改变控制流的两种机制**😗*
- 跳转和分支
- 调用和返回
能够对(由程序变量表示的) 程序状态的变化做出反应
-
不足:难以对系统状态的变化做出反应
-
磁盘或网络适配器的数据到达
-
除零错误
-
用户的键盘输入( 例如:Ctrl-C )
-
系统定时器超时
-
上述系统变化不能用程序变量表示
现代系统通过使控制流发生突变对这些情况做出反应,就是异常控制流
ECF
异常控制流 ECF 发生在计算机系统的各个层次:
- 硬件层,硬件中断
- 操作系统层,内核通过上下文切换将控制从一个进程转移到另一个进程
- 应用层,一个进程给另一个进程发送信号,信号接收者将控制转移到信号处理程序。
ECF 的应用:
- 操作系统内部。ECF 是操作系统用来实现 I/O、进程和虚拟内存的基本机制。
- 与操作系统交互。应用程序通过使用一个叫做系统调用(system call)的 ECF 形式,向操作系统请求服务。
- 编写应用程序。操作系统为应用程序提供了 ECF 机制,用来创建新进程、等待进程终止、通知其他进程系统中的异常事件、检测和响应这些事件。
- **并发。**ECF 是计算机系统中实现并发的基本机制。并发的例子有:异常处理程序或信号处理程序中断应用程序的执行,时间上重叠执行的进程和线程。
- 软件异常处理。C++ 和 Java 通过 try、catch、throw 等语句来提供异常处理功能。异常处理允许程序进行非本地跳转(即违反通常的调用/返回栈规则的跳转)来响应错误情况。非本地跳转是一种应用层 ECF,在 C 中由 setjmp和 longjmp 函数提供。
理解:首先要知道,异常控制流是从程序计数器的控制流层面来描述的。异常控制流就是程序计数器的控制流产生了程序外部原因带来的突变。
异常
**异常(exception)**是异常控制流的一种形式,一部分由硬件实现(也因此它的具体细节会随系统的不同而有所不同),一部分由操作系统实现。异常位于硬件和操作系统交界的部分。
异常(Exception)是指为了响应某事件将控制权转移到操作系统内核(操作系统作为一个程序常驻内存的部分)的情况。其实就是控制流里的突变以响应处理器状态的某些变化。
状态变化称为事件。在任何情况下,当处理器检测到有事件发生时,它就会通过叫做异常表的跳转表,进行间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序)。
异常处理完成后,根据事件类型,会有三种情况:
- 返回当前指令,即发生事件时的指令。
- 返回没有异常,所执行的下一条指令
- 终止被中断的程序‘
异常的处理
系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号。在系统启动时,操作系统分配和初始化一张称为异常表的跳转表,表目包含异常的处理程序的地址。
在运行时,处理器遇到了一个事件,并且确定了特定的异常号k,触发异常,执行间接过程调用,找到相应的异常处理程序
异常号是到异常表中的索引,异常表的起始地址放在一个叫做异常表基址寄存器的特殊CPU寄存器里。
一旦硬件触发了异常,剩下的工作就是由异常处理程序在软件中完成。在处理程序处理完事件之后,他通过执行“从中断返回”指令,可选地返回到被中断的程序。
异常处理和过程调用的异同
- 返回地址,过程调用一定是将返回地址(逻辑流下一条指令)压栈,而异常可能是下一条指令,可能是当前指令。
- 处理器会把状态字寄存器压栈(以便重新开始被中断的程序),而过程调用不会
- 异常流处理运行在内核模式下,对所有进程都有完全的访问权限。
- 如果控制从用户程序转到内核,所有的这些项目都被压倒内核栈中。
异常的类型
异常可以分为四类:中断、陷阱、故障和终止。
-
中断:
- 中断是异步发生,是来自处理器外部的I/O设备的信号的结果。
- 硬件中断不是由任何一条专门的指令造成,从这个意义上它是异步的。
(1)硬件中断的异常处理程序通常称为中断处理程序(interrupt handle)
(2)I/O设备通过向处理器芯片的一个引脚发信号,并将异常号放到系统总线上,以触发中断。
(3)在当前指令执行完后,处理器注意到中断引脚的电压变化,从系统总线读取异常号,调用适当的 中断程序。
(4)当处理程序完成后,它将控制返回给下一条本来要执行的指令。
-
故障(fault) :执行指令引起的异常事件,如溢出、非法指令、缺页、访问越权等。“断点”为发生故障指令的地址。
-
故障的例子:缺页故障
- 用户写内存地址(虚拟地址),该地址对应的物理页不在内存,在磁盘中
-
自陷(Trap) :预先安排的事件(“埋地雷”),如单步跟踪、断点、
-
系统调用 (执行访管指令) 等。是一种自愿中断。“断点”为自陷指令下条指令地址。
-
每个x86-64系统调用有一个唯一的ID号
-
关于系统调用的例子:文件读取
用户调用函数:
open(filename, options)
,调用_open函数所有系统调用函数都是调用syscall指令,_open也不例外。
-
00000000000e5d70 <__open>:
...
e5d79: b8 02 00 00 00 mov $0x2,%eax # open is syscall #2
e5d7e: 0f 05 syscall # Return value in %rax
e5d80: 48 3d 01 f0 ff ff cmp $0xfffffffffffff001,%rax
...
e5dfa: c3 retq
- 终止(Abort) :硬故障事件,此时机器将“终止”,调出断服务程序来重启操作系统。“断点”是任意的。
- “断点”:异常处理结束后回到原来被“中断”的程序执行时的起始指令
- 例子**😗* 非法内存引用
- OS发送SIGSEGV信号给用户进程(不尝试恢复),用户进程以“段错误”(segmentation fault)退出
进程
进程提供给应用程序两个假象:
-
独立的逻辑控制流:每个程序似乎独占CPU。(由内核通过上下文切换机制来实现)
-
使用调试器单步执行程序时会看到一系列的程序计数器(PC)值,这个 PC 的值的序列叫做逻辑控制流,简称逻辑流。
PC 的值唯一地对应于包含在程序的可执行目标文件中的指令,或包含在运行时动态链接到程序的共享对象中的指令。
逻辑流有许多不同的形式,异常处理程序、进程、信号处理程序、线程等都是逻辑流的例子。
进程是轮流使用处理器的。
-
-
私有的空间地址:每个程序似乎独占内存。(由内核的虚拟内存机制来实现)
-
进程为每个程序提供它自己的私有地址空间。一般而言,和这个私有地址空间中某个地址相关联的那个内存字节是不能被其他进程读或写的。
不同进程的私有地址空间关联的内存的内容一般不同,但是每个这样的空间都有相同的通用结构。
此处拿出老图:
地址空间的顶部保留给内核(操作系统常驻内存的部分),包含内核在代表进程执行指令时(比如当执行了系统调用时)使用的代码、数据、堆和栈。
地址空间的底部留给用户程序,包括代码段、数据段、运行时堆、用户栈、共享库等。代码段总是从地址 0x400000 开始。
内核栈和用户栈是分开的。
关于内存分配的结构详见虚拟内存一章。
-
进程就是一个执行中的程序的实例。系统中每个程序都运行在某个进程的上下文中。
以下两章内容主讲Linux内核的进程控制,可参见:
多重处理
计算机同时运行许多进程,如:
-
单/多用户的应用程序
-
Web 浏览器、email客户端、编辑器…
-
后台任务(Background tasks)
-
监测网络和I/O 设备
多重处理的真相:
-
寄存器当前值保存到内存
-
调度下一个进程执行
-
加载被保存的寄存器,并切换地址空间 (上下文切换)
多核处理器的用处:
-
单个芯片有多个CPU
-
共享主存、有的还共享cache
-
每个核可以执行独立的进程kernel负责处理器的内核调度
并发进程流
每个进程都是逻辑控制流,若其在时间上是有重叠的就是并发的,否则是顺序的,如该图
AB、AC是并发的,BC是顺序的。(上图是系统视角,并发进程的控制流物理上是不相交的,用户视角下A、C均为连续的,如下图)
上下文切换
用户模式和内核模式
处理器提供一种机制,限制一个应用程序可以执行的指令以及它可以访问的地址空间范围。这就是用户模式和内核模式。
处理器通过控制寄存器中的一个模式位来提供这个功能。
- 该寄存器描述了进程当前享有的特权。
(1)设置了模式位后,进程就运行在内核模式中(有时也叫超级用户模式);内核模式下的进程可以执行指令集的任何指令,访问系统所有存储器的位置。Linux系统中,提供用户root以访问内核模式。
(2)没有设置模式位时,进程运行在用户模式。 用户模式不允许程序执行特权指令。 比如停止处理器,改变模式位,发起一个I/O操作。不允许用户模式的进程直接引用地址空间的内核区代码和数据。任何尝试都会导致保护故障。用户通过系统调用间接访问内核代码和数据。 - 进程从用户模式转变位内核模式的方法
(1)通过中断,故障,陷入系统调用这样的异常。
(2)在异常处理程序中会进入内核模式。退出后,又返回用户模式。
Linux提供一种聪明的机制,叫/proc文件系统(将进程及其内存数据在/proc目录下以文件样式提供)。
-
允许用户模式访问内核数据结构的内容。
-
/proc文件将许多内核数据结构输出为一个用户程序可以读的文本文件的层次结构。
上下文及其切换
操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务。内核为每个进程维持一个上下文。上下文是由程序运行所需的状态组成的,包括存放在内存中的程序的代码和数据,是内核重新启动一个被抢占的进程所需的状态。
当内核决定抢占(暂时挂起)当前进程后,它使用上下文切换机制来将控制转移到新的进程(也是先前被抢占的进程)。上下文切换包括(其实就是多重处理的过程):
- 保存当前进程的上下文;
- 恢复某个先前被抢占的进程被保存的上下文;
- 将控制转移给这个新恢复的进程。
会引发上下文切换的状况:
-
内核代表用户进行系统调用
-
系统中断(例如上图的磁盘中断,因读取磁盘本身比CPU时钟时间长很多)
上下文之问
进程控制
系统调用错误处理
在代码中进行错误检查是必要的!!!
Unix及Linux等类Unix遇到错误时,它们通常会返回-1
并设置全局变量errno
来表示出错原因。
strerror 函数返回一个文本串,描述了和某个 errno 值相关联的错误。
if((pid = fork()) < 0) //如果发生错误,此时 errno 已经被设置为对应值了
{
fprintf(stderr, "fork error: %s\n", strerror(errno));//strerror(errno) 返回描述当前 errno 值的文本串
exit(0);
}
可以使用错误处理包装函数以简化错误处理过程
//错误报告函数
void unix_error(char *msg)
{
fprintf(stderr, "%s: %s\n", msg, strerror(errno));
exit(0);
}
//fork 函数的错误处理包装函数 Fork
pid_t Fork(void)
{
pid_t pid;
if ((pid = fork()) < 0)
unix_error("Fork error"); //调用上面定义的包装函数
return pid;
}
//错误处理包装函数使用原函数的首字母大写形式,以便隐式地进行错误处理
进程ID及其获取
每个进程都有一个唯一的非零正整数表示的进程 ID,叫做 PID。有两个获取进程 ID 的函数:
- **getpid 函数:**返回调用进程的 PID(类型为 pid_t,在 type.h 中定义了 pid_t 为 int)。
- **getppid 函数:**返回它的父进程的 PID。
#include<sys/types.h>
#include<unistd.h>
pid getpid(void);
pid getppid(void);
进程的状态
进程总是处于以下三种状态之一:
- 运行。进程要么在 CPU 上执行,要么在等待被执行且最终被内核调度。
- 停止。进程的执行被挂起且不会被调度。当收到 SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU 信号时,进程就会停止,直到收到一个 SIGCONT 信号时再次开始运行。
- 终止。进程永远地停止了。进程有三种原因终止:收到一个信号,该信号的默认行为是终止进程;从主进程返回;调用 exit 函数。
信号是一种软件中断的形式。
创建和终止进程
终止进程
void exit(int status)
- 以status退出状态来终止进程,常规为0,非正常为非0。
- 另一种设置退出状态的方法是从主程序中返回一个整数值
- exit不返回值
创建进程
父进程通过调用fork
函数创建一个新的运行的子进程
int fork(void)
-
子进程返回0,父进程返回子进程的PID(运行一次,返回两次)
-
新创建的子进程几乎但不完全与父进程相同:
-
子进程得到与父进程虚拟地址空间相同的(但是独立的) 一份副本
-
子进程获得与父进程任何打开文件描述符相同的副本(共有文件)
-
子进程有不同于父进程的PID
-
例子:
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
int main()
{
pid_t pid;
int x = 1;
pid = Fork();
if (pid == 0) { /* Child */
printf("child : x=%d\n", ++x);
exit(0);
}
/* Parent */
printf("parent: x=%d\n", --x);
exit(0);
}
编译后运行结果:
linux> ./fork
parent: x=0
child : x=2
得到以下规律:
- 调用一次,返回两次
- 并发执行
- 不能预测父进程与子进程的执行顺序
- 相同但是独立的地址空间
- Fork返回时(第6行),x在父进程和子进程中都为1
- 后面,父进程和子进程对x所做的任何改变都是独立的,可以认为x在父进程和子进程两个各自复制了一份副本
- 共享文件
- stdout文件在父、子进程是相同的
进程图
进程图有助于理解父进程和子进程之间的关系。
-
进程图是捕获并发程序中语句偏序的有用工具**😗*
-
每个顶点对应一条语句的执行
-
有向边a -> b 表示语句 a发生在语句 b 之前
-
边上可以标记信息如变量的当前值
-
printf语句的顶点可以标记上printf的输出
-
每张图从一个没有入边的顶点开始
-
图的任何拓扑排序对应于程序中语句的一个可行的全序排列**.**
-
所有顶点的总排序,这些顶点的每条边都是从左到右的
上面例程的进程图如下:
另可以造一个程序,如下:
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
void main()
{
Fork();
//主进程和子进程1
Fork();
//主进程产生子进程2,子进程1产生子进程3
printf("hello\n");
//共计4个进程,产生4个hello
exit(0);
}
父进程中的嵌套fork调用
void fork4()
{
printf("L0\n");
if (fork() != 0) {
printf("L1\n");
if (fork() != 0) {
printf("L2\n");
}
}
printf("Bye\n");
}
void fork5()
{
printf("L0\n");
if (fork() == 0) {
printf("L1\n");
if (fork() == 0) {
printf("L2\n");
}
}
printf("Bye\n");
}
fork4:
fork5:
回收子进程
当进程终止时,它仍然消耗系统资源,除非被父进程回收
即使主进程已经终止,子进程也还在消耗系统资源,我们称之为“僵尸”。为了“打僵尸”,就可以采用“收割”(Reaping) 的方法。
父进程利用 wait 或 waitpid 回收已终止的子进程,然后给系统提供相关信息,内核就会把 zombie child process 给删除。
如果父进程不回收子进程的话,通常来说会被 init 进程(pid == 1)回收,所以一般不必显式回收。但是在长期运行的进程(例如 shell 和 server)中,就需要显式回收。
关于init进程
系统启动时内核会创建一个 init 进程,它的 PID 为 1,不会终止,是所有进程的祖先。
如果一个父进程终止了,init 进程会成为它的孤儿进程的养父。init 进程会负责回收没有父进程的僵死子进程。
waitpid
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t 47pid, int *child_status, int options); //如果成功,返回对应的已终止的子进程的 PID;如果其他错误,返回 -1
//只有当参数 options=WNOHANG 时,才有可能返回 0;其他情况要么返回子进程 PID,要么返回 -1
参数:
-
pid,设置成-1则表示等待任意一个子进程,同wait;如果>0则表示等待一个指定的子进程,pid就是被等待子进程的进程号
- pid > 0:等待集合是一个单独的PID=pid的进程。
- pid = -1:所有子进程。
- pid < -1:|pid|进程组中任何子进程。
- pid = 0:当前进程同一进程组中任何子进程。
-
status,出参,获取子进程的退出状态,同wait
-
options,可以设置为0、WNOHANG或其他值(见下表)。设置为0则与wait一样,如果没有等待到子进程退出会一直阻塞;而设置为WNOHANG则表示非阻塞,如果被等待的子进程未退出,则会返回0值,成功等待到子进程则会返回被等待子进程的pid
-
WNOHANG 若无子进程结束也会返回(返回值为0),不会挂起当前进程。 WUNTRACED 若等待集合中一个进程被停止也返回。返回值为导致返回的已终止或停止子进程的PID WCONTINUED 若等待集合中一个进程收到SIGCONT从停止重新开始也返回。 WNOHANG|WUNTRACED 立即返回,返回值为导致返回的已终止或停止子进程的PID(若无子进程结束也会返回,返回值为0)
-
返回值:
- 等待成功正常返回则返回被等待进程的pid
- 如果第三个参数options设置成了WNOHANG,而此时没有子进程退出(没有成功等待到子进程),就会返回0,而不是阻塞在函数内部
- 调用出错则返回-1
错误条件:
- 调用进程没有子进程,waitpid返回-1,errno设置为ECHILD
- waitpid被一个信号中断,waitpid返回-1,errno设置为EINTR
wait
pid_t wait(int *child_status)
//调用wait等价于waitpid(-1,&status,0)
-
挂起当前进程的执行直到它的一个子进程终止
-
返回已终止子进程的pid
-
如
child_status != NULL
, 则在该指针指向的整型量中写入关于终止原因和退出状态的信息): -
子进程完成结束的顺序是任意的(没有固定的顺序)
-
如果
child_status
参数是非空的,那么 wait 就会在 status 中放上关于导致 wait 返回的子进程的状态信息,status 是child_status
指向的值。wait.h 头文件定义了解释 status 参数的几个宏:
- WIFEXITED(status):如果子进程通过调用 **exit 或者一个返回(return)**正常终止,就返回真。
- WEXITSTATUS(status):返回一个正常终止的子进程的退出状态。只有在 WIFEXITED() 返回为真时,才会定义这个状态。
- WIFSIGNALED(status):如果子进程是因为一个未被捕获的信号终止的,那么就返回真。
- WTERMSIG(status):返回导致子进程终止的信号的编号。只有在 WIFSIGNALED() 返回为真时,才定义这个状态。
- WIFSTOPPED(status):如果引起返回的子进程当前是停止的,就返回真。
- WSTOPSIG(status):返回引起子进程停止的是信号的编号。只有在 WIFSTOPPED() 返回为真时,才定义这个状态。
- WIFCONTINUED(status):如果子进程收到 SIGCONT 信号重新启动,则返回真。
例程:
void fork10() { pid_t pid[N]; int i, child_status; for (i = 0; i < N; i++) if ((pid[i] = fork()) == 0) { exit(100+i); /* Child */ } for (i = 0; i < N; i++) { /* Parent */ pid_t wpid = wait(&child_status); if (WIFEXITED(child_status)) printf("Child %d terminated with exit status %d\n",wpid,WEXITSTATUS(child_status)); else printf("Child %d terminate abnormally\n", wpid); } }
进程休眠
sleep函数
sleep 函数将一个进程挂起一段指定的时间。注意:sleep 不是 C 标准库里的函数,是 unistd 中的控制进程的函数。
#include <unistd.h>
unsigned int sleep(unsigned int secs); //返回还要休眠的秒数
如果请求的休眠时间量到了,sleep 返回 0,否则返回还剩下的要休眠的秒数(当 sleep 函数被一个信号中断而过早地返回,会发生这种情况)。
pause函数
pause 函数让调用函数休眠,直到该进程收到一个信号。
#include <unistd.h>
int pause(void);
加载及运行程序
execve:加载并运行程序
-
int execve(char *filename, char *argv[], char *envp[])
-
在当前进程中载入并运行程序:
-
Filename:可执行文件
-
目标文件或脚本(用#!指明解释器,例如 #!/bin/bash)
-
argv:命令行参数列表(字符串数组)
-
惯例:argv[0]==filename
-
envp:环境变量列表
-
“name=value” strings (e.g., USER=droh)
-
getenv, putenv, printenv
-
覆盖当前进程的代码、数据、栈
- 保留:有相同的PID,继承已打开的文件描述符和信号上下文
-
调用一次,并从不返回
进程程序替换与fork不同,它并不会创建新的进程,而是该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。替换前后的进程号并未改变。
Shell
shell 是一个交互型应用级程序,代表用户运行其他程序
一个简单的 shell 的实现方式
shell 会打印一个命令行提示符,等待用户在 stdin 上输入命令行,然后对这个命令行求值。
一个极简的 shell 程序包括以下几个函数:main 函数、eval 函数、parseline 函数、buildin 函数,它们的各自的主要职责如下:
- main:shell 程序的入口点,职责:循环从标准输入读取命令行字符串并调用 eval 函数解析并执行命令行字符串。
- eval:解析并执行命令行字符串。职责:首先调用 parseline 函数解析命令行字符串,然后使用 buildin 函数检查是否为内置命令,不是的话要生成一个进程(作业)来完成此命令,还要根据情况回收相应进程。
- parseline 函数:解析命令行字符串。职责:根据空格拆分命令行字符串,构造 argv 向量。
- buildin 函数:检查命令是否为内置命令,如果是的话直接调用相应函数,不是的话返回交给 eval 函数负责。
shell 的 main 例程
#include "csapp.h"
#define MAXARGS 128
int main(){
char cmdline[MAXLINE]; /* Command line */
while (1) {
/* Read */
printf("> ");
Fgets(cmdline, MAXLINE, stdin); //读取用户的输入
if (feof(stdin))
exit(0);
/* Evaluate */
eval(cmdline); //解析命令行
}
}
解释并执行一个命令行
/* eval - Evaluate a command line */
void eval(char *cmdline)
{
char *argv[MAXARGS]; /* Argument list execve() */
char buf[MAXLINE]; /* Holds modified command line */
int bg; /* Should the job run in bg or fg? */
pid_t pid; /* Process id */
strcpy(buf, cmdline);
bg = parseline(buf, argv); //调用 parseline 函数解析以空格分隔的命令行参数
if (argv[0] == NULL) //表示是空命令行
return; /* Ignore empty lines */
//调用 builtin_command 检查第一个命令行参数是否是一个内置的 shell 命令。如果是的话返回 1,并在函数内就解释并执行该命令。
if (!builtin_command(argv)) //如果返回 0,即表明不是内置的 shell 命令
{
if ((pid = Fork()) == 0) //创建一个子进程
{ /* Child runs user job */
if (execve(argv[0], argv, environ) < 0) //在子进程中执行所请求的程序
{
printf("%s: Command not found.\n", argv[0]);
exit(0);
}
}
/* Parent waits for foreground job to terminate */
if (!bg) // bg=0 表示是要在前台执行的程序,shell 会等待程序执行完毕
{
int status;
if (waitpid(pid, &status, 0) < 0) //等待子进程结束回收该进程
unix_error("waitfg: waitpid error");
}
else // bg=1 表示是要在后台执行的程序,shell 不会等待它执行完毕
printf("%d %s", pid, cmdline);
}
return;
}
注意:上面的 shell 程序有缺陷,它只回收了前台的子进程,没有回收后台子进程。不回收后台子进程会导致进程空转,占有内存甚至导致内存泄露。
还有一个 parseline 函数和 builtn_command 函数不再列出。 其中 parseline 函数负责解析以空格分隔的命令行参数字符串并构造要传递给 evecve 的 argv 向量。builtn_command 函数负责检查第一个命令行参数是否是一个内置的 shell 命令。
信号
信号(signal)就是一条小消息,它通知进程系统中发生了 一个某种类型的事件
-
类似于异常和中断
-
从内核发送到(有时是在另一个进程的请求下)一个进程
-
信号类型是用小整数ID来标识的(1-30) (P527)
-
信号中唯一的信息是它的ID和它的到达
-
信号类型:
发送信号
-
内核通过更新目的进程上下文中的某个状态,发送(递送)一个信号给目的进程
-
发送信号可以是如下原因之一:
-
内核检测到一个系统事件如除零错误(SIGFPE)或者子进程终止(SIGCHLD)
-
一个进程调用了kill系统调用,显式地请求内核发送一 个信号到目的进程
- ✓ 一个进程可以发送信号给它自己
-
进程组
发送信号的机制都是基于进程组这个概念的。
每个进程都只属于一个进程组,进程组是由一个正整数进程组ID来标识的。getpgrp
函数返回当前进程的进程组ID,setpgid
函数可以改变自己或者其他进程的进程组。
pid_t getpgrp(void)
int setpgid(pid_t pid, pid_t pgid)
kill
/bin/kill 程序可以向另外的进程或进程组发送任意的信号
- 示例
-
/bin/kill –9 24818
发送信号9(SIGKILL)给进程24818 -
/bin/kill –9 –24817
发送信号SIGKILL给进程组24817中的每个进程(负的PID会导致信号被发送到进程组PID中的每个进程)
-
ctrl+c/ctrl+z
输入 ctrl-c (ctrl-z) 会导致内核发送一个 SIGINT (SIGTSTP)信号到前台进程组中的每个作业
-
SIGINT – 默认情况是终止前台作业
-
SIGTSTP – 默认情况是停止(挂起)前台作业
接收信号
接收信号:当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了信号。
接收信号的时机:内核把进程从内核模式切换到用户模式时,例如从系统调用返回或是完成了一次上下文切换。
接收信号的过程:
-
内核计算进程的为被阻塞的待处理信号的集合
pnb=pending & ~blocked
。 -
如果集合为空:
- 将控制传递到逻辑控制流中的下一条指令。
- 否则:
- 内核选择集合中最小的非零位,强制进程接收。
- 触发进程的某种行为。
- 对所有的非零重复上述操作。
- 将控制传递到逻辑控制流中的下一条指令。
接收信号后反应的方式:
-
默认行为,是下面的一种:
- 忽略这个信号。
- 终止进程。
- 通过用户层函数信号处理程序 捕获这个信号。
-
指定行为:
-
调用执行预先设置好的信号处理程序。
我们可以使用signal函数设置信号处理程序,从而修改和信号相关联的默认行为。
handler_t *signal(int signum, handler_t *handler)
handler的不同取值:
- SIG_IGN:忽略类型为signum的信号;
- SIG_DFL:恢复默认行为;
- 用户自定义handler,这个程序称为信号处理程序。
注意,信号处理程序是与主程序同时运行、独立的逻辑流(不是进程)。如下图所示。
阻塞和解除阻塞信号
一个发出而没有被接收的信号叫做待处理信号 (pending),一个进程可以选择阻塞接收某种信号
-
阻塞的信号仍可以被发送,但不会被接收,直到进程取消对该信号的阻塞
-
一个待处理信号最多只能被接收一次
-
内核为每个进程在 pending 位向量中维护着待处理信号的集合,在 blocked 位向量中维护着被阻塞的信号集合。
-
只要传送了一个类型为 k 的信号,内核就会设置 pending 中的第 k 位,只要接收了一个类型为 k 的信号,内核就会清除 blocked 中的第 k 位。
-
blocked: 被阻塞信号的集合,通过 sigprocmask 函数设置和清除,也称信号掩码
-
Linux提供信号的隐式和显式阻塞机制。
-
隐式阻塞机制:内核默认阻塞与当前正在处理信号类型相同的待处理信号。
-
显式阻塞机制:可以使用
sigprocmask
函数和它的辅助函数明确地阻塞和解除阻塞选定的信号。
sigprocmask函数
sigprocmask 函数改变当前阻塞的信号集合(blocked 位向量),具体行为依赖 how 的值:
-
SIG_BLOCK:把 set 中的信号添加到 blocked 中(blocked = blocked | set)。
-
SIG_UNBLOCK:从 blocked 中删除 set 中的信号(blocked = blocked & ~set)。
-
SIG_SETMASK:block = set。
#include<signal.h> int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
如果 oldset 非空,blocked 位向量之前的值保存在 oldset 中。
其他辅助函数
辅助函数用来对 set 信号集合进行操作:
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(segset_t *set, int signum);
- sigemptyset 初始化 set 为空集合;
- sigfillset 把每个信号都添加到 set 中;
- sigaddset 把信号 signum 添加到 set 中;
- sigdelset 把信号 signum 从 set 中删除。如果 signum 是 set 的成员返回 1,不是返回 0。
一个临时阻塞 SIGINT 信号的例子
sigset_t mask, prev_mask;
Sigemptyset(&mask);
Sigaddset(&mask, SIGINT); //将 SIGINT 信号添加到 set 集合中
Sigprocmask(SIG_BLOCK, &mask, &prev_mask); //阻塞 SIGINT 信号,并把之前的阻塞集合保存到 prev_mask 中。
... //这部分的代码不会被 SIGINT 信号所中断
Sigprocmask(SIG_SETMASK, &prev_mask, NULL); //恢复之前的阻塞信号,取消对 SIGINT 的阻塞
信号处理程序
信号处理是 Linux 系统编程最棘手的问题。
处理程序的几个复杂属性:
- 处理程序与主程序和其他信号处理程序并发运行,共享同样的全局变量,可能和主程序与其他处理程序互相干扰。
- 如何接收信号及何时接收信号的规则常常有违人的直觉。
- 不同的系统有不同的信号处理语义。
编写处理程序的原则
-
G0: 处理程序尽可能简单
- e.g., 简单设置全局标志并立即返回
-
G1: 在处理程序中只调用异步信号安全1的函数
printf
,sprintf
,malloc
,andexit
are not safe!
-
G2:保存和恢复errno
- 确保其他处理程序不会覆盖当前的errno
-
G3: 阻塞所有信号保护对共享全局数据结构的访问
- 避免可能的冲突
-
G4: 用
volatile
声明全局变量- 强迫编译器从内存中读取引用的值
-
G5: 用
sig_atomic_t
声明标志-
原子型标志: 只适用于单个的读或者写 (e.g. flag = 1, not flag++)
-
采用这种方式声明的标志不需要类似其他全局变量的保护
-
非本地跳转
异步信号安全的,指函数要么是可重入的(如只访问局部变量),要么不能被信号处理程序中断 ↩︎