admin管理员组

文章数量:1440447

【进程信号】四、信号的捕捉

信号的捕捉

​ 在讲信号集的操作之前,我们先来讲讲信号的捕捉原理和内核态、用户态的知识,帮助我们后面的理解!

一、内核态与用户态

​ 我们知道,信号产生的时候并不会立刻被处理,而是在一个合适的时候才会被处理!这里的合适的时候指的是什么呢❓❓❓

​ 一个信号产生,对应的 pending 位图某个位置就被置 1 了,但是不仅仅是受该信号是否被阻塞而才会被处理,其实还有原因,是因为系统需要进行用户态与内核态之间的身份切换!并且等到从内核态转化为用户态的时候才会处理该信号!下面我们一步一步来解释:

① 什么是内核态和用户态

当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态(或简称为内核态)。此时处理器处于特权级最高的(0级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。每个进程都有自己的内核栈

当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。即此时处理器在特权级最低的(3级)用户代码中运行。 当正在执行用户程序而突然被中断程序中断时,此时用户程序也可以象征性地称为处于进程的内核态。因为中断处理程序将使用当前进程的内核栈。 这与处于内核态的进程的状态有些类似。

内核态和用户态的区别 理解Linux用户态和内核态 ​ 内核态与用户态是操作系统的两种运行级别,跟 intel cpu 没有必然的联系,intel cpu 提供 Ring0-Ring3 三种级别的运行模式 Ring0 级别最高,Ring3 最低。Linux 使用了 Ring3 级别运行用户态,Ring0 作为内核态,没有使用 Ring1Ring2Ring3 状态不能访问 Ring0 的地址空间,包括代码和数据。Linux 进程的 4GB地址空间,其中第 3G-4G 部分大家是共享的,是内核态的地址空间,这里存放在整个内核的代码和所有的内核模块,以及内核所维护的数据。用户运行一个程序,该程序所创建的进程开始是运行在用户态的,如果要执行文件操作,网络数据发送等操作,必须通过 writesend 等系统调用,这些系统调用会调用内核中的代码来完成操作,这时,必须切换到 Ring0 级别进入内核态,然后进入 3GB-4GB 中的内核地址空间去执行这些代码完成操作,完成后切换回 Ring3,回到用户态。这样,用户态的程序就不能随意操作内核地址空间,具有一定的安全保护作用。 ​ 至于说保护模式,是说通过内存页表操作等机制,保证进程间的地址空间不会互相冲突,一个进程的操作不会修改另一个进程的地址空间中的数据。

​ 究竟什么是用户态,什么是内核态,这两个基本概念以前一直理解得不是很清楚,根本原因个人觉得是在于因为大部分时候我们在写程序时关注的重点和着眼的角度放在了实现的功能和代码的逻辑性上,先看一个例子:

代码语言:javascript代码运行次数:0运行复制
void testfork()
{
    if(0 == fork())
    	printf(“create new process success!\n”);
    
    printf(“testfork ok\n”);
}

​ 这段代码很简单,从功能的角度来看,就是实际执行了一个 fork(),生成一个新的进程,从逻辑的角度看,就是判断了如果 fork() 返回的是 0 则打印相关语句,然后函数最后再打印一句表示执行完整个 testfork() 函数。代码的执行逻辑和功能上看就是如此简单,一共四行代码,从上到下一句一句执行而已,完全看不出来哪里有体现出用户态和进程态的概念。

​ 如果说前面两种是静态观察的角度看的话,我们还可以从动态的角度来看这段代码,即它被转换成 CPU 执行的指令后加载执行的过程,这时这段程序就是一个动态执行的指令序列。而究竟加载了哪些代码,如何加载就是和操作系统密切相关了

⚜️为什么要有内核态和用户态

​ 在 CPU 的所有指令中,有一些指令是非常危险的,如果错用,将导致整个系统崩溃。比如:清内存、设置时钟等。如果所有的程序都能使用这些指令,那么你的系统一天死机N回就不足为奇了。所以,CPU 将指令分为特权指令和非特权指令,对于那些危险的指令,只允许操作系统及其相关模块使用,普通的应用程序只能使用那些不会造成灾难的指令。

⚜️特权级

​ 熟悉 Unix/Linux 系统的人都知道,fork 的工作实际上是以系统调用的方式完成相应功能的,具体的工作是由 sys_fork 负责实施。其实无论是不是 Unix 或者 Linux,对于任何操作系统来说,创建一个新的进程都是属于核心功能,因为它要做很多底层细致地工作,消耗系统的物理资源,比如分配物理内存、从父进程拷贝相关信息、拷贝设置页目录页表等等,这些显然不能随便让哪个程序就能去做,于是就自然引出特权级别的概念,显然,最关键性的权力必须由高特权级的程序来执行,这样才可以做到集中管理,减少有限资源的访问和使用冲突。

​ 特权级显然是非常有效的管理和控制程序执行的手段,因此在硬件上对特权级做了很多支持,就 Intel x86 架构的 CPU 来说一共有 0~3 四个特权级,0级最高,3级最低,硬件上在执行每条指令时都会对指令所具有的特权级做相应的检查,相关的概念有 CPLDPLRPL,这里不再过多阐述。硬件已经提供了一套特权级使用的相关机制,软件自然就是好好利用的问题,这属于操作系统要做的事情,对于 Unix/Linux 来说,只使用了 0 级特权级和 3 级特权级。也就是说在 Unix/Linux 系统中,一条工作在 0 级特权级的指令具有了 CPU 能提供的最高权力,而一条工作在 3 级特权级的指令具有 CPU 提供的最低或者说最基本权力

⚜️用户态和内核态

​ 现在我们从特权级的调度来理解用户态和内核态就比较好理解了:当程序运行在 3 级特权级上时,就可以称之为运行在用户态,因为这是最低特权级,是普通的用户进程运行的特权级,大部分用户直接面对的程序都是运行在用户态;反之,当程序运行在 0 级特权级上时,就可以称之为运行在内核态。

​ 虽然用户态下和内核态下工作的程序有很多差别,但 最重要的差别就在于特权级的不同,即权力的不同运行在用户态下的程序不能直接访问操作系统内核数据结构和程序,比如上面例子中的 testfork() 就不能直接调用 sys_fork(),因为前者是工作在用户态,属于用户态程序,而 sys_fork() 是工作在内核态,属于内核态程序。

当我们在系统中执行一个程序时,大部分时间是运行在用户态下的,在其需要操作系统帮助完成某些它没有权力和能力完成的工作时就会切换到内核态,比如 testfork() 最初运行在用户态进程下,当它调用 fork() 最终触发 sys_fork() 的执行时,就切换到了内核态。

② 用户态和内核态的转换

从用户态切换到内核态的 3 种方式:

  1. 系统调用,这是用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作,比如前例中 fork() 实际上就是执行了一个创建新进程的系统调用。而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如 Linuxint 80h 指令中断。
  2. 运行异常:当 CPU 在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如硬错误、硬中断、分页错误、寻页缺失、缺页中断、页故障等。
  3. 外设中断:当外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。
⚜️具体的切换操作

​ 从触发方式上看,可以认为存在前述3种不同的类型,但是从最终实际完成由用户态到内核态的切换操作上来说,涉及的关键步骤是完全一致的,没有任何区别,都相当于执行了一个中断响应的过程,因为 系统调用实际上最终是中断机制实现的,而异常和中断的处理机制基本上也是一致的,关于它们的具体区别这里不再赘述。关于中断处理机制的细节和步骤这里也不做过多分析,涉及到由用户态切换到内核态的步骤主要包括:

1、从当前进程的描述符中提取其内核栈的 ss0esp0 信息。

2、使用 ss0esp0 指向的内核栈将当前进程的 cseipeflagsssesp 信息保存起来,这个过程也完成了由用户栈到内核栈的切换过程,同时保存了被暂停执行的程序的下一条指令。

3、将先前由中断向量检索得到的中断处理程序的 cseip 信息装入相应的寄存器,开始执行中断处理程序,这时就转到了内核态的程序执行了。

​ CS寄存器的低两位,叫做CPL(当前特权级),在linux中,有两种状态,就是0和3,0表示内核态,3表示用户态,GDT表和IDT表(中断向量表)中的13位和14位,叫做DPL(目标特权级)。只有当DPL>= CPL时,CPU才允许应用程序进入到内核中,也就是此时为内核态。以系统调用为例子,当发生系统调用时,会产生一个 INT 0x80 中断,接下来CPU自动查中断向量表(CPU的功能),此时CPL = 3(用户态),在表中查 0x80 对应的程序执行,0x80对应的程序是将CPL改为 0,也就是内核态,到这里就已经发生了用户态到内核态的切换了,然后接下来就是一系列的指令执行…


③ 继续理解内核态和用户态以及系统调用

​ 下面我们给出一些图片并继续深入来了解一下用户态和内核态:

在这里插入图片描述

​ 上图中讲述的大概内容就是用户态在运行的时候,如果碰到了系统调用、运行异常和外设中断的时候,这个时候必须是要和内核数据结构、硬件等资源打交道的,而我们不可能直接让用户态直接去访问,因为 OS 不相信任何人!我们无法预知这个用户态身份会对硬件资源等做出什么行为,为了保护底层,我们必须要有内核态,并且我们遇到这几种情况的时候也必须转化为内核态,并且内核会替我们去完成相对于的工作,防止用户级别对底层做出错误动作!

​ 并且我们可以看到,如果一直调用系统调用,比如说我们学过的 getpid()waitpid()write() 等等接口的时候,都必须要转化为内核态,这样子效率就非常的低,这就是为什么我们要 尽量的少用系统调用,因为效率是会有影响的。除此之外还涉及到池化技术,通过预先开辟内存可以避开不停去调用一些系统调用来提高效率,比如说 STL 中的空间配置器等!

一个进程是怎么通过 OS 来执行内核中的方法的呢❓❓❓

​ 还记得我们以前学地址空间的时候,没讲过几个部分,其中就有一个是我们今天的重点:内核空间

​ 下面这张图就涵盖了这些重要的内容:

  1. 每个进程都有各自的进程地址空间,对于 32 位机器来说,其中的 3GB 用户空间所对应的是用户级页表,而剩下的 1GB 内核空间所对应的是一张内核级页表。它们不同的点在于 用户级页表是每个进程独自拥有的,也就是进程之间是独立的;而 内核级页表对于不同进程来说都是共享同一张,它将物理内存中的内核数据和代码映射到了每个进程的内核空间!(比如说开机时候操作系统就要将其加载到内存中,而后我们要访问其接口都是通过内核级页表)
  2. 每一个进程中的内核空间是不能被修改的! 因为这是属于内核级别的,用户层没有这种权利去改动,所以无论进程如何切换,其内核空间都不会收到影响,这样子就保证了操作系统等资源的安全性!并且这也是为什么所有进程看到的操作系统和系统调用都是一样的原因!
  3. 回到原来的问题,一个进程是怎么通过 OS 来执行内核中的方法的呢,很明显,就是 通过内核级页表映射来找到对应的物理内存中的操作系统的代码和数据。举个例子,我们在用户空间中调用了系统调用函数,那么当执行到这行代码的时候,系统会跳转到内核空间中通过内核级页表,查找到物理内存中的代码和数据,接着就返回到程序中继续执行!

​ 在我们的 CPU 中存在着多种寄存器,如上图,一些寄存器中就存放着 task_struct 的起始地址、用户级页表的起始地址、MMU 的起始地址等等!其中有个寄存器叫做 CR3,其中存放的比特位代表的意思是当前进程的运行级别,0 为内核态,也就是最高权限;而 3 表示用户态,权限较低。(上面有详细的解释,其具体和英特尔芯片的硬件是有关系的,这里不过多赘述!)

​ 除此之外还有一些寄存器存放着内核级页表的起始地址,这样子可想而知 CPU 在访问这些资源的时候,速度是非常快的!那么用户凭什么能够执行或者访问内核的接口或者数据呢❓❓❓

​ 这个其实不难,因为每次用户要去访问内核接口的时候,系统都会去查看这个当前进程的运行级别,如果当前的运行级别是 3 也就是用户态则会直接拒绝当前的请求!

那么又有一个问题了:不对啊,我们一般去访问系统调用接口的时候,不就是用户态吗,为什么不会被系统拒绝访问呢,进程什么时候变成内核态的❓❓❓

​ 其实是这样子的,我们 调用的系统接口函数其实还是在用户态执行的,只不过 在这个接口函数中的起始位置,它会帮我们将运行级别从用户态转化为内核态,也就是从 3 号状态改为 0 号状态,这样子当前进程才会有权限去访问操作系统等资源!等到这个系统调用接口执行到结束的时候,这个接口的末尾会有为我们将运行级别重新变为用户态!

而这个接口里面是如何帮我们将运行级别从用户态转化为内核态的呢❓❓❓

​ 其实这就涉及到组成原理以及汇编的知识了,简单的说,在 linux 中可以通过一条 Int 80 指令来继续中断,使得执行级别 “陷入内核”,从而达到从用户态切换到内核态,这个了解即可!

2、信号的捕捉

​ 了解了上面的知识,我们现在尝试着来理解一下信号的捕捉过程,我们先讲过程,然后给出一个大概的过程图帮助理解!

​ 首先我们知道,我们是以用户态的身份在运行着我们的程序,而当我们的程序中比如说遇到一些系统调用、进程切换等情况的时候,这个时候势必需要切换为内核态去执行对应的系统调用,这个我们现在都能理解!

但是内核态执行完代码之后就直接切换为用户态了吗❓❓❓

答案是不会!因为切换身份是有开销的,OS 并不笨,它知道既然切换了身份了,那么就可以再顺便做点其它工作再返回回去也不错,就像张大仙玩我的世界的时候一直说 “来都来了” 一样哈哈哈!

在这里插入图片描述

​ 其实这会 OS 会顺便去检测一下我们学过的 pending 位图、block 位图和 handler 函数数组,如果对应的一些信号产生了,并且没有被阻塞,那么就会将这些信号进行捕捉(也就是处理)!

​ 而我们知道,捕捉的方法有三种:默认、忽略、自定义。前两种并不难理解,它们都会在内核态就被解决,但是自定义捕捉呢,我们自定义的函数不是在用户态层面上的吗❓❓❓

在这里插入图片描述

​ 我们必须知道一件事情:我们是不能以用户态的身份执行内核态的代码的,但是内核态却可以执行用户态的代码!那么问题来了,当前进程有必要从内核态切换为用户态去执行自定义捕捉函数吗❓❓❓

​ 答案肯定是 必须切换!为什么呢,因为 OS 不相信任何人,如果我们让当前进程以内核态的身份去执行用户态的代码,万一这份自定义代码中存在着一些不安全的行为比如说调用 fork 创建子进程之后又进行进程替换后进行 rm -rf *./ 这种操作,那么是十分危险的!

​ 所以结论就是:内核态从技术上可以执行用户态的代码,但是从设计上是决定不允许的!

​ 既然这样子,接下来就是要先从内核态转化为用户态,然后以用户态的身份去执行自定义捕捉函数,那么接下来呢,是直接返回到源程序调用系统接口下面往下走吗❓❓❓

​ 答案肯定不是,因为我们必须让内核态知道上面执行的自定义函数已经是完成的,并且我们一开始是从用户态转化为内核态,所以我们 最后返回去也要以内核态转化为用户态的形式! 最后整个捕捉的过程就是如下所示:

​ giao…是不是感觉很复杂啊整个过程,看这图有点晕了还,所以我们可以对图进行简化一下!

​ 这样子一来整个过程看的就非常的清晰了,这不就是一个蝴蝶结吗哈哈哈,或者说是数学里面的无穷大!

​ ⚜️注意:上述这种情况是针对自定义捕捉来说的,对于默认和忽略动作,并不会这么复杂!

​ 下面再贴一个参考图:

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

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。 原始发表:2025-04-11,如有侵权请联系 cloudcommunity@tencent 删除进程内核系统程序函数

本文标签: 进程信号四信号的捕捉