从我的博客之前的文章中,一直都在强调操作系统是由多进程协同工作而实现整个操作系统的逻辑目的,达到一个人为操纵的系统:

    之前的博文一直都在描述进程控制,进程之间的通信,还有线程控制与操作,但是却一直没有描述多个进程之间的相互是怎么通知整个系统的。对于多个不同进程之间,出现了异常或者进程间非数据传输的通信怎么实现,所以Linux系统就实现了一个概念叫做信号:

    信号就相当于我们现实生活之中的通知信息,去告知自己或者别人处于某种状态,是不是很人性化,信号的概念出现,解决了进程与进程通知:


那么关于信号,我们可以先来看一下在Linux下都有什么信号:wKioL1csV9WR7D7nAADWwA-3jew126.png


首先我们观察可以看到1-31号信号为普通信号,我们主要熟悉讨论的信号。32-33不占有信号表位,代表一个区分版本,34-64为实时信号。我们概不讨论,1-31信号我们可以在signal.h找到他的宏定义。数字代表信号编号,后面代表信号含义。


然后信号的具体含义就不过多解释,下面我们来了解一下产生信号的基本过程:

1. 用户在终端按下某些键时,终端驱动程序会发送信号给前台进程,例如Ctrl-C产生SIGINT信号,Ctrl-\产生SIGQUIT信号,Ctrl-Z产生SIGTSTP信号。

2. 硬件异常产生信号,这些条件由硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程再比如当前进程访问了非法内存地址,,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。

3. 一个进程调用kill(2)函数可以发送信号给另一个进程。 可以用kill(1)命令发送信号给某个进程,kill(1)命令也是调用kill(2)函数实现的,如果不明确指定信号则发送SIGTERM信号,该信号的默认处理动作是终止进程。 当内核检测到某种软件条件发生时也可以通过信号通知进程,例如闹钟超时产生SIGALRM信号,向读端已关闭的管道写数据时产生SIGPIPE信号。 如果不想按默认动作处理信号,用户程序可以调用sigaction(2)函数告诉内核如何处理某种信号。

(sigaction函数稍后详细介绍),可选的处理动作有以下三种:

1. 忽略此信号。

2. 执行该信号的默认处理动作。

3. 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。


信号的产生方式:

  1. 通过终端产生信号:

    通过键盘命令产生信号,Ctrl-C产生SIGINT信号,Ctrl-\产生SIGQUIT信号,Ctrl-Z产生SIGTSTP信号(可使前台进程作业,转到后台作业)。

  2. 调用系统函数向进程发送信号:

   在shell命令行中输入:  

kill -signnumber pid;

通过命令行可以杀死进程,不论是前台进程还是后台进程都可以进行立即处理。

其中kill命令是调用kill函数来实现的;

#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函数总是会成功的,所以没有返回值。


3.由软件条件产生的信号:

    其中SIGPIPE是一种由软条件产生的信号,在进程管道那一篇博文中已经讲过。为下面做铺垫,我们就说一下alarm函数和SIGALRM信号,

 

    调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。

代码:

#include<stdio.h>
#include<unistd.h>

int main()
{
	int ret = 9;
	ret = alarm(10);
	printf("alarm 1:%d\n",ret);
	sleep(3);
	ret = alarm(10);
	printf("alarm 2:%d\n",ret);
	return 0;
}

运行结果:
wKioL1ctqErgQrDWAAATJicmLLc308.png



阻塞信号:

A.信号在内核中的表示:

    信号的存在有3种情况:

    1.递达:实际执行信号的处理动作;

    2.未决:产生到递达之间的状态称为信号未决,要配合阻塞才能够产生未决状态。

    3.阻塞:进程选择阻塞等待的信号,不让其执行。

内核中的信号表示:

wKiom1ctqMDAQdiEAAA6qNpLF0g861.png

在PCB中存储着信号相关的表值,都用的是二进制位来表示,每个信号都有两个标志位分别表示阻塞(block)和未决(pending),然后还有一个函数表示这个信号的处理动作。


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

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

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

    其中对于以上存储的block表或者pending表。不论信号产生多少次,都只是存在于不存在的关系。未决指的是信号存在与否,阻塞是对信号的拦截。    

    所以对于阻塞表与未决表,都可以用sigset_t来进行存储,sigset_t称为信号集。

所以针对当前的环境,是如何对当前的阻塞/未决信号集进行操作呢?

信号集的操作函数:

    sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这 些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是有意义的。相当于系统内部的识别处理我们是无法从数据的表面观察出来的。

#include <signal.h>
int sigemptyset(sigset_t *set);    //初始化set信号集,全为0,不包含任何有效信号
int sigfillset(sigset_t *set);    //初始化set信号集,使其中所有信号的对应bit置位,
                                //表示该信号集的有效信号包括系统支持的所有信号。
int sigaddset(sigset_t *set, int signo); //向信号集中添加信号,成功返回0,失败返回-1
int sigdelset(sigset_t *set, int signo);    //向信号集中删除信号,返回值同上
int sigismember(const sigset_t *set, int signo);    //查询信号集中是否存在该信号,
                                                    //包含1,不包含0,出错-1。

    以上都是针对于sigset_t的信号集进行操作,但是在操作系统中,对于环境面对的信号集,只有阻塞表示允许修改的,那么以上只针对信号集进行操作,但是没有应用到我们的环境中,下面函数就是将信号集运用到阻塞表中:

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);    //成功返回0,出错-1

    oset是当前的信号集传出,set是当前信号集根据set进行改变。

如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中 一个信号递达。

how:

wKioL1cuzE6S3Uj1AABwipGhPuk130.png


    那么对于阻塞表来说我们进行的是修改操作,未决表示针对于我们信号还没有递达之前被阻塞的信号集,所以对于未决表,我们能够进行的操作就是读取。

#include <signal.h>
int sigpending(sigset_t *set);    //读取当前进程的未决信号集,成功返回0,出错-1

    好了,这就是关于信号传送转台,那么,对于信号而言,上面说了信号的处理方式,1.默认,2.忽略,3捕捉。下面我们就来说一下信号捕捉,也就是信号的操作自定义。


信号捕捉:

    其实对于信号而言,操作系统对于信号的处理过程实际上是这样的:

wKioL1cuzlWw2c9PAAFGOfuxbT8351.png

下面利用alarm函数写一个自己的mysleep,然后进行分析一下:

#include <stdio.h>
#include <signal.h>
void handler(int sig)
{
    printf("i get a sig %d\n", sig);
}
 
void mysleep(unsigned int time)
{
    struct sigaction new, old;
    new.sa_handler = handler;
    sigemptyset(&new.sa_mask);
    new.sa_flags = 0;
    sigaction(SIGALRM, &new, &old);//注册信号处理函数,捕捉SIGALRM信号
    alarm(time);//设定闹钟为自定义时间
    pause();//将进程挂起直到闹钟结束向进程发送SIGALRM信号
    alarm(0);//撤销闹钟
    sigaction(SIGALRM, &old, NULL);//恢复对信号的默认处理动作
}
 
int main()
{
    while(1)
    {   
        mysleep(5);//实现mysleep
        printf("hello world...\n");
    }   
 
    return 0;
}

根据上面的函数,我们来说一下关于信号捕捉的相关函数:

#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结构体:

wKiom1cuzqWDOn1ZAABoVr9jTos012.png

    将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。

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

sa_flags字段包含一些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数。

#include <unistd.h>
int pause(void);

    pause函数使调用进程挂起直到有信号递达。如果信号的处理动作是终止进程,则进程止,pause函数没有机会返回;如果信号的处理动作是忽略,则进程继续处于挂起状态,pause不返回;如果信号的处理动作是捕捉,则调用了信号处理函数之后pause返回-1,errno设置为EINTR, 所以pause只有出错的返回值错误码EINTR表示“被信号中断”。


下面是关于信号捕捉的过程分析,一定要清楚并理解:

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信号以前的处理动作。



关于信号就先说这么多,后面会补一篇博客书写信号的一些细节。