Linux进程信号
- 初步认识信号
- 信号的存储结构
- 信号的处理方式
- 信号的产生
- 硬件异常产生的信号
- 核心转储
- sigset_t信号集
- 信号集的操作函数
- 对block表的操作
- 对pending表的操作
- 对handler表的操作
- 信号的捕捉
- 用户态和内核态
- 信号的处理过程
- 可重入函数
- volatile关键字
初步认识信号
生活中有哪些信号?例如:红绿灯、闹钟、手势等都叫做信号,我们接收到这些信号都会采取一些措施来应对这些信号。
操作系统里面也是有信号的,进程也是可以接受信号的,接收之后进程也会采取信号所相对应的措施。
信号可能随时产生,信号的产生对于进程来讲是异步的,所以在接收到信号时,进程可能在做优先级更高的事情,不能立即处理信号,所以进程需要有保存信号的能力,在后续合适的时间去处理这个信号。
信号会保存在进程的PCB中,进程的PCB只能由OS(operating system 操作系统的意思,后面都用OS简写代替)修改,所以无论有多少种信号的产生,最终只能由OS来完成最后的发送。
系统自定义的信号列表
1-31为普通信号:普通信号被进程接收首先保存,可以等待进程执行完优先级更高的指令再来处理信号。重点讲解普通信号。因为Linux、windows、安卓等系统都是分时操作系统,用的是分时信号
31-64为实时信号:实时信号必须马上被处理,直到处理完毕。车载系统等实时操作系统会用实时信号。
信号的其他相关概念:
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
信号的存储结构
进程中接收信号的时候可能不会立即处理,但是不代表不会处理,所以需要先保存起来,然后等待合适的时机去处理。所以进程需要记录一个信号在进程中是否存在,0代表不存在,1代表存在,普通信号有31个,所以PCB中信号的保存就可以用位图结构,从低位开始,第一个比特位代表1号信号,依次类推。所以给进程发送信号就是直接修改特定进程的信号位图中的特定的比特位。
实时信号在操作系统中用的是队列的存储方式(了解即可)
在PCB中有三张表分别是:pending、block、handler。
pending表:位图结构。比特的位置,表示哪一个信号,从低位到高位第一个比特位表示1号信号,比特位的内容表示是否接收到该信号,1表示接收,0表示未接收。
block表:位图结构。比特的位置,表示哪一个信号,从低位到高位第一个比特位表示1号信号,比特位的内容表示该信号是否被阻塞。
handler表:函数指针数组。该数组的下标对应信号的递达动作。SIG_DEL表示默认处理,SIG_ING表示忽略,还有就是自定义方法的函数指针。用signal自定义信号递达动作时,就是往指针数组中存放函数地址。
信号的处理方式
当进程收到信号时,有三种处理信号的方法:
- 默认方式
- 忽略信号
- 用户自定义处理
用户自定义处理,有一个接口sighandler_t signal(int signum, sighandler_t handler);
第一个参数是信号的编号,第二个参数是一个函数指针,当进程处理signum时,会执行handler函数的方法。但是只是执行了singal方法,handler方法并不会立马执行,singal方法只是改变了信号产生时对应的执行动作。只有signum信号真正产生时,才会执行handler方法。
9号信号不能被自定义。
信号的产生
当一个进程被执行的时候,分为前台进程和后台进程,前台进程是可以被ctrl+c
直接中断的,本质上就是向这个前台进程发送一个2号信号,或者ctrl+\
给前台进程发送一个3号信号。
那么我们从键盘输入的时候,计算机怎么知道我从键盘里输入了数据呢?
CPU的背面有很多针脚,每一个针脚连接一个硬件,每一个针脚都有自己的编号,表示中断号,当键盘被按下的时候,连接键盘的针脚会接收到键盘被按下的信号,内存中会保存一个中断向量表,向量表中存放的都是函数指针,CPU会根据中断号去中断向量表中查询对应的函数,键盘的函数就是让OS读取数据,键盘被输入的数据就会被OS发送到前台进程。
系统接口:
给对应的进程发送信号。
自己给自己发送信号
结束调用abort的进程。相当于raise(6)。6号信号即便自定义处理方式了,也会执行完自定义操作之后退出。
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。
这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数
硬件异常产生的信号
什么叫做硬件异常产生的信号呢?举个例子:
int a = 10;
a = a/0;
这两行代码如果在Linux下编译会被警告,但是还是会生成可执行程序,在运行的时候会报错(float point exception 8号信号)。然后程序就不会继续向下执行了。
这个进程是怎么接收到这个8号信号的呢?
进程被执行的时候,代码会从上到下依次执行,在CPU中有若干寄存 器,其中有一个状态寄存器会记录每行代码的状态,如果结果不正确,有数据溢出,状态寄存器由0置1。这个寄存器就有了硬件异常。OS就会给引起硬件异常的进程发送信号。如果这个进程自定义了8号信号的处理方式,然后没有退出,操作系统一直调度这个进程一直执行8号信号的自定义动作。
下面这个也是硬件异常产生的信号:
int* p = nullptr;
p = 100;//p是一个指针变量,内部有空间,可以被强行赋值
*p = 100;//野指针
*是一个解引用的操作,就是要对p指向的空间进行访问,p指向的是nullptr,也就是0号空间的地址,进程中想要对变量进行赋值,存放在虚拟地址空间需要通过页表(MMU)去访问对应的物理内存,MMU也是一个硬件,被集成在CPU中的,通过MMU访问物理内存如果访问失败,有两种失败原因,一个是MMU中没有该虚拟地址的映射关系,另一个就是有映射关系但是没有访问权限,无论哪一种。MMU都会产生硬件异常,然后OS给进程发送信号。
核心转储
Linux下有这样一个功能,在进程发生异常的时候,核心代码部分进行核心转储,将内存中进程的相关数据dump到磁盘里面,一般称为核心转储文件,以core命名。如果是云服务器的话默认是关闭的。
ulimit可以设置/显示用户可以使用资源的限制。
我们可以看到core file size是被设置为0的,所以默认不会进行核心转储,如果想打开core文件,可以使用ulimit -c 1024(文件大小)
。
在学习进程等待的时候,waitpid返回结果存储在status,status为整形,32个比特位,返回结果存储在高八位,退出信号存储在第0-7个比特位。第七个比特位就是core dump的标志位,如果为0说明核心转储是关闭的或者进程正常退出,如果为1,说明进程异常退出并且核心转储是打开的。
那怎么样才能进行核心转储呢?首先我们了解了,进程收到信号后默认动作是退出,但是信号退出动作有其中两种trem和core,有什么区别呢?core和上面的core文件有什么关系呢?
如果信号的action是Term,进程收到信号后会直接退出。如果是Core,OS会进行核心转储。
核心转储有什么用
如果一个进程异常退出了,退出原因是最重要的,而异常退出之后生成的核心转储文件可以帮我们很快的定位到是因为哪一行代码退出的,收到几号信号退出的。
生成的可执行程序默认是release,如果需要gdb调试,需要在g++编译的时候加-g选项。
gdb调试时,直接使用core-file命令,后面跟着core文件就可以定位到错误部分。
为什么核心转储是关闭的
因为核心转储文件一般都很大,这种有问题的可执行程序每执行一次就会生成一个核心转储文件,在公司里假如某个服务挂掉了,他就会一直重启,重启一次就会生成一个核心转储文件,很快就会把磁盘占满。
sigset_t信号集
信号集的操作函数
#include <signal.h>
int sigemptyset(sigset_t *set);//初始化信号集
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);//把signo信号添加到set信号集中
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);//查看signo信号是否存在这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含
某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
对block表的操作
对pending表的操作
sigpending函数,把当前进程的pending表设置进set里面
对handler表的操作
上面介绍了signal这个函数可以修改handler表,还有一个函数也可以修改handler表。
NAMEsigaction - examine and change a signal action
SYNOPSIS#include <signal.h>int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);struct sigaction {void (*sa_handler)(int);void (*sa_sigaction)(int, siginfo_t *, void *);sigset_t sa_mask;int sa_flags;void (*sa_restorer)(void);
};
- sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非 空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体;
- 将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来
的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数。
示例代码:
void handler(int signo){cout<<"接收到了"<<signo<<"信号"<<endl;
}
int main(){struct sigaction act,oldact;memset(&act,0,sizeof(act));memset(&oldact,0,sizeof(oldact));act.sa_handler = handler;act.sa_flags=0;sigemptyset(&act.sa_mask);sigaddset(&act.sa_mask,3);sigaddset(&act.sa_mask,4);sigaddset(&act.sa_mask,5);sigaction(2,&act,&oldact);}
信号的捕捉
当进程接收到信号的时候,信号可能不会被立即处理,因为进程在做优先级更高的事情,那么什么时候会处理呢? 答案是当进程从内核态切换到用户态的时候,进程会在OS的指导下进行信号的检测和处理。
补充:当信号之前被block,block解除后对应的信号会被立即递达;
用户态和内核态
进程被加载到内存中被执行的时候分为用户态和内核态
用户态:执行我们自己写的代码时,进程所处的状态。
内核态:执行OS的代码时,进程所处的状态。例如:进程时间片到了,需要执行进程切换逻辑代码的时候。或者调用系统接口的时候。都处于内核态。
重新理解进程地址空间
- [0GB,3GB]是用户空间,每个进程的用户空间是不一样的。每个进程都有自己的用户级页表。
- [3GB,4BG]是内核空间,每个进程的内核空间都是一样的。所有进程都用同一张内核级页表。
- 操作系统就是在进程中运行的。
- 调用系统接口,就和调用自己写的库函数一样,都是在进程地址空间内完成调用。
- 进程在用户态的时候,无法访问OS的数据和代码。CPU中有一个寄存器(CR3)中比特位为3表示用户态,比特位为0表示内核态。但是用户无法直接修改寄存器的状态,我们调用系统接口的时候就需要从用户态切换成内核态。所以系统调用接口内部会帮我们做这个事情,在刚开始进入系统函数的时候没有立马进入内核态,还没有触发状态检测,系统接口会先修改CR3的寄存器状态,然后再去执行函数代码。
进程是如何被调度的?
我们都知道进程是被OS管理和调度的。那么到底如何调度的呢?
OS的本质也是软件,是一个一直死循环的软件。电脑的开机操作本质上就是把OS加载到内存。上面提到了OS在每一个进程中的内核空间中运行。在没有进程被OS调度的时候,OS也有自己的进程可以执行,在centos7中叫做systemd也就是1号进程。
Linux是一个分时系统,如果内存中有若干个进程,它会让每个进程都能被执行到,所以每个进程都有时间片。如果进程的时间片到了,就需要切换到别的进程,OS如果执行某段逻辑代码,例如死循环等,怎么知道该进程的时间片到了,需要切换进程呢?
在电脑主板上有一个时钟硬件,它是用来记录时间的。就算我们的电脑关机很久并且不联网,再开机电脑的时间也不会错误。就是依赖这个时钟硬件。这个时钟硬件会每隔很短的时间给OS发送一次硬件中断。OS就会执行对应的中断处理方法,会检查当前进程的时间片,如果超时。OS会将当前进程进行保存等一系列处理。OS会调用一个叫做schedule();的系统接口完成进程的切换调度。而这一切都是OS在当前进程的内核空间内完成的操作。
信号的处理过程
上面说了,信号只有在内核态向用户态转换的时候才会被处理,那么具体过程是什么呢?
== 当我们执行用户态的代码时,会因为系统调用等原因陷入内核态,进入内核态后完成某种任务之后,内核态要向用户态转换,转换之前会检查一下block表和pending表,如果block为0,pending表为1,就会执行对应的handler方法,有三种处理方式,其中是SIG_DEL、SIG_IGN、自定义函数。其中自定义函数在进程的用户空间内定义的,所以需要跳转到用户态去执行自定义方法,之所以不用内核态是因为防止自定义函数利用内核态权限修改OS数据或代码。执行完自定义方法之后不能直接返回到用户态的上下文中,因为自定义方法并不知道用户态到内核态的位置,需要先返回到内核态(sigreturn),然后在内核态用sys_sigreturn()系统接口返回。==
内核对信号进行处理之前会先把pending表中的bit位置为0。
可重入函数
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
我们学习的大多数函数都是不可重入的。
volatile关键字
我们先看一个现象 然后解释这个关键字
int quit=0;
void handler(int signo)
{cout<<"quit from zero to one"<<endl;quit=1;cout<<quit<<endl;
}
int main(){signal(2,handler);while(!quit);cout<<"main formal quit"<<endl;return 0;}
上面这段代码正常应该是接收到2号信号 然后修改quit退出死循环 然后正常退出。看看结果:
实际上确实是这样,没什么不对,但是Linux下gcc的优化级别分为O0 O1 O2 O3
,O3的优化级别是最高的,O0是默认编译方式,不做优化,不优化就不会有什么问题,如果把优化级别改成O1,就不一样了。
g++ -o $@ $^ -std=c++11 -O1
再进行编译运行
无论发送多少2号信号都不会退出,为什么?解释一下:
quit这个变量是在内存中存放的,cpu想要执行这段代码,对这个循环进行逻辑判断,需要先把quit的数据从内存中获取到cpu再进行运算,但是如果编译级别优化之后,cpu对这个quit变量使用频率非常高并且发现main函数里面只是对quit变量进行取反再判断,并没有进行修改。所以把变量存放在寄存器内部,每次判断从寄存器读取数据即可,即使变量在内存中被修改cpu也不会重新读取,这就叫做内存位置不可见了。解决方法加上volatile关键字,就是告诉cpu每次要从内存中获取数据,保证内存可见性。