【Linux】进程信号(上)—— 信号产生 | 保存信号

🌈欢迎来到Linux专栏~~信号


  • (꒪ꇴ꒪(꒪ꇴ꒪ )🐣,我是Scort🎓
  • 🌍博客主页:张小姐的猫~江湖背景
  • 快上车🚘,握好方向盘跟我有一起打天下嘞!
  • 送给自己的一句鸡汤🤔:
  • 🔥真正的大师永远怀着一颗学徒的心
  • 作者水平很有限,如果发现错误,可在评论区指正,感谢🙏
  • 🎉🎉欢迎持续关注!

Linux 信号

一. 信号快速认识

信号不是信号量!二者关系:老婆 vs 老婆饼

🎉生活角度的信号

  • 在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能"识别快递"
  • 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是⼀定要立即执行,可以理解成"在合适的时候去取"

结论:

1️⃣也就是说信号的处理方法, 在信号产生之前已经准备好了

2️⃣处理信号,立即处理吗?我可能正在做优先级更高的事情,会在合适的时候处理

3️⃣识别信号是内置的,进程识别信号,是内核程序员写的内置特性。

  • 信号到来 | 信号保存 | 信号处理
    信号处理 :默认处理(大部分是终止进程)、忽略、自定义动作,三种处理都叫做信号捕捉

🎉信号概念

信号:外部或者其他人或者硬件给进程发送的一种异步的事件通知机制,是告诉进程发送了什么
异步 :多种事件,彼此不影响,同时发生

事件:终止,异常,指令退出等等

之所以信号在合适的时候处理的前提是把信号保存下来

🌠查看信号

信号是从1号开始的,没有0号信号;其中1~31号是普通信号(31个)34 ~ 64实时信号

每个信号都有⼀个编号和⼀个宏

定义名称,这些宏定义可以在signal.h中找到,其中有定义

cpp 复制代码
define SIGINT 2 //使用时候既可以用名字,也可以用数字

本章只讨论编号34以下的信号,不讨论实时信号。

🌠处理信号:signal 函数

信号只需要调用一次即可,不需要重复调用

cpp 复制代码
#include <signal.h>
//typedef是把sighandler_t 变成 void func(int)型函数的指针
typedef void (*sighandler_t)(int); 

sighandler_t signal(int signum, sighandler_t handler);

参数说明:

  • int真实收到 的信号编号 signo
  • signum: 要捕获的目标信号
  • handler: 处理回调函数 。它可以是:自定义函数指针: 格式必须是 void func(int)

返回值: 成功:该信号之前的处理动作(老动作); 失败返回 :-1

cpp 复制代码
//signo 收到了什么信号,才让我执行的
void handler(int signo)
{
    std::cout << "收到了一个信号:"  << signo << std::endl;
    //默认2号信号终止,自定义处理信号了,就按照自定义的来
}

int main()
{
    signal(SIGINT, handler); //2号信号
    while(true)
    {
        std::cout << "test sig ..."  << getpid() << std::endl;
        sleep(1);
    }
    return 0;
}

所以得出:ctrl + c 终止进程,是通过键盘给目标进程发送2号信号 ,处理动作就是终止,所以此处自定义后,ctrl + c 也退出不了了

🌠细节问题

1️⃣信号自定义捕捉,如果你捕捉方法,不退出,进程就可能不会退出了。如果我把所有的信号都自定义了:都不退出? 会怎么样

  • 那不是怎么样都退出不了?工程师早考虑到了:kill -9kill -19 9号和19号信号不可被自定义 ------ 9号信号是管理员信号(boss)

2️⃣信号处理是谁处理的?有没有创建新信号?

  • 信号处理是自己做的

3️⃣默认处理:SIG_IGN;忽略:SIG_DFL(什么都不做,忽略此信号)

cpp 复制代码
#define SIG_DFL	((__sighandler_t)0)	 //0
#define SIG_IGN	((__sighandler_t)1)	 //1

signal(signumber, SIG_DFL); //忽略此信号

二、信号产生

当前阶段:

1️⃣通过终端按键产生信号

1️⃣Ctrl+C :向目标进程发送2号信号 (SIGINT)------ 默认动作终止进程

2️⃣Ctrl+\ :向目标进程发送3号信号 (SIGQUIT)------ 默认动作终止进程

3️⃣Ctrl+Z19号信号 ,默认发送停止信号,将当前前台进程挂起到后台

我们是怎么样知道信号的默认动作是什么?

  • signal(7)中都有详细说明: man 7 signal

1️⃣ 前台进程 vs 后台进程

前台进程./testsig运行,只有前台进程能从键盘读取输入,也就说当你按下 Ctrl+C ,终端驱动程序会将信号发送给当前前台进程组的所有成员

后台进程./testsig &运行,它不能从键盘读取输入。后台进程不会收到来自键盘的信号 。这意味着你按 Ctrl+C 无法杀死后台进程。

所以信号只能用来控制前台进程,因为只有前台进程才能获取键盘输入

ps:如果 Ctrl+Z后,在用kill -18,使得进程醒来,该进程就会变成后台进程

2️⃣为什么bash进程自己不对信号做响应呢?信号对bash没有任何作用

  • 因为bash进程把所有的信号都忽略了 ~ 吗?
  • 记住9号信号是boss信号,bash也无法忽略的
⭐理解OS如何得知键盘有数据


2️⃣调用系统命令向进程发信号

在Linux中,通过系统命令向进程发送信号主要使用 kill 命令

发送指定信号:kill -<信号编号> - <pid>

  • 例如:kill -9 1234 或 kill -SIGKILL 1234,向PID为1234的进程发送9号信号,该信号会无条件强制终止进程,进程无权忽略

3️⃣使用函数产生信号

👀kill

kill 命令是调用 kill 函数实现的。 kill函数可以给⼀个指定的进程发送指定的信号

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

int kill(pid_t pid, int sig);

成功返回 0 ,失败返回 -1

kill demo

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

void Usage(const std::string &cmd)
{
    std::cout << "Usage" << cmd << " signumber who" << std::endl;
}
// ./mykill signumber who
int main(int argc, char *argv[])
{
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }
    int signumber = std:: stoi(argv[1]);
    pid_t pid = std:: stoi(argv[2]);

    int n = kill(signumber, pid);
    (void)n;

    return 0;
}
👀raise

raise 函数可以给当前进程发送指定的信号(自己给自己发信号)

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

int raise(int sig);
👀abort (邪修)

abort 函数使当前进程接收到信号而异常终止

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

void abort(void);

其本质等于:给自己发送一个abort信号 ~ 6号信号

abort函数通常用来进行终止进程的!类似于exit

cpp 复制代码
SIGABRT      P1990      Core    Abort signal from abort(3)

void abort(void)
{
	int n = kill(getpid(), SIGABRT);
	(void)n;
}

abort即使你自定义了信号,它也会额外打印abort(core dumped)

4️⃣由软件条件产生信号 ~ 通过定时器模块

SIGPIPE 信号 ~ 管道损毁

SIGPIPE信号实际上就是一种由软件条件产生的信号,当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE信号进而被操作系统终止。

接下来主要介绍 alarm 函数和 SIGALRM 信号

alarm:给进程设置闹钟

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

unsigned int alarm(unsigned int seconds);

返回值:

  • 返回值是0 (上一个闹钟超时了)或者是以前设定的闹钟时间还余下的秒数

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

🗻alarm验证-体会IO效率问题

程序的作用是1秒钟之内不停地数数,1秒钟到了就被SIGALRM信号终止。必要的时候,对SIGALRM信号进行捕捉


📌 结论:

  • alarm闹钟只会响⼀次,默认终止进程
  • 冯诺依曼体系会导致效率的降低:多IO导致效率低
🏀设置重复闹钟
cpp 复制代码
#include <iostream>
#include <signal.h>

int cnt = 0;
void handler(int sig)
{
    std::cout << "进程捕捉到信号:" << sig << " pid: " << getpid() 
        << "cnt: " << cnt <<  std::endl;
    // 重设闹钟,会返回上⼀次闹钟的剩余时间
    int n = alarm(2);
    (void)n;
}

int main(int argc, char *argv[])
{
	// ⼀次性的闹钟,超时alarm会⾃动被取消	
    alarm(2);
    while(true)
    {
        signal(SIGALRM, handler);
        std::cout << "进程正在运行: " << cnt << std::endl;
        sleep(1);
    }
    return 0;
}

📌 结论:

  • 闹钟设置一次,起效一次
  • 重复设置的方法 ------ 在hander方法里设置;不要在循环里设置,否则会导致重复设置,一次都没触发
  • 如果时间允许,可以测试⼀下 alarm(0)取消闹钟
    当其传入参数为 0 时,其功能就是取消当前进程之前设置的尚未超时的闹钟,使其不再产生 SIGALRM 信号
🐣如何理解闹钟?

系统闹钟,其实本质是OS必须自身具有定时功能 ,并能让用户设置这种定时功能,才可能实现闹钟这样的技术。

现代Linux是提供了定时功能的,定时器也要被管理:先描述,在组织。内核中的定时器数据结构是:

cpp 复制代码
struct timer_list {
	struct list_head entry;
	unsigned long expires; //未来过期的时间
	
	void (*function)(unsigned long);
	unsigned long data;
	
	struct tvec_t_base_s *base;
};

我们不在这部分进行深究,为了理解它,我们可以看到:定时器超时时间 expires处理方法 function

  • 操作系统管理定时器,采用的是时间轮 的做法,但是我们为了简单理解,可以把它在组织成为"最小堆结构 "。(五秒后的闹钟没超时,后面50秒的闹钟会超时吗? 查堆顶)
  • 如果一个闹钟超时了,就在"堆"中释放,这样就是一次闹钟

5️⃣硬件异常产生信号

硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。

如果我们的程序除0,野指针等,程序就会崩溃 ------ 为什么会崩溃?

  • 本质是因为:程序因为异常,导致收到了信号,然后让进程终止的!
  • 除0是收到了8号信号SIGFPE,野指针收到了1号信号SIGSEGV

继续扣细节:

1️⃣为什么会收到信号?OS怎么知道当前进程div 0 或者野指针?

  • 信号被保存在进程的task_struct结构体中,是用位图来存储的
  • 一号比特位为1就代表收到了一号信号
  • 发送信号的本质:就是OS修改信号位图 ,虽然发送信号的方式有很多种,但最终只能有OS向目标进程写信号!
  • div 0 本质 CPU出错了;野指针本质是MMU报错,二者都是硬件,而OS是硬件的管理者,OS肯定知道当前是哪个进程给我的硬件搞破坏了 ,所以才向目标进程去写信号

    比如进行虚拟地址到物理地址的映射时,我们先将页表的左侧的虚拟地址导给MMU,然后MMU会计算出对应的物理地址,我们再通过这个物理地址进行相应的访问;MMU已经集成到CPU当中了,MMU出错也就是硬件出错

2️⃣为什么div 0 或者野指针,会触发硬件报错?

  • 本质上是CPU标志寄存器 对当前计算的溢出标志位 设置为1,说明当前的计算不可信。

  • OS是如何知道,当前进程是谁??OS会定义一个全局的指针current,它永远会指向当前正在运行的进程

    cpp 复制代码
    register struct task_struct *current asm("gr29");

3️⃣为什么会"死循环"的打消息?

收到了8号信号,用户捕捉了这个信号------ 打印,但没有让进程退出 ------ (OS发现了异常但是没有消除就会继续给进程发信号

  • 此时寄存器内容都是当前进程的硬件上下文
  • 后续进程的时间片到了,被切换走了后把cpu里所有的数据(包括标记寄存器 数据等等)都保存起来,一旦恢复出来 ------ 原本的数据就全回来了 (此时还是一直报错 ------ 所以OS就会给你一直发信号) 导致了死循环。

⭐ 本质:程序出错的那一行代码被OS反复执行了

4️⃣子进程退出 core dump?

core dump:当进程因为致命异常信号退出时,内核会(可选地)把进程内存写到文件core ~ 核心转储

  • 进程在运行时,大部分数据存在内存里,为了便于用户调试,进程运行时把异常信息 ------ 转储到磁盘中
  • 核心转储是为了后续debug ------ 回答了在哪里退出的问题
cpp 复制代码
ulimit -c 1024 //设置核心转储的大小为...

运行程序core dump,当出现了core文件后,直接cgdb ~ a.outcore-file core.XXX 即可找出异常 ------ 事后调试

  • 云服务器默认关core dump的原因是:防止core临时文件过大,进而占用盘内存被占满的情况。

5️⃣我怎么知道,我当前的进程发生了核心转储呢?
bash ------ 子进程(执行我们的代码) ------ Floating point exception(core dumped) 是bash进程打的

第八位就是记录子进程被杀时,有没有被core dump

cpp 复制代码
printf("退出码: %d, 退出信号: %d, core dumped: %d", (status >> 8) & 0XFF, status & 0x7F, (status>>7) & 0x1);

总结: 子进程被杀后, 父进程waitpid 拿到了子进程的退出信息,进而拿到了退出码、退出信号以及是否被core dump(core dump标志位)

  • Term:就是单纯的终止
  • Core:可能会发送核心转储!设置进程退出 status core dumped标志位给父进程

三、信号保存


为什么要保存信号? - 由于信号不会被立即处理,产生之后,处理之前就有时间窗口,所以必须被保存起来

🌅信号其他相关常见概念

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

举个例子

  • 信号递达 ------ 学生实际动手写作业
    老师布置了作业,学生最终拿起笔开始写作业,这个"执行写作业动作"的过程就是信号的递达
  • 信号未决 ------ 作业已经布置,但学生还没开始写
    老师刚在讲台上宣布了作业(信号产生),但学生还没动手写,作业处于"放着等写"的状态,这个从布置到动手之间的间隔期就是信号的未决状态
  • 信号阻塞 ------ 学生把作业塞进抽屉,规定自己现在坚决不写
    学生可以选择把作业本塞进抽屉锁上(阻塞)。只要抽屉没打开(未解除阻塞),哪怕老师布置了再多作业,学生也绝不会动手写(不会递达),这些作业只能一直躺在抽屉里(保持在未决状态),直到学生决定打开抽屉(解除阻塞),才会开始写作业(执行递达)
  • 阻塞与忽略的区别 ------ "锁抽屉不看 "与"拿出来看后扔进垃圾桶 "的区别。
    阻塞 不是处理信号的方式,属于不让信号递达 的做法之一
    忽略是处理信号的方式,属于开始处理信号了!

🌅在内核中的表示

信号在内核中的表示意图

1️⃣pending表是位图结构,代表的是信号未决: 0000 ... 0000(只用31位)

  • 比特位的位置:信号编号
  • 比特位的内容(1 / 0):是否收到信号

例如0000 ... 0011 :表示1号与2号信号都收到了

2️⃣block表也是位图结构,代表的是信号阻塞:

  • 比特位的位置:信号编号
  • 比特位的内容(1 / 0):是否收到阻塞

注意 block 和 是否pending 没有任何关系!只要block了,对应的pending的信号都不能执行的

3️⃣handlersignal函数是有关联的,我们可以把handler表看作是一个函数指针数组即可

cpp 复制代码
sighandler_t handler[32]
  • 数组的下标是:信号 - 1
  • 直接拿信号去索引handler表,就知道该怎么处理了

问题一:signal(2,myhandler)底层会做什么?

  • 拿着信号编号 去handler表里面找到相应的下标位置,再把myhandler的地址填入即可,这就是自定义捕捉

问题二:什么叫做忽略, 默认?

cpp 复制代码
define SIG_DEL ((__sighandler_t) 0)
define SIG_IGN ((__sighandler_t) 1)

把0和1强转成函数指针类型 ,通过地址对比来判断是DFL还是IGN;所以忽略和默认也是函数指针类型,只不过数字是0 和 1

问题三:在信号还没有产生的时候,进程就能识别和处理信号了!!

对于一号信号,识别到 block 和 pending都是0 的情况下,我也能知道它是怎么处理的 ------ 默认处理

  • 因为工程师内置了信号的管理和处理方法了!!

总结:进程信号是有三张表:block 对应阻塞,pending对应未决,handler对应抵达

🌳sigset_t

OS需要让用户控制信号,本质就是访问和操作上面的三张表!这三张表属于内核数据结构,也就注定了要提供系统调用(signal访问handler表),还需要提供其他系统调来获取或者修改block 和pending表

为了支持我们做操作,要先认识一个内核级数据类型 sigset_t(OS提供的类型!)

我们把sigset_t 看做是无符号长整形

cpp 复制代码
typedef unsigned long sigset_t;

🌳信号集操作函数

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

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

int sigemptyset(sigset_t *set); 清空信号集
int sigfillset(sigset_t *set); 信号集全部置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

🌍sigprocmask

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

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

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
返回值:若成功则为0,若出错则为-1

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

  • SIG_BLOCK:mask 与 set 按位或,相当于新增 ~ 什么与1按位与都是1
  • SIG_UNBLOCK:解除阻塞信号,比如mask 为 111,传入的set是010,set取反后是101,&后变成了101,也就解除了阻塞
  • SIG_SETMASK:把当前的set全部给到mask

其中第二个参数set是输入型的参数,把要设置的信息传入内核中,第三个参数oldset是输出型参数,是对block表写入之前的位图结构带出来 ~ 一旦调用成功,oldset是上一次block表的结构

🌍sigpending

读取当前进程的未决信号集 (pending表),通过set参数传出

cpp 复制代码
#include <signal.h>
int sigpending(sigset_t *set); //输出型参数

调⽤成功则返回0,出错则返回-1

上一个系统调用既能够获取,又可以修改;sigpending为什么就只能输出?

谁来修改pending表呢?

  • int kill(pid_t pid,int sig) ~ kill 命令
  • 信号产生的五种方式

无论信号是由另一个进程发送(如 kill() 系统调用),还是由硬件异常触发,内核都会在目标进程的 PCB(进程控制块,即 task_struct)中,将对应的信号位图(Pending Bitmap)中的某一位从 0 置为 1

🌍代码检验

下面用刚学的几个函数做个实验。程序如下

发现: 9号和19号信号不可被捕捉、不可被屏蔽,如果都屏蔽了,进程不是无敌了?工程师也会想到这点

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

void PrintPendind(sigset_t &pending)
{
    for(int signo = 31; signo >= 1; signo--)
    {
        if(sigismember(&pending, signo))
        {
            std::cout << 1 ;
        }
        else
        {
            std::cout << 0 ;
        }
    }
    std::cout << std::endl;
}

int main()
{
    //屏蔽2号信号
    sigset_t block_set, old_set;
    sigemptyset(&block_set);
    sigemptyset(&old_set);
    for(int i = 1; i <= 31; i++)
        sigaddset(&block_set, i);//到这里的修改都在栈空间~用户空间中修改,没有修改
    int n = sigprocmask(SIG_SETMASK, &block_set, &old_set);//此处才是把信号写入
    (void)n;

    while(true)
    {
        //获取pending表
        sigset_t pending;
        sigemptyset(&pending);

        n = sigpending(&pending);
        (void)n;

        //打印pending表
        PrintPendind(pending);
    }
    return 0;
}

那如果2号信号解除阻塞呢?那么2号在pending 表里会从1 ------> 0,信号抵达了

  • 一旦我们解除对某个信号的阻塞,该信号,就会立即被递达 ------ 如果是2号信号就直接终止了,所以我们要对2号信号进行捕捉signal

    那么有个小问题:在解除了阻塞后,是先递达之后再对pending表对应的1 变 0,还是先对pending表对应的1 变 0,再递达? 哪个先后顺序是对的

我们的验证方法是:在handler方法内部,继续获取pending表并打印,如果还是出现1说明是在handler后才变 0。

cpp 复制代码
void handler(int signo)
{
    std::cout << "进入了handle方法里了" << std::endl;

    sigset_t pending;
    sigemptyset(&pending);

    int n = sigpending(&pending);
    (void)n;

    // 打印pending表
    PrintPendind(pending);
    std::cout << "处理完成:" << signo << std ::endl; // 把2号信号改为打印
    std::cout << "handler 方法结束" << std::endl;
}

结果如下:

说明正确顺序:👉 先从 pending 中清除(1 → 0),再递达(执行 handler)

如果是先递达再清除会怎么样?因为handler 是用户可控的,可能写成这样👇

cpp 复制代码
void handler(int signo)
{
    raise(SIGINT);  // 再触发一次
}

👉 内核必须保证:不管用户写什么 handler,语义都一致、不会乱;所以是先清除再递达

面试级回复:

在 Linux 信号递达过程中,内核会先从 pending 集合中移除该信号(清除对应位),再执行信号处理动作(handler)。这样可以保证信号的单次消费语义,避免重复递达或竞态问题。

📢写在最后

相关推荐
石小千2 小时前
部署Nextcloud与Onlyoffice(二)安装Onlyofiice
linux·运维
xuanwenchao2 小时前
Mac M1/M2/M3/M4/M5芯片-系统安装Ubuntu
linux·ubuntu·macos
白毛大侠2 小时前
Docker vs 虚拟机 vs Go 用户态/内核态:这三组概念
运维·docker·golang·kvm
小白勇闯网安圈2 小时前
腾讯云服务器部署Dify
服务器·人工智能·云计算·腾讯云
芝士就是力量啊 ೄ೨2 小时前
提高服务器安全-采用密钥公钥登录而非密码登录-详细操作步骤
运维·服务器·安全
渠过客3 小时前
【运维】PM2 使用完全指南:Node.js 应用进程管理利器
运维·node.js
不会写DN3 小时前
处理 TCP 流中的消息分片
服务器·网络·tcp/ip
木下~learning3 小时前
Linux 驱动:RK3399 从零手写 GT911 电容触摸屏驱动(完整可运行)
linux·运维·服务器
摸爬滚打的小李3 小时前
tmux命令
linux