Linux 操作系统 --- 信号

序言

在本篇内容中,将为大家介绍在操作系统中的一个重要的机制 --- 信号。大家可能感到疑惑,好像我在使用 Linux 的过程中并没有接触过信号,这是啥呀?其实我们经常遇到过,当我们运行的进程当进程尝试访问非法内存地址时,我们的进程会被中断,这是因为操作系统向该进程发送了中断信号。

Linux 操作系统离不开信号机制,在这篇文章中,让我们走进信号,了解信号的从哪里来,又到哪里去。


1. 信号的概念

1.1 定义

信号是操作系统向进程发送的一种通知,表示某个特定事件已经发生。在Unix、类Unix 以及其他系统中,信号被广泛使用。

1.2 特点

信号具有如下的特点:

  • 异步性:信号的产生对进程来说是异步的,即 进程无法预知信号何时到来
  • 通知机制:信号是一种 软件中断(由软件程序触发的中断方式),用于中断进程的正常执行流程,使其处理特定事件。
  • 进程间通信:虽然信号主要用于异常处理和系统调试,但也可以用于进程间的基本通信。

1.3 种类

Linux 系统下,使用指令:kill -l 即可查看所有的信号:

信号是使用宏定义的,每个信号前面的数字,就是该信号宏对应的值。

前 31 个信号为常规信号,其余为实时信号。在本篇文章中,我们主要讨论前 31 个常规信号。

你也可以使用指令: man 7 signal 查看每一个详细信息:

补充知识点:Core && Term

在描述信号的字段中,有一个叫做 Action 的特征,他的值大多都是 Core Term 这是什么呢?

Term

termterminate 的缩写,表示默认动作是终止进程。当进程接收到一个默认动作为 term 的信号时,进程会被立即终止。

Core

core 表示 默认动作是终止进程并生成一个核心转储(core dump)文件。核心转储是一个包含进程在终止时的内存映像的文件,它对于调试程序非常有用,因为它 提供了进程终止时的状态信息

咦?就比如,SIGSEGV 段错误 当我的程序非法访问被终结时,被没有产生传说中的核心转储文件呀?这是因为你的服务器默认关闭了该功能,使用指令 ulimit -a 查看:

现在我们使用指令 ulimit -c 4096 开启该功能:

现在我们运行下一段程序:

c 复制代码
   8 int main()
   9 {
  10 
  11     int *ptr = NULL;
  12     *ptr = 1;
  13     
  14     return 0;
  15 }

程序不负众望地报错并退出了,产生了一个文件:

这个文件可以干嘛呢?当我们的程序出现异常时,相当该文件保存了案发现场,具体的用法是:

  • 首先使用 gdb 调试你的程序:
  • 之后输入指令 core-file your_core

可以看到,直接就复原了事故现场。

区别
  • 进程终止:term 信号会终止进程,但 不生成核心转储文件term 信号通常是用于请求进程正常终止的情况。
  • 调试信息:core 信号不仅会终止进程,还会 生成核心转储文件,这包含了进程的内存映像、寄存器状态、堆栈跟踪等信息,用于调试目的。

2. 信号的产生

信号是从哪里产生的呢?虽然最后都是操作系统来执行对一个进程发送信号,但是是谁告诉操作系统这样做的呢?

2.1 用户操作 --- kill 指令

当我们运行一个程序时,可以通过指令 kill 来让操作系统对该进程发送相应的信号,就比如,我们可以手动发送 SIGKILL 9号 信号将该进程终结,这里有一个程序:

c 复制代码
 1 TestSig1.cc                                                                                                                                                                                                  X 
   1 #include <iostream>
   2 #include <unistd.h>
   3 
   4 
   5 int main()
   6 {
   7     while(true)
   8     {
   9         std::cout << "I am Running, my pid is " << getpid() << std::endl;
  10         sleep(1);
  11     }
  12     return 0;
  13 }

该程序会每秒打印相应的内容,现在我们可以使用相关的指令 kill -9 [pid] 来杀掉该进程:

可以看到该进程被杀掉了!!!

2.2 用户操作 --- 按键操作

我们也可通过按键来让操作系统发送相关的信号,就比如我们平时终止一个进程的方式更多的是通过键盘按键,就比如 ctrl + c

其实这个按键对应的就是 3 号信号 SIGQUIT

2.3 用户操作 --- 系统调用

操作系统提供一个系统调用 int kill(pid_t pid, int sig); 该函数你可以想指定进程发送信号:

  • pid: 表示要发送信号的进程 ID
  • sig: 表示要发送的信号
  • 返回值:成功返回 0 ,失败返回 -1 ,错误码被设置

还有一个函数是 int raise(int sig);,该函数是向当前进程发送指定信号,简单来说,相当于简化的 kill => kill(getpid(), int sig);

2.4 触发软件条件

在之前管道的学习中,我们了解到如果 读端被关闭了,写端一直再写,那么操作系统就会认为这是一个坏掉的管道,就会发送 13 号信号 SIGPIPE 终止该进程,这就是触发了某种软件条件。

现在,在这里先向大家介绍几个非常重要的函数:

signal 函数

该函数允许程序员定义当特定信号发生时,程序应该如何响应, 简单说,这个函数用于捕获特定信号,然后执行指定操作的sighandler_t signal(int signum, sighandler_t handler);:

  • signum:指定要处理的信号类型。注意,SIGKILLSIGSTOP 这两个信号不能被捕获、阻塞或忽略。
  • handler:指定信号的处理方式。它可以是一个函数指针,指向一个用户定义的信号处理函数;也可以是 SIG_IGN,表示忽略该信号;或者是 SIG_DFL,表示采用信号的默认处理方式。
  • 返回值:成功时,signal 函数返回之前为该信号设置的信号处理函数的指针。如果之前没有为该信号设置过处理函数,则返回 SIG_DFL。失败时,返回 SIG_ERR,并设置 errno 以指示错误原因。

看着描述这么多,其实用起来不复杂,比如,现在我要捕获 2 号信号 SIGINT,他的默认操作是退出,现在我不想要推出,想要执行我的逻辑:

c 复制代码
 1 TestSig1.cc                                                                                       X 
   1 #include <iostream>
   2 
   3 #include <unistd.h>
   4 #include <sys/types.h>
   5 #include <signal.h>
   6 
   7 void signal_handle(int signum)
   8 {
   9     std::cout << "I got you signal: " << signum << std::endl;
  10 }
  11 
  12 int main()
  13 {
  14 
  15     // 2号信号的捕获
  16     signal(2, signal_handle);
  17 
  18     while(true)
  19     {
  20         std::cout << "I am Running, my pid is " << getpid() << std::endl;
  21         sleep(1);
  22     }
  23     return 0;
  24 }

现在我们使用 ctrl + c 已经不能终止该进程了:

你也可将 signal(2, signal_handle); 中的函数换成 SIG_IGN 这样就会忽略该信号。

现在我有个想法就是将全部信号都捕获,在写个死循环,是不是就没有人把我停下来了!!!我们能想到的,人家肯定也想到了,规定 9 号信号 SIGKILL 和 19 号信号 SIGSTOP 这两个信号不能被捕获、阻塞或忽略。保证系统的稳定性和管理员的控制权。

alarm 函数

大家为了早起都设置过闹钟吧,闹钟的作用就是时间一到就提醒我们执行某件任务。在 Linux 中的闹钟 alarm 也是一样的,我们设置一个定时,当时间一到执行某项任务,unsigned int alarm(unsigned int seconds);

  • seconds:定时器应该等待的秒数。如果 seconds 是 0,则任何当前设置的定时器都会被取消(你可以同时设置多个闹钟),但不会发送 SIGALRM 信号。
  • 返回值:如果之前已经设置了定时器,alarm 函数 返回之前设置的剩余时间(秒),直到定时器到期。如果之前没有设置定时器,则返回 0。

alarm 定时器到期时,会向进程发送 SIGALRM 信号,终止进程:

c 复制代码
12 int main()
  13 {
  14 
  15     // 设置一个闹钟,执行默认操作
  16     alarm(2);
  17 
  18     while(true)
  19     {
  20         std::cout << "I am Running, my pid is " << getpid() << std::endl;
  21         sleep(1);
  22     }
  23     return 0;
  24 }

两秒之后,进程自动终止:

但更多情况下,我们想要闹钟解释后执行我们的逻辑,而不是终止进程,那咋办呢? 捕获该信号,自定义处理信号,这就需要我们上面说的 signal 函数了:

c 复制代码
   7 void signal_handle(int signum)
   8 {
   9     std::cout << "Your alarm clock is ringing." << std::endl;
  10 }
  11 
  12 int main()
  13 {
  14 
  15     // 设置一个闹钟,执行默认操作
  16     alarm(2);
  17     // 捕获闹钟信号
  18     signal(SIGALRM, signal_handle);
  19 
  20     while(true)
  21     {
  22         std::cout << "I am Running, my pid is " << getpid() << std::endl;
  23         sleep(1);
  24     }
  25     return 0;
  26 }

现在闹钟时间到了,就不会终止进程啦!但是,我还有一个疑问,你这个闹钟只能执行一次呀,之后就失效了,我想要一个一直生效的定时任务,怎么做到呢?当捕获并执行自定义函数时再设置一个闹钟不就好啦:

c 复制代码
   7 void signal_handle(int signum)
   8 {
   9     std::cout << "Your alarm clock is ringing." << std::endl;
  10     alarm(2);
  11 }

这样就得到一个持续的定时任务啦:

在这里的闹钟就是一个触发了软件条件(倒计时),从而产生信号发送给进程!

2.4 硬件异常

段错误

在我们的程序中,很可能涉及到 段错误(非法内存访问),具体触发错误的细节如下:

  • 现代计算机使用内存管理单元(MMU)来管理内存。MMU 负责 将虚拟地址(程序使用的地址)映射到物理地址(实际内存地址)
  • 当我们尝试访问一个地址时,MMU 尝试将虚拟地址翻译为物理地址,并检查该虚拟地址对应的页表项,以确定是否有权限访问该地址,以及地址是否有效
  • CPU 发现该块地址是 无效的,或者是不具有写权限的,或者是无权限访问的,将触发异常
  • 操作系统向该进程发送 SIGSEG 的信号

这就是简单的硬件异常触发流程。


3. 信号的保存

现在我们已经基本了解了信号是从哪里来的,那么信号被一个进程接受过后,是以什么形式存在于进程当中呢?

在介绍信号的保存之前,希望大家记住这几个概念:

  • 实际执行信号的处理动作称为信号递达(Delivery)
  • 信号从产生到递达之间的状态,称为信号未决(Pending)。

信号的信息被保存在一个进程的 task_struct 中:

我们来好好的介绍这 3 个结构:

3.1 block 位图

Block 位图用于指示哪些信号当前被进程 阻塞。如果一个信号在 Block 位图中对应的位被设置(为 1 ),那么即使该信号已经到达,它也不会被立即处理,而是会 保持在未决状态,直到进程解除对该信号的阻塞。

3.2 pending 位图

Pending 位图(通常不是直接暴露给用户的,而是作为进程控制块 task_struct 的一部分)用于 跟踪哪些信号已经到达进程但尚未被处理。每个位代表一个信号,如果该位被设置(通常为 1 ),则表示对应的信号已经到达且处于 未决状态

3.3 handler 函数指针数组

用户可以通过系统调用来设置特定信号的处理函数。当用户为某个信号注册了一个自定义的处理函数时,操作系统就会将该函数的地址存储在 handler 表中对应信号编号的位置。如果用户没有为某个信号设置自定义处理函数,那么当该信号发生时,操作系统就会 调用默认的处理函数

所以,我们总结一下,当一个信号传递给进程时,操作系统会将 pending 表中该信号对应的值置 1,如果 block表中的对应的值 也是 1,代表该信号被阻塞,不会被立即处理,直至解除阻塞;当解除阻塞或者一开始就不是阻塞状态的话,就会执行 handle

表中该信号对应的函数操作。

3.4 验证结论

现在我们准备验证我们的想法,我们先阻塞一个信号,然后发送该信号,查看是否执行相关操作,在解除对该信号的阻塞,再次观察现象:

在这里会涉及到对信号集的操作,大家可以简单理解为 对信号集进行的对某个信号的阻塞操作最终会保存到阻塞表中 ,在这里就不具体说明操作了,感兴趣的小伙伴,我找了一篇比较好的文章 👉信号集操作指南

c 复制代码
6 // 自定义函数
   7 void signal_handler(int signum)
   8 {
   9     std::cout << "Recived signal SIGINT!!!" << std::endl;
  10 }
  11 
  12 int main()
  13 {
  14     // 捕获信号
  15     signal(SIGINT, signal_handler);
  16 
  17     sigset_t sigset;
  18     // 初始化信号集
  19     sigemptyset(&sigset);
  20     // 添加指定信号到信号集
  21     sigaddset(&sigset, SIGINT);
  22 
  23     // 阻塞该信号
  24     if(sigprocmask(SIG_BLOCK, &sigset, NULL) == -1)
  25     {
  26         perror("sigprocmask");
  27     }
  28 
  29     std::cout << "SIGINT is blocked. Try pressing Ctrl+C after 5s!!!\n" << std::endl;
  30     sleep(5);
  31 
  32     // 解除阻塞
  33     if(sigprocmask(SIG_UNBLOCK, &sigset, NULL) == -1)
  34     {
  35         perror("sigprocmask");
  36     }
  37 
  38     std::cout << "SIGINT is unblocked. Try pressing Ctrl+C!!!\n" << std::endl;
  39 
  40     while(true)
  41     {
  42         sleep(1);
  43     }
  44 }

第一次我们按 ctrl + c 没什么反应,过了 5s 后,函数自动被执行,可以看出我们的结论是正确的。


4. 信号的处理

现在我们知道信号哪里来的了,也知道保存在哪里了,现在我们来看看信号的处理方式。

4.1 执行默认方式

对于没有为其注册信号处理函数的信号,进程会执行该信号的默认操作。就比如,SIGTERM 信号的默认操作是请求进程终止,而 SIGSEGV(段错误)信号的默认操作是生成core文件并终止进程。

4.2 调用信号处理函数

如果进程为某个信号注册了信号处理函数(也称为信号处理器,上面代码中的 signal_handler 函数),那么当该信号到达时,内核会暂停进程的正常执行流程,转而调用该处理函数。

4.3 忽略信号

进程可以选择忽略某些信号。这意味着当这些信号到达时,进程不会执行任何特别的操作,而是继续执行其当前的代码路径。然而,需要注意的是,并非所有信号都可以被忽略。例如,SIGKILL和SIGSTOP等信号是不能被忽略的

4.4 阻塞信号

进程可以选择屏蔽某些信号,以 避免在关键操作期间接收到这些信号。通过调用sigprocmask 等系统调用,进程可以设置其信号屏蔽字,以决定哪些信号能够传递到进程中。被屏蔽的信号将保持在未决状态,直到屏蔽被解除后才会被处理。

4.5 阻塞和忽略的区别

这两个概念相当容易混淆,从定义上来说:

  • 阻塞是指 进程选择性地阻止某些信号的传递。当这些被阻塞的信号发生时,它们会被内核记录下来(处于未决状态),但不会立即执行信号的处理函数或执行默认操作。
  • 忽略是指进程对收到的某些信号 不执行任何操作,即不调用处理函数也不执行默认操作,而是简单地丢弃这些信号。

大家可以这样理解:信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。 前者是处于未决的状态,后者是被递达后选择了忽略,不做其他处理。


总结

在这篇文章中介绍了信号的概念,也介绍了信号从哪里来,到哪里去,被接受处理的过程,希望大家有所收获😁。

相关推荐
芊寻(嵌入式)10 分钟前
C转C++学习笔记--基础知识摘录总结
开发语言·c++·笔记·学习
獨枭12 分钟前
C++ 项目中使用 .dll 和 .def 文件的操作指南
c++
霁月风15 分钟前
设计模式——观察者模式
c++·观察者模式·设计模式
橘色的喵15 分钟前
C++编程:避免因编译优化引发的多线程死锁问题
c++·多线程·memory·死锁·内存屏障·内存栅栏·memory barrier
荒Huang16 分钟前
Linux挖矿病毒(kswapd0进程使cpu爆满)
linux·运维·服务器
何曾参静谧1 小时前
「C/C++」C/C++ 之 变量作用域详解
c语言·开发语言·c++
九河云1 小时前
如何选择适合的AWS EC2实例类型
服务器·云计算·aws
AI街潜水的八角1 小时前
基于C++的决策树C4.5机器学习算法(不调包)
c++·算法·决策树·机器学习
JSU_曾是此间年少2 小时前
数据结构——线性表与链表
数据结构·c++·算法
hjjdebug2 小时前
linux 下 signal() 函数的用法,信号类型在哪里定义的?
linux·signal