一.基础知识

  1. 信号产生的条件

    a. 终端按键产生。如:ctrl+c(SIGINT信号),ctrl+\(SIGQUIT信号),ctrl+z(SIGTSTP信号)......

    b. 系统命令和函数。如:kill(2)函数,raise函数,abort函数(SIGABRT信号)(就像exit函数一样,abort函数总是会成功的,所以没有返回值)。

    c.软硬件产生。如:当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程;闹钟超时产生SIGALRM信号;向读端已关闭的管道写数据时产生SIGPIPE信号; 若不想按默认动作处理信号,可调用sigaction(2)函数告诉内核如何处理某种信号......


  2. 信号处理动作

    a.忽略

    b.执行默认动作

    c.自定义动作(捕捉信号)


  3. 阻塞信号

    a.信号递达(Delivery):实际执行信号的处理动作。

    b.信号未决(Pending):信号从产生到递达之间的状态。

    c.信号阻塞: 进程可以选择阻塞(Block)某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。


    注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略表示信号已经递达,因为忽略是在递达之后可选的一种处理动作。


  4. 信号在内核中的表示可以看作是这样的:

    每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。

wKioL1csowuiT5DTAABozGrKu_Y723.png

(1). SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。

(2). SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除  阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。

(3). SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。


在进程解决对某信号的阻塞状态前,信号可能过多次,Linux是这样实现的:常规信号是采用只记录一次,而实时信号将这些信号保存在一个队列中。


问:如果在进程解除对某信号的阻塞之前这种信号产生过多次,如何处理?

(1)POSIX.1允许系统递送该信号一次或多次。

(2)Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。从上图来看,每个信号只有一 个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。未决和阻塞标志可以用sigset_t(信号集)数据类型来存储。


5.函数

wKioL1cstA6SgzB0AAAs1WkMDC0251.pngwKiom1cstVmC-mlAAAAdQrCBobQ026.png 

wKiom1cstliTyK5RAAAShvHOAsM954.png

 1)sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何  有效信号。

 2)sigfillset初始化set所指向的信号集。

 在使用sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定  的状态。

 3)sigaddset和sigdelset在该信号集中添加或删除某种有效信号。

 以上4个函数成功返回0,出错返回-1。

 4)sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返    回1,不包含则返回0,出错返回-1。

 5)sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。成功返回0,出错回-1。

 6)sigpending读取当前进程的未决信号集。调用成功则返回0,出错则返回-1。


6.代码验证

  1 #include<stdio.h>
  2 #include<signal.h>
  3 void printsigset(sigset_t* set)
  4 {
  5     int i=1;
  6     for(;i<32;i++)
  7     {
  8         if(sigismember(set,i))
  9         {
 10             putchar('1');
 11         }
 12         else
 13         {
 14             putchar('0');
 15         }
 16     }
 17     printf("\n");     //效果等价于puts("");      puts()函数用来向标准输出设备(屏幕)写字符串并换行。
 18 }
 19 int main()
 20 {
 21     sigset_t s,p;
 22     sigemptyset(&s);
 23     sigaddset(&s,SIGINT);
 24     sigprocmask(SIG_BLOCK,&s,NULL);
 25     while(1)
 26     {
 27         sigpending(&p);
 28         printsigset(&p);
 29         sleep(1);
 30     }
 31     return 0;
 32 }

输出结果:

wKiom1cvDBKj-9tcAAAi2tW4eVo149.png

结果分析:

  每秒钟把各信号的未决状态打印一遍,由于我们阻塞了SIGINT信号,按Ctrl-C将会使SIGINT信号处于未决状态,按Ctrl-\,因为SIGQUIT信号没有阻塞,仍然可以终止程序。



二.捕捉

1.内核如何实现信号的捕捉

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:

(1). 用户程序注册了SIGQUIT信号的处理函数sighandler。

(2). 当前正在执行main函数,这时发生中断或异常切换到内核态。

(3). 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。

(4). 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。

(5). sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态

(6). 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

就像是倒写的8,如图

wKioL1cvGcCTVBgIAAIeykPt6Wg793.png


2.函数

wKioL1cvJUCQBcRpAAAeUieD8uQ872.png

wKioL1cvM8uhq1e8AAAgZn9kW64766.png

(1)a.sigaction函数可以读取和修改与指定信号相关联的处理动作;

   b.成功返回0,出错返回- 1。

(2)a.signum是指定信号的编号;

   b.若act指针非空,则根据act修改该信号的处理动作;

   c.若oldact指针非空,则通过oldact传出该信号原来的处理动作;

   d.act和oldact指向sigaction结构体。

(3)a.将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号;

   b.赋值为常数SIG_DFL表示执行系统默认动作;

   c.赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数。(该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。)

wKiom1cvKbbgPcnXAAAQgBlvq7k595.png

  (1)pause函数使调用进程挂起直到有信号递达。

  (2)如果信号的处理动作是终止进程,则进程止,pause函数没有机会返回;

  (3)如果信号的处理动作是忽略,则进程继续处于挂起状态,pause不返回;

  (4)如果信号的处理动作是捕捉,则调用了信号处理函数之后pause返回-1,errno设置为EINTR, 所以pause只有出错的返回值。(错误码EINTR表示“被信号中断”)。


3.代码实现

  1 #include<stdio.h>
  2 #include<unistd.h>
  3 #include<signal.h>
  4 
  5 void sig_alrm(int signum)
  6 {}
  7 
  8 unsigned int mysleep(unsigned int nsecs)
  9 {   
 10     struct sigaction new,old;
 11     unsigned int unslept=0;
 12     new.sa_handler=sig_alrm;
 13     sigemptyset(&new.sa_mask);
 14     new.sa_flags=0;
 15     
 16     sigaction(SIGALRM,&new,&old);
 17     alarm(nsecs);
 18     pause();
 19     unslept=alarm(0);
 20     sigaction(SIGALRM,&old,NULL);
 21     return unslept;
 22 }
 23 int main()
 24 {
 25     while(1)
 26     {
 27         mysleep(3);
 28         printf("3 seconds passed!\n");
 29     }
 30     return 0;
 31 }

输出结果:

wKioL1cvOeSySXqqAAAlj7AKyKQ283.png

结果分析:

(1). main函数调用mysleep函数,后者调用sigaction注册了SIGALRM信号的处理函数sig_alrm。

(2). 调用alarm(nsecs)设定闹钟。

(3). 调用pause等待,内核切换到别的进程运行。

(4). nsecs秒之后,闹钟超时,内核发SIGALRM给这个进程。

(5). 从内核态返回此进程的用户态之前处理未决信号,发现有SIGALRM信号,其处理函数是sig_alrm。

(6). 切换到用户态执行sig_alrm函数,进入sig_alrm函数时SIGALRM信号被自动屏蔽, 从sig_alrm函数返回时SIGALRM信号自动解除屏蔽。然后自动执行系统调用sigreturn再次进入内核,再返回用户态继续执行进程的主控制流程(main函数调用的mysleep函数)。

(7). pause函数返回-1,然后调用alarm(0)取消闹钟,调用sigaction恢复SIGALRM信号以前处理动作。


三.补充

 1.sig_atomic_t类型与volatile限定符

(1)sig_atomic_t类型

例:对全局数据的访问只有一行代码,是不是原子操作呢?查看汇编

wKioL1cwK92hJbHlAABKNGIT8e0130.png

  虽然C代码只有一行,但是在32位机上对一个64位的long long变量赋值需要两条指令完成,因此不是原子操作。同样地,读取这个变量到寄存器需要两个32位寄存器才放得下,也需要两条指令,不是原子操作。若main和sighandler都对这个变量a赋值,最后变量a的值可能会发生错乱。

 

  如果在程序中需要使用一个变量,要保证对它的读写都是原子操作,C标准定义了一个类型sig_atomic_t,在不同平台的C语言库中取不同的类型,例如在32位机 上定义sig_atomic_t为int类型。


(2)sig_atomic_t类型  -> volatile限定符

wKiom1cwLGyy_yKTAABdKT9h8Rs214.png

  在main函数中首先要注册某个信号的处理函 数sighandler,然后在一个while死循环中等待信号发生,如果有信号递达则执行sighandler, 在sighandler中将a改为1,这样再次回到main函数时就可以退出while循环,执行后续处理。用上面的方法编译和反汇编这个程序,在main函数的指令中有:

wKioL1cwLmzgrLUUAABbzECWzCU605.png

  将全局变量a从内存读到eax寄存器,对eax和eax做AND运算,若结果为0则跳回循环开头,再次从内存读变量a的值,可见这三条指令等价于C代码的while(!a);循环。但编译器会优化,如下:

wKioL1cwLxjg1ZktAABQGN0Qrl4257.png

  优化之后省去了每次循环读内存的操作。第一条指令将全局变量a的内存单元直接和0比较,如果相等,则第二条指令成了一个死循环,注意,这是一个真正的死循环:即使sighandler将a改为1,只要没有影响Zero标志位,回到main函数后仍然死在第二条指令上,因为不会再次从内存读取变量a的值。

  

  编译器无法识别程序中存在多个执行流程。之所以程序中存在多个执行流程,是因为调用了特定平台上的特定库函数,比如sigaction、pthread_create,这些不是C语言本 身的规范,不归编译器管,程序员应该自己处理这些问题。C语言提供了volatile限定符,如果将 上述变量定义为volatile sig_atomic_t a=0;那么即使指定了优化选项,编译器也不会优化掉对变 量a内存单元的读写。


(3)必须使用volatile限定符

  a.程序中存在多个执行流程访问同一全局变量

  b.变量的内存单元中的数据不需要写操作就可以自己发生变化,每次读上来的值都可能不一样

  c.即使多次向变量的内存单元中写数据,只写不读,也并不是在做无用功,而是有特殊意义的什么样的内存单元会具有这样的特性呢?肯定不是普通的内存,而是映射到内存地址空间的硬件寄存器,例如串口的接收寄存器属于上述第一种情况,而发送寄存器属于上述第二种情况。sig_atomic_t类型的变量应该总是加上volatile限定符,因为要使用sig_atomic_t类型的理由也正 是要加volatile限定符的理由。


2.竞态条件 

(1)竞态条件(Race Condition): 如果我们写程序时考虑不周密,就可能由于时序问题而导致错误。

(2)sigsuspend:包含了pause的挂起等待功能,同时解决了竞态条件的问题,在对时序要求严格的场合下,应调用sigsuspend而不是pause。

wKioL1cwOPKTm2DoAAA3MvutlBI158.png

  a.sigsuspend没有成功返回值,只有执行了一个信号处理函数之后sigsuspend才返回,返回值为-1,errno设置为EINTR。

  b.调用sigsuspend时,进程的信号屏蔽字由sigmask参数指定,可以通过指定sigmask来临时解除 对某 个信号的屏蔽,然后挂起等待,当sigsuspend返回时,进程的信号屏蔽字恢复为原来的值,如果原来对该信号是屏蔽的,从sigsuspend返回后仍然是屏蔽的。


3.代码实现(用sigsuspend重新实现my_sleep函数)

  1 #include<stdio.h>
  2 #include<signal.h>
  3 #include<unistd.h>
  4 void sig_alrm(int signum)
  5 {}
  6 unsigned int my_sleep(unsigned int nsecs)
  7 {
  8     struct sigaction new,old;
  9     sigset_t newmask,oldmask,suspmask;
 10     unsigned int unslept;
 11 
 12     new.sa_handler=sig_alrm;
 13     sigemptyset(&new.sa_mask);
 14     new.sa_flags=0;
 15     sigaction(SIGALRM,&new,&old);
 16 
 17     sigemptyset(&newmask);
 18     sigaddset(&newmask,SIGALRM);
 19     sigprocmask(SIG_BLOCK,&newmask,&oldmask);
 20 
 21     alarm(nsecs);
 22 
 23     suspmask=oldmask;
 24     sigsuspend(&suspmask);
 25 
 26     unslept=alarm(0);
 27     sigaction(SIGALRM,&old,NULL);
 28 
 29     sigprocmask(SIG_SETMASK,&oldmask,NULL);
 30     return unslept;
 31 }
 32 int main()
 33 {
 34     my_sleep(3);
 35     printf("3 seconds passed!\n");
 36     return 0;
 37 }

输出结果:

wKioL1cwcF2DLnC8AAActgn1uC8651.png


(3)SIGCHLD信号

子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。


3.可重入函数(Reentrant)

(1)重入:函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入。

(2)定义:函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入函数。

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

  • 调用了malloc或free,因为malloc也是用全局链表来管理堆的。

  • 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

  • SUS规定有些系统函数必须以线程安全的方式实现