进程切换(process switch),作为抢占式多任务OS中重要的一个功能,其实质就是OS内核挂起正在运行的进程A,然后将先前被挂起的另一个进程B恢复运行。
硬件上下文
每个进程都有自己的地址空间,但是所有进程在物理上共享着CPU的寄存器,因此,当恢复一个进程执行前,OS内核必须要将挂起该进程时寄存器的值装入CPU寄存器。进程恢复执行前必须装入寄存器的一组数据就叫做“硬件上下文”(hardware context),它是进程执行上下文的子集,后者是进程执行时需要的所有信息(如地址空间中的数据等)。
Linux中,TSS保存着部分的进程的硬件上下文(如ss、esp等寄存器的值),剩余部分保存在内核堆栈中(如eax、ebx等通用数据寄存器的值)。
进程切换只发生在内核态,在进程切换之前,用户态使用的寄存器内容都已保存在内核堆栈上,如ss、esp等。
任务状态段(TSS)
80×86体系结构中有个特殊的段——TSS,用来存放硬件上下文。Linux为每个CPU分配一个TSS。这样,当一个CPU从用户态切换到内核态时,就从TSS中得到内核态的堆栈地址,如果用户态程序试图用in或out指令访问I/O设备时,CPU就可以访问在TSS中的I/O许可位图(I/O Permission Bitmap)来检查该操作是否合法。
tss_struct结构描述TSS的格式。系统中有一个全局数组——init_tss,里面保存着每个不同CPU的TSS(n个CPU就有n个TSS)。由此可见,TSS表示了CPU上当前进程的信息,没有必要为每个进程都分配TSS。
Linux创建的TSSD(任务状态段描述符)存放在GDT中,GDT的基地址保存在每个CPU的gdtr寄存器里。每个CPU的tr寄存器里有相应的TSS的TSSD的选择子,这可以用来在GDT中定位TSSD,从而得到TSS。CPU中有两个不可编程的寄存器,存放TSSD的Base字段和Limit字段,这样CPU可以快速地对TSS寻址,而不需经过GDT。
因为Linux为每个CPU分配TSS,而不是每个进程分配TSS,因此,被替换的进程的硬件上下文必须保存在别处,不能存在TSS中。每个进程描述符中有一个字段thread——一个thread_struct类型的字段,使用它可以保存部分硬件上下文。该结构中包含了大部分的CPU寄存器(如esp、eip等),但不包含eax、ebx之类的通用寄存器,因为它们保存在进程内核堆栈中。
执行进程切换
进程切换发生在schedule()函数中。进程切换分为两个步骤:
- 切换页全局目录(Page Global Directory)来加载一个新的地址空间,实际上就是加载新进程的cr3寄存器值。
- 切换内核堆栈和硬件上下文,这些包含了内核执行一个新进程的所有信息,包含了CPU寄存器。
现在假设prev表示即将被替换的进程的描述符,next表示即将执行的进程的描述符。其实,prev和next都是schedule()函数的局部变量。
switch_to宏
这里讨论进程切换的第2个步骤,该步骤通过switch_to宏来实现。
switch_to宏它有三个参数:prev、next、last。prev和next不需要解释,last参数是干什么的呢?实际上,任何进程切换涉及3个进程,不仅仅是2个。
假设内核决定将进程A挂起,执行进程B,那么在schedule()函数中,prev就是进程A的描述符地址,next就是进程B的描述符地址,一旦switch_to挂起A,那么进程A就冻结了。后来,当内核想重新执行进程A,它必须通过switch_to宏来挂起进程C(通常不是进程B),此时prev代表C、next代表A。当A恢复执行,它得到它原来的内核堆栈,在这个原来的内核堆栈里,prev代表A,next代表B。此时,代表进程A的内核代码失去了对进程C的引用,就找不到进程C了。事实证明,这个引用对于完成进程切换是有用的。
switch_to的last参数是一个输出参数,表示宏把进程C的描述符地址写在内存的某个地址(这是在A恢复执行后完成的)。在进程切换之前,switch_to把prev的值写入eax。在A恢复执行后,此时还是在switch_to宏代码中,A得到它原来的内核堆栈,prev是A的描述符地址,注意,因为CPU内eax寄存器的值不会因为切换而变化,因此,eax里存的是进程C的描述符地址,switch_to会将eax的值写入到last中,原来last指向进程A的prev就被C的描述符地址覆盖了。
关于switch_to宏的分析,请见下一篇。
__switch_to函数
switch_to宏里有一句“jmp __switch_to”,即跳转到__switch_to函数开始执行。该函数完成进程切换第2步骤的大部分工作。该函数是FASTCALL调用方式(利用关键字__attribute__(regparm(3))声明),因此参数用通用数据寄存器传递——eax传递prev_p、edx传递next_p。
关于__switch_to函数的分析,请见下一篇。
保存和加载FPU、MMX和XMM寄存器
从Intel 80486DX开始,FPU(算术浮点单元)被集成到了CPU中,浮点算术功能用ESCAPE指令来执行,操纵CPU中的浮点寄存器集。显然,当一个进程正在使用ESCAPE指令,那么浮点寄存器的内容就属于它的硬件上下文。
为了加速多媒体程序的执行,Intel在微处理器中引入了新的指令集——MMX,MMX指令也作用于FPU的浮点寄存器。这样,MMX就不能和FPU指令混用,但是OS内核就可以忽略新的MMX指令集,因为保存浮点寄存器的功能代码也能够应用于MMX的状态。
MMX使用SIMD(单指令多数据)流水线,Pentium III增强了这种SIMD能力,引入SSE(Streaming SIMD Extensions)扩展。该功能增强了8个128位寄存器(XMM寄存器)的功能,这些寄存器不和FPU/MMX寄存器重叠,因此能够与FPU/MMX指令混用。
Pentium IV还引入了SSE2扩展,支持高精度浮点值,SSE2和SSE使用同一个XMM寄存器组。
80×86微处理器不在TSS中保存FPU、MMX和XMM寄存器的值,不过还是提供了一些支持,能够在需要时保存它们。cr0寄存器有一个TS(Task-Switching)标志位,每当执行硬件上下文切换时,TS置位,每当TS被置位后进程执行ESCAPE、MMX、SSE或SSE2指令,控制器就产生一个“Device not available”异常。这样,TS标志位就能够让OS内核只有在真正需要时才保存或恢复FPU、MMX和XMM寄存器。
假设进程A使用了数学协处理器,那么当进程A被切换出去的时候,内核设置TS并将浮点寄存器的内容保存到进程A的TSS中(原著这么写,但是应该是保存到进程A描述符的一个字段中,TSS是与CPU关联的,进程没有TSS)。
如果新进程B不使用数学协处理器,那么内核就不需要恢复浮点寄存器的内容,但是,一旦进程B执行FPU、MMX等指令,CPU就产生一个“Device not available”异常,相应的异常处理程序就会用保存在进程B中的相关值来恢复浮点寄存器。
处理FPU、MMX和XMM寄存器的数据结构存放在进程描述符的thread字段的i387子字段中(即thread.i387),由i387_union联合体描述,其格式如下:
union i387_union {
struct i387_fsave_struct fsave; /* 保存FPU、MMX寄存器的内容 */
struct i387_fxsave_struct fxsave; /* 保存SSE和SSE2寄存器内容 */
struct i387_soft_struct soft; /* 由无数学协处理器的老式CPU模型使用 */
};
此外,进程描述符中还包含了两个附加的标志:
- thread_info结构中status字段的TS_USEDFPU标志,表示进程当前执行过程中是否使用过FPU、MMX和XMM寄存器。
- task_struct结构的flags字段的PF_USED_MATH标志,表示thread.i387的内容是否有意义。
保存和加载FPU、MMX和XMM寄存器主要用到__unlazy_fpu宏,该宏在__switch_to函数中使用,下一篇会对其进行分析。
内核态使用FPU、MMX和XMM寄存器
OS内核也可以使用FPU、MMX和XMM寄存器,当然,这么做的时候应该避免干扰用户态进程。因此,Linux使用如下方法来解决:
- 在内核使用协处理器之前,如果用户态进程使用了FPU(TS_USEDFPU标志为1),内核就要调用kernel_fpu_begin()函数,该函数里又调用save_init_fpu()来保存寄存器内容,然后重新设置cr0寄存器的TS标志。
- 使用完协处理器之后,内核调用kernel_fpu_end宏设置cr0寄存器的TS标志。
- 当用户态进程恢复执行时,math_state_restore()函数将恢复FPU、MMX和XMM寄存器的内容。
需要注意的是,如果当前用户态进程有在用数学协处理器时,kernel_fpu_begin()函数的执行时间比较长,甚至无法通过FPU、MMX或XMM达到加速的目的。因此,内核只在有限的场合使用FPU、MMX或XMM指令,比如移动或清除大内存区字段、计算校验和等。