信号是事件发生的一种通知机制,即便是信号没有发生,进程也知道怎么处理。
信号也算是通信。
那它与通信有什么区别?通信是以传输数据为目的,信号本质是想要把事件通知给进程。
我们来看个例子,这是一个死循环。
#include
int main()
{
while(1)
{
while(1)
{
printf("i am running \n");
sleep(1);
}
return 0;
}
当一个进程在跑时,在命令行中输入命令,没有反应。
因为bash(也是个进程)此时在后台,不能接收到此时的输入。
所以会出现混在一起的情况。两个进程都往显示器上打,都往同一块资源输出,这个资源叫做临界资源,因此显示器就是临界资源,可以很明显得知此时它并没有被保护。
把一个进程放到后台./myfile &
,此时便可以接收输入的其他命令。
为什么ctrl + c
会使程序退出?
本质是:ctrl + c是通过键盘向前台进程发送了信号kill -2
可以用系统调用接口signal
来查看。
#include
void (*signal(int signo, void (*func)(int)))(int);
第一个参数叫做默认信号编号;
第二个参数是函数指针。
作用,注册一个对特定信号的处理动作,当signo到来时,执行这个指针所指的函数。
返回值类型:void (*)(int)
处理信号时,把信号处理的过程叫做信号的捕捉。
#include
#include
#include
void handler(int signo)
{
printf("catch a signal :%d\n",signo);
}
int main()
{
signal(2,handler);
while(1)
{
printf("i am running \n");
sleep(1);
}
return 0;
}
在上面的代码中,我们把2号信号对应的信号处理动作自定义为hander。也就是说,如果ctrl + c真的是操作系统向进程发送了2号信号,那么我们再次ctrl + c时,便会执行hander处理方法。
结论:ctrl + c 本身是键盘向前台进程发送了信号。
这样说准确吗?键盘是硬件,不可能通过硬件直接向软件发送信号。
所以真正的过程是:操作系统识别到了ctrl+c,把 ctrl+c 解释成了信号,向对应进程发送!
这里有以下几个总结点:
用kiil -l
可以查看系统中定义的信号列表:
前31个叫做普通信号,后31个叫做实时信号。
1、忽略此信号。
2、执行该信号的默认处理动作。
3、提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(catch)一个信号。
下文将围绕信号的整个生命周期,概括为三个方面,对信号进行详解。分别是:
1、信号产生前都有哪些方式可以产生信号?
2、信号产生后是如何被保存的?
3、信号在处理时的处理方法?
上文中有提到的用ctrl+c终止进程这个例子,也就是说通过键盘,操作系统把ctrl+c解释成了信号。
常见的命令行输入kill命令是调用kill函数实现的。
kill
函数可以给一个指定的进程发送指定的信号。
#include
int kill(pid_t pid, int signo);
举例:
int main(int argc, char* argv[])
{
if(argc == 3)
{
kill(atoi(argv[1]),atoi(argv[2]) );
}
return 0;
}
用可执行程序mykill来杀死我们放在后台的进程19468:
在Linux中,9号信号不允许被捕捉。
raise函数可以给当前进程发送指定的信号(自己给自己发任意信号)。
#include
int raise(int signo);
举例:
#include
#include
#include
void handler(int signo)
{
printf("catch a signal %d\n",signo);
}
int main()
{
signal(2, handler);
while(1)
{
sleep(1);
raise(2);
}
return 0;
}
abort函数使当前进程接收到信号而异常终止。(给自己发abort信号)
#include
void abort(void);
#include
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。
这种发送方式叫做软件条件。
硬件异常以某种方式被硬件检测到并通知操作系统,操作系统会向当前进程发送适当的信号。
例如,当前进程执行了除以0的指令,CPU的运算单元会产生异常,操作系统将这个异常解释为SIGFPE信号发送给对应进程。
再比如当前进程访问了非法内存地址,处理器中的MMU(Memory Management Unit,内存管理单元)会产生异常,操作系统将这个异常解释为SIGSEGV信号发送给进程。
总结
所有的信号都必须经操作系统发出。
只有操作系统才能对进程指手画脚,因为操作系统是进程的管理者!
一个进程在保存信号时,要保存两个要点:一是收到了什么信号,二是是否收到信号。
引入一个数据结构——位图,来存放这些信息。
比特位的位置:代表收到第几位信号
比特位的内容:1为收到,0为尚未收到。
所以,操作系统给进程“发送”信号这种说法,说成操作系统给进程写信号更好理解一些。
1、进程怎么保存已经收到的信号?
2、操作系统怎么向一个进程发信号?
每一个信号都对应有的事件处理函数,处理这个事件其实就是去执行这个处理函数。
信号的处理方式有三种:
1.默认:操作系统中原定义好的每个信号的处理方式
2.忽略:什么都不做。
3.自定义:自行定义一个事件函数,使用这个函数替换操作系统中原默认的处理函数。在对应信号处理时就会调用这个定义的函数。
注:SIGSTOP/SIGKILL信号无法被阻塞,无法被自定义,无法被忽略。
实际执行信号的处理动作称为信号递达。
信号从产生到递达之间的状态,称为信号未决。
进程可以选择阻塞某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后的一种处理动作。
在一个进程中,针对信号有对应的三个表:分别是block(阻塞)、pending(未决)和handler(处理)。
block和pending的结构一模一样,都是位图。只是比特位的内容代表的含义不同。每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。
信号产生时,内核在PCB中设置该信号的未决标志(pending中对应比特位由0置1),直到信号递达才清除该标志。
在上图中,
由于未决和阻塞的结构一模一样,因此未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集。
这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
信号集操作函数
#include
// 初始化set所指向的信号集,使其中所有信号的对应bit清零
int sigemptyset(sigset_t *set);
// 初始化set所指向的信号集,使其中所有信号的对应bit置1
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);
在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。
#include
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
// 返回值:若成功则为0,若出错则为-1
how:
参数 | 含义 |
---|---|
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号 |
SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号 |
SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值 |
oset:在做修改之前,把以前的屏蔽字到oset中
#include
int sigpending(sigset_t *set)
// 调用成功则返回0,出错则返回-1。
1、首先,对阻塞信号集进行初始化,默认1-31号信号都没有被屏蔽。
2、通过sigpending获取到阻塞信号集,然后显示出来。
3、通过signprocmask把2号信号屏蔽,且给进程发送二号信号(这个动作相当于先修改了block,再修改了pending),然后显示pending。
4、自定义对2号信号的处理动作,解除对2号的屏蔽。
观察现象。
#include
#include
#include
void show_pending(sigset_t *pending)
{
int sig = 1;
for (; sig <= 31; sig++)
{
//判定一个信号是否在集合当中
if (sigismember(pending, sig))
{
printf("1");
}
else
{
printf("0");
}
}
printf("\n");
}
void handler(int sig)
{
printf("get a signal: %d\n", sig);
}
int main()
{
signal(2, handler);
sigset_t pending;
sigset_t block, oblock;
sigemptyset(&block);
sigemptyset(&oblock);
//把二号信号屏蔽
sigaddset(&block, 2);
//设置进操作系统
sigprocmask(SIG_SETMASK, &block, &oblock);
int count = 0;
while (1)
{
sigemptyset(&pending);//整体清空
sigpending(&pending);//获取pending信号集
show_pending(&pending);
sleep(1);
count++;
if (count == 10)
{
// 10s后,解除对二号的屏蔽(恢复)
printf("recover sig mask\n");
sigprocmask(SIG_SETMASK, &oblock, NULL);
}
}
return 0;
}
我们知道,一个信号的处理并不是在发送后立即被处理的,而在合适的时候。什么时候?
是在从内核态切换回用户态时进行检测并处理。
调用系统调用时,操作系统的代码用户不能执行,于是会陷入内核,这时当前进程从用户态转为内核态。
返回到用户:内核态转为用户态。
内核是如何实现信号捕捉的?
(1)当用户正常执行主控制流程由于中断、异常或系统调用会直接进入内核态进行处理。
(2)内核处理完异常准备返回用户态前,会先检测当前进程有没有可递达的信号,如果有就对可递达的信号进行处理。
(3)如果信号的处理函数是用户自定义的就返回用户态去执行用户自定义的信号处理函数。
(4)信号处理函数执行完之后,会调用一个特殊的系统调用函数sigreturn而再一次进入内核态,执行此系统调用。
(5)该系统调用完成之后,就返回主控制流程被中断的地方继续执行下面的代码。
(6)执行主控制流程的时候如果再次遇到异常、中断或系统调用的情况,就继续回到(1),重复这些流程。
问题一:这种状态的来回切换,操作系统是怎么做到的呢?
问题二:第4步,为什么又要回到用户模式去执行自定义的信号处理函数呢?
内核态的权限是非常高的,而用户态的权限是微小的。
在第三步时,作为内核态时,权限辣么高,完全有权利去运行自定义的处理函数,为什么还要转化为用户态去执行呢?
我们来看一段代码:
因为当程序运行后,mmu识别到数组越界后,“硬件异常”,操作系统将该异常解释为信号发送给该进程,但信号是从内核返回到用户时才被处理。
printf函数调用了系统调用接口write,陷入内核,当内核处理完异常准备返回用户态前,会检测到这个进程刚刚因为数组越界被发送的信号,而该信号的处理动作属于默认动作,直接处理掉后,返回用户态,继续执行上次中断的代码。
所以调用完printf后,才报错。
函数可重入指的是可以在不同的执行流中调用函数而不会出现数据二义问题。
操作的原子性:操作一次完成,中间不会被打断
原子操作:操作要么一次完成,要么就不做
函数是否可重入的关键在于函数内部是否对全局数据进行了非原子操作。若对全局变量进行了原子操作,那么这个函数一定是可重入的。
凡是用volatile修饰的变量是不可被“覆盖”的,在任何执行流中读取该数据,必须从该数据的真实存储位置进行读取,不能读取中间的临时缓存相关的数据。
告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略。父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程。子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动释放资源,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。