系统级基础信号知识【Linux】

目录

一,什么是信号 

进程面对信号常见的三种反应概述

二,产生信号

1.终端按键产生信号

signal

2. 进程异常产生信号

核心转储

3. 系统调用函数发送信号

kill

raise

abort

小结:

4. 由软件条件产生

alarm

5. 硬件异常产生信号

三,信号其他概念

1. 进程中储存信号的内核结构

2. sigset_t类型——信号集类型

3. sigpending接口

4. sigprocmask接口

5. 重新理解进程在计算机中的运行

四,捕捉信号

1. 捕捉信号流程

​编辑

2. sigaction

关键字——volatile

SIGCHLD信号


嘿!收到一张超美的风景图,希望你每天都能开心顺心! 

一,什么是信号 

操作系统中的信号是一种在进程间传递信息和通知的机制。它可以用来通知进程发生了某种事件,比如用户按下了某个键盘按键、进程收到了某个信号或者发生了某个错误等。

生活中的例子:

  1. 手机收到新短信或来电时会发出提示音,这就是一种信号,通知用户有新的事件发生。
  2. 交通信号灯会发出红、黄、绿三种不同的信号,指示车辆和行人何时可以通行。
  3. 火灾报警器发出警报声,通知人们有火灾发生,需要立即疏散。
  4. 门铃响起,通知主人有人来访。
  5. 警报器在发现入侵者时会发出警报声,通知屋主有危险。

进程面对信号常见的三种反应概述

可选的处理动作有以下三种 :
1. 忽略此信号。
2. 执行该信号的默认处理动作。
3. 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉 (Catch)一个信号。

这里我们以:kill  信号为例

我们平时手动结束一个进程: kill -9  PID, 本质上是让操作系统向目标进程发送 9信号,从而结束该进程。下面是kill 相关的信号表:

系统级基础信号知识【Linux】_第1张图片

以上普通信号,我们认识七八个即可

指令:man  7  signal

通过man 7 signal, 我们可以查看信号的详细信息

系统级基础信号知识【Linux】_第2张图片

试问:如何理解键盘中,组合键如何实现其功能? 

答: 首先我们得知道,键盘的工作方式:通过中断的方式产生信息。同时,操作系统中也一定有识别该组合键产生信息的记录表。

一个进程,在接受信号后,必然会对信号进程储存。进程则是通过PCB(task_struct)中位图unsigned  int来记录,信号是否存在。

而PCB又是内核数据结构,能修改PCB的也就只有OS自身。

即:信号发送的本质是,OS对目标进程的PCB中信号位图的修改

回到组合键的问题: 

二,产生信号

1.终端按键产生信号

signal

系统级基础信号知识【Linux】_第3张图片

signum:  捕获该进程信号

handler :  信号处理方法(函数指针)

比如这样:

void signalmain(int signal)
{
    cout << "信号处理中...  : " << signal << "  gitpid :" << getpid() << endl;
}

int main()
{
    signal(SIGINT, signalmain);

    while ( 1)
    {
        cout << "接受信号中...... " << endl; 
        sleep(1);
    }
    return 0;
}

运行过程中,我们不断通过,ctrl + c的组合键进行操作。运行结果如下; 

系统级基础信号知识【Linux】_第4张图片

从上面我们可以得出2个点:1.键盘的组合键确实是系统向当前进程发送信号。 2. 可以通过signal注册信号处理函数。

(signal使用须知:signal接口,并不是调用就会触发信号处理方法,它只是提前注册了信号处理函数;只有捕捉到特定信号时才会调用特定方法;  signal接口一般出现在main函数开始。)

2. 进程异常产生信号

核心转储

这个在进程控制,waitpid函数,status参数中提到过:

大概就是这样:在进程发生异常退出时,能进行核心转储,形成一个二进制的特殊文件

系统级基础信号知识【Linux】_第5张图片

解释一下,为什么云服务器核心转储默认是关闭?:因为服务器的管理,是有另外一层服务负责,他们的任务是将异常挂掉的服务进程自动重启如果因为进程老是异常挂掉,这会导致磁盘中存在大量的转储文件,会导致资源浪费。 

我们在终端,再次打开 man  7   signal, 文档中core的意思就是核心转储

系统级基础信号知识【Linux】_第6张图片

3. 系统调用函数发送信号

kill

系统级基础信号知识【Linux】_第7张图片

我们在终端输入kill -9  PID等等信号,在底层是调用了kill系统函数。kill也很简单。

kill : 进程 PID

sig:  信号编号

raise

系统级基础信号知识【Linux】_第8张图片

功能很简单,就是让OS向自身进程发送信号

abort

系统级基础信号知识【Linux】_第9张图片

功能: 终止自身进程(相当于向自身进程发送:kill -6)

小结:

上面这些接口,本质上都是利用系统接口调用,执行OS对应的系统调用代码,OS在PCB中设置或者是修改特定的值,进程对信号进行处理。

4. 由软件条件产生

例子: 

我们以曾经的管道为例,如果读端不仅没读,而且将读端关闭;写端一直在写。作为单向通信,写已经没有意义了,OS会终止写进程,发送SIGPIPE(13),这构成不了软件级的条件。 

alarm

 系统级基础信号知识【Linux】_第10张图片

 功能:就是过 seconds 后,OS将自动向该进程发送SIGALRM(14)信号。

注意:当进程开始,当alarm被触发后向进程发送信号,但我们要注意,alarm只是发送SIGALRM信号,并不会阻断进程正常进行

5. 硬件异常产生信号

硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。
例如:当前进程执行了除以0的指令, CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。
void func(int st)
{
   cout << "接受到信号: " << st << endl;
}

int mian()
{
  signal(SIGFPF, func);
  int count = 150;
  z = count / 0;
  
  while(1) {sleep(1);}
}

 现象:会一直打印 “接受到信号: 8”

1. 首先我们得思考,这个信号是谁发出的呢?
      CPU运算单位异常详细解释:CPU内部有寄存器,其中一个状态寄存器(位图存储信息),有对应的状态标记位,溢出标记位。OS会自动进行计算检测(先检测再计算),如果状态标记位是1(可以理解为是否异常),OS会立即调取进程PID,向目标进程发送信号,然后就是进程会选择合适的时机处理信号。因此, 一些运算,并不都是软件层产生的信号,一些则是硬件层产生的信号。

2,硬件出现异常,进程就一定会退出吗?  

     不一定,如果我们没有捕获信号,那么进程默认退出;而捕获后,我们的就可以控制进程是否退出。

3. 为什么上面代码,会进入死循环??

     在捕获信号后,信号处理中并未退出进程,该进程还在CPU运行队列中,将会被再次调度,当再次调度时OS还会继续检测,会继续发送信号,然后被捕获处理,接着继续在CPU运行队列中。

再比如:当前进程访问了非法内存地址,MMU(硬件)会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。

注: 野指针的异常,常常会有段错误: Segmentation fault

void func(int st)
{
   cout << "接受到信号: " << st << endl;
}

int mian()
{
  signal(SIGSEGV, func);
  int *count = nullptr;
  *count  = 100;
  
  while(1) {sleep(1);}
}

现象还是:死循环打印 “接受到信号: 11"

1. 如何理解地址访问??

     首先我们访问一个数据目标,我们一定得访问其物理地址。那么中间会有一段虚拟地址转换为物理地址的过程,由页表(并不是软件结构,而是一种硬件) + MMU(Memory Manager  Unit, 是一种硬件) , 当错误的地址被MMU(硬件寄存器)读取后,一定会报错,OS将MMU的报错转换为信号发送给进程,让其退出。

2. 死循环原因??

     因为状态寄存器储存进程的状态,在信号发送完一次后,再次被调度时,检测到进程状态寄存器中的异常,则又会发送信号,然后被切换保存进程上下文,就这样一直继续下去。

三,信号其他概念

实际执行信号的处理动作,称为 信号递达(Delivery)
信号从产生到递达之间的状态,称为 信号未决(Pending)
进程可以选择 阻塞 (Block )某个信号( 一般情况下,进程不会阻塞任何一种信号)。
被阻塞的信号产生时将 保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

1. 进程中储存信号的内核结构

系统级基础信号知识【Linux】_第11张图片

我们可以回想,之前是使用的signal系统接口:系统级基础信号知识【Linux】_第12张图片

结合上图,可以这么理解:sig就是pending中,对应信号的位置;第二参数位,我们暂时叫func, func就是对handler[sig]中设置处理函数的地址。

上面是对信号的自定义处理

而执行默认处理

signal(SIGSEGV, SIG_DFL); // 对应的下标是0

忽略处理

signal(SIGSEGV, SIG_IGN); // 下标为1

这有一点需要注意的是:当OS检测到进程的一个信号,下标值为signalsum,并不是直接handler[signalsum]直接访问,而是先比较是否是SIG_DEL(0)或者SIG_IGN(1),再比较自定义处理

2. sigset_t类型——信号集类型

从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储, sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有 效”和“无效”的含义是该信号是否处于未决状态。下一节将详细介绍信号集的各种操作。 阻塞信号集也叫做当前进程的 信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。( 总结:block, pending都可以用sigset_t类型表示
sigset_t类型的理解:
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从 使用者的角度是不必关心的,使用者只能 调用函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用 printf直接打印sigset_t变量是没有意义的

信号集处理,相关函数:

#include
int sigemptyset(sigset_t *set);  // 将信号集全设置为0,信号集初始化。
int sigfillset(sigset_t *set);        // 将信号集设置为1
int sigaddset (sigset_t *set, int signo);  // 添加某一信号
int sigdelset(sigset_t *set, int signo);    // 删除某一信号
int sigismember (const sigset_t *set, int signo); //   判断一个信号集的有效信号中是否包含某种 信号, 若包含则返回 1, 不包含则返回 0, 出错返回 -1

3. sigpending接口

系统级基础信号知识【Linux】_第13张图片

功能: 读取当前进程的未决信号集 , 通过 set参数传出 。调用成功则返回 0, 出错则返回 -1 。 

4. sigprocmask接口

功能:调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)

set: 是我们自定义的一个信号集。

how:  传入的set,对该进程的阻塞信号集进行怎样的操作,比如:添加屏蔽字,删除屏蔽字,覆盖屏蔽字。

oset:  传入一个新set,  储存修改前旧的阻塞信号集

how可选值:

系统级基础信号知识【Linux】_第14张图片

实践:

void cmpshow(const sigset_t& set)
{
   for (int i = 1; i <= 31; i++)
   {
    if (sigismember(&set, i))
     {cout<< "1";}
     else cout << "0";
   }
   cout << endl;
}

int main()
{
  sigset_t set, oset;
  sigemptyset(&set);
  sigemptyset(&oset);
  sigaddset(&set, 2); // 目标阻塞信号 2
  sigpending(&oset);
  int n = sigprocmask(SIG_BLOCK, &set, &oset);
  assert(n == 0);  // n 必然==0
  (void)n;  // 目的是调用一次n,避免在release版本中,n未被调用的警告
  while (1)
  {
     sigset_t tmp;
     sigemptyset(&tmp);
     sigpending(&tmp);
     cmpshow(tmp);
     sleep(1);
  } 
  return 0;
}

 问:为什么没有设置pending信号集的接口?? 答:没必要,像kill, raise,  abort指令接口都可以修改pending。

小结:我们的进程中的信号,都有各自接口负责管理,处理函数——signal; 信号未决表——sigpending;  阻塞信号集——sigprocmask。

代码知识加餐:

  int n = sigprocmask(SIG_BLOCK, &set, &oset);
  assert(n == 0);    // n 必然==0
  (void)n;           // 目的是调用一次n,避免在release版本中,n未被调用的警告

问题1,既然进程可以自己捕捉信号,那我们让进程能捕获任何信号,并且全部阻塞信号,那这样就可以制作一个无法被动退出的进程了吗??

回答:OS的设计者已经考虑到这种情况了,所以解决方法是:kill -9  PID 这个信号是管理者信号(SIGKILL & SIGSTOP)无法被阻塞,也无法修改其处理方法(指结束进程)

5. 重新理解进程在计算机中的运行

系统级基础信号知识【Linux】_第15张图片

四,捕捉信号

1. 捕捉信号流程

系统级基础信号知识【Linux】_第16张图片

如果信号的处理动作是用户自定义函数 , 在信号递达时就调用这个函数 , 这称为捕捉信号。由于信号处理函数的代码是在用户空间的, 处理过程比较复杂 , 举例如下 : 用户程序注册了 SIGQUIT 信号的处理函数 sighandler 。 当前正在执行main函数 , 这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的 main 函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复 main函数的上下文继续执行,而是执行sighandler函数,sighandler和main函数使用不同的堆栈空间 , 它们之间不存在调用和被调用的关系, 两个独立的控制流程 sighandler 函数返回后自动执行特殊的系统调用sigreturn 再次进入内核态。 如果没有新的信号要递达 , 这次再返回用户态就是恢复main函数的上下文继续执行了

疑问:为什么在内核层山时,不直接调用信号处理函数呢?

答:如果信号处理函数中存在非法操作,那么贸然让计算机内核进行访问数据,计算机数据安全无法得到保证。

2. sigaction

系统级基础信号知识【Linux】_第17张图片

功能:  检查或者修改信号处理方法。平时用的最多的是signal 用法简单易上手。

sig :  目标信号

struct  sigaction * :一种放多种信息的结构体,其中就包括自定义函数处理方法sa_handler

系统级基础信号知识【Linux】_第18张图片

#include 
#include 
#include 

void sigint_handler(int signo) {
    printf("Caught SIGINT, exiting...\n");
    exit(1);
}

int main() {
    struct sigaction sa;

    // 对结构体内数据初始化
    sa.sa_handler = sigint_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;    

    if (sigaction(SIGINT, &sa, NULL) == -1) {
        perror("sigaction");
        exit(1);
    }

    printf("Press Ctrl+C to send SIGINT...\n");

    while (1) {
        // Do some work
    }

    return 0;
}
当某个信号的处理函数被调用时 , 内核自动将当前信号加入进程的信号屏蔽字 , 当信号处理函数返回时自动恢复原来的信号屏蔽字, 这样就保证了在处理某个信号时 , 如果这种信号再次产生 , 那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时, 除了当前信号被自动屏蔽之外 , 还希望自动屏蔽另外一些信号 , 则用 sa_mask 字段说明这些需要额外屏蔽的信号, 当信号处理函数返回时自动恢复原来的信号屏蔽字。

重入函数

系统级基础信号知识【Linux】_第19张图片

讲解:

main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
如果一个函数符合以下条件之一则是不可重入的:

调用了malloc或free,因为malloc也是用全局链表来管理堆的。
调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

关键字——volatile

该关键字在C当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下。
示例代码:
int tmp = 0;

void func(int sig)
{
    cout << "tmp: " << tmp << "-->";
    tmp = 1;
    cout << tmp << endl;
}

int main()
{
   signal(2, func);

   while (!tmp);
   return 0;
}


// 编译
signal : signal.cc
	g++  -std=c++11 -o $@ $^ -O3 -g
    # -O3 ————编译器对代码进行三级优化

./PHONY: clean
clean:	
	rm -rf signal

现象:进程开始运行后,进行ctrl +  c,进程结束,一切正常。但未来我们的代码会跑在各种各样的编译器上,其中一些优化就会影响这个过程。这里就直接说了,在编译时,添加 -o3  进行三级优化,由于tmp没有进行写入操作,寄存器直接用0代替了tmp,这就会导致我们使用 ctrl  +  c,无法终止循环。而  volatile(易变的)  就是提醒计算机,请不要优化该数据

因此,用  volatile 修饰即可tmp即可。 

SIGCHLD信号

进程一章讲过用wait waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束 也可以非阻塞地查询是否有子进程结束等待清理( 也就是轮询的方式——查看子进程是否发来信号 )
采用第一种方式, 父进程阻塞了就不能处理自己的工作了;
采用第二种方式, 父进程在处理自己的工作的同时还要记得时不时地轮询一 下, 程序实现复杂。
其实, 子进程在终止时会给父进程发 SIGCHLD 信号, 该信号的默认处理动作是忽略, 父进程可以自定义SIGCHLD 信号的处理函数 这样父进程只需专心处理自己的工作, 不必关心子进程了, 子进程终止时会通知父进程, 父进程在信号处理函数中调用wait 清理子进程即可。
系统级基础信号知识【Linux】_第20张图片

下期:多线程

结语

   本小节就到这里了,感谢小伙伴的浏览,如果有什么建议,欢迎在评论区评论,如果给小伙伴带来一些收获请留下你的小赞,你的点赞和关注将会成为博主创作的动力

你可能感兴趣的:(Linux,linux,运维,服务器,c++,开发语言)