文章目录
- 信号
- 准备知识:
- 信号产生的方式
- 实验验证:9号信号是不可被捕捉(自定义的)
- 信号处理:
- 信号产生前:
- 信号产生的方式:键盘
- 实验显示:段错误(野指针)
- 实验验证:浮点数错误8号信号。
- 为什么我会收到信号呢?
- 信号产生方式: 进程异常产生信号。
- 实验验证:信号中止进程core_dump标志位是被设置了的事实
- 信号产生方式:系统调用接口产生kill();
- 实验验证:用系统接口kill()自主实现kill命令
- 软件条件也能产生信号
- 实验验证:统计一下alarm一秒钟,能够对一个整形值累加到多少(验证有无IO的效率差距)
- 如何理解OS给进程发送信号?
- 信号产生中
- linux 在进程当中如何识别该信号
- sigset_t 数据类型
- sigprocnask()设置屏蔽字修改block位图
- 实验:设置2号信号的屏蔽字
- sigpending()处理pending位图
- 实验:获取pending位图
- singal函数就是用来修改handler 表的
- 信号发送后
- 内核态和用户态
- 状态切换理解(何时进行信号捕捉)
- 信号捕捉函数sigaction();
- 实验:使用sigaction进行信号捕捉
- 可重入函数:
- volatile
- SIGCHLD信号
- 实验证明
信号
信号VS信号量,毫无关系
准备知识:
生活中信号:闹钟,红绿灯,烽火台。
当我们听到这些时,立马知道发生了什么。
- 是不是只有这些场景发生才知道该怎么做吗?
不是,其实和场景是否被触发是没有直接关联的。信号的处理动作甚至远远早于信号的产生
- 如何做到早就知道的呢?我们对特定事件的反应是被教育的,就是你记住的。
进程在没有收到信号的时候,知道如何识别并处理那一个信号。
-
那进程是如何做到的呢?
工程师代码设置好的。进程识别处理信号的这个能力是远远早于信号的产生的。
-
信号是给进程发的,进程要在合适的时候执行对应的动作。
收到某种信号并不是立即处理的。原因是信号随时都可能产生(异步),但是可能要做更总要的事情。进程收到某种信号的时候,并不是立即处理的而是在合适的时候。 -
那么已经到来的信号是不是应该被暂时保存起来?是。
进程收到信号之后,需要先将信号保存起来,以供在合适的时候被处理。 -
应该保存在哪里呢?进程控制块struct task_struct{}中。
信号本质也是数据。信号的发送就是往进程task_struct{}中发送数据。
task_struct是一个内核数据结构,定义进程对象。内核不相信任何人,只相信自己。 -
是谁向task_struct{}中写入数据呢?OS。无论信号如何发送,底层都是OS放的。
信号产生的方式
Ctrl C:就是向进程发送一个2号信号。
验证:通过signal注册对于2号信号的处理动作,改成我们自定义动作。注册handler的时候不是调用这个函数,只有当信号到来的时候才会调用。
kill -l
:查看信号
signal();
修改进程对信号的默认处理工作。
-
信号的产生方式其中一种就是键盘方式。
当
./mytest &
是将进程转换到后台进程,键盘产生的信号,只能中止前台进程。
kill -9 pid杀掉后台进程。
kill -signal_number +pid:向进程发送信号。 -
一般而言,进程收到信号的处理方案有三种。
-
默认动作,一部分是中止自己,
-
忽略动作,是一种信号的处理,只不过动作就是什么也不干。
-
(信号的捕捉)自定义动作,用singal()方法就是在修改信号的处理动作,默认-》自定义。
-
实验验证:9号信号是不可被捕捉(自定义的)
#include<stdio.h>
#include<signal.h>
#include<sys/types.h>
#include<unistd.h>
void handler(int signo)
{
switch(signo)
{
case 2:
printf("hello xiaoawei,get a signal :%d\n",signo);
break;
case 3:
printf("hello xiaoawei,get a signal :%d\n",signo);
break;
case 9:
printf("hello xiaoawei,get a signal :%d\n",signo);
break;
default:
break;
}
// exit(1);
}
int main()
{
int sig=1;
for(sig;sig<=31;sig++)
{
signal(sig, handler);
}
while(1)
{
printf("hello xiaoawei,pid:%d\n",getpid());
sleep(2);
}
return 0;
}
信号处理:
信号产生前:
实验显示:段错误(野指针)
野指针或者内存错误就是段错误,就是进程的崩溃。为什么会崩溃?因为收到信号。
进程收到11号信号,造成进程崩溃。
int *p=NULL;
*p=100;
实验验证:浮点数错误8号信号。
int a =10;a/=0;
结论:在Windows或者Linux系统下,进程崩溃的本质及时进程收到对应的信号,然后进程执行信号对应的处理动作
为什么我会收到信号呢?
段错误就显示到虚拟地址对应时出现错误,在硬件上有所显示。CPU进程运算除零错误状态寄存器有所显示。软件上面的错误,通常会体现在硬件或者其他软件上。而OS是硬件的管理者,就要对于硬件的健康负责。你的进程导致出错,OS就会溯源到你的进程,并且向你的进程发送信号进程处理终止进程。try-catch就是语言层面上的信号捕捉。
程序中存在异常问题导致我们收到信号退出。
- 当进程崩溃的时候,你最想知道什么?想知道原因
waitpid();status低7位可以获得退出信号,崩溃时可以得到原因。
-
你还想知道啥?怎么解决,在哪一行崩溃的?为了方便后续调试
core dump标志
。进程如果异常的时候,被core dump,该位置会被设置为1.
当一个进程退出的时候,他的退出码和退出信号都会被设置(正常情况)
当一个进程异常的时候,进程的退出信号会被设置,表示当前进程退出的原因。
如果必要,OS会设置退出信息中的core dump
标志位,并将在内存中的数据转存到磁盘中,方便我们后期调试。core dump 默认是被关掉的,也就是0.当出现浮点数错误时并不会产生core dump文件。设置打开之后,会形成。
利用GDB事后调试:makefile 后添加-g
选项叫做可调试。
不一定所有的情况都会形成coredump文件。比如kill -9。
实验验证:信号中止进程core_dump标志位是被设置了的事实
实验验证:用系统接口kill()自主实现kill命令
系统调用接口函数kill()
向进程发送信号(命令行参数的使用)
raise
自己给自己发送信号。自举函数
abort()
给自己发送sigabort
6号信号。
通过某种软件,来出发信号的传送,系统层面设置定时器
或者某种操作而条件导致不就绪等这样的场景下,触发信号发送。
alarm();14号信号
例子:进程间通信的时候,不读而且关闭读取入口的时候,一直在写没有意义的时候,OS向写端发送信号13号信号关闭了写端。
实验操作:5秒的进程,告诉OS,3秒之后给我发一个信号。返回值是剩余的2秒的时间。
跑了1秒钟直接取消:
实验验证:统计一下alarm一秒钟,能够对一个整形值累加到多少(验证有无IO的效率差距)
-
alarm执行默认操作,不做信号捕捉统计
int main() { alarm(1); while(1) { printf("count:%d\n",count++);//不断地进行IO操作 }
-
执行信号捕捉操作,1秒钟后中止。
int count=0; void handlerAlarm(int signo) { printf("count:%d\n",count); exit(1); } int main() { alarm(1); signal(SIGALRM,handlerAlarm);//信号捕捉是纯CPU和内存之间的操作 while(1) { count++; }
- 为何第一个很慢呢?
纯CPU和服务器是非常快的。有IO信号传输打印到显示器还是很慢的。
结论:信号产生的方式种类虽然很多,但是无论产生信号的方式怎样,
但是一定是通过OS向目标进程发送的信号。
如何理解OS给进程发送信号?
信号的编号是有规律的[1,31]
struct task_struct{
// 进程各种属性
// 进程内部一定要有对应的数据变量来保存记录是否收到了对应的信号
//用什么数据变量表示是否接收到信号呢?
uint32_t sigs;//位图结构
//0000 0000 0000 0000 0000 0000 0000 0000一共32位
//所谓比特位的位置代表的就是哪一个信号,比特位的内容(0,1)代表的就是是否收到了信号
//进程中采用位图表示是否收到信号。
};
本质是OS向指定进程的task_struct{}
的信号位图写入比特位,即完成了信号的发送,信号的写入。
信号产生中
-
实际执行信号的处理动作就成为信号传达。自定义 默认 忽略
-
信号从产生到传达之间的状态成为信号未决。本质存在于task_struct的位图中
-
进程可以选择阻塞某个信号。
本质是OS允许进程暂时屏蔽信号。该信号仍然是未决的。
信号不会被递达,直到解除阻塞,才能递达。- 忽略VS阻塞
忽略是递达的一种方式,阻塞是没有被递达,递达。
- 忽略VS阻塞
linux 在进程当中如何识别该信号
进程控制块task_struct{}
中有三张表block pending handler(函数指针数组)
-
pending确认位图是否收到了信号,保存的是已经收到但是还没有递达的信号
OS发送信号的本质就是修改目标进程的pending 位图。 -
block:状态位图表示哪些信号不应该被递达,直到解除阻塞,才能递达。
block表:本质上也是位图结构。uint32_t block;比特位的位置代表信号的编号
比特位的内容,代表信号是否被屏蔽,阻塞如果此时某某一个信号位的pending收到了信号,如果是被阻塞的,就不看是否收到该信号
如果是没有被block,如果再进行,该信号没有被block已经收到了,就证明可以抵达了
也就是说block说的算,block表又叫做阻塞表,阻塞位图,也叫作信号屏蔽字 -
函数指针数组 handler,处理信号执行自定义的函数方法
里面的函数都是程序员写好的,没接收信号到也知道怎么办。
信号的编号就是该数组的下标 -
以上三个位图实现了进程内置了识别信号的方式。
int isHandler(int signo)
{
if(block& signo)
{
//该信号被block阻塞了,根本就不看是否接收到该信号pending
}
else
{
if(signo & pending)
{
//该信号没有被阻塞并且已经收到了
handler_array[singo](signo);
return 0;
}
}
return 1;
}
不要认为有接口的才算是system call,也要意识到OS也会给用户提供数据类型配合系统调用来完成,比如pid_t是fork创建子进程的返回值。
sigset_t 数据类型
#include <signal.h>
//设置数据类型函数
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);
sigset_t数据类型结构接收pending时表示信号是否被接收,接收block时表示某一个信号是否被阻塞。
虽然sigset_t 是一个位图结构,但是不同的OS实现时不一样的,不能让用户直接修改该变量需要使用特定的函数。
sigset_t set;
set
是变量,在哪里保存呢?都是在用户栈上
sigprocnask()设置屏蔽字修改block位图
sigset_t *set是输入性参数;oldset是输出型参数(返回老的信号屏蔽字-block位图)
SIG_BLOCK: set包含了我们希望添加的当前信号屏蔽字的信号,相当mask=mask|set;
SIG_UNBLOCK:set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set
SIG_SETMAXK:设置当前信号屏蔽字为set所指向的值,相当于msk=set
读取或者更改进程的信号屏蔽字,设置屏蔽字的方式,让某些信号不被递达。
9号信号可以屏蔽吗?不可以,管理员信号是不能被屏蔽和自定义捕捉的。
实验:设置2号信号的屏蔽字
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
int main()
{
sigset_t iset,oset;
sigemptyset(&iset);
sigemptyset(&oset);
sigaddset(&iset,2);
sigprocmask(SIG_SETMASK,&iset,&oset);
while(1)
{
printf("hello world\n");
sleep(1);
}
return 0;
}
sigpending()处理pending位图
不对pending位图做出修改,只是单纯的获取进程的pending位图。显然*set
只是输出型参数。
- 那pending位图是由谁来更改的呢?OS,我们只需要知道怎么获取就行。
实验:获取pending位图
如果我的进程屏蔽掉2号信号,并且不断的获取当前进程的pending位图,并且打印显示(0000000000),然后手动发送2号信号,因为2号信号不会被递达,所以会一直保存在pending位图中,当不断获取位图时,打印的就是
当重新接收2时信号重新被抵达,但是你看不到由1变成0:
因为2号信号的默认是中止进程,就没了啊。非得看到,所以就对2号信号进行捕捉然后执行自定义函数,改变含义就行。
singal函数就是用来修改handler 表的
信号发送后
- 为什么合适的时候才会处理。当前进程可能在做更重要的事情。
信号延时处理(取决于OS和进程)
-
什么是合适的时候?信号什么时候被处理?
因为信号是被保存在进程PCB中的pending位图当中,处理包括检测和递达(忽略,默认,自定义)
当进程从内核态返回到用户态
的时候,进行上限的检测并且处理工作。
内核态和用户态
理解一:
用户态:就是用户代码和数据被访问或者执行的时候,所处的状态。我们自己写的代码全部都是在用户态执行的。
内核态:执行OS的代码和数据时,计算机所处的状态就叫做内核态,OS的代码的执行全部都是在内核态
- 主要区别:在于权限
调用系统调用接口就是在用户和OS之间的切换,比如用户调用open()系统调用,open函数是在内核中实现的,用户调用系统函数的时候,除了进入open函数,身份也发生变化,用户身份变成了内核身份。
理解二:
用户的身份是以进程作为代表的。用户的数据和代码一定要被加载到内存,OS的代码和数据也是一定要被加载到内存中的(开机的过程)。
-
OS的代码是怎么被执行到的呢?只有一个CPU。因为存在系统级页表(内核页表),整个系统只有一份。
CPU根据用户级页表将物理内存中的用户的代码数据加载到内存,同理CPU调用系统级别页表找到磁盘上的OS代码数据。而用户级页表人手一份,而系统级页表只有一个,为各种进程所共享。
CPU中的CR3寄存器区分OS和普通用户,为0代表OS,为3代表用户。
进程具有了地址空间,是能够看到用户和内核的所有内容的不一定能访问。
- CPU内有寄存器保存了当前进程的状态,
用户态使用的是用户级页表,只能访问用户数据和代码
内核态使用的是内核级页表,只能访问内核级的代码和数据
所有进程之间无论如何进行切换,都能够保证一定找到一个OS,因为我们每个进程都有3~4GB的地址空间使用同一张内核页表,因为每个进程的地址空间都有1G的内核空间。
所谓的系统调用,就是进程的身份转化成为内核,然后根据内核页表找到系统函数执行就行了。
在大部分情况下,实际上我们OS都是可以在进程的上下文中直接执行的。
状态切换理解(何时进行信号捕捉)
用户态执行系统调用,切换到内核态身份,执行完毕之后返回的途中
要进行进程信号的检查,有没有被block 有没有被OS传递某种信号,没信号就直接返回。如果有信号,就经过三张表,一次检查是否被屏蔽,pending位图检查信号是否到达,以及默认(比如是2号中止,那么直接在现在的内核态中止进程)忽略(直接返回用户态走系统调用的下一行代码)还是自定义捕捉(根据函数地址找到自定义函数(内核态->用户态),执行完再回到内核态,然后再返回到系统调用代码)。具体情况如下:
- 自定义捕捉的理解从上图来的抽象图:
这样实现返回时机合适的情况。
- 为何一定要切换回用户态才能够执行信号捕捉方法?
OS能不能直接执行别人的代码?OS不相信任何人,OS因为身份特殊不能直接执行用户的代码。如果handler方法里面写了恶意代码就直接导致了系统的崩溃。
所以合适的时候就是从内核切换回用户态的时候,对于信号的检查和处理。
信号捕捉函数sigaction();
修改的是handler函数指针数组,和signal();
函数类似。
NAME
sigaction - examine and change a signal action
SYNOPSIS
#include <signal.h>
int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
// 输入性参数 输出型参数
实验:使用sigaction进行信号捕捉
默认:
一个信号是不允许嵌套捕捉的。
当一个进程信号被检查执行时,会默认将他的信号设置屏蔽字,以免再执行过程中再一次调用它,形成混乱。
而你想:
当一个信号执行时,顺带着将其他信号也进行处理,比如在自定义捕捉2号信号时,也同步自定义掉3号信号,就可以将想处理的另一个信号添加到sigaction结构体中的sa_mask中,是sigset_t类型,要调用设置这个数据类型的函数。
int main()
35 {
36 struct sigaction act;
37 memset(&act,0,sizeof(act));
38 act.sa_handler=handler;
39 //act.sa_handler=SIG_IGN;//忽略这个2号信号
40 sigemptyset(&act.sa_mask);
41 sigaddset(&act.sa_mask,3);//顺便处理3号信号的自定义
42
43 sigaction(2,&act,NULL);
44 //修改的是当前进程的handler函数指针数组特定内容
45 while(1)
46 {
47 printf("hello xiaoawei\n");
48 sleep(1);
49 }
重复发送2号信号,只会记住一个,就会有信号的丢失,可以是最新的那一个。而实时信号是不会像上面一样丢失的。
可重入函数:
主执行流执行插入节点时,因为信号的到来,调用信号捕捉执行流都进行插入节点。insert函数被重复进入了。造成节点的覆盖,链接混乱问题,节点丢失,内存泄漏的问题。
如果一个函数被重入不会出现问题,叫做可重入函数
如果一个函数被重复进入会出现问题,叫做不可重入函数
我们学到的大部分函数,大部分是不可重入的。
malloc free,调用各种库的函数实现的都是不可重入的。
volatile
编译器会对程序进行优化,程序逻辑中是CPU不断对内存中的变量进行读取并且计算结果之后返回到内存当中。
因为主执行流没有对flag进行修改,优化就不将他放回内存而是放到寄存器中,这样就可以很快的下一次访问到flag。
优化的过程就是为了减少频繁访问的次数,就将内个变量的内容缓存到寄存器,以后主执行流就直接访问CPU中的缓存数据就行了。
但是,当信号调用函数对于内存中的数据进行修改之后,CPU中的并没有发生变化,CPU就忽略了信号对于内存中数据的处理。
当加上了volatile之后,就修正了这一问题,被寄存器屏蔽掉了。
作用就是:告诉编译器不要对我这个变量做任何优化,要贯穿式的读取内存不要读取中间缓冲区寄存器中的数据,保持内存的可见性。
SIGCHLD信号
子进程退出时,会向父进程发送SIGCHLD信号,该信号的默认处理动作是忽略。避免父进程阻塞式等待子进程的退出。
实验证明
我们可以在信号捕捉函数的时候可以直接回收子进程。waitpid();
void handler(int sig)
{
pid_t id;
while((id=waitpid(-1,NULL,WNOHANG))>0)
//WNOHANG非阻塞,一直读,读取失败时就说明底层没有子进程需要退出了
//-1代表任意一个子进程
//while循环解决了多个子进程
{
printf("wait child success:%d\n",id);
}
printf("child is quit!%d\n",getpid());
}
-
如果不想手动的设置子进程的回收,也不用给父进程发信号,你完事了就直接退出吧!也别成Z状态等待别人释放了。
不回收子进程,我们可以对他显示设置忽略17号信号
signal(SIGCHLD,SIG_IGN)
,当进程退出后不用父进程wait,自动释放僵尸进程,就没有僵尸状态了。不是所有的场景都需要我们等待。子进程退就退吧!只在Linux下有效。