一、对信号的宏观认识
从实际的场景来举例:红绿灯、上课铃声,电话铃声等都是一种信号的产生,而我们对这种信号处理的方式是后天训练而形成的,如:红灯停,绿灯行。
所以对于OS的信号而言,也就是工程师们内置提前写好的,对于对信号怎么处理有三种方式:怎么进⾏信号处理啊?a.默认(在OS默认约定的各个信号的产生做出什么行为)b.忽略(产生信号不做处理)c.⾃定义(用户自定义产生信号后的处理方式),后续都叫做信号捕捉。
二、信号的产生
我们产生信号的方式有这几种: 终端上命令产生、敲键盘按键产生、函数调用产生、软件条件产生、硬件异常产生
常用的信号:
kill -l 查询所有信号
信号也就是个整数,数字旁边的单词是这个信号的宏
终端命令产生: kill -数字 进程的pid
比如: kill -9 pid 就是终止这个进程
所以这里我们也可以发现,发送信号不是新建一个进程,而是把信号发送给指定的进程,这也说明信号是给当前进程产生的,那怎么给信号的呢? 那肯定是OS给task_struct结构体的呀!!那本质信号不就是个变量嘛,但实际是个位图储存信号,32个比特位刚好能对应1~31这些信号
敲键盘按键产生:ctrl +c :发送2号信号->终止前台进程(只有前台进程才能接收键盘的操作); ctrl + : 发送3号信号->也是终止进程(但它是异常终止,硬件异常那里讲区别);
ctrl + z : 发送20号信号->将前台进程挂起放到后台;
函数调用产生:
首先先来学一个函数: signal(sig_num,void (*)(int)->这是函数指针) 也就是这个函数能自定义信号处理,按你写的函数处理
⚠️⭐⭐⭐⭐⭐硬件异常产生:
这里的硬件异常对出对应的就是除0异常和野指针的访问,还有比如数组访问越界也是访问未知的地址也差不多是野指针的访问,为什么这些异常是硬件呢?因为你做运算和访问地址本质就是CPU或者MMU等硬件帮你做的运算最终通过一系列操作返回用户想要的结果嘛
比如我以演示除0异常:
这里显示翻译过来是浮点数错误也就是除0错误是对应发出8号信号,那我如果自定义信号·而且不主动退出进程看看会发生什么也就是在main函数里加上signal(8,test):
最后会一直死循环,那为什么会死循环呢?
这里面涉及很多硬件底层知识,我用最直白的话说:除零异常后,我们并没有清理内存,关闭进程打开的⽂件,切换进程等操作,所以CPU中还
保留上下⽂数据以及寄存器内容,除零异常会⼀直存在,就有了我们看到的⼀直发出异常信号的现象。访问⾮法内存其实也是如此
也就是当出现异常后就不会往后面执行代码如果你改了信号处理的默认处理方式且没有写exit()这样的函数使进程退出时,就会一直执行这个错误,然后就一直发送信号一直循环
软件条件产生:讲这个先讲一个个好玩的函数:alarm()函数(闹钟函数)是给进程计时的,它是发出14号信号,然后退出该进程
如上demo是进程运行两秒后退出,你也可以不退出自定义信号处理,这不就是可以变相使使用这个函数加signal()函数就能使进程过几秒后做什么任务么,⚠️但注意:这个函数是一次性的,也就是过两秒后不会执行第二次,故想要一直运行要在自定义函数信号处理面也加上alarm重新计时,这样就能实现进程过几秒后执行任务的类似功能
如何理解软件条件
在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产⽣机制。这些条
件包括但不限于定时器超时(如alarm函数设定的时间到达)、软件异常(如向已关闭的管道写数据
产⽣的SIGPIPE信号)等。当这些软件条件满⾜时,操作系统会向相关进程发送相应的信号,以通知
进程进⾏相应的处理。简⽽⾔之,软件条件是因操作系统内部或外部软件操作⽽触发的信号产⽣。
拓展:core dumped(核心转储):进程收到特定致命信号异常终止,操作系统把进程崩溃瞬间内存、寄存器等运行现场保存为 core 文件,用于后续 gdb 调试定位崩溃原因。例如Ctrl+\、段错误、除零错误都会触发 core dumped。
一句话总结信号产生
-异步信号(按键、kill、alarm):handler return(自定义信号处理),执行下一条代码,不会重复触发信号。
补充:如果不自定义信号处理(或叫捕获),使用信号默认处理动作,收到信号会直接终止进程,后续代码无法执行。
-硬件 Fault 同步信号(SIGFPE、SIGSEGV):handler return 后,重试出错指令,错误存在就死循环。
二、信号保存
信号产生了,但不代表信号就要立即处理,要等到合适的时候处理,所以这个时候就需要把信号先保存,上面已经说过,信号本质就是位图结构,在认识信号之前先来学习一些其他概念
• 实际执⾏信号的处理动作称为信号递达(Delivery)(会在合适的时候调用handler函数)
• 信号从产⽣到递达之间的状态,称为信号未决(Pending)。
• 进程可以选择阻塞(Block)某个信号。
• 被阻塞的信号产⽣时将保持在未决状态,直到进程解除对此信号的阻塞,才执⾏递达的动作.
• 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,⽽忽略是在递达之后可选的⼀种处理动作。
其实信号阻塞、信号未决、信号递达这些都是位图结构(实际是结构体,但我们为了简化就把它们当作一个整数,它们都在task_struct结构体中
信号阻塞跟信号未决类似,假如二号信号被堵塞那就第二位比特位置置为1,同理pending也一样,第二位比特位为1就代表有二号信号未决
handler是一个函数指针数组,它的下标索引映射就是每个信号的信号处理函数,里面保存的就是函数地址
每个信号对应一个处理方式:
-SIG_DFL:默认动作(比如 SIGINT 默认终止进程) -SIG_IGN`:忽略信号(信号递达之后,直接丢掉,不做任何操作)
自定义函数:用户自己写的信号处理函数,递达后跳转到用户态执行。
这里再延伸一下:默认教材是把block和pending的位图叫做信号集(sigset_t,这是内核里才有的数据类型),block又叫做信号屏蔽字,pending叫做信号集。
下面来学习一下一些函数
还有一个sigpending(sigset_t* set),设置pending的信号集
二、操作系统是怎么运行的
引出新概念->中断:打断 CPU 当前正在执行的工作,转而去处理紧急事件,处理完成后再回来继续原来的任务。
硬件中断
指的是键盘、声卡、网卡等硬件外设向中断控制器发送信号,由中断控制器提交中断号给 CPU
时钟中断⭐⭐⭐⭐⭐
可以把它当作跟硬件一样,但它不同的是它是周期性的发送事件(实际它已经被集成在CPU里面,因为外设和CPu交互效率低),它专有名词是时钟源。它的流程和硬件触发事件是一样的,不同的就是它的中断号映射到的函数方法是不同的,
所以说,我们以前学到的什么进程切换,进程调度都是和这个有关系的,这里有几个概念说清楚
时间片:它是一个进程调度的时间,本质就是一个计数器,因为时钟中断是周期性的,所以每次时钟中断,把当前进程的时间片-1是否 > 0 ; >0 ->继续进程调度,< 0代表该进程调度结束,需要切换进程调度(打上需要调度标记,不是立刻在中断内部切换;等中断返回用户态前夕,才做进程切换。)。
意义:实现抢占式调度。可以强制夺走 CPU,防止单个进程一直霸占 CPU 不放,让多个就绪进程轮流使用 CPU。注意:不是所有进程同等机会,优先级、进程阻塞状态都会影响。
那它既然是周期性中断,那没有进程怎么办? 所以它本质是个死循环(因为操作系统从你一开机就不会停止)里面有个for (;;) pause();没有任务就一直执行这个循环,一旦来了进程就执行上面的操作
⭐⭐⭐所以我们能知道,我们所说操作系统也是软件,本质它也是靠硬件中断来对进程做调度,也就是被动进行的!
软中断
也就是不是靠硬件触发的中断,而是可以通过软件触发,说明程序员也能通过代码实现中断,典型的就是系统调用,软中断 = 用户态主动触发陷入内核(
int 0x80/syscall,系统调用)(实际软中断不止系统调用)
那我们好像比如调用read()、write()等系统调用函数,我们也没感受到呀?
那是因为本质这些系统调用都是C语言写的,是C标准库给我们做的封装,
内核存在很多系统调用函数,它们的地址保存在_sys_call这个数组里面
我们这个内核调用系统调用叫做陷阱
还有一些异常也可以规划到软中断:比如缺页中断、除0和野指针等
所以:广义软中断 = 软件触发的事件:陷阱 (系统调用) + 各类异常(缺页、除 0)
三、用户态和内核态
其实我们对于一个进程的虚拟地址空间分为两部分:用户区和内核区
以我们的4G的空间来看,用户态: 0~3GB; 内核态:3GB~4GB;
四、信号捕捉
有了对操作系统怎么运行和用户态和内核态的概念,再谈这个就非常简单了
比如我们用键盘发信号,信号处理的流程: 键盘发送信号->发生键盘中断->->由用户态变为内核态->修改pending->返回用户态瞬间先处理信号->默认、忽略在内核完成,但自定义捕捉需要切回用户态->处理完成后返回内核态->恢复之前因为中断保存的上下文恢复->最后返回用户态继续执行代码
这张图能很好的解释:
再来学一个捕捉函数: sigaction函数它的作用和signal差不多但是还是有区别的
五、其他拓展知识
可重入函数
用一个实例引出
如上我们要进行头插节点,首先我要头插node1,在执行完p-<next=head时,因为时钟中断或其他原因导致需要处理信号,那恰好信号处理函数里也是insert插入,但它是插入node2,那此时的head指向的是node2,当处理完时返回内核,恢复main函数的上下文,继续执行下一条代码head=p时,,head最终指向head1,但你看上图发现了node2没人指向,就发生了内存泄漏
像上例这样,insert函数被不同的控制流程调⽤,有可能在第⼀次调⽤还没返回时就再次进⼊该函数,这称为重⼊,insert函数访问⼀个全局链表,有可能因为重⼊⽽造成错乱,像这样的函数称为不可重⼊函数,反之,如果⼀个函数只访问⾃⼰的局部变量或参数,则称为可重⼊(Reentrant)函数。
如果⼀个函数符合以下条件之⼀则是不可重⼊的:
• 调⽤了malloc或free,因为malloc也是⽤全局链表来管理堆的。
• 调⽤了标准I/O库函数。标准I/O库的很多实现都以不可重⼊的⽅式使⽤全局数据结构
volatile
一个例子说明
所以为了避免编译器优化, 可以在 定义flag时,加一个volatile 告诉编译器检测判断要先走内存在加载到CPU然后进行判断,
这两个知识在线程部分可能体现更明显,信号之旅结束了!!!