在初学 linux 编程的时候,一直觉得异步信号handle是个很神奇的东西,用户程序可以使用singal之类的系统调用为某某信号注册一个信号处理函数(handle函数)。
程序的二进制代码在内存中都有着确定的执行流程,为什么收到异步信号以后,程序会被“中断”,然后跳转到这个handle函数里面去运行呢?内核怎么有能力让程序做这样的跳转呢,总不可能临时修改程序的可执行代码吧?
后来学习了一些内核知识,才知道原来进程收到信号以后,并不是立即就被“中断”的,而是先在进程的控制结构(task_struct)中记录下收到了某某信号,然后等到进程即将从内核态返回用户态的时候,流程才被“中断”,handle函数才被调用。
用户进程什么时候会从内核态返回用户态呢?一般主要是三种情况:系统调用(用户进程主动进入内核)、中断(用户进程被动进入内核)、被调度执行(用户进程从等待执行变为正在执行)。
进程从收到信号到它从内核态返回用户态的过程,是需要一定时间的。但是这个时间一般会很短,至少 时钟 中断会以较大的频率(比如1毫秒一次)将用户进程带入内核(当然,只针对正在执行的进程)。
在进程即将从内核态返回用户态时,如果有信号需要处理,对应的handle函数将被调用(当然,可能没有注册handle,这时内核对信号进行默认的处理)。注意,现在进程还在内核态,内核是怎么调用用户态的handle函数的呢?
直接调用可以吗?当然不行。内核代码运行在高 CPU 特权级别下,如果直接调用handle函数,则handle函数也将在相同的CPU特权下被执行。那么用户将可以在handle函数里面为所欲为。
所以,调用handle必须先返回用户态。但是返回用户态后,程序流程又不受内核控制了,难不成内核还真的把用户进程的可执行代码临时改掉?
内核实际的做法还是比较巧妙。用户进程进入内核以后,都会在其对应的内核栈上留下返回地址,以便流程返回。内核调用handle函数的办法就是临时改掉栈上的返回地址,然后按原有的返回用户态的流程去返回。结果这一返回,就到了handle函数去了。(当然,需要修改的并不止是返回地址,而是一整个调用栈。)
虽然现在临时把返回地址改了,但是用户进程最终还是要返回到原先那个返回地址去的。那么,原先的返回地址及其调用栈应该保存在哪里呢?进程的内核栈空间有限,并且还需要应付handle函数中可能发生的系统调用,所以内核把这些信息放在内核栈上是不现实的,只能压到了用户栈上去。
当handle函数执行完毕,执行流程要返回到内核去。同样,由于CPU特权级别不同,从handle函数返回内核时不能单纯地利用RET指令去返回的。需要执行一次系统调用。
在handle执行完后,为什么要回到内核,再从内核返回到原始返回地址呢?如果直接返回到原始的返回地址那自然是很便捷。并且要这么做也不难,原始返回地址及其调用栈已经被压到了用户栈上,内核只需要在handle函数的调用栈上稍做手脚就行了。
1、返回到原始返回地址并不是回到那个地址就行了,需要把整个现场都恢复(主要是寄存器什么的)。当然,内核也可以在用户栈上面压一些代码,来完成这些事情;
2、现在可能不止一个信号要处理,最好让用户进程返回内核,继续处理其他信号;
为了返回内核,首先,内核在返回到handle函数之前,先将某个返回地址压到用户栈上,以便从handle返回时能够返回到指定的地址上。这个指定的地址其实也在进程的用户栈上,内核又在这个地址上放了几条指令(在栈上放置可执行代码),让进程去调用一个名叫sigreturn的系统调用。
返回到handle函数前的用户栈大致如下:
原有数据-》调用sigreturn的指令(设其地址为a)-》原始返回地址及其调用栈-》返回地址(值为a)-》handle的栈变量
内核在handle函数的调用栈上放置sigreturn指令,这是在linux2.4时的做法。每次调用用户的handle函数都需要向用户栈拷贝这么几条指令,这并不太好。
linux2.6有一个叫vsyscallpage的页面,上面包含了内核为用户程序准备的一些指令,其中就包括调用sigreturn指令。这个vsyscall页被映射到每个进程的虚拟地址空间靠近末尾的部分,被所有用户进程共享,对于用户进程是只读的。这样,handle函数的调用栈上就不需要再塞入sigreturn指令了,直接将handle函数的返回地址设为vsyscall页中对应的代码即可。
为了让handle执行完以后自动调用sigreturn返回内核,内核做了很多事情。那么可不可以约定好,让用户自己去调用sigreturn呢?
当然,这是可以的。只是为了让信号处理机制成为一套完整的机制,内核并没有这么做。否则用户在handle函数里面忘记调用sigreturn的话,可能莫名其妙地进程就崩溃了。而编译器也很难找出这样的错误。
进程调用sigreturn系统调用重新进入内核后,压在用户栈上的原始返回地址及其调用栈被获取。最终内核又会修改栈,让进程返回用户空间时返回到这个原始返回地址上。