当前位置: 首页 > news >正文

【Linux庖丁解牛】— 信号捕捉!

1. 宏观了解信号捕捉的流程

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号

在上面的信号捕捉【这里默认是有信号产生并且信号有自定义捕捉动作】流程图中一共有4次用户态和内核态之间的切换,这里有个细节:当系统从内核态中捕捉到信号检查回到用户态处理handler函数时。此时使用kernel身份还是用户态身份调用函数呢??其实是用用户态身份调用函数的,这样做的目的是为了防止用户在函数中恶意对系统中某些资源进行破坏,以用户态身份则没有这些权限。还有,一个进程在执行时是如何自动从用户态切换到内核态呢??这其实很容易办到,即使不使用系统调用,进程在执行时总是会被cpu调度,当进程时间片结束后被链入等待队列。进程可以频繁的在用户态和内核态之间切换。

一张图记忆信号捕捉的宏观流程: 

 至于具体什么是用户态和内核态,信号捕捉的具体细节,我们接下来慢慢说。。

2. 穿插话题-操作系统是怎么运行的

 2.1 硬件中断

我们先来回答一个问题,我们从外设输入的信息,系统是怎么获得的??难道是每隔一定的时间到所有外设中获得吗??如果真的是这样,那操作系统不就忙死了吗!!

我们先来简单谈一谈硬件:其实,我们的外部设备中都有中断控制器。当键盘输入数据时,其实就会发起中断,让中断控制器获得信息。但是什么是发起中断呢??其实就是一些高低【有无】电频。不过中断控制器有是如何获取这些信息呢??实际上,每种硬件都有类似于cpu中寄存器的东西!!这些寄存器会把中断信号记录下来,我们把记录下来的中断信号统称为中断号。然后,中断控制器通过中断好来通知cpu相应的外设响应了!!此时,cpu针脚【硬件之间通过针脚直接或间接通信】得到相应,接着获得中断号拿着中断号获取外设输入的数据

到目前为止,cpu知道有些外设已经准备好了并且获取了相关的数据,但是cpu并不知道如何处理这些数据,那么接下来就要交给软件处理,也就是操作系统!!

操作系统中包含一张表中断向量表,什么是中断向量表!!其实就是一个函数指针数组!!接下来我说的话不是很严谨,但可以帮助我们理解。我们可以把cpu获得的中断号理解为表中的下标,系统拿着中断号到表中索引找到对应的方法。找到后,再把该方法交给cpu执行!!

至此,os再也不用关心外部设备是否准备好!!因为外部设备会通知操作系统!!

我们把由外部设备触发的,中断系统运行流程,叫做硬件中断

下面是初始化中断向量表的Linux内核源码:

//Linux内核0.11源码 
void trap_init(void)
{int i;set_trap_gate(0,&divide_error);// 设置除操作出错的中断向量值。以下雷同。 set_trap_gate(1,&debug);set_trap_gate(2,&nmi);set_system_gate(3,&int3); /* int3-5 can be called from all */set_system_gate(4,&overflow);set_system_gate(5,&bounds);set_trap_gate(6,&invalid_op);set_trap_gate(7,&device_not_available);set_trap_gate(8,&double_fault);set_trap_gate(9,&coprocessor_segment_overrun);set_trap_gate(10,&invalid_TSS);set_trap_gate(11,&segment_not_present);set_trap_gate(12,&stack_segment);set_trap_gate(13,&general_protection);set_trap_gate(14,&page_fault);set_trap_gate(15,&reserved);set_trap_gate(16,&coprocessor_error);
// 下⾯将int17-48 的陷阱⻔先均设置为reserved,以后每个硬件初始化时会重新设置⾃⼰的陷阱⻔。 for (i=17;i<48;i++)set_trap_gate(i,&reserved);set_trap_gate(45,&irq13);// 设置协处理器的陷阱⻔。 outb_p(inb_p(0x21)&0xfb,0x21);// 允许主8259A 芯⽚的IRQ2 中断请求。 outb(inb_p(0xA1)&0xdf,0xA1);// 允许从8259A 芯⽚的IRQ13 中断请求。 set_trap_gate(39,&parallel_interrupt);// 设置并⾏⼝的陷阱⻔。 
}
void
rs_init (void)
{set_intr_gate (0x24, rs1_interrupt); // 设置串⾏⼝1 的中断⻔向量(硬件IRQ4 信
号)。 set_intr_gate (0x23, rs2_interrupt); // 设置串⾏⼝2 的中断⻔向量(硬件IRQ3 信
号)。 init (tty_table[1].read_q.data); // 初始化串⾏⼝1(.data 是端⼝号)。 init (tty_table[2].read_q.data); // 初始化串⾏⼝2。 outb (inb_p (0x21) & 0xE7, 0x21); // 允许主8259A 芯⽚的IRQ3,IRQ4 中断信号请
求。 
}

2.2 时钟中断

通过上面的理解,我们知道了操作系统是如何获取外设数据,进程在操作系统的指挥下被调度执行。但是操作系统也属于软件,操作系统又是被谁指挥,被谁调度执行呢??

我们知道,外部设备可以触发硬件中断,驱使操作系统处理对应的外设,但这需要用户或外设自己触发。有没有一种设备可以自己定期触发呢??

有的,兄弟,有的。有一种外部设备,叫做时钟源,它可以定期【以某种频率】向cpu发起中断。时钟源发起的中断号对应中断向量表中的schedule方法,该方法会完成各种进程调度。由于时钟源会和其他各种外设竞争中断号,效率不高。所以当代计算机已经把时钟源集成到cpu内部了,我们也把cpu内部的时钟源称为主频,频率越高,cpu调度进程的速度越快,计算机的性能也就越好

操作系统的入口其实就是一个死循环,一旦操作系统加载到内存,中断向量表也就加载到内存当中了。其中,时钟源不断自发的发起中断,操作系统在时钟源的驱使下,不断的调度schedule方法。在该方法内部,首先会检查当前被调度进程的时间片是否被耗尽。如果没有被耗尽直接返回并将其时间片减减,如果当前进程时间片耗尽,则执行自己注册的方法。后面就是我们之前讲过的cpu调度执行进程的大O(1)调度算法了!!

void main(void) /* 这⾥确实是void,并没错。 */ 
{ /* 在startup 程序(head.s)中就是这样假设的。 */ .../** 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到⼀个信号才会返 * 回就绪运⾏态,但任务0(task0)是唯⼀的意外情况(参⻅'schedule()'),因为任 * 务0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时), * 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运⾏,如果没 * 有的话我们就回到这⾥,⼀直循环执⾏'pause()'。 */for (;;)pause();
} // end main

2.3 软中断

硬件可以触发中断,有没有一种可能,软件也会间接触发硬件中断呢??

答案是可能的,在我们的程序在cpu调度运行时,如果发生除0操作,或非法内存的访问……。cup中的寄存器也会产生中断号向系统发起中断,让系统处理异常给进程发送对应的信号我们之前还讲过,操作系统不一定会给虚拟地址分配实际的物理地址,当实际用到该地址的时候系统再为其分配,那系统是如何做到的呢??答案就是缺页中断 ,当进程在cpu内执行时,cpu发现一个虚拟地址到物理地址的映射失败也就是MMU转化失败,此时cpu硬件就会触发缺页中断,向系统发起中断。系统就会调度对应的方法为其分配物理内存。       

系统调用原理:

我们刚刚所说的是软件异常间接导致硬件中断,但在cpu内部,可以让软件自己触发中断吗??

其实是可以的,这种通过软件让cpu主动中断,我们称为软中断。在x86中指令集中的int和x86_64指令集中的syscall就可以自动让cpu触发一次中断!!

我们知道触发中断需要对应的中断号【索引中断向量表】和对应的方法,所以,当cpu执行int或syscall指令触发中断时,对应的中断号是什么呢??是0x80!!那对应的方法是什么呢??是系统调用函数指针表!!这张表本质就是函数指针数组,里面包含了所有的系统调用,而数组下标映射唯一一个系统调用,我们把这个数字成为系统调用号!!至此,系统拿着系统调用号就可以执行对应的系统调用了!!

// 系统调⽤函数指针表。⽤于系统调⽤中断处理程序(int 0x80),作为跳转表。 
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,sys_setreuid, sys_setregid
};

但是,系统调用号是怎么来的呢??不急,我们刚刚所说都是在内核层面,我们现在再来看到用户层面。我们作为用户使用系统调用,本质上就是在获取对应的系统调用号,然后向系统发起软中断!!

我们的所有系统调用都是被glibc封装过的!!在系统调用的底层,都是使用汇编代码将对应的系统调用号move到寄存器eax中【系统会提供自己的一部分内核数据:即系统调用号和系统调用命名的映射关系等】,然后执行syscall或int指令触发软中断

至此,cpu可以同时拿到用户层提供的系统调用号,和触发软中断的指令syscall或int ,接着再执行内核层的系统调用流程即可完成从用户到内核的系统调用!!

最后,我们来小总结一下,区分一下概念:

缺页中断?内存碎片处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断, 然后走中断处理例程,完成所有处理。有的是进行申请内存,填充页表,进行映射的。有的是用来 处理内存碎片的,有的是用来给目标进行发送信号,杀掉进程等等。

所以:

• 操作系统就是躺在中断处理例程上的代码块!

• CPU内部的软中断,比如int 0x80或者syscall,我们叫做陷阱【陷入内核】

• CPU内部的软中断,比如除零/野指针等,我们叫做异常。(所以,能理解“缺页异常” 为什么这么叫了吗?)

如何理解用户态和内核态:

在我们的进程地址空间【32位】中,【0,3GB】为我们的用户区使用:代码区,数据区,共享区……而【3,4GB】是为我们的内核区使用。因为操作系统也是软件,所以操作系统加载到内存后,也需要有从虚拟地址到物理地址的映射,要做到的话,就需要页表来建立映射关系,我们把这张页表称为内核页表。每个进程的数据和代码都不一样,所以用户页表有多份,但是所有进程共用一个操作系统,所以内核页表只有一份!!

所以,操作系统无论如何切换进程,都可以找到操作系统,换句话说,操作系统是通过进程地址空间完成系统调用的。我们知道,进程在cpu中执行代码时,会进行各种地址空间的跳转。比如从代码区跳转到堆区栈区再跳转回代码区。既然如此,我们在使用系统调用的时候,不是会从代码区跳转到内核区吗用户如果拿着内核区的地址随意对内核的数据和代码进程访问,不就对系统造成破坏了吗??

为了解决这个问题,系统有了用户态和内核态的概念!!

用户态:以用户的身份,只能访问【0,3GB】。

内核态:以内核的身份,允许你通过系统调用的方式访问【3,4GB】。   

那系统又如何识别用户态和内核态呢??    

识别就是按照CPU内的CPL决定,CPL的全称是Current Privilege Level,即当前特权级别。在cpu内核中有一种cs寄存器,在该寄存器的末尾两个比特位会记录CPL。如果当前cpl为0,则表示内核态,如果为3则表示用户态。当我们使用系统调用陷入内核时,cpu会检查当前cpl值自动变更【如果为0,则不变,如果为3则变为0】,接着执行int或syscall指令发起软中断,即使我们是内核态,我们也只能使用系统提供的系统调用,按照系统软中断的那一套方式走!!如果我们直接使用内核区地址空间对系统进行访问,cpu检测到你的身份为用户态则会直接禁止用户的访问!!这就是系统保护自己的方式。

3. sigaction

sigaction的主要功能其实和我们使用的signal差不多,但是它多了一个功能:在抵达目标处理动作的时候可以自定义屏蔽其他信号

在cpu抵达某个信号的自定义捕捉函数时,内核还会自动将该信号自动加入当前进程的信号屏蔽字。说人话就是屏蔽该信号,这样做的目的是为了保证当前信号被处理时,该信号不会被重复递达!!当该信号被处理完后,内核会自动解除对当前信号的屏蔽!!

 该方法的第二个参数是一个结构体,在结构体中,我们目前只用关心我下面标注的成员。

使用demon代码:

#include <iostream>
#include <signal.h>
#include <unistd.h>void handler(int sig)
{std::cout << "获得一个信号: " << sig << std::endl;// 循环打印pending表while (true){sigset_t pending;sigpending(&pending);for (int i = 31; i >= 1; i--){if (sigismember(&pending, i))std::cout << 1;elsestd::cout << 0;}std::cout << std::endl;sleep(1);}
}int main()
{struct sigaction act, old;act.sa_flags = 0;sigemptyset(&act.sa_mask);act.sa_handler = handler;// 设置屏蔽信号sigaddset(&act.sa_mask, 3);sigaddset(&act.sa_mask, 4);sigaddset(&act.sa_mask, 5);// 自定义捕捉信号sigaction(SIGINT, &act, &old);while (true){std::cout << "hello sigaction! " << getpid() << std::endl;sleep(1);}return 0;
}

根据结果我们发现:第二次发送二号信号后,pending表虽然收到但并没有递达,说明2号信号被内核暂时屏蔽了,其他信号虽然发送,但被我们手动屏蔽了!!

4. volatile

volatile是C/C++中的一个关键字,我们一般很少见到,这里我们结合信号部分讲一下。首先,这个关键字的功能就是保证内存空间可见性

什么意思呢??我们先来看个代码:

#include <iostream>
#include <signal.h>
#include <unistd.h>int flag = 0;void handler(int sig)
{std::cout<<"修改flag-> 1\n";exit(0);
}int main()
{signal(2, handler);while (!flag);std::cout << "程序正常退出\n";return 0;
}

 进程在收到信号2后,果然修改flag为1,正常退出了!!

但是,在一些优化程度较高的编译器中,以上代码却会有问题,我们现在手动提高g++的优化程度先看结果

要修改 `g++` 的优化程度,可以通过在编译命令中添加不同的优化选项来实现。`g++` 支持以下几种优化级别:

1. **-O0**:关闭所有优化选项,这是默认的优化级别。适用于调试阶段,因为它不会对代码进行任何优化,从而使得调试过程更加容易。
2. **-O1**:基本优化,会减少程序大小并提高执行速度。适用于需要在性能和代码大小之间取得平衡的情况。
3. **-O2**:进一步优化,除了包含 `-O1` 的所有优化外,还包括一些其他优化,可能会使程序运行得更快,但不会减小程序大小。这是大多数情况下推荐使用的优化级别。
4. **-O3**:更高级别的优化,包括 `-O2` 的所有优化,并添加了其他高级优化选项,可能会显著增加编译时间。适用于对性能有极高要求的场景。
5. **-Os**:对生成文件大小进行优化。它会打开 `-O2` 开的全部选项,除了那些会增加文件大小的选项。适用于需要最小化可执行文件大小的情况。

 现在,无论我们如何发送2号信号,程序都无法退出了!!

这是为什么呢??本质其实是寄存器覆盖了进程看到变量的真实情况,内存不可见了!!什么真实情况呢??编译器看到进程逻辑中只对flag做判断并不涉及对其的修改,所以直接把flag优化到cpu内部寄存器做判断了!!原本是分两步的,先把flag拿到寄存器中,然后进行逻辑判断,一旦flag发生修改,flag会被重新拿到cpu中做逻辑判断。一旦优化后,cpu就看不到flag在内存中的变化了

而如果我们的全局变量被volatile修饰后,则可以保证变量内存空间的可见性!!

5.  SIGCHLD 信号

之前讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查 询是否有子进程结束等待清理(也就是轮询的方式)。采用第⼀种方式,父进程阻塞了就不能处理自己的工作了;采用第⼆种方式,父进程在处理自己的工作的同时还要记得时不时地轮询⼀下,程序实现复杂。

其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可

好,下面我们来写一个代码完成利用子进程退出会发送SIGCHLD信号给父进程的机制来完成回收子进程的工作。 

#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <wait.h>void wait_all(int sig)
{std::cout << "父进程收到信号SIGCHLD:" << sig << std::endl;//循环非阻塞轮询防止多个子进程同时退出发送信号导致一些进程没有被回收while (true){pid_t n = waitpid(-1, nullptr, WNOHANG); // 等待任意进程并且非阻塞等待if(n==0){break;//没有子进程退出了}else if(n<0){perror("等待失败");break;}}
}int main()
{// 自定义捕捉函数signal(SIGCHLD, wait_all);// 循环创建10个子进程for (int i = 0; i < 10; i++){pid_t id = fork();if (id == 0){sleep(1);exit(0);}}while (true){std::cout << "父进程正在运行\n";sleep(1);}return 0;
}

 

事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外⼀种办法:父进程调用sigaction将 SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这 是⼀个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。

如果我们不需要子进程的退出信息,用以下方法可以很简单的回收子进程!!

http://www.dtcms.com/a/285089.html

相关文章:

  • SVG基础语法:绘制点线面的简单示例
  • Selenium 启动的浏览器自动退出问题分析
  • 使用Collections.max比较Map<String, Integer>中的最大值
  • C语言基础6——数组
  • 元宇宙与Web3的深度融合:构建沉浸式数字体验的愿景与挑战
  • 2020717零碎写写
  • Python 异步编程之 async 和 await
  • ThreadLocal源码解析
  • Mac OS上docker desktop 替代方案
  • Linux 下按字节分割与合并文件
  • 压力大为啥想吃甜食
  • wireshark的常用用法
  • C++ Lambda 表达式详解:从入门到实战
  • Leetcode 03 java
  • 设备管理系统横评:预警功能、移动端体验、性价比谁更强?
  • PyTorch图像预处理全解析(transforms)
  • SAP-ABAP:SAP的‘cl_http_utility=>escape_url‘对URL进行安全编码方法详解
  • 6 基于STM32单片机的智能家居系统设计(STM32代码编写+手机APP设计+PCB设计+Proteus仿真)
  • 如何从 iPhone 向Mac使用 AirDrop 传输文件
  • 企业网络运维进入 “AI 托管” 时代:智能分析 + 自动决策,让云、网、端一眼看穿
  • 关于用git上传远程库的一些常见命令使用和常见问题:
  • Redis学习-02安装Redis(Ubuntu版本)、开启远程连接
  • ComfyUI 中RAM内存、VRAM显存、GPU 的占用和原理
  • 基于深度学习的图像识别:从零构建卷积神经网络(CNN)
  • 面对微软AD的安全隐患,宁盾身份域管如何设计安全性
  • Python调用父类方法的三种方式详解 | Python面向对象编程教程
  • 【DOCKER】-5 镜像仓库与容器编排
  • 云服务器如何设置防火墙和安全组规则?
  • Java EE进阶3:SpringBoot 快速上手
  • 【Linux】Makefile(二)-书写规则