开始之前,我们暂时先简单的定义一个概念,CPU任务,是指在CPU中运行的一段程序。根据任务的大小,粒度不同,一个任务可以是一个进程,一个线程,或者是一个中断处理程序等。
一台冯诺依曼体系结构的计算机是有这五个部门构成:运算器、控制器、存储器、输入设备、输出设备。其中运算器、控制器组成CPU。而输入输出设备和任务切换基本没什么关系,所以可以简单的认为任务切换主要包括CPU切换和内存切换(地址空间)两部分。
而在每个任务运行前,CPU 都需要知道任务从哪里加载、又从哪里开始运行,也就是说,需要系统事先帮它设置好CPU 寄存器和程序计数器。CPU 寄存器和程序计数器就是 CPU 上下文,因为它们是任务运行时必须的依赖环境。
任务切换的本质
对于CPU来讲,任务切换的本质就是CPU状态的切换,就是指CPU从一个任务中的状态切换到另一个任务的状态。再具体一点,CPU状态指的就是CPU寄存器和程序计数器的状态。而寄存器和程序计数器又可以称为CPU上下文。
对于任务切换的方案,一个最简单的方法就是:
1,保存当前状态,把当前CPU的状态保存到一个地方。
2,重新加载CPU状态,把新任务的状态加载进CPU运行。
假设计算机运行着A,B两个任务,当前运行A任务,准备要切换到B任务,然后再切换回A任务,这个过程是这样:
1,先把CPU当前的状态,也就是把任务A的状态保存到一个指定的内存地址,这个地址我们叫它C;
2,假设任务B的状态保存在内存地址D中,那我们就去内存地址D中获取任务B的状态并把它加载进CPU中运行,到此第一次任务切换就已经完成了。
3,当再次切换会任务A的时候,那就先把当前任务B的状态保存到内存地址D中,然后从内存地址C中获取任务A的状态然后重新加载到CPU中运行,这时第二次任务切换已经完成,CPU有恢复到了任务A的状态。
关于保存任务状态的那一块内存,在x86架构中叫做TSS任务状态段。
CPU 上下文切换
CPU上下文切换是指把当前的 CPU 上下文( 寄存器和程序计数器)保存起来,然后加载下一个要运行的任务的上下文到CPU寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
对于CPU上下文切换,我们先来看一下英特尔x86提供的硬件解决方案,然后再看Linux的方案。
英特尔进程切换方案
在 x86 体系结构中,提供了一种以硬件的方式进行CPU上下文切换的模式,对于每个任务(进程),x86 希望在内存里面维护一个 TSS(Task State Segment,任务状态段)结构。在里面保存所有的寄存器。另外,还有一个特殊的寄存器 TR(Task Register,任务寄存器),指向某个进程的 TSS。更改 TR 的值,将会触发硬件电路保存 CPU 所有寄存器的值到当前进程的 TSS 中,然后从新进程的 TSS 中读出所有寄存器值,加载到 CPU 对应的寄存器中。32 位的 TSS 结构如下图所示。
图片来自 Intel® 64 and IA-32 Architectures Software Developer’s Manual Combined Volumes
英特尔的方案有个缺点:每次切换都要先保存所有的寄存器状态,加载也是全量加载寄存器。但是实际上进程切换的时候,根本没必要每个寄存器都切换,因为并不是所有的寄存器都和进程切换相关。
Linux进程切换方案
Linux 操作系统在系统初始化的时候,在cpu_init 中会给每一个 CPU 关联一个 TSS,然后将 TR 指向这个 TSS,然后在操作系统的运行过程中,TR 寄存器是不会切换的,永远指向这个 TSS。
void cpu_init(void)
{
int cpu = smp_processor_id();
struct task_struct *curr = current;
struct tss_struct *t = &per_cpu(cpu_tss, cpu);
......
load_sp0(t, thread);
set_tss_desc(cpu, t);
load_TR_desc();
......
}
TSS 用数据结构 tss_struct 表示,在 x86_hw_tss 中可以看到和上图相应的结构。
struct tss_struct {
/*
* The hardware state:
*/
struct x86_hw_tss x86_tss;
unsigned long io_bitmap[IO_BITMAP_LONGS + 1];
}
在 Linux 中,真正参与进程切换的寄存器不多,主要的就是栈顶寄存器。于是,在 进程的task_struct 里面,用成员变量 thread来保留了要切换进程的时候需要修改的寄存器。 进行进程切换的时候,就将某个进程的 thread_struct 里面的寄存器的值,写入到 CPU 的 TR 指向的 tss_struct,对于 CPU 来讲,这就算是完成了切换。
地址空间切换
我们知道不同的进程的虚拟地址空间是完全独立的,地址空间分为内核地址空间和用户地址空间。对于内核空间,不同进程是共用同一个内核空间,只有用户空间才是各用各的。所以在进程切换时,地址空间切换的时候只需要切换用户空间就可以了。
程序要访问物理内存,需要通过MMU内存管理单元来将虚拟地址转换成物理地址, 而MMU需要通过分页机制和页表来完成虚拟地址到物理地址的转换。每个进程都有独立的地址空间,为了这个进程独立完成映射,这就需要每个进程都有独立的进程页表。也就是说进程切换的时候需要进行页表切换。这里需要引入一个寄存器,在英特尔CPU中有个CR3寄存器,这个寄存器又叫PDBR(Page Directory Base Register)页表基址寄存器。顾名思义,这个寄存器指向的是进程的顶级PGD(Page Global Directory页面目录),注意,这个寄存器保存的是真实的物理地址。每个任务的TSS段就有自己的CR3的物理地址。每次进行任务切换的时候,CR3的内容都会被替换改为新任务的CR3域中的物理地址。这里我们已经可以看到,页表切换其实也是通过寄存器切换来完成的。
当某个进程被调度到某个 CPU 上运行的时候,要调用 context_switch 进行上下文切换。对于内存方面的切换会调用 switch_mm_irqs_off,这里面会调用 load_new_mm_cr3来重新加载CR3寄存器。
至此,进程切换就可以告一段落了。当然对于地址空间的切换,还有一些细节没讲,比如内核栈和用户栈的切换没有讲,这些要放到另一篇文章来讲。
线程切换
当然这里的线程指的是同一个进程中的线程。每个进程都有一个独立的虚拟地址空间,进程内的线程都是共享同一个地址空间。所以很明了,线程切换不需要进行地址空间切换,这个就是线程切换和进程切换的最大的区别。那么这个区别会带来什么影响呢?
上面讲到,任务切换的时候要把新任务的一些寄存器加载到CPU,其中就保持CR3寄存器。每当用MOV指令重置CR3的值时,会导致分页机制高速缓冲区的内容无效。在任务切换时,CR3要被改变,但是如果新任务中CR3的值与原任务中CR3的值相同,那么处理器不刷新分页高速缓存,以便当任务共享页表时有较快的执行速度。
我们知道,要访问物理地址需要去查页表,而页表往往是多级的,比如说64位的Linux操作系统默认是采用4级页表,也就是说这个过程需要多次访问内存才能找到对应的物理地址。为了加快页表查找,操作系统会把一些页表项加入到一个高速缓存--TLB(translation Lookaside Buffer)。只有在TLB中找不到对应的页表项,才会到内存中查询页表,这样就减少了由于页表查询导致的处理器性能下降。但是在进程切换的时候,当我们进行地址空间切换后(修改CR3寄存器),TLB高速缓存中的数据就会失效。缓存失效就会导致命中率降低(cache miss),那么虚拟地址转换为物理地址就会变慢,表现出来的就是程序运行会变慢。同样的道理,除了TLB,由于地址空间切换也会导致CPU其它的高速缓存(L1,L2等)失效。
而线程切换并不需要切换地址空间,所以就不会有缓存失效这个问题。