信号是一种用户、OS、其他进程,向目标进程发送异步事件的一种方式。
在系统中信号是OS出场时程序员就内置好了的,因此任何进程都认识所有信号,信号产生之前,信号的处理方案就已经设定好了,一般有三种 1. 默认行为 2. 忽略信号 3. 自定义动作。处理信号不一定时立即处理,如果目前手上的事情优先级很高,就会先记录下来信号然后在合适的时候再去处理信号。
1. 信号产生
我们先补充一点,可执行程序 ./ + & 是以后台进程方式将其启动。前后台进程的区别就是 ctrl+c 终止的是前台进程,同时前台进程跑的时候命令行解释器就不能用了。后台进程 ctrl+c 杀不掉,同时命令行解释器可以正常使用。
关闭后台进程,查到进程pid直接 kill -9 杀进程。或者 fg 1 将后台进程挂到前台再ctrl+c杀进程
fg:front ground前台,把 1 号任务重新移到前台
后台进程最好不要向显示器中打印东西,可以使用 nohup ./可执行 & 的方式将后台进程的输出存入nohup.out文件中
事实上ctrl + c键盘组合键就是在向前台进程发信号,终止了前台进程。
man signal 查看
signal()系统调用,可以对指定的信号设定自定义捕捉方法
第一个参signum是信号的编号
第二个参数handler是一个函数指针
kill -l可以查看Linux支持的所有常见信号
1 到 31是普通信号,34 到 64 是实时信号,不过用不到实时信号。
其中 ctrl+c 就会被OS接收并解释成为2号信号,事实上所有的信号都是宏,因此signum可以用宏值或宏名称都可以。
这里我们明显看到了接收信号之后进程不再退出了,而是执行起了我们自定义的打印动作。
signal()函数不用放在循环中,只需要调用一次进程就会记住它
ctrl + \ 也可以退出
因为2号信号的默认动作是退出进程,因此在我们没有自定义行为之前一发2号信号就能杀进程了,那其他信号的默认动作可以 man 7 signal 查看更详细的信号手册
SIGINT就是2号信号,Term终止的意思,从键盘发过来的终止信号
刚才的 ctrl + \ 发送的是3号SIGQUIT信号,Core退出动作,也是从键盘发来的信号。
信号当中 9 号信号是无法被捕捉或修改的,永远都是杀进程。
前面提到过,信号不一定是及时处理的,因此OS一定有必要将发来的信号记录下来,那记录的载体就是一个位图,每一个比特位的位置表示几号信号,比特位的0、1表示有无信号,因为普通信号只有31个因此我们有1位不用就好了。
发送信号的本质:OS向目标进程的PCB的信号位图中写入0、1
在进程PCB中还维护一个 sighanlder_t arr[32] 的函数指针数组,起指向的方法就是信号的默认操作方案,我们修改操作方案也是在修改这张表。
OS如何知道外设中有信号要发过来的呢?OS执行的必要任务已经很多了,因此没精力在轮询每个硬件的状态,而得知外设中是否有信号就回到冯诺依曼体系了。在冯诺依曼体系中,硬件外设和CPU是连接的,在外设准备好数据或信号之后,会首先给CPU(的某个针脚)发送中断信号,当CPU收到中断信号之后就会通知OS某个外设准备好了,接下来OS在自己定夺要不要现在把数据拿过来。至此有了中断我们发现硬件和OS可以并行执行(同一时刻真正在同时运行)了。
信号和中断很像,但是信号是纯软件的在模拟中断的行为,硬件中断是纯硬件电路之间的行为。
前面的信号是由我们按键盘产生的,第二种信号产生的方法是指令产生,kill -1 pid 这个我们用过很多次了,kill 命令就是在给某个进程发送信号。
第三种信号产生的方法是通过系统调用产生,man 2 kill 查看
参数简单易懂,第一个进程pid,第二个要发送几号信号
man raise 查看
参数是信号,给自己所在进程发送指定信号
man abort 查看
谁调用这个函数就杀哪个进程,和exit()很像,不过它是给自己发6号信号SIGABRT
第四种产生信号的方法,软件条件
管道那里我们了解过,当管道的读端关闭时,写端也就是不让写入了,管道文件写入条件不具备,此时操作系统会向目标进程发送13号 SIGPIPE信号,如果软件没准备好,或条件不具备,就可以向目标进程中发送SIGPIPE信号。
下面我们可以看一下这个状态,man alarm 查看
这个函数可以给程序设定一个几秒钟之后的闹钟,到点之后OS就会向目标进程发送对应的闹钟信号,操作就是终止这个进程
返回值,该进程上一个闹钟的剩余时间,一个进程中只能重复使用一个闹钟,如果上一个闹钟没跑完就想跑下一个,只能alarm(0),关闭上一个闹钟,再开启下一个闹钟。如果闹钟自己响了,它返回值也是0
在执行1秒之后,也就是75000次之后程序收到了闹钟信号,由此终止进程
借助闹钟函数我们可以写一个模拟操作系统行为的程序
man pause查看
这个系统调用的行为就是等待信号
这里我们用lambda表达式重写了收到闹铃信号之后的行为,下面时lambda表达式相关
C++·C++11_c++11 初始化-CSDN博客文章浏览阅读1k次,点赞20次,收藏26次。本节讲解了C++11新增的一些特性,其中右值引用和移动用语,可变模板参数,lambda表达式和两种包装器是本节的重点。_c++11 初始化https://blog.csdn.net/atlanteep/article/details/140823594#t17 每次执行完hanlder函数之后重新设置闹铃,保证一直有信号传给进程
最后起到一秒钟执行一次信号行为的效果,如果我们将信号换成硬件中断,这就是操作系统的运行原理。
第五种信号产生方式,异常
这里我们出现野指针问题,程序出来给了段错误报错,这种错误给出11号信号SIGSEGV。
当出现除0问题之后报浮点错误,给出8号信号SIGFPE。
C/C++中,常见的异常,进程崩溃了,OS给目标进程发送对应错误的信号,进而导致该进程退出.
第一种错误是因为在计算的时候CPU内部的Eflags寄存器记录会记录除0或整形溢出这种溢出错误,第二种错误是因为野指针寻址的时候MMU硬件电路报错给到OS。最终为什么程序运行时的错误能表现出来,就是因为程序内部的所有错误最后都会表现在硬件错误上。
这两种信号的默认退出动作与之前的不同,它们使用的是Core,而之前的信号都是Term。我们直接说结论Term终止就是正常的终止进程不需要进行debug,但是Core会多做一些事情。
Core行为会在当前目录下生成core文件,记录运行的错误信息,但是云服务器上是默认关闭的,需要我们手动打开。
ulimit -a 命令
这里可以看到core文件的大小为0
指令 ulimit -c 大小 将core文件放开
之后可以通过gdb查看core得到程序运行时出现错误退出的原因。
前面我们学到信号捕捉系统调用signal,
当我们键盘ctrl+c后OS收到退出信号,可以看到收到2号信号的效果,但是这些都是自定义捕捉方案。
信号的捕捉方案有3中:默认、忽略、自定义捕捉。
自定义我们都知道是怎么回事了,忽略方案是系统给我们提供好的宏SIG_IGN,(signal ignore)
可以看到忽略2号信号之后ctrl+c确实用不了了,不过我们还可以选择别的信号退出。
同理默认捕捉方案也是系统提供宏SIG_DFL,(signal default),对于默认捕捉方案,设不设置都会按默认执行。
2. 信号保存
实际执行信号的处理动作称为信号递达(Delivery)
信号从产生到递达之间的状态叫信号未决(Pending)
进程可以选择阻塞(Block)某个信号
被阻塞的信号产生时将保持在未决状态,知道进程解除对该信号的阻塞,才能执行递达的动作,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是信号以经递达之后的忽略处理方案。
在进程PCB中维护了3张表pending、handler、block用来控制信号
pending就是我们前面提到过的记录信号是否存在的位图,当前进程收到的信号列表,[1,31] 的位图,每个比特位的位置都用1、0记录是否收到对应的信号。
handler表记录信号的动作,是一个函数指针数组,信号编号 - 1 得到信号对应的捕捉方案也就是函数指针的下标,SIG_DFL默认方案,SIG_ING忽略方案,或者自定义捕捉方案。
block表也是一个[1,31] 的位图,不过是用来表示某信号是否要阻塞,每个比特位的位置表示信号编号,1、0表示启用、阻塞特定信号,比如2号信号现在是阻塞(屏蔽状态),即使pending中接收到了该信号,也不会去调用handler中的对应方案。
2.1 sigset_t
sigset_t是一个结构体,其中主体结构就是32位比特位的位图,block表和pending表都是用它作为数据结构载体存储的。
sigset_t又称为信号集,之后我们也是用sigset_t来操作表的。
阻塞信号集,也就是block表也叫做当前进程的信号屏蔽字(Signal Mask),这里的屏蔽是阻塞的意思。
虽然内核源代码中将sigset_t结构暴露出来了,但是我们还是不要直接用它进行表的操作,对应的操作都有特定函数供我们使用
sigemptyset() 将指定 sigset_t 结构清空
sigfillset() 将指定 sigset_t 结构全部置1
sigaddset() 向指定信号集中添加信号 (signum置1)
sigdelset() 向指定信号集中删除某个信号 (signum置0)
sigismember() 判断一个信号是否在信号集中 (signum是否为1)
2.2 sigprocmask
该函数可以读取或更改进程的信号屏蔽字,也就是用这个函数设置block表
第一个参数 how 有3个选项对应的修改方案
第二个参数 set,输入型参数用来告诉操作系统要修改哪个bolck表
第三个参数oldset,输出型参数一份老的位图拷出来,方便上层随时恢复上次的信号屏蔽状态
2.3 sigpending
与sigprocmask相对应,sigpending函数用来操作pending表的
但是sigpending()函数只有一个作用,就是获取当前pending表的信号集
参数set是一个输出型参数
注意系统并没有提供修改pending表的函数,这sigpending()函数只是单纯的把当前pending表中的内容取出来。而之前信号产生的5种方案:键盘、指令、系统调用、软件条件、异常,就是就是对pending表的修改。
而对于handler表则是由signal()函数来修改,这点前面我们也演示过了,但是后面还有新的handler表的修改方法。
下面我们尝试使用一下这些函数
首先我们创建位来的屏蔽字,生成的两个sigset_t变量可能是乱码的状态,因此我们要先将它们制空,然后sigaddset向屏蔽字中添加要屏蔽的信号2,然后sigprocmask将屏蔽字加载进内核的block表中,此时完成信号屏蔽设置
然后进入循环,sigpending取出当前内核中的信号集,并用我们自己定义的函数进行打印。
最后运行起来的效果就是我们发送2号信号之后,虽然pending表中接收到了该信号,可以看到pending表的2号位变1了,但是因为2号信号在block表中被屏蔽的原因,无法拿到对应handler表中的方案,因此不会杀进程,程序照常运行。
但是这个2号信号就一直阻塞在pending表中了,那我们如何控制它让2号信号递达,从pending表中消失。
绿框中设设置一个计数器,当打印进行10次之后解除对2号信号的屏蔽,解除的方案就是将老的屏蔽字设置进内核中。同时,为了防止系统接收到2号信号之后立刻杀进程,因此我们在一开使将2号信号的行为设置成忽略。
可以看到在10次打印之内2号信号是被屏蔽的,pending表接收到了但是无法执行,解除之后2号信号递达,pending表中结束对其的保存。
3. 捕捉信号
我们先前说过,信号的处理并不一定是立即处理的,而是在合适的时候。这个所谓合适的时候,就是进程在内核态切换回用户态的时候,检测当前进程的pending&&block,决定是否用handler表处理信号。
内核态就是OS内核内置的代码,比如各种系统调用
用户态就是我们自己写的代码,自己写的循环、函数、等等
我们的代码中一定会同时涉及到内核态与用户态的代码,最基本的IO底层中就一定包含了系统调用。
捕捉信号的流程可以简化成下面这样
从int main开始,遇到问题进入内核,内核处理完之后准备切换回用户态时,就要处理信号了。如果是默认方案或忽略方案就直接回到main主控流,如果是自定义捕捉方案就要先回到用户态执行自定义方案,然后一定返回内核态,之后再回到main主控流。
也就是说在捕捉流程中一共有4次内核态用户态之间的切换,只有在红圈的地方是从内核态返回用户态,同时也是这里会有信号的处理检查。pending表中有没有信号,如果有对应block表中有没有屏蔽,如果都没有就再看handler表中有没有用自定义方案。
要完全理解用户态和内核态我们就不得不重谈进程地址空间了。
3.1 操作系统运行原理
3.1.1 硬件中断
外设与CPU之间通过中断控制器(GIC)连接,当外设中有数据输入或准备好了,则会触发中断,每个设备都有自己的中断号,中断控制器会将中断号通知给CPU的针脚,告诉CPU这个中断号的硬件发生中断了。
操作系统为了能处理每一个外设,于是在编码的时候添加了一张中断向量表(IDT),其是一个保存若干函数指针的数组,如果我们想执行中断向量表中的任意一个方法就要通过下标来访问,而这个下标就是外设的中断号。
此时CPU收到中断控制器发来的中断消息,于是就会先将正在计算的所有寄存器内容都拷贝出去保存在中断的上下文里,形成保护现场,然后将寄存器准备出来用于处理中断信息,硬件中拿到的中断号就可以作为下标去软件中对应中断向量表中对应的中断方法,而各种中断方法又是硬件驱动程序或OS编码中以经先写好了的。
当中断处理完了CPU会恢复现场,将寄存器中的值复原,继续运行之前的进程。
3.1.2 时钟中断
我们知道进程可以在操作系统的指挥下被调度运行,那么操作系统自己是被谁指挥,怎么推动执行的呢?
我们前面说的这些外设键盘、显示器都是需要用户输入或设置才能触发中断,那有没有一种可以自己定期触发硬件中断的外设?触发一次外设就让操作系统跑一下,如此让该硬件推着操作系统跑起来。
这个硬件就叫做时钟源,它固定每隔很短时间,比如一纳秒就给CPU发一次中断消息,如此周期性的发送中断。
而我们给这个时钟源固定一个中断号n,在中断向量表中的n号下标处对应一个进程调度方案,由此每触发一次时钟中断就执行一次进程调度方案,这个时钟源倒逼CPU不断进行任务调度,由此一直调度操作系统,因此操作系统就是基于中断向量表进行工作的。
当代的CPU因为觉得将时钟源放在外设调度太慢了,而且还占用中断控制器,阻碍其他硬件的中断信号,因此就把时钟源集成到了CPU内部。CPU的主频就是每一秒钟时钟源向CPU中发送多少次中断,比如我的CPU主频2.3GHz那每秒大约能触发23亿次时钟中断信号。
CPU主频越高,调度的就越频繁,相应的处理其他中断的响应就越快,因此CPU效率就越高。
在这一体系下操作系统就可以"躺平"了,他要调度就由时钟查IDT中断向量表,要IO就由键盘发送中断信号然后查对应IDT。操作系统自己不需要做任何事情,需要什么功能就向中断向量表中添加方法即可。OS在准备好IDT之后就要自己进入死循环就好了,静等别人给它发中断,收到中断信号之后再做对应处理。
OS的运行逻辑就像我们前面这个信号的代码,先把操作方案在注册好,并写入handler表中,这个表就相当于IDT,然后设置1秒中的闹钟信号,这就相当于时钟源,信号编号就相当于中断号。然后主程序陷入沉睡死循环,这就相当于OS陷入死循环,然后每当1秒中后闹钟发送一次信号,主程序就去执行对应操作,这就相当于时钟源发送一次中断,OS就跑一次,如果此时别的硬件也发来中断,那OS就去查IDT执行对应方案。
现在我们应该很清楚了,OS的执行完全是靠着外部中断推动的,尤其是时钟源的中断在不停的从头执行OS编码。
3.1.3 时间片
现在我们知道时钟中断是有固定触发周期的中断,在进程PCB中会存在一个计数器count,每触发一次时钟中断,进程调度一次,count--,当count==0的时候该进程的时间片就跑完了。
比如如果时钟源每1纳秒触发一次中断,如果我们想让某进程时间片跑1微秒,那就把count设置为1000,每触发一次时钟中断count就自减1,直到减到0,就该切换进程了。
3.1.4 软中断
前面产生中断的原因都在于硬件,但是软件也有触发中断的方案,比如 除0、访问野指针、系统调用等,操作系统为了支持系统调用,CPU中也设计了对应的汇编指令(x86下叫int80、64位下叫syscall),可以让CPU内部触发中断逻辑。
既然CPU中有可以触发中断的汇编指令集,那就可以把这种中断方案写到软件中去,这种用软件推动CPU发送中断的方案叫软中断。
也就是说CPU也有固定的中断号(int的中断号位0x80),CPU自己内部由于触发汇编指令的中断逻辑,而产生中断,并自主到IDT中断向量表中查询中断方案。整个过程中没有外设硬件的参与。
中断向量表中有一个系统调用的入口函数,操作系统在源代码设计上有一个sys_call_table系统调用表用来维护所有的系统调用方法,当我们要使用哪个系统调用的时候,就使用该系统调用的数组下标,这个数组下标叫系统调用号。
我们说中断向量表中的那个系统调用入口,通过拿到系统调用号就可以帮我们从系统调用表中调用对应系统调用。
系统调用的过程,首先把我们要调用的系统调用号写入到寄存器EAX中,然后出发int0x80、syscall中断陷入内核,触发软中断,CPU就会自动执行系统调用的处理方法,而这个方法会根据系统调用号,自动查系统调用表,找到对应系统调用方法。没错,系统调用也是通过中断完成的。
但是,在内核中的系统调用都是用汇编写的,这些函数接口根本就不是C函数,而是系统调用号+约定的传递参数、返回值的寄存器、int0x80(syscall)
而我们能使用的系统调用(fork、signal)都是C语言封装过后的产物,便于用户来使用系统调用接口,而不是用汇编语言使用系统调用。所有Linux系统都要提供 glibc C标准库,这个标准库中就给我们把系统调用都封装好了,因此我们在使用系统调用的时候并没有用汇编的风格,而是C语言的风格。
操作系统就是躺在中断处理历程上的代码块。
CPU内部的不因为出错而触发的软中断,比如int 0x80我们称为陷阱。
CPU内部因为出错触发的软中断,比如除0、野指针,我们称为异常。
3.1.5 页表
我们知道每个进程都有自己的虚拟地址空间,事实上,在虚拟地址空间中还分有两个区域,用户区(占前0,3GB),内核区(占后3,4GB)。
用户区通过用户页表映射物理内存,内核区通过内核页表映射物理内存,而用户页表每个进程都有一张,内核页表所有进程共用一张。
用户区中存着的就是代码区、全局数据区、堆区、栈区、共享区、命令行参数和环境变量等我们之前所熟知的东西,这部分内容用户可以通过自己设定的函数方法随意访问和使用,而不用使用系统调用。
内核区中一般存放内核代码、内核数据结构、系统调用接口、中断处理程序等。这其中用户最关心的就是系统调用,而我们想使用系统调用只需要知道其调用号,OS会自己触发软中断执行系统调用的。
操作系统无论怎么切换进程,都能找到同一个操作系统,只要访问虚拟地址空间中的内核区就可以了,所有系统调用也就可以直接在内核区中找到。
不管是通过哪一个进程的地址空间进入内核的,都是因为触发软中断进入操作系统的。
内核区和用户区在CPU内有对应CS段寄存器,其中记录了CPL(当前权限级别)状态,CS寄存器中有两个比特位,0表示内核态,3表示用户态,在3期间CPU只允许进程访问0到3GB的内存空间的权限,访问内核态就要来到0状态只能访问内核对应空间。
用户进入内核态的方法有时钟、外设中断,CPU内部出异常(除0,野指针)触发软中断、系统调用,最终都会汇聚到int80上。
3.1.6 总结
操作系统是怎么运行的?
操作系统在启动的一瞬间,没有开启任何进程之前,操作系统就卡在pause()的while死循环中,整个系统就暂停了,CPU也是闲置状态。
但是一旦启动一个进程,这个pause死循环就永远不会被执行了,因为CPU一直在调度这个进程,如果有系统调用,就会通过中断的方式去到内核区中执行,调用完再返回调度进程,包括时钟中断也会一直检查时间片。
我们在使用操作系统的时候可以发现它有些自己的行为,比如定期把文件缓冲区从内存刷新到磁盘上去。这是因为操作系统自己在创建的时候就会启动这样一批进程,这些进程的任务就是定期把各种任务做刷新,这种进程叫内核固定历程。
3.2 信号捕捉
现在我们知道一个进程执行的时候一定会因为各种原因,比如时钟中断,进入内核,然后检查pending表,如果block表中没有阻塞该信号,就会转而回到用户态去做用户自定义的信号行为。
为什么信号自定义处理完之后要切换回内核态呢?这是因为函数之间跳转需要两个函数之前有调用关系,在调用形成函数栈帧时会把当前函数返回地址入栈,将来调用完弹栈就能回来,main函数和内核之间有调用关系,但是和用户自定义的信号行为函数无关,因此自定义信号处理完之后只能从内核返回main函数。
操作系统通过pc指针得知自定义信号处理完之后要返回到main函数的哪行。
3.3 信号捕捉的操作
下面我们认识一个新的信号捕捉方法 man sigaction
检查并改变一个信号的处理动作
第一个参数 signum ,更改哪个信号
第二个参数 act ,一个结构体 struct sigaction
第二个成员,第五个成员不管,设为nullptr,第四个成员设为0
第一个成员 sa_handler ,跟signal函数的handler参数是一样的,都是要更改为的信号操作方案的函数指针。
为了防止信号处理的时候嵌套逻辑,比如现在正在处理2号信号,有一个很长的循环,在处理2号信号的时候如果又发来一个2号信号,那就会陷入嵌套 递归逻辑。为了避免这种情况发生,操作系统会在处理信号的时候将该信号在block表中置1屏蔽。
第三个成员 sa_mask ,就是允许用户设定处理该信号的时候屏蔽哪些信号,比如处理2号信号的时候屏蔽3,4,5号信号
当然即使我们不设置2号信号还是被屏蔽的状态。
在block表中当信号处理完之后OS就会把刚刚屏蔽的信号都自动恢复回来。但是在处理该信号期间如果发来了该信号还是会记录在pending表中,只不过会在掉handler表的时候被block表屏蔽住。
而在pending表中,信号一旦递达完毕就会将pending表的位图位置清0。
4. 可重入函数
一个函数如果被两个执行流同时访问,如果会出问题就是不可重入函数,不会出问题就是可重入函数。
比如链表的插入函数insert,在进程中正在执行插入,刚插入一半,还没和前面的节点链接,此时进来一个信号,信号的行为是在同一个位置插入一个节点,这种情况下就会导致信号插入的节点丢失
此时产生内存泄漏问题。
只要函数使用了全局的资源,这个函数就是不可重入函数。
如果一个函数使用的资源都是自己内部临时创建的,就是可重入函数。
一般的函数都是不可重入的,包括标准库中的各种函数基本上都是不可重入的,一般只有带_r的函数才是可重入的,r表示repeat重复。