Linux下 信号的保存与捕捉

欢迎来到我的频道 【点击跳转专栏】

码云链接 【点此转跳】

文章目录

  • [1. 信号的保存](#1. 信号的保存)
    • [1.1 信号其他相关常⻅概念](#1.1 信号其他相关常⻅概念)
    • [1.2 在内核中的表⽰(即保存的原理)](#1.2 在内核中的表⽰(即保存的原理))
    • [1.3 sigset_t(OS提供的类型)](#1.3 sigset_t(OS提供的类型))
    • [1.4 相关函数](#1.4 相关函数)
      • [1. 信号集的5个操作函数](#1. 信号集的5个操作函数)
      • [2. sigprocmask](#2. sigprocmask)
      • [3. sigpending](#3. sigpending)
      • [4. 测试代码和效果](#4. 测试代码和效果)
      • [5. 由测试代码所得出的几个结论](#5. 由测试代码所得出的几个结论)
  • [2. 信号的捕捉](#2. 信号的捕捉)
    • [2.1 用户态和内核态](#2.1 用户态和内核态)
    • [2.2 信号自定义捕捉流程](#2.2 信号自定义捕捉流程)
    • [2.3 操作系统是怎么运⾏的(质变话题!)](#2.3 操作系统是怎么运⾏的(质变话题!))
      • [2.3.1 硬件中断](#2.3.1 硬件中断)
      • [2.3.2 时钟中断](#2.3.2 时钟中断)
      • [2.3.3 当前的时钟中断(读完2.3.2后读)](#2.3.3 当前的时钟中断(读完2.3.2后读))
      • [2.3.4 死循环](#2.3.4 死循环)
      • [2.3.5 软中断](#2.3.5 软中断)
      • [2.3.6 异常](#2.3.6 异常)
      • 中断总结
    • [2.4 如何理解内核态和⽤⼾态](#2.4 如何理解内核态和⽤⼾态)
    • [2.5 一个问题](#2.5 一个问题)
    • [2.6 通过信号简单模拟OS](#2.6 通过信号简单模拟OS)
    • [2.7 sigaction](#2.7 sigaction)

1. 信号的保存

当前阶段:

1.1 信号其他相关常⻅概念

  • 实际执行信号的处理动作称为信号递达(Delivery)
  • 信号从产生到递达之间的状态,称为信号未决(Pending)
  • 进程可以选择阻塞 (Block )某个信号
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
  • ⚠️:阻塞和忽略是不同的, 只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

1.2 在内核中的表⽰(即保存的原理)

信号在内核中的表⽰意图:

  • 每个信号都有两个标志位分别表示阻塞(block)未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
  • SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
  • SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
  • SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler

在Linux中: 常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。我们不讨论实时信号。


1. pending表

pending表:标识信号未决

信号【1,31】

  • 比特位的位置: 信号编号
  • 比特位的内容: 是否未决,即是否收到该信号

这个pending表 本质就是 位图!

cpp 复制代码
// 内核结构 2.6.18
struct task_struct {
...
/* signal handlers */
struct sighand_struct *sighand;
sigset_t blocked

//⚠️:这个就是内核中的`pending表`
struct sigpending pending;
...
}
cpp 复制代码
struct sigpending 
{
struct list_head list;
sigset_t signal;//位图
};

2.block表

block表:

  • 比特位的位置: 信号编号
  • 比特位的内容: 是否阻塞

block表本质也是个位图

task_struct中有该结构:


3.handler表

handler表:

本质可以理解成一个sighandler_t handler[32]的数组,即 一个函数指针的数组 映射的就是信号递达时需要做的任务:

问题1: signal(2,myhandler)底层会做什么?

数组下标-1 在handler表的 1号位置(即2号信号映射的位置),即 直接以信号编号作为索引,在进程控制块(PCB)的信号处理函数表(handler 表)中,将 myhandler 函数的地址写入对应位置。

问题2: 什么叫忽略,什么叫默认?

首先 我们传入的myhandler 属于代码区 地址绝对不属于 0或1 所以我们对 SIG_IGNSIG_DFL 进行以下定义:

问题3: 为什么说在信号还没有产生的时候,进程就已经能识别和处理信号了?

因为在信号还没有产生的时候 ,内置信号的管理和处理方法就已经写在了handler表内了!

在内核中,在task_struct中有handler表:

然后 sighand_struct中的内容为:

然后里面有个k_sigaction的函数指针 其中

cpp 复制代码
// #define _NSIG 64

表示分别为对应1~64号信号的 k_sigaction结构体!

内容为:

里面有个struct sigaction结构体:

该结构体内就有个存储着 信号处理方法的函数指针 __sighandler_t sa_handler;

OS需要让用户控制信号,本质就是访问和操作上的三张表,而这三张表属于内核数据结构!所以提供了系统调用signal

1.3 sigset_t(OS提供的类型)

从上图来看,每个信号只有一个bit未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。

因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为 信号集 , 所以我们可以称pending表pending信号集block表block信号集

这个类型可以表示每个信号的"有效"或"无效"状态,在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞,而在未决信号集中"有效"和"无效"的含义是该信号是否处于未决状态。

而我们只需要把sigset_t 当作无符号的long类型整数即可:

⚠️: 阻塞信号集也叫做当前进程的信号屏蔽字

1.4 相关函数

1. 信号集的5个操作函数

sigset_t类型对于每种信号⽤⼀个bit表⽰"有效"或"⽆效"状态, ⾄于这个类型内部如何存储这些bit则依赖于系统实现, 从使⽤者的⻆度是不必关⼼的, 使⽤者只能调⽤以下函数来操作sigset_ t变量,⽽不应该对它的内部数据做任何解释, ⽐如⽤printf直接打印sigset_t变量是没有意义的。

cpp 复制代码
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
  • 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
  • 函数sigfillset初始化set所指向的信号集, 函数的作用相当于按下了"全选"按钮,它会将信号集里的所有位置都标记为有效(bit位全置1),表示这个集合现在包含了系统支持的所有信号。
  • 注意,在使用sigset_ t类型的变量之前,一定要调用sigemptysetsigfillset做初始化,使信号集处于确定的状态。
  • 初始化sigset_t变量之后就可以在调用sigaddsetsigdelset在该信号集中添加或删除某种有效信号。
  • int signo: 要添加或者删除的信号编号(例如 SIGINT、SIGQUIT 等)。

这四个函数都是成功返回0,出错返回-1sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含 某种信号,若包含则返回1,不包含则返回0,出错返回-1。

2. sigprocmask

调⽤函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)。

cpp 复制代码
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
  1. int how : 决定如何修改当前的信号屏蔽字。通常取以下三个宏之一:
    • SIG_BLOCK: 将 set 中的信号添加 到当前的屏蔽字中(即阻塞这些信号)。相当于 mask = mask | set
    • SIG_UNBLOCK: 从当前的屏蔽字中移除 set 中的信号(即解除阻塞)。相当于 mask = mask & ~set
    • SIG_SETMASK: 将当前的屏蔽字直接替换set 中的值。相当于 mask = set
  2. const sigset_t *set : 指向一个信号集的指针,包含你想要阻塞、解除阻塞或设置为屏蔽字的信号集合。如果此参数为 NULL,则不改变当前的屏蔽字,仅用于查询当前状态(配合 oset 使用)。
  3. sigset_t *oset : 用于保存修改前 的旧信号屏蔽字。如果你不需要保存旧值,可以传入 NULL

3. sigpending

cpp 复制代码
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。
调⽤成功则返回0,出错则返回-1

sigset_t *set: 这是一个输出参数(指针)。函数执行成功后,会将当前进程中所有处于"未决"状态的信号写入该变量指向的内存中。

4. 测试代码和效果

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
void PrintPending(sigset_t &pending)
{
    for (int signo = 31; signo > 0; signo--)
    {
        if (sigismember(&pending, signo))
        {
            std::cout << "1";
        }
        else
        {
            std::cout << "0";
        }
    }
    std::cout << std::endl;
}
void handler(int signo)
{
    std::cout<<"处理完成: "<<signo <<std::endl;
}
int main()
{
    //0.捕捉2号信号
    signal(2,handler);
    // 1. 屏蔽2号信号
    sigset_t block_set, old_set;
    sigemptyset(&block_set);
    sigemptyset(&old_set);

    sigaddset(&block_set, SIGINT);

    int n = sigprocmask(SIG_SETMASK, &block_set, &old_set);
    (void)n;
    std::cout<<"我的pid: "<<getpid()<<std::endl; 

    int cnt = 1;
    while (true)
    {
        sigset_t pending;
        sigemptyset(&pending);
        // 2.获取pending信号集
        n = sigpending(&pending);
        (void)n;
        // 3.打印pending表
        PrintPending(pending);

        if (cnt == 20)
        {
            //2. 解除2号信号屏蔽
            int n = sigprocmask(SIG_SETMASK, &old_set, nullptr);
            (void)n;
        }
        cnt++;
        sleep(1);
    }
}

当向该进程发送2号信号,我们2号进程确实始终处于未决状态,证明了该信号确实被成功阻塞

当20秒后 ,解除对2号信号的阻塞,我们的进程立刻被捕获,打印信息 同时我们发现pending信号集的2号位置由1变0 说明2号信号不再被未决!


有个疑问🤔 pending信号集 是在handler调用前 恢复0 还是调用后??

因为信号handler方法的执行 还是进程自己!所以 我们可以在handler方法内继续获取 handler方法的打印!!

我们把 捕捉函数进行修改:

cpp 复制代码
void handler(int signo)
{
    std::cout<<"处理完成: "<<signo <<std::endl;
    sigset_t pending;
        sigemptyset(&pending);
        // 2.获取pending信号集
       int n = sigpending(&pending);
        (void)n;
        // 3.打印pending表
        PrintPending(pending);
      std::cout<<"这是在handler内打印的"<<std::endl;
}

我们发现正在调研信号处理方法的时候 pending信号集对应的位置就已经置为0了!!

5. 由测试代码所得出的几个结论

我们按照 测试代码 那样一个一个验证(我这里就不写了) 可以得到几个结论

  1. 9、19号信号 不可被捕捉 不可被忽略 同时 不可被阻塞!!!
  2. 一旦我们解除对某个信号的阻塞,该信号就会立即被递达!
  3. pending信号位是在调用 handler处理函数前就已经置为0了!!

2. 信号的捕捉

当前阶段:

2.1 用户态和内核态

当进程调度的时候,从内核态返回用户态的时候,会进行信号的检测的处理!

用户态; 进程执行代码,访问数据,都在访问【0,3GB】地址空间的时候,就是访问用户自己的代码,自己的数据

内核态: 都在访问【3GB,4GB】地址空间的时候,就是访问OS的过程!

内核态的权限级别更高,当我们执行系统调用的过程 其实就是在内核态执行的!

2.2 信号自定义捕捉流程

问题一: 执行信号捕捉方法的时候,以谁的身份来执行。

不能以内核态执行,一旦在信号捕捉时越权操作,就容易出问题,所以必须以用户态身份执行handler方法。

问题二: 具体什么时候处理信号呢?

从内核态返回用户态的时候,用do_signal()进行信号的检测和处理!!

举例信号捕捉流程:

由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:

  1. 用户程序注册了 SIGQUIT 信号的处理函数 sighandler
  2. 当前正在执行 main 函数,这时发生中断或异常切换到内核态。
  3. 在中断处理完毕后要返回用户态的 main 函数之前检查到有信号 SIGQUIT 递达。
  4. 内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行 sighandler 函数,sighandlermain 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
  5. sighandler 函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态。
  6. 如果没有新的信号要递达,这次再返回用户态就是恢复 main 函数的上下文继续执行了。

2.3 操作系统是怎么运⾏的(质变话题!)

一段程序 由ELF加载为进程 然后scanf()阻塞,该进程进入对应的阻塞队列,等待用户输入 进程知道键盘的数据被按下,是因为OS,问题来了: OS怎么知道键盘被按下了??

如果OS自己主动轮询检测 拜托🙏 人家OS可是大忙人 哪有这个功夫 这么做效率太低了! 所以可以通过 中断 去检测!!!

2.3.1 硬件中断

在计算机体系内,输入设备可以把自己一部分信息告诉内存,然后让中央处理器进行处理 ; 其实 输入设备 还可以把自己另一部分信息 直接告诉中央处理器 而这另一部分信息 我们称为中断

在CPU执行进程期间 CPU内的寄存器保存了大量的数据 叫硬件上下文,然后我们对应的某一个外部设备准备好了 比如说键盘键盘准备好后 我们的外部设备会对CPU的特定的针脚触发 特定的电脉冲(高电频) 当CPU识别某个特定的针角 上面有电频 就认为是外设向CPU发送中断了!!这就是 中断的本质!

不过针脚毕竟是有限的 而且大部分针角 还要做数据通信 、内存对接和总线进行勾连 所以CPU得用有限的针角对接无限的外设! 外设在中断角度 其实并没有和CPU直接相连!而是通过一种中断控制器的东西 当外设发送中断 中断控制器会把我们对应的中断 通知给CPU

中断控制器有这么几个作用:

一个中断控制器 可以用一根线 外接更多的设备 跟拓展坞一样 一根线连接CPU 其他多个接口连接外设! 这么做可以拓展我们CPU中断的能力!中断控制器 可以间接帮助我们获取外设触发中断时相关的信息

不过实现中断的前提, CPU本身得支持中断 !得 CPU、中断控制器和外设都支持中断的前提下 才能实现中断!

但是键盘什么时候准备好,CPU是不知道的 所以是 异步产生中断的!

但是网卡传输结束 键盘触发 硬盘读写 未来产生中断的外设一定会很多!

当外设准备好后 会向中断控制器 发送 中断请求,此时中断控制器 如果假设 线1有高电频 此时中断控制器就能判断是键盘,同理 如果是线2有高电频,此时就能判断出是网卡 以此类推!当中断控制器一旦收到外部的请求,就会立刻通知CPU。

而此时 CPU想知道外设是什么?外设想让CPU干什么?

在OS内 存在一组 叫 中断向量表 的东西 在语言层面 我们可以暂时把它理解成一个函数指针数组。

cpp 复制代码
typedef void(*handler_t)(void);
handler_t IDT[NUM];

而只要是数组 就一定有下标 我们可以赋予外设一定的编号 然后将编号转化为下标 此时我们就不能以O(1)找到 处理具体外设中断的方法吗? 一旦外设接入OS 每一个外设都会有唯一一个叫 中断号的东西(每个支持即插即用的兼容设备,在出厂时都携带了唯一的识别信息。当设备接入时,它会向操作系统报告自己的身份;然后中断号在接入操作系统后由系统动态分配或映射 ) 假设键盘的中断号 为0 当键盘发起中断后 对应导线产生高电频 此时就会得到该导线的中断号为0(我们可以理解 我们对应的中断控制器也有寄存器),此时中断控制器会向CPU发送中断 同时CPU获取中断号 然后CPU通过某种转化 向数组下标进行缩影 找到对应的中断向量表 然后就能处理中断同时执行相应的中断程序(即驱动) 把外设数据拷贝到内存里!

此时我们的 CPU在处理当前 进程的代码CPU识别到外部有中断到来,CPU必须把寄存器内部的上下文数据保存起来 这个叫 现场保护! 保护的本质是为了让CPU内寄存器腾出来 然后我在根据中断号 查找中断向量表 执行其内部对应的方法!然后执行完毕 再恢复现场,处理完毕中断,继续之前的工作!即现场恢复

所以硬件中断本质为: 暂停CPU正在执行的任务,处理硬件突发事件,结合中断号和中断向量表执行对应任务!

这个中断的过程怎么和信号如此相似??

因为在计算机世界,是先有硬件中断的,然后我们发现 进程也需要类似的中断,于是就发明的信号机制,信号机制,是由纯软件的方法,模拟中断完成特定的任务!!

所以 信号 硬件中断,原理类似,但是本质完全不同!


几个结论:

  • 中断向量表就是操作系统的一部分,启动就加载到内存中了!

内核代码:

  • 通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询
  • 由外部设备触发的,中断系统运行流程,叫做硬件中断

2.3.2 时钟中断

如果有一种外设(中断号假设为4) 按照固定的频率,给CPU发送属于自己的中断

cpp 复制代码
void do_timer()
{
 //执行进程调度,包括时间片的检测,切换调度等动作!!
}

进程由OS调度运行,OS也是软件啊,谁让OS运行呢??(2.3.2 2.3.3 都在回答这个问题!)

IDT属于OS的一部分,所以CPU可以通过中断的方式,定期执行IDT中指定的方法!

所以 OS就是 躺在中断处理上的软件集合!所以在中断帮助下 OS就能自动帮我们完成 进程调度和时间片检测等一系列动作!!

这个硬件叫 外部晶振


在内核中 有个专门处理 时间和调度的初始化函数sched_init(在内核的main中)

结构体具体如下:

该结构体里面有个处理外部 时钟中断的函数 set_intr_gate,这里的0X20就是其中断号:

当收到时钟中断 就会执行时钟中断处理程序 _time_interrupt而时钟中断的处理程序为(一部分为汇编写的 方便与硬件寄存器直接交互):

然后在_time_interrupt中 会调用一个叫do_timer的函数

这个do_timer为:


问题一: 为什么OS能计算时间?

在OS内部会存在一些全局变量 long long tickts =0; //记录累计的滴答数 我们把触发一次 时钟中断 叫做 触发一次 滴答!


为什么我们开机的时候会显示准确时间?

因为我们电脑主板上存在一个 小型的纽扣电池!哪怕断电依然可以为我们主板的记时单元进行不断的供电!所以当系统开机的时候 我们是能知道当前时间的!( 主板上的纽扣电池在断电期间为实时时钟(RTC)供电,保存当前的日期和时间。当操作系统开机时,内核首先会去读取这个 RTC 中的时间数据,以此作为系统时间的初始值 )

当开机后 每一次滴答 我们的 ticktis 就会 +1 ,而这个不就是时间嘛? 因为 外部晶振的振动时间是固定的,所以触发多少次滴答就是多少时间 !

开机时候的时间+累计的滴答次数*时钟中断的时间(本质就是时间) = 当前时间! 所以我们的OS是可以记时的!


问题2: 时间片是什么?

在每个进程的 task_struct中会有个叫conter的东西 就是时间片 本质就是 计数器

因为 是每发生一次时钟中断是固定的 假设1ms 所以发生一次中断 current就会-- 表示1ms过去了! 当时间片计数器归0 就会进行schedule() 即 进程调度!

⚠️:这个检测机制就是在do_time这个函数中!

中断处理 vs 进程运行

假如在10、11ms 发生了中断 此时CPU要执行do_time 那这之间CPU给谁用了? 进程!!

中断 和 进程运行 本质是 串行运行关系!

所以 do_timer() 本质就是 判断时间片是否过期 没过期 直接return,中断处理什么都没做;如果判断时间片耗尽 ,则会调用schedule() 选择一个进程 重新设置该进程的时间片 并切换进程!!

所以OS能被触发运行最核心的 就是 时钟中断!!!


问题3: 为什么OS可以执行它的调度算法?

靠的就是固定时间间隔的时钟中断!

2.3.3 当前的时钟中断(读完2.3.2后读)

如果用外部晶振 进行 时钟中断 这么做说实话还是太慢了 所以现在 直接 把一个叫 时间源的东西直接集成到CPU内部 直接由它直接在内部进行时间中断 这样就相当于少了层中转 大大提高效率 不过原理部分的 硬件中断 时钟中断部分一模一样 只是从外设升级成放到 CPU 内部!


CPU有个性能指标 叫主频 即1s内能触发多少次 时钟中断 下图的3.20 GHz相当于上亿次!(晶振频率!)

所以在 CPU内部触发 时钟中断 其实有两部分组成 一个是 时钟发生器(3.20 GHz的就是它),但是肯定不能以这种频率触发中断! 太快了 ; 还有个 时钟计数器(可配置) 比如 设置每隔0.32 GHz计数器就会-- 当该计数器为0后 就会触发 时钟中断!

时钟源 = 时钟发生器 + 时钟计数器

所以 OS的运行 是由 时钟源+时钟中断+硬件中断 促使的!!

伪代码:

cpp 复制代码
// Linux 内核0.11
// main.c
sched_init(); // 调度程序初始化(加载了任务0 的tr, ldtr) (kernel/sched.c)
// 调度程序的初始化⼦程序。
void sched_init(void)
{
...
set_intr_gate(0x20, &timer_interrupt);
// 修改中断控制器屏蔽码,允许时钟中断。
outb(inb_p(0x21) & ~0x01, 0x21);
// 设置系统调⽤中断⻔。
set_system_gate(0x80, &system_call);
...
}
// system_call.s
_timer_interrupt:
...
;// do_timer(CPL)执⾏任务切换、计时等⼯作,在kernel/shched.c,305 ⾏实现。
call _do_timer ;// 'do_timer(long CPL)' does everything from
// 调度⼊⼝
void do_timer(long cpl)
{
...
schedule();
}
void schedule(void)
{
...
switch_to(next); // 切换到任务号为next 的任务,并运⾏之。
}

2.3.4 死循环

OS本身是什么?

答: OS本质是一个 死循环

它自己不会做任何事情只会不断的Pause 暂定 是在在硬件时钟促使下才会自动调度的!

2.3.5 软中断

在CPU内部,内置了一些特殊的软件指令集(汇编),syscall 或 int -> 也会自动触发中断处理流程(和硬件触发中断本质一个原理)! 其触发的中断号一般默认为 0x80

当汇编可以 触发中断 是不是意味着我们C、C++程序员也能做到!!这就叫 软中断!!


问题1: 为什么需要软中断?

我们所用的各种 系统调用 都被放在一张系统调用表里,里面存储其 入口地址:

内核层面 每一个系统调用 都有一个系统调用号 本质就是 数组下标!

假设我要调用sys_readssize_t read(int fd,xxx);读取内存缓冲区内容!其汇编底层实现相当于:

将参数 系统调用号放入对应寄存器 并触发软中断

0x80对应的中断函数早在OS开机的时候已经被设置好了:

sched_init中有这个:

然后 system_call函数就会 读取 系统调用号 然后在底层直接调用 system_call[sysnumber](寄存器内存储的参数)查表 通过表中地址 调用对应方法

_system_call 里面具体会通过 _sys_call_table(这个是系统调用表的起始地址) 然后 加上 eax(系统调用号)*4找到对应系统调用在表中的位置 然后将结果入栈(即 返回的结果

至此我们就可以使用软中断完成 系统调用!

细节1: 谁来做,软中断之前的所有工作(如下图)?

方框部分 即真正调用 系统调用 这两行就够了! 而参数入参 返回值获取 这些都是 C标准库做的!!! OS只会提供你 系统调用号 和 对应约定好的寄存器 如果用汇编实现这种操作对用户要求太高

我们⽤的系统调⽤,从来没有⻅过什么 int 0x80 或者 syscall ,都是直接调⽤上层的函数那是因为C标准库,给我们把⼏乎所有的系统调⽤全部封装了!!!

我们所用的read系统调用 其实都是C语言提供的接口 只是在底层实现中 调用了系统调用的中断方式!

细节2: 内部系统调用 怎么知道有多少个参数?参数分别是谁?

在C语言封装中 它早就把参数个数传入约定的寄存器 然后如果参数少 则用寄存器传参数 如果参数多 还可以用栈(CPU上的)空间传 !


问题2: 还有其他中断形式吗?

2.3.6!

2.3.6 异常

缺⻚中断(缺页异常):

页表映射不具备 MMU 转化失败 此时不就相当于 MMU硬件报错!

内存碎⽚处理:

内存申请不够 不就相当于 内存报错!

除零野指针错误:

不就分别对应 CPUMMU出错!

上面这一堆 最终可以理解成报错 OS肯定也要介入! 所以OS需要可以进行 异常处理

如:缺页中断 MMU可以持续发中断 可以判断是野指针还是因为虚拟地址和物理地址没映射 然后进行一定处理;内存不够 可以持续中断 然后可以把不要的放入swap分区等进行内存不足问题;然后除零错误 CPU中断 然后进行对应的中断处理,如 发信号等!!!

所以OS内除了正常中断处理(硬件、时钟、软中断等),还需要进行异常的中断处理!!

所以中断向量表中 除了会受理正常的中断 还会绑定异常中断等等 OS会把我们常见的异常错误 提前做好对应的中断设定(中断处理函数) !!!

CPU 正在执行某条特定的代码指令,从而在处理器内部触发的打断事件,无论它是遇到了错误(如除零、缺页),或者特殊情况(如写实拷贝),在操作系统的专业术语中,我们都将其归类为"异常"。

中断总结

  • 我们把如 时钟中断,外设中断 称为 硬件中断!
  • 我们把CPU内的软中断 如 int 0x80\syscall 叫做 陷阱!
  • 我们把CPU内发生的内中断 如除零、野指针等,叫做 异常!(MMU虽然是硬件 但其是在CPU内部,其中断发生是同步的,即它是由 CPU 正在执行的某一条特定指令直接引起的 而正儿八经的硬中断的 异步的!)

所以 OS是一个软件块 -> 一个躺在中断处理例程上的代码块!即一个基于中断处理的软件集合!

2.4 如何理解内核态和⽤⼾态

任何进程 在【0,3】GB空间 都会有一个 用户级页表;当开机的时候,OS会加载到内存,同时还需要建立一张 内核集页表,把OS内核直接映射到 进程的【3,4】GB空间里!这样我们经过页表也能找到OS的所有资源!

将来我们调用代码区的scanf,会调用readread会在CPU对应的寄存器内保存其系统调用号并通过软中断,通过int 0X80陷入内核 通过内核页表(系统调用表已经映射到3~4GB虚拟地址空间)找到OS,然后找到并调用对应系统调用函数。

1. 我们发现,进程的所有函数调用,都是在自己的虚拟地址空间内完成!

2. 进程,每一个进程都有一套自己的用户级页表,但是内核级页表只有一份,被所有进程所共享!

3. 因为大家都是公用同一种内核级别页表 且都是在固定的【3,4】GB映射的!所以进程在任何时间进行调度的时候,都能随时找到OS!


我们把【0,3】GB 规定为用户空间,我们把 OS映射到进程的 【3,4】GB 为内核区。

为了防止用户直接用指针访问【3,4】GB 然后直接访问OS 所以Linux有两种 执行级别:用户态和内核态

当处于用户态 只能访问【0,3】GB ; 只有处于内核态时 才能让进程访问 【3,4】GB所映射的OS。

在Linux中,会有很多地方需要用到权限管理!我们寄存器内部包含了大量的寄存器值,叫进程的硬件上下文。

所以说 用户态和内核态,是需要硬件支持的!简化的说 用户态和内核态本质其实是CPU的两种执行级别!在特定寄存器内会有特定的bit位来描述CPU的工作模式!

CPU有个寄存器叫 CS,里面有两个bit标志位 只有两种取 00 11 -> 0 3 当为0时候,表示CPU处于内核态,当处于3时,标识CPU处于用户态!

这种 0和3 的东西 我们叫做 CPL(Current Privilege Level),即当前特权级别!只有处于内核态的时候 才允许用户通过中断的方式调用系统调用!

当你拿野指针访问内核区域 CPU当前执行级别是11 此时直接报错!!!

问题: 那么谁来判断野指针访问的是【3,4】GB ?

其实在用户和内核页表中 有着属于自己的标志位字段! 也是用两个bit位标识 00 或 11 我们把这个标志位叫做 DPL!在内核页表设置的是11 用户页表设置是00 ,当你指针访问对应空间时,查页表发现DPL11,发现DPL值CPL值不同 即 CPU内部的执行级别和页表当中的权限级别不相符,则不会进行虚拟地址转换 所以最后会报错!!!

所以当我们执行系统调用的时候,系统调用的底层 除了把 系统调用号写入寄存器 还会在调用 syscall 0x80或者int 0x80的汇编指令触发中断时 把CS中的CPL改为0后进入内核态(这个过程是自动的),通过 IDTR 寄存器直接定位物理内存找到IDT并跳转到操作系统预先设定好的中断处理程序,获取system_call的虚拟地址,然后通过内核页表转换得到物理地址,执行system_call时 再通过对应的系统调用号和函数内存储的_sys_call_table(系统调用表的起始地址)虚拟地址 去查内核页表 找到对应的系统调用表中具体系统调用的虚拟地址存入指令寄存器 最后再通过MMU查内核页表转换 找到具体系统调用函数并执行!!

那么你会问了 我们是不是也可以通过自己写个syscall 0X80进入内核态 这样不就可以直接用指针访问OS的代码和数据了咩~

答案是 可以进入内核态 但是不能访问OS的代码和数据!因为对OS的访问和设置 我们只能通过 系统调用真正访问内核!!!!除了权限级别的保护 其实还有别的保护!

2.5 一个问题

当我们调用函数 如func的时候 怎么知道while(1)的地址的? 这是因为调用 func前先把 while的地址压入栈 当指向完funcwhile的起始地址会进入eip寄存器接着执行

2.6 通过信号简单模拟OS

cpp 复制代码
#include <iostream>
#include <vector>
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

// 记录当前正在运行的任务在 tasks 向量中的索引
int current = 0;

/*
  模拟操作系统的进程控制块(PCB)
  包含进程的基本信息以及用于调度的计数器(时间片)
 */
class task_struct
{
private:
    int pid;      // 进程ID
    int status;   // 进程状态(当前未实际使用)
    int counter;  // 剩余运行时间片数

public:
    // 构造函数:初始化进程ID,并将初始时间片设为5
    task_struct(int p) : pid(p), counter(5) {}

    // 消耗一个时间片(将计数器减1)
    void desc() 
    {
        counter--;
    }

    // 重置时间片(当进程重新被调度时调用)
    void ResetCounter() 
    {
        counter = 5;
    }

    // 获取当前进程的PID
    int Pid() 
    {
        return pid;
    }

    // 判断当前进程的时间片是否已耗尽
    bool Expired() 
    {
        return counter <= 0;
    }

    // 模拟进程正在运行的状态输出
    void run() 
    {
        std::cout << "process " << pid << " running" << std::endl;
    }

    ~task_struct() {}
};

// 全局任务队列,用于存放所有就绪状态的进程
std::vector<task_struct> tasks;

/*
 定时器中断处理函数(模拟时钟中断)
 signo 接收到的信号编号(此处为 SIGALRM)
 */
void do_timer(int signo)
{
    // 1. 消耗当前运行进程的一个时间片
    tasks[current].desc();

    // 2. 检查当前进程的时间片是否已耗尽
    if (tasks[current].Expired())
    {
        std::cout << tasks[current].Pid() << " 过期了,重新选择进行调度" << std::endl;
        
        // 3. 时间片耗尽,触发调度算法:随机选择一个新进程运行
        current = rand() % tasks.size();
        
        // 4. 为新选中的进程重置时间片
        tasks[current].ResetCounter();
    }
    else 
    {
        // 如果时间片未耗尽,继续运行当前进程
        tasks[current].run();
    }

    // 重新设置1秒后的闹钟,以触发下一次时钟中断
    alarm(1);
}

int main()
{
    // 设置初始的1秒定时器
    alarm(1);
    
    // 注册定时器信号(SIGALRM)的处理函数为 do_timer
    signal(SIGALRM, do_timer);
    
    // 初始化随机数种子,用于后续的随机调度
    srand(time(nullptr));

    // 向任务队列中添加5个模拟进程
    tasks.emplace_back(1);
    tasks.emplace_back(2);
    tasks.emplace_back(3);
    tasks.emplace_back(4);
    tasks.emplace_back(5);

    // 主循环:让主程序挂起等待信号
    // 实际的调度逻辑完全由时钟中断触发的 do_timer 函数接管
    for (;;)
    {
        pause(); // 暂停执行,直到捕获到信号
    }

    return 0;
}

效果:

2.7 sigaction

在 Linux 系统编程中,sigaction 是用于读取和修改与指定信号相关联的处理动作的核心系统调用。相比于早期的 signal 函数,它提供了更丰富、更可靠的信号控制机制,是实际开发中捕捉和处理信号的推荐方式。

c 复制代码
#include<signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
  • signum :需要处理的信号编号(如 SIGINT, SIGALRM)。注意不能对 SIGKILLSIGSTOP 进行操作。
  • act :输入型参数,指向一个新的 sigaction 结构体,定义了新的信号处理行为。如果为 NULL,则仅查询当前设置而不做修改。
  • oldact :输出型参数,用于保存该信号之前的处理动作。如果不需要保存旧动作,可设为 NULL
  • 调⽤成功则返回0,出错则返回- 1

核心结构体:struct sigaction

这是 sigaction 的灵魂所在,其内部字段决定了信号到来时的具体表现:

c 复制代码
struct sigaction 
{
    void     (*sa_handler)(int);            // 传统信号处理函数指针
    void     (*sa_sigaction)(int, siginfo_t*, void*); // 扩展处理函数
    sigset_t sa_mask;                       // 信号屏蔽集
    int      sa_flags;                      // 行为控制标志位
    void     (*sa_restorer)(void);          // 内部恢复现场用,通常忽略
};

各字段详细解析:

  • sa_handler / sa_sigaction :指定信号到达时的回调函数。可以赋值为自定义函数、SIG_IGN(忽略该信号)或 SIG_DFL(执行系统默认动作)。 当设置了 SA_SIGINFO 标志时,会使用 sa_sigaction,从而能获取关于信号的额外上下文信息。
  • sa_mask (信号屏蔽字) : 指定一个信号集,表示在执行当前信号处理函数期间,除了当前信号会被自动阻塞外,还需要额外阻塞哪些信号。这能有效防止在处理某个信号时被其他关键信号打断。当处理函数返回时,内核会自动恢复原来的信号屏蔽字。
  • sa_flags (控制选项) :用于精细控制信号的处理行为,常见的标志包括:
    • SA_RESTART:如果信号中断了某个系统调用(如 read, sleep),处理完信号后系统调用会自动重新启动,而不是返回错误。
    • SA_SIGINFO:启用扩展的信号处理接口(即使用 sa_sigaction),以获取更丰富的信号信息。
    • SA_NODEFER:在信号处理程序执行期间,不自动阻塞当前正在处理的信号。

常见用法总结:

通过灵活组合 actoldact 参数,可以实现多种操作:

  • 只查询当前动作sigaction(signo, NULL, &old);
  • 设置新动作并保存旧动作sigaction(signo, &act, &old);
  • 只修改,不关心旧动作sigaction(signo, &act, NULL);

信号的捕捉细节

当某个信号的处理函数被调用的时候,内核将自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时候自动恢复原来的的信号屏蔽字!

问题1: 如何证明?

我们可以设计一段代码 当发送2号信号后 立即被捕捉 同时捕捉后自定义的函数在死循环打印其spending表,且表内2号信号位置位0;当再次发送2号信号的时候 我们发现其spending表的2号信号位始终变为1 变成未决状态 这 说明了 2号的处理函数被调用的时候,内核将自动将当前信号加入进程的信号屏蔽字!!!

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>

void handler(int signo)
{
    std::cout << "捕捉一个信号:" << signal << std::endl;
    sigset_t pending;
    while (true)
    {
        sigpending(&pending);
        for (int i = 31; i >= 0; i++)
        {
            if (sigismember(&pending, i))
            {
                std::cout << "1";
            }
            else
            {
                std::cout << "0";
            }
        }
        sleep(1);
        std::cout<<std::endl;
    }
}

int main()
{
    struct sigaction act, oact;
    act.sa_handler = handler;
    sigemptyset(&(act.sa_mask));
    act.sa_flags = 0;
    act.sa_restorer = nullptr;
    sigaction(SIGINT, &act, &oact);
    while (true)
    {
        while (true)
        {
             std::cout << "进程在运行: " << getpid() << std::endl;
            sleep(1);
        }
    }
}

效果:


问题2: 为什么要这么做?

当某个信号的处理函数被调⽤时,内核⾃动将当前信号加⼊进程的信号屏蔽字,当信号处理函数返回时⾃动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产⽣,那么 它会被阻塞到当前处理结束为⽌。 这样就可以有效防止 handler方法被不断嵌套执行!!

当如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
sa_flags字段包含一些选项,我们目前都把sa_flags设为0,sa_sigaction是实时信号的处理函数,本章不详细解释这两个字段,有兴趣的可以自己了解一下。

相关推荐
A_humble_scholar2 小时前
Linux(九) 进程管理完全指南:从入门到实战
linux·运维·chrome
江华森2 小时前
Linux 操作命令完全指南
linux·运维
rjszcb3 小时前
Linux,sensor调试笔记1,修改帧率,以及曝光上不去问题
linux
C++ 老炮儿的技术栈3 小时前
Ubuntu root账号自动登陆
linux·运维·服务器·c语言·c++·ubuntu·visual studio
2301_780789663 小时前
零信任架构中,身份感知防火墙(IAFW)的部署要点与最佳实践
linux·运维·服务器·人工智能·tcp/ip·架构
小狮子&3 小时前
ubuntu2604无法共享文件夹问题解决
linux·运维·服务器
biter down3 小时前
3:VMware Workstation 安装 Ubuntu 22.04 超详细教程
linux·运维·ubuntu
曾阿伦4 小时前
netcat / ncat / socat 用法详解与示例
linux·http·信息与通信
Benszen4 小时前
Secret详解
linux·运维·服务器