?前言?
? 概念
? 总体认识
? 信号产生
? kill命令
? 通过终端按键
? 系统调用函数
? 软件条件
? 硬件异常
? core 和 term
? 信号保存
? 信号的相关概念
? 内核表示
? sigset_t
? 信号集操作函数
? sigprocmask
? sigpending
? 捕捉信号编辑
? signal
? sigaction
? 用户态和内核态
? 可重入函数
? volatile关键字
? SIGCHLD
? 总结
信号是进程间事件异步通知的一种方式,属于软终端。信号就是一条消息,它通知进程发生了一件事。
在Linux系统上支持30中不同类型的信号,每种信号都对应于某种系统事件。底层的硬件异常是由内核异常处理程序处理的,对于用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。
——《深入理解计算机系统》
我们先来使用一下信号:1. 用户输入命令,在shell下启动一个前台进程;2. 用户按Ctrl + c ,这个键盘输入产生了一个硬件中断,被OS获取,解释为信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出。
注意:
1. Ctrl + C 产生的信号(SIGINT)只能发送给前台进程,一个命令后面加上&可以放到后台运行,这样shell不必等待进程结束就可以接受新的命令,启动新的进程。
2. shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接收到Ctrl + C这种控制键产生的信号
3. 前台进程在运行过程中用户随时可能按下Ctrl + C而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到SIGINT信号而终止,所以信号相对于进程的控制流程来说是异步的,
我们通过下图,来认识信号的处理过程。下图也是接下来讲解信号的主线。
1. 因为某种事件发生,内核向进程发送了信号,该进程收到该信号;
2. 如果正在处理某种事件,会在合适的时间去处理,在这个期间你收到信号但没有处理,叫做保存;
3. 当时间合适,就处理该信号,有三种动作:a. 执行默认动作;b. 执行自定义动作;c. 忽略该动作。
因为进程并不知道信号要发送,所以还在处理自己的事情,但满足某种条件,内核向进程发送信号,进程并不知情,这个过程就是异步的,进程并不知道什么时候产生信号。
信号处理时,如果执行自定义动作,就要提供一个信号处理函数,要求内核切换到用户态来处理这个函数,这种方式叫做 捕捉(catch)一个信号。
我们可以通过 kill -l 命令查看系统定义的信号列表
a. 每个信号都有一个编号和一个宏定义,这些宏定义在signal.h中可以找到,例如有定义#define SIGINT 2
b. 编号34以上的信号是实时信号,本章只讨论34以下的信号,不讨论实时信号。这些信号在各自什么条件下产生,默认处理动作是什么,在signal 7 中都有详细说明
【 man 7 signal 】命令查看
我们可以再shell下,输入kill -信号 进程pid 的方式向指定进程发送信号
Ctrl + C : 就是向前台进程发送2号信号。
Ctrl + \ :就是向前台进程发送3号信号。
#include <signal.h>
int kill(pid_t pid , int signo) : 向任意进程发送任意信号,kill命令就是调用kill函数。
int raise(int signo) : 向当前进程发送信号。
这两个函数都是成功返回0,失败返回-1。
#include <stdlib.h>
void abort(void):使当前进程接收到信号(6 SIGABRT)而终止异常。
这个函数总是会成功,所以没有返回值。
1. SIGPIPE就是一种由软件条件而产生的信号,在管道章节中进行讲解,即读端关闭,写端不会一只写,而是会收到信号SIGPIPE而终止。
2. alarm函数和SIGALRM信号
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
/*
调用alarm函数可以设定一个闹钟,
也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号,
该信号的默认处理动作是终止当前进程。
*/
这个函数返回值是0或者以前设定的闹钟的剩余秒数。
打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响, “以前设定的闹钟时间还余下的时间” 就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。
#include <iostream>
#include <unistd.h>
int main()
{
int count = 1;
alarm(1);
while(count++)
{
std::cout << "count = " << count << std::endl;
}
return 0;
}
在一秒内计数,一秒后被SIGALRM终止。
1. 除0错误
CPU中有一个状态检测寄存器eflags,包含了多个状态标记位,其中有一个溢出标记位OF,可以帮助程序员检测可能得数值错误,如果溢出,OF标志位会被置为1,反之会被置为0。如果为1,内核会向进程发送 8号信号 SIGFPE。
2. 野指针
我们以部分信号为例,第三列表示该信号的默认动作,Core和Term都是中断进程,Ign是忽略动作。
core和term的相同点都是中断进程,但是core会产生一份core文件,dump到硬盘中,协助我们进行debug文件,即事后调试。
当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core。
进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这就是事后调试。
一个进程允许产生多大的core文件取决于进程的Resource Limit,默认是不允许产生core文件,因为core文件可能包含用户密码等敏感信息不安全。
在开发调试阶段,可以用ulimit命令更改这个限制,允许产生core文件。下图演示如何生成core文件,以及调试core文件。
信号的产生和发送都是OS来操作的,因为OS是进程的管理者,且信号的并不是立即处理,而是在合适的时候。信号如果不是立即处理,那么信号就需要暂时被进程记录下来,记录在哪里?进程在还没有收到信号时,是否知道自己应该对哪些信号进行处理?
1. 实际执行信号的处理动作叫做 信号递达(Delivery)
2. 信号从产生到递达之间的状态称为 信号未决(Pending)
3. 进程可以选择阻塞(Block)某个信号
4. 被阻塞的信号产生时保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
5. 阻塞和忽略不同,只要信号被阻塞就不会被递达,而忽略是递达之后的一种可选处理动作。
上图是信号在内核中的表示示意图。
block 和 pending是两个位图,可以理解为32为比特位的位图,第几个比特位表示第几个信号,该比特位是否为1,表示该信号是否有效。在pending位图中,是否有效表示是否收到该信号,block位图中,是否有效表示是否阻塞该信号。
handler是一个函数指针数组,又32个函数指针元素,通过下标来访问对应的方法,如果用户自定义了信号捕捉,该函数指针就指向该函数。
因此,进程通过两个位图和一个有32个元素的函数指针数组来表示信号是否收到,是否递达信号,以及收到信号后,执行信号的动作。
n号信号再被递达前,清除pending位图中对应的比特位,且n号信号在被递达时,会屏蔽你、
号信号,直到n号信号递达后,再处理n号信号。
sigset_t 是 Linux提供的一种类型,叫做信号集,里面封装了位图,用户可以通过sigset_t 来操作进程对信号的屏蔽,获取进程的pending位图,block位图,添加屏蔽信号,删除屏蔽信号等。
阻塞信号集也叫做当前继承的信号屏蔽字,这里的屏蔽应该理解为阻塞而不是忽略。
sigset_t 类型对于每种信号用一个bit为表示有效或者无效,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者角度不必关心,只需要调度以下函数来操作sigset_t变量。
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
sigemptyset 函数用来初始化指向的信号集,使其中所有信号对应的bit清零,表示该信号集不包含任何有效信号。
sigfillset 函数用来初始化,使其中所有信号对应的bit置为1,表示该信号集不包含任何有效信号。
在使用sigset_t 类型变量之前,一定要调用sigemptyset 或者 sigfillset函数做初始化,是信号集处于确定状态。
用sigaddset 和 sigdelset在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回0,失败返回-1.
sigismember 函数是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含,返回1;不包含,返回0;出错返回-1。
调用该函数,可以读取或者更改进程的信号屏蔽子(阻塞信号集)
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
成功返回0,失败返回-1
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出,如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
#include <signal.h>
sigpending
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
程序运行时,每秒钟把各信号的未决状态打印一遍,由于我们阻塞了SIGINT信号,按Ctrl-C将会 使SIGINT信号处于未决 状态,按Ctrl-\仍然可以终止程序,因为SIGQUIT信号没有阻塞。
上图是我们捕捉信号的流程,当进程在因为某种事件进入内核态,处理完这个事件就处理当前进程可以递达的信号。捕捉信号就是执行自定义信号处理函数,这个函数是在用户态,为了安全等问题,需要返回用户态去执行这个函数,再返回内核态,由内核态返回用户态。
用户态和内核态会在下文中进行讲解,再次先做了解。
signal函数就是用来捕捉特定信号的,提供自定义函数,来执行自定义动作。这里就和之前信号保存的内容连接在一起,将进程中函数指针表[signum]的函数指针 指向handler函数,当有该信号被递达时,就执行该函数。
下图是就是捕捉2号信号,捕捉后打印内容:
#include <stdio.h>
#include <signal.h>
void handler(int signo)
{
std::cout << "get a signal : " << signo << std::endl;
}
int main()
{
//对2号信号进行捕捉
signal(2,handler);
while(true)
{
std::cout << "Running , pid : " << getpid() << std::endl;
}
return 0;
}
和signal函数一样,都是用来捕捉信号的,sigaction函数可以读取和修改指定信号相关联的处理动作。调用成功则返回0,出错返回-1。
signum是指定信号的编号。若act不为空,则根据act修改信号的处理动作。若oact不为空,则通过oact传出该信号原来的处理动作,act和oact都是指向sigaction结构体。
sigaction结构中,只需要处理3个参数即可,给出自定义的 sa_handler 函数, 初始化sa_mask,将sa_flags 初始化为0。
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
std::cout << " get a signal" << signo << std::endl;
exit(1);
}
int main()
{
struct sigaction act,oact;
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(2,&act,&oact);
while(true)
{
std::cout << "I am pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
将sa_handler赋值为SIG_IGN传给sigaction表示忽略信号,赋值为SIG_DFL表示执行系统的默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者想内核注册一个信号处理函数,该函数返回值void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用一个函数处理多种信号,显然这也是一个回调函数,不是被main函数调用,而是被OS调用。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
这是怎么做到用户不能随便访问内核空间的?
用户态切换到内核态,就是将CS寄存器中比特位将0改为1。用户想要跳转OS内核态,通过cs检测是否是0,如果是3,不允许访问。因此想要调用系统调用,就先要由用户态切换到内核态。
因此,如果用户想要调用系统调用,先要检查CS寄存器比特位是否为0,如果不是改为0,访问内核空间。
如果用户不使用系统调用,就不能访问到内核空间,因为寄存器的比特位为3,就不允许访问。
用户态与内核态的切换是随时可能进行的。例如CPU要在一定的时间内,通知OS检查时间片,如果时间片到了就切换进程,此时就需要用户态与内核态的切换。
进程在执行函数时,可能会用户态与内核态进行切换,切换时进行信号检测,捕捉信号,在捕捉信号中再次调用该函数,引发了数据二义性问题,此时这个函数就是不可重入函数。
如果一个函数符合以下条件之一则是不可重入的:
1. 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
2. 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
现代编译器会对程序进行优化,例如我们有一个全局函数,某一行调用后,此后不会再使用,影响后面的结果,此时编译器为了速度就不会一次一次内存中读取,而是将数据放在CPU寄存器中,即寄存器隐藏了内存中实际的值。
此时,如果信号捕捉时修改了该变量,但是主函数访问该变量还是从CPU中读取,没有看到内存中修改后的数据,就引发了程序问题。
volatile关键字的作用就是在变量前 + volatile ,要求编译器保持内存的可见性。
#include <signal.h>
#include <iostream>
#include <unistd.h>
int g = 0;
void handler(int signo)
{
std::cout << "get signal" << signo << std::endl;
g = 1;
}
int main()
{
signal(2,handler);
while(!g)
{ ; }
std::cout << "close" << std::endl;
return 0;
}
进程讲过用wait和waitpid函数等待子进程结束来清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞查询子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞就不能处理自己的工作;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下。
其实,子进程终止时会给父进程发送SIGCHLD子进程,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需要处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程。
此外,如果想不产生僵尸进程,还有一种方法:父进程调用sigaction将SIGCHLD的处理动作设置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生子进程,也不会通知父进程。系统的忽略动作和用户设置的SIG_IGN是不一样。
以上就是进程信号的所有内容了,围绕信号的产生的发送,信号的保存,以及信号的处理讲解,每个阶段都有细分的小点,掌握这些,可以说对信号有了完全清晰的理解。
最后,如果感觉本期内容对你有帮助,欢迎点赞,收藏关注Thanks♪(・ω・)ノ