1. 原有的基于TSS的任务切换的不足
原有的Linux 0.11采用基于TSS和一条指令,虽然简单,但这指令的执行时间却很长,在实现任务切换时大概需要200多个时钟周期。而通过堆栈实现任务切换可能要快,而且采用堆栈的切换还可以使用指令流水的并行化优化技术,同时又使得CPU的设计变得简单。所以无论是Linux还是Windows,进程/线程的切换都没有使用Intel 提供的这种TSS切换手段,而都是通过堆栈实现的。
2. 进程切换的六段论
基于内核栈实现进程切换的基本思路:当进程由用户态进入内核时,会引起堆栈切换,用户态的信息会压入到内核栈中,包括此时用户态执行的指令序列EIP。由于某种原因,该进程变为阻塞态,让出CPU,重新引起调度时,操作系统会找到新的进程的PCB,并完成该进程与新进程PCB的切换。如果我们将内核栈和PCB关联起来,让操作系统在进行PCB切换时,也完成内核栈的切换,那么当中断返回时,执行IRET
指令时,弹出的就是新进程的EIP,从而跳转到新进程的用户态指令序列执行,也就完成了进程的切换。这个切换的核心是构建出内核栈的样子,要在适当的地方压入适当的返回地址,并根据内核栈的样子,编写相应的汇编代码,精细地完成内核栈的入栈和出栈操作,在适当的地方弹出适当的返回地址,以保证能顺利完成进程的切换。同时完成内核栈和PCB的关联,在PCB切换时,完成内核栈的切换。
2.1 中断进入内核
- 为什么要进入内核中去?
大家都知道,操作系统负责进程的调度与切换,所以进程的切换一定是在内核中发生的。要实现进程切换,首先就要进入内核。而用户程序都是运行在用户态的,在Linux中,应用程序访问内核唯一的方法就是系统调用,应用程序通过操作系统提供的若干系统调用函数访问内核,而该进程在内核中运行时,可能因为要访问磁盘文件或者由于时间片耗完而变为阻塞态,从而引起调度,让出CPU的使用权。 - 从用户态进入内核态,要发生堆栈的切换
系统调用的核心是指令int 0x80
这个系统调用中断。一个进程在执行时,会有函数间的调用和变量的存储,而这些都是依靠堆栈完成的。进程在用户态运行时有用户栈,在内核态运行时有内核栈,所以当执行系统调用中断int 0x80
从用户态进入内核态时,一定会发生栈的切换。而这里就不得不提到TSS的一个重要作用了。进程内核栈在线性地址空间中的地址是由该任务的TSS段中的ss0和esp0两个字段指定的,依靠TR寄存器就可以找到当前进程的TSS。也就是说,当从用户态进入内核态时,CPU会自动依靠TR寄存器找到当前进程的TSS,然后根据里面ss0和esp0的值找到内核栈的位置,完成用户栈到内核栈的切换。TSS是沟通用户栈和内核栈的关键桥梁,这一点在改写成基于内核栈切换的进程切换中相当重要! - 从用户态进入内核发生了什么?
当执行int 0x80
这条语句时由用户态进入内核态时,CPU会自动按照SS、ESP、EFLAGS、CS、EIP的顺序,将这几个寄存器的值压入到内核栈中,由于执行int 0x80
时还未进入内核,所以压入内核栈的这五个寄存器的值是用户态时的值,其中EIP为int 0x80
的下一条语句"=a" (__res)
,这条语句的含义是将eax所代表的寄存器的值放入到_res变量中。所以当应用程序在内核中返回时,会继续执行 “=a” (__res) 这条语句。这个过程完成了进程切换中的第一步,通过在内核栈中压入用户栈的ss、esp建立了用户栈和内核栈的联系,形象点说,即在用户栈和内核栈之间拉了一条线,形成了一套栈。 - 内核栈的具体样子
父进程内核栈的样子
执行int 0x80
将SS、ESP、EFLAGS、CS、EIP入栈。
在system_call中将DS、ES、FS、EDX、ECX、EBX入栈。
system_call:
cmpl $nr_system_calls-1,%eax
ja bad_sys_call
push %ds
push %es
push %fs
pushl %edx
pushl %ecx # push %ebx,%ecx,%edx as parameters
pushl %ebx # to the system call
movl $0x10,%edx # set up ds,es to kernel space
mov %dx,%ds
mov %dx,%es
movl $0x17,%edx # fs points to local data space
mov %dx,%fs
call sys_call_table(,%eax,4)
pushl %eax
movl current,%eax
cmpl $0,state(