Linux之信号详解
Posted 小赵小赵福星高照~
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux之信号详解相关的知识,希望对你有一定的参考价值。
进程信号
文章目录
信号入门
- 理解生活中的信号
下课铃,红绿灯,闹钟,烽火,脸色…等等这些都是信号
a.你为什么能认识红绿灯或者闹钟?曾经有人教育过我们,大脑里记住的
b.现在没有闹钟的时候,你知不知道闹钟响了之后,该怎么办?知道
很多事情需要经过这三个问题:是什么?为什么?怎么办?
a,b交代了:是什么?为什么?怎么办?
c.操作系统相当于社会,进程相当于人,进程要能够识别非常多的信号
总结:人能够识别信号
- 见一下Linux当中的信号
生活角度的信号
- 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。
- 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
- 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
进程就是你,操作系统就是快递员,信号就是快递
技术应用角度的信号
用户输入命令,在Shell下启动一个前台进程。用户按下 Ctrl-C ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出
#include <stdio.h>
int main()
while(1)
printf("I am a process, I am waiting signal!\\n");
sleep(1);
我们ctrl+c,发现进程终止了:
注意
- Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
- Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
- 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
信号概念
用kill -l命令可以查看系统定义的信号列表
每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定 义 #define SIGINT 2,1号到31号信号称为普通信号,剩下的是实时信号,我们不讨论实时信号。
这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal:
信号的生命周期:信号产生时->信号识别中->信号处理中
信号产生
键盘产生
我们写一个死循环的程序:
#include<iostream>
#include<sys/types.h>
#include<unistd.h>
int main()
while(true)
std::cout<<"i am a process: "<<getpid()<<std::endl;
sleep(1);
Makefile的编写
CC=g++
LDFLAGS=-std=c++11
Src=sig.c
Bin=mysig
$(Bin):$(Src)
$(CC) -o $@ $^ $(LDFLAGS)
.PHONY:clean
clean:
rm -f $(Bin)
我们写了一个程序,让他死循环,然后让他运行起来变成进程,我们ctrl+c实际上是将2号信号发送给进程:
kill命令产生
也可以命令行kill命令给进程发送信号,kill命令其实底层调用了kill接口函数:
kill -SIGINT 24994
底层本质这两种是一样的,对于相当一部分信号而言,当进程收到的时候默认的处理动作就是终止当前进程
信号19暂停进程,18继续进程:
发送18信号发现进程并没有终止:
调用系统函数向进程发信号
abort
终止进程
void abort(void);
raise
int raise(int sig);
raise函数可以给当前进程发送指定的信号(自己给自己发信号)。
kill
int kill(pid_t pid,int sig);
发送一个信号给一个进程
kill也是一个接口,kill命令就是调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。
kill命令的命令行输入模拟实现
#include<cstdlib>
//kill 9 112233
int main(int argc,char *argv[])
if(argc != 3)
cerr<<"Usage: "<<argv[0]<<"pid signum"<<endl;
exit(1);
kill(atoi(argc[2]),atoi(argv[1]));
可以看到成功杀掉了进程15430
由软件条件产生信号
alarm
软件条件是某种条件设定,当条件触发时,OS会向进程发信号,设定闹钟,到达该时间点,OS给该进程发送信号
比如我们想计算1秒能够打印多少次count:
#include<iostream>
using namespace std;
#include<unistd.h>
int main()
alarm(1);//1秒之后会给目标进程发送SIGALRM,注意:设定不会有明显的现象,1秒之后触发
int count = 0;
while(1)
cout<<count++<<endl;
1秒之后会给目标进程发送SIGALRM,注意:设定不会有明显的现象,1秒之后触发
我们可以通过信号捕捉的方式看一下alarm发送的是什么信号:
#include<iostream>
using namespace std;
#include<unistd.h>
void handler(int signo)
cout<<"get a signo: "<<signo<<endl;
sleep(3);
int main()
for(int i = 1;i<32;i++)
signal(i,handler);//信号捕捉
sleep(3);
alarm(1);//1秒之后会给目标进程发送SIGALRM,注意:设定不会有明显的现象,1秒之后触发
int count = 0;
while(1)
//cout<<count++<<endl;//因为有输入输出,所有效率低
count++;//不带IO进行累加
可以看到14信号是SIGALRM。
我们发现一秒才打印了count一共60000多次,计算机计算速度那么快为什么才这么少呢?是因为有输入输出,而且我们这是云服务器,还需要网络将数据发送给云服务器,还通过网络将数据结果返回来,所以效率低,我们将count设置成全局变量,不带IO进行累加,在信号捕捉里看一下一秒count能够加到多少:
#include<unistd.h>
int count = 0;
void handler(int signo)
cout<<"get a signo: "<<signo<<endl;
cout<<"count is "<<count<<endl;
sleep(3);
int main()
for(int i = 1;i<32;i++)
signal(i,handler);//信号捕捉
sleep(3);
alarm(1);//1秒之后会给目标进程发送SIGALRM,注意:设定不会有明显的现象,1秒之后触发
//int count = 0;
while(1)
//cout<<count++<<endl;//因为有输入输出,所有效率低
count++;//不带IO进行累加
return 0;
可以发现不进行IO进行累加,count都加到了2亿
硬件异常产生信号
说到硬件异常产生信号,我们要先说一个东西core dump标志位,这个标志是否打开核心转储,这个标志位在进程等待那里提到过,父进程获取子进程退出状态时就有这个信息。
查看Linux中内核中一些东西的大小:
ulimit -a
我们看到core file size是0,说明此时的核心转储是关闭的,那么怎么打开?
ulimit -c 1024
将核心转储的大小设置成1024:
我们在写程序时,故意写个除0错误,进程会收到8号信号然后被终止掉,当进程收到8号信号时,目录下会有core文件,core dumped叫做核心转储:OS将进程运行时的核心数据dump到磁盘上,方便用户进行调试使用,一般而言线上环境核心转储是被关闭的(比如云服务器),为什么呢?
一般在公司里,如果服务器出问题了,一般不是立即找bug,而是尽快的将服务再弄起来,在下一次出问题之前将bug找出来解决,线上环境如果将核心转储打开,服务器一直出问题,就会生成一堆的core文件,时间一长服务器内存可能已经被占满了,服务器可能登录都是问题了
为什么要核心转储?方便用户进行调试使用,比如我们在程序中写出一个除0操作,进入gdb调试,将core文件倒到gdb,gdb可以直接定位到出错的行数:
core-file core.29792
程序异常(野指针(11号信号),除0(8号信号))这些都是硬件异常产生信号的范围,站在语言的角度,就叫程序崩溃,站在系统的角度,就叫做进程收到了信号,core dump标志位,如果发生了核心转储,为1,没有发送核心转储,为0
检测core dump标志位
Makefile的编写
CC=g++
LDFLAGS=-std-c++11
Src=mytest.cc
Target=mytest
$(Target):$(Src)
$(CC) -o $@ $^ $(LDFLAGS)
.PHONY:clean
clean:
rm -f $(Target)
检测core dump标志位,子进程退出,父进程获取子进程的退出状态waitpid:
#include<iostream>
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>
using namespace std;
int main()
pid_t id = fork();
if(id == 0)
//child
int count = 5;
while(count)
cout<<"I am child: "<<getpid()<<"count:"<<count<<endl;
count--;
sleep(1);
int arr[5] = 0;
for(int i = 0;i<50;i++)
arr[i] = i;//越界
cout<<arr[i]<<endl;
int *p;
*p = 100;
int a = 10;
int b = 0;
a/=b;//除0
exit(0);
//parent
int status = 0;
pid_t ret = waitpid(id,&status,0);
id(ret == id)
cout<<"wait success!"<<endl;
cout<<"exit code!"<<(status>>8)&0xff<<endl;
cout<<"exit signal!"<<(status & 0x7F)<<endl;
cout<<"core dump!"<<(status>>7)&1<<endl;
return 0;
我们发现core dump是0,然后我们将核心转储打开:ulimit -c 1024,允许core dump核心转储
一个进程要有core dump标志位为1:
需要操作系统打开核心转储,而且还要出现相关的错误(除0,越界等等)
当你的进程触发错误的时候,比如说除0,野指针越界的时候,也会由操作系统识别到,然后给目标进程发送信号,来达到终止进程的目的。
如何理解自己曾经遇到的各自程序崩溃的现象?
本质上就是操作系统识别到错误,然后给目标进程发送信号,来达到终止进程的目的
OS是如何具备识别异常的能力?
OS是软硬件的管理者!软硬件好的时候OS清楚,软硬件坏的时候也能够知道
基本上所有的报错都有对应的软硬件:
除0报错:CPU->状态寄存器,出现错误是状态寄存器会发生变化
越界/野指针:内存和页表MMU
出现错误后OS会知道谁干的,比如如果CPU执行指令出错了,这是谁的指令,然后将该进程终止
总结
- 上面所说的所有信号产生,最终都要有OS来进行执行,为什么?
OS是进程的管理者
-
信号的处理是否是立即处理的?在合适的时候
-
信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
-
一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?
-
如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?
信号识别
- 进程收到信号其实不是立即处理的,而是在合适的时候
比如你正在打游戏,此时外卖员来给你送外卖来了,敌方正在推你的高低,此时你正在处理重要的事情
为什么不是立即处理的而是在合适的时候?
信号的产生是在进程的运行的任何时间点都可以产生的,有可能进程正在做更重要的事情,信号的产生和进程的运行是:异步的
信号的处理
- 默认方式(部分是终止进程,部分有特定的功能)
- 忽略信号
- 自定义方式:捕捉信号
闹钟响了,你起床这是默认。闹钟响了你继续睡觉,这是忽略,闹钟响了,你跳舞,这是自定义
信号的本质:因为信号不是立即处理的,所以信号一定要先被保存起来
在哪里保存?如何保存?谁发的,如何发?
在进程的PCB,进程控制块task_struct
- 如何保存?
对进程而言,关心的是"是否有信号"+"信号是谁"的问题,就跟外卖员给你送外卖,你关心的是外卖到了没和是什么外卖,用什么结构来保存呢?位图!unsigned int signals;比特位的位置代表的是是谁,比特位的内容(0或者1)代表的是是否收到信号
- 谁发的,如何发?
发送信号的本质就相当于写对应进程的task_struct信号位图,因为OS是进程的管理者,对进程数据做修改,OS是有能力和义务的!信号是OS发送的,通过修改对应进程的信号位图(0->!),完成信号的发送!信号的产生都是直接或者间接通过OS发送给进程
自定义方式捕捉信号
#include<iostream>
#include<sys/types.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
std::cout << "get a signal: " << signo << std::endl;
int main
signal(2, handler);//自定义方式捕捉信号
while(true)
std::cout<< "l am a process: " << getpid0 << std::endl;
sleep(1);
return 0;
可以看到捕捉到了2信号
#include<iostream>
#include<sys/types.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
std::cout << "get a signal: " << signo << std: : endl ;
int main
for(int i=0;i < 32; i++)
signal(i, handler);
while(true)
std::cout<< "l am a process: " << getpid0 << std: : endl;
sleep(1);
return 0;
可以对大部分信号进行自定义捕捉,但是个别无法自定义捕捉,比如9号信号,更不能忽略,9号信号没有办法捕捉和忽略
所以我们写了自定义捕捉程序时,如果程序终止不了,可以使用kill -9选项进行终止程序
/usr/include/bits/signum.h
在这个路径下可以看到信号的信息说明:
信号的发送,信号的识别,信号的处理上面已经讲解了,信号发送给进程后,进程不一定会立即处理,需要保存信号,那么具体是怎么保存的呢?我们从内核的角度来讲解信号的保存
- 保存信号的内核角度
- 理解如何保存
阻塞信号
信号其他相关概念
- 实际执行信号的处理动作称为信号递达(Delivery)。
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
信号抵达的方式有:默认,忽略,自定义捕捉,进程可以允许某些信号不会被递达(阻塞),此时这些信号是阻塞信号,保持在未决状态,直到解除阻塞(方可递达),被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
注意:
阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
在内核中的表示
前面我们说过进程在接受到信号后,可能不是立即处理信号,而是先将信号保存起来,是在进程控制块中保存的:
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
在上图的例子中:
- SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生了,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前
不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。 - SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?
Linux是这样实现的:
常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。
pending位图:比特位的位置代表信号的编号,比特位的内容(0 or 1)代表是否收到信号,OS发送信号本质是修改task_struct pending位图的内容
handler数组:用信号的编号,作为数组的索引,找到该信号对应的信号处理方式,然后指向对应的方法(递达)
block位图:比特位的位置代表信号的编号,比特位的内容(0 or 1) 代表是否阻塞该信号
注意:
如果没有收到对应的信号,照样可以阻塞特定信号,阻塞更准确的理解成一种"状态",检测信号是否会被递达,是否被阻塞,都是OS的任务,信号的自定义捕捉方式是用户提供的!是在用户的权限下对应的方法
sigset_t信号集
信号集用来描述信号的集合,每个信号占用一位。从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集
这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。下面将详细介绍信号集的各种操作。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
我们写程序创建sigset_t变量,本质上是在栈上开辟的空间创建的他,是在用户空间,我们设置进程属性还需要系统调用接口
int main()
sigset_t set;//在栈上开辟空间,用户空间,设置进程属性(OS),系统调用接口
return 0;
信号集操作函数
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统
实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做
任何解释,比如用printf直接打印sigset_t变量是没有意义的
这些操作函数只是在用户空间上的,修改的是用户空间的变量
#include <signal.h>
int sigemptyset(sigset_t *set);
函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有
效信号。
#include <signal.h>
int sigfillset(sigset_t *set);
函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系
统支持的所有信号。
注意:
在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号
#include <signal.h>
int sigaddset (sigset_t *set, int signo);
指定位置设置为1(添加信号)
#include <signal.h>
int sigdelset(sigset_t *set, int signo);
指定位置设置为0(删除信号)
#include <signal.h>
int sigismember(const sigset_t *set, int signo);
判断特定信号是否已经被设置
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含
某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
这些都只是语言层面的操作函数,我们需要设置进程属性的话就需要系统调用接口:
sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
set是输入型参数,oset是输出型参数:
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改进程的信
号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后
根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值:
how | 说明 |
---|---|
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set |
SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set |
SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask=set |
sigpending
以上是关于Linux之信号详解的主要内容,如果未能解决你的问题,请参考以下文章深入详解Linux进程间通信之共享内存(Shared Memory)+信号量同步