【Linux】Linux的信号

版权声明:本文为博主原创文章,允许转载,但希望标注转载来源。 https://blog.csdn.net/qq_38410730/article/details/81609452

Linux的信号是一种系统或进程发出的通知,它的主要作用是用来激活信号接收者的一段程序,除此之外,也可以携带少量信息。从实现方式上来看,它是一种用软件构建的中断系统,只不过接收及处理中断请求的不是处理器而是进程。与外设向处理器的中断请求一样,它是一种异步通信方式。

基本概念

计算机系统必须创建某种机制,要使发生事件的实体能在事件发生时将这个事件发送出去,同时还要使希望感知这个事件的实体能够接收到这个事件,并做出下一步的行为。也就是说,信号就是一个携带少量信息的通知。

系统中的很多事件都可以产生一个信号。Linux为系统中可能产生的信号都进行了命名和编号,并为接收信号的进程提供了默认服务,即每个信号都附带一个信号默认服务程序。也就是说,进程在接收到某个信号时会执行另一个服务程序,如果进程没有提供该服务程序,将会执行一个系统提供的默认服务程序。

系统常用的信号和默认服务有:

  • 当用户按某些终端键时会产生信号。如:DELETE键;
  • 硬件异常产生的信号。例如:除数为0、无效的存储访问等等;
  • 进程使用函数kill(2)可将信号发送给另一个进程或进程组。自然,有些限制:接收信号的进程和发送信号的进程的所有者必须相同,或发送信号进程所有者必须为超级用户;
  • 用户可用命令kill(1)将信号发送给其它进程。常用于中止一个失控的后台进程;
  • 当检测到某种软件条件已经发生,并将其通知有关进程时也产生信号。这里指的不是硬件条件(如被0除),而是软件条件,例如在网络连接上传来非规定波特率的数据。

Linux部分信号的名称、编号、用途及默认服务见下表:

Linux部分信号名称、编号、默认服务
信号名 编号 默认服务 用途
SIGHUP 1 进程中止 控制TTY断开连接
SIGINT 2 进程中止 用户在键盘上按下Ctrl+C
SIGQUIT 3 进程中止,内存转储 TTY键盘上按下了Ctrl+\
SIGILL 4 进程中止,内存转储 非法指令
SIGABRT 6 进程中止,内存转储 异常终止abort()
SIGFPE 8 进程中止,内存转储 浮点异常
SIGKILL 9 进程中止 中止信号
SIGSEGV 11 进程中止,内存转储 非法内存访问
... ... ... ...

从接收信号进程的角度来看,信号的产生是随机的,它实质上是向进程发出的中断请求,即信号的到来意味着进程要中断现行工作去执行信号服务程序。所以人们也将信号机制叫做“软中断”,只不过这不是向处理器而是向进程请求的中断,如下图所示:

具体来说,进程接收到某个信号之后,可以有如下三种反应:

  • 忽略信号。大多数信号都可使用这种方式进行处理,但有两种信号却不能被忽略:SIGKILL和SIGSTOP,因为它们为超级用户提供了一种使进程中止或停止的可靠方法。另外,如果忽略某些由硬件异常产生的信号(例如非法存储访问或除以0),则进程的行为是未定义的;
  • 捕捉信号。为了做到这一点,进程必须为该信号提供一个用户信号处理程序;
  • 执行默认操作。对于大多数信号而言,系统默认的操作是中止该进程。

信号的发送

系统中,中断、异常服务程序及进程都可以调用发送信号函数来发送信号。用来发送信号的主要函数有kill()、raise()、sigqueue()、alarm()、setitimer()及abort()等。

下面以函数kill()和alarm()为例简单介绍发送信号函数的作用。

函数kill()的原型如下:

int kill(pid_t pid, int signo);

其中,参数pid为进程标识符ID。根据pid的值,函数kill()有如下四种不同的操作:

  • pid>0,将信号发送给进程ID为pid的进程;
  • pid==0,将信号发送给ID与发送进程所在进程组ID相等的进程组;
  • pid<0,将信号发送给一个进程组,该进程组的ID等于pid绝对值;
  • pid==-1,未定义此等情况。

函数alarm()的原型如下:

unsigned int alarm(unsigned seconds);

其中,参数seconds的值是秒数,经过了指定的seconds秒后会产生信号SIGALRM。

函数alarm()的功能是为一个“闹钟”设定一个定时时间,当时间值到达或超过时间设定值时,会产生SIGALRM信号。如果不忽略或不捕捉此信号,则其默认动作时中止该进程。

函数alarm()的返回值为0或以前设置的闹钟时间的剩余时间数(秒)。

信号的安装

如果进程要处理某一信号,那么就要在进程中安装该信号。所谓信号的安装,就是把信号编号及其对应的处理函数加入到进程控制块中。

Linux用来安装信号的函数有两个:signal()和sigaction()。其中,signal()是在系统调用sys_signal()基础上实现的库函数,不支持信号传递信息;sigaction()是在系统调用sys_sigaction()实现的库函数,它与sigqueue()系统调用配合,允许信号传递附加信息。

函数sigaction()的原型如下:

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

其中,参数signo是信号的编号;参数指针act为一个包含要安装的信号处理程序的sigaction结构;参数指针oact,则返回该信号的原有包含了信号处理程序的sigaction结构。

当函数调用成功后,返回0,否则返回-1。

函数中所使用的sigaction结构如下:

struct sigaction {
	__sighandler_t	sa_handler;        //信号处理函数指针
	unsigned long	sa_flags;            //信号处理函数行为方式标志
	sigset_t	sa_mask;	/* 屏蔽位图 */
};

这个结构可以看作是带有管理信息的信号处理函数指针。

域sa_handler是一个函数指针,它指向的函数就是对应信号的服务程序,如果用户设计了进程的信号处理函数,那么就应该把这个处理函数与这个指针关联起来,这样当进程响应这个信号时就可以引用而执行信号服务程序了。

至于结构中的域sa_mask则相当于中断屏蔽控制寄存器。它是一个叫做信号集sigset_t类型的变量。该类型定义如下:

typedef struct {
	unsigned long sig[_NSIG_WORDS];
} sigset_t;

它其实是一个位图,每一位都对应一个信号。如果位图中的某一位为1,就表示在执行当前信号处理程序期间,位图为1的位所对应的信号都被“屏蔽”,以防止这些信号中断当前信号服务程序的执行。

系统定义了下列五个对信号集进行操作的函数:

int sigemptyset(sigset_t * set);    //初始化函数,排除信号集中的所有信号
int sigfillset(sigset_t * set);    //初始化函数,使信号集包括所有信号
int sigaddset(sigset_t * set, int signo);    //添加信号
int sigdelset(sigset_t * set, int signo);    //删除信号
int sigismember(const sigset_t * set, int signo);    

结构sigaction中的sa_flags是用来指定信号处理函数操作方式的参数,其可选值如下表:

信号处理的选择项标志(sa_flags)
sa_flags 说明
SA_NOCLDSTOP 若signo是SIGCHLD,则当一子进程停止时(作业控制),不产生此信号。当一子进程中止时,仍旧产生此信号。
SA_RESTART 由此信号中断的系统调用自动再启动
SA_ONSTACK 若用sigalstack(2)已说明了一替换栈,则此信号传送给替换栈上的进程
SA_NOCLDWAIT 若signo是SIGCHLD,则当调用进程的子进程中止时,不创建僵死进程。若调用进程在后面调用wait,则阻塞到它所有子进程都中止,此时返回-1,errno设置为ECHILD。
SA_NODEFER 若捕捉到此信号时,在执行其信号捕捉函数时,系统不自动阻塞此信号。注意,此种类型的操作对应于早期的不可靠信号。
SA_RESETHAND 对此信号的处理方式在此信号捕捉函数的入口处复置为SIG_DFL。注意,此种类型的信号对应于早期的不可靠信号。
SA_SIGINFO 此选项对信号处理程序提供了附加信息

sigaction action结构如下图所示:

在文件linux/kernel/signal.c中定义的系统调用sys_signal()的原型如下:

asmlinkage long sys_signal(int sig, __sighandler_t handler);

在文件linux/kernel/signal.c中定义的系统调用sys_rt_sigaction()的原型如下:

asmlinkage long sys_rt_sigaction(
                                 int sig,            //信号编号
				 const struct sigaction __user * act,        //sigaction结构的实例
				 struct sigaction __user * oldact,
				 size_t);

该函数的第一个参数为信号的编号,可以去除SIGKILL及SIGSTOP外的任何一个特定有效的信号;第二个参数是指向结构sigaction的一个实例的指针;第三个参数oldact指向的对象用来保存原来对相应信号的处理,可指定oldact为NULL。

进程的信号向量表

与处理器使用中断向量表把中断源和中断服务程序关联起来,并对其进行管理的方法类似,进程在自己的控制块中也设置了一个信号向量表,前面所述的信号的安装,其实就是要把sigaction结构安装到这个向量表中。

进程控制块中有很多与信号有关的域,其中最重要的就是指向信号向量表的指针sighand:

struct task_struct {
        ...
	struct sighand_struct *sighand;        //信号向量表指针
	struct sigpending pending;            //未决信号对列指针
        ...
};

在文件include/linux/sched.h中,信号向量表结构sighand_struct的定义如下:

struct sighand_struct {
	atomic_t		count;
	struct k_sigaction	action[_NSIG];            //信号数组
	spinlock_t		siglock;
	wait_queue_head_t	signalfd_wqh;
};

其中,信号数组action[_NSIG]的每一个元素都对应一个信号,其下标就是信号的编号。数组元素是一个k_sigaction结构:

struct k_sigaction {
	struct sigaction sa;
	__sigrestore_t ka_restorer;
};

也就是说,进程与信号向量表之间的关系如下图所示:

进程响应信号的时机

既然对进程来说信号是一种中断请求,那么进程什么时候可以响应并处理这种中断呢?

因为信号的处理程序都运行在进程空间,所以Linux规定,进程对信号的检测与响应总是发生在系统调用或中断服务的末尾处,也就是说当进程由系统空间返回到用户进程空间之前。即内核在时钟中断timer_interrupt处理程序最后会跳转去的ret_from_sys_call及其他系统调用的后面有对函数do_singal()的调用,如下图所示:

信号的生命期及可靠性

从信号发送到相应的处理函数执行完毕,这是一个信号完整的生命期。因此,一个信号是否能完整地度过其生命期,取决于信号的可靠性。Linux的信号分为不可靠信号和可靠信号。

信号的生命期

一个信号的生命期可分为三个阶段,这三个阶段可由四个重要事件来刻画:诞生、在进程中注册、在进程中的注销以及信号处理程序执行完毕。

当有事件发生时,如检测到硬件异常、定时器超时以及调用信号发送函数kill()或sigqueue()等,即会诞生相应的信号。

在接收信号的进程端维护着一个信号队列,该队列是一个sigqueue结构的链表。结构sigqueue的定义如下:

struct sigqueue {
	struct sigqueue * next;            //指向下一个
	siginfo_t info;
};

其队列头是一个digpending结构:

struct sigpending {
	struct sigqueue * head, ** tail;
	sigset_t signal;
};

在这个结构中,除了用队列头尾两个指针之外还有一个sigset_t类型的位图signal。前面讲过,系统中的每个信号都在位图中占有一个固定的位置,可以在某个信号的固定位置以1或0表示这个信号的状态。所以结构sigpending中的这个位图就用其中各个位的值来表示对应信号的到来情况:用1表示信号已经到来,但还未被进程处理;用0表示该位对应的信号未到,或已处理完毕。因此,这个位图也叫做进程的未决信号位图。

进程对信号管理的结构如下图所示:

当信号诞生后,该信号及其相关信息会被加入进程的信号向量表(信号的安装),同时还会将其加入到目标进程未决信号队列,并记录在该队列中的未决信号集中,等待进程处理。这个行为叫做信号向进程注册。

每次在目标进程从系统空间返回到用户空间的过程中,会检测未决信号位图,以发现等待处理的信号。如果存在未决信号并且该信号没有被进程阻塞,则在未决信号链中卸掉该信号的结点并立即执行相应的信号处理函数,执行完毕后,信号生命期结束。

不可靠信号

Linux信号机制基本上是从Unix系统中继承过来的。早期的Unix系统中的信号机制比较简单和原始,后来在实践中暴露出一些问题,因此,那些建立在早期机制上的信号就叫做“不可靠信号”。

不可靠信号的主要问题是:进程在处理一个信号后,信号会自动将该信号的响应设置为默认服务。因此,进程在下次接收到这个信号后,信号执行的是默认服务,而在某些情况下,这个默认服务并不是进程所希望的服务。所以,常常需要在信号处理程序中,重新安装该信号,但这种做法很不靠谱,极易造成信号的丢失。

因为这种信号采用的是位图来进行注册,而没有采用队列,所以当进程中已有先到的同样信号处在未决状态时,后到的这个信号将会被丢弃,从而造成信号丢失。因此,这种信号叫做“不可靠信号”。

可靠信号

通过信号排队的方法实现了“可靠信号”,即在多个信号集中到达时,内核用信号队列记住这些信号,这样就从根本上解决了信号丢失的问题。

即当一个信号发送给一个进程时,不管该信号是否已经在进程中注册,都会被再注册一次,并加入到未决信号队列,这意味着同一个信号可以在同一个进程的未决信号队列中占有多个sigqueue结构。因此,信号不会丢失,是“可靠信号”。

也就是说,可靠信号与不可靠信号的区别在于:

可靠性是指信号是否会丢失,即该信号是否支持排队; 如果支持排队就是可靠的,不支持排队就是不可靠的。

但由于已经很多的实际应用已经采用了原有的不可靠信号,所以为了兼容,Linux仍然保留了原本的那些不可靠信号,同时新增了一些可靠信号。通常:

  • SIGHUP(1号) 至 SIGSYS(31号)之间的信号都是继承自UNIX系统,是不可靠信号,也称为非实时信号;
  • SIGRTMIN(33号) 与 SIGRTMAX(64号)之间的信号,它们都是可靠信号,也称为实时信号。

总结

匿名管道、命名管道和共享内存都是以文件形式出现的通信方式,三者的共同特点都是以文件作为通信双方的中介,也都是属于特殊文件。但匿名管道是虚文件,它并不存在于外部存储器。又由于它是通过继承方式来实现共享的,所以它只能用来在具有亲缘关系的进程之间进行通信。另外,它是依靠单向通信来维护进程间的同步的。

消息队列和管道基本上都是4次拷贝,而共享内存(mmap, shmget)只有两次。

  • 4次:1,由用户空间的buf中将数据拷贝到内核中。2,内核将数据拷贝到内存中。3,内存到内核。4,内核到用户空间的buf;
  • 2次: 1,用户空间到内存。 2,内存到用户空间。

消息队列和管道都是内核对象,所执行的操作也都是系统调用,而这些数据最终是要存储在内存中执行的。因此不可避免的要经过4次数据的拷贝。但是共享内存不同,当执行mmap或者shmget时,会在内存中开辟空间,然后再将这块空间映射到用户进程的虚拟地址空间中,即返回值为一个指向一个内存地址的指针。当用户使用这个指针时,例如赋值操作,会引起一个从虚拟地址到物理地址的转化,会将数据直接写入对应的物理内存中,省去了拷贝到内核中的过程。当读取数据时,也是类似的过程,因此总共有两次数据拷贝。

命名管道就是一个文件,它与普通文件的最大区别有两点:第一,它是严格按照FIFO方式工作的;第二,它只能单向操作。而共享内存则是一种只存在于内存的虚文件,由于它既可读又可写,因此它需要使用另外的同步措施。

消息队列与匿名管道以及命名管道相比,具有更大的灵活性。因为,它提供有格式的字节流,有利于减少开发人员的工作量。同样,消息队列可以在几个进程间复用,而不管这几个进程是否具有亲缘关系,这一点与命名管道很类似。

消息类似于一种中断,因此它有类似于中断号的信号编号,也有类似于中断服务程序的信号服务程序。它是一种异步通信方式,是事件与进程通信的手段。

猜你喜欢

转载自blog.csdn.net/qq_38410730/article/details/81609452