[ULK-C11]信号传递

进程在正常结束以前,不可能无休止地运行下去.进程在运行的时候,系统中总会发生这样或那样的事件.内核必须响应这些事件,这样,进程的执行就会被打断.

有些事件产生自硬件,比如时钟或I/O设备.这样的事件通常与当前正在执行的进程没有什么关系,不会对进程的执行流造成任何影响.于是,进程根本意识不到自己曾被这种事件打断过.我们通常将这样的事件叫做中断.

有些事件来自进程自身,这通常意味着进程做了些不该做的事,比如除0.当这样的事情发生时,内核必须打断正在执行的进程,并进行相应的处理.这样的事件几乎一定会改变进程的执行流.我们通常把这样的事件叫做异常.

还有些事件既不是从硬件产生,也不是从进程自身产生.这样的事件产生自其他进程.Linux允许一个进程向另一个进程发送一个消息,以便进程之间可以相互”通信”.于是,为了响应消息,收到消息的进程通常又会被打断.我们把这样的事件叫做信号.

现实更加复杂.事实上,异常事件发生以后,内核不会强势介入进程的执行流,而是用了一种更加”文雅”的方式:向进程发送一个信号,虽然在大多数情况下这个信号会杀死进程.

这样,我们知道,信号是被发送给进程的一条消息,收到信号的进程通常会对信号作出响应,从而改变自己的执行流.

并且,我们还可以总结出信号的两个作用:

  • 在用户态, 信号被用作进程间通信;
  • 在内核态,信号被内核用来通知进程已发生的事件.

我们需要先定义一些与信号处理相关的术语,并明确它们的语义.首先,我们来定义信号的三个状态.值得注意的是,信号的三个状态存在于理论之中,内核不会记录信号的状态.

  • 产生
    当一个导致信号生成的事件发生时,内核会根据需要更新一个或多个进程描述符.从事件发生到进程描述符更新完毕,这个过程叫做信号产生.下面这些事件都可能引起信号产生:
    • 硬件异常(如除0)
    • 软件条件(如定时器超时)
    • 终端操作
    • kill()函数调用
  • 传递
    内核强迫目标进程对信号作出反应.这个过程叫做信号传递.内核强迫目标进程相应信号的方式有:
    • 改变目标进程的执行状态
    • 执行特定的信号处理程序
    • 既改变目标进程的执行状态,又执行特定的信号处理程序
  • 未决
    产生的信号不会立即被传递.当信号已产生但是没有被传递时,信号处于未决状态.

其次, 我们看内核传递信号的三种方式.与信号状态不同的是,内核对信号的处理动作明确记录在进程描述符的sighand->action[sig].sa_handler字段中.

  • 显式忽略: SIG_IGN
  • 默认操作: SIG_DFL
    默认操作与信号相关,有以下三种:
    • Terminate: 终止进程
    • Dump: 终止进程并转储其执行上下文
    • Ignore: 忽略信号
    • Stop: 停止进程
    • Continue: 若进程被停止,就将它设为运行态
  • 捕获: handler_address

至此,事情好像已经完美了: 信号产生以后,经历未决状态,最后被内核根据注册在进程描述符中的动作进行传递.然而,信号并不总是能够被顺利传递,因为内核可能阻塞信号.

  • 阻塞
    信号注册在以下两个地方时,信号被阻塞.被阻塞的信号处在信号未决状态,当它被解除阻塞以后,它才有机会被传递.
    • blocked
    • sighand->action[sig]->sa_mask

一. do_signal()函数

处理非阻塞的挂起信号

参数

  • regs 栈,current在用户态下寄存器的内容存于此处
  • oldset 阻塞信号的位掩码数组

说明

通常只在CPU返回到用户态时才调用此函数:TIF_SIGPENDING标志的检查总是在内核准备返回到用户态时进行.
反复调用dequeue_signal()直到pending和shared_pending队列为空

复杂性

  • 竞争条件,冻结系统,产生内存信息转储,停止/杀死整个线程组
  • 中断处理程序调用此函数(可能性?)
  • 当current正受到其他进程监控的时候怎么办?
    do_notify_parent_cldstop()和schedule()
  • 待处理的信号是一个被忽略的信号(可能性?)
  • 待处理西信号需要被执行缺省操作
  • 待处理信号有一个信号处理函数

二. 执行信号的缺省操作

  • 当接收进程是init()时,丢弃信号;
  • 当信号是SIGCONT, SIGCHLD, SIGWINCH, SIGURG时,忽略信号;
  • 当信号是SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU时,停止整个线程组;
  • 缺省操作:Dump
  • 缺省操作: Terminate: do_group_exit()(组退出)

do_signal_stop()
组停止

P440

三. 捕获信号

信号处理程序必须在用户态执行:

原程序– –信号处理程序— –信号处理程序– –原程序
|1(中断) |2 |3(系统调用) |4 |5 |
–内核态产生信号– –系统调用—- –内核–

多次状态切换引起复杂性:
- 内核态向用户态向用户态切换时,内核态堆栈被清空.因此,第2次特权级切换以后,原程序的硬件上下文丢失
- 信号处理程序调用系统调用后,第4次特却级切换时,内核必须返回到信号处理程序而非原程序

一个信号引发的血案
一个非阻塞的信号被发送给一个进程.当中断或异常发生时,进程切换到内核态.正要返回到用户态前(return_from_intr),内核执行do_signal()函数,这个函数又依次处理信号(handle_signal())和建立用户态堆栈(setup_frame()),当进程又切换到用户态时,因为信号处理程序的起始地址被前值放进程序计数器中(谁干的?setup_frame()吗?), 因此开始执行信号处理程序.当信号处理程序终止时, setup_frame()函数放在用户态堆栈中的返回代码就被执行(?).这个代码调用sigreturn()系统调用,相应的服务例程把原程序的用户态堆栈的硬件上下文复制到内核态堆栈,并把用户态堆栈恢复到它原来的状态(restore_sigcontext()).当这个系统调用结束时,普通进程就因此能恢复自己的执行.

set_frame()函数

在用户态堆栈中放置一个帧(sigframe).

这个帧含有处理信号所需要的信息,并确保正确返回到handle_signal()函数.

参数
sig: 信号ID
ka: k_sigaction表
oldset: 阻塞信号掩码
regs: 内核态堆栈中的用户态硬件上下文的地址

疏理一下思路.setup_frame()函数被handle_signal()函数调用.后者在运行在内核态,负责执行信号处理程序.

我们知道这里有两个复杂性,分别对应着两次特权级切换.第一,信号处理程序结束以后,必须能够正确地返回到handle_signal()函数所指定的入口点;第二,handle_signal()函数结束后,必须能够正确返回到原程序.

第一个复杂性出现的原因在于,信号处理程序结束后,会执行sigreturn()系统调用,这个系统调用使信号处理程序返回内核.返回到内核的什么位置呢?这个位置必须存储下来,并且由sigreturn()系统调用引用.

第二复杂性出现的原因在于,原程序第一次陷入内核时,其硬件上下文保存与内核态堆栈已备将来返回,这没错.但是当内核向原程序返回时,这些硬件上下文会丢失.原因在于,在这两次特权级切换之间,出现了额外的两次特权级切换,这两次切换分别对应着从内核向信号处理程序切换以及信号处理程序结束时向内核陷入,其中,前者会将进程的内核态堆栈清空.

现在我们要开始研究帧了,你最好把这两个复杂性记在脑子里,然后看看帧对解决它们有何帮助.

帧(sigframe)

pretcode
信号处理函数的返回地址.
sig
信号编号,这是信号处理程序所需要的参数.
sc
用户态进程的上下文,这个上下文是原程序第一次陷入内核的时候从用户态堆栈复制而来的.现在,这个上下文又被内核放入帧中,重新复制到用户态堆栈.
fpstate
用户态进程的浮点寄存器内容.这个也算是硬件上下文的一部分把?
extramask
被阻塞的实时信号的位数组.这是位数组,这里面都是实时信号,这些实时信号都被阻塞了.问题是,这个帧域有什么用?
retcode
sigreturn()系统调用的8字节代码.

信号处理程序是什么?它是怎样被调用的?又是怎样返回的?我可以试着想象一下.

信号处理程序是放在内存中某个位置的一段代码,它的入口点被task->sighand.k_sigaction这张表记录.当内核想要调用这段代码的时候,内核会首先向用户态堆栈拷贝进程的硬件上下文.并可能做一些其他的准备工作.在此以后,内核调转到入口点,开始执行信号处理函数.这中间有一次从内核向用户的特权级切换,我不知道它发生在哪儿.

这段代码会从用户态堆栈的固定位置,sig字段,取出它的参数,然后开始执行.执行完毕后,从用户态堆栈取出它的返回地址,pretcode字段,开始返回.

setframe()函数的执行

首先,获得用户态堆栈的地址(regs->esp),然后将用户硬件上下文复制到用户态堆栈.

这时,原程序的硬件上下文已经被复制到用户态堆栈,于是,我们可以放心大胆的修改存放在内核态堆栈中的regs字段.随着regs字段的修改,内核将神不知鬼不觉地返回到信号处理程序.信号处理程序继续使用原程序的用户栈,不过,它的信息存放于原程序用户栈的最上面.

检查信号标志

这一步是干啥呢?
在执行信号的时候,新来的信号是要被阻塞的.哪些信号要被阻塞呢?
1. 进程描述符里面规定要阻塞的信号是要被阻塞的: current->blocked
2. 这个信号处理函数规定要阻塞的信号,是要被阻塞的: ka->->sa.sa_mask
3. 当前信号,是要被阻塞的: sig

至此,handle()返回到do_signal(), do_signal()也立即返回.do_signal()返回时,当前进程恢复它在用户态的执行.而由于setup_frame()函数已经偷天换日,所以eip寄存器指向了信号处理程序的第一条指令,esp寄存器已压入用户栈顶.至此,信号处理程序开始执行.

信号处理程序结束时,返回栈顶地址.

现在你知道了吧!
一个程序执行结束的时候,会返回到它的栈顶地址,无比自然.

栈顶地址指向帧的pretcode字段所引用的vsyscall页中的代码,这段代码发出0x80中断,开始调用sigreturn()系统调用.

执行sigreturn的时候,特权级已经变为内核.我们来关注原程序硬件上下文.检测到PIT_SIGPENDING陷入内核的时候,原程序硬件上下文被保存到内核态堆栈中,这是正常的步骤,但是具体的过程我还是不清楚.随后,由于要回到用户态执行信号处理程序,为了防止返回用户态时原程序硬件上下文的丢失,set_frame()将这个硬件上下文复制到用户栈.随后控制权交给信号处理程序.终于,信号处理程序结束,sigreturn()系统调用触发内核陷入,控制权回到内核.此时,原程序的硬件上下文保存于用户栈.我想,我们必须将这个硬件上下文从用户栈复制到内核栈.因为,当原程序恢复执行时,它的硬件上下文一定是从内核栈恢复的,是吗?

我们来看具体的过程.

sys_sigreturn()首先找到帧在用户态堆栈的地址,这可以通过esp字段轻易完成.然后恢复current->blocked字段.何为恢复?刚才,为了执行信号处理程序,我们修改了current->blocked字段,强迫它吞并了信号处理程序的阻塞位和所处理信号的阻塞位.现在,我们要将这个字段恢复到以前的状态.然后,我们重新调用recalc_sigpending()函数,这样,如果有新的阻塞信号,我们就传递它们.这里,我认为,所谓”阻塞信号”指的是,信号可以产生,但是不会被传递.解除阻塞意味着信号终于可以被传递了.继续我们的过程.现在,我们要访问帧的sc字段,这个字段指向原程序在用户栈中的硬件上下文,我们把这个上下文拷贝到内核栈.所有这一切交给一个函数来完成:restore_sigcontext(),这个函数还会将用户栈中的硬件上下文删除.

看来,故事到此就该告一段落了.

四. 系统调用的重新执行

有的时候,进程通过系统调用向内核请求服务,比如读写一个文件.然而,内核并不能总是满足进程的请求,此时,内核将这个进程置为TASK_INTERRUPTIBLE或TASK_UNINTERRUPTIBLE状态.

问题来了,如果进程由于不能完成系统调用而处在I或UI状态下,一个信号产生在这个进程上,会发生什么呢?

先来说明处在I状态的进程把.如果一个进程处在I状态,并且受到一个信号.那么内核不会等待系统调用完成,而是直接将进程置为R态.进程苏醒后,切换会用户态,此时信号被传递给进程.当这种情况发生的时候,系统调用没有完成它的工作,系统调用历程会向内核返回一个错误码.注意,用户进程并不会收到这个错误码,用户进程获得的唯一错误吗是EINTER,这个错误码告诉用户进程系统调用没有执行完.

让我们回到内核错误码上来.内核掌握着两点关键信息:一是从系统调用服务历程返回的错误码.二是用户进程对信号的传递方式.根据这两点信息,内核从如下动作中选择一个:1.不重新执行系统调用.2.重新执行系统调用.3.根据SA_RESTART标志的值决定是否重新执行系统调用.

上述这一切的前提是,进程因执行系统调用失败而挂起.那么,内核是怎么知道进程挂起的原因是系统调用执行失败的呢?这就是regs硬件上下文的orig_eax字段发挥作用的地方了.P446

系统调用的重新执行需要分情况讨论
系统调用被未捕获的信号中断
系统调用被捕获的信号中断

这是自然的,因为前者不需要执行信号处理程序,后者则需要执行信号处理程序.

系统调用被未捕获的信号中断

在第一种情况下,do_signal()修改regs硬件上下文,让eip指向int $0x80或者sysenter指令.这样,当原程重新开始执行的时候,它会直接一头扎进系统调用的代码中.

有一种特殊情况,eax中存放的是restart_syscall()系统调用号,系统调用服务例程返回RESTART_RESTARTBLOCK,这个错误代码仅仅用于与时间有关的系统调用.为什么?如果一个系统调用要求进程睡眠20ms,10ms后进程被信号中断,如果重新执行这个系统调用,那么进程最终会睡眠30ms.

怎么解决这个问题呢?这种情况发生时,内核不会忠实地完全重新执行系统调用.当这种情况发生时,正在执行系统调用服务历程的内核会将一个特别定制的系统调用服务历程的地值放在thread_info的restart_block字段,并在返回错误码-ERESTART_RESTARTBLOCK.这样,sys_restart_syscall()服务例程只执行这个特别定制的函数.

系统调用被捕获的信号中断.

梳理思路.进程因系统调用失败而挂起.此时捕获到一个信号,进程被唤醒,并从系统调用服务例程返回,注意,应该不会回到用户态,而是直接处理信号.

handle_signal()函数会根据内核收到的出错码和sigaction表的SA_RESTART标志来决定是否必须重新执行未完成的系统调用.如果系统调用需要重新执行,那么,我想,handle_signal()会修改eip,然后返回用户程序,用户程序紧接着陷入内核执行系统调用,不会执行信号处理程序.否则,用户进程接受出错码-EINTR,然后陷入继续完成信号处理程序?

五. 与信号处理有关的系统调用

你可能感兴趣的:(linux,函数,内核,信号,ULK)