目录
一.前置知识
1.前台进程和后台进程
a.概念理解
b.相关指令
2.信号的前置知识
a.Linux 系统下信号的概念
b.进程对信号的处理方式
3.信号的底层机制
二.详解信号
1.信号的产生
a.键盘组合键
b.kill 指令和系统调用接口
① kill 指令
② kill() 系统调用接口
③ raise() 系统调用接口
c.硬件级异常问题产生信号
①除零报错
②野指针报错
d.由软件条件产生信号
①匿名管道
②闹钟
2.信号的发送
a.OS给进程发信号的实质
① Pending表(未决信号集)
② Block表(信号屏蔽集)
③ handler表(信号处理函数表)
④小结
b.信号集操作函数
①有关sigset_t类型位图操作的函数接口
②sigprocmask
③sigpending
c.屏蔽进程的指定信号(代码实战)
3.信号的处理
a.用户态和内核态
b.用户态和内核态间的切换方式
①系统调用
②抛异常
③中断
c.重谈地址空间
4.信号的捕捉
a.signal
b.sigaction
c.实战演练
三.信号驱动回收子进程(附加篇)
一.前置知识
1.前台进程和后台进程
a.概念理解
前台进程:Linux系统中,前台只能运行一个进程,并且在进程运行期间,命令行失效,此时,如果我们想控制该进程,就只能通过信号!!
后台进程:可以同时运行多个进程,进程在运行期间,命令行仍起作用,但我们无法以键盘组合键的方式向后台进程发送控制指令。
由于后台进程可能同时运行多个,所以,每个后台运行的进程都会有自己的编号 [x]
b.相关指令
当我们向运行某个可执行程序时,如:./test 即让 test 可执行程序在前台运行。如果我们在其后加上 & 如: ./test & 则表示使 test 可执行程序在后台运行。
jobs —— 查看系统后台进程情况
fg + [x] —— 可以将编号为x的进程切换到前台
Ctrl + z —— 暂停前台进程,同时将该进程转到后台去
bg+ [x] —— 将后台被暂停的编号为x的进程启动
2.信号的前置知识
a.Linux 系统下信号的概念
什么是信号?简单来说,操作系统向目标进程发送的控制指令就是信号。
进程和信号的关系
①进程必须要有能力“识别”并“处理”信号,即使进程还未收到这一信号。
②进程收到某一信号时,可能不会立即对这一信号做出处理动作,也就是说,进程在收到信号和处理信号间有一个“空窗期”,这就要求进程具备对信号的临时存储能力。
kill -l 查看Linux系统中的各个信号,如下图:
man 7 signal —— 用于查看信号相关信息的命令。该命令会展示关于信号机制的详细解释,包括信号的含义、产生原因、默认行为、处理方式。
b.进程对信号的处理方式
进程对信号的处理方式一共有三种:①默认动作; ②忽略; ③*自定义动作(信号捕捉)。
什么是默认处理动作?--- 就是进程处理某一信号时, 该信号的功能是系统默认赋予的。例如:9号信号的功能是终止某一进程,当进程收到9号信号时,执行的功能就是将自己终结。
忽略行为该如何理解?--- 进程收到信号,但却不执行信号的功能,就是忽略。
*自定义动作指的是什么?--- 用户捕捉某个或某些信号,并对它们的功能进行修改,当进程收到这些信号时,会执行用户重新为信号赋予的功能,这就是自定义动作。
3.信号的底层机制
情景:当前台进程正在运行时,我们输入的 Ctrl + C 是如何终止进程的?或者说,我们输入 Ctrl + C后,OS底层都做了哪些事?
首先,OS如何知道外设的数据输入??—— 中断技术
当硬件设备有数据输入时,产生的光电信号经过8259表的特殊处理,会激活CPU上与该外设相连接的针脚,在CPU寄存器上生成中断号,随后,OS通过该中断号遍历中断向量表,找到表上记录的相应硬件的处理方法,即OS会从键盘缓冲区读取数据,并对读取的数据类型做判断,若其为组合的控制键(如:Ctrl + c、Ctrl + v),则OS向前台进程发送对应的信号,若为普通的输入数据,则将其拷贝到指定的内存缓冲区上。
至于,当进程收到信号后,对信号的识别、存储和执行,咱们放在后文中细讲~~
但有一点需要先提一下:信号的本质就是用软件来模拟中断的行为!!
二.详解信号
1.信号的产生
a.键盘组合键
如:
Ctrl + c 向前台进程发送2号信号 —— 其功能默认是终止进程
Ctrl + \ 向前台进程发送3号信号 —— 其功能默认是终止进程
注意:9号(暂停进程)和19号(杀掉进程)不能被signal自定义捕捉!!
b.kill 指令和系统调用接口
① kill 指令
在命令行上,我们可以使用 kill -x + pid,通过目标进程的pid,向目标进程发送 x 号信号。
② kill() 系统调用接口
在代码中,我们可以使用 kill(pid_t pid , int signal)函数,通过进程的 pid,向目标进程发送 signal 号信号。若发送成功,返回0;发送失败,则返回-1.
为了能够灵活的指定目标进程的控制信号,我们可以通过用命令行参数的方式拿到目标进程的pid和控制信号,如:
运行效果:
③ raise() 系统调用接口
当代码执行到 raise(int signal)函数时,OS会向自身发送 signal 号信号。
c.硬件级异常问题产生信号
①除零报错
当源文件的代码有类似除零这样的运算错误时,可执行程序运行时报错的底层原理是什么??
当可执行程序被加载到内存后,OS先将进程的 task_struct 放到运行队列,随后CPU执行程序代码,当CPU状态寄存器发现有除零操作时,其溢出标记位直接被置为1,从而形成在硬件层面上的报错,作为硬件的管理者,OS会从CPU那里拿到错误信息,又作为进程的管理者,OS把CPU溢出标记位信息翻译成终止信号,最后向目标进程发信号将其终止掉。
②野指针报错
当程序中出现这样的代码时: int * p=nullptr; *p=1; 典型的野指针问题,程序运行出错(段错误),那么其底层的原理是什么?
nullptr 在进程地址空间上的位置是0号地址,*p=1是通过页表将1写到“与0号地址空间构成映射关系”的物理内存上,但是,由于页表并未记录物理内存与地址空间上0号地址的映射关系,并且0号地址非法,所以当CPU通过MMU(内存管理单元,可以将虚拟地址翻译成物理地址)将值写入内存时,MMU报错,即硬件层面上的出错!!报错信息会被OS捕捉到,然后OS会向该进程发送终止信号,把该进程杀掉。
d.由软件条件产生信号
①匿名管道
进程间通信机制中,有个东西叫做匿名管道(博主以前文章中有过详细讲解),当匿名管道的读端关闭,写端一直向管道内写入数据的话,OS就会向该进程发送13号信号(SIGPIPE)干掉进程!
②闹钟
alarm() 函数是一个用于设置定时器的系统调用函数,它的主要功能是设置一个定时器(闹钟),当定时器时间到达时,内核会向当前进程发送14号信号(SIGALRM)信号。
总结:产生信号的方式可能有很多,但向进程发送信号的只能是OS!!
2.信号的发送
a.OS给进程发信号的实质
OS通过进程pid找到进程的PCB,然后将发送的信号写到进程PCB内的信号位图上,这就是OS给进程发送信号的本质。
那么问题来了,信号位图是什么?
我们知道,进程PCB在创建时,就已经内置了对信号的处理方法,即PCB需要具有识别、存储并执行每一种信号的能力,那么,这个能力是什么??--- 进程PCB内有关信号的三张表
进程PCB内有关信号的三张表指的是什么?
① Pending表(未决信号集)
它本身是一个位图,其中,比特位的大小表示信号值(如:位图的第8个比特位表示8号信号),比特位的内容表示进程是否收到对应信号(如:位图的第8个比特位值为0,表示进程未收到8号信号;若值为1,则表示进程收到了8号信号),Pending 表的功能是用来记录当前进程收到了哪些信号。
② Block表(信号屏蔽集)
它也是一个位图,其中,比特位的大小表示信号值,比特位的内容表示对应信号是否被进程屏蔽。
注意信号屏蔽和信号忽略的区别
信号屏蔽:信号屏蔽是指进程对特定信号进行屏蔽,使得这些信号在发生时不会被立即处理,而是处于未决状态。只有当进程的信号屏蔽集发生改变,不再屏蔽这些信号时,这些信号才会被捕获并处理。信号屏蔽是一种将信号处理进行延后的机制。
信号忽略:信号忽略则是指进程对特定信号进行忽略处理,即当这些信号发生时,进程会接收到信号,但不会对信号进行任何处理。需要注意的是,并非所有信号都可以被忽略,如SIGKILL和SIGSTOP等信号就不能被忽略。
③ handler表(信号处理函数表)
定义:handler表是一个映射表,它将信号的编号(如SIGINT、SIGTERM等)与相应的处理函数(这些处理函数可以是用户自定义的,也可以是系统默认的)建立映射关系。
作用:当进程接收到一个信号时,内核会暂停当前进程的执行,并根据信号的编号在handler表中查找对应的处理函数。如果找到了处理函数,则执行该函数;如果没有找到(即该信号被忽略或未设置处理函数),则根据信号的默认行为进行处理。
④小结
实际执行信号的处理动作称为信号递达(handler).
信号从产生到递达之间的状态称为信号未决(pending).
进程可以选择阻塞某个信号(block).
被阻塞的信号产生时将保持在未决状态,暂时不递达(不处理),直到进程解除对此信号的阻塞,才执行递达的动作。
b.信号集操作函数
①有关sigset_t类型位图操作的函数接口
int sigemptyset( sigset_t* set); 功能:将set中所有比特位的值置为0
int sigfillset( sigset_t* set); 功能:将set中所有比特位的值置为1
int sigaddset( sigset_t* set , int signo); 功能:在set中添加信号signo
int sigdelset( sigset_t* set , int signo); 功能:在set中将信号signo删除
int sigismember( sigset_t* set , int signo); 功能:判断set中信号signo是否存在
②sigprocmask
③sigpending
int sigpending(sigset_t *set); 函数能够查询当前进程或线程的未决信号集(也就是Pending表的内容),并将该集合复制到set参数指向的信号集中。
sigpending() 函数通常与sigprocmask() 函数一起使用,以管理进程的信号屏蔽字和未决信号集合。例如,在更改进程的信号屏蔽字之前,可以使用sigpending() 函数查询当前的未决信号集合,以便在之后恢复信号屏蔽字时能够正确处理这些未决信号。
c.屏蔽进程的指定信号(代码实战)
3.信号的处理
我们已经知道:OS向进程发送信号的实质是将信号写入进程PCB中的Pending表。那么,进程是在什么时候执行信号的具体功能的呢?--- 进程从内核态返回到用户态的时候,进行信号的检测和信号的处理。
那么,什么是内核态?什么又是用户态?进程又是如何从内核态返回到用户态的呢?
a.用户态和内核态
用户态和内核态是操作系统中的两种重要状态,它们分别代表了不同的运行级别和权限范围。
用户态:用户态是用户程序运行时的状态,在这种状态下,CPU只能执行非特权指令,不能直接访问内存等硬件设备,也不能执行特权操作,如修改系统配置、访问其他进程的内存等。用户态下的程序运行在用户空间,其资源访问权限受到严格限制,只能访问自己的[0 , 3GB]空间,以确保系统的安全性和稳定性。
内核态:内核态是操作系统内核运行时的状态,在这种状态下,CPU可以执行所有的指令,包括特权指令,可以访问所有的内存地址和硬件设备,拥有最高的权限。内核态下的程序运行在内核空间,负责管理系统资源、处理硬件事件、提供系统服务等。
b.用户态和内核态间的切换方式
①系统调用
用户态进程通过系统调用请求操作系统提供服务时,会触发从用户态到内核态的切换,系统调用是用户态进程主动要求切换到内核态的一种方式。
②抛异常
当CPU在执行用户态下的程序时,如果发生某些事先不可知的异常(如缺页异常),会触发由当前运行进程切换到处理此异常的内核相关程序中,从而转到内核态。
③中断
当外围设备完成用户请求的操作后,会向CPU发出中断信号。CPU在接收到中断信号后,会暂停执行当前的用户态程序,转而执行与中断信号对应的内核中断处理程序,从而完成从用户态到内核态的切换。
进程执行信号功能的底层示意图
图解:操作系统在用户态执行系统调用接口时,会从用户态——>内核态,以内核态的权限执行内核代码,当OS从内核态返回到用户态时会进行信号检测,若有用户自定义捕捉信号,则执行处理方法(内核——>用户),执行完后,会通过特定的系统调用接口,从用户再回到内核态,最后从内核态携着系统调用接口的返回信息回到用户态,即主控制流程。
c.重谈地址空间
就如曾经的库函数调用一样,调用系统调用接口,也是在进程的地址空间上进行的!!
操作系统的朴素理解:就是基于一个时钟中断的死循环!!
4.信号的捕捉
什么是信号捕捉?本质是对某个或某些信号的功能进行重定义,当OS给当前进程发送相关信号时,进程会执行重定义后的功能,这就是信号捕捉。
如何进行信号捕捉?--- 系统调用接口:①signal; ②sigaction。
接下来,咱们会详解这两个接口的功能~~
a.signal
sighandler_t signal ( int signum , sighandler_t handler );
signum 是我们要自定义的信号,handler 是 void ( * )( int ) 类型的回调函数,该回调函数的功能需要我们自主实现。当进程的代码执行到该系统调用接口后,此时,如果我们在向该进程发送 signum 信号的话,进程执行的就是 handler 函数中的代码~~
示例:
我们知道,当进程收到信号时,对信号的处理有三种方式:①忽略;②默认处理;③自定义捕捉
而 signal() 接口不仅可以实现信号的捕捉,它还能决定进程对信号的处理方式。当 signal() 第二个参数传递咱们自定义的函数时,signal() 执行的是自定义功能;当 signal() 第二个参数传递的是 SIG_IGN 时,表示信号忽略;当 signal() 第二个参数传递的是 SIG_IGN 时,表示执行信号的默认处理方法;
信号的忽略 —— signal( signum , SIG_IGN );
信号的默认处理 —— signal( signum , SIG_DFL );
信号的自定义捕捉 —— signal( signum , handler );
b.sigaction
int sigaction(int signum, const struct sigaction* act, struct sigaction* oact);
第一个参数 signum:指定要操作的信号编号,除了9号信号(SIGKILL)
和19号信号(SIGSTOP)
之外,其他所有信号都可以被处理。
第二个参数 act:用于指定新的信号处理方式,与 signal 中第二个参数作用类似。
第三个参数 oact:拿到老的 act,即上一次信号的处理方法。
详解struct sigaction 结构体
struct sigaction
{
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
};
sa_handler:信号处理函数指针,与signal函数中的handler参数类似。
sa_sigaction:作用是存放上一次使用的 handler。
sa_mask:信号屏蔽集,指定在信号处理函数执行期间应被阻塞的信号。
c.实战演练
情景:当进程在执行某个信号时,再次收到该信号会如何处理?我们又该怎么用代码得到我们想要的答案?
答:当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么,它会被阻塞到当前处理结束为止。如果在调用信号处理函数时,除了当前信号被自动屏蔽外,还希望自动屏蔽另外一些信号,则用 sigset_t sa_mask 字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
代码验证如下:
运行结果如下:
三.信号驱动回收子进程(附加篇)
SIGCHLD信号——基于“信号驱动”回收子进程。
在进程一章中讲过可以用 wait 和 waitpid 函数来解决僵尸进程问题. 其中,父进程可以阻塞等待子进程结束,也可以非阻塞轮询式查询子进程是否结束。若采用第一种方式,父进程阻塞了就不能执行自己的工作了;若采用第二种方式,父进程在执行自己任务的同时,还要记得时不时轮询式查询一下子进程的运行状态,程序实现较为复杂。
其实,子进程在终止时会给父进程发送 SIGCHLD 信号,该信号的默认处理方式是忽略。所以,父进程可以自定义 SIGCHLD 信号的处理函数,这样父进程只需专心处理自己的工作,不必关系子进程。当子进程终止时,会通知父进程,此时,父进程在信号处理函数中调用 wait 直接回收子进程即可。