[Linux]进程信号

[Linux]进程信号

进程信号的定义

进程之间事件异步通知的一种方式。它是一种软件中断,用于向进程发送通知和指令,以便对其进行控制或传递信息。进程信号由整数值来标识,每个值对应一个特定的信号。不同的信号对应不同的状况。

信号的特点

  • 信号产生前,进程就知道如何处理
  • 信号一旦产生,进程能够识别信号。
  • 进程接收到信号后,不一定会立即处理,进程在收到信号后会先记录下来。
  • 信号的产生对于进程是异步的。

信号的生命过程

信号的生命过程分为三个阶段:

  • 信号产生
  • 信号保存
  • 信号处理

image-20230913193125910

发送信号的原理

进程信号有许多个,操作系统为了管理进程接收的信号,需要使用一定的结构描述信号,操作系统采用了位图结构来记录不同的信号,该位图结构记录在进程的pcb中,当操作系统向进程发送信号时,就向进程pcb中描述信号的位图结构写入数据。

进程处理信号的方式分类

  1. 忽略此信号。
  2. 执行该信号的默认处理动作。
  3. 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉 (Catch)一个信号。

使用指令查看Linux系统定义的信号

使用kill-l可以查看Linux系统定义的信号列表:

image-20230912131515776

使用man 7 signal可以查看Linux系统定义的信号详细说明:

image-20230912132501892

信号产生

使用终端按键产生信号

在Linux系统中输入crtl + c可以给进程发送2号信号,2号信号默认的处理动作是终止进程。给进程发送2号信号的示例如下:

键盘发送信号

使用指令向进程发送信号

Linux系统中提供了kill -信号数 进程pid指令用于向指定进程发送信号:

指令信号

调用系统调用向进程发送信号

kill接口

Linux系统中提供了kill系统调用用于向指定进程发送信号:

//kill所在的头文件和声明
#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);
  • pid参数 – 要发送信号的进程编号。
  • sig参数 – 要给进程发送的信号。
  • 成功返回0,失败返回-1,错误码被设置。

raise接口

Linux系统中提供了raise系统调用用于向进程自身发送信号:

//raise所在的头文件和声明
#include <signal.h>

int raise(int sig);
  • sig参数 – 向进程发送的信号。
  • 成功返回0,失败返回失败原因对应的非0值。

abort接口

Linux系统下C语言库中提供了abort库函数用于向进程自身发送信号 SIGABRT

//abort所在的头文件和声明
#include <stdlib.h>

void abort(void);
  • abort是C语言库中提供的接口。
  • abort向调用进程自身发送6号信号 SIGABRT

由软件条件产生信号

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

//alarm所在的头文件和声明
#include <unistd.h>

unsigned int alarm(unsigned int seconds);
  • 调用alarm函数操作系统会创建闹钟并建立对应数据结构组织起来。

硬件异常产生信号

硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除 以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。

核心转储

Linux操作系统可以在进程异常时,将核心代码部分和相关的内存数据全部导出到磁盘外设中,这一功能被称作核心转储。

进程信号和核心转储的关系

image-20230912170508835

不同的进程异常情况,进程会收到不同的进程信号,使用man 7 signal指令查看进程信号的信息,其中Action列中为Core的信号发送给进程后,进程就会进行核心转储。

使用指令操作系统核心转储信息

查看核心转储信息

使用ulimit -a指令可以查看到系统核心转储文件的信息:

image-20230912170936766

云服务中默认设定核心转储文件的大小上限为0,也就是不进行核心转储。

修改核心转储信息

使用ulimit -c指令可以修改核心转储文件的大小上限:

image-20230912171123251

核心转储文件的使用

核心转储文件的主要作用是当程序发生崩溃或异常终止时,通过分析核心转储文件,可以了解程序崩溃的原因、定位错误的位置以及查找潜在的缺陷。

运行一个进程用于,然后给它发送8号信号,让他产生核心转储文件:

image-20230912171514803

使用gdb调试可执行程序,然后使用core-file指令打开核心转储文件,就能从调试中看到进程异常原因:

image-20230912171807299

核心转储和退出信号的关系

如果某一进程产生异常生成了核心转储文件,Linux系统下进程使用系统接口等待回收该进程,获得的退出信息中倒数第八位会被置为1:

image-20230912173222507

也就是core dump标志被置为1。

阻塞信号

  • 实际执行信号的处理动作称为**信号递达(**Delivery) 。
  • 信号从产生到递达之间的状态,称为信号未决(Pending)。
  • 进程可以选择阻塞 (Block )某个信号。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
  • 注意,**阻塞和忽略是不同的,**只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

信号在内核的表示(信号保存)

信号在内核中的表示示意图如下:

image-20230912194206031

操作系统在内核数据结构task_struct中为信号维护了三张表:

  • block: 位图结构,比特位的位置,表示一种信号,比特位的内容,对应的信号是否被阻塞。
  • pending: 位图结构,比特位的位置,表示一种信号,比特位的内容,对应的信号是否接收到了。
  • handler: 函数指针数组,数组的下标,表示信号的编号,数组的特定下标的内容,表示信号的递达动作。(用户自定义递达动作就是通过修改该数组实现的)

信号集操作

信号集数据类型

  • 每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集

  • sigset_t类型可以表示每个信号 的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有 效”和“无效”的含义是该信号是否处于未决状态。

  • sigeset_t类型的底层实现也是一种位图结构。

信号集操作函数

sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,这个类型内部如何存储这些bit则依赖于系统实现,用户不需要关心具体实现,只要能够按需求操作信号集即可,因此Linux系统提供了如下函数来操作信号集:

//信号集操作函数所在的头文件及函数声明
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo); 
  • 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。

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

  • 注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptysetsigfillset做初始化,使信号集处于确定的状态。

  • 初始化sigset_t变量之后就可以在调用sigaddsetsigdelset在该信号集中添加或删除某种有效信号。

  • 前四个函数都是成功返回0,出错返回-1。

  • sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。

sigprocmask函数

调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集–block表)。

//sigprocmask所在的头文件及函数声明
#include <signal.h>

int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 
  • 如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
  • 如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
  • 如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
  • 返回值:若成功则为0,若出错则为-1

假设当前的信号屏蔽字为mask,下表说明了how参数的可选值:

SIG_BLOCKset包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set
SIG_UNBLOCKset包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set
SIG_SETMASK设置当前信号屏蔽字为set所指向的值,相当于mask=set

sigpending函数

调用函数sigpending可以读取当前进程的未决信号集,通过set参数传出:

//sigpending所在的头文件及函数声明
#include <signal.h>

int sigpending(sigset_t *set);
  • 调用成功则返回0,出错则返回-1。

捕捉信号(信号处理)

用户态和内核态

Linux系统中进程地址空间分为两个部分,一个部分是用户空间,另一个部分是内核空间,两个部分都有自身对应的页表,一个是用户级页表,另一个是内核级页表,操作系统计算机开机时会将自身的代码和数据加载到内存中,这部分代码和数据通过同一张内核级页表映射到每个进程的地址空间中的内核部分,示意图如下:image-20230913193735144

由于操作系统的代码和数据通过同一张内核级页表映射到每个进程的地址空间中的内核部分,因此在进程运行时,只需要像跳转到动态库一样,跳转到操作系统的代码和数据,使得进程代码和操作系统代码的切换可以在一张进程地址空间中跳转完成,但操作系统为了不让用户通过进程地址空间中的内核部分的随意访问操作系统的代码和数据,操作系统设置了两种状态:**用户态和内核态,当处于用户态时,进程只能访问用户进程的代码和数据,当处于内核态时,进程只能访问操作系统的代码和数据。**为了记录当前处于用户态还是内核态,将标志信息记录在CPU的寄存器中。

小总结一下:

  • 所有进程地址空间的用户空间中的内容是不一样的,每一个进程都有自己的用户级页表,看到属于自己的代码和数据。
  • 所有进程地址空间的内核空间中的内容是一样的,每一个进程都会看到同一个内核级页表,看到同一个操作系统。
  • 操作系统实际是通过进程的地址空间完成运行。
  • 使用系统调用的本质就是跳转到进程地址空间的内核空间部分运行。
  • 为了保护操作系统中的代码和数据,操作系统设置了用户态和内核态。

信号处理的时机

当进程从内核态切换回用户态的时候(信号记录在内核中,只能在内核态访问),进程会在操作系统的指导下,进程信号的检测和处理。

用户态切换至内核态的情况如下:

  • 发生系统调用:当用户程序需要访问受保护的系统资源或请求操作系统提供的服务时,它会发起系统调用。操作系统会接收到系统调用请求,然后执行相应的系统代码来处理该请求,并返回结果给用户程序。
  • 异常或中断事件:当发生硬件故障、软件错误或外部中断等事件时,操作系统需要对其进行处理。操作系统会通过中断处理程序或异常处理程序来响应这些事件,并执行必要的系统代码来处理它们。
  • 定时器事件:操作系统通常会使用定时器来进行时间管理和调度。当定时器触发时,操作系统会响应该事件,执行系统代码以更新任务调度和执行状态。

补充说明: 计算机存在一个计时器硬件,当计时器记录进程运行到一定时间后,操作系统会执行对应的中断方法,检查时间片,如果时间片到了就会调用操作系统中的调度函数,完成进程的切换。

信号处理的过程

在进程运行执行对应代码时,遇到需要切换至内核态的情况,产生中断跳转至内核态,进入内核态后完成对应中断处理后,开始检测信号,进行,执行信号对应的处理动作,若是默认处理或者忽略处理后就会直接进入用户态跳转回用户态中断前代码,如果是自定义处理动作,会进入用户态跳转执行对应的自定义处理动作,然后调用sigreturn回到内核态,再调用sys_sigreturn进入用户天跳转回用户态中断前代码。执行自定义处理动作的过程示意图如下:

image-20230913213158930

  • 在检测信号后,会将要处理的信号在block表对应位置置为1,将pending表中对应位置置为0,然后再执行处理动作。

signal函数

调用函数signal可以将对应信号的处理动作改为自定义处理动作:

//signal所在的头文件及函数声明
#include <signal.h>

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);
  • signum参数 – 要修改的信号。
  • handler参数 – 自定义处理函数的函数指针。传入SIG_DFL为默认处理动作,SIG_IGN为忽略动作。
  • 返回值是一个函数指针,如果成功设置信号处理程序,则返回先前的信号处理程序的函数指针;如果出现错误,则返回 SIG_ERR
  • SIGKILLSIGSTOP 信号不能被修改为自定义处理动作。

编写如下代码进行测试:

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

using namespace std;

void handler(int signo)
{
    cout << "get the signal: " << signo << endl;
}

int main()
{
    signal(SIGINT, handler);
    while(true)
    {
        cout << "i am running, pid: " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

编译代码运行查看结果:

signal

sigaction函数

sigaction函数可以读取和修改与指定信号相关联的处理动作。

//sigaction所在的头文件及函数声明
#include <signal.h>

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
  • signo参数 – 指定信号的编号。
  • 若act指针非空,则根据act修改该信号的处理动作。
  • 若oact指针非空,则通过oact传出该信号原来的处理动作。
  • 调用成功则返回0,出错则返回- 1。

sigaction函数需要使用struct sigaction数据类型作为参数,其结构定义如下:

struct sigaction {
    void     (*sa_handler)(int);
    void     (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t   sa_mask;
    int        sa_flags;
    void     (*sa_restorer)(void);
};

其主要字段如下:

  • sa_handler: 赋值为一个函数指针表示用自定义函数捕捉信号
  • sa_mask: 在进行信号处理时需要额外屏蔽的信号
  • sa_flags: 默认为0即可。

sigaction函数使用结构体作为参数修改信号处理动作,相比signal功能更为强大。

volatile关键字

在C语言中,volatile 是一个关键字,用于告诉编译器某个变量是易变的(volatile)并且不应该进行优化。

为了体会volatile关键字的作用,创建如下文件及文件内容:

makefile:

mysignal:mysignal.cc
	g++ -o $@ $^ -std=c++11 -O2//注意此处采用O2优化级别

.PHONY:clean
clean:
	rm -rf mysignal

mysignal.cc:

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

using namespace std;

int quit = 0;

void handler(int signo)
{
    cout << "get the signal: " << signo << endl;
    quit = 1;
    cout << "quit: " << quit << endl;
}

int main()
{
    signal(2, handler);
    while(!quit);
    cout << "process quit" << endl;
    return 0;
}

编译代码并查看运行结果:

volatile1

从现象中可以看出,在键盘中输入ctrl+c传入二号信号后,执行了自定义的处理动作quit改为了1,但是循环依旧继续,程序并未终止。这是因为编译器的优化,在无优化的情况下,从汇编来看应该是在每次循环时将quit数据从内存加载到CPU寄存器中进行判断,但是由于main函数中没有quit修改的代码,编译器以为quit不会修改,直接将汇编优化成了只从内存中加载一次quit,然后只进行判断,信号处理后修改的是内存的数据,因此不会使得循环停止。

使用volatile关键字修改代码:

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

using namespace std;

volatile int quit = 0;

void handler(int signo)
{
    cout << "get the signal: " << signo << endl;
    quit = 1;
    cout << "quit: " << quit << endl;
}

int main()
{
    signal(2, handler);
    while(!quit);
    cout << "process quit" << endl;
    return 0;
}

编译代码并查看运行结果:

volatile2

volatile关键字让quit不再优化,每次判断都从内存加载quit到CPU中,因此现象如上图。

SIGCHLD信号

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

void handler(int signo)
{
    while(true)
    {
        pid_t id = waitpid(-1, NULL, WNOHANG);
        if (id > 0) printf("waitpid: %d, my pid: %d\n", id, getpid());
        else break;
    }
}

说明:

  • waitpid传入-1让其接收任意一个僵尸状态的子进程。
  • 循环调用waitpid是为了解决如果一个进程回收时,其他进程进入僵尸状态。
  • 采用WNOHANG非阻塞是为了解决暂时没有子进程需要回收,进入阻塞状态。

除了以上自定义处理外,Linux系统还提供了另一种方法解决僵尸状态的子进程:父进程调用sigactionSIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。(此方法对于Linux可用,但不保证在其它UNIX系统上都可用。)

评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

好想写博客

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值