【Linux】Linux进程信号产生和保存

一. 信号快速认识

1.1 生活角度的信号

在生活中存在着很多信号,比如红绿灯信号、上课铃声以及电话铃声等等,这些信号会告诉我们接下来的动作。在生活中,信号会中断我们人正在做的事情,是事件的异步通知机制。

  • 同步:顺序执行(像是在银行柜台排队取钱。你必须站在柜台前等业务员处理完,拿到钱后才能离开去干别的事。)
  • 异步:多种事件同时发生,互不影响(红绿灯由红变绿和行人在路上走互不影响)

1.2 Linux进程信号

在Linux操作系统中,信号就是一种给进程发送的,用来进行事件的异步通知机制。信号的产生,相对于进程的运行是异步的。

基本结论:

  • 1. 信号处理,进程在没有信号产生的时候,就已经知道信号该如何处理了(我们在没有看到红灯之前就知道接收到红灯信号之后怎么做)
  • 2. 信号的处理,不是立即处理,而是可以等一会再处理(需要进程将信号记录下来),合适的时候,进行信号的处理(电话铃声响了可以不立即接通电话可以空闲的时候接通)
  • 3. 人能够识别信号,是因为被提前教育(训练)过,进程也是如此,OS程序员设计的进程,进程早已经内置了对信号的识别和处理方式
  • 4. 给进程产生信号的信号源非常多

二. 信号产生的方式

在Linux操作系统中一共有62个信号,其中前1 - 31个信号属于普通信号(可以不被立即处理),后面的32个信号属于实时信号(接收到信号后需要立即处理)不需要着重考虑。信号在内核中是用宏定义的。

2.1 键盘产生信号

我们知道在进程执行的过程中,可以在键盘上按下ctrl+c终止这个进程的运行。其中ctrl+c就是给进程发送信号,进程对于相当一部分的信号,就是让自己终止 ,事实上ctrl+c就是向目标进程发送2号SIGINT信号。

进程收到信号后,对信号的处理方式

  • 按默认设定处理(默认处理动作就是进程自己终止)
  • 自定义处理
  • 忽略处理

那如何证明ctrl+c就是2号信号以及信号的默认处理动作是终止信号呢?我们可以修改进程对信号的默认处理动作,使用sighandler_t signal(int signum, sighandler_t handler);这一系统调用用于捕捉信号来修改进程对信号的默认处理动作。
signum:是信号的编号
handler:是函数指针,typedef void (*sighandler_t)(int);是函数指针类型

cpp 复制代码
void handler(int sig)
{
    std::cout << "接收到用户键盘按下Ctrl+c信号,对应信号为" << sig << std::endl;
}

int main()
{
    signal(SIGINT, handler);
    int cnt = 0;
    while (1)
    {
        std::cout << cnt++ << std::endl;
        sleep(1);
    }
    return 0;
}

这里对某一信号(Ctrl+c)默认处理动作的系统调用只需要调用一次,当系统接受到这个信号的时候,会将这个信号发送给进程,但是进程不会执行原有的处理方式,而是去执行我们自定义的函数。

可以看到在用户按下Ctrl+c后,进程并没有终止,反而去执行了我们自定义的函数,因此可以证明Ctrl+c就是2号SIGINT信号,以及进程对于大多数信号的默认处理动作就是结束自身进程。

进程对于信号的默认处理方式在文档中可以看到有TermCore终止以及StopIgn忽略这几种,对于大部分的信号处理方式是终止。
进程对于自定义信号的处理叫做信号的捕捉

目标进程

进程可以分为前台进程和后台进程

  • ./XXX 是前台进程,前台进程是从标准输入中读取数据的进程
  • ./XXX &是后台进程,后台进程无法从标准输入中读取数据
  • 无论是前台进程还是后台进程都可以向标准输出中打印数据

    从上图的代码演示中可以看到,前台进程中输入Ctrl+c进程有反应,后台进程没有反应,因此也不能通过Ctrl+c杀掉后台进程。前台进程只会执行当前的进程,在键盘输入其他进程后不会执行其他进程,但是后台进程可以执行其他进程(不严谨)。

在这个示例中,标准输入就是键盘,因为键盘只有一个,所以输入的数据一定是交给一个确定的进程的,因此一个终端前台进程必须有一个,后台进程可有多个 ,shell进程是开机打开的第一个前台进程,当执行前台进程的时候,shell创建子进程进程程序替换执行这个进程,shell父进程被提到后台,这也就是为什么在执行前台进程的时候不能执行其他进程的原因(除掉kill),前台进程的本质就是从标准输入中获取数据,所以键盘产生的信号也只能发送给前台进程,因此所谓的系统将信号发送给目标进程的目标进程就是前台进程

那如何杀掉后台进程呢?

  1. 通过pid杀掉

    2.将后台进程提到前台(前后台进程之间的切换)
    jobs查看所有的后台任务
    fg + 任务号将特定的进程提到前台

    这样就将一个后台进程切换到前台杀掉了。
    Ctrl+z将前台进程切换到后台
    bg+任务号让切换过去的后台进程恢复运行

什么叫做给目标进程发送信号

信号产生之后,可以不被立即处理,所以这些没有被处理的信号需要被记录下来,等到合适的时候处理。那这些信号被记录在哪里又是被如何记录的呢?这些信号被记录在进程的struct task_struct{unsigned int sigs}中,但是可能不是只有一个信号,所以用一个整数表示位图,比特位为1表示接收到对应下标的信号。

因此给进程发送信号的本质就是修改task_struct结构体中的属性,也就是为什么需要提供Pid和信号编号,给前台进程发信号只需要信号编号,因为前台进程知道自己的pid,使用kill -信号编号 + pid方便找到对应的进程修改内核数据。但是修改位图是修改的内核数据,作为普通用户并没有权限修改,只有OS可以修改(无论信号怎么产生,在底层都是OS给进程发信号),因此OS必须提供系统调用(kill)供上层用户使用来给进程发送信号。

键盘产生的信号,9号、19号信号不能被自定义捕捉

2.2 系统调用/函数产生信号

2.2.1 kill命令和kill系统调用

OS给用户提供了kill这一系统调用给进程发送信号
int kill(pid_t pid, int sig);
pid对应目标进程的pid,sig是要给目标进程发送的信号编号

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

// ./mykill signalnumber pid
int main(int argc,char* argv[])
{
    if(argc != 3)
    {
        std::cout<<"./mykill signalnumber pid"<<std::endl;
        return 1;
    }

    int signum = std::stoi(argv[1]);
    pid_t target = std::stoi(argv[2]);
    int n = kill(target,signum);
    if(0 == n) 
        std::cout<<"send"<<signum<<" to "<<target<<" success!"<<std::endl;
    return 0;
}

上段代码实现的功能就是模拟实现的kill指令,通过从命令行中读取命令行参数解析后,向目标进程发送对应的信号。

2.2.2 raise

int raise(int sig);sig是信号编号,这一函数 用来给进程自身发送信号。

2.2.3 abort

void abort(void);

abort - cause abnormal process termination,使进程终止掉。

abort是向进程发送6号信号,可以被自定义捕捉,但当执行完自定义动作之后,进程必须被结束掉。

2.3 硬件异常产生信号

除0异常


SIGFPE SIGFPE P1990 Core Floating-point exception浮点数异常

野指针


SIGSEGV P1990 Core Invalid memory reference 段错误

以上两种错误方式是程序中犯错误,然后OS检测到程序出错,OS向进程的task_struct发送错误信息

2.4 软件条件产生信号

基于管道的进程间通信,当读端关闭的时候,写端进程就会接收到OS发送的SIGPIPE信号终止掉进程,这是软件条件产生的信号之一。

  • SIGALRM时钟信号
    发送时钟信号的系统调用是unsigned int alarm(unsigned int seconds);
    调用alarm可以设定一个闹钟,也就是告诉OS在seconds秒之后给当前进程发SIGALRM信号,该信号的默认处理动作是终止当前进程。这个系统调用的返回值是上一个闹钟终止的剩余时间。
cpp 复制代码
void handler(int sig)
{
    std::cout << "接收到信号为:" << sig << std::endl;
    alarm(1);
}

int main()
{
    signal(SIGALRM, handler);
    alarm(1);
    while (1)
    {
        std::cout << "***" << std::endl;
        sleep(1);
    }
    return 0;
}

上面代码可以实现每隔一秒钟向进程发送信号的功能

cpp 复制代码
// //////////func////////////
void Sched()
{
    std::cout << "我是进程调度" << std::endl;
}
void MemManger()
{
    std::cout << "我是周期性的内存管理,正在检查有没有内存问题" << std::endl;
}
void Fflush()
{
    std::cout << "我是刷新程序,我在定期刷新内存数据,到磁盘" << std::endl;
}
// /////////////////////////

// typedef void(*func_t)();

std::vector<func_t> task_list;

void handler(int sig)
{
    std::cout << "**************************************" << std::endl;
    for (auto f : task_list)
        f();
    std::cout << "**************************************" << std::endl;
    alarm(1);
}

int main()
{
    task_list.push_back(Sched);
    task_list.push_back(MemManger);
    task_list.push_back(Fflush);
    signal(SIGALRM, handler);
    alarm(1);
    while (1)
    {
        pause();
    }
    return 0;
}

pause暂停进程的运行,等待进程发送信号 将其唤醒

对于上段代码稍作修改,就可以模拟实现一个操作系统,操作系统开启后,就会进入一个死循环等待用户输入,也就是相当于等待用户发送信号,当pause接收到信号被唤醒后,进程会根据信号执行对应的任务。

快速理解时钟

时钟在OS中会有多个,既然有多个时钟信号就必然会涉及到操作系统对时钟信号的管理,实际上在内核中,存在时钟信号的结构体,里面有一项属性用来记录时钟的结束时间,利用类似最小堆结构就可以将到期的时钟信号发送给对应的进程,进而执行对应的动作。

三. 信号的保存

  • 信号递达(Delivery):信号被处理的动作
  • 信号未决(Pending):信号从产生到递达的过程(位图被修改,但是没有处理)
  • 进程可以选择阻塞(Block)某个进程
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对该信号的阻塞,才能执行递达动作(进程的位图被修改,但是一直不执行,当阻塞解除的时候,才会递达)阻塞信号又叫做屏蔽信号
  • 阻塞和忽略是不同的,阻塞是递达之前的行为,而忽略是递达时的处理方式之一。

3.1 内核中的信号保存

我们知道,所谓的发送信号就是内核修改进程task_struct中的位图结构,实际上在这个结构体中,涉及到信号保存一共有三张表。

pending表可以看作是unsigned int pending的位图,比特位的位置表示收到第几个信号,比特位的数值表示是否接收到信号;block表可以看作是unsigned int block的位图,比特位的位置表示收到第几个信号,比特位的数值表示是否阻塞该信号。因此这个信号是否要被处理要进行pending & (~block)。而handler表是一个函数指针数组,里面存贮的是数组下标对应信号的默认处理动作,我们的signal函数进行自定义处理动作其实就是根据信号编号作为索引来修改的这一个函数指针数组。

cpp 复制代码
struct sigpending {
 struct list_head list;
 sigset_t signal;
}


#define SIG_DFL ((__sighandler_t) 0) /* Default action. */ 默认处理动作

#define SIG_IGN ((__sighandler_t) 1) /* Ignore signal. */ 忽略信号

接收到信号后不处理,继续执行。

3.2 sigset_t类型

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

cpp 复制代码
#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{
  unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;

3.3 信号集操作函数

sigset_t类型对于每种信号用一个bit表示"有效"或"无效"状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,用户只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。

cpp 复制代码
#include <signal.h> 
int sigemptyset(sigset_t *set); // 将bit清0
int sigfillset(sigset_t *set); // 将bit全设置为1
int sigaddset(sigset_t *set, int signo); // 添加一个信号
int sigdelset(sigset_t *set, int signo); // 删除某一个信号
int sigismember(const sigset_t *set, int signo); // 查询是否有这个信号

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

3.3.1 sigprocmask

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);获取或者更改进程的信号屏蔽字,返回值:若成功则为0,若出错则为-1

如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。

cpp 复制代码
/* Values for the HOW argument to `sigprocmask'.  */
#define	SIG_BLOCK     0		 /* Block signals.  */
#define	SIG_UNBLOCK   1		 /* Unblock signals.  */
#define	SIG_SETMASK   2		 /* Set the set of blocked signals.  */

3.3.2 sigpending

int sigpending(sigset_t *set);读取当前进程的未决信号集,通过set参数传出。成功则返回0,出错则返回-1。进程的未决信号集由OS向其更改。

cpp 复制代码
void Print(sigset_t &pending)
{
    printf("我是一个进程(%d),pending: ", getpid());
    for (int i = 31; i >= 1; i--)
    {
        if (sigismember(&pending, i)) // 为1
        {
            std::cout << '1';
        }
        else
        {
            std::cout << '0';
        }
    }
}

int main()
{
    sigset_t block, oldblock;
    // 置空处理
    sigemptyset(&block);
    sigemptyset(&oldblock);
    sigaddset(&block, SIGINT); // 屏蔽二号信号,但实际上并没有屏蔽
    // 信号在内核中,这里的函数处理的是栈上的数据,因此需要使用系统调用设置信号

    // 在内核中屏蔽二号信号
    int n = sigprocmask(SIG_SETMASK, &block, &oldblock);
    (void)n;

    while (1)
    {
        // 重复获取pending数据集
        sigset_t pending;
        int m = sigpending(&pending);
        // 打印pending集
        Print(pending);
    }
    return 0;
}

使用sigemptysetsigaddset这一系列接口设置的信号只在栈空间中,而真正的信号是在内核中,因此需要使用sigprocmasksigpending这些系统调用来设置阻塞和获取信号的pending集。
9号信号不可被递达,不可被阻塞!
在信号递达之前,就会将pending集中的信号位图由1->0

总结:

  • 如果有多个重复信号,常规信号在递达之前只会被记录一次,而实时信号在递达前产生多次可以依次放在一个队列里。
    Core VS Term

  • Core行为的核心是在当前路径下生成一个文件,进程异常退出的时候,会将进程在内存中的核心数据拷贝到磁盘形成一个文件,这种机制叫做核心转储。核心存储的目的是为了Debug,在云服务器上为了保证服务器的正常运行,是关闭了Core Dump机制的,可以用ulimit -c设置core文件的大小。

    所以生成core文件的作用就是为了方便Debug

  • Term行为就是直接退出

    低7位表示进程的异常退出信号,第八位表示是否产生core文件。

cpp 复制代码
int main()
{
    pid_t id = fork();
    if (0 == id)
    {
        printf("***********\n");
        printf("***********\n");
        int a = 0;
        a /= 0;
        printf("***********\n");
    }

    int status = 0;
    waitpid(id, &status, 0);
    printf("signal: %d,exit code: %d,core dump: %d\n", (status & 0x7F), (status >> 8) & 0xFF,
           (status >> 7) & 0x1);
    return 0;
}
相关推荐
CaspianSea2 小时前
清理 Ubuntu里不需要的文件
linux·运维·ubuntu
好评1242 小时前
【C++】AVL树:入门到精通全图解
数据结构·c++·avl树
c++逐梦人2 小时前
命令⾏参数和环境变量
linux·操作系统·进程
天码-行空2 小时前
达梦数据库(DM8)详细安装教程
linux·运维·数据库
白驹过隙不负青春2 小时前
Centos7开启、关闭swap
linux·centos
机器视觉知识推荐、就业指导2 小时前
Qt 6 所有 C++ 类(官方完整清单 · 原始索引版)
开发语言·c++·qt
负二代0.02 小时前
Linux下的软件管理
linux·运维
IT19952 小时前
C++ 实战笔记:OpenSSL3.5.2 实现 SM2 数据加密(附完整源码 + 注释)
开发语言·c++·笔记
leaves falling2 小时前
c语言自定义类型深度解析:联合(Union)与枚举(Enum)
c语言·开发语言·算法