Re:Linux系统篇(四十六)信号篇·四:一文串联操作系统底层:时钟中断、内核态切换、系统调用与 Linux 信号处理全解析

文章目录
概要&序論
Hello,大家好我是此方,前面我们讲了信号是概念,信号的产生手段和信号的处理。今天我们来讲信号的捕捉和硬件中断时钟中断。内容部分涉及硬件。本文可以说是信号篇中最核心的内容。我们开始吧。
当前阶段:
一、信号捕捉流程图
1.1什么是信号捕捉
信号处理有三种情况,依据hander表的三种情况。
- 忽略:进程直接把信号丢弃,当成什么也没发生。
- 默认:执行操作系统内核为该信号设定的默认核心动作(通常是终止进程、暂停进程或产生 Core Dump 核心转储)。
- 捕捉:调用用户自定义的信号处理函数。
1.2 信号捕捉的全流程
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
由于信号处理函数的代码是在用户空间的代码段,处理过程比较复杂,举例如下:

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

- 内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行 sighandler 函数,
sighandler 和 main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
- 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。
所以,“控制器”这个词在计算机体系结构里至少有两层含义:
- 设备控制器:控制具体设备(键盘控制器、磁盘控制器、USB控制器等)。
- 中断控制器:统一管理来自各设备控制器的中断请求并通知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时钟中断与时间片/时间戳
为了讲解方便,我上面对这一点讲的并不清楚,时钟中断一来就一定会切换进程——这是错误的。
真正流程:
- 触发:时钟中断触发 → \rightarrow → 进入内核。
- 处理:更新系统时间 → \rightarrow → 当前进程时间片递减 (时间片–)。
- 判断:检测时间片是否耗尽?
- 未耗尽:直接返回原进程继续执行。
- 已耗尽 (时间片 == 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生万物,有其一定道理。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)