Linux进程信号(下)

Linux进程信号(中)https://blog.youkuaiyun.com/Small_entreprene/article/details/146323008?sharetype=blogdetail&sharerId=146323008&sharerefer=PC&sharesource=Small_entreprene&sharefrom=mp_from_link当前阶段:

捕捉信号

经过我们前面的学习,也是清楚了信号的产生和保存,因为我们产生信号之后,进程并不会立即处理这个信号,所以才有的信号需要被进程保存,进而衍生出来信号可以被pending,block,甚至handler,这一条线我们已经摸清楚了,但是什么时候处理这个信号,还有信号是如何处理的?

接下来就是来谈论信号处理的话题。

信号捕捉的流程

我们来看看信号捕捉的流程:

信号处理的流程可以分为用户模式(User Mode)和内核模式(Kernel Mode)两个部分,具体步骤如图所示:(我们先将流程梳理清楚,再来好好谈谈用户态和内核态,先宏观再局部)

在执行主控制流程的某条指令时因为中断、异常或系统调用进入内核
当进程在用户模式下执行时,可能会因为中断、异常或系统调用而进入内核模式。此时,内核会暂停进程的执行,并保存当前的上下文信息。

内核处理完异常准备回用户模式之前先处理当前进程中可以递送的信号
在内核处理完异常或系统调用后,准备返回用户模式,继续执行后续代码之前,会调用do_signal()函数来检查当前进程中是否有待处理的信号。如果有信号需要处理,内核会进一步处理这些信号。(都说进程是在合适的时候处理信号,这个合适的时候就是进程从内核态,返回到用户态的时候!进程会在操作系统的驱使之下,进行信号的检查)

那么 do_signal( ) 信号查找的是什么?

查找是否收到此信号:pending表对应位置是否为1,为0就直接返回;

如果pending表对应位置为1:检查相应bolck位置是否为1,为1屏蔽的话就直接返回;

如果block为0,且pending表为1,则进程根据信号的处理方法(handler)进行信号递达。

如果信号的处理动作是自定义的信号处理函数则回到用户模式执行信号处理函数
如果信号的处理动作是自定义的信号处理函数(即用户定义的信号处理函数),内核会将控制权返回到用户模式,并执行相应的信号处理函数。这个信号处理函数是在用户空间中定义的,用于处理特定信号的逻辑。


引入子问题:那么如果处理信号的处理动作不是自定义捕捉呢?而是默认处理或者忽略呢?(handler是SIG_DFL或SIG_IGN)

  • 如果信号是忽略动作,动作是SIG_IGN,那么进程对应的pending位置比特位由1变0,返回用户层,继续向后运行;
  • 如果信号是默认动作,这时候进程就需要按照特定信号的约定,执行默认动作,大部分默认动作是终止,所以假设在内核态发现接收到的1号信号的处理动作是默认处理(终止进程),操作系统就会将该进程杀掉,释放PCB,地址空间,本来就在操作系统内部,级别是够的,所以本质就是被操作系统所杀掉的。默认动作还有暂停,那么操作系统也是在内核当中,找到对应进程PCB,将进程状态由R改为S,然后链接到指定的队列当中,让进程等待。

默认行为和忽略行为都比自定义捕捉要简单。

对于执行自定义处理方法,执行的自定义方法是用户写的,那么执行该handler方法时,操作系统是要以谁的身份执行的?用户?还是操作系统内核呢?

自定义处理方法由用户编写时,执行身份始终是用户,操作系统默认在用户态运行用户代码,仅允许非特权操作(如计算、数据处理)(如果可以做非法操作:比如在信号捕捉方法里删除我们用户没有权限删除的配置文件等,那么就会在信号捕捉这引入了新的BUG);若代码中调用了系统调用(如读写文件、网络请求),则操作系统内核会临时切换至内核态代为执行特权操作(如访问硬件),但整个过程仍由用户程序发起,内核完成操作后立即返回用户态,确保用户代码无法直接以内核身份运行。简单来说:用户身份执行代码,内核身份处理特权请求,两者通过系统调用严格隔离。


继续捕捉过程:

信号处理函数返回时执行特殊的系统调用sigreturn再次进入内核
当信号处理函数执行完毕后,会调用sigreturn系统调用再次进入内核模式。sigreturn系统调用的作用是恢复信号处理前的进程上下文,包括寄存器状态、堆栈指针等。(调用函数,开辟栈帧,压栈的结果,在压栈的时候,将sigreturn系统调用包含其中)

返回用户模式从主控制流程中上次被中断的地方继续向下执行
最后,内核会恢复进程的上下文,并返回用户模式,从主控制流程中上次被中断的地方继续执行。这样,进程就可以继续执行其正常的任务,而不会因为信号处理而中断太久。

通过上述流程,我们可以看到信号处理是一个涉及用户模式和内核模式交互的过程。信号的处理时机是在内核处理完异常或系统调用后,准备返回用户模式之前。信号的处理方式则取决于信号的类型和用户定义的处理函数。整个过程确保了信号能够被及时处理,同时又不会对进程的正常执行造成太大的影响。理解这一流程对于深入理解Linux内核中的信号机制具有重要意义。

下图有便于记忆:4个圈代表用户态与内核态的相互转化次数。

但是,有几个问题:

我的进程,凭什么要进入内核?(现在还不懂什么系统调用,系统调用是怎么实现的等等目前不知道)

现在我们理解我们自己写的代码调用系统调用,然后能够进入内核,但是,我如果不调用系统调用呢?

while(true)
{    }

我们写个死循环,系统可能会变卡,但是不妨碍我们使用Ctrl+c将该进程杀死。难道这种代码也会进入内核吗?会的!这是为什么?

因为这一份代码在运行时也是一个进程,是进程就会被调度,进程持有CUP,但并不是要等该进程跑完才换另一个进程持有CPU,每个进程都有对应的时间片,时间片到了,操作系统就会将该进程从调度队列中剥离下来,看起来while循环一直在不断的跑,其实是在不断消耗时间片。是操作系统将该进程拿下来的,这不就是操作系统强制进入,让其进入内核态了嘛。这也是我们可以Ctrl+c对进程进行终止。

穿插话题-操作系统是怎么运行的

硬件中断

当我们启动操作系统后(启动电源键),操作系统就会载入到内存当中。操作系统在载入内存的过程中,有一个非常重要的工作:操作系统要不断地去响应外部事件。与此同时,操作系统在加载成功之后,一般我们在执行某些操作时,操作系统就要不断地去响应外部事件:

例如,我们今天写了一个C/C++的代码,其中有scanfcin函数,当我们不去从键盘上输入数据的时候,该进程就会卡在那里(进程PCB的状态由“就绪”变为“阻塞”,进程被放入当前键盘文件结构struct file对象的等待队列中),等待用户从键盘上输入数据。一旦用户输入数据,进程就可以拿到数据并继续执行。那么,进程是怎么知道用户在键盘上输入了数据呢?

键盘属于硬件设备,操作系统需要管理这个键盘。那么,操作系统是如何知道键盘上有数据输入的呢?如果采用轮询策略,即操作系统不断检查键盘是否有数据输入,这将导致大量的CPU资源浪费,尤其是在有多个硬件设备需要管理的情况下。因此,我们引入了中断机制来解决这个问题。

中断机制的工作原理

中断机制是一种硬件和软件协同工作的机制,用于处理外部设备的请求。当外部设备需要CPU处理事件时,它会向中断控制器发送请求,中断控制器随后通知CPU。CPU接收到中断后,会保存当前任务状态,获取中断号,并根据中断号在中断向量表中找到相应的中断处理程序执行。处理完成后,CPU恢复之前保存的任务状态,继续执行被中断的任务。

但是我们之前学习过冯诺依曼体系: 

输入设备和输出设备不会和CPU直接打交道,而是和内存直接打交道,这是我们之前的认知,观察图片,我们还有相应控制流,我们对应的外设是可以和CPU在线路上可以直接连到一起,只不过不以发送,拷贝数据为目的,主要是为了传递信息。设备数据最终还是要直接进入内存。这是为了说明外设是可以与CPU有直接联系的!

外部设备的中断信息其实并没有和CPU直接相连,我们不可能将所有的外设和CPU直接连接起来,这样的话,特别不容易处理,所以在硬件上,往往会出现一个称为中断控制器的东西,中断控制器其实在焊电路的时候,就将信息的路口给了对应的设备,未来一旦设备就绪了,中断控制器就会给对应CPU的针脚发送中断信息,也就是说中断的工作是由中断控制器来实现的。

我们在读写磁盘的时候,要将读写磁盘的请求交给磁盘,通常会向磁盘发送类似"in 100 xxxx"(要给磁盘在100号扇区里,写xxxx数据),未来在给磁盘下达的命令里,既包含控制命令{in},又磁盘位置{100},还包含具体数据{xxxx},向磁盘下发该内容的时候,具体来说,是下发给磁盘内部的控制器,几乎说有的外设,包括内存,这种硬件设备,内部也有寄存器,寄存器不仅仅在CPU中有,在设备里中也有,有的寄存器叫做命令寄存器,有的叫做位置寄存器,有的叫做数据寄存器,因此,我们可以将下图看成:

我们将中断控制器看成是一个设备,当外部设备一旦就绪了,里面的白色框框{中断号n}看成该设备的一个寄存器reg,当某一个设备向中断控制器发送高电频,控制器就可以知道哪一个接口被点亮了,比如说0号,然后就将0号写到寄存器当中,然后再由中断控制器主动给CPU对应的针脚发送高电频,CPU就知道外部有设备就绪了,但是CPU不清楚是谁准备好了,CPU就可以访问对应的中断控制器,就可以从该寄存器当中读到中断号!自此,CPU就知道哪一个外部设备准备好了!

硬件话题到此为止,接下来所有话题就是围绕软件展开!

操作系统在加载到内存并开始运行后,其核心任务之一就是管理硬件资源,包括外部设备。当外部设备准备好与CPU交互时,它会发出一个中断信号,通知CPU它需要处理一些数据。然而,仅仅让CPU知道外部设备就绪是不够的,因为CPU本身并不具备直接处理这些数据的能力。CPU需要具体的指令来指导它如何操作,包括如何读取外部设备的数据、将数据存储到内存的哪个位置、是否需要调整内存空间、以及是否需要进行内存的申请、释放或合并等。

在这种情况下,操作系统的作用就显得尤为重要。操作系统必须提供一个机制来指导CPU如何处理这些硬件上的数据。这就是中断向量表(Interrupt Descriptor Table, IDT)的作用所在。中断向量表是操作系统内部的一个数据结构(函数指针数组,下标就是中断号),它定义了当特定中断发生时,CPU应该执行哪个中断服务例程(Interrupt Service Routine, ISR)。

当外部设备发出中断信号时,操作系统通过中断控制器接收到这个信号,并查找中断向量表以确定相应的中断服务例程。这个例程是一段预先编写好的代码,它知道如何处理特定类型的中断,包括如何与外部设备通信、如何读取或写入数据、以及如何管理内存等。

中断服务例程的执行过程通常包括以下几个步骤:

  1. 保存现场:在执行中断服务例程之前,CPU会保存当前的执行状态,包括寄存器的值和程序计数器等,以便在中断处理完成后能够恢复到中断前的状态。

  2. 执行中断服务例程CPU跳转到中断向量表中指定的中断服务例程的地址,并开始执行这段代码。中断服务例程会执行所有必要的操作,如读取外部设备的数据、处理数据、更新内存等。

  3. 恢复现场:中断服务例程执行完成后,CPU会恢复之前保存的执行状态,以便继续执行被中断的任务。

  4. 返回主程序:CPU返回到中断发生前的指令位置,继续执行主程序。

通过这种方式,操作系统能够管理所有的外部设备,确保它们能够与CPU有效地交互,同时保证系统的稳定性和效率。中断向量表是操作系统管理硬件资源的关键组件,它使得操作系统能够灵活地处理各种类型的中断,从而实现对硬件资源的高效管理。

发中断 --- 发信号?

保存中断号 --- 记录信号?

中断号 --- 信号编号?

处理中断 --- 处理信号?--- 自定义捕捉?

所以信号,纯软件,本质是用软件来模拟硬件中断的!!! 

从某种程度上说,软件信号是一种用软件机制来模拟硬件中断的方式。它允许进程之间以一种异步的方式进行通信,而不需要直接的硬件支持。软件信号和硬件中断都是为了实现异步事件处理和任务调度,但它们的应用场景和实现机制有所不同。硬件中断通常用于处理外部设备的事件,而软件信号则用于进程间的通信和控制。

外部中断不是重点,但是不需要知道,上面是外部中断,操作系统的行为,那么操作系统在没有外部中断的情况下,操作系统在干什么? 

操作系统在没有外部中断的情况下,主要处于一种待命状态,准备响应各种系统事件。它通过时钟中断来周期性地进行进程调度,确保各个进程能够公平地使用CPU资源。同时,操作系统也在等待系统调用,这些调用允许用户进程请求操作系统提供的服务,如文件操作和进程控制。

此外,操作系统还负责内存管理、设备驱动程序交互、文件系统维护以及错误检测和处理等任务。这些活动并不是主动执行的,而是在相应的事件触发时才会进行,从而确保系统资源的有效管理和优化。

操作系统并不是主动“做”进程管理、文件管理或进程调度,而是通过响应各种事件(包括中断和系统调用)来实现这些功能。

等待事件:操作系统处于一种等待状态,监听来自硬件(如时钟中断、I/O设备)或软件(如系统调用)的事件。(不断地在暂停)

for(;;)
{
    pause();
}
时钟中断
进程可以在操作系统的指挥下,被调度,被执⾏,那么操作系统⾃⼰被谁指挥,被谁推动执⾏呢?
外部设备可以触发硬件中断,但是这个是需要⽤⼾或者设备⾃⼰触发,有没有⾃⼰可以定期触发的
设备?

操作系统在没有外部中断的情况下,主要处于一种待命状态,准备响应各种系统事件。它通过时钟中断来周期性地进行进程调度,确保各个进程能够公平地使用CPU资源。同时,操作系统也在等待系统调用,这些调用允许用户进程请求操作系统提供的服务,如文件操作和进程控制。(操作系统,就在硬件时钟中断的驱动下,进行调度了!!!

此外,操作系统还负责内存管理、设备驱动程序交互、文件系统维护以及错误检测和处理等任务。这些活动并不是主动执行的,而是在相应的事件触发时才会进行,从而确保系统资源的有效管理和优化。(所以操作系统就是基于中断进行工作的软件!!!

在没有外部中断的情况下,操作系统主要依赖时钟中断来进行进程调度和系统维护。时钟中断是一种内部中断,由系统的时钟硬件定期触发,用于实现操作系统的时间管理功能。(硬件时钟放在外部的话,就会和外部设备竞争中断控制器,而且硬件传递还需要触发,效率太低了,所以硬件设计者就把时钟源集成在了CPU当中了,然后调用中断向量表中的相关方法,比如进程调度的方法)

由此,CPU内就有了主频的概念:也是判断电脑说哪一个计算机好,我们经常说的是看CUP,几核的,处理器,都有对应的频率,指的就是中断触发的频率:

  • 进程调度:时钟中断用于实现时间片轮转调度算法,确保每个进程都能公平地获得CPU时间。当一个进程的时间片用完时,时钟中断会触发,操作系统会保存当前进程的状态,选择下一个进程运行。

  • 系统计时:时钟中断用于更新系统时间,这对于任务的定时执行和时间相关的系统服务(如定时器)至关重要(时间片本质就是一个计数器,因为硬件会以固定频率发送)。

struct task_struct
{
    int count=10;//10ns;
}

current->count--;
if(current->count == 0)//时间片耗尽
{
    schedule();//进程调度/切换/......
}
  • 资源管理:时钟中断可以帮助操作系统监控系统资源的使用情况,如内存和CPU使用率,从而进行必要的资源调整和管理。

  • 系统维护:时钟中断还可以用于执行一些定期的系统维护任务,如文件系统检查、系统日志记录等。

时钟中断的流程:

  1. 触发时钟中断:系统时钟硬件定期发送中断信号给CPU。

  2. 保存现场:CPU接收到时钟中断后,会保存当前的执行状态,包括寄存器和程序计数器。

  3. 执行中断处理程序:CPU跳转到时钟中断处理程序,执行相关的调度和计时任务。

  4. 恢复现场:中断处理完成后,CPU恢复之前保存的执行状态,继续执行被中断的任务。

这样,操作系统不就在硬件的推动下,⾃动调度了么!!!(这也就是操作系统要通电的原因)

通过这种方式,操作系统能够在没有外部中断的情况下,继续进行有效的进程管理和系统维护,确保系统的稳定和高效运行。

时钟中断是一种硬件机制,用于让计算机在固定的时间间隔(如每秒)产生一个中断信号。操作系统通过处理这些中断信号来更新系统时间。即使在离线状态下,只要计算机的硬件时钟(RTC)正常工作,时钟中断就能保证系统时间的准确性。每次中断触发时,系统会将当前时间戳加1秒(或其他时间单位),从而让计算机知道当前时间。

闹钟超时也是基于此,只有硬件时钟准确且系统能正确处理中断,闹钟才能在设定时间触发。(闹钟超时处理:Linux的闹钟机制允许用户通过系统调用设置闹钟,操作系统负责在指定时间后处理这些闹钟。操作系统为每个闹钟创建一个结构体对象,包含闹钟的时间戳、信号类型、闹钟ID、进程PID等信息。操作系统维护一个链表或其他数据结构来存储这些闹钟对象,使用最小堆(或优先队列)来高效地找出最早到期的闹钟。当闹钟超时,操作系统会从堆中删除这个闹钟,并执行相应的处理,如发送信号。)

我们在上篇中写了一个代码,就是在描述操作系统,这时候,我们就可以更好理解了:

#include <iostream>
#include <functional>
#include <vector>
#include <unistd.h>
#include <signal.h>
 
// 定义任务函数 //
void Sched() {
    std::cout << "我是进程调度" << std::endl;
}
 
void MemManger() {
    std::cout << "我是周期性的内存管理,正在检查有没有内存问题" << std::endl;
}
 
void Fflush() {
    std::cout << "我是刷新程序,我在定期刷新内存数据,到磁盘" << std::endl;
}
/
 
// 使用 std::function 定义任务函数的类型
using func_t = std::function<void()>;
 
// 任务函数列表
std::vector<func_t> funcs;
 
// 时间戳,用于记录信号触发的次数
int timestamp = 0;
 
// 信号处理函数
void handlerSig(int sig) {
    timestamp++; // 每次信号触发时,时间戳加 1
    std::cout << "##############################" << std::endl;
 
    // 遍历任务列表,依次调用每个任务函数
    for (auto f : funcs) {
        f();
    }
 
    std::cout << "##############################" << std::endl;
 
    // 重新设置 alarm,确保 1 秒后再次触发信号
    alarm(1);
}
 
int main() {
    // 将任务函数添加到任务列表中
    funcs.push_back(Sched);
    funcs.push_back(MemManger);
    funcs.push_back(Fflush);
 
    // 注册信号处理函数,将 SIGALRM 信号绑定到 handlerSig
    signal(SIGALRM, handlerSig);
 
    // 设置第一次信号触发时间为 1 秒后
    alarm(1);
 
    // 主循环:程序通过 pause() 阻塞,等待信号到来
    // 每次信号触发时,handlerSig 会被调用,然后程序继续阻塞
    while (true) {
        pause();
    }
 
    return 0;
}

如果是这样,操作系统不就可以躺平了吗?对,操作系统⾃⼰不做任何事情,需要什么功能,就向中 断向量表⾥⾯添加⽅法即可.操作系统的本质:就是⼀个死循环!

void main(void) /* 这里确实是void,并没错。 */
{ /* 在startup程序(head.s)中就是这样假设的。 */
    ...
    /*
    * 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到一个信号才会返回就绪运行态,
    * 但任务0(task0)是唯一的例外情况(参见'schedule()'),因为任务0在任何空闲时间里都会被激活
    * (当没有其它任务在运行时),
    * 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运行,如果没有的话我们就回到这里,
    * 一直循环执行'pause()'。
    */
    for (;;)
        pause();
} // end main

操作系统的核心是一个死循环,通过中断机制响应各种事件。硬件时钟产生的时钟中断是操作系统调度任务的基础。时间片是操作系统用于公平调度任务的机制,而CPU主频决定了CPU处理指令的速度。主频越高,系统能够更快速地响应中断,提高任务调度的效率。

在我们进程在CPU上运行时,假设每隔1纳秒会中断一次,那么在1纳秒内,进程就可以运行,中断到来时再短暂处理一下中断,时间继续;后来进程除0了,在CPU内部有一个寄存器(EFLAGS),这就发生了CPU硬件上的溢出,就是数据出错了,以前CPU是没法处理的,后来设计者就将除0错误规定成一种由CPU内部触发的中断!出错了,CPU自己就生成一个中断号,操作系统就需要停下来做中断处理,也就为其注册了异常处理,比如说为目标进程发送信号。野指针,指针重复释放,也是同样的机制!也就是说,所有的硬件异常,都会被转化成中断,系统会自动注册中断处理方法!(准确来说,这不是叫硬件中断,而是叫做异常)

之前说过在虚拟地址上开辟空间,未必一定要在物理地址上开辟空间,因为会有一种错误,叫做:缺页中断。当进程访问一个不在物理内存中的虚拟地址时,MMU(内存管理单元)会检测到这个访问请求,并触发缺页中断。此时,CPU会将控制权交给操作系统,操作系统会检查该虚拟地址是否有效。如果地址有效,操作系统会尝试将缺失的页面从磁盘加载到物理内存中;如果地址无效,操作系统会向进程发送一个信号,或者直接终止该进程。

软中断

上面的中断都是硬件设备触发的,有没有可能,因为软件原因,也要触发上面的逻辑?是有的! 

之前提到的内容中,主要涉及的是硬件中断(CPU外部)异常(CPU内部),而不是软件中断。 软件中断是由程序主动触发的中断。

CPU内部,可以有让软件自己来触发中断行为吗?(上面所说的是软件导致硬件出错了,是被动让的:除0...),也就是说有没有让CPU主动进行中断。

有的!!!:为了让操作系统支持系统调用,CPU设计了专门的指令(如intsyscall)(指令集)(C/C++代码:本质就是编译成为了指令集+数据!---可以调度执行),这些指令可以让CPU内部触发中断逻辑,从而实现从用户态到内核态的切换。

  • 用户态程序通过执行特定的指令(如int指令)来触发软件中断。例如,在x86架构中,int 0x80指令用于触发系统调用。

  • 执行int指令时,程序会将控制权交给操作系统,并提供一个中断号(如0x80)。

在Linux中,所有的系统调用都是被写在一张表里面的,就是系统调用函数指针表里,用于系统调用中断处理程序(int 0x80),作为跳转表:

 

上面说的是内核的实现,那么用户层呢?我们用的系统调用是如下示例的:

 

我们以open为样例,open的底层实现是:(最核心的代码片段)

move eax 5//系统调用号,就可以被内核拿到
int 0x80

上层只需要将系统调用号,通过寄存器传递给操作系统,再int 0x80触发软中断,拿到约定寄存器的内容(系统调用号),就可以执行相应的系统调用了。

可是系统调用本身不就是OS提供的吗?为什么还要这样大费周章?

其实OS是不提供任何系统调用接口的!OS只提供系统调用号!!!我们使用的open,fork等,都是被glibc封装的!!!

在Linux系统中,这些函数的实现通常在glibc库中,glibc库会将这些函数调用转换为对应的系统调用号,并传递给内核。这就是为什么你在用户态程序中看不到直接调用int 0x80syscall指令的原因,因为这些底层细节已经被库函数封装起来了。

所以:

操作系统就是躺在中断处理例程上的代码块;

CPU内部的软中断,比如说int 0x80或syscall,我们叫做陷阱;

CPU内部的软中断,比如说缺页中断,除0错误,野指针等,我们叫做异常;(所以能理解“缺页异常”为什么可以这么叫了吗?)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值