✨✨ 欢迎大家来到贝蒂大讲堂✨✨
🎈🎈养成好习惯,先赞后看哦~🎈🎈
所属专栏:Linux学习
贝蒂的主页:Betty's blog
1. 信号的引入
1.1 信号的概念
在Linux
系统中,信号(Signal)是一种软件中断机制,用于通知进程发生了特定的事件。信号可以由系统内核、其他进程或者进程自身发送。
我们可以通过指令kill -l
参考所有信号:
信号的本质就是一个define
定义的宏,其中1~31号信号是普通信号,34~64号信号是实时信号,普通信号和实时信号各自都有31个。每一个信号与一个数字相对应,每个信号也都有特定的含义和默认的处理动作。例如,信号SIGINT
(通常由用户按下ctrl + c
产生)表示中断信号,默认情况下会导致进程终止。
其中需要注意的是:在Linux
中,前台进程只能有一个,而后台进程可以为多个。一般而言,我们的bash
进程作为我们的前台进程,而一旦我们执行一个可执行程序,这个可执行程序就会成为前台进程,而bash
进程就会转为后台进程。但是我们如果在执行一个可执行程序时,在之后加一个&
,此时的可执行程序就会由前台进程转换为后台进程。而前台进程与后台进程本质间区别就是前台进程可以从键盘获取数据,后台进程则不能。
比如我们运行一个后台进程,就无法通过ctrl + c
终止进程,因为其无法从键盘读取数据。此时就只能通过kill
指令直接杀死对应的进程。
1.2 信号的获取
我们可以通过指令man 7 signal
查看信号的详细说明:
其中 Term
、Core
表示终止;Ign
标记忽略;Cont
表示继续;Stop
表示暂停。我们早在进程等待时就知道,wait
与waitpid
的参数status
本质就是一个位图结构,其低16比特位当中,高8位表示进程的退出状态,即退出码。进程若是被信号所杀,则低7位表示终止信号,而第8位比特位是core dump
标志。
c
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
其中 core dump
标志就是用来区分 Term
和 Core
的。云服务器的 Core dump
功能默认是关闭的,但我们可以通过指令ulimit -a
指令来查看当前系统的所有资源限制。
我们可以通过指令ulimit -c size
,去设置它的大小,如果 size > 0
就表示开启 Core dump
功能。
其中Term
对应的core dump
标志位是 0,表示正常终止;Core
对应的core dump
标志位是 1,表示异常终止。我们可以在程序中,通过位运算status>>7&1
来获取对应的core dump
标志。
打开系统的core dump
功能后,一旦进程出现异常,操作系统会将进程在内存中的运行信息转储到进程的当前目录中,形成core.pid
文件,这一过程被称作核心转储。core.pid
文件中详细记录了程序的异常原因,可以直接帮我们定位到出错行。
比如如下这段代码:
cpp
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
int main()
{
int a = 10;
int b = 0;
a /= b;
return 0;
}
既然core dump
可以帮助我们定位错误信息,那么我们为什么要将其关闭呢?那是因为如果每次产生错误信息都形成core.pid
文件的话,系统可能产生大量文件,而迫使操作系统挂掉,为了避免这种情况,一般而言我们并不建议开启该功能。
1.3 signal函数
当一个信号被发送给一个进程时,进程可以采取以下几种方式来处理信号:
- 忽略信号:进程可以选择忽略某些信号,即不对信号做出任何反应。但并不是所有信号都可以被忽略,例如
SIGKILL
和SIGSTOP
信号不能被忽略。- 捕获信号:进程可以注册一个信号处理函数,当接收到特定信号时,就会执行这个函数。通过这种方式,进程可以在接收到信号时执行自定义的处理逻辑。
- 执行默认动作:如果进程没有显式地忽略或捕获信号,那么它将执行信号的默认动作。默认动作通常是终止进程、停止进程、继续进程等。
接下来我们介绍一个函数signal
,其可以设置进程对某个信号的自定义捕捉方法:即当进程收到 signum
信号的时候,去执行 handler
方法。
- 函数原型:
- typedef void (*sighandler_t)(int);
- sighandler_t signal(int signum, sighandler_t handler);
- 参数:
signum
:是一个整数,表示要处理的信号编号。handler
:是一个函数指针,指向一个信号处理函数。这个信号处理函数接受一个整数参数(即接收到的信号编号),并且没有返回值(void
)。可以是以下几种值:
SIG_DFL
:表示默认的信号处理动作。SIG_IGN
:表示忽略该信号。- 自定义的信号处理函数指针,用于处理特定信号。
例如,下面的代码中将2号信号进行了捕捉,当该进程运行起来后,若该进程收到了2号信号就会打印出对应的信号编码。
c
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
void handler(int sign)
{
printf("get a signal :%d\n",sign);
}
int main()
{
signal(2,handler);
while(1)
{
printf("hello world!\n");
sleep(1);
}
return 0;
}
其中前台进程在运行过程中,用户随时可能按下Ctrl+C
而产生一个信号,也就是说该进程的代码执行到任何地方都可能收到SIGINT
信号而终止,所以信号相对于进程的控制流程来说是异步的。
2. 信号的产生
在我们操作系统中,信号的产生方式有许多,总体归纳来说有四种。
2.1 终端按键
其中我们通过键盘快捷键直接向我们的进程发出信号的方式非常常见,其中较为我们常用的有:
组合键 | 功能 |
---|---|
Ctrl+C | 向进程发出SIGINT 信号,终止进程。 |
Ctrl+\ | 向进程发出SIGQUIT 信号,终止进程。 |
Ctrl+Z | 向进程发送SIGTSTP 信号,暂停进程的执行。 |
2.2 系统接口
我们也可以通过操作系统为我们提供的接口对进程发送对应的信号。
其中较为常用的一个接口为kill
,其具体用法如下:
- 函数原型:int kill(pid_t pid, int sig);
- 参数:
pid
对应要发送信号进程的pid
,sig
表示发送的信号种类。- 返回值:如果成功,返回值为 0。否则,返回值为 -1
例如:下面这段代码,我们可以对指定进程发送一个SIGKILL
信号,正常终止该进程。
c
int main()
{
int cnt = 0;
while(1)
{
printf("hello world!\n");
sleep(1);
++cnt;
if(cnt == 5)
{
kill(getpid(),SIGKILL);
}
}
return 0;
}
接下来我们再来介绍两个接口:raise
与abort
。
c
int raise(int sig);
void abort(void);
raise
函数用于给当前进程发送sig
号信号,而abort
函数相当于给当前进程发送SIGABRT
信号,使当前进程异常终止。
abort
与exit
函数同样是终止进程,它们之间有什么区别吗?
首先明确
abort
函数和exit
函数的不同作用。abort
函数的作用是异常终止进程,它本质上是通过向当前进程发送SIGABRT
信号来实现这一目的。而exit
函数的作用是正常终止进程。需要注意的是,使用
exit
函数终止进程可能会失败,因为在某些复杂的程序运行环境中,可能存在一些因素干扰正常的进程终止流程。然而,使用abort
函数终止进程通常被认为总是成功的,这是由于其通过发送特定信号强制终止进程,一般情况下进程很难忽略该信号而继续运行。
2.3 软件条件
在我们前面学习管道通信时,就知道如果进程将读端关闭,而写端进程还一直向管道写入数据,那么此时写端进程就会收到SIGPIPE
信号进而被操作系统终止。SIGPIPE
就是一种典型的因为软件异常而产生的信号。
例如,下面代码,创建匿名管道进行父子进程之间的通信,其中父进程去读取数据,子进程去写入数据,但是一开始将父进程的读端关闭了,那么此时子进程在向管道写入数据时就会收到SIGPIPE
信号,进而被终止。
c
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
int fd[2]={0};
if(pipe(fd)<0)
{
perror("pipe:");
return 1;
}
pid_t id = fork();
if(id ==0 )
{
//child -> write
close(fd[0]);
char*msg = "hello father, i am child...";
while(1)
{
write(fd[1],msg,strlen(msg));
sleep(1);
}
close(fd[1]);
exit(0);
}
// father -> read
close(fd[1]);
close(fd[0]);
int status = 0;
waitpid(id,&status,0);
printf("child get a signal :%d\n",status&0x7f);
return 0;
}
我们能够通过alarm
函数,设定一个闹钟,倒计时完毕向我们的进程发送SLGALRM
信号,其具体用法如下:
- 函数原型:unsigned int alarm(unsigned int seconds);
- 参数:
seconds
表示倒计时的秒数。- 返回值:如果调用
alarm
函数前,进程已经设置了闹钟,则返回上一个闹钟时间的剩余时间,并且本次闹钟的设置会覆盖上一次闹钟的设置。如果调用alarm
函数前,进程没有设置闹钟,则返回值为0。
例如下面这段代码,我们首先对SLGALRM
信号进行捕捉,并给出我们的自定义方法,然后5秒后调用alarm
函数。
c
#include<stdio.h>
#include<signal.h>
#include<stdlib.h>
#include<sys/types.h>
#include<unistd.h>
void handler(int sign)
{
printf("get a signal:%d\n",sign);
exit(1);
}
int main()
{
signal(SIGALRM,handler);
alarm(5);
while(1)
{
printf("hello wrold!\n");
sleep(1);
}
return 0;
}
2.4 硬件异常
当程序出现除 0、野指针、越界等错误时,程序会崩溃,本质是进程在运行中收到操作系统发来的信号而被终止。 这些发送的信号都是由硬件异常产生的。
比如下面这段代码,进行了对空指针的解引用,那么其到底是如何被操作系统识别的呢?
c
#include<stdio.h>
int main()
{
int *p = NULL;
*p = 3;//对空指针解引用。
return 0;
}
首先我们知道,当我们要访问一个变量时,进程控制块task_struct
一定要会经过页表的映射,将虚拟地址转换成物理地址,然后才能进行相应的访问操作。
而页表属于一种软件映射关系,在从虚拟地址到物理地址映射过程中,有一个硬件单元叫做 MMU
(内存管理单元),它是负责处理 CPU 的内存访问请求的计算机硬件。如今,MMU
已集成到 CPU 当中。虽然映射工作原本不是由 CPU 做而是由 MMU
做,但现在其与 CPU 的紧密结合使得整个内存访问过程更加高效。
当进行虚拟地址到物理地址的映射时,先将页表左侧的虚拟地址提供给 MMU
,MMU
会计算出对应的物理地址,随后通过这个物理地址进行相应的访问。
由于 MMU
是硬件单元,所以它有相应的状态信息。当要访问不属于我们的虚拟地址时,MMU
在进行虚拟地址到物理地址的转换时会出现错误,并将对应的错误写入到自己的状态信息当中。此时,硬件异常,硬件上的信息会立马被操作系统识别到,进而向对应进程发送 SIGSEGV
信号。