【Linux笔记】——进程信号的捕捉——从中断聊聊OS是怎么“活起来”的
🔥个人主页🔥:孤寂大仙V
🌈收录专栏🌈:Linux
🌹往期回顾🌹:【Linux笔记】——进程信号的保存
🔖流水不争,争的是滔滔不息
- 一、信号捕捉的流程
- 二、硬件中断
- 时钟中断
- 三、异常
- 四、用户态与内核态
- 五、不可重入函数
- 六、volatile
一、信号捕捉的流程
上图中,横线以上是用户态以下是内核态,执行主程序也就是我们的代码的时候会因为一些原因比如中断、异常、或系统调用进入进入内核态,在内核态时处理完异常准备返回用户态之前会进行信号检测,所谓的信号处理确实就是在那个pending表检测,如果有相应的信号需要处理,就会去用户态进行信号的处理,然后信号处理函数返回时执行特殊的系统调用再进入到内核态,然后返回最开始用户态main函数中执行的时候被中断的地方继续向下执行。
重点注意:
信号的真正执行(调用 handler)其实是在用户态完成的。
但是信号检测 & 调度的决定权在内核态。
信号处理函数(handler)本质上是“用户态的中断服务例程”
如果信号的处理动作是用户自定义函数,在信号递达的时候就调用这个函数,就称为捕捉函数
信号处理的过程:
- 用户程序注册了 SIGQUIT 信号的处理函数 sighandler 。
- 当前正在执行 main 函数,这时发生中断或异常切换到内核态。
- 在中断处理完毕后要返回用户态的 main 函数之前检查到有信号 SIGQUIT 递达。
- 内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行 sighandler 函数, sighandler 和 main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
- sighandler 函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态。
- 如果没有新的信号要递达,这次再返回用户态就是恢复 main 函数的上下文继续执行了。
二、硬件中断
穿插软件中断的话题,下面对硬件中断的理解并不严谨主要是理解
简单来说,外部设备就绪,会通过发送高低点评给中断控制器,(中断控制器是给 CPU 管理突发事件的“调度台”,负责接收、筛选、优先级排序各种中断请求,然后在合适的时机打断 CPU 处理)。中断控制器接收到后判断是不是该管这事,再决定要不要“喊醒”CPU 处理。在操作系统中软件层面,内存中会有一个函数指针数组中断向量表就是中断来了要干什么。CPU会根据中断号索引中断向量表中的中断方法。“中断”本质上是打断 CPU 当前的执行,去处理“更紧急”的事。
虽然“中断”和“信号”本质上属于不同领域的机制,但它们的运行思路确实很像,可以说有种“精神上的共鸣”。
时钟中断
引入问题,当中断没有到来了的时候操作系统在干什么,操作系统什么都没做!是暂停的。
对中断进行进一步的理解,多增加点东西
时钟源在物理上是外部设备,中断向量表中有进程调度的中断服务,时钟源发出Tick信号后,必须通过中断控制器传递到CPU,CPU响应后由操作系统处理“时钟中断”,从而驱动整个系统的“时间流动”。时钟源以固定的特定的频率向CPU发送特定的中断,然后操作系统就在硬件时钟中断的驱动下根据中断号执行中断处理历程执行中断方法进行调度了。这里可以得出一个结论,操作系统就是基于中断进行工作的软件。时钟中断让OS有了‘时间’的概念,靠时间片完成任务切换,形成一个高效运转的多任务系统。CPU主频决定做事快不快,时钟中断频率决定多频繁切任务。操作系统,是中断驱动下的超级调度器。
操作系统=中断驱动的超级调度器
三、异常
上面我们聊的是通过硬件来触发中断,其实通过软件也能触发中断。
为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(int 或者 syscall),可以让CPU内部触发中断逻辑。int和syscall属于指令集,指令集是“CPU能干的活”。
指令集里有 syscall / int 这样的指令,可以触发异常(trap),让CPU跳到内核态,再查系统调用表找到对应的内核函数去执行。就可以理解为触发了异常,然后去中断向量表中在处理软中断的中断服务中,根据系统调用号查系统调用函数执行对应的方法。(系统调用函数指针表,用于系统调用中断处理程序,作为跳转表。每一个系统调用都有一个唯一的下标,这个下标叫做系统调用号)。这里可能会有问题,那用户层面是如何进行系统调用的?其实我们用的函数,不是操作系统提供的系统调用接口,操作系统只提供系统调用号。我们用的函数都是比如说open close这些都是glibc封装的。
// 系统调⽤函数指针表。⽤于系统调⽤中断处理程序(int 0x80),作为跳转表。
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
sys_setreuid, sys_setregid
}
所有调用系统函数(比如你在用户空间调用 open、read、write 等操作)通常是通过异常来实现的,具体来说,是通过系统调用(system call)触发的异常来完成的。 还有除以0、非法指令等,通常是由CPU执行时的错误或特殊事件触发,CPU自己去查中断向量表。
注意⚠️不要混淆软中断与异常
软中断(SoftIRQ) 并不是软件程序直接触发的中断。这个名字有点误导,实际上它是内核自己使用的一种机制。它是用来处理一些高频率的中断任务(比如网络数据包、磁盘I/O等)并把这些任务分配给内核去处理,但并不是由用户直接触发的。
软中断的触发并不是由用户程序直接发起,而是 硬中断触发后,由内核自己决定要不要处理。当硬中断(比如网络卡收到数据包)完成后,内核可以决定将一些任务推到“软中断”队列,稍后在合适的时机处理。
四、用户态与内核态
根据上图我们发现,0-3gb是用户区,3-4gb是内核区,用户区是用户的进程和数据等等,内核区是操作系统、和有哪些系统调用、中断运行需要的东西也在这里。多个进程有多个用户区,多个用户区根据进程的不同存有不同的信息,但是多个进程的内核区都指向一块区域,所以操作系统无论怎么切换进程都能找到同一个操作系统,所以操作系统系统调用方法的执行实在进程的地址空间中执行的。
我们发现内核和用户都在同一块地址空间上,会不会用户可以随便访问内核的代码和数据?其实并不会因为操作系统不相信任何人必须采用系统调用的方式进行访问,所以用户态,只能以用户身份访问自己的0-3gb的空间,内核态,以内核的身份运行通过系统调用的方访问3-4gb的空间。
在系统中,用户或者os怎么知道自己处于内核态还是用户态?
用户态 → 内核态,寄存器到底发生了啥?
假设场景:
你调用了个 read() 系统调用,本质上是个 syscall 指令 → CPU切内核态。
CPU干的 4件事:
-
【换CS】:切换代码段特权级
用户态 时:CS = 0x1B(Ring3 用户代码段)
内核态 时:CS = 0x08(Ring0 内核代码段)
这个动作瞬间改变了特权级,低2位是 CPL(Current Privilege Level) -
【换SS(栈段) + RSP(栈指针)】:切换到内核态栈
用户态有自己的用户栈(SS = 0x23, RSP = 用户栈地址)进内核前,CPU 会:把用户态栈信息(SS、RSP)暂存切换到内核态专用栈(TSS 里提前设定好了内核栈地址)防止内核操作时还用着“脆弱的用户栈”。 -
【保存上下文】:把用户态的寄存器信息压栈把当前的RIP(下一条要执行的用户代码地址、RFLAGS(标志寄存器)、甚至 RAX、RBX、RCX、RDX… 这些通用寄存器,全都“啪!”压到内核栈上。
这是为了返回用户态时能恢复“原汁原味”。 -
【跳转到内核代码执行】
CPU 根据 IDT表(中断描述符表) 找到对应的中断/系统调用入口。开始执行内核里的 sys_read() 代码。
执行完后 → 内核态 → 用户态(返回)
当 read() 干完活后,要回去继续跑你的代码,CPU还得干这些事:
CPU做了:
-
【恢复寄存器上下文】:从内核栈里把刚才压的 RIP、RFLAGS、通用寄存器 恢复回来。
-
【换回用户态CS】:CS 从 0x08 → 0x1B (回到用户代码段,Ring3)2.
-
【换回用户态SS、RSP】:恢复用户栈。
-
【执行 iret 指令】:跳回你用户程序的下一条指令,继续愉快干活。
“CPU切内核态时,核心就两件事:
换‘工作服’(CS、SS、RSP变成内核态的),收拾行李(把用户态寄存器都保存好)。干完活后,再换回‘用户态衣服’,提着原来的行李继续走。”
五、不可重入函数
在进行链表插入节点的时候,本来插入一个节点就OK了,但是这时候插入节点的时候进行了自定义捕捉信号,让这个插入节点的逻辑又去干别的了。那么这时候可能就会出错,比如插入的节点是node1,这时候信号捕捉像同一个链表中插入了节点node2,最后只有一个节点插入了链表中。
insert函数可能会因为出入而造成错乱,这样的函数被称为不可重入函数。
如果一个函数符合以下条件之一就是不可重复的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准i/o库函数,标准i/o库的很多实现都是以不可重入的方式使用全局数据结构。
六、volatile
信号处理函数里访问的全局变量,必须用 volatile 来修饰。
volatile相当于告诉编译器,不要优化不要把全局变量放到寄存器,每次都要从内存中拿。
//假如写了一个全局变量
int stop=0;
//主程序循环
while(!stop)
{
}
//信号处理函数
void sigint_handler(int signum)
{stop=1;
}
如果没有volatile ,编译器看stop是全局变量,并且stop没变过,就直接放寄存器里了。这是信号触发,stop=1改的是内存中的值,但是循环检测检测的是寄存器里的值,这时候就完蛋了死循环了。
所以,信号处理函数必须要用volatile ,因为这样全局变量就不会往寄存器里拿了,想要用每次都要访问内存,去内存中拿,那么这样上述的例子,stop=1改了内存中的值,这时循环检测检测的值就是内存中已经改过的值。
volatile 的本质作用:
“告诉编译器:这个变量随时可能被外部修改,不许优化,不许缓存,每次都得去内存拿最新值。”