浅谈Linux信号机制

来源:互联网 发布:手机电脑文件软件 编辑:程序博客网 时间:2024/06/05 11:29

信号在Linux系统中有广泛的应用,但信号机制不像消息队列、信号量那么直接明了,而是存在着较多的特殊逻辑。本文主要是分析信号的用户层接口在内核中是如何实现的,以及使用过程中需要注意的地方。如无特别说明,本文中内核代码的版本是2.6.32.27(展示代码有删减,只保留相关的部分),体系结构是IA-32

1. 基础知识

这里不介绍信号的概念、作用和常用API等内容,只是描述一下内核中和信号有关的几个基本知识点。

1.1 进程和线程

虽然用户在使用多线程时可以不理会底层实现,只根据POSIX规范来设计就可以了,但在阅读内核代码时就需要搞清楚Linux实现和POSIX的差异,因为在线程库NPTL的封装下,用户感受到的并不是真实的情况。 

从用户角度,一个进程由若干个线程组成,每个线程有个线程IDTID),进程中的所有线程共用一个进程IDPID),这是POSIX的要求。

从内核角度,进程和线程没有本质区别,或者说只有进程没有线程。每个进程在内核中由一个struct task_struct数据结构描述,通过PID来唯一标识。为了满足POSIXLinux需要解决以下几个问题:

l 表示一个单线程的进程;

l 表示一个多线程的进程;

l 线程的私有资源(TID、寄存器、堆栈、信号屏蔽码等)和线程间的共享资源(代码段、文件描述符、信号处理函数等);

l 各线程可独立调度、拥有独立优先级等。

Linux的解决方法是不专门设计针对线程的数据结构,而是用进程来模拟线程(为了清晰,蓝色字体表示应用层,红色字条表示内核,下同):

l 单线程的进程就是该进程自身,用户看到的线程TIDPID实际上都是内核中进程的PID

l 多线程的进程实际上是一组进程(进程数和线程数相同),称为Thread Group(线程组。为毛不叫进程组?),该线程组中的每个进程有各自的PID和统一的TGID。用户使用gettid()(有些C库不支持,要通过syscall(SYS_gettid)来调用)来获取线程的TID时,内核实际返回的是进程的PID;使用getpid()获取线程的公共PID时,内核返回的是进程的TGID。TGID实际上就是Thread Group Leader(最早存在的进程)的PID。

l 既然各用户线程在内核中是独立进程,那么私有资源的问题自然就解决了;共享资源则主要是在创建子进程时通过标记位来控制——选择复用父进程的哪些资源(比如映射同样的文件内容到代码段、使用指针指向同一个信号处理函数结构等等);

l 属于进程调度内容,和本文无关。

假设一个进程先fork()一次,再pthread_create()两次,大概的关系图如下所示:

 

为了避免混淆,下文不管是描述应用层还是内核,均不使用“进程”这个词,而使用“线程组”来表示所有线程整体,使用“线程”指单一用户线程或单一内核task_struct

1.2 task_struct中和signal有关的成员

 

sighand是和信号处理函数相关的结构体,通过slab分配,在1.5中详细描述

blockedreal_blocked和信号阻塞相关,前者表示当前线程阻塞了哪些信号(包括临时阻塞的,比如在sigaction()中指定的sa_mask参数),后者则不包括临时阻塞的;

pending是该线程已收到但还未处理的信号列表,在1.4中详细描述

sas_ss_spsas_ss_size和信号处理函数使用的栈相关,很少会用到;

signal成员指向一个较大的数据结构,里面包含了和信号有关的其它内容。

1.3 信号标志位图sigset_t

 

用户态也会用到这个数据结构,这里就不再介绍了。提一下_NSIG是支持的信号种数,在很老的Linux版本上32位处理器上只支持32种信号,现在的Linux版本在绝大多数处理器上都支持64种信号了(包括32位处理器),一些体系结构比如MIPS上甚至能支持128种信号。

1.4 待处理信号链表

线程的待处理信号通过struct sigpendingstruct sigqueue两个数据结构组成链表:

 

其中struct sigpending中用一个sigset_t表示当前有什么信号正在待处理链表中,然后用一个struct list_head来引导一个双向链表。struct list_head是内核中最基本的数据结构,有疑问请谷歌。每个struct sigqueue都代表一个待处理信号,也是通过struct list_head接入到链表中,然后由siginfo_t结构存储信号值和其它相关信息。大概的数据结构如下:

 

所以线程通过sigpending.signal就可以知道有没有信号、有哪些信号在等待处理,通过sigpending.list作为入口就可以遍历所有的待处理信号。

task_struct中有两个这样的待处理链表,task_struct.pendingtask_struct.signal->shared_pending。前者是task_struct所代表的线程私有的待处理信号,后者是线程组共享的待处理信号。我们发送的信号是加入到pending还是shard_pending,线程组中又是哪个线程来处理shared_pending的,这些问题将在下文中分析。

1.5 信号处理函数相关结构sighand_struct

    

struct sighand_struct是一个和信号处理函数相关的结构体。atomic_t count是一个原子变量(内核中最基本的同步机制,直接利用了硬件提供的同步手段,其它同步机制都直接或间接利用了原子变量,这里不讨论,简单理解为一个整形即可),保存了该数据结构被引用的次数。因为内核允许创建进程(被迫用这个词)时直接复用父进程的信号处理函数,这样可以省去复制该数据结构的过程(注意到struct task_struct中的sighand是一个指针),所以需要一个计数来表明当前有几个进程在使用这个内存区域,没人用了才可以释放掉。

创建进程过程中的do_fork() --> copy_process() --> copy_sighand()可以看出这个逻辑:创建进程时指定了CLONE_SIGHAND的话就表示复用父进程的struct sighand_struct,增加count的计数值;否则就从slab分配器中获取一个新的。

那么什么情况下创建进程会带有CLONE_SIGHAND标记呢?一种最常见情况是创建线程。在copy_process()中有这么一个检查:如果指定了创建线程,却又没指定共享struct sighand_struct,那么返回错误。这里说明了一个情况:一个线程组中所有的线程都共享同一套信号处理函数。

回到struct sighand_struct,其中的struct k_sigaction数组为每一个信号分配一个元素,里面保存了该信号的处理函数相关内容。这个结构在内核和用户态的定义有一些不同,但熟悉用户态sigaction()函数的童鞋应该很快就能看出这个数据结构的含义:

2. 系统调用实现

这部分内容介绍应用层常用的几个API在内核中是如何运作的。为了避免被分支逻辑干扰,下面的代码中只列出主流程部分,分支逻辑将在第3节描述。

2.1 注册信号处理函数

我们一般使用sigaction()来注册,对应的系统调用是rt_sigaction,主流程在do_sigaction()函数中实现,这里我们配合应用层的sigaction()一起看(Linux man-pages中的函数原型):

 

t是当前线程的task_struct指针,k指向sig信号的处理函数结构sighand_struct(见1.5)。将k的内容复制到oact,这块数据最终会被复制到sigaction()oldact指向的用户空间。act中的数据是来自sigaction()act指向的用户空间。用act的内容覆盖k,由此sighand_struct就被更新了,下次该信号到达时就会使用新的方式来处理。

注册信号处理函数的过程看上去非常简单,就是将原有的信号处理方式从task_struct.sighand->action[]中取出复制给用户,然后将用户指定的内容覆盖内核中的原有内容。

2.2 发送信号

发信号的接口有很多个,常用的有kill()/tgkill()/pthread_kill()/sigqueue()等,细节见Linux man-pages,大概的区别是:

l kill()发给线程组,其中的某一个不特定线程会处理该信号(只考虑参数pid大于0这种最常见情况);

l tgkill()发给指定线程组中的指定线程;

l pthread_kill()由一个线程发给同线程组中的另一个线程,实际上是通过封装tgkill()实现的;

l sigqueue()类似于kill(),但可以带参数给信号处理函数。

这些函数的主流程大同小异,下面以tgkill()为例来说明,调用过程:

do_tkill()-->do_send_specific()-->do_send_sig_info()-->send_signal()-->__send_signal()

prepare_signal()是发送前的预备工作,主要是判断是否可以忽略这个信号。如果能忽略,那么整个发送流程就可以提前终止了,起到节能减排的作用,所以放在流程的最前面。这个函数里调了好几层,但展开来看就是一个个条件进行判断,简单总结就是:

l 如果线程已经屏蔽了该信号,那么不能够忽略,即使当前该信号的处理方式是SIG_IGN。这个逻辑看起来有点奇怪,原因是用户阻塞一个信号时往往只是想推迟处理该信号,而不是丢弃,用户有可能在合适的时机修改信号处理函数,然后取消阻塞开始处理;

l 在未被屏蔽的情况下,如果信号处理函数被设置为SIG_IGN,那么可以忽略;

l 如果信号处理函数并未设置,但默认的处理方式就是忽略该信号,那么可以忽略;

l 此外还有部分特殊逻辑将在后续说明。

 如果能通过上面的层层检测,那么就可以继续发送流程了。__sigqueue_alloc()则通过slab分配到一个struct sigqueue结构,

通过list_add_tail()加入到pending待处理链表中,然后保存siginfo结构,这是信号处理时需要用到的信息。

 

sigaddset()pendingsigset_t signal成员中给该信号打上标记,如1.4所描述。complete_signal()用于唤醒一个线程来处理信号:

首先使用wants_signal()函数判断发送方指定的目标线程是否合适处理该信号,如果可以则选中它;

其次如果指定了发送给一个线程,而不是发给整个线程组(group0),或者接收方是单线程,那么在此时就没有合适的线程能处理这个信号了,直接返回(不是返回失败,因为当前选不到合适线程并不代表该信号就能丢弃,比如指定线程阻塞了该信号留待以后处理);

剩下的就是发送给线程组、并且接收方是多线程的情况了,这时遍历所有线程看看哪个合适就选择那个线程来处理。

从这个函数可以看出,如果信号是发给整个线程组的,那么任一线程都有可能被选中来处理信号。

 

wants_signal()判断一个线程是否合适的依据如下:

l 如果线程屏蔽了该信号,那么不合适;

l 如果线程已经打上了PF_EXITING标记(线程在退出时的清理流程: do_exit()-->exit_signals()会标记上PF_EXITING,表示该线程准备退出了,不会再接收新信号),也不合适;

l 如果是SIGKILL,那么表示要杀掉整个线程组,这时任一个线程都应该立即响应;

l 如果线程处于调试状态,那么不合适;

l 如果线程正在某个CPU(内核把N1个物理CPU*N2个核*N3个超线程视为N1*N2*N3CPU)上运行,或者线程没有待处理的信号,那么合适。线程没有在运行说明要下次调度到这个线程时才能处理信号,相对正在运行的来讲实时性会差一些,所以不选择;线程有待处理信号说明信号要排队处理,实时性也没那么好(这里的判断是否正确?)。总体来说这里的选择过程只是一个发送阶段的初步选择,依据并不严格,而且即使一个合适的线程都没有也不要紧,可以加入pending队列等待后续有合适线程再处理。

 

由此信号就发送完成了,简单来说就是判断信号是否有必要发送,如果有则加入到信号等待链表中。

2.3 处理信号

处理信号是内核自动触发的,并不是系统调用,这段内容放在这里是因为注册-发送-处理构成了一个完整流程。

从发送过程来看,很容易猜到信号的处理过程:首先检查待处理链表中是否有信号,有的话就取出其中的sighand_struct成员,按照里面指定的方式来处理。确实是这样,但在细节上我们有几点要确定:

l 什么时候去检查待处理信号链表?

l 处理过程使用的栈空间来自哪里?

l 如何从内核中跳去执行处理函数,又如何从处理函数回到用户主程序?

 

信号作为一种异步机制,其优先级应当比所属的线程要高,也就是说如果一个线程收到信号,那么要先处理信号,再回来继续执行线程本身。但信号处理属于用户行为,从安全、公平的角度讲信号处理的优先级只对所属线程有效,不应当影响到其它的线程。Linux选择的方法是系统从内核态返回到用户态时,先判断即将运行的线程是否有信号需要处理,如果有则先处理信号,之后才是线程本身。这样既保证了信号的优先级比所属线程高,又保证了信号的优先级不会跨线程(一个线程首先要获得运行资格,它的信号才会被处理)。

 

entry_32.SIA-32的系统调用及中断相关的汇编代码,在系统调用/中断处理完成后、即将返回之前,会调用do_notify_resume(),这里检查线程的_TIF_SIGPENDING标记,如果置位(2.2中的signal_wake_up()会设置这个标记)说明有信号正在等待,调用do_signal()进行处理:

user_mode()通过判断栈中保存的寄存器信息来判断程序的运行路径:程序有两种可能到达当前位置,一是在用户态由系统调用/中断进入内核,二是在内核态运行过程发生中断(也包括系统调用执行过程被中断,或者嵌套中断)。如果是后者,则程序回到被中断的内核代码继续执行,此时不需要进行信号处理。user_mode()的判断方法依赖于体系结构,比如ARM是检查CPSR状态寄存器,X86是检查CS寄存器中的RPL级别,这里不展开。

 

然后就是通过get_signal_to_deliver()获取一个待处理信号:

通过dequeue_signal()从线程的待处理链表中取出一个待处理信号,返回0说明没有信号需要处理,整个流程就结束了。

有待处理信号的话,ka指向对应的sighand_struct结构,如果当前设为SIG_IGN,那么跳过,重新获取一个待处理信号;如果不是SIG_DEF(也就是用户设置了自己的处理函数),那么就将这个sighand_struct复制输出参数以便后续进行处理。这里不是直接返回ka指针而是复制了整个sighand_struct是因为这个结构有可能在get_signal_to_deliver()返回后、在do_signal()处理前就被用户修改了。在get_signal_to_deliver()中对sighand的访问是加了spinlock的,代码中忽略了。

走到最后sig_kernel_ignore()的是设置为SIG_DEF的情况,如果SIG_DEF本来就是忽略(SIGCONT/SIGCHLD/SIGWINCH/SIGURG这几个),直接跳过继续获取下一个待处理信号。在往下(没列出代码)的逻辑就是SIG_DEF为终止线程组或者生成core文件的情况了,这里不展开描述。

总的来说get_signal_to_deliver()的目的就是循环取出待处理信号,直到已无信号需要处理,或者用户为信号指定了处理函数为止。SIG_IGN/SIG_DEF的信号会直接在该函数中处理掉,然后continue重新获取新的信号。

 

dequeue_signal()取出待处理信号的过程如下:

先尝试在线程私有的pending中获取,没有的话再从线程组共享的shared_pending中获取。

 

__dequeue_signal的实现:

next_signal()是一堆逻辑运算,作用是在pending中找到一个未被屏蔽的待处理信号,信号值越小的越先被选中。collect_signal()则是取出这个信号对应的struct sigqueue数据结构(见1.4)。

list_for_each_entry()遍历整个链表,寻找指定信号,如果该信号在链表中只有一个struct sigqueue实例,就删掉sigset_t结构中的标记位,然后将该实例从链表中移除,复制其内容,最后是释放实例占用的空间(在__send_signal()中分配的)。

 

这时do_signal()就完成了第一步——获取待处理信号,接下来就是正式处理这个信号了。首先我们明确一点:信号处理函数所使用的栈不能是线程的内核栈,一个最基本的原因是安全性——处理函数可是用户写的,那么就只能使用线程的用户栈了(关于内核栈和用户栈可以参考http://km.oa.com/articles/view/224736)。实际上用户注册时也可以指定另一个专用的栈空间(1.2中提到的sas_ss_spsas_ss_size),但我们很少会去这么做,因为这个特性主要是用于处理用户栈溢出所产生的SIGSEGV信号——用户栈溢出时信号处理函数必须有另外的栈才能够正确执行,所以这里只讨论使用用户栈的情况。基于这一点我们可以知道:do_signal()在内核中执行,使用线程的内核栈,信号处理函数位于是用户空间的代码段,同时需要访问用户空间的数据段和栈空间,所以这个信号处理函数的调用过程必然涉及到代码段、数据段和栈的切换。

在应用程序通过系统调用进入内核的时候,CPU和内核会在内核栈中保存用户态的一些寄存器,用于从内核态返回用户态时恢复现场,如下图:

其中SS/ESP/EFLAGS/CS/EIPCPU自动保存的(IA-32的中断机制,具体可以参考Intel IA-32 Architectures Software Developers Manual Volume 1 6.4节),其它寄存器则是在系统调用的内核入口处通过SAVE_ALL汇编宏实现的,代码在entry_32.S中,这里不关心。这些保存的内容在内核C代码中用一个pt_regs数据结构来表示

我们关注的重点是最下面的(也是最先入栈的)几个寄存器:SSESP分别是用户栈的段寄存器和栈顶指针;CSEIP是用户程序代码段的段寄存器和程序指针。因为IA-32支持32位寻址,单独使用地址寄存器(ESPEIP)已经能够访问整个用户地址空间,所以段寻址机制基本上用不上了(除了权限控制),SSCS实际上就设为一样的,下面的讨论中也就忽略掉段寄存器。从内核态返回时最关键的操作就在这个栈上:先使用和SAVE_ALL相对应的RESTORE_ALL汇编宏弹出“其它寄存器”,然后执行IRET指令,CPU自动用pt_regs.sp填入ESP恢复用户栈,用pt_regs.ip填入EIP恢复用户程序指针。内核执行信号处理程序的技巧同样也在这个栈结构上:内核用一个sigframe_ia32数据结构来表示信号处理函数的栈帧(可参考http://km.oa.com/articles/show/224736第1节),将这个结构存储到用户栈上,然后将pt_regs.sp下移(因为sigframe_ia32入栈了),将pt_regs.ip指向信号处理函数,然后执行同样的返回流程——先RESTORE_ALL再IRET,这样就可以进入到信号处理函数中了。主要步骤在do_signal()-->handle_signal()-->setup_rt_frame()-->ia32_setup_frame()

get_sigframe()中将用户栈顶指针减去sigframe_ia32大小,相当于在用户栈上保留出空间用于存储sigframe_ia32

然后__put_user()ia32_setup_sigcontext()将信号值和其他相关信息保存到用户栈上,修改pt_regs中的内容,修改结果如下图所示:

当内核通过IRET返回用户态时,由CPU自动恢复的ESPEIP已经被变成信号处理函数相关的了,处理函数得以执行。这个实现方式就类似于应用层的恶意代码对栈溢出漏洞进行攻击一样。

 

上面讲的是如何执行信号处理,那么执行完之后,要怎样回到用户线程中去呢?一方面我们需要恢复用户程序的ESPEIP,另一方面我们需要一段代码来执行这个恢复任务。内核在修改pt_regs之前已经对用户程序的ESPEIP做了备份:在上面的ia32_setup_sigcontext()中,原始的pt_regs已经保存到了sigframe_ia32sc成员中了。至于恢复工作,从技术上考虑可以提供一个系统调用或者库函数,要求信号处理函数结束时一定要调用这个接口,但可行性显然不高,内核还是应当想办法为用户分忧。内核实现了一个sigreturn系统调用专门来做信号处理函数结束后恢复用户线程的工作,然后在构建sigframe_ia32时,将信号处理函数的返回地址修改为这个sigreturn的入口,在上面的ia32_setup_frame()中restore就是这个入口地址,frame->pretcode则是信号处理函数的返回地址(sigframe_ia32显然是按照函数调用栈帧结构精密设计的),当信号处理函数RET的时候,实际上就执行了sigreturn系统调用,又进入到内核中了。这个系统调用就可以将sigframe_ia32中的用户线程ESPEIP取出,放入到当前的pt_regs中,剩下的事你懂的。

信号应用的几个关键环节就讲完了,还有一些值得使用者关注的地方过阵子再补上。

0 0
原创粉丝点击