自定义捕捉与处理信号的底层逻辑
目录
信号的处理
信号的捕捉
信号其他相关常见概念
信号的处理过程
信号捕捉的底层逻辑
用户与内核,重谈地址空间
硬件中断原理---谈操作系统是这么运行的
软件中断原理---C语言的完美嵌入操作系统内核
信号的处理
我们查询man 7 signal时看信号的具体退出行为时,一般是Core/Term,Term是信号正常退出不需要debug(调试),就是不给调试的机会,而以Core方式退出的信号会生成核心转储(core dumped),为什么我们系统报语法错误我们可以直接看到,这个错误信息就是OS从核心转储里面拿的,在进程崩溃的时候将进程在内存中的部分信息保存在当前目录下形成的pid.core文件,方便后续调试。我们的云服务器一般是关闭了core功能的,就是表面信号执行了core,但是目录下是生不成pid.core文件的这是因为我们的云服务器性能没有那么高,如果每个程序都debug了就会生成多个core文件,到时候core不断的向磁盘写入,磁盘很容易被占满的。
我们写了一个a /= 0,所以信号成功core了,但是接着我们ll当前目录果然没有core文件。
我们可以使用ulimit -a
命令显示当前 shell 进程及其子进程的资源限制,从中可以找到core的资源信息就是第一个,原来没有出现的原因是系统不主动给他开空间,所以我们就需要主动设置core文件的大小。
使用可以使用 ulimit -<选项> <值>
来修改限制,例如:ulimit -c 空间大小(数字)修改core文件的大小就可以了,选项都有提示的。
可以看到已经设置成功了设置了10240个空间,接着我们重新运行./sig按理来说是肯定可以看到当前目录有一个core.数字文件,但是如果还是看不到那也正常。大概率核心转储内的信息是被apport提前拦截了,所以看不到。我们cat /proc/sys/kernel/core_pattern如果运行结果如下:|/usr/share/apport/apport -p%p -s%s -c%c -d%d -P%P -u%u -g%g -- %E,那就是被apport捕获了。
华为云服务器默认可能做了额外的安全限制,导致 core dump
无法直接生成,这个不重要听听就好。
假设我们成功看到了core文件,core文件的用处在于帮助我们调试,这时打开gdb/cgdb,在命令行输入core-file core可以快速的定位错误来源,core-file core可以帮助我们事后调试。
core dump标志最找出现在子进程退出的信号提示图里面:
可以看到当子进程被异常终止时,终止专题的低7位(0-6位)存储导致进程终止的信号,第7位(最高位往下)是一个标志位标志有没有生成核心转储,1就是生成了,0就是没有。
所以·要生成核心转储(core dump)取决于退出信号是否终止动作是Core,服务器是否开启core功能以及有没有外部干扰(比如:apport)。
信号的捕捉
信号的捕捉方式有默认,忽略和自定义捕捉,我们之前看了自定义捕捉的情况,我们看一看忽略和默认怎么使用signal实现。
SIG_DFL
是 POSIX 规定的一个信号处理宏,表示让进程对某个信号采取默认行为(Default)。
SIG_IGN
是 POSIX 规定的一个信号处理宏,表示忽略(Ignore)某个信号。
可以看到这两个宏的本质其实是函数,属于信号处理函数的范畴,所以可以作为signal的第二个参数传递。
pause();
是一个 POSIX 标准的系统调用,它让进程进入休眠状态,直到接收到信号,进行长时间sleep。
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
int main()
{
::signal(2, SIG_DFL); //执行默认行为
//::signal(2, SIG_IGN); //忽略
while (1)
{
cout << "让我长久的睡去吧!!!" << endl;
pause();
}
return 0;
}
SIG_DFL确实执行了2的默认方法接着我们换另一个试试:
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
int main()
{
//::signal(2, SIG_DFL); //执行默认行为
::signal(2, SIG_IGN); //忽略
while (1)
{
cout << "让我长久的睡去吧!!!" << endl;
pause();
}
return 0;
}
SIG_IGN确实忽略了ctrl+c的终止,使进行死循环。
一个进程在没有收到信号的时候已经做好了对合法的信号做何种处理了,并不是信号告诉它怎么处理自己的。
信号其他相关常见概念
1。实际执行信号的处理动作称为信号抵达(Delivery)
2。信号从产生到递达之间的状态,称为信号未决(Pending)。
3。进程可以自主选择阻塞(Block)某个信号
4。被阻塞的信号产生时将保持在未决状态,直到进程接触对此信号的阻塞,才执行递达的动作
5。注意阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
所以信号一旦产生了,进行一定会进行pending,进程认识信号是内置的特性。
信号的处理过程
结合上面的知识我们发现进程的内部存在三张表block表,pending表,handler表,这三张表都存储在进程的task_struct里面,方便进行看见,pending表和block都是位图,handler表是函数指针数组,下标就是信号编号,内容就是处理信号的函数,pending位图和block位图的比特位位置下标是信号编号,当进程接收都信号时会第一时间将pending表对应下标的信号由0置1表示信号产生此时进程收到了信号,所以pending表的比特位内容(1/0)就是表示是否受到信号,block位图的比特位下标的位置同样是信号编号,此表表示受到信号后是否处于未递达给进程处理的阻塞状态,所以比特位的内容:1/0,是/否处于阻塞/屏蔽特定信号!
所以一个进程要处理信号就需要同时访问这三个表,今天进程要处理2号信号,进程就得先访问pending表看这个信号有没有被我捕捉/产生,如果这个比特位是0那就不需要后面的查表了,如果是1说明2信号产生了,接着横向查询block看是否处于阻塞状态,如果block表为0,那信号产生了被我捕捉而且不阻塞,这时信号完全可以递达,所以最好横向的查找handler表调用处理方法。这三个表是横着看的。
很多人会问这个SIG_DFL作为处理方法是个函数,但是后面还有数字是0是什么意思,这个最后的数字表示这些处理函数在handler表里面的下标。
信号捕捉的底层逻辑
操作系统的运行状态分为用户态和内核态,用户态就是执行自己写的代码的时候,内核态就是执行操作系统自己内置的代码的时候,我们之前说信号的处理是在合适的时候,这个合适的时候就是进行从内核态切换回用户态的时候,检测当前进程的pending&&block,决定是否处理handler表处理信号。
信号自定义捕捉的流程,底层逻辑如下图:
横线上面是用户态,下面是内核态,在内核态黑线的交叉点进行信号检测。
信号的自定义捕捉流程分为 用户态(进程注册信号处理函数) 和 内核态(信号递送与处理) 两部分。在用户态,进程调用 signal()
或 sigaction()
注册信号处理函数,这会修改 task_struct
结构体中的 handler
表,将对应信号的默认处理方式改为用户自定义的函数。信号可以由用户输入(如 Ctrl+C
触发 SIGINT
)、进程间通信(kill(pid, SIGUSR1)
)、硬件异常(除零触发 SIGFPE
)、内核定时(alarm()
触发 SIGALRM
)等方式产生,信号产生后,内核会在 pending
表中置位,如 pending[SIGINT] = 1
。当进程从内核态返回用户态时(如 pause()
被唤醒或时间片到期),内核检查 pending
和 blocked
表,若 pending[SIGINT] == 1
且 block[SIGINT] == 0
,则调用 do_signal()
进行信号处理,查找 handler
表确定信号处理函数,并切换用户态执行 handler()
,执行完成后恢复进程上下文继续执行原代码。在 task_struct
结构体中,pending
存储待处理信号的位图,blocked
存储被阻塞信号的位图,action
记录每个信号的处理函数。综上,信号的自定义捕捉流程包括 注册处理函数 -> 信号产生 -> 信号递送 -> 执行处理函数 -> 恢复上下文,确保信号能正确传递并执行相应操作。
讲人话就是,用户态发出信号一定由于某种中断,中断处理在内核态所以下来,结果发现是自定义的处理方式,所以又上去执行用户态的自己写的代码(这个过程需要信号检测),然后处理完了处理函数返回值返回内核态接着再返回之前中断处继续响应可能的新的中断。所以信号检测的点一定会在内核态。
用户与内核,重谈地址空间
硬件中断原理---谈操作系统是这么运行的
外设就绪时会发起硬件中断给CPU表面我外设已经准备好了,往往面对CPU发送中断的硬件有很多,所以外设准备就绪之后,会先向中断控制器发送中断,然后获得一个中断号n,然后中段控制器让CPU特定的针脚知道哪个外设中断了,相当于通知CPU,不同的针脚可以识别出不同的设备,然后CPU得知中断,获取中断号n,由于不同外设的中断号是不同的,唯一的,接着CPU就需要拿着唯一的中断号n进入中断向量表根据唯一的中断号查找特定的中断服务方法并执行,中断向量表就是一个以下标为中断号,对应内容为执行中断对应方法的表,处理方法可能根据下标的不同有处理显示,网卡,处理时钟等等,然而有时候当一个中断过来时CPU未必在合适的时间处理,CPU可能还在处理别的信息,所以CPU为了下去查表(IDT),得先将寄存器中之前处理的保持的数据保存在中断上下文,保存好了的整个过程叫:保护现场,所以往往都是外设准备好了,CPU才自己读取外设的中断,换句话来说就是如果一个外设都没有准备好,硬件中断就不会触发,CPU不会管外设在干什么,你好了叫我来就行。最后别忘了恢复现场循环之前的工作。
中断向量表(IDT)就是操作系统的一部分,启动就加载到内存中了,通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询
外部设备触发硬件中断需要设备或者用户自己发送,那有没有可以不断定期向CPU发送硬件中断的设备呢,有的,外设里面有一个叫做时钟源的外设会定时的发起时钟中断,定期触发中断,此时这个设备拿到固定的中断号就可以驱动CPU下去中断向量表中调用处理时钟的函数,这个设备已经被集成在了CPU里面了,时钟1s发送多少次硬件中断称为主频,主频越多的CPU处理事件就越多所以这个CPU就越好,所以直接接触的CPU,那处理的本质就是这个中断向量表里面有对应硬件中断的处理方法,我今天在这个表里面的y下标创建一个叫进程调度的方法,然后给CPU生成一个固定的中断号y,那调度进程的工作是OS在做的,所以进程可以在CPU的指挥下被调度被执行,原本只会进程的操作系统顷刻间变成了这张表,操作系统要调度进程就看y信号中断来了没有,来了就执行一下函数,可以看出CPU执行了进程调度函数的结果,到底哪个进程进入调度才是OS的工作,还记得时间片轮转吗,OS基于时间片的大O(1)算法进行调度,时间片到了OS就自动切换进程了,进程的调度不一定就是切换,操作系统这么知道时间片到了呢?时钟中断是一直固定发过来的,时间片相当于计数器,我今天给count=1000,时钟中断每次到来时都判断一下进程PCB中的count有没有--到0,如果没有就什么事都不做,如果到0就切换进程,所以判断不需要操作系统来做,操作系统无非在整个进程的调度的过程中就做了切换的动作,时钟中断一直在推进操作系统进行调度,操作系统就是基于中断向量表进行工作的。
上述由外部设备触发的,中断系统运行流程,叫做硬件中断。
上面这个就是中断向量表。
timer_interrupt就是时钟中断函数,里面(下一个图)就在设置时间中断,然后转到再下一个图这个是PCB结构的一部分看到当counter == 0时调度了schedule切换进程方法。
那这样操作系统不就可以躺平了吗,对的,操作系统自己不做任何事情,需要什么功能,就向中断向量表里面添加方法就可以了,反正中断会来调度,操作系统的本质就是一个死循环,不退出就可以了。
操作系统就可以在硬件时钟的推动下自动调度了。操作系统是躺在中断处理例程上的代码块!
操作系统躺平就行了,反正中断会累死累活的来提醒我,而且我只需要切换进程就行了,在进程的调度的整个流程来看操作系统就是躺赢狗呀,硬件中断得了mvp。这时操作系统就要说了:
你这么认这个评分系统干什么呀,啊这种评分评价会把我OS的付出给异化掉的懂吗,知不知到什么叫异化具体化,啊,在进程调度/切换这块你能这么讲吗,我跟你打个比方啊,比如说冯诺依曼有CPU,硬件中断,操作系统,进程要调度了,硬件中断(时钟中断)不停的给OS发信号,要切了没,要切了没,操作系统每次就在那等着,等中断提示进程时间片到了才起来切换一下进程,然后又继续等,完了进程调度完一结算,啊硬件中断得了MVP,一看操作系统天天就在那等着别人,就切换一个工作,还不用一种干,不是在那等着就是装死,躺赢狗,操作系统就是躺赢狗,操作系统在进程调度上的评分是3.0,硬件中断带动所有进程调度13.0carry全场,能这么算吗,啊,你告诉我操作系统是不是躺赢狗啊,那么在意这个评分干嘛呢?那不是看你具体做了什么吗啊,我操作系统有没有切换进程,硬件发送硬件中断,硬件的唯一管理者是不是我,我有没有管理好,还隔着评分评分,让硬件中断来管理硬件又没有那个能力,那不都是一个集体吗,都发光发热不就好了!!!
软件中断原理---C语言的完美嵌入操作系统内核
上述外部硬件中断需要硬件设备触发,因为软件原因也可以触发上面的逻辑,用软件推动CPU发送中断的就是软中断,为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(int,syscall),可以让CPU触发中断逻辑,这两个都是中断指令,我们可以将这两个汇编指令写入软件中,中断就由这个软件发出所以是软中断。
这两个指令发送中断同时将系统调用号n存在CPU的寄存器(如EAX)里面,同时让CPU陷入内核,然后这个系统调用号号会直接被复制拷贝到内核地址空间,不需要用户进一步的往下传,这个本质就是触发软中断,CPU根据得到的中断号进入中断向量表调用软中断服务的函数,进行系统调用,由于系统调用很多,所以调用系统调用函数的内部其实是系统调用函数指针表,作为跳转表,中断服务中的系统调用函数指针表根据你要调用的系统调用编号作为数组下标的引索自动查表完成系统调用函数的调用,所以系统调用也是通过中断完成的。
上面这个就是系统调用函数指针表。
所以其实Linux内核提供的系统调用接口根本不是C语言函数,而是系统调用号+约定的传递参数,系统调用号,返回值的寄存器! + int 0x80(中断号),syscall
那我们平时调用系统调用比如pwd都没有使用什么系统调用号,也没有系统调用函数指针表的调用呀,如果每次系统调用都使用系统调用号+系统调用函数指针表的形式,就太麻烦了不是,所以我们使用的直接用函数名访问的机制是GNU glibc(C语言)给我们把系统调用进行了封装!,是C语言封装了系统调用!!!
以sys开头的函数是系统调用。
所以对于软中断来说,CPU内部的软中断中仅仅帮助CPU陷入操作系统内核的指令或者软件,没有语法错误或者其他错误的中断我们叫做陷阱,反之比如除0/野指针等的存在语法错误而中断的,我们加做异常
所以缺页中断为什么加做缺页异常而不是缺页陷阱是因为缺页中断属于CPU 访问某个虚拟地址时,发现该地址对应的物理页不在内存中,触发的中断。就是页表无法将虚拟地址成功对应的映射到物理地址空间,这是一种异常/错误吧,CPU对此需要做出解决的。
上图为设置陷阱及其编号。