预备知识:
一、信号产生(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
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,不会使进程退出。
3、闹钟问题
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到该队列中,不存在阻塞情况。
二、信号的发送
1、进程是否收到信号?2、进程收到哪一个信号?
OS给进程发实际上是给PCB对象发送 [0,31]
OS是进程的管理者,只有OS才有资格修改task_struct
这里的signal就是下面的Pending
三、信号保存
只要来一个信号就要加入Pending中保存,然后结合Block判断是否处理,根据Handler决定如何处理。
1、三张表
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);
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:信号处理时自动屏蔽
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
)函数。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了
malloc
或free
,因为malloc
也是用全局链表来管理堆的。- 调用了标志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信号
为了避免出现僵尸进程,父进程需要使用wait
或waitpid
函数等待子进程结束。
父进程可以阻塞等待子进程结束,也可以非阻塞地查询的是否有子进程结束等待清理,即轮询的方式。采用第一种方式,父进程阻塞就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
其实,子进程在终止时会给父进程发生SIGCHLD
信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD
信号的处理动作,这样父进程就只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait
或waitpid
函数清理子进程即可。
单进程下:
因此可以把wait/waitpid写在信号捕捉函数内部。
多进程下:
多个子进程同时退出,当正在处理一个时,会屏蔽SIGCLD信号,就会有一些信号没有被捕捉,进而导致内存泄漏。