文章目录
- 1. 生活中的信号
- 2. 信号的概念
- 3. 信号的产生
- 3.1 系统调用
- 3.2 软件条件
- 3.2 异常
- 3.3 `Core`和`Term`的区别
- 4. 信号的保存
- 5. 信号的处理
- 5.1 地址空间的进一步理解
- 5.2 键盘输入数据的过程
- 5.3 理解OS如何正常运行
- 5.3.1 OS如何运行
- 5.3.2 如何理解系统调用
- 5.4 内核态和用户态
- 6. 可重入函数
- 7. volatile关键字
- 8. `SIGCHLD`信号
1. 生活中的信号
日常生活中,等红绿灯、上课铃响…其实都是在给我们发送信号,绿灯亮了给我们发送可以通行的信号,我们才能通行;红灯亮了,给我们发送禁止通行的信号,我们就要停下来。而有时会有特殊情况,临近吃中饭,你正在做作业,爸妈喊了你一声,你就知道要去吃饭了,但手里头就差一点作业就完成了,于是你暂时不管爸妈叫你吃中饭的信号,完成手里的作业再去吃中饭
我们对上述信号粗犷的理解:
- 信号可以随时产生
- 我们认识信号
- 我们知道信号的处理方式
- 我们可能正在处理要紧的事情,把到来的信号暂不处理
2. 信号的概念
信号是Linux系统提供的一种向进程发送特定事件的方式,需要进程识别和处理
Linux系统中,总共有64个信号,其中34~64称为实时信号,不作为本篇文章的重点
根据信号的定义,进程需要对信号进行处理,进程处理信号有三种方式:
- 默认动作
- 忽略动作
- 自定义捕捉
对于默认动作,让进程根据OS规定好的处理方式去处理,通常是终止进程,也有忽略信号的
其中终止进程有两种行为,Core
和Term
,现在我们就认为它们都是终止进程,后面会将它们区分
忽略动作就是不管信号,很简单
自定义捕捉:我们自己设计好对应信号的处理方式(参数为int
,返回值为void
的函数),使用signal
捕捉信号,当进程收到指定的信号时,会去调用我们自定义的方法
我们拿2号信号举例,它的默认动作是终止进程,相当于键盘上的ctrl+c
3. 信号的产生
信号的产生总共有5种方式
- 使用
kill
命令向指定进程发送信号 - 键盘输入,比如
ctrl+\
,相当于3号信号 - 系统调用
- 软件条件
- 异常
3.1 系统调用
这里介绍几个系统调用函数
kill
是命令,同时也是一个函数,可以向指定进程发送指定信号
raise
函数向自身进程发送指定信号
abort
函数向自身进程发送6号信号
可以看到,abort
函数向自身发送了6号信号的同时,还强制终止了当前进程
注:既然我们可以使用signal
函数捕捉信号,能不能把所有信号都捕捉了?
如果把所有信号都捕捉了,会导致什么结果?可能会发生进程永远无法退出的情况,OS考虑到了这点;经过测试,我们发现9号信号(SIGKILL
)和19号信号(SIGSTOP
)无法被捕捉
3.2 软件条件
在进程间通信篇中,我们谈管道的4种情况时说到,当管道的读端关闭了,写端还在继续写,OS就会认为写的数据没意义,直接终止进程;这其实是OS向进程发送了13号信号(SIGPIPE
)
针对软件条件,再介绍一个函数:alarm
,可以把它理解为一个闹钟,设定好时间,它就会在未来向进程发送14号信号(SIGALRM
)
借助alarm
函数,我们进一步理解I/O很慢这个的观点,之前我们总说输入输出是很费时的做法,那到底有多费时,通过比较下面的两份代码,就知道有输入输出和没输入输出,它们的效率相差之大了
对于alarm
函数,它是进程设置的闹钟,而进程在OS中是有多个的,也就意味着闹钟在OS中也有多个;哪个闹钟属于哪个进程,OS需要将它们管理好;因此,闹钟在OS中也有自己的结构体,并以小根堆的结构组织起来
一个进程只能设定一个闹钟;alarm
函数的返回值表示上一个闹钟还剩多少时间;alarm(0)
表示取消闹钟
3.2 异常
进程异常我们在进程等待时提到过,进程退出有三种情况,进程正常退出,结果正确/错误,进程异常退出
今天,我们从信号的角度,从三个问题,进一步理解进程的异常
- 程序为什么会崩溃?
- 崩溃了为什么会退出?
- 程序崩溃了能不能不退出?
以我们现在的知识,理解上面的问题十分容易,程序崩溃是因为我们进行了非法的访问或操作,OS向进程发送了信号,该信号的默认动作是终止进程,因此我们的程序直接退出了,最典型的有Floating-point exception(SIGFPE
)、Invalid memory reference(SIGSEGV
)
我们能使用signal
函数捕捉信号,因此程序崩溃了可以不退出,但肯定是不推荐的,具体是为什么,下面会讲
可以看到,不管是除0收到的SIGFPE
信号,还是野指针收到的SIGSEGV
信号,它们的默认动作是终止进程,但我们进行了捕捉,程序没有退出;但是,为什么运行结果是一直在执行handler
方法,不应该是执行一次handler
方法,然后死循环执行主函数后面的代码吗?这就关系到CPU了,两种情况,我们分别解释
我们知道,代码最总是交给CPU去执行的,CPU的运算工作主要分两种,算数运算和逻辑运算,而在CPU的内部,有着各种各样的寄存器,有保存数据的,指向一段代码的…其中有个名为eflag
的寄存器,它包含多个位,这些位的值为1/0,有个位叫做溢出标志位,用来表示当前的运算结果是否溢出
当我们的代码执行到10/0时,将10和0分别放到CPU的寄存器中进行计算,我们知道,10/0是不合法的,CPU也知道,因此将eflag
寄存器中的溢出标志位置为1,而OS作为CPU的管理者,CPU运算时出了问题,它当然得知道并处理这个问题,于是它读到eflag
寄存器的溢出标志位为1,就明白当前的运算结果不合法,给进程发送了信号;此时,该进程的时间片到了,要进行进程切换,我们知道进程切换最重要的就是寄存器需要保存和恢复进程的上下文数据,它也照常这样做,将10和0保存好,下次调度时再恢复,这时大家就应该明白了,下次调度时,运算的还是10/0,溢出标记位置1,OS向进程发送信号,如此往复,就出现了死循环打印handler
方法
对于野指针的问题,也类似,但也有不同;首先介绍三个寄存器,MMU、CR2、CR3
虚拟地址到物理地址的转换过程中需要MMU的参与,将空指针转换成物理内存时,CPU产生异常,同时将异常地址放在CR2中,OS检测到CPU的异常,给进程发信号,然后进行进程切换,又是对空指针的转换…
到这里我们也就知道,为什么程序崩溃时推荐直接终止,所谓终止,其实就是释放掉寄存器中的错误数据,终止CPU的异常
关于异常,就说到这
3.3 Core
和Term
的区别
进程异常,采用默认动作终止进程时,有两种类别,Term
和Core
,Term
就是终止进程,而Core
在终止进程的同时,还会在进程的当前工作路劲下生成一个以进程pid为后缀的core文件(Ubuntu的版本不同,生成的core文件格式可能不同,这里以Ubuntu22.04为例),记录了程序的崩溃信息
在云服务器下,默认是不允许生成core文件,使用ulimit -c size
命令允许生成core文件
此时运行程序,如果程序异常退出,则会生成core文件
注:如果显示的错误信息后有core dumped,表明core文件已经形成,但在当前目录下没有core文件,表明默认core文件的生成路径不在进程的工作目录下,使用下面的命令即可
sudo bash -c "echo core > /proc/sys/kernel/core-pattern"
上面的部分,我们还有些问题需要解决
- 为什么默认关闭生成core文件?
- core文件有什么用?
core文件记录下了程序的崩溃信息,在哪一行崩溃,崩溃的原因、上下文等等,导致文件大小比较大,如果一个程序崩溃了就重新运行,这样下去,磁盘空间很容易就被core文件占满,因此一般Linux都是默认关闭生成core文件
当程序崩溃时,使用gdb调试,都是一行一行进行,有了core文件,使用core-file core.pid
命令就能直接定位到崩溃的那一行了,方便我们调试了;这种调试方式我们称为事后调试
进程崩溃退出时生成debug文件,这种技术我们叫做核心转储,该debug文件是进程退出时的镜像文件
关于core,在进程等待中提到过,进程退出会留下退出码和退出信号,其中异常退出时有一个标记位用来表示是否生成了core文件
有时进程在做要紧的事,对到来的信号暂不处理,但会在合适的时候处理,这里合适的时候具体是什么时候,会在后面会详谈,现在我们要知道,要处理的前提是记得这个信号,也就是进程得保存信号
4. 信号的保存
初步理解信号的保存:
信号在进程中用位图的方式保存,比如给一个整型,最高位不用,第131位分别代表131号的信号;一个进程是否收到信号,由bit位的内容决定,1表示收到,0表示没有;因此,发送信号就是将信号位图由0变为1,而该位图在内核当中,只有OS能改变该位图的内容;发送信号的本质是OS发给进程信号,也可以叫做写信号
在正式说信号的保存之前,先给出几个概念:
- 执行信号的处理动作叫做信号递达
- 信号从产生到递达之前的状态叫做信号未决
- 进程可以选择阻塞(被阻塞的信号将永不递达,直到解除阻塞状态)某个信号
- 阻塞和忽略不同,忽略是处理信号的一种动作,而信号一旦被阻塞,永不被处理
进一步理解信号的保存:
在进程的pcb中,有一条属性指向了三张表,分别是block、pending、handler表,其中block和pending表都是位图的结构,bit位的位置代表几号信号,bit位的内容表示进程是否收到/阻塞该信号;而handler表是一张函数指针数组表,数组的下标代表几号信号,数组的元素指向handler方法
未来进程处理信号时,从pending表的1号信号开始,检查信号bit位是否为1,如果为1,横向向左检查该信号是否被阻塞,如果没有,就去调用handler方法;依次往下,直到处理完所有信号
讲完理论,下面用代码加深理解:
先介绍一种类型和几个函数
sigset_t
,它是Linux提供给用户的一种类型,实际是位图的封装
上述代码结果:最开始屏蔽2号信号,当给进程发送2号信号时,2号信号确实没有递达,一直处在未决的状态,过了10秒,我们解除对2号信号的屏蔽,进程立即处理了2号信号并将pending表的2号位置0
通过代码结果,得出结论:
- 当信号的屏蔽被解除,进程会立即处理解除屏蔽的信号
- 在执行handler方法(递达)之前,就会将pending位置0
5. 信号的处理
前面我们已经使用过signal
捕捉信号,处理动作有三种,SIG_DEL/SIG_IGN/handler
;同时,我们还提到过,进程可能正在忙,会保存到来的信号,在合适的时候进行处理,所谓合适的时候,就是进程从内核态切换为用户态的时候处理
其中,内核态和用户态是什么?首先给大家描述一个轮廓:进程在执行主函数的某条语句时,遇到了中断、异常或系统调用(后面就拿系统调用举例),而进入内核;执行完系统调用后,再返回到用户前,进行信号的检测,如果信号的默认处理动作是忽略或终止,那很简单,直接返回或终止进程,但如果是自定义捕捉,就要先切换为用户,执行完handler
方法后,再返回内核,最后返回到主函数,继续往下执行
-
为什么执行系统调用要进入内核?
系统调用的函数是OS提供的,都是OS的代码,必须由OS亲自执行
-
OS能不能直接去调用用户的
handler
方法?不能,OS不相信任何人,不清楚
handler
方法写的是什么内容,万一是访问用户没有权限访问的内容,不就通过OS访问到了;因此,用户的代码只能有用户自己执行 -
为什么要返回到内核再返回到主函数,能不能执行完
handler
后直接返回到主函数?不能,执行
handler
方法的是不同于主函数的流程,并不知道应该返回到主函数的哪个位置
简单描述一番,在详谈用户态和内核态前,先讲几个相关知识
5.1 地址空间的进一步理解
我们以32位机器为例,物理内存总共4G,OS分配给每个进程4G的地址空间,其中1G属于内核空间,3G属于用户空间;我们自己的代码通过页表映射到物理内存,这个我们能理解,但使用的各种系统调用都是系统的代码,进程是怎么找到系统的代码的?
当电脑开机时,OS是第一个启动的软件,加载到了物理内存;同动态库的加载类似,OS的代码会加载到每个进程地址空间中,只不过OS的代码是加载到进程的内核空间中,同时会有一张内核级页表,构建了内核空间与物理内存的映射关系;也就是说,系统的代码在进程的地址空间上;执行我们自己的代码,是在用户空间上来回跳转,通过用户级页表找到内存中的代码;执行系统的代码,是在内核空间上来回跳转,通过内核级页表找到内存中的内核代码
每个进程都是自己的用户级页表,但所有进程公用一张内核级页表
总结上述内容:
- OS本身就在进程的地址空间中
- 无论进程如何切换,我们总能找到OS
- 访问OS,本质是在进程的地址空间上进行的
- 访问内核空间,就能找到OS的代码和数据,但OS不相信任何人,访问会收到约束,这里的约束就是我们只能通过系统调用访问内核空间
5.2 键盘输入数据的过程
当进程在运行时,按下ctrl+c,OS会向进程发送信号,进程收到后直接终止,这里的问题是,OS怎么直到我们按下了ctrl+c,它是怎么收到我们按下的信息的?
OS在启动时,会优先向内存中加载一批操作硬件的方法,比如读磁盘、读键盘…存放在函数指针数组中,我们把这个函数指针数组叫做中断向量表;电脑中的每个硬件都有自身的中断号(同时对应着中断向量表的下标),当我们按下键盘时,会触发的硬件中断,向CPU发送中断信号,CPU接受该信号,并在内部存放该硬件的中断号,CPU根据中断号,去执行内存中对应的方法,OS就拿到了键盘的数据
看到这里,想想信号的发送、处理过程,当进程出现异常,OS给进程发送指定信号,进程根据handler表处理,是不是跟硬件中断有点类似,实际上,信号是模拟实现软件版的硬件中断
5.3 理解OS如何正常运行
5.3.1 OS如何运行
在上面硬件中断的基础上,理解OS如何运行就非常容易;在CPU外部,会有一个时钟,每隔几毫秒向CPU发送中断,CPU内部寄存器就会存放执行调度方法的中断号,进而去调用中断向量表中的调度方法,如此一直循环;OS的本质就是死循环时钟中断,不断调度系统任务
5.3.2 如何理解系统调用
在内核中,有一张函数指针数组表,存放着所有系统调用的内核函数,内核函数对应的下标我们叫做系统调用号,未来只要拿到系统调用号,就能进行系统调用了
上面时钟中断属于外部中断的方式,CPU内部寄存器也可以自己形成中断号,我们把CPU自身形成中断号的方式叫做内部中断,或者陷阱/缺陷,时钟中断的方式叫做外部中断;不论是内部中断还是外部中断,其目的都是让CPU内部的寄存器形成一个中断数字
我们用的系统调用其实是内核函数的封装,系统调用内部提供了该内核函数的系统调用号;当有系统调用时,触发CPU内部中断,CPU去执行内存中的系统调用方法,OS拿到系统调用号和系统调用函数指针数组,就能完成系统调用
5.4 内核态和用户态
在CPU内,有个名为code segment的寄存器,记录代码区的范围,其中有两个bit位,可以表示0或3;当值为0时,表示内核态,当值为3时,表示用户态
结论十分简单,但问题是,前面我们说用户是无法直接访问内核空间的,怎么做到不让用户访问内核空间?进程访问地址空间时,必然要通过CPU去执行,如果访问的是内核空间,CPU检查cs寄存器,看是否处于内核态,如果不是,CPU就直接拦截;因此,系统调用函数内部肯定会将用户态设置为内核态
6. 可重入函数
之前我们学过链表的增删查改,以下图的插入函数为例,在插入过程中,执行完第一条语句,此时该进程的时间片到了,进行进程的切换,从用户态变为内核态;在变回用户态前,进行信号的检测,检测到信号并执行handler方法,但如果handler方法也是链表的插入,处理完后,该链表结构为图2;再返回到语句2,执行完链表结构为图3,此时p2节点就找不到了,导致了内存泄漏的问题
在这种情况下,我们把insert函数称为不可重入函数;大部分情况下,涉及到全局变量/数据修改的函数都是不可重入函数;可重入函数的概念与之相反
7. volatile关键字
上述代码,编译器优化和不优化出现两种结果,这是为何?
CPU负责代码中的逻辑/算数运算,如果不进行优化,CPU将内存中的flag变量的值先存放到寄存器中,再进行判断;也就是说寄存器的值随着flag的变化而变化
如果进行优化,寄存器第一次读到flag的值后,不再读取内存中的flag值,CPU往后都是直接判断,所以即使我们修改了内存中的flag值,寄存器的值始终不变化,也就是说寄存器隐藏了内存中的真实值
要解决这种情况,就要使用volatile
关键字,对flag变量进行volatile
修饰,表示保持该变量在物理内存可见性;这样,不管编译器如何优化,都是先读内存中的flag值到寄存器,再判断
8. SIGCHLD
信号
子进程在退出时,会留下自己的退出码和退出信息,同时,他还会给父进程发送SIGCHLD
信号;如果我们不管子进程的退出码和退出信息,同时不想自己处理子进程的僵尸问题,在捕捉信号时使用SIG_IGN
,这样进程退出不会有僵尸问题