Huangjin007_头像
【Linux 系统篇(三十八)】信号(四):信号捕捉的执行流程与内核态、用户态封面图

【Linux 系统篇(三十八)】信号(四):信号捕捉的执行流程与内核态、用户态

在这里插入图片描述

大家好,欢迎来到 huangjin007_ 的博客
⭐ 个人主页:huangjin007_
🔥 文章收录专栏:Linux 内功修炼手册(系统篇)
总会有一些坚持
能从冰封的土地里
培育出十万朵怒放的蔷薇

Linux 系统篇(三十八) —— 信号捕捉的执行流程与内核态、用户态

文章目录


一、信号捕捉:用户函数是怎么执行的?

1.1 什么是信号捕捉?

  如果一个信号的处理动作是用户自定义的函数,那么当这个信号递达时,进程就会执行这个函数,这种处理方式就叫作捕捉信号。

  例如:

#include <iostream>
#include <signal.h>
using namespace std;

void handler(int signo)
{
    cout << "捕捉到了信号:" << signo << endl;
}

int main()
{
    signal(SIGINT, handler);

    while (true)
    {
        // 主程序持续运行
    }

    return 0;
}

  运行程序后,在终端按下 Ctrl+C,通常就会产生 SIGINT 信号。

  由于我们注册了 handler 作为 SIGINT 的处理函数,因此进程会执行 handler(),而不是直接按照默认动作终止。

  这里有一个问题:

  当前程序明明正在执行 main(),为什么突然就能去执行 handler() 呢?

  难道内核直接调用了我们的函数?

  并不是。

1.2 信号什么时候处理?

  先回忆一下前面学过的知识:

  信号产生后,不一定立即处理,而是先保存在 Pending 集合中。

  那么,内核究竟什么时候检查信号呢?

  一个非常重要的知识点:

  进程从内核态返回用户态之前,内核会检查是否存在可以递达的信号。

  我们可以把这个过程理解为:

发生中断、异常或者系统调用

内核处理相应事件

检查是否有可以递达的信号

没有

有

用户态执行 main()

进入内核态

准备返回用户态

是否存在信号?

恢复原来的执行流程

根据处理动作执行相应操作

  这里需要特别注意:

  信号检查并不是按照固定的时间间隔执行,而是在内核准备返回用户态等适当时机进行。

  因此,即使信号已经产生,也可能需要等待当前执行流程进入内核,并到达合适的检查时机,才能被处理。

  下面这张图可以帮助我们记忆什么时候进行用户态与内核态的切换以及检查pending表的时机。

在这里插入图片描述

1.3 为什么 handler 必须在用户态执行?

  这是信号捕捉中最关键的一个问题。

  我们自己编写的 handler() 属于用户程序代码,它运行在用户空间。

  而内核代码运行在内核空间,具有更高的执行权限。

  如果内核直接以自己的特权级别跳转到用户编写的函数,就会破坏用户态和内核态之间的权限隔离。

  所以,内核不能简单地在内核态直接执行 handler()。

  正确的做法是:

  内核先准备好用户态的执行环境,再返回用户态执行 handler。

  整个过程可以概括为:

中断 / 异常 / 系统调用

准备用户态执行环境

handler 返回

返回用户态

用户态
main()
正常执行

内核态
处理相关事件
检查 Pending
发现信号递达

用户态
handler()
执行信号处理

内核态
sigreturn
恢复原来的现场

用户态
main()
从原位置继续

  所以,信号捕捉有一个非常重要的执行路径:

  内核态 → 用户态执行 handler → 内核态恢复现场 → 用户态继续执行原程序。

  这也是为什么信号捕捉比默认处理和忽略信号复杂得多。

1.4 handler 和 main 是什么关系?

  我们再深入一点。

  假设程序正在执行:

int main()
{
    int a = 10;

    // 正在执行这里
    a++;

    cout << a << endl;
}

  此时 SIGINT 到达,内核决定递达这个信号,于是程序转去执行 handler()。

  那么 handler() 和 main() 是普通的函数调用关系吗?

  不是。

  它们不是简单的:

调用

返回

main()

handler()

main()

  因为 handler() 并不是由 main() 中某条 call 指令主动调用的。

  它们属于两个不同的执行流程:

  • main() 是原本正在执行的主控制流程。
  • handler() 是信号递达时插入执行的信号处理流程。

  内核会保存原来被中断的执行现场,并为信号处理函数准备相应的执行环境。

  因此,handler() 执行结束后,程序才能恢复原来的执行现场,继续执行 main()。

  信号处理函数不是普通的函数调用,而是内核安排的一次用户态控制流程切换。

1.5 默认处理、忽略和捕捉有什么区别?

  前面我们学习过信号有三种处理方式。

  现在结合内核态和用户态,再看一次它们的区别。

处理方式内核做什么是否执行用户自定义函数
默认处理根据默认动作处理信号否
忽略信号丢弃该次信号,不执行自定义处理否
捕捉信号准备用户态执行环境,执行 handler是

  例如:

  • 如果 SIGINT 使用默认动作,进程通常会被终止。
  • 如果 SIGINT 被忽略,进程不会执行自定义函数,而是继续原来的执行流程。
  • 如果 SIGINT 被捕捉,进程会执行我们注册的 handler()。

  因此,只有自定义捕捉需要切换到用户态执行用户编写的处理函数。


二、sigreturn:handler 执行完后,如何回到 main?

  前面已经知道,信号处理函数执行结束后,并不会简单地直接跳回 main()。

  它还需要经过一个特殊的恢复过程。

2.1 为什么需要 sigreturn?

  假设程序正在执行:

int main()
{
    int a = 10;

    a++;

    cout << a << endl;
}

  执行到 a++ 附近时,突然发生了信号递达。

  内核需要暂时中断原来的执行流程,转去执行 handler()。

  那么问题来了:

  handler 执行完以后,CPU 怎么知道原来的程序应该从哪里继续执行?

  程序当时的执行现场不仅仅是一个函数地址,还包括:

  • CPU 寄存器的值。
  • 程序计数器,也就是接下来要执行的位置。
  • 栈指针。
  • 原来的信号屏蔽字。
  • 其他恢复执行所需要的上下文信息。

  如果这些信息没有保存好,程序就无法正确恢复原来的执行状态。

  因此,内核需要在递达信号时保存必要的执行现场。

  当 handler 执行结束后,再通过特殊的信号返回机制进入内核,恢复之前保存的信息。

  这个过程就涉及 sigreturn。

2.2 sigreturn 是怎么工作的?

  可以把它分成三个阶段。

  第一阶段:保存现场。

  内核发现有信号需要捕捉时,会保存当前用户程序的执行上下文,并在用户栈上准备信号帧。

  第二阶段:执行 handler。

  内核设置好用户态的执行环境,让 CPU 返回用户态执行我们注册的信号处理函数。

  第三阶段:恢复现场。

  handler 执行结束后,通过预先安排的返回路径触发信号返回机制,进入内核。

  内核根据之前保存的信号帧,恢复原来的执行上下文。

main() 正在执行

信号递达

内核保存原执行现场

准备信号帧和返回路径

用户态执行 handler()

handler 正常返回

执行信号返回机制

再次进入内核

恢复原来的执行现场

返回用户态

main() 从原位置继续执行

  这里的核心就是:

  sigreturn 负责让内核恢复信号处理前保存的执行现场,而不是简单地返回某个函数地址。

2.3 handler 是如何自动执行 sigreturn 的?

  我们平时写信号处理函数时:

void handler(int signo)
{
    // 处理信号
}

  函数结束时,并没有手动写:

sigreturn();

  那为什么它还能自动进入内核呢?

  因为内核在准备信号处理环境时,已经安排好了处理函数结束后的返回路径。

  可以把它理解为:

在这里插入图片描述

  当 handler() 正常返回时,会沿着预先安排好的返回路径执行。

  在 Linux 中,这条路径通常涉及信号 trampoline。它会通过相应的系统调用入口触发信号返回机制。

  在不同架构和系统实现中,具体的 trampoline 形式可能不同,例如可能由 C 库提供,也可能利用内核提供的 vDSO 机制。

  所以,我们不需要在自己的 handler() 中手动调用 sigreturn()。

  内核和运行时环境已经为我们准备好了这条返回路径。


三、sigaction:注册信号处理函数

  前面我们使用过 signal() 注册信号处理函数。

  但是在 Linux 编程中,更推荐使用 sigaction()。

3.1 函数原型

#include <signal.h>

int sigaction(
    int signo,
    const struct sigaction *act,
    struct sigaction *oact
);

  sigaction() 可以读取或者修改指定信号的处理动作。

  三个参数分别表示:

参数含义
signo指定要操作的信号编号
act新的信号处理动作
oact保存原来的信号处理动作

  返回值:

  • 成功返回 0。
  • 失败返回 -1,并设置 errno。

  其中:

  • act 不为空:根据 act 修改信号处理动作。
  • oact 不为空:通过 oact 获取原来的信号处理动作。

  如果只想设置处理动作,可以把 oact 设置为 nullptr。

  如果只想获取原来的处理动作,可以把 act 设置为 nullptr。

3.2 struct sigaction 结构体

  sigaction() 的核心是 struct sigaction 结构体。

  它包含几个重要成员:

  1. sa_handler

  指定信号的处理方式。

  它有三种常见情况:

sa.sa_handler = SIG_DFL;   // 默认处理
sa.sa_handler = SIG_IGN;   // 忽略信号
sa.sa_handler = handler;   // 自定义处理函数

  当指定为自定义函数时,函数的形式为:

void handler(int signo)
{
    // 处理信号
}

  参数 signo 表示当前递达的信号编号。

  这样,我们就可以让同一个处理函数处理多种不同的信号。

  例如:

void handler(int signo)
{
    if (signo == SIGINT)
    {
        // 处理 SIGINT
    }
    else if (signo == SIGQUIT)
    {
        // 处理 SIGQUIT
    }
}

  这也是一种回调机制:

  我们提前把函数注册给内核,等到信号递达时,再由系统安排执行,而不是由 main() 主动调用。

  2. sa_mask

  指定执行信号处理函数期间,额外需要屏蔽的信号集合。

  注意:当某个信号处理函数正在执行时,当前信号默认也会被自动屏蔽。

  例如:

sigemptyset(&sa.sa_mask);
sigaddset(&sa.sa_mask, SIGQUIT);

  表示在执行这个信号处理函数期间,除了当前信号默认会被屏蔽之外,还额外屏蔽 SIGQUIT。

  当处理函数结束后,内核会恢复之前的信号屏蔽字。

  3. sa_flags

  用于设置一些信号处理选项。

  例如:

sa.sa_flags = 0;

  表示不启用额外选项。

  还有一些常用选项:

  • SA_RESTART:在符合条件时,让部分被信号中断的系统调用自动重新启动。
  • SA_NODEFER:执行处理函数时,不自动屏蔽当前正在处理的信号。
  • SA_SIGINFO:使用带有额外信息参数的信号处理函数。

  4. sa_sigaction

  当设置 SA_SIGINFO 时,可以使用 sa_sigaction 指定带有三个参数的处理函数。

  形式如下:

void handler(int signo, siginfo_t *info, void *context)
{
    // 处理信号
}

  它可以获得更多信号相关信息。

  注意:sa_handler 和 sa_sigaction 在结构体中属于联合使用的处理函数入口,不能把它们理解成两个完全独立、可以同时生效的处理函数。

3.3 为什么执行 handler 时要屏蔽当前信号?

  假设我们注册了一个 SIGINT 处理函数:

void handler(int signo)
{
    // 处理 SIGINT
}

  现在发生了这样的情况:

  1. 第一次 SIGINT 到达。
  2. 内核决定递达信号,开始执行 handler()。
  3. handler() 还没有执行完,第二次 SIGINT 又产生了。

  如果允许第二次信号立即打断第一次处理过程,就可能出现处理函数不断嵌套的情况。

  因此,系统默认会在处理某个信号时,将当前信号加入屏蔽字。

  这样,第二次相同信号就会暂时处于阻塞状态,等待当前处理函数结束后再处理。

当前 SIGINT 自动被屏蔽

handler() 执行结束

第一次 SIGINT 到达

执行 handler()

第二次 SIGINT 到达

进入 Pending 状态

恢复原来的屏蔽字

第二次 SIGINT 才有机会被递达

  这里还要结合前面学过的常规信号合并规则:

  如果第二次以及更多次 SIGINT 在第一次处理结束前重复产生,常规信号通常只保留一个未决状态,并不会无限排队。

  另外,sa_mask 可以让我们指定处理函数执行期间还要额外屏蔽哪些信号。


四、动手实践:使用 sigaction 捕捉信号

  理论讲了这么多,还是得写点代码。

  下面通过一个小程序,观察 sigaction() 的使用方式。

4.1 完整代码

#include <iostream>
#include <signal.h>
#include <unistd.h>

using namespace std;

void handler(int signo)
{
    const char msg[] = "捕捉到了 SIGINT 信号!\n";

    // write() 是异步信号安全函数
    write(STDOUT_FILENO, msg, sizeof(msg) - 1);
}

int main()
{
    struct sigaction sa;

    // 设置自定义处理函数
    sa.sa_handler = handler;

    // 初始化信号集合为空
    sigemptyset(&sa.sa_mask);

    // 不设置额外选项
    sa.sa_flags = 0;

    // 注册 SIGINT 的处理动作
    if (sigaction(SIGINT, &sa, nullptr) == -1)
    {
        perror("sigaction");
        return 1;
    }

    while (true)
    {
        cout << "主程序正在运行..." << endl;
        sleep(1);
    }

    return 0;
}

  编译运行:

在这里插入图片描述

  运行后按下 Ctrl+C,可以看到:

在这里插入图片描述

  可以观察到:

  • Ctrl+C 产生 SIGINT。
  • 内核检查到信号可以递达。
  • 程序执行我们注册的 handler()。
  • 处理函数结束后,程序继续执行原来的主控制流程。

五、操作系统是怎么运行起来的?

  前面我们知道,内核会在准备返回用户态时检查信号。

  但这里又产生了一个问题:

  一个进程正在执行用户代码,凭什么会突然进入内核态?

  如果程序一直执行:

while (true)
{
}

  它又没有主动调用系统调用,操作系统怎么获得执行机会?

  这就需要理解操作系统的中断机制。

5.1 硬件中断:外设主动通知 CPU

  我们在键盘上输入文字,程序并没有一直主动询问键盘:

键盘有数据了吗?
键盘有数据了吗?
键盘有数据了吗?

  这种不断询问的方式叫作轮询。

  如果设备很多,CPU 就得花费大量时间反复检查设备状态。

  有没有更好的办法?

  有。

  让外部设备准备好之后,通过中断机制通知 CPU。

  例如:

  • 键盘接收到按键输入。
  • 网卡接收到网络数据。
  • 定时器产生时钟事件。

  它们可以通过中断机制通知 CPU。

5.2 CPU、设备控制器和中断控制器

  这里有三个容易混淆的概念。

  1. 设备控制器

  负责控制具体的硬件设备。

  例如:

  • 键盘控制器。
  • 磁盘控制器。
  • 网卡控制器。

  设备控制器通常包含一些寄存器,用于保存设备状态、控制命令和数据等信息。

  2. 中断控制器

  负责管理来自不同设备的中断请求,并向 CPU 转发相应的中断信息。

  在早期 x86 系统中,常见的是 8259A 中断控制器。

  现代 PC 通常使用 APIC 等更复杂的中断控制机制。

  3. CPU

  负责响应中断,并根据中断信息进入相应的处理流程。

在这里插入图片描述

  这里需要注意:

  不是所有硬件事件都必须经过同一种外部中断控制器。例如,现代系统中的本地定时器、处理器内部事件等,可能使用不同的中断路径。

5.3 CPU 的中断引脚是什么?

  CPU 和外部硬件之间需要通过电路进行连接。

  在传统硬件设计中,CPU 芯片具有各种引脚,其中包括用于接收中断请求的相关引脚。

  例如:

  • IRQ:可屏蔽中断请求相关信号。
  • NMI:不可屏蔽中断相关信号。

  外部设备产生中断请求后,可以通过相应的硬件连接和中断控制器通知 CPU。

  这里不用深入研究具体电路,只需要理解:

  外部设备能够通过硬件中断机制通知 CPU,而不需要 CPU 一直主动轮询设备。

5.4 硬件中断的基本流程

  以传统 x86 平台为例,可以把硬件中断过程简化为:

外部设备产生中断请求

设备控制器处理相关状态

中断控制器接收请求

通知 CPU

CPU 响应中断

保存必要的执行现场

根据中断向量找到对应处理入口

执行中断处理程序

恢复执行现场

继续原来的执行流程

在这里插入图片描述

  这里有两个重要概念。

  1. 中断向量

  每种中断都有对应的编号,称为中断向量。

  CPU 根据中断向量找到对应的处理入口。

  2. IDT:中断描述符表

  IDT 全称:

Interrupt Descriptor Table

  也就是中断描述符表。

  它保存了 CPU 处理各种中断、异常时需要使用的描述符信息。

  可以把它理解为一张索引表:

在这里插入图片描述

  注意:IDT 并不是简单的 C 语言函数指针数组,而是由 CPU 规定格式的描述符表,每个表项包含处理入口和相关权限、属性等信息。

  记住:中断向量是编号,IDT 负责根据编号找到对应的处理入口。

5.5 硬件中断和 Linux 信号有什么关系?

  前面三篇学习信号时,我们知道:

  信号产生后,内核会记录对应的信号状态,等到合适的时机再处理。

  现在再看硬件中断:

  外部设备产生中断请求后,CPU 会进入对应的中断处理流程。

  两者在思想上有一些相似之处:

硬件中断Linux 信号
中断请求到达信号产生
中断向量提供编号信号编号标识信号
CPU 进入中断处理流程内核管理信号状态
执行中断处理程序执行相应的信号处理动作

  但是一定要注意:

  Linux 信号不是硬件中断本身,也不是简单地用软件模拟硬件中断。两者的实现机制不同,只是在异步通知和延后处理的思想上有相似之处。


六、时钟中断:谁来推动操作系统调度?

  硬件中断解决了外部设备通知 CPU 的问题。

  但是,还有一个更重要的问题:

  操作系统如何知道一个进程运行了多久?又如何决定什么时候切换进程?

  总不能让一个进程一直占着 CPU 不放吧?

  这就需要时钟中断。

6.1 时钟中断是什么?

  系统中有负责产生定时事件的硬件时钟设备。

  它会按照一定的机制产生周期性或可编程的定时中断,通知 CPU:

  又到一个定时处理时机了。

  操作系统可以利用这些定时事件完成:

  • 更新系统计时信息。
  • 统计进程运行时间。
  • 更新调度相关的数据。
  • 在适当时机触发进程调度。

  所以,时钟中断不仅用于计时,还能为操作系统调度提供重要的时间依据。

6.2 时钟中断和 CPU 主频有什么关系?

  这里有一个特别容易混淆的知识点:

  CPU 主频不等于时钟中断频率。

  CPU 主频表示 CPU 内部时钟运行的频率。

  例如,CPU 主频为 3GHz,表示其内部时钟每秒有约 30 亿个周期。

  而时钟中断频率表示定时器产生中断事件的频率。

  例如:

CPU 主频:3GHz

定时器中断频率:1000Hz

  这意味着:

  • CPU 内部时钟每秒运行约 30 亿个周期。
  • 定时器每秒产生约 1000 次中断事件。

  两者完全不是一个概念。

  现代 Linux 也不一定需要依靠固定频率的周期性时钟中断完成所有计时和调度工作,还存在高精度定时器、动态时钟机制和 tickless 等设计。

6.3 时间片与进程调度

  我们知道,多个进程需要共享 CPU。

  操作系统会根据调度策略决定哪个进程获得 CPU。

  在简化的时间片模型中:

不需要

需要

进程 A 获得 CPU

开始运行

时钟中断到来

更新进程运行时间

检查调度条件

继续运行

进行调度

切换其他进程

  例如,一个进程被分配了一定的运行时间。

  当时钟中断到来时,内核会更新相关计数信息。

  如果当前进程仍然可以继续运行,就可能返回原进程继续执行。

  如果时间片耗尽,或者出现其他需要重新调度的情况,调度器就可能选择其他合适的进程运行。

6.4 时钟中断到来,一定会切换进程吗?

  不一定。

  这是一个非常重要的易错点。

  时钟中断到来后,内核会处理定时事件,但不代表每次都会进行进程切换。

  例如:

不需要

需要

时钟中断到来

进入内核态

更新计时和调度信息

检查是否需要调度

返回当前进程继续

调度器选择下一个进程

  可能触发调度的情况包括:

  • 当前进程的运行时间已经达到调度要求。
  • 有更适合运行的进程变为就绪状态。
  • 当前进程主动阻塞。
  • 当前进程主动让出 CPU。
  • 当前进程结束。

  具体调度时机和策略取决于内核版本及调度器实现。

在这里插入图片描述

6.5 从 Linux 0.11 源码理解时钟中断

  前面讲的是基本原理。

  现在结合 Linux 0.11 的源码,看一下早期 Linux 是如何处理时钟中断的。

  在调度程序初始化时,可以看到类似这样的代码:

在这里插入图片描述

  这段代码的作用是:

  将时钟中断对应的中断向量与 timer_interrupt 入口关联起来。

  随后,时钟中断发生时,会进入相应的处理流程。

  在 Linux 0.11 的经典实现中,时钟中断处理过程中会调用:

do_timer(cpl);

  do_timer() 会处理计时和调度相关工作。

  其中一个重要逻辑是检查当前任务的时间片:

void do_timer(long cpl)
{
    if ((--current->counter) > 0)
        return;

    schedule();
}

  这里是为了理解早期调度机制而进行的简化展示。

  可以看到:

  1. 时钟中断到来。
  2. 当前任务的时间片计数递减。
  3. 如果时间片还没有耗尽,就直接返回。
  4. 如果时间片耗尽,就进入 schedule()。
  5. 调度器选择下一个任务,并通过上下文切换机制切换执行任务。

  这段代码最重要的地方,就是时钟中断到来不代表一定切换进程,是否调用调度器还要看具体条件。

6.6 死循环为什么也会进入内核?

  回到前面的问题:

while (true)
{
}

  这个程序没有主动调用系统调用,为什么仍然可以被调度?

  因为时钟中断。

用户态执行死循环

时钟中断到来

CPU 进入内核态

内核处理时钟事件

更新调度信息

判断是否需要调度

准备返回用户态

  所以,即使用户程序一直在执行死循环,CPU 仍然可以通过时钟中断进入内核。

  而当内核准备返回用户态时,也就有机会检查当前进程是否存在可以递达的信号。

  这就把前面信号捕捉的知识串起来了。

  还有一个值得了解的现象:

  为什么死循环可能让电脑变卡?

  因为一个不主动阻塞、持续执行计算的进程,会不断消耗 CPU 时间。

  例如:

while (true)
{
    // 一直计算,不主动休眠
}

  如果它长期占用某个 CPU 核心,就可能让这个核心的使用率接近 100%。

  其他进程仍然可以被调度,但能够分到的 CPU 时间可能受到影响,于是系统响应速度就会下降。


七、异常与陷阱:软件也能让 CPU 进入内核

  除了外部硬件中断,还有两类非常重要的机制:

  • 异常(Exception)
  • 陷阱(Trap)

  它们也能够让 CPU 从用户态进入内核态。

7.1 异常:CPU 执行指令时发生的问题

  异常通常由 CPU 在执行指令的过程中检测到。

  例如:

int a = 10;
int b = 0;

int c = a / b;

  在 x86 等处理器上,除法指令发生除零错误时,CPU 会产生相应的异常。

  再比如:

int* p = nullptr;

*p = 100;

  如果程序访问了不允许访问的地址,就可能触发页故障等异常,最终由内核根据具体情况处理。

  异常的基本流程:

CPU 执行用户程序

发现异常情况

CPU 进入异常处理流程

内核执行对应异常处理程序

根据异常类型进行处理

返回用户态

采取其他处理

  这里需要特别注意:

  不是所有异常都会导致进程收到信号。

  例如缺页异常。

  当程序访问一个合法的虚拟地址,但对应页面暂时不在物理内存中时,CPU 可能产生缺页异常。

  内核检查后,如果发现这是合法访问,就可以把需要的页面调入内存、更新页表,然后让程序继续执行。

  只有当访问行为不合法等情况下,内核才可能向进程发送 SIGSEGV。

  常见情况:

情况可能的结果
除零错误SIGFPE
非法内存访问SIGSEGV
合法缺页内核完成缺页处理,程序继续执行

  因此:

  缺页异常不等于段错误。

7.2 异常是同步的,硬件中断是异步的

  这两个概念也很重要。

  异常通常是同步的。

  它与当前正在执行的指令有关。

  例如:

int a = 10 / 0;

  CPU 执行对应的除法指令时,发现除数为零,于是产生异常。

  硬件中断通常是异步的。

  它来自外部设备,不一定与 CPU 当前正在执行的某条指令有直接关系。

  例如:

  • 键盘产生输入。
  • 网卡收到数据。
  • 定时器产生中断。

  可以简单理解为:

类型产生来源与当前指令的关系
异常CPU 内部检测到异常情况通常与当前指令有关
硬件中断外部设备或定时器等通常与当前指令无关

7.3 陷阱:软件主动请求内核服务

  前面讲的异常,通常是 CPU 在执行指令时发现问题。

  那么,软件能不能主动让 CPU 进入内核?

  当然可以。

  例如,我们调用:

read(fd, buf, size);

  程序希望内核帮我们读取文件。

  用户程序不能直接随意执行内核代码,因此需要通过 CPU 提供的系统调用入口进入内核。

  在 x86 体系结构中,有不同的入口机制。

  例如:

  • 传统 32 位 Linux x86 系统常见 int 0x80。
  • 现代 x86-64 Linux 通常使用 syscall 指令。

  它们都能让程序进入内核,但底层机制并不完全相同。

  这里可以先记住:

  系统调用是一种由用户程序主动发起的内核服务请求。


八、系统调用:为什么我们看不到 syscall?

  前面我们提到:

syscall

  或者:

int 0x80

  那么问题来了:

  平时我们写 C/C++ 程序时,明明是这样调用的:

open("test.txt", O_RDONLY);

  或者:

fork();

  为什么没有看到 syscall 呢?

  因为中间还有一层封装。

8.1 glibc 帮我们封装了系统调用

  在 Linux 用户程序中,我们经常使用:

open();
read();
write();
fork();
close();

  这些接口通常由 C 库提供。

  以 glibc 为例,它会为相应的系统调用提供封装函数。

  因此,我们可以直接调用:

read(fd, buf, size);

  而不需要自己手动编写汇编指令。

  可以把整个过程理解为:

调用 read()

准备系统调用号和参数

用户程序

glibc 封装函数

执行 syscall 指令

进入内核态

内核根据系统调用号找到对应处理函数

执行系统调用

返回用户态

glibc 返回结果

用户程序继续执行

  所以,我们平时调用的 read() 并不是直接跳到内核函数执行,而是通过库函数封装和系统调用机制进入内核。

8.2 系统调用号是什么?

  内核需要知道:

  用户到底想让内核执行哪一个系统调用?

  于是就有了系统调用号。

  可以把它理解成一个编号。

系统调用号

系统调用分派机制

找到对应的内核处理函数

  在早期 Linux 内核中,可以看到类似这样的系统调用表:

fn_ptr sys_call_table[] =
{
    sys_setup,
    sys_exit,
    sys_fork,
    sys_read,
    sys_write,
    sys_open
};

  这只是早期 Linux 内核源码中的示意。

  假设某个系统调用号对应下标 3,那么内核就可以根据这个编号找到对应的处理函数。

  这里有两个重要概念:

  • 系统调用号:告诉内核要执行哪个系统调用。
  • 系统调用分派机制:根据系统调用号找到对应的处理入口。

在这里插入图片描述

8.3 系统调用参数和返回值放在哪里?

  以 x86-64 Linux 为例,系统调用需要通过寄存器传递系统调用号和参数。

  常见约定如下:

寄存器用途
rax系统调用号,返回时保存结果
rdi第一个参数
rsi第二个参数
rdx第三个参数
r10第四个参数
r8第五个参数
r9第六个参数

  所以,系统调用并不是简单地把一个函数地址交给 CPU。

  而是通过约定好的寄存器传递调用号和参数,再由内核完成对应的处理。


九、内核态与用户态:为什么需要权限隔离?

  前面反复出现两个概念:

  • 用户态
  • 内核态

  它们到底有什么区别?

9.1 用户态与内核态是什么?

  CPU 为了保证系统安全,提供了不同的特权级别。

  在 x86 体系结构中,常见的特权级别有:

Ring 0:最高特权级别
Ring 1
Ring 2
Ring 3:最低特权级别

  Linux 通常使用:

  • Ring 0 执行内核代码。
  • Ring 3 执行普通用户程序。

  对应到我们经常说的:

状态执行内容权限
用户态用户程序权限受限
内核态操作系统内核代码拥有更高权限

  CPU 当前的特权级别可以通过相关处理器状态信息判断,在 x86 保护模式下涉及 CPL(Current Privilege Level)等概念。

  一般来说:

CPL = 3:用户态
CPL = 0:内核态

9.2 为什么用户程序不能直接执行内核代码?

  假设一个普通程序可以随意访问硬件、修改页表、操作其他进程的内存。

  那么整个系统就会变得非常危险。

  一个普通程序出现错误,就可能影响整个操作系统。

  所以,CPU 和操作系统需要进行权限隔离。

  用户程序如果需要访问内核提供的功能,就必须通过规定的入口进入内核。

  例如:

read();
write();
fork();

  这些系统调用会通过相应的机制进入内核,内核检查参数、权限和资源状态,再决定是否执行对应操作。

  用户程序不能随意跳转到内核代码执行,必须经过 CPU 和操作系统规定的入口与权限检查。

9.3 内核空间是不是每个进程都有一份?

  这是一个很容易混淆的问题。

  我们经常会看到这样的经典地址空间示意图:

32 位进程虚拟地址空间(4GB)

内核空间
3GB ~ 4GB

大小:1GB

用户空间
0GB ~ 3GB

大小:3GB

  这是经典的 32 位 Linux 地址空间划分示意,并不是所有 Linux 系统都固定使用 3GB/1GB 的划分。

  在传统的内核映射模型中,不同进程的地址空间会映射同一套内核代码和相关内核区域。

  因此,当进程切换时,内核仍然能够在相应的内核映射中运行。

  不过要注意:

  内核空间的代码和全局映射可以被多个进程共享,但每个进程仍然有自己的进程状态、内核栈等数据。

  每个进程有自己的用户空间,而操作系统内核是整个系统共享的核心管理部分,并不是每个进程都拥有一套独立的操作系统。


十、为什么信号捕捉需要进入内核?

  现在回到本篇最初的问题。

  为什么信号处理函数明明是用户代码,却需要内核参与?

  因为信号的整个生命周期都需要内核管理。

  我们把过程重新串起来:

在这里插入图片描述

  这一整套流程说明:

  • 内核负责管理信号。
  • 内核负责判断信号是否可以递达。
  • 用户处理函数在用户态执行。
  • 内核负责在处理函数返回后恢复原来的执行现场。

十一、本篇重点总结

11.1 核心知识点

知识点需要掌握的内容
信号捕捉执行用户自定义信号处理函数
信号检查时机内核准备返回用户态等适当时机
handler 执行位置用户态
信号帧保存信号处理前的执行上下文
sigreturn恢复信号处理前的执行现场
sigaction读取或修改信号处理动作
sa_handler指定默认、忽略或自定义处理函数
sa_mask指定处理函数执行期间额外屏蔽的信号
sa_flags设置额外的信号处理选项
硬件中断外部设备通过中断机制通知 CPU
中断控制器管理和转发外部设备的中断请求
IDT保存中断和异常处理所需的描述符
时钟中断为计时和进程调度提供时间依据
异常CPU 执行指令时检测到的异常情况
系统调用用户程序通过规定入口请求内核服务
用户态与内核态CPU 执行权限不同的两种状态

11.2 容易出错的地方

错误说法正确理解
信号一产生就立即执行 handler信号需要经过内核检查,在适当时机递达
handler 是 main 主动调用的handler 是内核安排执行的信号处理流程
内核直接在内核态执行 handlerhandler 在用户态执行
handler 默认使用独立栈默认使用当前线程的用户栈,也可以配置备用信号栈
handler 执行完直接回到 main通过信号返回机制恢复原来的执行现场
所有异常都会产生信号合法缺页等异常可以由内核正常处理
IDT 就是普通函数指针数组IDT 是 CPU 规定格式的中断描述符表
时钟中断频率等于 CPU 主频两者是不同概念
时钟中断每次都会切换进程只有满足调度条件时才会发生调度
死循环不会进入内核时钟中断等机制仍然可以让 CPU 进入内核
syscall 就是普通函数调用它是 CPU 提供的系统调用入口指令
用户程序可以随意执行内核代码必须经过规定的入口和权限检查

结语:

  今天的内容到这里就结束了,希望你能有所收获~

干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/Huang_jin007/article/details/170245496

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--