文章目录
- 信号入门
- 生活角度的信号
- 技术应用角度的信号
- 用kill -l命令可以察看系统定义的信号列表
- 信号处理常见方式概述
- 产生信号
- 通过键盘进行信号的产生,```ctrl+c```向前台发送2号信号
- 通过系统调用
- 异常
- 软件条件
信号入门
生活角度的信号
- 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。
- 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
- 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
技术应用角度的信号
#include <stdio.h>
#include <unistd.h>
int main()
{
while (1)
{
printf("I am a process, I am waiting signal!\n");
sleep(1);
}
}
我们知道上述代码是死循环的,我们使用
ctrl+c
来终止掉这个进程,本质是键盘向CPU发送了一个中断被操作系统获取并解释成信号(ctrl+c
被解释成2号信号),最后操作系统将2号信号发送给目标前台进程,当前台进程收到2号信号后就会退出。
按照文章开头所谈的话,进程就是我,操作系统就是快递员,信号就是快递
注意:
ctrl+c
产生的信号只能发给前台进程。shell
可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 ```ctrl+c``这种控制键产生的信号- 前台进程在运行过程中用户随时可能按下
ctrl+c
而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到SIGINT
信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
- 我们可以使用
jobs
来查看正在执行的任务fg number
将后台中的命令调至前台继续运行bg number
将一个在后台暂停的命令变成继续执行&
加在一个命令的最后,可以把这个命令放到后台执行;
(./xxx &)这样就可以将进程放在后台
ctrl+z
可以将一个正在前台执行的命令放到后台,并且处于暂停状态,不可执行
用kill -l命令可以察看系统定义的信号列表
kil -l
其中1 - 31号信号是普通信号,34 - 64号信号是实时信号
我们看到上面这一堆的大写字母 + 数字,不难想到它们是使用了宏
信号是如何记录的?
使用位图,如果该位置为1
即该信号被收到
所以信号产生的本质上就是操作系统直接去修改目标进程的task_struct中的信号位图。
信号处理常见方式概述
- 执行该信号的默认处理动作。
- 忽略此信号。
- 提供一个信号处理函数,内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号;自定义的——信号的捕捉
我们可以使用man 7 signal
来查看信号的默认处理动作。
产生信号
通过键盘进行信号的产生,ctrl+c
向前台发送2号信号
#include <stdio.h>
#include <unistd.h>
int main()
{
while (1)
{
printf("hello signal!\n");
sleep(1);
}
return 0;
}
除此之外,我们还可以使用ctrl+\
来终止进程
那么它们两个有什么区别呢???
我们发现
ctrl+c
对应的行为是Term
,而ctrl+\
对应的行为是Core
,二者都代表着终止进程,但是Core
会多进行一个动作,那就是核心转储
何为核心转储???
首先, 在云服务器中,核心转储是默认被关掉的,我们可以通过使用ulimit -a
命令查看当前资源限制的设定。
第一行显示core文件的大小为0,即表示核心转储是被关闭的。
我们可以通过ulimit -c size
命令来设置core文件的大小。
core文件的大小设置完毕后,就将核心转储功能打开了。此时如果我们再使用
ctrl+\
对进程进行终止,就会发现终止进程后会显示core dumped
并且在该路径下生成了一个
core.pid
文件
其中pid
,是一串数字,而这一串数字就是发生这一次核心转储的进程的PID。
核心转储有什么用呢???
当我们的代码报错以后,我们总需要找到报错原因;
当我们的程序在运行过程中崩溃了,我们会通过调试来进行逐步查找程序崩溃的原因;
当我们的程序在运行结束了,那么我们可以通过退出码来判断代码出错的原因;
而在某些特殊情况下,我们会使用核心转储,核心转储是操作系统在进程收到某些信号而终止运行时,将该进程地址空间的内容以及有关进程状态的其他信息转而存储到一个磁盘文件当中,这个磁盘文件也叫做核心转储文件,一般命名为core.pid
。
#include <stdio.h>
int main()
{
printf("I am Div\n");
int a = 10;
a /= 0;
return 0;
}
显然,上述代码出现了除零错误
那ctrl+其他
有什么效果呢???
我们可以通过以下代码,将1~31号信号全部进行捕捉,将收到信号后的默认处理动作改为打印收到信号的编号。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signal)
{
printf("get a signal:%d\n", signal);
}
int main()
{
int signo;
for (signo = 1; signo <= 31; signo++)
{
signal(signo, handler);
}
while (1)
{
sleep(1);
}
return 0;
}
但我们发送9号进程,它并不会收到,而是执行收到9号信号后的默认处理动作,即被杀死。
所以,对于某些信号是不可以被自定义处理的。比如9号信,因为如果所有信号都能被捕捉的话,那么进程就可以将所有信号全部进行捕捉并将动作设置为忽略,此时该进程将无法被杀死,即便是操作系统也做不到。
通过系统调用
我们可以以kill -信号编号 进程ID
的形式进行发送。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
int main()
{
while (true)
{
printf("I am a running process..., mypid = %d\n", getpid());
sleep(1);
}
return 0;
}
kill函数
int kill(pid_t pid, int sig);
实际上,
kill
指令的底层就是kill
函数
kill函数
用于向进程ID
为pid
的进程发送sig
号信号,如果信号发送成功,则返回0,否则返回-1。
raise函数
raise函数
可以给当前进程发送指定信号,即自己给自己发送信号
int raise(int sig);
用raise函数每隔一秒向自己发送一个2号信号。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
printf("get a signal:%d\n", signo);
}
int main()
{
signal(2, handler);
while (1)
{
sleep(1);
raise(2);
}
return 0;
}
abort函数
raise函数
可以给当前进程发送SIGABRT
信号,使得当前进程异常终止
void abort(void);
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
printf("get a signal:%d\n", signo);
}
int main()
{
signal(6, handler);
while (1){
sleep(1);
abort();
}
return 0;
}
abort函数的作用是异常终止进程
exit 函数的作用是正常终止进程
异常
当我们程序当中出现类似于除0、野指针、越界之类的错误时,为什么程序会崩溃?本质上是因为进程在运行过程中收到了操作系统发来的信号进而被终止,那操作系统是如何识别到一个进程触发了某种问题的呢?
我们知道,CPU当中有一堆的寄存器,当我们需要对两个数进行算术运算时,我们是先将这两个操作数分别放到两个寄存器当中,然后进行算术运算并把结果写回寄存器当中。此外,CPU当中还有一组寄存器叫做状态寄存器,它可以用来标记当前指令执行结果的各种状态信息,如有无进位、有无溢出等等。而操作系统是软硬件资源的管理者,在程序运行过程中,若操作系统发现CPU内的某个状态标志位被置位,而这次置位就是因为出现了某种除0错误而导致的,那么此时操作系统就会马上识别到当前是哪个进程导致的该错误,并将所识别到的硬件错误包装成信号发送给目标进程,本质就是操作系统去直接找到这个进程的task_struct,并向该进程的位图中写入8信号,写入8号信号后这个进程就会在合适的时候被终止。
那对于下面的野指针问题,或者越界访问的问题时,操作系统又是如何识别到的呢?
当我们要访问一个变量时,一定要先经过页表的映射,将虚拟地址转换成物理地址,然后才能进行相应的访问操作。
其中页表属于一种软件映射关系,而实际上在从虚拟地址到物理地址映射的时候还有一个硬件叫做MMU,它负责处理CPU的内存访问请求的计算机硬件,因此映射工作不是由CPU做的,而是MMU做的,但现在MMU已经集成到CPU当中了。
当需要进行虚拟地址到物理地址的映射时,我们先将页表的左侧的虚拟地址导给MMU,然后MMU会计算出对应的物理地址,我们再通过这个物理地址进行相应的访问。
而MMU既然是硬件单元,那么它当然也有相应的状态信息,当我们要访问不属于我们的虚拟地址时,MMU在进行虚拟地址到物理地址的转换时就会出现错误,然后将对应的错误写入到自己的状态信息当中,这时硬件上面的信息也会立马被操作系统识别到,进而将对应进程发送SIGSEGV信号。
C/C++程序会崩溃,是因为程序当中出现的各种错误最终一定会在硬件层面上有所表现,进而会被操作系统识别到,然后操作系统就会发送相应的信号将当前的进程终止。
软件条件
SIGPIPE信号
SIGPIPE信号实际上就是一种由软件条件产生的信号,当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE信号进而被操作系统终止。
下面代码当中,创建匿名管道进行父子进程之间的通信,其中父进程是读端进程,子进程是写端进程,但是一开始通信父进程就将读端关闭了,那么此时子进程在向管道写入数据时就会收到SIGPIPE信号,进而被终止。
#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)
{ //使用pipe创建匿名管道
perror("pipe");
return 1;
}
pid_t id = fork(); //使用fork创建子进程
if (id == 0)
{
//child
close(fd[0]); //子进程关闭读端
//子进程向管道写入数据
const char* msg = "hello father, I am child...";
int count = 10;
while (count--)
{
write(fd[1], msg, strlen(msg));
sleep(1);
}
close(fd[1]); //子进程写入完毕,关闭文件
exit(0);
}
//father
close(fd[1]); //父进程关闭写端
close(fd[0]); //父进程直接关闭读端(导致子进程被操作系统杀掉)
int status = 0;
waitpid(id, &status, 0);
printf("child get signal:%d\n", status & 0x7F); //打印子进程收到的信号
return 0;
}
alarm函数
unsigned int alarm(unsigned int seconds);
alarm函数可以设定一个闹钟,也就是告诉操作系统在若干时间后发送SIGALRM信号
给当前进程
alarm函数的返回值:
- 若调用alarm函数前,进程已经设置了闹钟,则返回上一个闹钟时间的剩余时间,并且本次闹钟的设置会覆盖上一次闹钟的设置。
- 如果调用alarm函数前,进程没有设置闹钟,则返回值为0。
用下面的代码,测试自己的云服务器一秒时间内可以将一个变量累加到多大。
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
int main()
{
int count = 0;
alarm(1);
while (1)
{
count++;
printf("count: %d\n", count);
}
return 0;
}
我们发现加的变量好像有点小,原有有两点:
- 由于我们每进行一次累加就进行了一次打印操作,而与外设之间的IO操作所需的时间要比累加操作的时间更长
- 我当前使用的是云服务器,因此在累加操作后还需要将累加结果通过网络传输将服务器上的数据发送过来,因此最终显示的结果要比实际一秒内可累加的次数小得多。
为了尽可能避免上述问题,我们可以先让count变量一直执行累加操作,直到一秒后进程收到SIGALRM信号后再打印累加后的数据。
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
int count = 0;
void handler(int signo)
{
printf("get a signal: %d\n", signo);
printf("count: %d\n", count);
exit(1);
}
int main()
{
signal(SIGALRM, handler);
alarm(1);
while (1)
{
count++;
}
return 0;
}
count变量在一秒内被累加的次数变成了四亿多,所以,我们得出一个结论与计算机单纯的计算相比较,计算机与外设进行IO时的速度是非常慢的。