深入了解linux系统—— 信号的捕捉

前言

信号从产生到处理,可以分为信号产生、信号保存、信号捕捉三个阶段;了解了信号产生和保存,现在来深入了解信号捕捉。

信号捕捉

对于1-31号普通信号,进程可以立即处理,也可以不立即处理而是在合适的时候处理;

在合适的时候处理信号,什么时候合适呢?

信号捕捉的流程

要了解信号捕捉的流程,先要了解内核态和用户态;

简单来说,内核态就是以操作系统的身份去运行;而用户态就是以用户的身份去运行。(后面再详细说明)

这里直接来看信号捕捉的流程:

我们的进程在正常执行,在执行到某条指令,因为系统调用、中断或异常从而进入内核;

而内核处理完异常之后,准备回到用户之前,就会处理当前进程可以递达的信号;

处理信号,执行so_signal方法,如果进程对于信号是自定义捕捉,处理信号就要从内核态回到用户态处理信号;

自定义捕捉完信号之后,就要再回到内核态,然后由内核态再回到用户态,从上次被中断的地方继续向下执行。

以自定义捕捉为例,信号捕捉的流程如下图所示:

所以,在信号捕捉的整个流程中,存在4次用户态和内核态的转换;简化成以下图:

简单总结描述信号捕捉流程:

  1. 用户进程执行
    • 进程在用户空间正常执行代码
  2. 进入内核
    • 发生系统调用/中断/异常 → CPU自动切换到内核态
  3. 内核处理事件
    • 内核完成系统调用/中断/异常的处理
  4. 信号检查
    • 内核返回用户态前检查信号:
      有未处理且未阻塞的信号? → 继续
      无信号 → 直接返回用户态
  5. 准备信号处理 (针对自定义信号)
    • 内核在用户栈创建"信号栈帧"(包含):
      • 信号处理函数地址
      • 原始执行状态(寄存器值)
      • rt_sigreturn系统调用地址
  6. 第一次返回用户态
    • 内核修改CPU状态:
      • 指令指针 → 信号处理函数
      • 栈指针 → 新信号栈帧
    • 切换到用户态执行信号处理函数
  7. 信号处理完成
    • 信号处理函数执行结束(return语句)
    • 自动跳转到rt_sigreturn系统调用
  8. 第二次进入内核
    • 执行rt_sigreturn系统调用 → 进入内核态
    • 内核从信号栈帧恢复原始状态
  9. 最终返回用户态
    • 内核切换回用户态
    • 进程从当初被中断的位置继续执行

操作系统运行

要了解操作系统是如何运行的,就要先了解一些硬件相关知识

硬件中断

硬件中断是外部硬件设备 (如键盘、鼠标、硬盘、网卡、定时器芯片等)向 CPU 发出的一种紧急通知信号 ,意思是"我有重要的事情需要你马上处理!"

就像OS是如何知道键盘上有数据那样,并不是OS定期去排查,而是键盘给CPU发送中断,从而让CPU执行OS中对应的方法。

如上图所示,存在一个中断控制器,其中每一个中断号都对应一个外部设备;

  • 当外部设备就绪时,就会向中断控制器发送中断,中断控制器就会通知CPU存在中断;(向CPU对应针脚发送高低电频)
  • CPU就会获取中断号,然后中断当前工作并保护现场(保存临时数据等);
  • OS中存在中断向量表,其中存储了对于每一个中断号的对应处理方法;
  • CPU就会根据中断号,去执行中断向量表这对应的中断处理方法。

中断向量表是操作系统的一部分,在启动时就会加载到内存;

通过外部硬件中断,操作系统就不需要对外设进行周期性检测;而是当外部设备触发中断时,CPU就会执行对应的中断处理方法。

这种由外部设备触发,中断系统运行流程,称为硬件中断

时钟中断

有了硬件中断,操作系统就无序去对外设进程周期性检测;

而操作系统不光要管理硬件资源,也要进行进程调度;那能否按照硬件中断的原理,定期的向CPU发送中断,从而定期的执行操作系统的进程调度方法。

所以,就有了时钟源(当代已经集成在CPU内部);就会定期的向CPU发送中断,CPU通过中断号去执行中断向量表中对应的进程调度方法。

那这样,定期的向CPU发送中断,也就是定期执行进程调度方法;那进程的时间片,本质上就是一个计数器了,每次调度进程就让进程的时间片计数器--,当减到0时就说明进程时间片用完,就指定进程调度算法,执行下一个进程。

CPU存在主频,主频指的就是时钟源向CPU发送中断的频率,主频越快,CPU单位时间内就能够完成更多的操作;CPU就越快。

死循环

有了硬件中断和时钟中断,那操作系统只需要将对应功能添加到中断向量表中,那操作系统还需要干什么呢?

操作系统的本质:就是死循环

c 复制代码
void main()
{
	//......
	for(;;)
		pause();
}

通过查看内核,我们也能够发现,操作系统在做完内存管理等任务之后,就是死循环。

软中断

上述硬件中断、时钟中断都是由硬件触发的中断;除此之外呢,也可能因为软件原因触发上述中断。

为了让操作系统支持进行系统调用,CPU中也设计了汇编指令int(或者syscall),让CPU内部触发中断逻辑。

在这里就要了解一下系统调用了,在之前的认知中,系统调用是由操作系统通过的,我们是直接调用系统调用;

但是,在操作系统中,所有的系统调用都存储在一张系统调用表当中;(这张系统调用表用于系统调用中中断处理程序)

我们所调用的系统调用openwrite等等,都是由glibc封装的;

而想要让CPU执行对应的方法,就要让CPU直到对应的系统调用号;

CPU根据系统调用号,然后查表才能调用对应的方法。

通过观察,我们也可以发现在glibc的封装实现,是先将系统调用号写入寄存器eax;然后再syscall触发软中断,让CPU根据eax寄存器中的系统调用号执行对应的方法。

内核态和用户态

在信号捕捉流程中,存在一个概念就是:内核态和用户态;

我们知道在进程运行时,通过系统调用或者中断等等陷入内核,进入内核态;而在进行自定义处理时,再有内核态回到用户态;自定义处理完成之后,再通过特定的系统掉用再进入内核态;最后才回到最初中断的位置,由内核态进入用户态。

那内核态和用户态是什么呢?

简单来说,内核态就是以操作系统的身份执行;用户态就是以用户的身份执行。

在虚拟地址空间(进程地址空间中),[0,3]GB是用户空间,我们程序的代码数据、动态库等等都在用户这3GB中;而[3,4]GB是内核空间;

在我们的程序中,我们可以返回自己实现的方法、可以调用库函数;这都是在[0,3]GB用户空间内进行跳转的。

执行对应的代码时,使用用虚拟地址通过页表(用户页表)映射物理地址处,就可以找到对应的代码和数据。

而在我们调用系统调用时,在进程地址空间中,就要从[0,3]GB用户空间跳转到[3,4]GB的内核空间;这样在执行时,通过内核页表映射,找到对应内核的代码运行。

当然,在内核中存在许多进程,这些进程都可能会调用系统调用;而在每一个进程的进程地址空间中的[3,4]GB都是内核空间,都可以通过页表(内核页表)映射,找到内存中操作系统的代码。

所以,我们在进行系统调用时,不用去担心进程能否在内存中找到对应的地址,因为在进程[3,4]GB内核空间中,有了虚拟地址,通过内核页表映射,就能够在内存找到对应的物理地址。

所以,系统调用的执行就是在进程地址空间中进行的。

说了这么多,简单总结就是:

  • 用户态就是,在进程地址空间中,通过[0,3]GB用户空间的虚拟地址,进行页表映射,执行用户自己的代码
  • 内核态就是,通过[3,4]GB内核空间的虚拟地址,进行页表映射,执行操作系统的代码

问题:如何知道虚拟地址是[0,3]GB用户空间的地址还是[3,4]GB内核空间的地址?(CPU执行时如何知道是用户态还是内核态)

在页表当中,记录的不仅仅是虚拟地址和物理地址的映射关系,还用权限(rw)以及当前身份。

此外,在硬件上也存在对应标志:CPU中的Cs段寄存器对应标志位:00(二进制)代表内核、11(二进制)代表用户。

可重入函数

可重入函数是指可以被多个执行流(例如线程、中断处理程序、信号处理程序)同时调用,而不会产生错误或意外结果的函数

简单来说就是:

一个可重入函数在执行过程中,如果被另一个执行流打断并再次进入该函数,当恢复执行时,它仍然能够正确完成其任务,不会破坏自身的数据或全局状态。

如上图所示,在调用insert时,执行至某位置,进程收到信号转而去执行handler方法,而在handler方法中有调用了insert方法;这样导致了最终的结果不符合我们的预期。

对于一个可重入函数,该函数要满足:

  1. 不使用静态(全局)或非常量静态局部变量: 这些变量在内存中只有一份拷贝,如果多个执行流同时修改它们,会导致数据不一致。
  2. 不返回指向静态数据的指针: 调用者可能会修改这些数据,影响其他执行流。
  3. 仅使用调用者提供的数据或自己栈上的局部变量: 每个执行流(线程/函数调用实例)都有自己的栈空间,局部变量是独立的。
  4. 不调用不可重入的函数: 如果它调用的函数本身是不可重入的(比如使用了全局状态),那么它自己也就变得不可重入了。
  5. 不修改自身的代码: 通常这不是问题,但某些特殊场景(如自修改代码)需要考虑。
  6. 不依赖外部硬件状态(除非以原子方式访问): 比如多个执行流同时操作同一个硬件寄存器可能造成冲突。

volatile

volatileC语言中的一个关键字,这个关键字的在之前的学习中并没有使用过;

volatile关键字用来修饰一个变量,其作用就是,告诉编译器该变量的值可能会变化,让编译器不要对其进程优化,让CPU每次访问该变量的值都从内存中获取。

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

int flag = 0;
void handler(int signum)
{
    std::cout << "change flag 0 -> 1" << std::endl;
    flag = 1;
}
int main()
{
    signal(2, handler);
    int cnt = 0;
    while (!flag)
    {
        std::cout << "flag :" << flag << std::endl;
        sleep(1);
    }
    return 0;
}

在上述代码中,main函数while(!falg),当flag = 0时,循环一直在进行;

当进程收到2号信号时,执行自定义处理handler方法,修改falg

预期结果就是:进程在收到2号信号时,flag修改为1,循环就结束了。

正常来说,CPU在执行进程时,访问flag变量都是从内存中读取;而在main函数中并没有修改flag变量,一些编译器就会对其进行优化,将flag变量直接写入CPU寄存器中。

volatile修饰变量就是告诉编译器不要进行优化,每次都从内存中读取变量的值。

SIGCHLD信号

这里简单了解一些SIGCHLD信号;

SIGCHLD信号是子进程退出时,操作系统给父进程发送的一个信号。

我们知道,子进程在退出时,会进入僵尸状态,等待父进程回收退出信息;就要父进程等待子进程。

而如果我们不关心子进程的退出信息,我们就可以将父进程对于SIGCHILD信号的处理方式设置成SIG_IGN

这样子进程在退出时,操作系统给父进程发送SIGCHLD信号,父进程SIG_IGN,此时子进程的task_struct就会立即被回收,不需要父进程等待。

cpp 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
    signal(SIGCHLD, SIG_IGN);
    int id = fork();
    if(id < 0)
        exit(1);
    else if(id == 0)
    {
        printf("child process pid : %d\n",getpid());
        sleep(1);
        exit(1);
    }
    int cnt = 3;
    while(cnt--)
    {
        printf("parent process pid : %d\n",getpid());
        sleep(1);
    }
    return 0;
}

可以看到,子进程退出后,父进程没有等待wait;子进程也没有出现僵尸状态。

但是,可以看到进程对于SIGCHLD信号的处理方式是Ign;那为什么不调用signal(SIGCHLD, SIG_IGN),父进程不等待,子进程就要进入僵尸状态呢?

这里,进程对于SIGCHLD信号的处理方式是默认处理SIG_DFL,而默认处理的方式是Ign

SIG_IGN不一样,操作系统设置成默认处理SIG_DFL,默认处理的方式是Ign;这样在子进程退出后,父进程就可以随时获取子进程的退出信息,回收子进程了。

相关推荐
Kapaseker1 小时前
一杯美式搞懂 Any、Unit、Nothing
android·kotlin
黄林晴1 小时前
你的 Android App 还没接 AI?Gemini API 接入全攻略
android
恋猫de小郭11 小时前
2026 Flutter VS React Native ,同时在 AI 时代 VS Native 开发,你没见过的版本
android·前端·flutter
冬奇Lab12 小时前
PowerManagerService(上):电源状态与WakeLock管理
android·源码阅读
BoomHe18 小时前
Now in Android 架构模式全面分析
android·android jetpack
甲鱼92920 小时前
MySQL 实战手记:日志管理与主从复制搭建全指南
运维
二流小码农1 天前
鸿蒙开发:上传一张参考图片便可实现页面功能
android·ios·harmonyos
鹏程十八少1 天前
4.Android 30分钟手写一个简单版shadow, 从零理解shadow插件化零反射插件化原理
android·前端·面试
Kapaseker1 天前
一杯美式搞定 Kotlin 空安全
android·kotlin
三少爷的鞋1 天前
Android 协程时代,Handler 应该退休了吗?
android