Linux —— 进程信号

一、对信号的基本理解

  1. 通常对于一个信号而言,有三个基本阶段,信号产生、信号保存、信号处理

而对于一个进程而言,在每个描述进程的相关结构体task_struct中有个专门用来保存信号的位图结构,每个位对应着一个信号,在进程中对信号的捕捉和保存,实际就是在修改task_struct中的位图

所以,所谓的发送信号,其实是向进程的task_struct中的位图写入信号,而这个过程,一定是由操作系统来完成的,因此,接下来无论哪种信号产生的方法,最终都是由OS来完成

  1. 对于一个进程而言,信号的产生是异步的。

3. 通过指令(kill -l)可以在Linux下查看信号,其中前31个是分时信号,后面34到64是实时信号,我们本章只学习前31,也就是分时信号

4. 进程还分前台进程和后台进程,Linux下只允许一个前台进程运行,其余都是后台运行,直接输入指令执行的进程,默认为前台进程

例如:Ctrl+C 实际就是在向前台进程发送一个SIGINT信号,效果是终止进程,所以在一些死循环的进程中可以用Ctrl+C去终止

  1. 关于键盘,我们在按下键盘的时候,系统是怎么知道,并且识别到信息的呢?

不太准确但大概是来说,在我们按下某个键或者多个键的时候,首先是通过硬件中断的方式,去告知CPU键盘被按下了,CPU上会有相关的引脚,通过发送电频信号的方式,CPU会存下对应的中断号,然后软件上存在一个中断向量表,会识别出具体是哪个外设产生的中断信号,然后当识别到是键盘的时候,再去判断处理,具体是哪些数据(哪些键被按下了)

6. 接受到信号后,处理信号的方式无非就三种,一种是执行默认动作,一种是自定义动作,还有一种是忽视信号(不作为)

要如何让某个信号去执行自定义动作,这得通过一个系统调用接口:signal

头文件

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

函数声明

cpp 复制代码
typedef void (*sighandler_t)(int);       // void fun_t(int signo)类型的函数指针

sighandler_t signal(int signum, sighandler_t handler);

参数说明

signal函数的作用,是修改signum号信号的默认动作为自定义的动作handler

所以第一个参数signum是信号的编号,第二个参数handler是自定义的一个动作(函数),这个函数类型是void fun_t(int signo),这个函数中的signo值是表示哪一个信号去执行了它,也就是和signum的值是一样的

用法样例

cpp 复制代码
#include<iostream>
using namespace std;
#include<signal.h>
#include<unistd.h>

void handler(int signo)
{
    cout << "我修改了" << signo << "号信号的默认动作" << endl;
}

int main()
{
    signal(2,handler); // 2号信号其实就是Ctrl+C,这里2号信号原本是执行终止进程的动作,我们修改了其行为为打印
    
    while(true)
    {
        cout << "我是一个前台进程,我正在死循环,我的pid是:" << getpid() << endl;
        sleep(1);
    }
    return 0;
}

二、本章节的核心内容

三、信号产生

对于一个信号,我们存在信号产生,信号保存,信号处理三个阶段,而我们这里要学习的信号,又存在哪些产生方式呢?

1. 系统调用

1.1 kill

头文件

cpp 复制代码
#include<sys/types.h>

#include<signal.h>

函数声明

cpp 复制代码
int kill(pid_t pid, int sig);

参数说明

kill函数的第一个参数是进程的pid,第二个参数是信号编号,返回值为0则说明函数调用成功,返回-1则说明失败,错误码被设置

用法示例

kill指令实际就是对kill函数的封装,我们尝试自己简单的封装一个kill指令,能够实现:

./mykill 信号编号 进程pid

这种形式的执行,并且做个简单的使用说明手册

mytest.cc

cpp 复制代码
#include<iostream>
using namespace std;
#include<signal.h>
#include<string>
#include<cstring>
#include<unistd.h>

void Usage(string proc)
{
    std::cout << "\tUsage: \n\t";
    std::cout << proc << " 信号编号 目标进程\n" << std::endl;
}

int main(int argc,char *argv[])
{
    //如果不按要求使用,则通过一个使用说明
    if(argc != 3) 
    {
        Usage(argv[0]);
        exit(1);
    }

    //调用kill函数去发送信号
    int sig = atoi(argv[1]);
    pid_t pid = atoi(argv[2]);
    int n = kill(pid,sig);
    if(n != 0)
    {
        cerr << errno << " : " << strerror(errno) << endl;
        exit(2);
    }

    return 0;
}

我们随便写一个死循环进程,去测试该代码即可

1.2 raise

头文件

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

函数声明

cpp 复制代码
int raise(int sig);

参数说明

该函数是对调用这个函数的进程发生sig信号,也就是哪个进程调用,就对那个进程发送信号,参数就是信号码,返回值和kill一样,返回0表示成功,-1失败,错误码被设置

1.3 abort

头文件

cpp 复制代码
#include<stdlib.c>

函数声明

cpp 复制代码
void abort(void);

函数说明

这个函数是C语言封装过的一个接口,并不是直接的系统调用,它的作用是给调用该函数的进程发送6号信号,作用是终止进程,类似于exit接口,因为有exit接口,所以该还是不怎么使用,它有一个特性,因为是经过C语言封装过的,我们可以使用signal函数对6号信号进行捕捉并且让其完成自定义动作,但如果使用该接口发送的6号信号,进程仍然会执行默认动作,进程会先执行完自定义动作,然后再去执行终止进程的动作

2. 由软件条件产生的信号

由软件条件产生的信号指的是,通过程序中的特定操作或者条件触发产生的信号,而不是外部硬件设备或者用户主动干预产生的信号,这里介绍一个经典的接口alarm,这个接口可以认为就是一个闹钟,设置的时间到了以后就会发送一个SIGALRM信号(终止进程)

头文件

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

函数声明

cpp 复制代码
unsigned int alarm(unsigned int seconds);

参数说明

second指定了定时器的超时时间,即多少秒后发送SIGALRM信号给当前进程。设置信号传送闹钟,当定时器超时时,内核会为进程产生SIGALRM信号。如果忽略或不捕捉此信号,默认动作是终止调用该alarm函数的进程。

返回值说明

  • 如果在seconds秒内没有再次调用alarm函数设置新的闹钟,则返回值为 0 或之前设置的闹钟时间的剩余秒数。如果之前未设闹钟,则返回 0。
  • 如果在seconds秒内再次调用了alarm函数设置了新的闹钟,则后面定时器的设置将覆盖前面的设置,即之前设置的秒数被新的闹钟时间取代,同时返回之前闹钟的剩余时间。
  • 当参数seconds为 0 时,之前设置的定时器闹钟将被取消,并将剩下的时间返回。

3. 由硬件异常产生的信号

3.1 除0问题

当程序出现除数为0的时候,在CPU中有个硬件叫状态寄存器的硬件用于检查本次计算是否有溢出问题,当出现除0问题的时候,会导致状态寄存器检测到溢出,然后会向该进程发送8号信号SIGFPE,表示浮点数异常

ps:如果用signal函数重新定义了8号信号的动作,导致没能成功终止掉进程,那么会因为不断检测到寄存器溢出的问题,从而不断的发送8号信号。

3.2 野指针问题

当程序出现野指针问题时,CPU中存在一个MMU会出现转换错误,MMU是用来转换虚拟地址和物理地址之间的一个硬件,在对某个指针解引用操作时,首先是该指针是否有在页表中存在映射关系,其次是是否具有访问该空间的权限,若是出现了这两个问题,MMU就会检测到,并且出现异常后,向进程发送11号信号SIGSEGV,表示段错误

ps:如果用signal函数重新定义了11号信号的动作,导致没能成功终止掉进程,那么会因为不断检测到寄存器溢出的问题,从而不断的发送11号信号。

4. 核心转储

核心转储大致理解就是,在Linux系统下有一个能力,可以在程序异常的时候,将核心代码和数据进行保存起来,并且dump到磁盘上,一般会在当前的运行目录下,生成一个core.pid这样的文件,这个功能一般云服务器下的Linux是关闭的

这个通过核心转储生成的文件在调试的时候,我们可以通过该文件,直接定位到该进程中出现该异常的代码,在查看信号文档中,会有结束信号的功能,其中core和term都是终止程序的功能,唯一区别就是core执行的终止进程,具有核心转储的能力

四、信号保存

1.信号保存的基本概念

首先,前面讲了关于信号的产生,以及产生的方式,而信号在产生后,我们需要让一个进程接收到信号,并且由于信号接收到以后,并不是在第一时间就处理的,所以我们需要将信号保存起来,进程中存在一个位图结构的表,叫pending表,就是用来记录保存信号的,信号被接收记录后保存下来的这个状态,也就是信号未决,因为此时对这个信号处理还有其他因素在起作用

2. 信号阻塞 block表

进程中,除了pending表去将信号保存起来,还同样存在一个位图结构的表block,表示该信号是否被阻塞,被阻塞的信号,将不会被递达(执行信号的对应动作)。

3. 信号处理 handler表

处理信号的方式无非就三种,一种是执行默认动作,一种是自定义动作,还有一种是忽视信号(不作为),handler本质是函数指针数组,handler表记录的就是对应信号的处理方式:

SIG_DFL:表示执行默认动作

SIG_IGN:表示忽略该信号

其他自定义的函数指针:执行自定义动作

4. 对block表的相关操作函数

4.1 sigset_t

block表的本质是一个记录信号阻塞信息的位图结构,Linux操作系统提供了一个数据类型sigset_t,专门用于表示信号集,并且提供了对sigset_t类型进行各种增删查改的接口

cpp 复制代码
#include <signal.h>
int sigemptyset(sigset_t *set); //将信号集全置为0,初始化
int sigfillset(sigset_t *set);  //将信号集全置为1
int sigaddset (sigset_t *set, int signo);  //向信号集set添加signo号信号
int sigdelset(sigset_t *set, int signo);   //向信号集set去掉signo号信号
int sigismember(const sigset_t *set, int signo);  //检查信号集set中是否存在signo号信号

4.2 sigprocmask

通过操作系统提供的接口,我们可以自定义出一个信号集,然后再通过系统调用接口sigprocmask去对进程中的block表进行操作

cpp 复制代码
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 

//返回值:若成功则为0,若出错则为-1

how参数表示,我们希望如何对block表进行操作,是将我们定义好的信号集进行添加操作(SIG_BLOCK),还是删除(SIG_UNBLOCK),还是覆盖(SIG_SETMASK)

set就是我们定义好的信号集,oset是输出型参数,它会将旧的信号集返回到oset中

4.3 sigpending

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

int sigpending(sigset_t *set);

sigpending接口是与之相关的接口,它是用于返回得到pending表的内容

五、信号检测

前面有提到,进程会将接受到的信号保存到pending表中,并不一定会立刻去处理信号,实际在处理信号之前,要对一个进程的pending表进行检测,然后再看对应的block表,判断该信号是否被阻塞,最后在根据handler表去判断如何执行,而信号的检测发生在内核态转变为用户态之前的一刻

1. 内核态和用户态

我们可以先简单的这样理解,在执行操作系统的代码时,所处的状态就是内核态,在执行用户写的代码时,所处的状态就是用户态

我们前面提到,操作系统本质就是软件,操作系统也是需要创建进程去跑的,但操作系统并不信任用户,在很多系统级的操作时,我们通过操作系统提供的接口去完成,而实际上这个过程,在计算机的角度看,就是在跑我们的代码时,当执行到系统调用接口时,我们会先将当下的状态,从用户态,切换成内核态,当处于内核态时,才有权限去执行操作系统相关的代码

从内核态转变成用户态的原因可能是:系统调用、中断、异常等等,中断的就是前面提到的,CPU一次只能执行一个进程,但会快速的不断执行进程,在外面看来好像是同时执行了很多进程,但实际上可以认为是因为速度非常快,所以才看起来每个进程都在被同时执行,而实际的每个都执行一部分后,中断去执行另一个

一个进程正常执行是处于用户态的,这也保证了用户不能具备操作系统一样的权限去随意执行一些非法操作。

CPU中存在一个叫CR3寄存器的东西,去表示当前的状态,0表示内核态,3表示用户态,当然还有其他状态,最常见的就是这两个,当CPU在执行系统调用时,会从用户态切换成内核态,而执行完系统调用后,又会从内核态再次切回会用户态,此时,对于这个进程而言,就是信号检测的时间点

2. 信号检测

信号检测就发生在内核态向用户态转变的前一刻,信号检测的本质,其实就是对pending表进行检测,检测方式是找到最靠近右边的1比特位,每次只能检测一个信号,相同的信号重复发送,也只会记录一次

3. 信号的捕捉

3.1 概念

信号的捕捉其实就是让某个信号去执行我们自定义动作,详细的过程:

首先进程因为中断或者其他原因,从用户态切换到内核态去执行相关任务,在执行完后,在从内核态切换后用户态之前,会进行信号检测,检测到某个信号,并且未被阻塞,执行动作为自定义动作时,此时需要处理该信号,该信号的动作是自定义的函数,因此需要再次切换成用户态,去执行自定义动作,结束后不能直接返回原先的代码位置,需要再次切换成内核态通过sys_sigreturn函数去找到进程被中断的位置,在这个过程中,该信号在被执行自定义动作时,会将同类型的信号阻塞,避免递归式的调用该函数

3.2 与信号捕捉相关的接口

3.2.1 signal

头文件

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

函数声明

cpp 复制代码
typedef void (*sighandler_t)(int);       // void fun_t(int signo)类型的函数指针

sighandler_t signal(int signum, sighandler_t handler);

参数说明

signal函数的作用,是修改signum号信号的默认动作为自定义的动作handler

所以第一个参数signum是信号的编号,第二个参数handler是自定义的一个动作(函数),这个函数类型是void fun_t(int signo),这个函数中的signo值是表示哪一个信号去执行了它,也就是和signum的值是一样的

3.2.2 sigaction

相比于signal,sigaction函数用于更精确地设置信号的处理动作和相关属性

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

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
  • signum:指定要操作的信号编号。
  • act:指向一个 sigaction 结构体,用于设置新的信号处理动作和属性。
  • oldact:如果不为 NULL,则用于保存之前设置的信号处理动作和属性。

sigaction 结构体通常包含以下重要成员:

  • sa_handler:指定信号处理函数的指针。
  • sa_mask:指定在处理该信号时需要阻塞的其他信号集合。
  • sa_flags:用于控制信号处理的一些标志,例如 SA_RESTART 表示在信号处理后自动重启被中断的系统调用。

六、拓展知识

1.可重入函数

由于信号可以被捕捉执行自定义动作,而进程的执行和信号的处理是异步的,就可能会出现这样一种情况,例如我进程中正在做的是对单链表的插入,但我只执行到一半,这个时候中断,然后再次执行之前,接受到某个被设定了自定义动作的信号,并且执行该信号的自定义动作中,也存在对该链表的操作,就可以会出现各种问题,像这种涉及到链表的函数,就被称为不可重入函数

如果一个函数符合以下条件之一则是不可重入的:

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

  2. 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

2. volatile关键字

这个关键字的修饰,在C++中是应对一些特殊的场景的

volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作

这里可以通过一个例子去举例:

3. SIGCHLD信号

前面说到,关于父子进程的问题中,当子进程结束时,父进程要对子进程进行回收,回收的方式有阻塞等待和非阻塞轮询,而实际上在子进程结束后,还会向父进程发送一个信号,也就是SIGCHID信号,我们也可以通过捕捉该信号的方式,去对子进程进行回收,但实际需要考虑的有很多,因此不推荐,例如同时多个子进程结束,而信号一次只能处理一个等等问题。

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

总结

本篇总结整理了关于信号的各种相关知识,了解了在进程中信号是如何被保存和处理的,并且还了解了关于用户态,内核态等等相关知识,加深了对操作系统的认知理解,并且总结了相关的接口操作。

相关推荐
€☞扫地僧☜€1 小时前
docker 拉取MySQL8.0镜像以及安装
运维·数据库·docker·容器
hjjdebug1 小时前
linux 下 signal() 函数的用法,信号类型在哪里定义的?
linux·signal
其乐无涯1 小时前
服务器技术(一)--Linux基础入门
linux·运维·服务器
Diamond技术流1 小时前
从0开始学习Linux——网络配置
linux·运维·网络·学习·安全·centos
写bug的小屁孩1 小时前
前后端交互接口(三)
运维·服务器·数据库·windows·用户界面·qt6.3
斑布斑布1 小时前
【linux学习2】linux基本命令行操作总结
linux·运维·服务器·学习
紅色彼岸花1 小时前
第六章:DNS域名解析服务器
运维·服务器
Spring_java_gg1 小时前
如何抵御 Linux 服务器黑客威胁和攻击
linux·服务器·网络·安全·web安全
✿ ༺ ོIT技术༻1 小时前
Linux:认识文件系统
linux·运维·服务器
恒辉信达1 小时前
hhdb数据库介绍(8-4)
服务器·数据库·mysql