信号
一、信号的产生:
1.用户在终端按下某些键时,终端驱动程序会发送信号给前台进程
例如:
Ctrl-C产生SIGINT信号
Ctrl-\产生SIGQUIT信号
Ctrl-Z产生SIGTSTP信号
2.硬件异常产生信号,这些条件由硬件检测到并通知内核,然后内核向当前进程发送适当的信号。
例如:当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给程。
再比如当前进程访问了非法内存地址,,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
3.由软件条件产生信号
(1)SIGPIPE是一种由软件条件产生的信号
(2)alarm函数 和SIGALRM信号。
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发 SIGALRM信号, 该信号的默认处理动作是终止当前进程。这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。
4.调用相关的函数来向进程发送信号;
例如:
kill命令是调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。
raise函数可以给当前进程发送指定的信号(自己给自己发信号)。
#include <signal.h>
int kill(pid_t pid, int signo);
int raise(int signo);
这两个函数都是成功返回0,错误返回-1
abort函数使当前进程接收到SIGABRT信号⽽而异常终止。
#include <stdlib.h>
void abort(void);
就像exit函数一样,abort函数总是会成功的,所以没有返回值。
(kill -l 可以察看系统定义的信号列表)
二、当进程接收到信号后可选的处理动作有三种:
1.忽略此信号。
2.执行该信号的默认处理动作(大部分默认的动作是终止进程)。
3.提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。(下文有详细讲解)
三、阻塞信号
1.信号在内核中的表示:
实际执行信号的处理动作称为信号递达(Delivery),信号从产生到递达之间的状态,称为信号未决(Pending)。进程可以选择阻塞 (Block )某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞, 才执行递达的动作。
信号在内核中的表⽰示⽰示意图:
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产⽣生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
在上图的例子中,
1. SIGHUP信号未阻塞也未产生过,当它递达时执⾏行默认处理动作。
2. SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
3. SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?
POSIX.1允许系统递送该信号一次或多次。
Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在⼀一个队列里。从上图来看,每个信号只有一 个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集, 这个类型可以表⽰示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该 信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask), 这⾥里的“屏蔽”应该理解为阻塞而不是忽略。
2. 信号集操作函数
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,这些bit依赖于系统实现,使用者只能调用以下函数来操作 sigset_t变量,而不应该对它的内部数据做任何解释,比如:利用位运算来获取未决信号集中的bit位信息,用printf直接打印sigset_t变量是没 有意义的。
信号集操作函数:
#include <signal.h>(前四个成功返回0,出错返回-1)
(1)int sigemptyset(sigset_t *set);\u2028 //初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集 不包含任何有效信号。
(2)int sigfillset(sigset_t *set);\u2028 //初始化set所指向的信号集,使其中所有信号的对 应bit置位,表示 该信号集的有效信号包括系统支持的所有信号。
//注意:在使用sigset_t类型 的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。
(3)int sigaddset(sigset_t *set, int signo);\u2028 //添加某种有效信号
(4)int sigdelset(sigset_t *set, int signo);\u2028 //删除某种有效信号
(5)int sigismember(const sigset_t *set, int signo); //判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0, 出错返回-1。
3.屏蔽字(阻塞信号集)操作函数
#include<signal.h>
int sigprocmask(int how, const sigset_t* set, sigset_t* oset);
返回值:若成功则为0,若出错则为-1
参数how指示如何更改(假设当前的信号屏蔽字为mask)
如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
4.读取当前进程中的未决信号集
#include <signal.h>
int sigpending(sigset_t *set);
sigpending读取当前进程的未决信号集,通过set参数传出。
调⽤用成功则返回0,出错则返回 -1。
void catch ( int i)
{
printf( "catch %d SIG \n" ,i);
}
void printsigset(sigset_t* p)
{
int i;
for (i=1;i<32;++i)
{
if (sigismember(p,i))
{
printf( "1" );
}
else
{
printf( "0" );
}
}
printf( "\n" );
}
int main()
{
int i;
sigset_t n,o,t;
sigemptyset(&n); //初始化信号集
sigemptyset(&o);
for (i=1;i<32;++i) //将31个常规信号设置为阻塞状态
{
signal(i, catch );
sigaddset(&n,i); //添加信号
sigprocmask(SIG_BLOCK,&n,NULL); //设置阻塞字
}
int j=0;
while (1)
{
sigpending(&t);
printsigset(&t);
usleep(500000);
if (j < 32)
{
kill(getpid(),j); //给当前进程发送 i 号信号
j++;
if (j == 9 )
j++;
if (j == 19)
j++;
}
else if (j++ == 40)
break ;
}
for (i=1;i<32;++i) //将31个常规信号设置为阻塞状态
{
sigaddset(&o,i);
sigprocmask(SIG_UNBLOCK,&o,NULL); //设置阻塞字
printf( "释放%d 号信号\n" ,i);
usleep(500000);
}
}
四、捕捉信号
1.内核实现信号的捕捉
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程⽐比较复杂,举例如下:
1. 用户程序注册了SIGQUIT信号的处理函数sighandler。
2. 当前正在执行main函数,这时发生中断或异常切换到内核态。
3. 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。
4. 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系, 是两个独立的控制流程。
5. sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。
6. 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
捕捉的过程(∞):
信号注册是使用的函数
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则 返回- 1。
signo是指定信号的编号。
若act指针非空,则根据act修改该信号的处理动作。
若oact指针非 空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结 构体:
struct sigaction {
void (*sa_handler)(int);
//将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,
赋值为常数SIG_DFL 表示执行系统默认动作,赋值为一个函数指针表⽰示⽤用⾃自定义函数捕捉信号
void (*sa_sigaction)(int, siginfo_t *, void *);
//sa_sigaction是实时信号的处理函数
sigset_t sa_mask;
//sa_mask字段说明这些需要额外屏蔽的信号,
int sa_flags;
//默认为0
}
void catch ( int sig)
{
//
}
int my_sleep(int time)
{
struct sigaction new ,old;
new .sa_handler = catch ; //初始化 sigaction 结构体
sigemptyset(& new .sa_mask);
new .sa_flags = 0;
sigaction(SIGALRM,& new ,&old); //注册函数
alarm(time);
pause(); //挂起进程函数
sigaction(SIGALRM,&old,NULL);
alarm(0);
}
1. 调用sigaction注册了SIGALRM信号的处理函数 sig_alrm。
2. 调用alarm(time)设定闹钟。
3. 调用pause等待,内核切换到别的进程运行。
4. time秒之后,闹钟超时,内核发SIGALRM给这个进程。
5. 从内核态返回这个进程的用户态之前处理未决信号,发现有SIGALRM信号,其处理函数是catch。
6. 切换到用户态执行catch函数,进入sig_alrm函数时SIGALRM信号被自动屏蔽,从sig_alrm函数返回时SIGALRM信号自动解除屏蔽。然后自动执行系统调用 sigreturn再次进入内核,再返回用户态继续执行进程的主控制流程(main函数调用 的mysleep函数)。
7. pause函数返回-1,然后调用alarm(0)取消闹钟,调用sigaction恢复SIGALRM信号 以前的处理 动作。
三个问题:
1、信号处理函数catch什么都没干,为什么还要注册它作为SIGALRM的处理函数?不注册信号处理函数可以吗? (SIGALRM会终止进程?)
pause函数使调用进程挂起直到有信号递达,如果不注册SIGALRM处理函数,当有信号SIGALRM信号产生时会执行默认动作,终止进程,达不到sleep(1)的效果了;
2、为什么在mysleep函数返回前要恢复SIGALRM信号原来的sigaction? (想想不恢复 会怎样?)
因为要模仿sleep函数,sleep函数在sleep(time)之后不会对SIGALRM 信号进行修改的,将SIGALRM 不恢复会使alarm()失效;
3、mysleep函数的返回值表示什么含义?什么情况下返回非0值?
mysleep的返回值是在信号SIGALRM信号传来时闹钟还剩余的秒数;当闹钟结束前有其他信号发送给该进程,并该进程对其进行了相关的处理时,alarm(0)取消闹钟会使返回值非零;
五、可重入函数
当捕捉到信号时,不论进程的主控制流程当前执行到哪⼉儿,都会先跳到信号处理函数中执行,从信号处理函数返回后再继续执行主控制流程。信号处理函数是一个单独的控制流程,因为它 和主控制流程是异步的,二者不存在调用和被调用的关系,并且使用不同的堆栈空间。引入了 信号处理函数使得一个进程具有多个控制流程,如果这些控制流程访问相同的全局资源(全局 变量、硬件资源等),就有可能出现冲突,如下面的例子所示。 如下是不可重入的函数:
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完 第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于 是切换 到sighandler函数,sighandler也调⽤用insert函数向同一个链表head中插入节 点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从 main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二 步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次 进入该函 数,这称为重入,insert函数访问一个全局链表,有可能因为重入⽽而造成错乱,像这样 的函数称为 不可重⼊入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入 (Reentrant) 函数。
不可重入的函数的条件:
调用了malloc或free,因为malloc也是用全局链表来管理堆的。
调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
六、sig_atomic_t类型与volatile限定符
1.C标准定义了⼀一个类型sig_atomic_t,在不同平台的C语⾔言库中取不同的类型,例如在32 位机 上定义sig_atomic_t为int类型。
2.编译器在进行词法语法分析时如果判断出某个变量使用频繁,并且不被更改时会对这个变量进行相关的优化,变为寄存器变量,优化之后省去了每次循环读内存的操作,效率⾮非常⾼高。
编译器优化的缺点:编译器只是对代码到二进制的转换,对逻辑的正确性不能做出判断,并且编译器无法识别程序中存在多个执行流程。
例如:下图中(编译器将 a 优化为寄存器变量),信号处理函数中对全局变量中的a的修改(内存中)不能在主执行流中(寄存器中)体现,这就造成了逻辑的错误!
3.C语⾔言提供了volatile限定符,如果将 上述变量定义为volatile sig_atomic_t a=0;那么即使指定了优化选项,编译器也不会优化掉对变 量a内存单元的读写。 对于程序中存在多个执⾏行流程访问同一全局变量的情况,volatile限定符是必要的,
此外,虽 然程 序只有单一的执⾏行流程,但是变量属于以下情况之一的,也需要volatile限定:
1. 变量的内存单元中的数据不需要写操作就可以⾃己发生变化,每次读上来的值都可能不一 样
2. 即使多次向变量的内存单元中写数据,只写不读,也并不是在无用功,而是有特殊意义的 什么样的内存单元会具有这样的特性呢?肯定不是普通的内存,而是映射到内存地址空间的硬 件寄存器,例如串⼜口的接收寄存器属于上述第一种情况,而发送寄存器属于上述第二种情况。
sig_atomic_t类型的变量应该总是加上volatile限定符,因为要使用sig_atomic_t类 型的理由也正 是要加volatile限定符的理由。
七、竞态条件与sigsuspend函数
现在重新审视“mysleep”程序,
设想这样的时序:
1. 注册SIGALRM信号的处理函数。
2. 调⽤用alarm(nsecs)设定闹钟。
3. 内核调度优先级更⾼高的进程取代当前进程执⾏行,并且优先级更⾼高的进程有很多个,每个都要执行很长时间
4. nsecs秒钟之后闹钟超时了,内核发送SIGALRM信号给这个进程,处于未决状态。
5. 优先级更高的进程执行完了,内核要调度回这个进程执行。SIGALRM信号递达,执行处理函 数sig_alrm之后再次进入内核。
6. 返回这个进程的主控制流程,alarm(nsecs)返回,调用pause()挂起等待。
7. 可是SIGALRM信号已经处理完了,还等待什么呢?
出现这个问题的根本原因是系统运行的时序(Timing)并不像我们写程序时所设想的那样。 虽然alarm(nsecs)紧接着的下一行就是pause(),但是无法保证pause()一定会在调用 alarm(nsecs)之 后的nsecs秒之内被调⽤用。由于异步事件在任何时候都有可能发生(这里 的异步事件指出现更高优 先级的进程),如果我们写程序时考虑不周密,就可能由于时序问题 而导致错误,这叫做竞态条件 (Race Condition)。
sigsuspend包含了pause的挂起等待功能,同时解决了竞态条件的问题
#include <signal.h>
int sigsuspend(const sigset_t *sigmask);
和pause一样,sigsuspend没有成功返回值,只有执行了一个信号处理函数之后 sigsuspend才返回,返回值为-1,errno设置为EINTR
调用sigsuspend时,进程的信号屏蔽字由sigmask参数指定,可以通过指定sigmask来临时 解除对某 个信号的屏蔽,然后挂起等待,当sigsuspend返回时,进程的信号屏蔽字恢复为原来的值,如果原来对该信号是屏蔽的,从sigsuspend返回后仍然是屏蔽的。