一,捕捉信号流程
捕抓信号:如果在信号处理的时候使用的是自定义函数,则这个处理信号行为我们就叫做捕捉信号。
假设原来在执行main函数的主流程,发现 [硬件]异常(需要从用户态切换到内核态)
切换到内核态,内核会保存原进程上下文,处理硬件异常(如检查内存访问权限,发信号…)
内核在处理完异常(如硬件中断、系统调用)后,且即将返回用户态之前,会do_signal()检查是否有未决信号待处理,如果有,则会分析对应信号的处理函数
如果函数是自定义的,则需要回到用户态执行对应的函数
如果是默认的,则直接在内核态执行,执行完就恢复上下文,返回用户态执行主流程了
如果是忽略行为,则直接返回用户态执行主流程
当在用户态执行完了信号处理函数,会调用sigreturn 系统调用重新陷入内核态(因为上下文在内核里面,会恢复上下文)
这里怎么实现调用sigreturn的?
回到内核态后,根据恢复的上下文信息,重新回到主流程(即:又要切换回用户态)
二,操作系统是怎么运行的
1. 硬件中断
1.1 问题导入
当我们scanf的时候,要等待键盘输入,如果不输入,进程就会从 R变S状态,那当我们输入了,按下回车以后,OS怎么知道我们的硬件就绪了?
通过硬件中断!
当硬件就绪的时候,硬件会发起一个硬件中断,这个中断会被CPU知道,然后由CPU去执行OS里面的中断处理方法。
整个过程,操作系统就不需要对外设进行任何周期性的检测或者轮询
我们之前学过冯诺依曼体系结构,外设和CPU的数据传输都需要通过内存,但是,控制信号的传输是不需要的(即,简单认为:外设和CPU肯定也是有电路直接相连的)
1.2 理解原理
当今的CPU会集成一个中断控制器,中断控制器有很多引脚。
当一个外设发送硬件中断的时候,中断控制器负责将外设的中断请求转换为 CPU 可识别的中断号n。(即:通过中断号,可以知道是哪个外设)
同时,中断控制器会通知CPU有硬件中断产生,CPU会从中断控制器获得对应的中断号n
然后,CPU会保存现场(通过CPU里的寄存器)
再通过中断号,去到OS里的中断向量表中,找到对应的中断处理方法并执行
执行完以后,回到CPU,然后恢复现场,继续执行原来的工作
中断向量表
我们可以简单的把中断向量表IDT理解成一个:函数指针数组(里面存储的是每个中断对应的中断处理函数的指针),下标对应的就是中断号n
这个中断向量表属于OS的一部分(即:被写好的,内核初始化时会注册各类中断的处理函数)
2. 时钟中断
上面我们可以理解到,操作系统会被硬件中断驱动。
实际上,操作系统是中断驱动的,并且有一个硬件会周期性的给OS发时钟中断!
这个硬件设备每隔一段时间就会发一个时钟中断,并且这个中断请求对应的中断处理函数是执行进程调度
就实现了OS的在周期性时钟中断的驱动下,“不停”的进程调度。
同时,每一个进程中的时间片的时间基本单位通常是时钟中断周期的整数倍
在时钟中断的驱动下,不断执行调度:判断当前进程是否需要切换(时间片到期 / 进程优先级…),如果不用,则对应进程的时间片--
即,操作系统一直是这种状态:
// 操作系统
void main(void)
{
......
for (;;)
pause(); // 暂停等硬件中断
}
// 接收到时钟中断,执行中断处理(进程调度)
// 调度入口
void do_timer(long cpl)
{
......
schedule(); // 调度核心函数
}
void schedule(void)
{
......
// 1. 选择下一个进程(通过调度算法,判断时间片,优先级...)
next = pick_next_task();
// 2. 若选中进程不是当前进程,则切换
if (next != current)
switch_to(next); // 切换到新进程
}
三. 软中断
软中断分为:异常和陷阱
异常:就是之前讲的硬件异常。软件导致的硬件异常,然后导致CPU内部触发中断。CPU是被动的
如:缺页中断、内存碎片处理、除零野指针错误…
这些问题,全部都会被转换成为CPU内部的软中断,
然后走中断处理例程,完成所有处理。有的是进行申请内存,填充页表,进行映射的。有的是用来处理内存碎片的,有的是用来给目标进程发送信号,杀掉进程…
陷阱:软件主动调用指令,使CPU主动发出中断请求。CPU是主动的
1. 陷阱
软件怎么发中断请求?
x86_64机器通过:syscall,x86通过INT 0x80来实现软件触发中断,CPU会自动发送中断请求,走一遍中断处理流程。
syscall 不等于 INT 0x80,这个80就是中断号
INT 0x80依赖IDT来表入口(表是什么下面讲)
但是syscall已经不依赖IDT了,而是直接用MSR寄存器来记录表入口,直接跳转
系统调用号
其实我们使用的fork、open这些函数,不是系统调用,它们也是glibc封装的,OS并没有提供这些函数,而是只提供了系统调用号。
系统调用号是操作系统内核为每一个系统调用分配的唯一整数标识符(本质是数组下标)。
所有的系统调用都被记录在sys_call_table表里,这个表是一个函数指针数组,保存了每个系统调用的函数入口。
如何通过系统调用号找到对应的系统调用函数入口?
对于INT 0x80,通过中断号80找到sys_call_table表,然后通过sys_call_table + 4 * 系统调用号(从寄存器来)的形式找到对应系统调用函数的入口
而对于syscall,已经直接把表的入口存入了寄存器中,通过寄存器中的表入口地址 + 寄存器中的系统调用号 * 8 就可以准确找到系统调用函数入口(因为这时候,指针变8个字节了)
所以,如open函数,其内部其实封装了两条重要语句:
把系统调用号move到寄存器
调用syscall / INT 0x80 发送中断,走中断处理流程,实现从用户态到内核态的切换。内核通过sys_call_table[2]找到sys_open并执行。
四,用户态和内核态
进程地址空间里我们看过这样一张图,我们说[0, 3]是用户层,实际上[3,4]G是内核层。
而操作系统也是软件,也有实际的物理内存存储对应的代码。也会通过内核页表来映射到虚拟地址中。
特别的是:因为操作系统只有一份且都映射到[3, 4]GB空间,所以,所有的用户共享这一份内核页表。
我们进程调用的过程也是在虚拟内存地址上进行的,只不过,从代码区跳转到了内核区(所有函数的调用,都是地址空间的跳转)
操作系统无论怎么切换进程,都能找到同一个操作系统(换句话说操作系统系统调用方法的执行,也是在进程的地址空间中执行的)
那,处在用户区的代码,怎么能随便跳到内核区呢?
为了限制这一行为,就有了CS段寄存器
CS段寄存器会存储当前正在执行的代码段的起始地址,并且CS 寄存器的低 2 位会记录当前代码段的特权级信息(CPL)
当用户程序通过syscall指令从用户态陷入内核态时,硬件自动将 CS 的 CPL 从 3 切换为 0,使程序获得内核特权级,从而执行内核代码
五,其他
1. 可重入函数
可重入与不可重入只是函数的性质
如下图:
main函数调用insert函数向⼀个链表head中插⼊节点node1,插⼊操作分为两步
刚做完第⼀步的时候,如果因为硬件中断使进程切换到内核,再次回到用户态之前检查到有信号待处理,于是切换到sighandler函数
sighandler也调用insert函数向同⼀个链表head中插⼊节点node2,执行完sighandler后返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执执行(继续执行第二步)
结果insert函数又让head指向node1,这就导致了node2丢失了,内存泄漏
重入:像上例这样,insert函数被不同的控制流程调用(这里是串型的控制流),有可能在第⼀次调用还没返回时就再次进⼊该函数,我们叫做重入
不可重入函数:像上面的insert函数,有可能因为重入而造成错乱的函数,我们称为不可重入函数,否则叫做可重复
如果一个函数符合以下条件之一则是不可重入的:
调用了malloc或free,因为malloc也是用全局链表来管理堆的。
调用了标准I/O库函数。标准I/O库的很多实现都以不可重⼊的方式使用全局数据结构。
2. volatile
一般来说,程序运行时,获得一个变量的值,每次都应该去内存里面拿到CPU。
但是,如果编译器优化过度,则会在第一次直接把内存的值保存到寄存器里,然后后续用这个值都不去内存里面找了,而是使用寄存器里的缓存值
这会导致:如果变量有修改(在内存里的值已经变了),但是编译器还是去寄存器里拿原来的值。导致错误。
volatile 作用:
保持内存的可见性,告知编译器,被该关键字 修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
3. SIGCHLD信号
子进程在终止时会给父进程发SIGCHLD信号
当我们不需要获取子进程的退出信息,只是想让子进程的资源被回收则可以:用sigaction / signal将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程
此方法在Linux 可用,在其他Unix系统上不一定
暂时不深究原因,记住就好。
————————————————
版权声明:本文为CSDN博主「愚润泽」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/tan_run/article/details/148191874