对于硬件触发的,无论是中断,还是信号,肯定是先到内核的,然后内核对于中断和信号处理方式不同。一个是完全在内核里面处理完毕,一个是将信号放在对应的进程 task_struct 里信号相关的数据结构里面,然后等待进程在用户态去处理。当然有些严重的信号,内核会把进程干掉。但是,这也能看出来,中断和信号的严重程度不一样,信号影响的往往是某一个进程,处理慢了,甚至错了,也不过这个进程被干掉,而中断影响的是整个系统。一旦中断处理中有了 bug,可能整个 Linux 都挂了。
有时候,内核在某些情况下,也会给进程发送信号。例如,向读端已关闭的管道写数据时产生 SIGPIPE 信号,当子进程退出时,我们要给父进程发送 SIG_CHLD 信号等。
最直接的发送信号的方法就是,通过命令 kill 来发送信号了。例如,我们都知道的 kill -9 pid 可以发送信号给一个进程,杀死它。
另外,我们还可以通过 kill 或者 sigqueue 系统调用,发送信号给某个进程,也可以通过 tkill 或者 tgkill 发送信号给某个线程。虽然方式多种多样,但是最终都是调用了 do_send_sig_info 函数,将信号放在相应的 task_struct 的信号数据结构中。
- kill->kill_something_info->kill_pid_info->group_send_sig_info->do_send_sig_info
- tkill->do_tkill->do_send_specific->do_send_sig_info
- tgkill->do_tkill->do_send_specific->do_send_sig_info
- rt_sigqueueinfo->do_rt_sigqueueinfo->kill_proc_info->kill_pid_info->group_send_sig_info->do_send_sig_info
当发现一个进程应该被调度的时候,我们并不直接把它赶下来,而是设置一个标识位 TIF_NEED_RESCHED,表示等待调度,然后等待系统调用结束或者中断处理结束,从内核态返回用户态的时候,调用 schedule 函数进行调度。信号也是类似的,当信号来的时候,我们并不直接处理这个信号,而是设置一个标识位 TIF_SIGPENDING,来表示已经有信号等待处理。同样等待系统调用结束,或者中断处理结束,从内核态返回用户态的时候,再进行信号的处理。
signal_wake_up_state 的第二件事情,就是试图唤醒这个进程或者线程。wake_up_state 会调用 try_to_wake_up 方法。这个函数我们讲进程的时候讲过,就是将这个进程或者线程设置为 TASK_RUNNING,然后放在运行队列中,这个时候,当随着时钟不断的滴答,迟早会被调用。如果 wake_up_state 返回 0,说明进程或者线程已经是 TASK_RUNNING 状态了,如果它在另外一个 CPU 上运行,则调用 kick_process 发送一个处理器间中断,强制那个进程或者线程重新调度,重新调度完毕后,会返回用户态运行。这是一个时机会检查 TIF_SIGPENDING 标识位。
一个信号中断系统调用的典型逻辑。
首先,我们把当前进程或者线程的状态设置为 TASK_INTERRUPTIBLE,这样才能使这个系统调用可以被中断。
其次,可以被中断的系统调用往往是比较慢的调用,并且会因为数据不就绪而通过 schedule 让出 CPU 进入等待状态。在发送信号的时候,我们除了设置这个进程和线程的 _TIF_SIGPENDING 标识位之外,还试图唤醒这个进程或者线程,也就是将它从等待状态中设置为 TASK_RUNNING。
当这个进程或者线程再次运行的时候,我们根据进程调度第一定律,从 schedule 函数中返回,然后再次进入 while 循环。由于这个进程或者线程是由信号唤醒的,而不是因为数据来了而唤醒的,因而是读不到数据的,但是在 signal_pending 函数中,我们检测到了 _TIF_SIGPENDING 标识位,这说明系统调用没有真的做完,于是返回一个错误 ERESTARTSYS,然后带着这个错误从系统调用返回。
然后,我们到了 exit_to_usermode_loop->do_signal->handle_signal。在这里面,当发现出现错误 ERESTARTSYS 的时候,我们就知道这是从一个没有调用完的系统调用返回的,设置系统调用错误码 EINTR。
信号的发送与处理是一个复杂的过程,这里来总结一下。
- 假设我们有一个进程 A,main 函数里面调用系统调用进入内核。
- 按照系统调用的原理,会将用户态栈的信息保存在 pt_regs 里面,也即记住原来用户态是运行到了 line A 的地方。
- 在内核中执行系统调用读取数据。
- 当发现没有什么数据可读取的时候,只好进入睡眠状态,并且调用 schedule 让出 CPU,这是进程调度第一定律。
- 将进程状态设置为 TASK_INTERRUPTIBLE,可中断的睡眠状态,也即如果有信号来的话,是可以唤醒它的。
- 其他的进程或者 shell 发送一个信号,有四个函数可以调用 kill、tkill、tgkill、rt_sigqueueinfo。
- 四个发送信号的函数,在内核中最终都是调用 do_send_sig_info。
- do_send_sig_info 调用 send_signal 给进程 A 发送一个信号,其实就是找到进程 A 的 task_struct,或者加入信号集合,为不可靠信号,或者加入信号链表,为可靠信号。
- do_send_sig_info 调用 signal_wake_up 唤醒进程 A。
- 进程 A 重新进入运行状态 TASK_RUNNING,根据进程调度第一定律,一定会接着 schedule 运行。
- 进程 A 被唤醒后,检查是否有信号到来,如果没有,重新循环到一开始,尝试再次读取数据,如果还是没有数据,再次进入 TASK_INTERRUPTIBLE,即可中断的睡眠状态。
- 当发现有信号到来的时候,就返回当前正在执行的系统调用,并返回一个错误表示系统调用被中断了。
- 系统调用返回的时候,会调用 exit_to_usermode_loop。这是一个处理信号的时机。
- 调用 do_signal 开始处理信号。
- 根据信号,得到信号处理函数 sa_handler,然后修改 pt_regs 中的用户态栈的信息,让 pt_regs 指向 sa_handler。同时修改用户态的栈,插入一个栈帧 sa_restorer,里面保存了原来的指向 line A 的 pt_regs,并且设置让 sa_handler 运行完毕后,跳到 sa_restorer 运行。
- 返回用户态,由于 pt_regs 已经设置为 sa_handler,则返回用户态执行 sa_handler。
- sa_handler 执行完毕后,信号处理函数就执行完了,接着根据第 15 步对于用户态栈帧的修改,会跳到sa_restorer 运行。
- sa_restorer 会调用系统调用 rt_sigreturn 再次进入内核。
- 在内核中,rt_sigreturn 恢复原来的 pt_regs,重新指向 line A。
- 从 rt_sigreturn 返回用户态,还是调用 exit_to_usermode_loop。
- 这次因为 pt_regs 已经指向 line A 了,于是就到了进程 A 中,接着系统调用之后运行,当然这个系统调用返回的是它被中断了,没有执行完的错误。
此文章为11月Day18学习笔记,内容来源于极客时间《趣谈Linux操作系统》,推荐该课程。