Linux系统编程:信号

目录

1.信号概念

2.信号产生

[2.1 终端](#2.1 终端)

[2.2 系统调用](#2.2 系统调用)

[2.3 硬件异常](#2.3 硬件异常)

[2.4 软件条件](#2.4 软件条件)

[2.5 小结](#2.5 小结)

[3. 进程退出时的核心转储问题](#3. 进程退出时的核心转储问题)

[4. 信号捕捉初识](#4. 信号捕捉初识)

[5. 阻塞信号](#5. 阻塞信号)

[5.1 相关概念](#5.1 相关概念)

[5.2 在内核中的表示](#5.2 在内核中的表示)

[6. 信号捕捉](#6. 信号捕捉)

[6.1 知识铺垫](#6.1 知识铺垫)

[6.2 信号捕捉流程](#6.2 信号捕捉流程)

[6.3 sigset_t](#6.3 sigset_t)

[6.4 信号集操作函数](#6.4 信号集操作函数)

[6.5 sigaction](#6.5 sigaction)

[7. 可重入函数](#7. 可重入函数)

[8. 关键字volatile](#8. 关键字volatile)


1.信号概念

信号是进程之间事件异步通知的一种方式,属于软中断 。例:用户输入命令,在Shell下启动一个前台进程。用户按下Ctrl-C ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出。这里的ctrl+c就被OS解释成为了一种信号
注:

  • 用kill -l命令可以察看系统定义的信号列表
  • 当进程收到信号的时候,进程可能正在执行更重要的代码,信号不一定会被立即处理--因为信号可以随时产生(异步)
  • 进程本身必须要有对信号的保存能力
  • 进程在处理信号时,有三种动作--默认动作、自定义动作、忽略动作

kill -l

每个信号都有一个编号和宏定义 -- [1, 31]:普通信号 [34, 64]:实时信号

一个共识就是:信号是发送给信号的, 进程是被保存到哪里了呢?--task_strut中。当进程收到信号后,会修改PCB中的信号位图,unsigned int signal -- 其有32个比特位,比特位的位置代表编号,比特位的内容表示是否收到信号:0(未收到)、1(收到)

由上得出,信号的发送就是修改PCB中的位图结构,只有OS有权修改。本质就是OS向目标进程发送信号,所以会提供一系列的系统调用,使得用户可以通过OS发送信号。

2.信号产生

2.1 终端

通过终端的按键产生信号,ctrl+c 和 ctrl+\ 默认动作都是终止进程 ,分别对应2和3号信号

2.2 系统调用

#include <sys/types.h>

#include <signal.h>

int kill(pid_t pid, int sig);

参数:pid:目标进程pid

sig:发送几号信号

返回值:成功返回0,失败返回-1
可以向任意进程发送任意信号

demo代码:

cpp 复制代码
mysignal.cc/
 #include <iostream>    
  #include <signal.h>    
  #include <unistd.h>    
  #include <sys/types.h>    
  #include <string>    
      
  using namespace std;    
      
  static void Usage(const string& proc)    
  {    
      cout << "\nUsage:" << proc << " pid sino\n" << endl;    
  }    
  
      
  int main(int argc, char *argv[])    
  {    
       if(argc != 3)    
       {    
           Usage(argv[0]);    
       exit(1);    
   }    
       // 1.通过键盘发送信号    
       // 2.通过系统调用发送信号    
       pid_t pid = atoi(argv[1]);    
       int signo = atoi(argv[2]);    
       int n = kill(pid, signo);    
       if(n != 0)                                                                                            
       {    
           perror("kill");    
       }    
    return 0;
}

///mytest.cc
#include <iostream>                                                                                          
#include <sys/types.h>    
#include <unistd.h>    
    
using namespace std;    
    
int main()    
{    
    while(1)    
    {    
        cout << "我是一个正在运行的进程,pid:" << getpid() << endl;    
        sleep(1);    
    }    
    
    return 0;    
}    

上面代码的目的是,先运行mytest进程,进程mysignal通过命令行参数找到mytest进程来终止该进程

int raise(int sig); -- 给自己发送任意信号

cpp 复制代码
  //mysignal.cc/  
  #include <iostream>
  #include <signal.h>
  #include <unistd.h>
  #include <sys/types.h>    
  #include <string>   

  int main(int argc, char *argv[])    
  {  
      int cnt = 0;    
       while(cnt <= 10)    
       {
           cout << "signal ss" << cnt++ << endl;
           if(cnt >= 5) raise(9);
       }
      return 0;
}

由上述现象可以看到,raise在循环五次后发送9号信号,杀死进程。

#include <stdlib.h>

void abort(void); -- 使当前进程接收到信号而异常终止, abort函数总是会成功的 -- 6号信号

下面两个接口都可以通过kill接口实现

对信号处理行为的理解:

  • 很多情况,进程收到大部分的信号默认处理动作都是终止进程
  • 信号的意义:信号的不同,代表不同的事件,但对事件发生之后的处理都做都可以一样。

2.3 硬件异常

如:当前进程执行了除 以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。还有野指针问题

野指针问题:

cpp 复制代码
#include <stdio.h>
#include <signal.h>
void handler(int sig)
{
 printf("catch a sig : %d\n", sig);
}
int main()
{
 signal(SIGSEGV, handler);
 sleep(1);
 int *p = NULL;
 *p = 100;
 while(1);
 return 0;
}

若不进行信号捕捉怎么发生段错误

信号捕捉以后会捕捉到11号信号打印:

2.4 软件条件

2.4.1 管道

SIGPIPE 是一种由软件条件产生的信号,在**"管道"** 中已经介绍过了。在管道中,读端关闭,写段一直写,会发生一场,OS向写端发送SIGPIPE信号:13号信号

2.4.2 定时器软件条件

#include <unistd.h>

unsigned int alarm(unisegned int seconds);

参数:seconds:秒数 -- 若为0,则意味着取消闹钟

返回值:提前唤醒时,会返回剩余秒数

告诉内核在seconds秒后,给当前进程发SIGALRM信号

cpp 复制代码
#include <iostream>    
  #include <signal.h>    
  #include <unistd.h>    
  #include <sys/types.h>    
  #include <string>    
      
  using namespace std;

int main(int argc, char *argv[])    
  {  
     int cnt = 0;
        alarm(1);
       while(1)
       {
           cnt++;
            cout << "cnt = " << cnt++ << endl; // 外设打印 拖慢了计算的节奏
       }
return 0;                                                    
  } 

这段代码帮我们统计了计算机在一秒内可以累加并打印多少次。

cpp 复制代码
#include <iostream>    
  #include <signal.h>    
  #include <unistd.h>    
  #include <sys/types.h>    
  #include <string>    
      
  using namespace std;

int cnt = 0;    
      
void catchSignal(int signo)    
  {    
      cout << "获得一个信号,信号编号为:" << cnt << endl;    
      // exit(1);    
  }  

int main(int argc, char *argv[])    
  {  
     
        alarm(1);
       while(1)
       {
           cnt++;
            
       }
return 0;                                                    
  } 

这段代码为何与前一段代码打印出来的结果相差如此之大呢,是因为计算机进行打印(IO)是很费时的。

2.5 小结

  • 所有的信号产生方式都是由OS来执行的,因为OS是进程的管理者

  • 信号并不是立即处理的,是在合适的时间

  • 信号需要被保存在PCB中

  • 进程若没有收到信号的时候,进程就知道如何对信号处理

  • OS向进程发信号就是OS修改目标进程的PCB中的信号位图

3. 进程退出时的核心转储问题

进程退出时有两种方式:Term、Core,以core方式退出的进程可以利用核心转储来快速定位错误

**ulimit -a :**用于显示当前shell的各种资源限制(ulimits)

ulinit -c 1024, 打开云服务器的core file选项,将size设置为1024

demo代码:

cpp 复制代码
#include <iostream>    
  #include <signal.h>    
  #include <unistd.h>    
  #include <sys/types.h>    
  #include <string>    

int main()
{
    while(1)
    {
      int a[10];
      a[10000] = 106;
   }
    return 0;
}

当打开core file选项前:

当打开core file选项后结果为下,并且当前目录下会生成一个core.pid文件:

core dumped:核心转储---当进程出现异常时,我们将进程在对应的时刻,在内存中的有效数据转储到磁盘中;那么它存在的意义是什么呢?为了支持调试,如何支持?--gdb 文件

在gdb上下文中输入: core-file core.pid 即可找到异常位置 ,如下图所示,就能找到上面代码中的问题。这种方式称为事后调试

4. 信号捕捉初识

前文提到过,信号是可以被自定义捕捉的,siganl函数就是来进行信号捕捉的

#include <signal.h>

sighandler_t signal(int signum, sighandler_t handler);

参数:signum:信号编号或宏定义

handler:回调函数,用来如何处理这个信号

cpp 复制代码
#include <stdio.h>    
#include <signal.h>    
void handler(int sig)    
{    
 printf("catch a sig : %d\n", sig);    
}    
int main()    
{    
 signal(2, handler); //前文提到过,信号是可以被自定义捕捉的,siganl函数就是来进行信号捕捉的,提了解一下                                                                                 
 while(1);    
 return 0;    
}   

可以看到,一开始,代码进入死循环,当我们在命令行按 ctrl+c 时,进程不会退出,而是执行了我们自己定义的动作。-- 信号处理的自定义动作

对所有的[1, 31]信号捕捉后,是否这个进程就无法杀死了呢? -- 不会 kill -9 pid 会杀死任意进程

5. 阻塞信号

5.1 相关概念

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

5.2 在内核中的表示

block(block又称为信号屏蔽字 )中位置内容为1的信号不会被递达,除非阻塞解除。即使没有收到某一个信号,也可以将该信号设置为阻塞状态--设置block表。**若一个信号在产生前被设置为阻塞状态,当该信号产生后,不会被递达,直到阻塞解除。**下面为一个伪代码:

在内核中除了两个位图外还有:typedef void(*handler_t)(int signo); -- 函数指针
handler_t handler[32] -- 数组内容为指针,指向对每个信号的处理方法(函数)。

结论:

  • 如果一个信号没有产生,并不妨碍其先被阻塞
  • 进程通过三种结构的结合来识别信号
  • POSIX.1允许系统递送该信号一次 或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可 以依次放在一个队列里。

6. 信号捕捉

6.1 知识铺垫

用户态: 处于用户态的 CPU 只能访问受限资源 ,不能直接访问内存等硬件设备,不能直接访问内存等硬件设备,必须通过「系统调用」陷入到内核中,才能访问这些特权资源。

内核态: 处于内核态的 CPU 可以访问任意的数据,包括外围设备,比如网卡、硬盘等,处于内核态的 CPU 可以从一个程序切换到另外一个程序,并且占用 CPU 不会发生抢占情况,一般处于特权级 0 的状态我们称之为内核态

用户为了访问内核或者硬件资源,必须通过系统调用完成

用户无法以用户态的身份执行系统调用,那么执行系统调用的是进程,但是身份是内核态身份

(系统调用是比较费时的,应尽量避免频繁的系统调用)

CPU中存在指向页表的寄存器、指向task_struct的寄存器、CR3寄存器:表征当前进程的运行级别,0内核态,3用户态

看上图,OS中还有唯一的一个内核级页表,将不同进程的内核空间映射到物理内存的同一块区域,那么访问OS的接口,只需要在自己的地址空间进行跳转就可以了

6.2 信号捕捉流程

信号在产生时,不是被立即处理的,是从内核态返回用户态的时候进行处理的,那么是什么时候进入的内核态呢?-- 系统调用/进程切换

信号捕捉流程如下:

  • 进程由于中断/异常进入内核态返回用户态之前会检查当前进程PCB中的block、pending、handler表;
  • 先查看blockblock表 若blok为1无论是否产生信号都不处理,直接返回;为0,继续检查pending表,查看信号是否产生;
  • 若一个位置block为0,pending为1,则继续查handler表匹配的方法,执行对应的处理方法;
  • 若handler表中的方法是自定义方法 ,由于自定义方法处于用户态,此时进程还要通过特定的调用从内核态变为用户态执行对应的方法(注意这里是无法在内核态执行用户态代码的)
  • 执行完处理方法后,再返回内核态 (不能直接执行完处理方法后直接返回带用户态的代码处),继续返回到用户态执行到的对应的代码处

如果信号的处理方法 为自定义的那么一定涉及到四次状态的切换

6.3 sigset_t

每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号 的"有效"或"无效"状态,在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞,而在未决信号集中"有效"和"无效"的含义是该信号是否处于未决状态。

6.4 信号集操作函数

#include <signal.h>
int sigemptyset(sigset_t *set); // 初始化,将所有信号对应的比特位清0
int sigfillset(sigset_t *set); // 初始化,将所有信号对应的比特位置1
int sigaddset (sigset_t *set, int signo); // 将signo信号的比特位置为1
int sigdelset(sigset_t *set, int signo); // 将signo信号的比特位置0
int sigismember(const sigset_t *set, int signo); // 判断set中是否包含signo信号
int sigprocmask(int how, const sigset_t* set, sigset_t* oset);

参数:how:指示如何更改

SIG_BLOCK:mask = mask | set -- set包含了我们希望添加到当前信号屏 蔽字的信号

SIG_UNBLOCK:mask = mask & ~set -- set包含了我们希望从当前信号 屏蔽字中解除阻塞的信号

SIG_SETMASK:mask = set -- 设置当前信号屏蔽字为set所指向的值

set:按照how更改信号屏蔽字

oset:读取当前进程的信号屏蔽字

返回值:成功返回0,失败返回-1
int sigpending(sigset_t *set); // 获取当前进程的pending位图/未决信号集

成功返回0,失败返回-1

对上述接口使用的demo代码:

cpp 复制代码
#include <iostream>    
#include <unistd.h>    
#include <signal.h>    
#include <vector>    
    
#define MAXSIGNUM 31    
    
using namespace std;    
    
static vector<int> sigarr = {2};    
    
void printPending(sigset_t *pending)    
{    
    for(int i = MAXSIGNUM; i >= 1; i--)    
    {    
        if(sigismember(pending, i)) cout << "1";    
        else cout << "0";    
    }    
    cout << endl;    
}    
    
int main()    
{    
    // 1.先尝试屏蔽指定的信号    
    sigset_t block, oblock, pending;    
    // 1.1 初始化    
    sigemptyset(&block);  // 将位图结构中都置为0                                                                                                                                             
    sigemptyset(&oblock);  // 将位图结构中都置为0    
    sigemptyset(&pending);  // 将位图结构中都置为0    
    // 1.2 添加要屏蔽的信号    
    //for(const auto &e:sigarr)     
    sigaddset(&block, 2);    
    // 1.3 开始屏蔽    
    sigprocmask(SIG_SETMASK, &block, &oblock);    
    
    // 2.遍历打印pending的信号集    
    int cnt = 10;    
    while(1)  
    {    
        // 2.1 初始化pending信号集
        sigisemptyset(&pending);
        // 2.2 获取当前进程的未决信号集
        sigpending(&pending);
        // 2.3 打印
        printPending(&pending);

        sleep(1);
        if(cnt-- <= 0)
        {
            cout << "恢复对信号的屏蔽,不屏蔽任何信号\n" << endl;
            sigprocmask(SIG_SETMASK, &oblock, &block); // 一旦解除信号的屏蔽,且此时信号已经处于未决状态时,会立马把该信号递达,执行对应的处理动作
        }
    }


    return 0;
}

这段代码实现的功能是,阻塞2号信号(ctrl+c),打印pending表,在10s后解除对所有信号的屏蔽。

若在10s内收到2号信号时,不会直接处理,在10s后解除对2号信号的屏蔽后,执行2号信号的处理方法,默认为终止程序。结果如下:

6.5 sigaction

struct sigaction:

其中sigset_t sa_mask:当正在处理某种信号时,想顺便屏蔽其他信号,就可以添加到这个 sa_mask

int sigaction(int signo, const struct sigaction* act, struct sigaction* oact);

参数:signo:

act:输入性参数

oact: 输出型参数:获取对应信号旧的处理方法

返回值:成功返回0,失败返回-1

cpp 复制代码
#include <iostream>    
#include <signal.h>    
#include <cstdio>    
#include <unistd.h>    
    
using namespace std;    
    
void Count(int cnt)    
{    
    while(cnt)    
    {    
        printf("cnt: %2d\r", cnt--);    
        fflush(stdout);    
        sleep(1);    
    }    
    cout << endl;    
}    
    
void handler(int signo)    
{    
    cout << "正在处理" << signo << "号信号" << endl;                                                                                             
    Count(20);    
}    
    
int main()    
{    
    struct sigaction act, oact;    
    act.sa_handler = handler;    
    act.sa_flags = 0;    
    sigemptyset(&act.sa_mask);
    sigaddset(&act.sa_mask, 3);

    sigaction(SIGINT, &act, &oact);
    // sigaction(3, &act, &oact);
    while(1)
    {
        cout << "我是一个进程" << endl;
        sleep(1);
    }

    return 0;
}

上述代码想要实现的功能是,在处理2号信号期间同时屏蔽掉3号信号

当我们在递达一个信号期间,同类型的信号无法被递达。

当前信号正在被捕捉,系统会将当前信号加入到进程的信号屏蔽字block,完成捕捉动作,系统又会自动解除对该信号的屏蔽。一般一个信号被解除屏蔽时,如果该信号已经被pending,会自动递达当前信号
当某个信号的处理函数被调用时, 内核自动将当前信号加入进程的信号屏蔽字 , 当信号处理函数返回时自动恢复原来的信号屏蔽字, 这样就保证了在处理某个信号时 , 如果这种信号再次产生 , 那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时, 除了当前信号被自动屏蔽之外 , 还希望自动屏蔽另外一些信号 , 则用 sa_mask 字段说明这些需要额外屏蔽的信号, 当信号处理函数返回时自动恢复原来的信号屏蔽字

7. 可重入函数

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

(1).一般而言,认为main执行流和信号捕捉执行流是两个执行流

(2).如果在main中和在sighandler中,某函数被重复的进入,出问题--该函数为不可重入函数;若未出问题则为可重入函数

可重入/不可重入不是一个问题,也不需要解决。目前大部分情况都为不可重入函数

如果一个函数符合以下条件之一则是不可重入的:

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

8. 关键字volatile

关键字volatile:保持内存可见性,避免编译器优化导致的错误

cpp 复制代码
#include <stdio.h>                                                   
  #include <signal.h>    
      
volatile int quit = 0;    
      
  void handle(int signo)    
  {    
      printf("%d号信号,正在被捕捉!\n", signo);    
      printf("quit: %d", quit);    
      quit = 1;    
      printf(" -> quit: %d", quit);    
  }    
      
  int main()    
  {    
      signal(2, handle);    
      while (!quit)    
      {    
          printf("正在循环!\n");    
        sleep(1);    
      };    
      printf("注意,我是正常退出的!\n");    
      
      return 0;    
  }  
相关推荐
yaoxin52112341 分钟前
第二十七章 TCP 客户端 服务器通信 - 连接管理
服务器·网络·tcp/ip
内核程序员kevin43 分钟前
TCP Listen 队列详解与优化指南
linux·网络·tcp/ip
‘’林花谢了春红‘’3 小时前
C++ list (链表)容器
c++·链表·list
sinat_384241095 小时前
使用 npm 安装 Electron 作为开发依赖
服务器
机器视觉知识推荐、就业指导5 小时前
C++设计模式:建造者模式(Builder) 房屋建造案例
c++
朝九晚五ฺ5 小时前
【Linux探索学习】第十四弹——进程优先级:深入理解操作系统中的进程优先级
linux·运维·学习
自由的dream5 小时前
Linux的桌面
linux
xiaozhiwise6 小时前
Makefile 之 自动化变量
linux
Kkooe6 小时前
GitLab|数据迁移
运维·服务器·git