进程信号(信号产生 | 信号保存 | 信号处理 | 阻塞 未决 递达)

信号是事件发生的一种通知机制,即便是信号没有发生,进程也知道怎么处理。

信号也算是通信。

那它与通信有什么区别?通信是以传输数据为目的,信号本质是想要把事件通知给进程。

一、信号的概念

我们来看个例子,这是一个死循环。

#include<stdio.h>
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 <signal.h>
	void (*signal(int signo, void (*func)(int)))(int);

第一个参数叫做默认信号编号;
第二个参数是函数指针。
作用,注册一个对特定信号的处理动作,当signo到来时,执行这个指针所指的函数。
返回值类型:void (*)(int)

处理信号时,把信号处理的过程叫做信号的捕捉

#include<stdio.h>
#include<signal.h>
#include<unistd.h>
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 解释成了信号,向对应进程发送!

这里有以下几个总结点

  1. ctrl+c 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
  2. 在一个终端(会话)中,只允许一个前台进程运行,但可以同时运行任意多个后台进程。其中,只有前台进程才能接到像 ctrl+c 这种控制键产生的信号。
  3. 前台进程在运行过程中用户随时可能按下 ctrl+c 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步的。

kiil -l可以查看系统中定义的信号列表:
在这里插入图片描述
前31个叫做普通信号,后31个叫做实时信号。

信号处理常见的方式

1、忽略此信号。
2、执行该信号的默认处理动作。
3、提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(catch)一个信号。

下文将围绕信号的整个生命周期,概括为三个方面,对信号进行详解。分别是:
1、信号产生前都有哪些方式可以产生信号?
2、信号产生后是如何被保存的?
3、信号在处理时的处理方法?

在这里插入图片描述

二、产生信号的触发条件

1、通过终端按键产生信号。

上文中有提到的用ctrl+c终止进程这个例子,也就是说通过键盘,操作系统把ctrl+c解释成了信号。

2、调用系统函数向进程发信号

常见的命令行输入kill命令是调用kill函数实现的。
kill函数可以给一个指定的进程发送指定的信号。

#include <signal.h>
	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<signal.h>
	int raise(int signo);

举例:

#include<stdio.h>
#include<stdlib.h>
#include<signal.h>

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 <stdlib.h>
	void abort(void);

3、由软件条件产生信号

#include <unistd.h>
	unsigned int alarm(unsigned int seconds);

调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。

这种发送方式叫做软件条件。

4、硬件异常

硬件异常以某种方式被硬件检测到并通知操作系统,操作系统会向当前进程发送适当的信号。

例如,当前进程执行了除以0的指令,CPU的运算单元会产生异常,操作系统将这个异常解释为SIGFPE信号发送给对应进程。

再比如当前进程访问了非法内存地址,处理器中的MMU(Memory Management Unit,内存管理单元)会产生异常,操作系统将这个异常解释为SIGSEGV信号发送给进程。

总结

所有的信号都必须经操作系统发出。
只有操作系统才能对进程指手画脚,因为操作系统是进程的管理者!

三、信号产生后是如何被保存的

一个进程在保存信号时,要保存两个要点:一是收到了什么信号,二是是否收到信号。
引入一个数据结构——位图,来存放这些信息。
比特位的位置:代表收到第几位信号
比特位的内容:1为收到,0为尚未收到。

所以,操作系统给进程“发送”信号这种说法,说成操作系统给进程写信号更好理解一些。

1、进程怎么保存已经收到的信号?

  • 普通信号1-31,用位图来表示(第一个bit位代表1号,以此类推…)

2、操作系统怎么向一个进程发信号?

  • 比如操作系统要向该进程发送第x号信号,首先它先找到这个进程的PCB,然后把这个进程PCB内部的信号位图的第 x 个比特位由0置1即可。

四、信号的处理

每一个信号都对应有的事件处理函数,处理这个事件其实就是去执行这个处理函数。
信号的处理方式有三种:
1.默认:操作系统中原定义好的每个信号的处理方式
2.忽略:什么都不做。
3.自定义:自行定义一个事件函数,使用这个函数替换操作系统中原默认的处理函数。在对应信号处理时就会调用这个定义的函数。

注:SIGSTOP/SIGKILL信号无法被阻塞,无法被自定义,无法被忽略。

阻塞信号

实际执行信号的处理动作称为信号递达

信号从产生到递达之间的状态,称为信号未决

进程可以选择阻塞某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。

注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后的一种处理动作。

在一个进程中,针对信号有对应的三个表:分别是block(阻塞)、pending(未决)和handler(处理)。
在这里插入图片描述
block和pending的结构一模一样,都是位图。只是比特位的内容代表的含义不同。每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。

  • block记录信号被屏蔽或阻塞的信息。
  • pending保存收到的信号,对应的就是保存信号的那个位图。
  • handler是一个函数指针数组,里面是一个一个指向不同处理函数的指针。signal函数修改的就是handler表。

信号产生时,内核在PCB中设置该信号的未决标志(pending中对应比特位由0置1),直到信号递达才清除该标志。

在上图中,

  • 第一行代表的是,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作SIG_DFL
  • 第二行代表的是,当前进程收到了SIGINT信号,但它正在被阻塞,所以暂时不能递达。如果解除阻塞,处理动作为SIG_IGN。(这里要注意,虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。)
  • 第三行代表的是,当前进程尚未收到SIGQUIT信号,但三号是被阻塞的。如果解除阻塞,当收到信号后,递达时,处理动作执行的是用户自定义方法。

信号集

  • sigset_t

由于未决和阻塞的结构一模一样,因此未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集。

这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。

阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

信号集操作函数

#include <signal.h>

// 初始化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做初始化,使信号集处于确定的状态。

  • sigprocmask函数:读取或更改进程的信号屏蔽字(阻塞信号集)
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
// 返回值:若成功则为0,若出错则为-1

how:

参数含义
SIG_BLOCKset包含了我们希望添加到当前信号屏蔽字的信号
SIG_UNBLOCKset包含了我们希望从当前信号屏蔽字中解除阻塞的信号
SIG_SETMASK设置当前信号屏蔽字为set所指向的值

oset:在做修改之前,把以前的屏蔽字到oset中

  • sigpending函数:获取当前进程的pending信号集,通过参数set传出
#include <signal.h>
int sigpending(sigset_t *set)
// 调用成功则返回0,出错则返回-1。
  • 简单的代码实现:

1、首先,对阻塞信号集进行初始化,默认1-31号信号都没有被屏蔽。
2、通过sigpending获取到阻塞信号集,然后显示出来。
3、通过signprocmask把2号信号屏蔽,且给进程发送二号信号(这个动作相当于先修改了block,再修改了pending),然后显示pending。
4、自定义对2号信号的处理动作,解除对2号的屏蔽。

观察现象。

#include<stdio.h>
#include<unistd.h>
#include<signal.h>

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),重复这些流程。

问题一:这种状态的来回切换,操作系统是怎么做到的呢?

  • 不同的进程,用户区所对应的代码和数据是不一样的,而内核区却是一样的。因为在系统中,只有一份操作系统的代码和数据,所以当在cpu上运行内核区的代码时,因为代码只有一份,其实此时可以认为,该进程就变成了操作系统。

问题二:第4步,为什么又要回到用户模式去执行自定义的信号处理函数呢?
内核态的权限是非常高的,而用户态的权限是微小的。
在第三步时,作为内核态时,权限辣么高,完全有权利去运行自定义的处理函数,为什么还要转化为用户态去执行呢?

  • 原因是为了防止恶意操作。
    如果这个定义的处理动作是一个非法甚至具有破坏性的操作,那么此时如果作为内核态去执行,就相当于 “借刀杀人”,借着权限最高的操作系统的手去做一些不好的事情,这样是无法被控制滴。
    这也就说明了,谁的代码就应该由谁执行。

我们来看一段代码:

在这里插入图片描述
在这里插入图片描述
现在就不难理解,为什么printf会打印嘞?

因为当程序运行后,mmu识别到数组越界后,“硬件异常”,操作系统将该异常解释为信号发送给该进程,但信号是从内核返回到用户时才被处理。

printf函数调用了系统调用接口write,陷入内核,当内核处理完异常准备返回用户态前,会检测到这个进程刚刚因为数组越界被发送的信号,而该信号的处理动作属于默认动作,直接处理掉后,返回用户态,继续执行上次中断的代码。
所以调用完printf后,才报错。

可重入函数

函数可重入指的是可以在不同的执行流中调用函数而不会出现数据二义问题。

操作的原子性:操作一次完成,中间不会被打断
原子操作:操作要么一次完成,要么就不做

函数是否可重入的关键在于函数内部是否对全局数据进行了非原子操作。若对全局变量进行了原子操作,那么这个函数一定是可重入的。

volatile关键字

凡是用volatile修饰的变量是不可被“覆盖”的,在任何执行流中读取该数据,必须从该数据的真实存储位置进行读取,不能读取中间的临时缓存相关的数据。

告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。

SIGCHLD信号(了解)

子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略。父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程。子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。

不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动释放资源,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值