深入了解linux系统—— 信号的捕捉
前言
信号从产生到处理,可以分为信号产生、信号保存、信号捕捉三个阶段;了解了信号产生和保存,现在来深入了解信号捕捉。
信号捕捉
对于1-31
号普通信号,进程可以立即处理,也可以不立即处理而是在合适的时候处理;
在合适的时候处理信号,什么时候合适呢?
信号捕捉的流程
要了解信号捕捉的流程,先要了解内核态和用户态;
简单来说,内核态就是以操作系统的身份去运行;而用户态就是以用户的身份去运行。(后面再详细说明)
这里直接来看信号捕捉的流程:
我们的进程在正常执行,在执行到某条指令,因为系统调用、中断或异常从而进入内核;
而内核处理完异常之后,准备回到用户之前,就会处理当前进程可以递达的信号;
处理信号,执行
so_signal
方法,如果进程对于信号是自定义捕捉,处理信号就要从内核态回到用户态处理信号;自定义捕捉完信号之后,就要再回到内核态,然后由内核态再回到用户态,从上次被中断的地方继续向下执行。
以自定义捕捉为例,信号捕捉的流程如下图所示:
所以,在信号捕捉的整个流程中,存在4
次用户态和内核态的转换;简化成以下图:
简单总结描述信号捕捉流程:
- 用户进程执行
- 进程在用户空间正常执行代码
- 进入内核
- 发生系统调用/中断/异常 → CPU自动切换到内核态
- 内核处理事件
- 内核完成系统调用/中断/异常的处理
- 信号检查
- 内核返回用户态前检查信号:
有未处理且未阻塞的信号? → 继续
无信号 → 直接返回用户态- 准备信号处理(针对自定义信号)
- 内核在用户栈创建"信号栈帧"(包含):
- 信号处理函数地址
- 原始执行状态(寄存器值)
rt_sigreturn
系统调用地址- 第一次返回用户态
- 内核修改CPU状态:
- 指令指针 → 信号处理函数
- 栈指针 → 新信号栈帧
- 切换到用户态执行信号处理函数
- 信号处理完成
- 信号处理函数执行结束(return语句)
- 自动跳转到
rt_sigreturn
系统调用- 第二次进入内核
- 执行
rt_sigreturn
系统调用 → 进入内核态- 内核从信号栈帧恢复原始状态
- 最终返回用户态
- 内核切换回用户态
- 进程从当初被中断的位置继续执行
操作系统运行
要了解操作系统是如何运行的,就要先了解一些硬件相关知识
硬件中断
硬件中断是外部硬件设备(如键盘、鼠标、硬盘、网卡、定时器芯片等)向 CPU 发出的一种紧急通知信号,意思是“我有重要的事情需要你马上处理!”
就像OS
是如何知道键盘上有数据那样,并不是OS
定期去排查,而是键盘给CPU
发送中断,从而让CPU
执行OS
中对应的方法。
如上图所示,存在一个中断控制器,其中每一个中断号都对应一个外部设备;
- 当外部设备就绪时,就会向中断控制器发送中断,中断控制器就会通知
CPU
存在中断;(向CPU
对应针脚发送高低电频)CPU
就会获取中断号,然后中断当前工作并保护现场(保存临时数据等);- 在
OS
中存在中断向量表,其中存储了对于每一个中断号的对应处理方法;CPU
就会根据中断号,去执行中断向量表这对应的中断处理方法。
中断向量表是操作系统的一部分,在启动时就会加载到内存;
通过外部硬件中断,操作系统就不需要对外设进行周期性检测;而是当外部设备触发中断时,CPU
就会执行对应的中断处理方法。
这种由外部设备触发,中断系统运行流程,称为硬件中断
时钟中断
有了硬件中断,操作系统就无序去对外设进程周期性检测;
而操作系统不光要管理硬件资源,也要进行进程调度;那能否按照硬件中断的原理,定期的向CPU
发送中断,从而定期的执行操作系统的进程调度方法。
所以,就有了时钟源(当代已经集成在CPU
内部);就会定期的向CPU
发送中断,CPU
通过中断号去执行中断向量表中对应的进程调度方法。
那这样,定期的向CPU
发送中断,也就是定期执行进程调度方法;那进程的时间片,本质上就是一个计数器了,每次调度进程就让进程的时间片计数器--
,当减到0
时就说明进程时间片用完,就指定进程调度算法,执行下一个进程。
而CPU
存在主频,主频指的就是时钟源向CPU
发送中断的频率,主频越快,CPU
单位时间内就能够完成更多的操作;CPU
就越快。
死循环
有了硬件中断和时钟中断,那操作系统只需要将对应功能添加到中断向量表中,那操作系统还需要干什么呢?
操作系统的本质:就是死循环
void main()
{//......for(;;)pause();
}
通过查看内核,我们也能够发现,操作系统在做完内存管理等任务之后,就是死循环。
软中断
上述硬件中断、时钟中断都是由硬件触发的中断;除此之外呢,也可能因为软件原因触发上述中断。
为了让操作系统支持进行系统调用,CPU
中也设计了汇编指令int
(或者syscall
),让CPU
内部触发中断逻辑。
在这里就要了解一下系统调用了,在之前的认知中,系统调用是由操作系统通过的,我们是直接调用系统调用;
但是,在操作系统中,所有的系统调用都存储在一张系统调用表当中;(这张系统调用表用于系统调用中中断处理程序)
我们所调用的系统调用
open
、write
等等,都是由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
执行时如何知道是用户态还是内核态)在页表当中,记录的不仅仅是虚拟地址和物理地址的映射关系,还用权限(
r
、w
)以及当前身份。此外,在硬件上也存在对应标志:
CPU
中的Cs
段寄存器对应标志位:00
(二进制)代表内核、11
(二进制)代表用户。
可重入函数
可重入函数是指可以被多个执行流(例如线程、中断处理程序、信号处理程序)同时调用,而不会产生错误或意外结果的函数。
简单来说就是:
一个可重入函数在执行过程中,如果被另一个执行流打断并再次进入该函数,当恢复执行时,它仍然能够正确完成其任务,不会破坏自身的数据或全局状态。
如上图所示,在调用insert
时,执行至某位置,进程收到信号转而去执行handler
方法,而在handler
方法中有调用了insert
方法;这样导致了最终的结果不符合我们的预期。
对于一个可重入函数,该函数要满足:
- 不使用静态(全局)或非常量静态局部变量: 这些变量在内存中只有一份拷贝,如果多个执行流同时修改它们,会导致数据不一致。
- 不返回指向静态数据的指针: 调用者可能会修改这些数据,影响其他执行流。
- 仅使用调用者提供的数据或自己栈上的局部变量: 每个执行流(线程/函数调用实例)都有自己的栈空间,局部变量是独立的。
- 不调用不可重入的函数: 如果它调用的函数本身是不可重入的(比如使用了全局状态),那么它自己也就变得不可重入了。
- 不修改自身的代码: 通常这不是问题,但某些特殊场景(如自修改代码)需要考虑。
- 不依赖外部硬件状态(除非以原子方式访问): 比如多个执行流同时操作同一个硬件寄存器可能造成冲突。
volatile
volatile
是C
语言中的一个关键字,这个关键字的在之前的学习中并没有使用过;
volatile
关键字用来修饰一个变量,其作用就是,告诉编译器该变量的值可能会变化,让编译器不要对其进程优化,让CPU
每次访问该变量的值都从内存中获取。
#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
就会立即被回收,不需要父进程等待。
#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
;这样在子进程退出后,父进程就可以随时获取子进程的退出信息,回收子进程了。