一、信号的基本概念

前面我们学习进程的时候,其实就已经见到信号这个东西了,只不过我们没有对其进行深究,信号其实就是我们的操作系统中对于一些事件,进行的一些约定俗成。就比如我们生活中,对于红绿灯,我们会知道,红灯停绿灯行的。

在我们的Linux一共有如下几个信号:

其中1-31号信号是普通信号,然后34-64为实时信号,我们用户就只会接触到1-31号信号,注意的是我们的信号是1开始的,没看到有0号信号。

但是我们前面学习进程的时候知道0号信号就是进程正常完成任务的表现,

下面我们提出几点疑问:

1、我们是如何知道信号产生的?

就好比我们人类,我们对于一些事物的规律是会慢慢认识的,上课铃声响了我们知道要回教室上课,红灯亮了我们知道要等待。那么在我们的操作系统中,内置了识别信号的特性,这个工作是操作系统内核程序员写的。

2、信号产生后,我们知道如何去处理这个信号么?

是知道的,然后对于这个信号的处理方式,在信号产生之前就已经准备好了的。 

3、那么对于信号,我们是会立马去处理么?

这个并不一定,要看这个信号的优先级,就比如我们此时正在考试,然后你的快递到了,而且我们知道这个快递到了,但是我们也还是等待考试结束后,找一个合适的时间再去拿这个快递。

所以对于信号的处理也是一样,会在合适的时候进行处理。

4、信号保存问题?

上面我们清楚,对于信号,我们的进程并不会马上进行处理,那么我们就要将获取到的信号进行保存,那么我们的进程是如何进行保存的呢?

而且我们的进程一次可能会收到多个信号。

在我们进程的struct task_struct中存在一个32位的二进制数,这个是一个位图,那么刚刚好我们的普通信号就是32个,那么每一个bit位的数字就表示一个信号。

5、那么我们对于信号是如何进行处理的呢?

首先我们的操作系统对于每一个信号都有其默认的处理方式,所以第一种处理方式就是默认方式

然后对于一些信号我们也会采取忽略的处理方式。

然后对于我们用户,我们还可以自定义处理方式。

如上就是我们信号的基础概念了。

二、信号的产生

信号的生命周期,其从信号产生,然后到信号保存,再到处理。

如下:

信号的产生有多种方式,下面我们会对其进行详细讲解,上面我们提到了,对于信号,我们并不是第一时间就进行处理的,那么我们就要将产生的信号进行保存,后续再进行处理。

我们先来认识一个系统调用:

这个系统调用就是我们用来进行信号捕捉的,其可以用于自定义信号处理方式,其第一个参数是我们要自定义的信号,然后第一个参数,其是一个函数指针,那么就是一个回调函数,那么当捕捉到指定的信号,那么其就会去执行这个任务。

信号的产生主要有如下四种方式:

1、使用系统命令产生

我们在命令行中,使用kill指令,就可以对我们指定的进程发送信号。

所以我们下面写一个代码,其中调用signal系统调用,我们来看看其是否接收到这个信息,然后进行自定义处理:

代码如下:

如上我们对3号信号进行自定义处理,然后我们运行程序,在命令行中对该进程发送信号:

可以看到,我们使用kill命令成功向我们的进程发送了3号信号,而且我们的进程也执行了这个自定义信号处理,而且其执行完这个自定义函数后,我们的进程又回来继续完成其代码了,那么我们有个疑惑就是,我们的自定义处理方式,是当前进程执行的,还是说其又创建了个子进程进行完成的。

答案是,该进程自己进行的。

那么如果说,我们进行多次的3号信号发送,其是否还会继续进行3号信号的处理呢?是否需要进行一个循环呢?对于这个信号自定义的系统调用?

下面我们可以多进行几次信号的发送:

可以看到是不需要的。只需要进行一次自定义即可。

然后在我们的程序中,我们也可以使用kill系统调用进行信号的发送

其第一个参数就是我们要发送的进行的pid,然后第二个参数就是要发送的信号。

2、键盘产生信号

像我们使用Ctrl+c,就是向我们当前进程发送2号信号

Ctrl+\就是发送3号信号。

Ctrl+z发送19号信号。

不过要注意的是,只有我们的前台进程才能收到键盘发送的信号,我们前面也提到过,前台进程和后台进程的区别就是是否能够从键盘上获取到输入。

补充:

为啥我们在命令行中对当前进程进行Ctrl+z操作,但是我们的bash进程并没有被我们杀死,这是因为我们的bash进程将我们的所有信号都进行了忽略。

那么我们是否可以对所有的信号都进行自定义呢?

其实是不行的,要是可以的话,那么我们就可以恶意进行自定义了,让我们的进程没有方法退出了。

无法自定义捕获、屏蔽、处理的信号
1. 两个核心硬性限制信号
SIGKILL (9)
作用:强制杀死进程
限制:
不能用 signal() / sigaction() 注册自定义处理函数;
不能加入阻塞信号集(sigprocmask 无法屏蔽);
无法被忽略;
发送后内核直接终止进程,不会存入未决信号集 pending。
SIGSTOP (19)
作用:暂停进程(挂起,进入 TASK_STOPPED 状态)
限制:
不能自定义信号处理函数;
不能阻塞、不能忽略;
发送后进程立刻暂停,只能用 SIGCONT 恢复运行

3、硬件报错产生

当我们的程序进行除0操作还有野指针,那么我们的程序就会崩溃。

那么其为啥会崩溃呢?

这是因为当我们进行这个操作的时候我们的程序发生异常,然后收到了信号,然后导致我们的进程结束的。

那么这个错误是谁发现的呢?是操作系统么,还是说另有其人,那么我们的代码进行运算,那么是要调用COU的运算器的,那么在CPU中进行运算,我们的CPU发现的错误,然后导致我们的硬件错误的,然后我们的操作系统就会收到硬件所发出的错误信息,然后结束了我们的进程。

 下面我们通过代码来看其现象:

可看到对于除0操作是对我们的进程发送的8号信号。

下面我们看看野指针错误 

对于野指针,是11号信号。

为啥我们的除0和野指针会造成硬件错误呢?

在我们的CPU中存在一个标志寄存器。标志位就是标识这个运算是否是可靠运算。当我们进行除0操作的时候,那么会产生溢出行为,那么OF标志位会从0变1,那么操作系统就会知道硬件产生了错误,然后会发送8号信号终止这个错误的进程。

当我们进行除0操作的时候,会出现core dumped的报错,那么这个是啥意思呢?

这个就是我们的核心转储,核心转储就是将我们进程退出的时候的异常信息转储到磁盘中,方便我们后续debug的时候进程使用。

前面我们学习进程等待的时候,对于进程退出信息,我们的父进程要进行回收,这个退出信息是在status中的一个输出型参数中的一个16位的二进制数,然后其第八位是一个core dump标志位,其代表的是子进程对应的信号有没有发生过core dump错误,如果有那么这个位就为1,没有就为0。

4、使用函数产生

我们的操作系统也为我们提高了一系列的系统调用,给我们的程序进行信号的操作,下面我们来学习和使用三个对于信号发送的函数。

在我们的程序中可以使用kill函数,就可以对于指定pid的进程发送信号。

其第一个参数就是我们要给那个进程发送信号,然后呢,第二个参数就是我们要发送的几号信号。

我们可以发现这个就和我们的kill指令同名。

然后我们系统还提供了raise库函数:

我们可以看到其就一个参数,就是我们要发送的多少号信号,没有指定的进程,那么其是 给我们当前进程发送信号,其底层实际上就是调用我们的kill系统调用然后发送的进程就是我们当前进程的pid。

还有一个,是固定发送14信号:

abort:

这个也是库函数提供的对于信号产生的函数,其也是在当前进程进行发送信号,但是我们发现其没有参数,因为其就是固定发送6信号,然后其和上面的函数不一样的是,其进行信号自定义处理后,还是会进行自己默认的处理。

这个函数,在我们的程序调试和测试中比较常用,因为其触发会有我们的核心转储,那么我们就可以很好的去定位到程序的问题所在。

5、由软件产生

SIGIPE是由软件条件产生的信号。

我们可以调用alarm函数,这个函数就是在我们的程序中设置一个闹钟,那么其函数原型如下:

其就一个参数,就是传入一个无符号整数,然后按照这个整数,时间多少秒,然后就触发闹钟,然后就会给我们的进程发送14号信号。这个信号的默认处理方式就是结束进程,不过我们也可以使用上面的函数对14号信号的处理方式进行自定义。

调⽤ alarm 函数可以设定⼀个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发 SIGALRM 信号,该信号的默认处理动作是终⽌当前进程。

这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个⽐⽅,某⼈要⼩睡⼀觉,设定闹 钟为30分钟之后响,20分钟后被⼈吵醒了,还想多睡⼀会⼉,于是重新设定闹钟为15分钟之后响,“以 前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表⽰取消以前设定的闹钟,函数 的返回值仍然是以前设定的闹钟时间还余下的秒数。

然后我们也可以进行多次闹钟的设置,但是要注意的是,我们这个设置,不要在我们的main函数中使用一个循环进行设置,因为,使用这个循环的话,那么其每次都会刷新闹钟的时间的。

我们要在自定义14号信号的处理的时候再进行设置。

三、信号保存

上面我们提到,我们信号的生命周期,其中就包括了信号保存,然后为啥要进行信号保存呢?

这是因为我们的进程当遇到信号其实并不是马上进行处理的,那么我们收到信号,也要将其进行保存,不然就会造成信号丢失了。所以我们需要将其进行保存。

不过我们知道的,对于信号的处理也是我们当前的进程来进行的。

我们先补充下面几个概念:
实际处理信号的工作叫做信号递达

信号从产生到递达之间的状态叫做信号未决

进程可以选择阻塞那个信号

被阻塞的信号在产生的时候就会进入到未决状态,直到进程解除对这个信号的阻塞,然后才会进行递达动作。

不过要注意的是忽略和阻塞是不一样的,忽略是对于信号的处理,阻塞是对于信号的状态

下面是在内核中对于信号保存的结构

这个也是在我们进程的task_struct中的,那么其就是三个表。

首先我们看第一个pending表,那么其就是标识我们信号的未决的,其一共有32位,其实际上就是一个二进制数,其32位,然后从第一个位子开始就表示我们1号信号是否未决。

然后block中其也是一个二进制数,也是32位的,其是标识我们进程对于信号是否阻塞。

然后handler就是我们对应信号的处理方式了,当我们使用系统调用对信号进行自定义处理,那么其内部其实就是进行程序替换,将我们信号的handler中指向的方法给替换了。

从上图来看,每个信号只有⼀个bit的未决标志,⾮0即1,不记录该信号产⽣了多少次,阻塞标志也是这样 表⽰的。因此,未决和阻塞标志可以⽤相同的数据类型sigset_t来存储,,这个类型 可以表⽰每个信号的“有效”或“⽆效”状态,在阻塞信号集中“有效”和“⽆效”的含义是该信号 是否被阻塞,⽽在未决信号集中“有效”和“⽆效”的含义是该信号是否处于未决状态。下⼀节将详 细介绍信号集的各种操作。阻塞信号集也叫做当前进程的 这⾥的“屏 蔽”应该理解为阻塞⽽不是忽略。

前面我们在学习信号产生的时候已经学习了对于信号处理的自定义,那么其实就是对我们的handler表进行修改。

下面我来介绍对于另外两个表的修改

对于信号集操作,我们先补充几点;

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

函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表⽰该信号集不包含任何有效信号。

函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表⽰该信号集的有效信号包括系统⽀持的所有信号。

注意,在使⽤sigset_t类型的变量之前,⼀定要调⽤sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调⽤sigaddset和sigdelset在该信号集中添加或删除某种有效信号。

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

然后我们初始化完成后,可以对block表进行操作,对其进行操作我们要使用到:

sigprocmask

其原型如下:

第一个参数就是我们的操作选项如下:

第一个选项就是添加,就将我们的block表的内容进行覆盖,第二个就是删除掉指定的信号的阻塞。第三个选项是直接进行赋值类似。

然后就是pending表,其实前面产生信号部分就是对这个表进行修改,然后我们可以使用 sigpending来读取当前进程的pending,也就是未决信号。

然后有个问题:

当我们的信号进行处理,pending是在处理前变成0,还是处理后变成0呢?

其实是在处理前就变成0了。我们可以在调用自定义处理方式的时候将我们的pending的内容获取打印出来。

然后当我们的信号的阻塞被解除,那么其就立马被递达。

四、信号捕捉

1、信号捕捉的过程

对于信号的捕捉,其大致流程如下,当我们收到信号的时候,并不是立刻处理的,而是当我们产生中断,或者异常的情况,那么此时我们的CPU就会切换到内核态。然后我们的内核态要回到用户态的时候,会进行信号的检测还有处理。

所以我们回答一个问题信号的处理就是在内核态要返回用户态的时候进行处理的。

内核态在进行切换的时候,如果检测到信号,那么就会去处理我们的信号,但是,我们自定义的处理方式,也是在我们的用户空间上进行处理的么?

不行,而是由我们的内核态进行处理的。

那么用户态和内核态又是啥呢?

我们前面学习程序虚拟地址空间的时候提到,那个4GB空间,[0-3GB]空间是用户空间,然后[3-4]空间是内核空间,那么我们的内核态就是在那里工作的。

下面我们举个例子来理解:

⽤⼾程序注册了 SIGQUIT 信号的处理函数 sighandler 。

当前正在执⾏ main 函数,这时发⽣中断或异常切换到内核态。

在中断处理完毕后要返回⽤⼾态的 main 函数之前检查到有信号 SIGQUIT 递达。

内核决定返回⽤⼾态后不是恢复 main 函数的上下⽂继续执⾏,⽽是执⾏ sighandler 函数, sighandler 和 main 函数使⽤不同的堆栈空间,它们之间不存在调⽤和被调⽤的关系,是两个独⽴的控制流程。

sighandler 函数返回后⾃动执⾏特殊的系统调⽤ sigreturn 再次进⼊内核态。

 如果没有新的信号要递达,这次再返回⽤⼾态就是恢复 main 函数的上下⽂继续执⾏了。

那么我们的操作系统是如何知道有中断和异常的呢?

下面我们来了解一下操作系统是如何运行的。

2、操作系统的运行

1、硬件中断

我们知道当代计算机都是使用的冯诺依曼体系,我们的CPU和外设都需要通过内存来进行联系。

但是,我们的硬件其实还是可以和CPU之间可以直接联系的,其对于一些控制信息可以通过中断控制器进行联系。

完整流程(7 个步骤)
步骤 1:外设就绪,发起中断
外部设备(如键盘按下、磁盘读写完成)产生硬件信号,向中断控制器发送中断请求。
步骤 2:中断控制器分配中断号
中断控制器接收到信号后,给该中断分配一个唯一的中断号(比如键盘对应 IRQ1,时钟对应 IRQ0)。
步骤 3:通知 CPU
中断控制器通过引脚向 CPU 发送中断信号,告诉 CPU"有设备需要处理"。
步骤 4:CPU 响应,获取中断号
CPU 暂停当前正在执行的指令,响应中断,并从中断控制器读取具体的中断号。
步骤 5:CPU 保护现场
CPU 将当前程序的寄存器状态(程序计数器、栈指针、通用寄存器等)压入栈中保存,确保中断处理完后能回到原来的位置继续执行。
步骤 6:根据中断号执行中断处理例程
CPU 通过中断向量表(IDT)查找该中断号对应的处理函数入口地址,然后跳转到对应的中断服务
步骤 7:

执行完毕,恢复现场
中断处理完成后,CPU 从栈中恢复之前保存的寄存器状态,回到被中断的程序继续执行,就像什么都没发生过一样。

中断向量表(IDT):本质是一个 "跳转表",每个中断号对应一个处理函数的内存地址,CPU 通过查表快速定位处理逻辑。

保护现场 / 恢复现场:是中断机制的关键 —— 保证中断处理不会破坏原程序的运行状态,实现 "透明" 的打断与恢复。

中断控制器的作用:对多个外设的中断进行仲裁和编号,避免多个设备同时发中断时 CPU 混乱。

简单来说:外设有事喊 CPU → CPU 先存档 → 查表找对应处理方法 → 干完活读档继续原来的工作

2、时钟中断

时钟中断是操作系统中最核心、最频繁的一种硬件中断,由系统定时器(可编程间隔定时器 PIT / APIC 定时器)周期性触发。

硬件定时器每隔固定时间(比如每 10ms 一次,即频率 100Hz)向 CPU 发一个中断信号,这个就是时钟中断

它是操作系统的 "心跳"

没有时钟中断,操作系统就失去了时间感,无法调度进程、无法计时

时钟中断其主要干如下的事情:

工作 说明
更新系统时间 维护 jiffies 计数器,累计走过了多少个时钟节拍
进程时间片统计 给当前运行进程的运行时间 +1,判断时间片是否用完
触发进程调度 如果当前进程时间片耗尽,设置调度标志,中断返回时触发调度
处理定时器 检查哪些超时的定时器(sleep、alarm 等)到期了,触发回调
更新资源统计 更新 CPU 使用率、系统负载等统计信息

时钟中断是抢占式调度的基础:

  1. 进程 A 正在运行
  2. 时钟中断来了 → 发现进程 A 的时间片用完了
  3. 设置 need_resched 标志
  4. 中断处理完毕、返回用户态前
  5. 检查到标志 → 调用调度器 → 切换到进程 B

这就是为什么一个进程 "跑着跑着" 会被切走 ——时钟中断在背后偷偷打断它

3、sigaction

是 Linux/Unix 中注册信号处理函数的系统调用,比简单的 signal() 更强大、更可靠,是 POSIX 标准推荐的方式。

函数原型如下:

第一个参数是我们要进行操作的信号。

第二个参数,我们发现其是一个结构体,这个也是我们要将这个信号的处理方式进行修改的内容。

第三个参数是原来信号的处理方式,函数执行后会把原来的设置存回来(可以传 NULL 表示不需要)。

第二个参数结构体详情如下:

第一个成员:

其是一个简单处理函数,只接收信号编号,最简单的处理方式。

也可以设置为下面两个方式:

SIG_DFL:恢复默认行为

SIG_IGN:忽略该信号

第二个成员:

这个是详细处理函数

能拿到更多信息:谁发的信号、发送进程 PID、发送原因等。需要配合 sa_flags = SA_SIGINFO 使用

注意:sa_handlersa_sigaction 二选一,不要同时赋值。

第三个成员:

a_mask — 信号屏蔽集

在执行信号处理函数期间,额外屏蔽哪些信号,防止处理过程中被其他信号打断。当前正在处理的信号本身默认就会被屏蔽,不用手动加。这个就是这个函数的特殊之处。

第四个成员:

sa_flags — 行为控制标志

常用标志位(可以用 | 组合):

注意事项:

信号处理函数要尽量短、要异步信号安全

处理函数里不能随便调用函数!printfmallocstrtok 等都不是异步信号安全的,可能造成死锁或数据损坏。

安全的做法:处理函数里只设标志位,主循环里检测标志位再做事。

系统调用可能被信号打断(EINTR)

如果没设 SA_RESTARTreadwritewaitsleep 等慢速系统调用被信号打断后会返回 -1errno = EINTR

要么加 SA_RESTART,要么自己写循环重试:

不能用 signal () 代替 sigaction

signal() 行为在不同 Unix 实现中不一致(有的处理一次就重置为默认),可移植性差。正式代码一律用 sigaction

SIGKILL 和 SIGSTOP 不能捕获、不能忽略

这两个信号是给内核 "兜底" 用的,对它们调用 sigaction 会失败。

信号不排队(标准信号)

同一个信号在处理期间又来了多次,只会再递送一次,不会累计次数。实时信号(SIGRTMIN ~ SIGRTMAX)才支持排队。

sa_mask 只在处理函数执行期间生效

处理函数返回后,屏蔽集自动恢复原样,不会影响进程原本的信号掩码。

fork 后子进程继承信号处理方式

子进程会复制父进程的 sigaction 设置,但 exec 后,被捕获的信号会恢复为默认(因为处理函数地址在新程序里无效了),忽略的信号保持忽略。

总结

sigaction = 信号处理的 "高级配置面板",核心就三块:

处理函数sa_handler 简单版 / sa_sigaction 详细版

屏蔽集sa_mask 控制处理期间屏蔽哪些信号

标志位sa_flags 控制重启、嵌套、详细信息等行为

五、可重入函数

main函数调⽤insert函数向⼀个链表head中插⼊节点node1,插⼊操作分为两步,刚做完第⼀步的时候,因为硬件中断使进程切换到内核,再次回⽤⼾态之前检查到有信号待处理,于是切换到 sighandler函数,sighandler也调⽤insert函数向同⼀个链表head中插⼊节点node2,插⼊操作的两步都做完之后从sighandler返回内核态,再次回到⽤⼾态就从main函数调⽤的insert函数中继续 往下执⾏,先前做第⼀步之后被打断,现在继续做完第⼆步。结果是,main函数和sighandler先后向 链表中插⼊两个节点,⽽最后只有⼀个节点真正插⼊链表中了。

 像上例这样,insert函数被不同的控制流程调⽤,有可能在第⼀次调⽤还没返回时就再次进⼊该函 数,这称为重⼊,insert函数访问⼀个全局链表,有可能因为重⼊⽽造成错乱,像这样的函数称为不可 重⼊函数,反之,如果⼀个函数只访问⾃⼰的局部变量或参数,则称为可重⼊(Reentrant)函数。想⼀ 下,为什么两个不同的控制流程调⽤同⼀个函数,访问它的同⼀个局部变量或参数就不会造成错乱? 

如果⼀个函数符合以下条件之⼀则是不可重⼊的

调⽤了malloc或free,因为malloc也是⽤全局链表来管理堆的。

调⽤了标准I/O库函数。标准I/O库的很多实现都以不可重⼊的⽅式使⽤全局数据结构。

六、volatile

这个是一个关键字,我们直接看一个代码:

当我们的编译器优化的很极致,然后对于while循环中的变量,那么其每次进行判断的时候,就不会再去内存中看了,而是会在我们CPU的寄存器中保存,但是我们那个函数的修改,修改的是内存中的,那么就会导致这个错误问题,所以对于这个变量flag,我们可以加一个volatile,那么就可以减少这个优化了。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐