Linux操作系统之信号:保存与处理信号

发布于:2025-07-16 ⋅ 阅读:(17) ⋅ 点赞:(0)

目录

前言:

前文回顾与补充:

信号的保存

PCB里的信号保存 

sigset_t

信号集操作函数

信号的处理

信号捕捉的流程:​编辑

操作系统的运行原理

硬件中断

时钟中断

死循环

软中断 

总结:


前言:

在上一篇文章,我已经为大家详细的阐述了信号产生的物种方式,相信大家对于信号已经有了较为深刻的印象与理解了。

那么今天我们就来继续谈论信号的另外两个重要话题:信号的保存与捕捉。

前文回顾与补充:

我们之前说, 捕捉信号一共有三种方式,分别是:默认,忽略,与自定义。

在使用signal时,有两个宏分别代表默认与忽略的处理:

#include <iostream>
#include <unistd.h>
#include <signal.h>


int main()
{
    ::signal(2,SIG_IGN);//ignore 忽略:本身就是一种处理方法,什么也不用做直接忽略掉
    ::signal(2,SIG_DFL);//default:执行该信号默认的处理方法
    return 0;
}

我们之前说的所有信号的产生,最后都要由OS执行,这是为什么呢?

:因为OS是进程的管理者

信号的处理是否会被立即处理?

:不会,会在合适的时候进行处理

一个信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适?

:需要,会被记录在进程的PCB中

⼀个进程在没有收到信号的时候,能否能知道,⾃⼰应该对合法信号作何处理呢?
:进程的处理方法一早就被设置好了
而进程时如何被存储到PCB中的呢?只是我们上节课说的位图结构吗?
这就要涉及我们今天的内容:信号的保存了!!

我们先补充一点信号的其他知识:

实际执行信号的处理动作被称为信号递达(Delivery)

信号从产生到递达之间的状态,被称为信号未决(Pending)

进程可以选择阻塞(block)某个信号

被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才会执行递达的动作。

这里要注意,阻塞与忽略是不同的,只要信号被阻塞就不会递达,而忽略时在递达之后我们选择的一种处理信号的动作


信号的保存

PCB里的信号保存 

我们刚刚说,由于信号的处理不是立即处理,所以我们需要保存好进程,就保存在进程的PCB中,我们先来看一下具体保存信号的结构:

这个pending就是我们之前说的信号位图,保存这个进程是否收到了相应的信号。

这个blcok也是一个位图,保存的是对应位置的信号是否被我们阻塞/屏蔽了。

而这个handler,则是一个函数指针数组,里面存储的就是对应信号的默认处理方法。我们信号的编号-1,就是对应的数组下标。

这也就是为什么我们只需要signal一次,就能永久改变处理方法的原因:因为我们是直接把方法拷贝替换成了自己的处理函数。

每个信号都有两个标志位分别表⽰阻塞(block)和未决(pending),还有⼀个函数指针表⽰处理动
作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上
图的例⼦中,SIGHUP信号未阻塞也未产⽣过,当它递达时执⾏默认处理动作。
SIGINT信号产⽣过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻
塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
SIGQUIT信号未产生过,⼀旦产生SIGQUIT信号将被阻塞,它的处理动作用户自定义函数sighandler。  

sigset_t

从上图来看,每个信号只有⼀个bit的未决标志, ⾮0即1, 不记录该信号产生了多少次,阻塞标志也是这样表示的。因此, 未决和阻塞标志可以用相同的数据类型sigset_t来存储,这个sigset_t称为信号集  , 这个类型可以表示每个信号的“有效”或“无效”状态,。
在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞, 而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的 信号屏蔽字(Signal Mask),这里 的“屏蔽”应该理解为阻塞而不是忽略。

信号集操作函数

由于保管信号的是位图结构,我们不好每次都进行位操作来修改位图,所以有对应的系统调用:

函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含
任何有效信号。一般我们用这个是为了给自己定义的sigset_t的变量初始化。
函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号
包括系统⽀持的所有信号。
注意,在使⽤sigset_tt类型的变量之前,⼀定要调用sigemptyset或sigfillset做初始化,使信号集处于
确定的状态。初始化sigset_t变量之后就可以在调⽤sigaddset和sigdelset在该信号集中添加或删
除某种有效信号。
以上四个函数都是成功返回0,出错返回-1。sigismember是⼀个布尔函数,⽤于判断⼀个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
此外,还有一个系统调用,我们专门用来 读取或更改进程的信号屏蔽字(阻塞信号集)。
如果oldset是⾮空指针,则读取进程的当前信号屏蔽字通过oldset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。如果oldset和set都是⾮空指针,则先将原来的信号 屏蔽字备份到oldset⾥,然后 根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了 how参数的可选值。

 

如果我们想要恢复成老的数据,该怎么办?

所以有输出型参数oldset供我们使用。

如果调⽤sigprocmask解除了对当前若⼲个未决信号的阻塞,则在sigprocmask返回前,⾄少将其中⼀
个信号递达。
还有一个系统调用: sigpending
我们通过传递一个参数set,可以获取当前进程的 未决信号集。
以上几个方法,就实现了我们对位图结构的简单的增删查改。
我们可以写一段代码来加深理解:

 

#include <iostream>
#include <string>
#include <functional>
#include <vector>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>


int main()
{
    //初始化,把指定的位图全部清0
    sigset_t block,oblock;
    sigemptyset(&block);
    sigemptyset(&oblock);

    //设置信号,此时我们有没有把对2号信号的屏蔽,设置进入内核中?:只是在用户栈上设置了block的位图结构
    // 并没有设置进入内核中!
    sigaddset(&block,2);

    //把我们对2号信号的屏蔽,设置进入内核中
    sigprocmask(SIG_SETMASK,&block,&oblock);

    while(true)
    {
        //获取该进程的pending表并打印
        sigset_t pending;
        sigpending(&pending);

        std::cout<<getpid()<<":";
        for(int i=31;i>=0;i--)
        {
            if(sigismember(&pending,i))//挨个挨个检查是否存在在该位图里
            {
                std::cout<<"1";
            }
            else
            {
                std::cout<<"0";
            }
        }
        std::cout<<std::endl;
        sleep(1);
    }
    return 0;
}

运行代码,我们打开另外一个bash给该进程发送信号

那我们此时在增加一段代码,使得他在一定时间后自动解除屏蔽试试??

#include <iostream>
#include <string>
#include <functional>
#include <vector>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>


void handler(int signo)
{
    std::cout<<"我已经解除屏蔽"<<signo<<std::endl;
}

int main()
{
    signal(2,handler);
    //初始化,把指定的位图全部清0
    sigset_t block,oblock;
    sigemptyset(&block);
    sigemptyset(&oblock);

    //设置信号,此时我们有没有把对2号信号的屏蔽,设置进入内核中?:只是在用户栈上设置了block的位图结构
    // 并没有设置进入内核中!
    sigaddset(&block,2);

    //把我们对2号信号的屏蔽,设置进入内核中
    sigprocmask(SIG_SETMASK,&block,&oblock);
    int count=0;
    while(true)
    {
        //获取该进程的pending表并打印
        sigset_t pending;
        sigpending(&pending);

        std::cout<<getpid()<<":";
        for(int i=31;i>0;i--)
        {
            if(sigismember(&pending,i))//挨个挨个检查是否存在在该位图里
            {
                std::cout<<"1";
            }
            else
            {
                std::cout<<"0";
            }
        }
        std::cout<<std::endl;
        count++;
        if(count>=10)
        {
            sigprocmask(SIG_SETMASK, &oblock, nullptr);//解除屏蔽
        }
        sleep(1);
    }

    return 0;
}

 运行代码,可以看见

 


信号的处理

 我们可以先说出结论,之前所说的合适的时候,指的就是,进程在从内核态切换为用户态时,会检测当前进程的pending与blcok,根据这两个位图是决定是否执行处理方法handler。

这里引出了两个新概念,用户态与内核态。

大家不用急,且听我慢慢道来:

信号捕捉的流程:

信号处理函数的代码是在⽤⼾空间的,处理过程还是比较复杂的,我们可以举例如下: 

用户程序注册了SIGQUIT 信号的处理函数 sighandler

当前正在执行main函数,这个时候会发生中断或者异常切换到内核态(为什么一定会发生中断我后面会解释)

在中断处理完毕后,要返回用户态的main函数前会检查到有信号SIGQUIT 递达。

内核决定返回用户态后不是恢复main函数的上下文继续执行代码,而是执行sighhandler函数(此时sighandler 和 main 函数使⽤不同的堆栈空间,它们之间不存在调⽤和被调⽤的关系,是两个独⽴的控制流程

sighandler 函数返回后自动执⾏特殊的系统调⽤ sigreturn 再次进⼊内核态。
如果没有新的信号要递达,这次再返回⽤⼾态就是恢复 main 函数的上下⽂继续执⾏了。
所以总的过程可以总结为上图的一个无穷的形状,整个过程一共会进行四次内核态与用户态的切换。
所以我们可以先简单说明: 执行自己的代码是用户态, 执行系统调用是内核态。

操作系统的运行原理

我们这里不得不插一句嘴说一下操作系统的运行原理,只有这样才能让大家理解内核态与用户态更加深入。

这一内容还是很重要的,我们会填上之前说的很多坑。

硬件中断

我们之前在键盘产生信号时一直在说硬件中断,这个到底是怎么一回事呢?
 

当我们的硬件准备就绪时,就会开始中断,每一个硬件都有自己的一个中断号,并且进程会通过高电压的形式通知CPU,我已经准备就绪了。当我们的CPU知道硬件中断后,会去获取这个硬件对应的中断号。

此时,CPU会保护现场,包括存储此时运行进程代码数据的CPU的数据,保存在PCB中(之前讲页表时我们提到过保护现场这个现象) 。

在这之后,会根据中断号来进行处理的方法,即:


这个中断向量表IDT,本质是还是一个函数指针数组,每一个硬件对应的中断号,就对应了下标。

所以每一个硬件产生中断后,他的处理方法我们一开始就知道了,该如何处理。

这里的中断处理例程,一共有四步:
1、保存现场

2、根据中断号,查中断向量表

3、调用对应的处理方法

4、恢复现场

而这个中断向量表,是操作系统的⼀部分,启动就加载到内存中了。

通过外部硬件中断,操作系统就不需要对外设进⾏任何周期性的检测或者轮询

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

 


时钟中断

但是我们还是有一个疑问,进程可以在操作系统的指挥下,被调度,被执⾏,那么操作系统⾃⼰被谁指挥,被谁推动执⾏呢?

外部设备可以触发硬件中断,但是这个是需要⽤⼾或者设备⾃⼰触发,有没有⾃⼰可以定期触发的 设备?

这里就要引出我们时钟中断的概念了

我们规定了一个时间,固定的触发硬件中断,这个中断,被我们称为时钟中断,负责定期帮我们实现中断!!

而这个中断在中断向量表的处理方法只有一个,那就是去调度进程!!

注意,调度进程不代表一定要进行进程切换。

还记得时间片这个概念吗?

其实就是一个整数count。

我们初始规定count=1000;

那么每一次进行调度,就回让count--,当count为0时,就会进行进程的切换。

所以我们还有主频这个概念,就是指 CPU 内部时钟信号的工作频率,通常以 赫兹(Hz) 为单位。你的CPU主频越高,价格越贵,同一个时间进行进程调度越多,性能越好。

这样,操作系统不就在硬件(时钟中断)的推动下,自动进行调度了么!!!
所以操作系统,就是基于中断向量表进行工作的。

死循环

如果是这样,操作系统不就可以躺平了吗?
对,操作系统⾃⼰不做任何事情,需要什么功能,就向中断向量表⾥⾯添加⽅法即可.操作系统的本质:就是⼀个死循环!
void main ( void ) /* 这⾥确实是 void ,并没错。 */
{          /* startup 程序 (head.s) 中就是这样假设的。 */
        ...
        /*
        * 注意 !! 对于任何其它的任务, 'pause()' 将意味着我们必须等待收到⼀个信号才会返
        * 回就绪运⾏态,但任务 0 task0 )是唯⼀的意外情况(参⻅ 'schedule()' ),因为任
        * 务 0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时),
        * 因此对于任务 0'pause()' 仅意味着我们返回来查看是否有其它任务可以运⾏,如果没
        * 有的话我们就回到这⾥,⼀直循环执⾏ 'pause()'
        */
        for (;;)
        pause();
} // end main

软中断 

上述外部硬件中断,需要硬件设备触发。
有没有可能,因为软件原因,也触发上⾯的逻辑,我们不是说过软件中断吗?所以自然有!
为了让操作系统⽀持进⾏系统调用,CPU也设计了对应的汇编指令(int 或者 syscall),可以让CPU内
部触发中断逻辑。
用户层怎么把系统调⽤号给操作系统?
: 寄存器(比如EAX)
操作系统怎么把返回值给用户?
: 寄存器或者用户传入的缓冲区地址
系统调用的过程,其实就是先int 0x80、syscall陷⼊内核,本质就是触发软中断,CPU就会自动执
⾏系统调用的处理方法,而这个⽅法会根据系统调⽤号,自动查表,执⾏对应的⽅法
所以系统调⽤号的本质就是数组下标!
// sys.h
// 系统调⽤函数指针表。⽤于系统调⽤中断处理程序(int 0x80),作为跳转表。
extern int sys_setup (); // 系统启动初始化设置函数。 (kernel/blk_drv/hd.c,71)
extern int sys_exit (); // 程序退出。 (kernel/exit.c, 137)
extern int sys_fork (); // 创建进程。 (kernel/system_call.s, 208)
extern int sys_read (); // 读⽂件。 (fs/read_write.c, 55)
extern int sys_write (); // 写⽂件。 (fs/read_write.c, 83)
extern int sys_open (); // 打开⽂件。 (fs/open.c, 138)
extern int sys_close (); // 关闭⽂件。 (fs/open.c, 192)
extern int sys_waitpid (); // 等待进程终⽌。 (kernel/exit.c, 142)
extern int sys_creat (); // 创建⽂件。 (fs/open.c, 187)
extern int sys_link (); // 创建⼀个⽂件的硬连接。 (fs/namei.c, 721)
extern int sys_unlink (); // 删除⼀个⽂件名(或删除⽂件)。 (fs/namei.c, 663)
extern int sys_execve (); // 执⾏程序。 (kernel/system_call.s, 200)
extern int sys_chdir (); // 更改当前⽬录。 (fs/open.c, 75)

 extern int sys_time (); // 取当前时间。 (kernel/sys.c, 102)
 extern int sys_mknod (); // 建⽴块/字符特殊⽂件。 (fs/namei.c, 412)
 extern int sys_chmod (); // 修改⽂件属性。 (fs/open.c, 105)
 extern int sys_chown (); // 修改⽂件宿主和所属组。 (fs/open.c, 121)
 extern int sys_break (); // (-kernel/sys.c, 21)
 extern int sys_stat (); // 使⽤路径名取⽂件的状态信息。 (fs/stat.c, 36)
 extern int sys_lseek (); // 重新定位读/写⽂件偏移。 (fs/read_write.c, 25)
 extern int sys_getpid (); // 取进程id。 (kernel/sched.c, 348)
 extern int sys_mount (); // 安装⽂件系统。 (fs/super.c, 200)
 extern int sys_umount (); // 卸载⽂件系统。 (fs/super.c, 167)
 extern int sys_setuid (); // 设置进程⽤⼾id。 (kernel/sys.c, 143)
 extern int sys_getuid (); // 取进程⽤⼾id。 (kernel/sched.c, 358)
 extern int sys_stime (); // 设置系统时间⽇期。 (-kernel/sys.c, 148)
 extern int sys_ptrace (); // 程序调试。 (-kernel/sys.c, 26)
 extern int sys_alarm (); // 设置报警。 (kernel/sched.c, 338)
 extern int sys_fstat (); // 使⽤⽂件句柄取⽂件的状态信息。(fs/stat.c, 47)
 extern int sys_pause (); // 暂停进程运⾏。 (kernel/sched.c, 144)
 extern int sys_utime (); // 改变⽂件的访问和修改时间。 (fs/open.c, 24)
 extern int sys_stty (); // 修改终端⾏设置。 (-kernel/sys.c, 31)
 ...   
 extern int sys_dup2 (); // 复制⽂件句柄。 (fs/fcntl.c, 36)
 extern int sys_getppid (); // 取⽗进程id。 (kernel/sched.c, 353)
 extern int sys_getpgrp (); // 取进程组id,等于getpgid(0)。(kernel/sys.c, 201)
 extern int sys_setsid (); // 在新会话中运⾏程序。 (kernel/sys.c, 206)
 extern int sys_sigaction (); // 改变信号处理过程。 (kernel/signal.c, 63)
 extern int sys_sgetmask (); // 取信号屏蔽码。 (kernel/signal.c, 15)
 extern int sys_ssetmask (); // 设置信号屏蔽码。 (kernel/signal.c, 20)
 extern int sys_setreuid (); // 设置真实与/或有效⽤⼾id。 (kernel/sys.c,118)
 extern int sys_setregid (); // 设置真实与/或有效组id。 (kernel/sys.c, 51)

 // 系统调⽤函数指针表。⽤于系统调⽤中断处理程序(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
 };
所以缺⻚中断?内存碎⽚处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断, 然后⾛中断处理例程,完成所有处理。有的是进⾏申请内存,填充⻚表,进⾏映射的。有的是⽤来处理内存碎⽚的,有的是⽤来给⽬标进⾏发送信号,杀掉进程等等。
所以,操作系统就是躺在中断处理例程上的代码块!
CPU内部的软中断,比如int 0x80或者syscall,我们叫做 陷阱, CPU内部的软中断,比如除零/野指针等,我们叫做 异常。(这也是“缺页异常” 为什么这么叫的原因)

总结:

由于时间关系。

今天的博客就写到这里,明天我们将会进行最后的结尾,为大家更加具体的说一下什么是内核态与用户态。

相信今天的知识已经把大家之前所学的内容串联起来,大家对操作系统也有了更加深刻的理解!

明天我们将会完成信号部分的内容,并给大家讲一些信号done的相关内容,之后我们将会开始线程的学习!!


网站公告

今日签到

点亮在社区的每一天
去签到