【Linux系统】信号

目录

 

一.信号的概念

1.生活中的信号

2.什么是信号

二.信号的产生

1.前台进程与后台进程

2. 中断

3.操作系统中的信号

4.产生信号的四种方式

(1)键盘输入产生信号

(2)系统调用产生信号

 (3)硬件异常产生信号

(4)软件条件产生信号

总结

5.操作系统中的时间(补充)

6.core和term

三.信号的保存 

1.信号未决,递达,阻塞

2.内核中关于信号的三张表

3.修改,查询阻塞和未决信号集

(1)系统提供给用户的数据类型sigset_t

(2)系统调用

四.信号的处理

1.用户态和内核态

(1)用户级页表和内核级页表

(2)用户态和内核态的标志

2.处理信号

3.系统调用

4.细节

五.信号的其他补充问题

1.函数重入

2.关键字volatile

3. SIGCHLD信号

(1)基于信号回收子进程

(2)手动忽略SIGCHLD


 

一.信号的概念

1.生活中的信号

红绿灯就是一种信号,因为我们

  1. 知道它的特征,能识别
  2. 知道对应的灯亮,意味着什么,要做什么
  1. 我为什么认识这个信号?因为有人提前告诉过我,所以信号没有产生的时候,我们已经能知道怎么处理这个信号,信号到来,我也能立即识别出来
  2. 信号的到来,我们并不清楚具体什么时候,即信号到来相对于我正在做的工作,是异步产生的
  3. 信号产生了,我们不一定要立即处理它,而是在合适的时候处理,所以我要有一种能力将已经到来的信号暂时保存

2.什么是信号

信号是向目标进程发送通知消息的一种机制。

目标进程能识别信号,并且知道怎么处理

二.信号的产生

1.前台进程与后台进程

进程分为前台和后台(./xxx &),前台进程在命令行操作时只能有1个,后台进程可以有多个

前台和后台的本质区别是前台进程能够接受用户输入,后台进程不能接受用户输入,所以前台进程只能有1个。键盘输入ctrl + c,前台进程接收(通过接收信号的方式来间接接收)后终止。

当我们启动一个前台进程,shell无法接收指令,因为它被操作系统提到后台。终止前台进程后,操作系统将shell提到前台,可以接收指令。shell也是一个进程,但是不能被ctrl+c终止。

  1. ctrl + c  :终止前台进程
  2. jobs   :查看后台进程
  3. fg [number]   :将后台进程提到前台
  4. ctrl + z  :将前台进程暂停,前台进程如果被暂停,会立即被操作系统提到后台,shell提到前台,否则键盘会失效
  5. bg [number]  :将后台暂停的进程在后台启动(一定是后台暂停的进程,因为只有shell在前台运行才能接收你的指令)
  6. 终止一个后台进程:(1)fg [number] ,  ctrl+c  (2)kill -9 pid

2. 中断

操作系统怎么知道键盘有输入?

CPU和外设有针脚相连(间接相连),不同外设可以向特定针脚发送电信号。给每个针脚一个编号,叫做中断号。当某个外设数据就绪时时,向针脚发送高电平,CPU识别到某个针脚的高电平,就将中断号写到寄存器,操作系统就可以读取到这个编号。

计算机启动时,操作系统会将一个函数指针数组加载到内存,这个数组叫做中断向量表,内容是各种外设的读取方法,数组下标就是对应外设的中断号。外设向CPU发送中断号后,操作系统会立即停止手头的工作,从寄存器读取中断号,到中断向量表寻找读取方法,将数据从外设读取到内存。

操作系统怎么知道键盘有输入?

当用户按下键盘,键盘向CPU发送电信号,CPU写入中断号,通知操作系统,操作系统提取中断号,执行中断向量表中的键盘驱动读取方法,将数据从外设拷贝到指定的缓冲区中。

信号就是用软件来模拟中断的行为,中断是外设和操作系统之间的信息通知,信号是进程和进程之间的信息通知。

3.操作系统中的信号

 man 7 signal :查询信号

我们既可以使用信号编号,也可以使用信号名称,实际上这些名称就是一个个宏。

细节:

  1. 没有0号信号。因为进程的退出信息有退出信号和退出码,如果退出信号为0,表示进程不是因为信号而退出,是正常终止的,所以没有0号信号是为了标识进程未收到信号正常退出。
  2. 1~31号信号是普通信号,34~64是实时信号,我们只谈普通信号
  3. 每个进程都有函数指针数组,信号编号和数组下标强相关(信号从1开始,下标从0开始)

4.产生信号的四种方式

(1)键盘输入产生信号

例如键盘输入ctrl+c,键盘向CPU触发中断,CPU通知操作系统,操作系统读取中断号,从中断向量表中调用键盘的驱动读取方法。键盘的数据分为普通数据和控制数据,操作系统解析”ctrl+c“为控制数据,所以不会把数据写到缓冲区,而是转化为向前台进程发送2号信号。

类似地,键盘输入ctrl+z 转化为向前台进程发送19号信号,该信号的默认处理方法是暂停进程。

键盘输入ctrl+\ 转化为向前台进程发送3号信号,该信号的默认处理方法是终止进程。

(2)系统调用产生信号

 功能:向指定的进程发送指定的信号

 功能:向本进程发送指定的信号

//代码实例:实现kill指令对应的可执行程序
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <string>
using namespace std;

void Usage(const string& proc)
{
    cout << "\nUsage: " << proc << "signo processid" << endl; 
}
int main(int argc, char* argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        return 0;
    }

    int signo = stoi(argv[1] + 1);
    int processid = stoi(argv[2]);
    kill(processid, signo);
}

 (3)硬件异常产生信号

除0错误

CPU执行进程的代码,例如10/0,状态寄存器中的溢出标志位被置1,CPU通知操作系统自己出现异常,操作系统将硬件异常,转换为向导致该异常的进程发送8号信号SIGFPE(float point exception),该信号的默认处理方法是终止进程。

void handler(int signo)
{
    cout << "signo: " << signo << endl;
    sleep(1);
}
int main()
{
    signal(8, handler);
    int a = 5 / 0;
    
    return 0;
}

以上代码会陷入死循环:CPU执行进程代码引发异常,不再向后执行,通知操作系统,操作系统让CPU调度其它进程,同时向目标进程发送8号信号,但由于用户对8号信号自定义捕捉,进程没有退出,当它重新被CPU调度时,硬件上下文加载到CPU中,再次引发异常。

解引用空指针

空指针就是进程地址空间中的0号地址,是一个无效地址,页表中没有映射关系。当CPU访问0号地址,通过MMU(内存管理单元,一种硬件,集成在CPU中),查询页表从虚拟地址转化为物理地址,MMU转化失败产生异常,通知操作系统,操作系统将硬件异常转化为向目标进程发送11号信号SIGSEGV(Segmentation Violation),该信号的默认处理行为是终止进程。

小结

进程出现异常,和语言没有关系,与操作系统和硬件有关。操作系统是软硬件的管理者,硬件异常是进程引起的,所以操作系统杀掉进程,硬件上下文数据也就没有了,硬件恢复健康状态。操作系统允许用户自定义捕捉异常相关的信号,是想让用户自定义一些行为,例如打印一些错误消息,但不终止进程的行为是不恰当的。

(4)软件条件产生信号

管道的读端关闭,写端仍然向管道发送数据,操作系统向写端进程发送13号信号SIGPIPE,该信号的默认处理方法是终止进程。这就是一种由软件异常,用户也可以自己设置软件条件是操作系统向进程发送常规信号。

每调用一次alarm,系统会在内核中创建一个内核数据结构,该结构用于描述设定的闹钟, 其中肯定包含了时间戳,以及设定闹钟的进程id等信息。有如此多闹钟,操作系统如何得知哪些闹钟超时了呢?操作系统将这些闹钟按照时间戳,用小根堆存储,只需对比堆顶闹钟的时间戳是否超时,假如超时就pop,没有超时说明所有的闹钟都没有超时。

闹钟超时操作操作系统向进程发送14号信号SIGALRM,默认处理方法是终止进程。

alarm的返回值

一个进程不允许同时设置多个闹钟,若旧闹钟的时间还没到,新闹钟会覆盖旧闹钟,同时返回旧闹钟剩余的秒数。

//每隔两秒闹钟响一次
void handler(int signo)
{
    cout << "signo: " << signo << ", 闹钟响了" << endl;
    alarm(2);
}

int main()
{
    signal(14, handler);
    alarm(2);
    while (true)
    {
        sleep(1);
        cout << "I am running, pid: " << getpid() <<  endl;
    }
    
    return 0;
}

总结

产生信号的方式有很多种,但最终都是操作系统向目标进程发送的信号,因为操作系统才是进程的直接管理者。操作系统是如何向进程发送信号的呢?下一部分信号的保存会详细讲解。

5.操作系统中的时间(补充)

  1. 所有用户行为都是以进程的行为在操作系统中表现的
  2. 操作系统只需要把进程调度好,合理把CPU,磁盘等资源分配给进程,就能完成用户任务
  3. 计算机中有一种硬件CMOS,周期性地高频率地向CPU发送时钟中断。
  4. CPU收到时钟中断后,CPU直接去中断向量表中执行操作系统的调度方法,去调度进程,也就是说,是时钟中断推着操作系统去调度进程的。
  5. 所以操作系统的运行是基于硬件中断的死循环,只不过有些中断是间歇性的,比如键盘,有些是持续性的,比如CMOS。

 6.core和term

 core和term都会终止进程,但是core在终止进程的同时还会core dump(核心转储),即将进程出错时的上下文数据转储到磁盘当中,命名为core.[pid],方便用户调试,定位错误位置。

查看core dump的设置,云服务器默认core dump文件大小限制为0,即关闭core dump

更改core dump文件大小限制(仅在当前shell终端有效)

core dump文件使用:

  1. 编译器编译时带上-g选项,生成debug版可执行程序。
  2. 运行可执行程序,程序终止并core dump
  3. 用gdb打开可执行程序,然后输入core-file [core dump file name]指令,即可定位错误的代码

三.信号的保存 

1.信号未决,递达,阻塞

  1. 执行信号的处理方法叫做信号递达(delivery)
  2. 信号从产生到递达之间的状态叫做信号未决(pending)
  3. 进程可以阻塞某个信号,被阻塞的信号不能被递达,直到解除阻塞

信号的处理方法有三种:

  1. 默认处理方法
  2. 忽略(忽略也算处理信号,和阻塞不同)
  3. 自定义捕捉
void handler(int signo)
{
    cout << "singo: " << signo << endl;
}
int main()
{
    /*
    typedef void(*sighandler_t)(int);
    #define SIG_DFL ((sighandler_t)(0));
    #define SIG_IGN ((sighandler_t)(1));
    */
    signal(2, handler); //对2号信号自定义捕捉handler方法
    signal(2, SIG_IGN); //对2号信号的处理方法设置成忽略 
    signal(2, SIG_DFL); //对2号的处理方法设置成默认
    while (1)
    {
        cout << "I am runnint, pid: " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

举个栗子:

将古代皇帝批阅奏折比作递达信号,奏折送到皇帝的办公桌上,皇帝可能现在有更重要的事情要处理,比如朝会,所以奏折处于未决状态。

假如皇帝非常讨厌某个大臣,吩咐贴身太监,如果有他的奏折,就单独放在一边暂时不看,这叫做奏折被阻塞了。

如果有一天皇帝对那个大臣的印象转变,将它从黑名单删除,迫不及待地查看它以前写的奏折,这叫做奏折被解除阻塞,立即递达。

皇帝批阅奏折的方式也有多种,比如写上“知道了”,这是奏折的默认处理方法;看完后什么也不写,留中不发,这是忽略该奏折;看完后非常高兴,做出具体的答复,这叫奏折自定义捕捉。

未决的信号一定被被阻塞了吗?不一定,可能是该信号还没来得及处理

假如收到了信号,且该信号被阻塞了,则该信号一定是未决的?正确

没有收到信号,可以阻塞信号吗?可以

2.内核中关于信号的三张表

操作系统向进程发送信号,即将进程PCB中pending(未决)表相应位置的比特位由0置1。

因为有handler表,进程知道怎么处理对应的信号。因为有pending表,进程能保存信号,并在合适的时候处理。handler表是函数指针数组,pending表和block表是两张规模一样的位图,只不过比特位的内容表达的含义不同。

3.修改,查询阻塞和未决信号集

(1)系统提供给用户的数据类型sigset_t

系统中的block和pending表都是位图,让用户直接操作位图难度较大,所以操作系统将这种规模的位图typedef成一种数据类型sigset_t,并提供提供操作这种数据结构的系统调用。

在用户层,我们将pending位图叫做未决信号集,将block位图叫做阻塞信号集或者信号屏蔽字。信号屏蔽字类似于文件权限中的权限掩码。

(2)系统调用

#include <signal.h>
//更改本地用户区的信号集(不是修改内核中的信号集)
int sigemptyset(sigset_t *set);  //将信号集的全部比特位置0
int sigfillset(sigset_t *set);   //将信号集的比特位全部置1
int sigaddset (sigset_t *set, int signo);  //将指定信号添加到信号集中(置1)
int sigdelset(sigset_t *set, int signo);  //将指定的信号从信号集中去除(置0)
int sigismember(const sigset_t *set, int signo);  //判断指定信号是否在信号集中
//用本地的信号集设置内核中的block表
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
//返回值:若成功则为0,若出错则为-1 

//how:
//1. SIG_BLOCK 将本地信号中集的信号阻塞,即mask = set | mask
//2. SIG_BLOCK 将本地信号集中的信号解除阻塞,即mask = set & (~mask)
//3. SIG_SETMASK 将本地信号集赋值给内核中信号集,即mask = set

//set:输入型参数
//oldset:输出型参数,返回内核中旧的信号屏蔽字

//获取内核中的未决信号集
#include <signal.h>
int sigpending(sigset_t *set);
//读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。

注意:9号信号SIGKILL和19号信号SIGSTOP不可被屏蔽,也不可被自定义捕捉或忽略!!!

//运行以下代码,向该进程发送2号信号,由于2号信号被屏蔽,
//2号信号将处于未决状态,15s后,取消屏蔽,2号信号被递达,
//观察pending表变化
void handler(int signo)
{
    cout << "signo: " << signo << endl;
}
void PrintPending(const sigset_t& pending)
{
    for (int signo = 31; signo >= 1; signo--)
    {
        if (sigismember(&pending, signo))
        {
            cout << '1';
        }
        else
        {
            cout << '0';
        }
    }
    cout << endl;
}
int main()
{
    signal(2, handler);
    //1.屏蔽2号信号
    sigset_t block, oblock;
    sigemptyset(&block);
    sigaddset(&block, 2);
    sigprocmask(SIG_BLOCK, &block, &oblock);

    //2.让进程不断获取当前进程的pending信号集
    int count = 0;
    while (true)
    {
        if (count == 15)
        {
            cout << "即将解除对2号信号屏蔽" << endl;
            sigprocmask(SIG_SETMASK, &oblock, nullptr); //解除对2号信号屏蔽;
        }
        cout << "pid: " << getpid() << endl;
        sigset_t pending;
        sigpending(&pending);
        PrintPending(pending);
        sleep(1);
        count++;
    }
    return 0;
}

四.信号的处理

信号在和合适的时候处理,“合适”是什么时候?进程从内核态返回到用户态的时候,进行信号的检测和处理。

1.用户态和内核态

用户态是一种受控的状态,能够访问的资源有限,内核态是一种操作系统的状态,能够访问大部分系统资源,系统调用背后就包含了身份的变化。

(1)用户级页表和内核级页表

每个进程都有自己的地址空间,以32位机器为例,[0,3GB]是用户空间,[3,4GB]是内核空间。用户可以随意访问自己的用户空间,例如代码,数据,关联的动态库,环境变量和命令行参数等。每个进程都有自己的用户及页表,用于用户空间的虚拟地址向物理地址的映射。

操作系统有代码吗?有:系统调用,进程调度等,有数据吗?有:各种数据结构,如PCB,地址空间,文件对象,页表等。CPU如何快速找到操作系统的数据?事实上,系统中存在一张内核级页表,用于进程内核空间的虚拟地址向物理地址的映射(这种映射关系比用户级页表简单得多),所有进程共用这一张页表,因为操作系统在进程内核空间的位置是一样的。CPU直接通过进程内核空间和内核级页表就能找到操作系统,所以CPU可以在任意时刻找到操作系统。

进程的所有代码的执行,都可以在地址空间内跳转和返回。用户自己的代码在代码段,静态库在代码段,动态库在共享区,系统调用在内核区。

(2)用户态和内核态的标志

CPU当中的CS寄存器有两个标志位,1表示内核态,3表示用户态。所谓的切换用户态到内核态,就是将寄存器中的标志位由3置1。CPU中还有CR3寄存器,用于保存CPU正在调度的进程的用户级页表的地址(物理地址)。CR1寄存器保存最近一次引发缺页中断的虚拟地址。

系统调用入口处,操作系统先将CPU中的标志位由3改到1,表示此时CPU的背后是操作系统,所以CPU才能跳转到地址空间的内核区,执行操作系统的代码。 系统调用完成后,操作系统还要把信息从内核空间返回到用户空间,将内核态切换到用户态,

2.处理信号

操作系统将CPU从内核态切换到用户态之前,会检查信号集中是否有信号需要处理。加入pending集中某个信号为1,block集中为0,该信号就会被递达。若处理方法是忽略,只需将pending集中的比特位置0;如果是默认方法,也只需先置0,执行操作系统提供的代码。值得探讨的是自定义捕捉呢?

自定义捕捉方法是用户提供的,执行用户的代码必须将状态切换到用户态!!!因为操作系统不相信用户,不能把访问内核空间的机会留给用户。以用户态执行完自定义捕捉方法后,还要通过系统调用sigreturn从用户态切换到内核态,返回到系统调用中,把返回信息返回给用户空间,再将内核态切换到用户态。

3.系统调用

sa_mask

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时,自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。也即,操作系统不允许自定义捕捉嵌套调用。

递达中的信号被自动屏蔽,用户还可以通过设置sa_mask,在处理过程中屏蔽其它信号,当处理结束,信号自动解除屏蔽。

4.细节

  1. 操作系统检查信号集,发现有信号要处理,会先将pending表中比特位置0,再去递达信号

  2. 当有多个信号要处理,操作系统会先将所有信号都置0,然后按照一定优先级执行处理方法,最后统一返回用户态

  3. 操作系统递达信号时,会自动将本信号阻塞,使其无法嵌套递达,递达结束后,自动解除阻塞

五.信号的其他补充问题

1.函数重入

将一个结点头插到链表需要两步:

//p=&node1

p->next = head; //(1)

head = p;           //(2)

 将这两步封装成一个insert函数。假如进程收到了一个信号,由于没有从内核态返回用户态的的契机,信号暂时没有递达。

当main函数调用insert插入node1,主执行流执行完第(1)步时,恰好进程的时间片到了,CPU调度其它进程。

当CPU收到时钟中断,操作系统再次调度该进程,内核态向用户态切换,处理信号。而信号的处理方法恰好也调用了insert函数,插入node2。

完成两步之后,回到主执行流继续执行,最后的结果就是node2结点丢失了,最终造成内存泄漏!!!

insert函数被两个执行流(主执行流,处理信号的执行流)重复进入了,简称被重入了。因为该函数被重入,导致了内存泄漏。

如果一个函数被重入而不会引发问题,叫做可重入函数,否则叫不可重入函数。

是否可重入只是函数的一个特性,不应以它来判断一个函数的优劣。实际上大部分函数都是不可重入的(用到全局变量),这种函数放在多执行流下才有可能出现问题。

2.关键字volatile

VS下release版本下编译代码,编译器会做一些优化。gcc/g++同样如此,只不过需要指令设定。加上-O[n],n越大,优化程度越高,默认是0,没有优化。

g++ -o process process.c -std=c++11 -O1

int flag = 0;
void handler(int signo)
{
    cout << "signo: " << signo << endl;
    flag = 1;
    cout << "change flag to : " << flag << endl;
}

int main()
{
    signal(2, handler);
    cout << "pid: " << getpid() << endl;
    while (!flag)
    {}
    cout << "正常退出" << endl;
    return 0;
}

加上-O1之前,运行得到的程序,按下ctrl+c进程正常退出。加上之后,再次编译运行,按下ctrl+c进程没有退出。

这就是编译器优化的缘故,编译器判定while循环内没有对flag作修改,所以转化成汇编代码就成为,第一次将flag的数据从内存读取到寄存器,以后的判断直接从寄存器取值,而不再读取内存的数据。但编译器没有料到还有信号处理方法更改了这一全局变量。

 volatile:修饰变量,阻止编译器优化,保持CPU对内存的可见性

3. SIGCHLD信号

子进程退出的时候,会给父进程发送17号信号SIGCHLD

(1)基于信号回收子进程

//基于信号回收子进程
void handler(int signo)
{
    cout << "signo: " << signo << endl;
    waitpid(-1, nullptr, 0); //阻塞式等待
}
int main()
{
    signal(SIGCHLD, handler);
    pid_t id = fork();
    if (id == 0)
    {
        cout << "child is running" << endl;
        sleep(5);
        exit(10);
    }
    int cnt = 10;
    while(cnt--)
    {
        sleep(1);
    }
}

//产生的问题:多个子进程同时退出,向父进程发送SIGCHLD信号
//但未决信号集中只能保存一个信号,waitpid只能执行1次
//解决方案:基于信号,非阻塞循环等待子进程
void handler(int signo)
{
    cout << "signo: " << signo << endl;
    pid_t id = 0;
    while ((id = waitpid(-1, nullptr, WNOHANG)) != 0) //如果还有子进程没有退出,waitpid就会返回0
    {
        if (id == -1) //如果没有子进程了,waitpid就会返回-1
        {
            break;
        }
        cout << "回收进程" << id << endl;
    }
}

(2)手动忽略SIGCHLD

signal(SIGCHLD, SIG_IGN);

效果:子进程退出时不再给父进程发送信号,父进程也无需wait回收资源,僵尸进程自动被操作系统释放(该方法只在Linux系统有效)

注意:这里我们手动设置SIG_IGN是一个特例,和我们之前讲的信号处理方法中的忽略不同。事实上,操作系统对于该信号默认的处理动作就是我们之前的SIG_IGN。

总结

  1. 生活中的信号特点类比操作系统中的信号
  2. 产生信号的四种方式:键盘,系统调用,硬件异常,软件条件
  3. 保存信号的三张表:pending,block,handler,以及皇帝批奏折的例子
  4. 信号处理过程的一张“无穷”图,用户态和内核态理解
  5. 函数重入概念初步了解
  6. volatile关键字用法
  7. SIGCHLD信号的应用——基于信号循环回收子进程和忽略子进程信息回收
  • 13
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值