Linux之信号详解

Posted 小赵小赵福星高照~

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux之信号详解相关的知识,希望对你有一定的参考价值。

进程信号

文章目录

信号入门

  1. 理解生活中的信号

下课铃,红绿灯,闹钟,烽火,脸色…等等这些都是信号

a.你为什么能认识红绿灯或者闹钟?曾经有人教育过我们,大脑里记住的

b.现在没有闹钟的时候,你知不知道闹钟响了之后,该怎么办?知道

很多事情需要经过这三个问题:是什么?为什么?怎么办?

a,b交代了:是什么?为什么?怎么办?

c.操作系统相当于社会,进程相当于人,进程要能够识别非常多的信号

总结:人能够识别信号

  1. 见一下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,发现进程终止了:

注意

  1. Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
  2. Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
  3. 前台进程在运行过程中用户随时可能按下 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向进程发送信号?能否描述一下完整的发送处理过程?

信号识别

  • 进程收到信号其实不是立即处理的,而是在合适的时候

比如你正在打游戏,此时外卖员来给你送外卖来了,敌方正在推你的高低,此时你正在处理重要的事情

为什么不是立即处理的而是在合适的时候?

信号的产生是在进程的运行的任何时间点都可以产生的,有可能进程正在做更重要的事情,信号的产生和进程的运行是:异步的

信号的处理

  1. 默认方式(部分是终止进程,部分有特定的功能)
  2. 忽略信号
  3. 自定义方式:捕捉信号

闹钟响了,你起床这是默认。闹钟响了你继续睡觉,这是忽略,闹钟响了,你跳舞,这是自定义

信号的本质:因为信号不是立即处理的,所以信号一定要先被保存起来

在哪里保存?如何保存?谁发的,如何发?

在进程的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 

在这个路径下可以看到信号的信息说明:

信号的发送,信号的识别,信号的处理上面已经讲解了,信号发送给进程后,进程不一定会立即处理,需要保存信号,那么具体是怎么保存的呢?我们从内核的角度来讲解信号的保存

  1. 保存信号的内核角度
  2. 理解如何保存

阻塞信号

信号其他相关概念

  • 实际执行信号的处理动作称为信号递达(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_BLOCKset包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set
SIG_UNBLOCKset包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set
SIG_SETMASK设置当前信号屏蔽字为set所指向的值,相当于mask=set

sigpending以上是关于Linux之信号详解的主要内容,如果未能解决你的问题,请参考以下文章

Linux之信号详解

Linux之信号详解

linux进程间通信之Posix 信号量用法详解代码举例

深入详解Linux进程间通信之共享内存(Shared Memory)+信号量同步

深入详解Linux进程间通信之共享内存(Shared Memory)+信号量同步

深入详解Linux进程间通信之共享内存(Shared Memory)+信号量同步