在这里插入图片描述

◆ 博主名称: 小此方-CSDN博客
大家好,欢迎来到小此方的博客。
⭐️Linux系列个人专栏: 【主题曲】Linux
⭐️此方的GitHub: github_此方
⭐️ Re系列专栏:我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)


概要&序論

  Hello,大家好我是此方,前面我们讲了信号是概念,信号的产生手段和信号的处理。今天我们来讲信号的捕捉和硬件中断时钟中断。内容部分涉及硬件。本文可以说是信号篇中最核心的内容。我们开始吧。

当前阶段:
在这里插入图片描述

一、信号捕捉流程图

1.1什么是信号捕捉

  信号处理有三种情况,依据hander表的三种情况。

  • 忽略:进程直接把信号丢弃,当成什么也没发生。
  • 默认:执行操作系统内核为该信号设定的默认核心动作(通常是终止进程、暂停进程或产生 Core Dump 核心转储)。
  • 捕捉:调用用户自定义的信号处理函数。

1.2 信号捕捉的全流程

  如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
  由于信号处理函数的代码是在用户空间的代码段,处理过程比较复杂,举例如下:

在这里插入图片描述

  • 用户程序注册了 SIGQUIT 信号的处理函数 sighandler
  • 当前正在执行 main 函数,这时发生中断或异常切换到内核态。
  • 在中断处理完毕后要返回用户态的 main 函数之前检查到有信号 SIGQUIT 递达。

在这里插入图片描述

  • 内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行 sighandler 函数,

sighandlermain 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。

  • sighandler 函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态。
  • 如果没有新的信号要递达,这次再返回用户态就是恢复 main 函数的上下文继续执行了。

  这个流程图在面试的时候经常考到,如果能够给面试官讲清楚,非常的加分。 简化成形象图就是一个∞:

在这里插入图片描述

  细节:如果你去给面试官画这张图,信号检测的这个点一定在这条直线的下面!

  开个小玩笑,它长得像什么?

在这里插入图片描述

1.3详细介绍细节

1.3.1为什么信号捕捉函数在执行完毕后要“再次陷入内核然后回到main执行流”而不“直接回到main执行流”?

  你的自定义信号处理函数 sighandler() 执行完了。问题来了:执行完后,程序怎么知道接下来该去哪?它没有 main() 剩下的地址信息。所以必须调用一个特殊的系统调用:sigreturn。程序再次进入内核态,去找内核接头。
  内核收到 sys_sigreturn() 的请求,它在内核里把之前保存的 main() 函数的现场(各种寄存器、上下文、打断时的下一条指令地址)给恢复出来。
  最终,内核把控制权安全交回给 main()。你的主程序从之前第 1 步被打断的地方,继续往下执行。

1.3.2如何演示默认处理和忽略处理

  如果信号的处理动作是默认或者忽略 , 那么比自定义捕捉要简单得多。

  • 如果是忽略:直接将对应的信号位由 1 置为 0,然后直接返回用户态的 main 主控制流程继续向下执行。
  • 如果是默认:大多数默认动作是直接杀死进程。内核会直接终止进程(或转储并杀死、暂停进程等),甚至不再需要返回用户态。

在这里插入图片描述

1.3.3在自定义捕捉函数中触发内核陷入会发生什么

  肯定不会直接简单地“跳回”第二步,而是会重新触发一次完整的“用户态 → \rightarrow 内核态”的切换流程。
在这里插入图片描述

1.3.4hander函数如何自动执行sigreturn系统调用

  我们都知道,main函数调用func函数。会为func函数开辟栈帧,同时在栈顶压入返回地址。func函数栈帧开辟,是从高地址向低地址移动固定长度的指针位置func函数调用完毕,指针又往回移动固定长度。此时刚好指向我们在main函数栈顶压入的那个地址,实现成功返回。

在这里插入图片描述
  那么可不可以修改这个返回值的地址呢?完全可以。就在sighander函数退出内核态、返回用户态之前的这最后几微秒里,内核动用了它的特权:

内核直接把手伸进了你的用户栈里,强行在栈顶写入了 sigreturn 的地址。

  然后汇编pop这个返回地址到CPU的pc指针,CPU拿到的就是这个sigreturn系统调用的地址。于是触发系统调用再次陷入内核。

在这里插入图片描述

1.3.5死循环进程陷入内核

1.4内核态与用户态

1.4.1内核态与用户态的感性理解

  内核态与用户态的内容非常重要,我想随着学习的深入分两次讲解,这是第一次。
  先说得通俗一点:蓝色形态(用户态),和红色形态(内核态)操作系统正是通过这种形态(模式)的切换,既保护了底层的安全,又给了上层软件运行的自由。

  • 由操作系统执行,执行操作系统代码,访问操作系统数据的时候,CPU被切换为内核态。
  • 执行用户自己写的代码。访问用户进程数据的时候,CPU被切换为用户态。
  • 内核态的权限级别更高,权限级别这块儿暂时讲不了。

  “陷入”或者“坠入”内核,只是一个为了方便程序员理解而发明的拟人化比喻。听起来好像程序掉进了一个深渊或者换了一个地方,但物理现实根本不是那么回事。

1.4.2为什么内核跳转到用户自定义函数必须切换用户态

  既然内核态的权限级别比用户态高,那么在内核态下,CPU一样可以执行用户态的代码!那么为什么内核跳转到用户自定义函数必须切换用户态?
  因为用户写的方法中如果有非法操作比如删除配置文件。这些操作在权限更强的内核态下可以被执行,但是用户态不可执行,如果你不切换形态就去执行用户态的代码,别有用心的用户会对你的系统造成严重破坏

1.4.3关于死循环进程

  这段内容和本文内容相关性不大。只是提到了,补充一下,有人可能会问:在代码里写一个死循环while(true){},这个进程还能进入内核吗?
  完全可以,它也是一个可以被调度的进程,当时间片一过,就会被CP强制保护现场,切换下去,此时就深入内核了。进程进入内核是一个非常简单并且高频的事情。
  那么为什么唯独这个进程跑起来你的操作系统明显感觉比较卡?

  • 别的普通进程: 操作系统虽然给了它们相同的时间片,但它们大多只运行很短一段时间,就因为等待磁盘、网络、用户输入等 I/O 事件,主动放弃剩余时间片,进入睡眠状态
  • 死循环 while(true){} 进程: 它几乎不会发生 I/O,也不会主动阻塞,因此每次都会把分配给自己的整个时间片全部跑满,直到时钟中断到来,被内核强制剥夺 CPU。
  • 导致的根本原因:因此,其他进程释放出来的大量 CPU 时间最终都会被这个死循环进程消耗掉,使某个 CPU 核长期接近 100% 占用,系统响应速度明显下降,看起来就会很卡。

二、操作系统是怎么转起来的

  操作系统管理硬件,它是怎么知道硬件上有数据的?于是就有猜测:直接让操作系统轮询扫描所有的硬件文件对应的缓冲区,有数据了,就拷贝到用户层缓冲区。但是这样对操作系统来说太忙了!操作系统还要做很多别的事情。

于是我们引入新的概念——硬件中断。

2.1CPU和硬件是连接在一起的

  回到进程篇的第一篇,当初讲冯诺依曼体系的时候,我刻意回避了这个问题:我们的外部设备要把数据加载到内存中,这样CPU才会读取这些数据。但是不代表外部设备不和CPU关联

在这里插入图片描述
  在电路板上,外部设备和CPU连在一起,硬件就可以间接的通过CPU的针脚来进行信号沟通(依靠高低电平表示01

2.2 CPU通过中断控制器收发控制信号

2.2.1中断控制器是CPU获取硬件控制信息和下发指令的关键

  准确的说,CPU下发硬件指令是给控制器下发的。
在这里插入图片描述
  有一种板子叫做8259,(8259已经过时了,第二代APIC 替代了 8259,并把 8259 的功能全部继承并大幅扩展。
  我们的主板上面有一个或者多个8259的集联,在焊接电路的时候,就将对应的中断信息入口连接设备。一旦设备就绪,设备就会给中断控制器发一个高电频,那么这个中断控制器就会给CPU的特定针脚发动我们对应的控制信息。CPU知道了有设备已经就绪了。于是CPU去访问中断控制器,获取中断控制器中的中断号码(从寄存器中获取)知道哪个设备准备好了。

2.2.2什么是CPU针脚

  针脚长什么样子?
在这里插入图片描述

  针脚就是芯片上的引脚。比如CPU:

+---------------+                     
|      CPU      |
+---------------+
|||||||||||||||||  引脚(Pin)

  每个Pin都有用途。例如:VCC,GND,RESET,CLK,IRQ,NMI。其中IRQ就是中断引脚。
  例如以前的8259A自己也有很多Pin。
在这里插入图片描述
  中断控制器最后必须通知CPU。CPU只需要知道:"有人找我。"至于是谁找它:8259A告诉CPU。

磁盘——>IRQ1——>8259A——>INT——>CPU IRQ Pin

2.2.3简单了解——什么是主板

  主板长什么样?

在这里插入图片描述

  主板就是一块电路板。它上面焊着:CPU(插槽),内存,南桥/北桥(老架构),PCH(现代Intel),中断控制器(现代很多已经集成),USB控制器,SATA控制器,网卡,PCIe设备……

  扯远了,我们回来,给出结论:依靠这一套机制,哪一个外部设备好了,CPU就知道。OS再也不关注,外部设备是否准备好,而是它准备好,会叫我。

2.3硬件中断的处理流程

2.3.1设备控制器与设备控制器上的寄存器

  几乎每个硬件设备都有自己的设备控制器,因为每种设备都需要专门的控制逻辑;而这些设备控制器通常不会直接连接CPU,而是把中断请求统一发送给系统中的中断控制器,再由中断控制器通知CPU。
  所以,“控制器”这个词在计算机体系结构里至少有两层含义:

  1. 设备控制器:控制具体设备(键盘控制器、磁盘控制器、USB控制器等)。
  2. 中断控制器:统一管理来自各设备控制器的中断请求并通知CPU。

  寄存器不止是CPU有,设备的控制器上面也有。CPU将进来的请求(虚拟地址)转换为物理地址。发送命令+物理地址+数据 给硬件。硬件控制器比如磁盘控制器

  • 控制寄存器:存命令。
  • 地址寄存器:存地址。
  • 数据寄存器:存数据。

2.3.2从硬件到CPU

  磁盘就绪——>磁盘控制器向中断控制器发送高电平——>中断控制器接收高电平——>中断控制器内部根据针脚,生成并保存中断号n——>中断控制器同时CPU针脚——>CPU获取中断号。

在这里插入图片描述

2.3.3中断向量表

  中断向量表是一个函数指针数组,其中被操作系统注册了大量的中断处理函数。你可以简单理解,中断向量表的下标就是中断号。

在这里插入图片描述

2.3.4从CPU到操作系统

  CPU使用获取来的中断号去中断向量表中索引,得到中断处理方法并调用。 在执行中断处理方法之前CPU会进行现场保护,把当前执行流寄存器的数据保存起来,处理完中断之后再去读取被保护数据。

在这里插入图片描述
  你觉得以上这个过程熟悉吗?

  • 发中断 — 发信号?
  • 保存中断号 — 记录信号?
  • 中断号 — 信号编号?
  • 处理中断 — 处理信号?自定义捕捉?

  我们前三篇学的信号,本质是用软件来模拟硬件中断的。 两者在思想原理上很像。但是实现上完全不同。

2.4操作系统是被动执行任务的

2.4.1操作系统在中断未到达前在干嘛

  我们用初代Linux的代码看一看。

  操作系统在中断未到达前处于持续的暂停中什么都没做!
在这里插入图片描述

2.4.2时钟中断

  时钟中断的部分内容我放在软件条件那里了【2.2闹钟产生信号Re:Linux系统篇(四十四)信号篇·二:Linux 信号的产生原理:硬件异常、软件条件与内核处理机制
  早期,科学家沿用的硬件中断的整套逻辑,把内核架构中的时钟源和硬件控制器放在了一起,在中断向量表中注册了“进程切换”函数。
  时钟源以固定的频率向CPU发送信号,CPU收到信号自动调用中断向量表中的进程调度函数处理信号。

在这里插入图片描述

  后来人们发现,时钟中断可以直接集成在CPU里面,不用走中断控制器,这样效率更高。时钟中断的触发频率就是你计算机的主频。

在这里插入图片描述
  有了以上两点的认知,我们很轻易的就得出两个结论

  • 操作系统就在硬件时钟中断的驱动下进行调度的。
  • 操作系统的本质:就是基于中断,进行工作的软件

2.4.3补充内容:为什么时钟中断被集成到CPU内部了

  中断控制器同时接收到多个控制信号,是否要在内部维护什么结构来保存这些信号?既然保存了信号是否应当有一种优先级处理机制来给CPU发送信号?
  是的,中断控制器内部确实要维护"哪些中断正在等待处理"的信息。但它一般不是一个简单的FIFO队列,而是一组位图(Bitmap)+ 优先级仲裁逻辑。
  这个优先级仲裁逻辑不必细讲,不过值得一提的是“时钟中断信号”的优先级往往更高。

于是引发了一个问题:时钟源容易和硬件发生竞争并且往往时钟源获胜。导致硬件访问的效率低下。

  因此人们在发现问题的根源后把时钟源集成到了CPU内部

2.4.4时钟中断与时间片/时间戳

  为了讲解方便,我上面对这一点讲的并不清楚,时钟中断一来就一定会切换进程——这是错误的。
真正流程

  1. 触发:时钟中断触发 → \rightarrow 进入内核。
  2. 处理:更新系统时间 → \rightarrow 当前进程时间片递减 (时间片–)。
  3. 判断:检测时间片是否耗尽?
    • 未耗尽:直接返回原进程继续执行。
    • 已耗尽 (时间片 == 0) 或 触发以下其他条件时,才会真正调用调度器进行上下文切换:
      • 有更高优先级进程变为就绪状态
      • 当前进程主动阻塞
      • 当前进程主动让出 CPU (yield)
      • 当前进程结束

在这里插入图片描述

  比如我一个进程有10单位的个时间片。每一个时间片的长度是1纳秒。时钟中断的触发频率是1纳秒一次,那么时钟中断每触发一次就会让时间片–。直到时间片==0,调用shedule 切换进程。

  计算机第一次开机,从网络中读取时间戳x。知道自己的时钟中断的频率y。1秒中内触发的时钟中断的次数:1/y 。于是我们计算出“历史总频T”= x* (1/y)。 于是让我们的计算机在离线的时候也能知道现在几点了。

2.5从源代码看时钟中断

  在main函数中,我们发现有很多的初始化函数,其中就有调度程序初始化。
在这里插入图片描述
  设置中断门,注册时钟中断。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
  在时钟中断中调用了do_timer函数。
在这里插入图片描述
  进入do_timer函数我们发现这样的处理逻辑:时钟中断发生,查看current(task_struct重命名)里面的counter(task_struct中的成员变量,时间片)。如果大于0,时间片没完,时间片–,并退出。否则执行调度函数。

在这里插入图片描述

在这里插入图片描述
  调度函数内部,switch_to函数切换到任务号为next的任务。

在这里插入图片描述
  再看点别的:硬件中断处理函数初始化

在这里插入图片描述

  许多的注册函数被注册进中断向量表。

在这里插入图片描述

在这里插入图片描述

2.6 因为软件原因触发中断处理流程

  有没有可能因为软件因素触发如上的中断处理流程?有可能的。

2.6.1 被动中断——异常

  异常是一种由CPU内部条件触发的同步中断。与时钟中断、硬件中断等外部设备产生的异步中断不同,异常一定发生在CPU执行某条指令的过程中,并且与当前正在运行的程序相关。
  例如,程序执行除0操作:

int a = 10 / 0;

  CPU执行对应的除法指令时,会检测到除数为0,此时CPU内部产生除法错误异常。随后CPU根据异常编号查询异常向量表入口,并跳转到操作系统注册的异常处理程序。
  除了除法错误之外,CPU在执行指令过程中还可能检测到其他异常情况,例如:

  • 缺页异常(Page Fault):程序访问的虚拟地址没有对应的物理页面,CPU中的MMU检测到页表无效后产生异常,操作系统随后负责加载页面或者终止进程。
  • 非法指令异常(Invalid Opcode):CPU无法识别当前执行的指令。
  • 保护异常(General Protection Fault):程序违反CPU权限保护机制,例如用户态访问内核空间。
  • 断点异常(Breakpoint Exception):调试程序主动设置断点导致CPU进入异常处理流程。

  需要注意的是,异常虽然通常由程序行为引起,但是异常的产生者是CPU硬件,而不是软件本身。例如:

int *p = NULL;
*p = 100;

  程序访问空指针后,并不是程序主动发送一个“错误中断”,而是CPU执行内存访问指令时,由MMU发现该地址不可访问,于是产生缺页异常,操作系统收到异常后进一步处理,最终可能向进程发送 SIGSEGV 信号。

2.6.4 主动中断——陷阱

  触发指令:用户态程序通过硬件指令主动陷入内核。x86(32位)环境下使用 int [中断号] 指令,通过指定中断号触发;x86_64(64位)环境下则直接使用专为内核跳转优化的 syscall 指令。
  0x80 中断号:在 32位 Linux 中,0x80(十进制 128)是内核专为系统调用注册的软件中断号。执行 int 0x80 即可触发系统调用流程。

我为什么要讲int和syscall?为理解系统调用的执行原理做准备。

2.7系统调用如何执行

  补充内容:系统调用函数的指针被放在一张函数指针数组中存储。

在这里插入图片描述
  “在内核初始化(如 sched_init)期间,内核通过 set_system_gate 宏,将软中断号 0x80 与系统调用总入口函数 system_call 进行绑定,将其注册为系统调用的中断处理程序。当用户态发起 int 0x80 指令时,CPU 就会通过中断向量表跳转到 system_call 执行具体的系统调用逻辑。”
  讲句人话就是!就是在中断向量表中注册了一个新的函数。然后触发该中断的中断号会去调用这个函数。

在这里插入图片描述
在这里插入图片描述
  system_call是用汇编写的。看!用表地址+4个寄存器值大小,调用系统调用。 你现在看不懂没关系,我下面画了一张图,看完这张图再回来你就明白了。

在这里插入图片描述

在这里插入图片描述

  #define SYS_ify(syscall_name) _NR##syscall_name :是一个宏定义,用于将系统调用的名称转换为对应的系统调用号。比如:SYS_ify(open) 会被展开为 __NR_open

  这里$SYS_ify (vfork) 是将vfork这个系统调用转化成系统调用号,然后再movl放入寄存器。 然后直接调用系统调用。

  而系统调用号,不是 glibc 提供的,是内核提供的,内核提供系统调用入口函数 man 2 syscall ,或者直接提供汇编级别软中断命令 int or syscall ,并提供对应的头文件或者开发入口,让上层语言的设计者使用系统调用号,完成系统调用过程。
  glibc通过include这个头文件获取宏并转化的:
linux-2.6.18\linux-2.6.18\include\asm-x86_64\unistd.h

在这里插入图片描述
  最后,我们把以上的理解拼起来放在一张图中。
在这里插入图片描述
  系统调用也是C语言的glibc库里面的。操作系统只提供系统调用号宏,于是这个世界上所有的语言都和C语言有关,C生万物,有其一定道理。


好的本期内容就到这里,如果对你有帮助,还不要忘记点赞三联支持。我是此方,我们下期再见。bye! Linux、C++、算法持续连载中,欢迎关注WeChat Official Account 【此方的技术栈】。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐