在这里插入图片描述

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


概要&序論

  Hello,大家好我是此方,今天继续介绍信号是如何产生的问题。上一篇我们介绍了信号的两种产生机制:键盘产生和系统调用/命令产生。本文将讲解剩下两类信号产生机制——“硬件异常”与“软件条件”。同时带大家初步接触“时钟中断”等内容。

一、 硬件异常产生信号

1.1什么是硬件异常

  在 Linux 操作系统中,程序的崩溃或异常终止(如除0错误、野指针访问)会触发硬件异常,CPU 首先捕捉到硬件异常,并立刻通过中断机制将其移交给操作系统内核。

信号由操作系统发送。
在这里插入图片描述

1.2硬件异常举例

1.2.1浮点数错误

  8号信号:SIGFPE (Floating-point exception)浮点数异常。

在这里插入图片描述

1.2.2段错误

  11号信号:SIGSEGV (Segmentation violation / Segmentation fault)段错误。
在这里插入图片描述

1.3 CPU是怎么知道硬件错误

  操作系统作为软硬件资源的管理者,之所以能知道进程的犯错,得益于 CPU 内部各种寄存器和硬件单元的实时监控

在这里插入图片描述

1.3.1算术溢出与除0错误

  当程序执行 a /= 0 时,CPU 的算术逻辑单元(ALU)在计算时会触发异常,并自动将状态寄存器(如 x86 的 EFLAGS 寄存器)中的特定标志位(例如溢出或错误标志)置位。

1.3.2 内存访问段错误

  当程序尝试访问 int *p = nullptr; *p = 100; ,负责地址翻译的集成硬件 MMU(内存管理单元) 会拿着该虚拟地址和 CR3 寄存器 中保存的当前进程页表物理地址去进行转换。MMU 在将虚拟地址转换为真正的物理地址时,主要根据页表项中的“权限和状态”来判断访问是否合法。

1.4 MMU 查表时的权限与状态判定

1.4.1 根本没有映射(空指针引发的未分配错误)

  页表项中有一个最关键的位叫 Present(存在位,通常是 Bit 0)

  • Present = 1: 代表该虚拟地址已经映射了真实的物理内存。
  • Present = 0: 代表这块虚拟地址在物理内存中根本不存在。

当 MMU 发现对应行的 Present 位为 0 时,硬件逻辑电路就会自动触发中断信号(硬件报错)。

1.4.2 有映射但越界或越权(非法访问限制区域)

  即使地址存在映射,页表项中还包含 R/W(读写权限位)U/S(用户/内核权限位)

  • 如果代码企图去修改(写)一个只读的常量字符串(R/W 位为 0),MMU 就会报错。
  • 如果普通应用程序(用户态)企图去访问内核才能看的地址(U/S 位为 0),MMU 同样会报错。

1.5 操作系统的接管与进程上下文读取

  当 MMU 或 CPU 触发硬件报错后,CPU 会瞬间暂停当前正在执行的应用程序,强行切换到操作系统的缺页异常处理程序。此时,操作系统必须立刻、无条件地读取当前进程的上下文(即内核中的 current->task_struct 以及 CPU 寄存器),以此弄清楚两件事:是谁在哪个地方犯了错?

在 Linux 内核源码中,current 是一个极其常用的宏或者指针。它永远指向当前正在 CPU 上执行的那个进程的 task_struct。

  操作系统会读取以下一组特殊的寄存器:

  • CR2 寄存器: 这个寄存器极其重要!MMU 报错时,会自动把导致报错的那个虚拟地址塞进 CR2 里。操作系统读取它,才能知道程序刚才是在访问哪块内存时翻车的。
  • 程序计数器(CS:RIP / EIP): 操作系统要看看底是哪一行代码(哪条指令)触发了这次内存访问。
  • 通用寄存器和错误码(Error Code): 搞清楚程序当时是在执行“读”还是“写”,以及当时是在用户态还是内核态。

1.6 合法与非法的甄别,产生信号

  拿到上下文并读取 CR2 里的虚拟地址后,操作系统会去对比当前进程的 VMA(虚拟内存区域,即 vm_area_struct 链表) 来甄别这次报错是否合法:

  • 合法错误(真正的缺页): 比如使用 mmap 分配了虚拟内存,但在第一次读写时物理内存还没落实(Present = 0)。操作系统会执行补齐页表的操作,分配物理内存并建立映射,随后恢复上下文,重新执行那条代码,程序得以“复活”。
  • 非法错误(真正的越界/野指针): 如果操作系统发现该虚拟地址根本不在进程合法的虚拟内存区域内,或者违反了读写权限。操作系统就会将其判定为非法,并向目标进程发送 SIGSEGV(段错误) 信号;若是除0错误,则发送 SIGFPE(浮点异常) 信号。最终强行杀死进程,造成程序崩溃。

二、软件条件产生信号

2.1管道产生信号

  软件条件产生信号的方式有很多种,我们曾经学过管道产生信号的方式。Re:Linux系统篇(三十七)通信篇·二:深入理解匿名管道:父子进程是如何“看到同一个文件”

在这里插入图片描述
  操作系统不会做任何浪费资源的事情。当读端关闭的时候,写段任在写入就毫无意义。此时操作系统必须发送信号SIGPIPE终止进程。

2.2闹钟产生信号

2.2.1先见一见闹钟怎么用

  这一块本来应该放在时钟中断讲,但是放在这里也可以。

在这里插入图片描述
在这里插入图片描述

  补充一下pause系统调用,让这个进程处于暂停状态。
在这里插入图片描述

#include<iostream>
#include<unistd.h>
#include <signal.h>
void Hander(int i )
{
    std::cout<<"收到一个信号"<< i <<std::endl;
}
int main()
{
    for(int i = 0 ; i < 32 ; i++)
        signal(i,Hander);
    alarm(10);
    std::cout<<"定一个闹钟"<<std::endl;
    std::cout<<"开始睡觉"<<std::endl;
    pause();
    std::cout<<"睡醒"<<std::endl;
    return 0 ;
}

  如上。我们让进程设置一个闹钟。时间到了就会发送14号信号。终止进程。

在这里插入图片描述

2.2.2闹钟的用法详细介绍

调用 alarm(seconds) 后,系统开始倒计时。

  • 时间到了会怎样? 系统会向你的程序发送一个 SIGALRM 信号
  • 默认后果:如果你的程序没有提前做好准备,收到这个信号后会直接退出(终止运行)
  • 一次只能设一个闹钟:如果先调用了 alarm(10),紧接着又调用了 alarm(3),前一个闹钟会被直接覆盖。程序只会在 3 秒后收到闹钟信号。
  • 如何取消闹钟:调用 alarm(0) 即可直接关闭倒计时,不会再触发信号。

2.2.3递归闹钟与操作系统的基本运行原理

  我们让闹钟执行的自定义捕捉函数调用新的闹钟。让每一个闹钟响起后执行随机的工作

#include<iostream>
#include<unistd.h>
#include <signal.h>
#include<time.h>
#include<vector>
#include<functional>
std::vector<std::function<void (int)>> work ;
void func_1(int x) { std::cout << "我是一个网络任务,参数为: " << x << std::endl; }
void func_2(int x) { std::cout << "我是一个磁盘任务,参数为: " << x << std::endl; }
void func_3(int x) { std::cout << "我是一个日志任务,参数为: " << x << std::endl; }
void func_4(int x) { std::cout << "我是一个计算任务,参数为: " << x << std::endl; }
void func_5(int x) { std::cout << "我是一个数据库任务,参数为: " << x << std::endl; }
void Hander(int i )
{
    std::cout<<"###########################"<<std::endl;
    work[rand()%5](rand()%100);
    alarm(1);
    std::cout<<"###########################"<<std::endl;
}
int main()
{
    work.push_back(func_1);
    work.push_back(func_2);
    work.push_back(func_3);
    work.push_back(func_4);
    work.push_back(func_5);
    srand((unsigned int)time(NULL));
    for(int i = 0 ; i < 32 ; i++)
        signal(i,Hander);
    alarm(1);
    //std::cout<<"定一个闹钟"<<std::endl;
    //std::cout<<"开始睡觉"<<std::endl;
    while(true)
        pause();
    //std::cout<<"睡醒"<<std::endl;
    return 0 ;
}

在这里插入图片描述

  进程可以处于暂停状态,通过外部信号传达实现不同功能的调用。——这就是操作系统,以及操作系统去做进程管理和内存管理等工作的基本原理

  比如拿进程管理为例子,进程被创建好之后PCB就被push_back到一个管理结构中,然后操作系统在运行的时候会去遍历这个管理结构,找到时间片的下一个进程,然后执行他。

  操作系统的本质就像是我们这个进程,登录以后就处于一种死循环。操作系统不会去主动执行这些任务。alarm () 间隔时间为0…0几秒,这样的时间间隔,我们的操作系统就会以非常高的频率来执行我们的任务!
  操作系统居然不是主动干活的

后面会讲这就是时钟中断

2.2.4时间戳与时间片

  时间戳的本质就是一个计数器,外部每一秒刺激操作系统一次,操作系统调用一次就++,实时记录操作系统一共开机了多少时间 (操作系统自启动后的时间间隔)

在这里插入图片描述

2.2.5操作系统对闹钟的先描述再组织

  OS内会不会同时存在很多的闹钟!OS要不要对闹钟进行管理?先描述,在组织!
  闹钟结构体的伪代码:

struct timer_list {
    struct list_head entry;
    unsigned long expires;

    void (*function)(unsigned long);
    unsigned long data;

    struct tvec_t_base_s *base;
};

  我们的很多闹钟会被按照这个时间间隔值(unsigned long expires;(操作系统自启动后的时间间隔))进行建小堆,操作系统对比自己的时间间隔值和闹钟的时间间隔值:1000<1005 不响。1006>1005响。然后调用闹钟的函数,传递信号SIGALRM

在这里插入图片描述

  新建一个闹钟就是在堆里面插入一个闹钟结构体。但是实际上在内核中,并没有使用这个堆。

  闹钟产生信号的这个过程没有硬件参与,这就是软件条件触发信号的另一种方式。扯的有点远了

  信号的产生。我们就讲完了。

在这里插入图片描述

在这里插入图片描述


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

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

更多推荐