Linux“信号“从硬件到软件详解

1.什么是信号

信号本质上是一种用户、OS、其他进程向目标进程发送异步事件的一种方式;所谓的异步事件就是指突然发生的事件;进程是怎么识别信号的呢?其实信号是内置的,也就是程序员给每个进程都内置了信号的特性,因此进程是认识信号的;进程处理信号必须是立即的吗?不是的,如果进程有优先级更高的事情要做,可能不会立即处理而是在合适的时候。进程处理信号有三种方式,分别是下面的;

处理方式 英文 / 宏 含义(最简单解释) 典型用法
忽略信号 SIG_IGN 内核直接丢掉信号,进程完全不处理 忽略某些不影响运行的信号
默认处理 SIG_DFL 按系统默认动作 处理(终止 / 暂停 / 忽略等) 不自定义处理时系统自动用
捕获(自定义) 自定义函数 收到信号时,执行自己写的函数 做善后、日志、优雅退出

2.信号的产生

2.1键盘产生

补充知识:进程分为前台进程和后台进程,ctrl+C只能中止前台进程;

所以ctrl C其实就是给我们的前台进程发信号;

其实我们的进程对信号自定义操作使用的是信号自定义捕捉函数

typedef void(*sighandler_t) (int) 是一个函数指针类型;

而signal这个函数的作用是signal() 是 C 语言中设置信号处理方式的基础函数,简单说就是:告诉操作系统 "当进程收到某个信号时,该执行什么操作"(忽略 / 默认 / 自定义处理)。

参数名 含义 新手常用值 / 示例
signum 要处理的信号编号(告诉函数 "要设置哪个信号") SIGINT(2,Ctrl+C)、SIGTERM(15,终止信号)、SIGSEGV(11,段错误)
handler 信号的处理方式(告诉函数 "收到信号后做什么") 1. SIG_IGN:忽略该信号2. SIG_DFL:使用系统默认行为3. 自定义函数名:执行自己写的处理函数

1-31 普通信号,31-64实时信号,跟我们学习的没有关系;

Ctrl +C Linux会解释为2号信号STGINT

测试信号捕捉

通过下面的代码使用signal函数,将原本用来终止前台进程的信号SIGINT的默认操作改为执行我们的自定义操作执行函数Handler;这样我们发现在进程执行的过程中,我们输入Ctrl + C,输出的是signal handler ,不再是终止进程;

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

void Handler(int signo)
{
    std::cout <<"signal handler"<<std::endl;
}

int main()
{
    signal(SIGINT,Handler);
    while(true)
    {
       std::cout <<"good luck"<<std::endl;
       sleep(2);
    }
    return 0;
}

现在我们使用Ctrl C进程不退出了该怎么办呢?不用担心,我们的信号还有很多,就比如说我们的Ctrl \,就是给我们的进程发3号信号,SIGQUIT,当然它也可以使用signal函数,进行信号捕捉,让执行我们新的操作;

signo 是大家给信号处理函数的参数起的常用变量名 (也可以写成 sig/signal_num,只是习惯叫 signo),作用是:

👉 当信号触发时,操作系统会把这个信号的编号 传给处理函数,signo 就是用来接收这个编号的变量。

呢我们能不能把我们的进程都捕捉了呢?让执行我们默认的方法,这样的话,是不是就永远删不掉我们的进程了呢?emmmmmm,其实还有一个信号,9号信号是没办法被捕捉的,所以我们可以使用这个方法;

查pid

bash 复制代码
# 终极版:带表头 + 只查目标进程 + 排除grep自身
ps -aux --headers | grep -v grep | grep a.out

使用9号信号杀死进程

bash 复制代码
kill -9 进程名
cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>

void Handler(int signo)
{
    std::cout <<"Get a signal ,signal number is :"<<signo <<std::endl;
}

int main()
{
    
    for(int signo=1 ;signo <32 ;signo++)
    {
        signal(signo,Handler);
        std::cout << "自定义捕捉信号:"<<signo <<std::endl;
    }
    while(true)
    {
       std::cout <<"good luck"<<std::endl;
       sleep(2);
    }
    return 0;
}

2.1.2进程如何记录信号

在PCB中维护了一个位图,其中对应的信号来了就将对应的信号的比特位由0置为1;

软件------如何理解信号处理

所以发送信号的本质其实是写入信号,因为是操作系统对目标进程的比特位由0置1,操作系统有权利将它的比特位置为1,因为操作系统是进程的管理者,而且只有操作系统有这样的权力,所以信号产生有很多方法,但是信号发送只有操作系统一种方法;

所以注册的时候其实是修改这张表的内容;

硬件------如何理解信号处理

操作系统如何知道我们的键盘被按下了?

当键盘按键被按下时,键盘会向主板的键盘控制器 发送电信号,控制器将信号转为中断请求(IRQ) ,通知 CPU(中央处理器)。CPU 暂停当前任务,调用操作系统的中断处理程序 。OS 从键盘接口读取扫描码 ,将其翻译成对应的字符或功能键,再把事件放入输入缓冲区 。应用程序通过系统调用读取数据,最终感知按键。整个过程由硬件中断 + 内核驱动配合完成,OS 不需要一直轮询查询。

所以信号其实是在模拟中断的;

2.2指令产生

指令底层也是使用的系统调用,

2.3 系统调用

①kill 系统调用接口

② raise 系统调用接口

cpp 复制代码
nt main()
{
    int cnt = 10;
    int i=1;
    while (true)
    {
        std::cout << "哈哈哈哈哈哈,我又活了:"<< i <<"天"<< std::endl;
        cnt--;
        i++;
        sleep(1);
        if (cnt <= 0)
        {
            std::cout << "糟糕,我死了!" << std::endl;
            raise(9);
        }
      
    }

    // for(int signo=1 ;signo <32 ;signo++)
    // {
    //     signal(signo,Handler);
    //     std::cout << "自定义捕捉信号:"<<signo <<std::endl;
    // }
    // while(true)
    // {
    //    std::cout <<"good luck"<<std::endl;
    //    sleep(2);
    // }
    return 0;
}

③ abort系统调用函数

2.4软件条件

比如说我们的管道读端关闭,我们的操作系统就会给进程管道写端发送进程终止的信号;

还有我们给自己设置一个闹钟函数,时间到了OS就会给对应的闹钟进程发送一个SIGALEM信号,终止进程;这样的调用信号终止进程的方法就叫做软件中断;

cpp 复制代码
int number =0;
void die(int signo)
{
    printf("number :%d ,signo %d",number,signo);
    exit(0);
}

int main()
{
   alarm(3);
   signal(SIGALRM,die);
   while(true)
   {
     number++;
   }
  
   return 0;
}

这就是我们设置的给一个进程通过调用闹钟中断进程的小demo;

理解闹钟:

其实闹钟是操作系统中的定时器,很多进程都可以设置闹钟,所以我们的操作系统其实要对每个进程的定时器进行管理,先描述后组织,所以我们把定时器描述成一个结构体,使用链表+hash将他们进行管理;

alarm的返回值是闹钟的剩余时间;alarm(0)在操作系统看来是想要取消闹钟;其实OS的执行对应的操作就是使用硬件中断;

cpp 复制代码
using func_t = std::function<void()>;
std::vector<func_t> task;
int gnumber =0;

void Hander(int signo)
{
    for(auto & f :task)
    {
       f();
    }
    alarm(1);
    std::cout<<"gnumber"<<gnumber<<std::endl;
}

int main()
{


    task.push_back([](){std::cout << "我是一个下载任务"<< std::endl;});
    task.push_back([](){std::cout << "我是一个日志任务"<< std::endl;});
    task.push_back([](){std::cout << "我是一个mysql任务"<< std::endl;});
    alarm(1);  //我们的这个闹钟是一次性的闹钟,想让任务一直被调用,
               //应该在while中循环设置我们的闹钟
    signal(SIGALRM,Hander);
    while(true)
    {
        pause();
        std::cout<<"我醒来了..."<<std::endl;
        gnumber++;
    } 
    return 0;
}

2.5 异常中断

补充知识:CR3寄存器负责页表中虚拟地址和物理地址转换;

而虚拟地址到物理地址转换其实是一个MMU的硬件完成的,并不是我们的OS;现在这个MMU硬件已经被集成在了CPU中

①野指针(段错误)

有了野指针之后,OS会给对应进程发送11号信号;而OS发现野指针错误其实是因为通过页表映射的时候,发现不了对应的值;

②浮点数异常

OS如何知道div(0)会出现异常?

其实是我们的CPU有状态寄存器,当计算出现问题的时候,状态寄存器溢出标记位由0变为1,CPU发生硬件中断,让OS知道,这样OS就知道是哪个进程出错了;

OS给进程发送8号信号;

③core vs Term

Term表示正常退出。

core在当前目录下形成文件,pid.core,进程崩溃的时候,将进程在内存中的部分信息保存起来,方便后续调试;

如果是子进程出异常了呢?

3.信号的保存

信号没有被立即执行的时候是会被保存在进程的PCB信号位图中的;

3.1信号捕捉的三种方式

信号忽略,信号自定义,信号默认

信号忽略和信号默认分别是1号和0号信号;

①忽略本身就是信号捕捉的一种,只不过它的默认动作是忽略;

cpp 复制代码
int main()
{

   ::signal(2,SIG_IGN); //ignore 信号
   while(true)
   {

      pause();

   }
  
}

②信号默认

cpp 复制代码
int main()
{

   ::signal(2,SIG_DFL); //ignore 信号
   while(true)
   {

      pause();

   }
  
}

3.2关于信号的补充知识

阻塞每个信号,理解成屏蔽某个信号会更好,但是屏蔽并不是忽略,因为只要信号被屏蔽就不会递达,而忽略是在抵达之后可选的一种处理动作;

3.3内核中如何处理信号的三种保存方式

① pending表中维护的是信号位图;

处于信号从产生到抵达之间的状态,称为信号未决;pending表的编号表示信号编号,pending表的内容表示是否收到对应的信号

②handler表其实是一个函数指针数组,对应的是信号抵达的概念;

我们发现handler表中存储的就是我们的函数信号,而handler表的编号就是信号编号,而我们使用signal(2,handler)去让执行我们自定义的方法其实就是拿着2-信号的数组下标去修改对应的函数内容;

③block表也是一个位图,bit位的编号表示信号编号,但是信号内容表示的是信号是不是阻塞或者屏蔽;我要屏蔽一个信号和我是否收到这个信号是没有关系的,因为他们都有各自的位图;

学了这三个表的具体内容,我们应该想到对于这三张表我们应该横着看,这样对一个信号,我们就能知道它是屏蔽了吗,递达了吗,信号内容是什么;

3.4sigset_t(信号集)

每个信号只有⼀个bit的未决标志, 非0即1, 不记录该信号产生了多少次,阻塞标志也是这样表示的。因此, 未决和阻塞标志可以用相同的数据类型sigset_t来存储,, 这个类型可以表示每个信号的"有效"或"无效"状态, 在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞, 而在未决信号集中"有 效"和"有效"的含义是该信号是否处于未决状态。信号集的底层 = 位图(bitmask)结构图 = sigset_t 包裹一个 unsigned long 数组,每一位代表一个信号。

3.5 信号集的操作函数

因为操作系统不想我们直接使用位图对对应的信号集进行处理,所以将给我们封装了以下函数

第5个函数的作用说错了 ,是判断一个信号是否在信号集中;

①sigpromask (修改block表)

可以对信号block表进行处理;调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集

cpp 复制代码
块
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1

如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改

进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里面,然后 根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。

如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中⼀

个信号递达。

② sigpending (检查信号的抵达状态)

3.6 代码测试

4.信号捕捉

4.1操作系统是怎么运行的

操作系统有两种状态 ①用户态 ②内核态

用户态执行自己的代码,内核态执行OS的代码

当用户态main()函数执行过程中因中断、异常或系统调用陷入内核态后,内核在完成异常处理、准备返回用户态前,会调用do_signal()函数检查并处理当前进程的待处理信号;若信号绑定了自定义sighandler处理函数,内核会保存主流程上下文,修改用户态返回地址,切换回用户态直接执行该信号处理函数,而非回到原中断点;sighandler执行完毕时,会通过特殊系统调用sigreturn再次进入内核态,由sys_sigreturn()恢复之前保存的主流程上下文,最终返回用户态,从main()函数上次被中断的指令处继续执行,整个过程体现了信号的异步性与内核态、用户态的两次上下文切换,保障了主流程的完整恢复。

信号捕捉流程简化图

①硬件中断

首先操作系统是我们的机器开机之后的启动的第一个软件,并且只有关机才会被关闭

展示的外设中断处理流程 ,本质是操作系统(OS)协调硬件与 CPU 的核心机制:当外部设备就绪后向中断控制器发起中断请求,中断控制器仲裁后分配中断号并通知 CPU,CPU 立即暂停当前任务、将寄存器状态压栈保护现场,随后根据中断号查询由 OS 在启动时初始化并维护的中断向量表(IDT),跳转到 OS 预先注册的对应中断服务例程(如处理键盘、网卡、磁盘的内核函数),由 OS 内核完成设备数据读取、状态更新及进程唤醒等核心操作,处理完毕后 OS 恢复 CPU 之前保存的寄存器现场,结束中断并让 CPU 继续执行被打断的原任务,整个过程中 OS 既是中断处理规则的制定者(初始化 IDT、注册 ISR),也是中断业务逻辑的实际执行者,保障了 CPU 与外设高效、有序的异步通信。

②时钟中断

进程可以在操作系统的指挥下被调用被执行,呢么操作系统自己是被谁指挥,被谁推动执行呢?

时钟源与OS 紧密关联,它本质是一套由时钟中断驱动的事件循环系统 :开机时 CPU 将内核加载到内存并初始化后,硬件时钟源(如 HPET、APIC 定时器、RTC)会以固定频率周期性产生时钟中断 ,这是操作系统最核心的 "心跳" 触发源 ------ 每次时钟中断都会强制打断当前执行流,让 CPU 陷入内核态执行时钟中断服务例程,操作系统借此完成时间片轮转调度(判断是否要切换进程)、系统时间更新、定时器到期处理(如睡眠进程唤醒)、资源统计等核心后台任务;同时,外设中断、异常、系统调用等事件会与时钟中断交织触发,共同推动操作系统执行,但时钟源是最基础的 "节拍器",没有它,操作系统就无法实现进程抢占式调度、时间管理等关键功能,整个系统会陷入停滞,因此可以说操作系统是以时钟中断为核心节拍、各类硬件 / 软件事件共同驱动的自驱系统,时钟源是维持其持续运转的底层动力。而当代的计算器已经将时钟源集合在了CPU中,现在叫做主频,比如2.6hz,这也就是为什么计算机的主频越快,我们的计算机越好,因为对OS的调用越频繁;

③死循环

外部设备可以触发硬件中断,但是这个是需要用户或者设备自己触发的,有没有自己可以定期触发的设备呢?

系统里有一个定时器硬件(时钟源) ,它不需要用户、不需要程序触发,自己就会每隔固定时间自动产生一次中断 ,就像心脏自动跳动一样,这种周期性自动触发的中断就是时钟中断 ;操作系统靠它来推进时间、切换进程、做定时任务,是整个系统持续运转的 "动力源";而死循环是 CPU 一直执行某段代码不退出,它和时钟中断的关系在于:如果没有时钟中断,一个死循环进程会永远霸占 CPU 让系统卡死,但时钟中断会定期打断它,强制回到操作系统做进程调度,所以时钟源是系统自动、定期、主动触发的核心设备,死循环必须靠它才能被打断,系统才不会卡死

所以我们的OS其实就是一个死循环,需要通过中断来执行相应的任务,如进程调度就是一个;

④时间片(CPU中的计数器)

系统里有个时钟源 硬件,会每隔几毫秒自动触发一次时钟中断 ,不需要任何人操作;每次中断都会强制把 CPU 交给操作系统,操作系统就用这个固定间隔作为时间片 ------ 也就是给每个进程分配一小段 CPU 使用时间,时间一到就通过时钟中断强行收回 CPU,切换下一个进程运行,以此实现多任务同时执行;如果某个进程是死循环一直霸占 CPU,时钟源依然会定期产生中断,让操作系统强制打断它、切换进程,系统就不会卡死,所以时钟源是产生节拍的根源,时钟中断是触发信号,时间片就是操作系统用这个固定节拍划分出来的 CPU 执行单位,三者环环相扣,共同保证系统能公平、有序、抢占式地运行多个进程。

⑤软中断

软中断就是指:并非由外部硬件设备主动触发,而是通过软件方式在程序内部主动触发的中断机制 ------ 具体来说,是利用 CPU 提供的专用汇编指令(如 intsyscall),在程序运行时主动触发 CPU 的内部中断逻辑,因为CPU必须得有自己可以认识的指令集,也就是它内部只能认识它自己内置的指令集,所以CPU工程师在CPU中设定了对应的指令集,而system_call就是一个可以被CPU认识的二进制,当CPU看到这个二进制就会执行中断,也就是我们说的软中断;从而让操作系统介入处理,实现系统调用等软件层面的请求与交互,它与硬件中断共享 CPU 的中断处理流程,但触发源来自软件程序而非外部硬件设备;用户和操作系统之间的媒介是CPU和系统调用函数,而为了让我们的CPU认识我们的系统调用,因此 需要把我们的系统调用号提前写进我们的CPU寄存器中,并且我们写的函数的形参其实也是先把实参写到了CPU的寄存器中;

所以系统调用也是通过中断完成的;

而系统调用接口是我们的glibc封装的;每一种中断都有对应的中断号,操作系统会根据具体的中断号在中断向量表中查找对应的中断方法;缺页中断?内存碎片处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断,然后走中断处理例程,完成所有处理。有的是进行申请内存,填充页表,进行映射的。有的是用来处理内存碎片的,有的是用来给目标进程发送信号,杀掉进程等等。

0X80和system这种中断只是为了在我们调用系统调用函数的时候陷入内核进行系统调用的软中断,我们把这种中断叫做陷阱;所谓的缺页中断其实就是OS发现对应变量有虚拟地址但是没有物理地址,而没有办法进行访问物理内存,这就叫做缺页中断;

3.6再谈内核态和用户态

用户级页表每个进程都有一个,而内核级页表所有进程只有一个;如果修改源代码让操作系统页表可以映射到不同的操作系统,呢么进程之间不久可以更加独立了吗,这就是内核级虚拟机的实现方法;

无论是通过哪一个进程的地址空间访问内核都是通过软中断进入操作的;用户态和内核态在CPU中有对应的寄存器访问对应的权限,0是内核态,3是用户态;Linux中权限级别只有0和3,没有1和2,这是CPU自己设置的,跟操作系统没有关系,所有是什么态是CPU自己的条件;

① 操作系统的内核固定历程:即操作系统为了将内存中的数据刷新到外设上、检查我们的闹钟是否到时间了等这样的操作,会设置自己的进程,我们把这样的进程叫做操作系统的内核固定历程;

4.信号捕捉的操作

4.1sigaction

cpp 复制代码
#include <unistd.h>
#include <signal.h>
#include <cstdio>
#include <functional>
#include <vector>

void handler(int signo)
{
    static int cnt=0;
    while(true)
    {
       std::cout << "get a signal"<< signo <<"cnt:"<<cnt++ <<std::endl;
       sleep(1);
    }
   
    // exit(1);
}


int main()
{
    struct sigaction act ,oact;
    act.sa_handler=handler;
    ::sigaction(2,&act,&oact);
    while(true)
    {
        pause();
    }
    return 0;
}

信号在处理中未处理完的时候 ,再触发相同的信号,OS会把后面触发的相同信号的block位置由0置为1,直到原来的信号处理完才会处理被屏蔽的信号;怎么证明呢?打印block位图;

cpp 复制代码
void PrintBlock()
{
    sigset_t set, oset;
    sigemptyset(&set);
    sigemptyset(&oset);
    ::sigprocmask(SIG_BLOCK, &set, &oset);
    for (int signo =31; signo > 0; signo--)
    {
        if (sigismember(&set, signo))
        {
            std::cout << 1;
        }
        else
        {
            std::cout << 0;
        }
    }
    std::cout << std::endl;
}

void handler(int signo)
{
    static int cnt = 0;
    while(true)
    {
        std::cout << "get a signal" << signo << "cnt:" << cnt++ << std::endl;
        sleep(1);
        PrintBlock();
    }
    
    exit(1);
}

int main()
{
    struct sigaction act, oact;
    act.sa_handler = handler;
    ::sigaction(2, &act, &oact);
    while (true)
    {
        PrintBlock();
        pause();
    }
    return 0;
}

5.可重入函数

一个函数被两个以上的执行流同时进入了,称为重入;

出问题了,该函数是不可重入函数;

没出问题,该函数不是可重入函数;

对比项 可重入函数(Reentrant) 不可重入函数(Non-reentrant)
核心定义 可以在执行中被打断,再次安全进入执行,结果正确 执行中被打断后再次进入,会导致数据错误 / 崩溃
使用变量 仅使用局部变量、函数参数(栈变量) 使用全局变量、静态变量、共享资源
中断 / 信号调用 绝对安全 极不安全,会产生数据污染
共享状态 无任何共享状态 依赖共享状态
打断后恢复 恢复执行不受影响 数据被覆盖、逻辑混乱
常见例子 memcpy、strcpy、纯局部变量函数 printf、malloc、带全局变量的函数
适用场景 信号处理函数、中断处理、多任务环境 普通单线程主流程

6.volatile关键字(易变)

volatile 告诉编译器:这个变量会被意外修改,不要优化(放在CPU的寄存器中),每次都必须从内存读最新值。

volatile 是 C 语言中用来告诉编译器该变量的值随时可能被意外改变的关键字,它的核心作用只有三个:

①禁止编译器优化编译器不会把变量缓存到寄存器里,每次读写都必须直接从内存读取 / 写入。

②保证变量的可见性确保每次使用变量时,都能拿到最新的值,而不是旧的缓存值。

③防止指令重排编译器和 CPU 不能打乱 volatile 变量的执行顺序;

相关推荐
A小辣椒1 天前
TShark:Wireshark CLI 功能
linux
A小辣椒1 天前
TShark:基础知识
linux
AlfredZhao1 天前
OCI 明明分配了 200G 系统盘,为什么 df 只看到 30G?
linux·oci
AlfredZhao2 天前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户9718356334662 天前
银河麒麟 KY10 申威(SW64) 安装 nginx-1.16.1-2.p01.ky10.sw_64.rpm 详细步骤
linux
猪脚踏浪2 天前
linux 拷贝文件或目录到指定的位置
linux
大树883 天前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
摇滚侠3 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
霸道流氓气质3 天前
领域驱动设计(DDD)在 Spring Boot 微服务中的实践指南
运维·spring boot·微服务
bush43 天前
嵌入式linux学习记录十四、术语
linux·嵌入式