Linux信号超详细剖析

预备知识:

一、信号产生(OS发给进程)

1、键盘组合键

Linux中,一次登录对应一个终端,bash/shell。且只允许一个进程是前台进程,默认就是bash/shell,其它都是后台进程。获取键盘输入的是前台进程。

Ctrl+c: 向前台进程发送2号信号,SIGINT(interrupt),平时的指令都是bash/shell收到然后执行

Ctrl+\:向前台进程发送3号信号 SIGQUIT

Ctrl+Z:向前台进程发送19号信号 SIGSTOP

硬件中断问题:

键盘数据如何输入给内核的?数据如何转化为信号?

CPU有很多针脚(给CPU寄存器0-1充放电),每一个针脚都有自己的编号,键盘是通过中断控制器连接到CPU的,当键盘按下某个按钮,就会触发中断控制器触发中断,然后CPU当中会有一个中断号,操作系统会根据中断号在中断向量表(操作系统和不同外设互通的方法表)当中执行对应的函数。

外设  要拷贝和拷贝完给CPU发送(仅控制信号,DMA芯片....)
硬件中断  中断号  中断向量表

信号  软件中断  模拟硬件中断设计

外设写给内核缓冲区时,OS判断,区分数据和控制。

如果是数据就用系统调用read等 从进程缓冲区-->用户缓冲区(内核-->用户内存)
如果是控制就转化为信号发给进程(用户层)

该过程,内核知道何时开始和终止,进而也能控制进程是运行还是等待。

意义:提高OS效率,不用自己检查外设何时读写

信号是进程之间异步通知的一种方式,软中断
异步:硬件层面何时接收外部写入是不确定的
软件层面 进程何时收到信号是不确定的

2、kill命令

直接用bash/shell向指定进程发送信号

3、系统调用

signal

可以捕捉指定signum的信号,并传入自己的方法,自定义该信号的行为。

9  19号信号 不能被捕捉

只需要设置一次,底层将该进程对应该信号的方法替换了(函数指针)

kill(指定进程指定信号)

模拟实现mykill

raise(调用者发指定信号)

封装了kill(getpid(),signum)

abort(调用者固定信号)

已经变成3普通函数了

abort()

函数内部多了一些功能,比自定义多了固定的abort退出

即发送abort信号-->自定义   调用abort()函数-->自定义+aborted  abort自己比较特殊

4、硬件异常

一般捕捉信号完成一些收尾工作(面向用户  如:C++try catch异常体系),记录日志,数据保存等,在自定义工作完成后退出。

不是为了出现错误解决错误,而是让用户知道错误的原因。

div除零错误/异常

发生除零错误,OS给进程发信号,然后进程退出。

自定义8号进程为仅发送一条消息

当发生除零错误时,原代码执行到a/=0时,OS一直给进程发送8号信号

while :; do ps axj | head -1 && ps axj | grep mysignal | grep -v grep;sleep 1;done

该进程没有退出变为Z状态。

信号8捕捉前,进程要么正常退出,要么执行默认动作FPE后退出

信号8捕捉后,OS一直给它发送8号信号,它就一直执行自定义动作,不会退出

野指针/段错误:

信号捕捉后与上面的div异常相同。

异常如何让OS发信号?(不同的CPU寄存器报错)

1、对于div除零异常

进程不退出就会一直被调度,OS死循环向它发信号。(出现了硬件异常问题,但没有解决,CPU一直检测报错)

2、对于野指针异常

5、软件条件异常(特殊事件)

1、管道PIPE

2、文件描述符fd

返回-1,不会使进程退出。

bad file descriptior

3、闹钟问题
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用 alarm 函数可以设定一个闹钟 , 也就是告诉内核 seconds秒之后给当前进程发SIGALRM信号 , 该信号的默认处理动作是终止当前进程
一次设置闹钟,就是独立的一次定时信号

Myhanlder中可以通过调用alarm设置一些定时任务。

运行主要代码main外,定时执行指定的定时任务。

设置新的alarm的同时,得到上一次alarm的剩余时间,之前没有设置就返回0.

此外,OS中有很多闹钟,管理它们也要有相应的数据结构和对象。

alarm结构体中应该包含:时间戳记录开始/终止时间,指向的pid或task_struct指针

使用优先级队列,按照时间差作为Comp(小堆)

堆顶不超时就不用遍历,堆顶超时就操作后pop直至栈顶不超时

6、Term/Core终止进程区别

Core = Term+core dump

终止+保存出错信息用来事后调试

是否正常退出用[8,15]位表示,收到的信号用[0,6]位表示

收到的是Term还是Core用code dump标志位来标识。

ulimit

云服务器默认不开启core功能

开启core功能

出问题可以事后调试

core dump形成的临时文件太大了。

云服务器中服务挂掉后,第一时间不是为了找到出错位置,而是重新启动。

系统一般会自动重启,事后根据日志等排查。

如果开启core dump,且重启失败,一直重复,就会一直创建临时文件,进而导致磁盘存储的更大的问题。

7、实时信号

用于车载系统等,一遇到信号必须立即处理。

进程会维护一个实时信号队列,该进程每次收到信号就push到该队列中,不存在阻塞情况。

如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次
或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可 以依次放在一个队列里。本章不讨论实时信号。

二、信号的发送

1、进程是否收到信号?2、进程收到哪一个信号?

OS给进程发实际上是给PCB对象发送  [0,31]

OS是进程的管理者,只有OS才有资格修改task_struct

这里的signal就是下面的Pending

三、信号保存

只要来一个信号就要加入Pending中保存,然后结合Block判断是否处理,根据Handler决定如何处理。

实际执行信号的处理动作称为信号递达 (Delivery)
信号从产生到递达之间的状态 , 称为信号未决 (Pending)
进程可以选择阻塞 (Block ) 某个信号。
阻塞的信号产生时将保持在未决状态 , 直到进程解除对此信号的阻塞 , 才执行递达的动作 .
注意 , 阻塞和忽略是不同的 , 只要信号被阻塞就不会递达 , 而忽略是在递达之后可选的一种处理动作。

1、三张表

block表和pending表都是位图的数据结构,而handler表则是一个函数指针数组。
信号的接收主要是靠pending表,block与handler表主要在信号的处理阶段使用。
其中pending表中的0、1就分别表示对应的信号是否存在
而block表中的0、1代表后面的信号是否能够被使用,1表示可以,0表示不可以。
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次 或多次。Linux是这样实现的:常规信号在 递达之前 产生多次只计一次,而实时信号在递达之前产生多次可 以依次放在一个队列里。本章不讨论实时信号。

2、SIG_DEF和SIG_IGN

ignore忽略该信号,default相当于没有signal设置

3、sigset_t类型-->pending

是OS给用户层提供的数据类型,为了提高可移植性,封装一个类型,上层不论什么语言都用一种类型和相应的系统调用即可。

OS设计时只需要根据不同语言来添加不同版本,用户使用是统一的。

sigpending函数

输出型参数+sigismember得到pending位图(该位是否为1)

4、sigprocmask

int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
如果oset是非空指针,则读取进程的当前信号屏蔽字mask通过oset参数传出
如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后 根据set和how参数更改信号屏蔽字。

5、对2号信号屏蔽和解除屏蔽

6、9/19号无法block

阻塞除了9和19的信号,每次发送信号,对应的pending位就被设置为1.

四、信号处理

信号被处理的时间是内核态切换到用户态的时候。

那么什么是内核态,什么是用户态呢?

调用系统调用时,OS会进行内核<-->用户 之间的状态切换

如:int 80

内核态:处于内核态的 CPU 可以访问任意的数据,包括外围设备,比如网卡、硬盘等,处于内核态的 CPU 可以从一个程序切换到另外一个程序,并且占用 CPU 不会发生抢占情况,一般处于特权级 0 的状态我们称之为内核态。

允许访问内核的代码和数据

用户态:处于用户态的 CPU 只能受限的访问内存,并且不允许访问外围设备,用户态下的 CPU 不允许独占,也就是说 CPU 能够被其他程序获取。

进程地址空间

OS的本质(被动接收时钟中断)

内核态与用户态的切换说白了就是CPU状态的切换+页表的切换。

状态切换:1、改变ecs寄存器保证权限 2、更换页表,确定起始地址

CPU中有一个寄存器为cr3(页表/页目录的虚拟地址)

一个ecs寄存器,标识为0时是内核态,标识为3时是用户态。

什么时候会进行内核态与用户态之间的转换呢?情况有很多:

1.系统调用时

2.时间片到了(要切换调度的进程就会进入内核态,返回时检测信号并处理)

for(;;)pause();

画图理解信号处理

问题1:内核态也能执行自定义的代码,为什么要切换回用户态?

内核态权限无约束,用户态的代码可能因此来访问OS的代码和数据,不安全。

问题2:执行完hander方法后为什么要回到用户态再回到内核态?

用户态不知道进入内核前的上下文,执行到哪一行,要进入内核态找到后再返回。

sigaction

问题1:pending何时由1变0

进行信号处理前就会改为0-->  先清零,后调用

这里sigismember要从1开始,因为信号从1开始,0表示是否收到信号

问题2:信号处理时自动屏蔽

当某个信号的处理函数被调用时 , 内核自动将当前信号加入进程的信号屏蔽字 , 当信号处理函数返回时自动恢复原来的信号屏蔽字, 这样就保证了在处理某个信号时 , 如果这种信号再次产生 , 那么 它会被阻塞当前处理结束为止
例:在处理2号信号时,又收到2号信号,此时只会保存新的2号信号,不会立刻再去执行。
原因:在handler中只要陷入内核,(如系统调用,printf访问硬件等),若没有自动屏蔽,就会再次 检测到2号信号并执行,导致 重复调用
如果 在调用信号处理函数时, 除了当前信号被自动屏蔽之外 , 希望自动屏蔽另外一些信号 , 则用 sa_mask 字段说明这些需 要额外屏蔽的信号, 当信号处理函数返回时自动恢复原来的信号屏蔽字。
一直处理2号信号,此时再收到则会保存到pending位图中。

sa_mask

五、可重入函数

1、首先,main函数中调用了insert函数,想将结点node1插入链表,但插入操作分为两步,刚做完第一步的时候,因为某些原因(硬件中断,时间片轮转)使进程切换到内核,再次回到用户态之前检查到有信号待处理,于是切换到sighandler函数。

2、而sighandler函数中也调用了insert函数,将结点node2插入到了链表中,插入操作完成第一步后的情况如下:

3、当结点node2插入的两步操作都做完之后从sighandler返回内核态,此时链表的布局如下:

4、再次回到用户态就从main函数调用的insert函数中继续往下执行,即继续进行结点node1的插入操作。

最终结果是,main函数和sighandler函数先后向链表中插入了两个结点,但最后只有node1结点真正插入到了链表中,而node2结点就再也找不到了,造成了内存泄漏

实际执行顺序如下:

insert函数被不同的控制流调用main函数和sighandler函数使用不同的堆栈空间(并行),它们之间不存在调用与被调用的关系,是两个独立的控制流程),有可能在第一次调用还没返回时就再次进入该函数,我们将这种现象称之为重入。

insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数我们称之为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称之为可重入(Reentrant)函数。

如果一个函数符合以下条件之一则是不可重入的:

  1. 调用了mallocfree,因为malloc也是用全局链表来管理堆的。
  2. 调用了标志I/O库函数,因为标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

六、volatile在信号中的使用

volatile是C语言的一个关键字,该关键字的作用是保持内存的可见性。

原本flag为0,一直死循环,然后发送2号信号改变flag,继续向后执行。

flag为逻辑判断,也是计算,在CPU中进行。

但由于这只是单纯检测flag(只读取不写入),CPU可能对其进行优化(放到寄存器中

g++优化-O

使用-O1优化后发送信号2改变flag,但仍然是死循环。

优化后第一次直接把flag的值拷贝到寄存器中,之后就不会访问内存了(内存不可见),之后每次检测,都从CPU寄存器中读取。

在flag前加上volatile,避免编译器对flag过度优化,使其内存可见即可。

七、SIGCHLD17信号

为了避免出现僵尸进程,父进程需要使用waitwaitpid函数等待子进程结束。

父进程可以阻塞等待子进程结束,也可以非阻塞地查询的是否有子进程结束等待清理,即轮询的方式。采用第一种方式,父进程阻塞就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。

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

单进程下:

因此可以把wait/waitpid写在信号捕捉函数内部。

多进程下:

多个子进程同时退出,当正在处理一个时,会屏蔽SIGCLD信号,就会有一些信号没有被捕捉,进而导致内存泄漏。

  • 0
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值