内核版本
- linux-v5.8
体系结构
- arm64
一、前言
操作系统运行过程中,时刻进行着各种上下文的相互切换:
- 用户空间和内核空间
- 进程上下文与中断上下文
- 不同进程上下文之间
跟读并理解这些上下文的切换过程,可以对操作系统的运转过程有更为清晰的认知,本文基于arm64体系架构,阐述Linxu系统中各种上下文的切换过程。
二、用户空间与内核空间相互切换
2.1 为什么存在用户空间与内核空间?
在构建操作系统时,为了实现系统稳定,对资源的有效利用以及多任务切换等操作,需要操作系统拥有绝对的控制权;这就需要将操作系统与用户进程隔离,使运行在其上的用户进程受限直接执行(LDE,Limited Direct Execution),以防止其长期霸占CPU执行,修改系统敏感资源、配置,窃取其他用户进程的用户信息等;另外如果用户进程发生错误(如除零,解引用空指针等),也希望不会影响到操作系统和其他用户进程的正常运行。
LDE机制的实现需要软硬件共同参与,以arm64为例,它提供了多种异常级别:
图一 arm64异常级别
操作系统运行在EL1层级(也称为内核空间),可以执行各种特权指令,如开关中断,存取寄存器等;而用户进程则运行在EL0层级(也称为用户空间),如果需要执行一些特权操作,则需要切换到EL1层级,进入内核空间,由操作系统代为执行,从而使操作系统拥有绝对的控制权。
2.2 用户进程在用户空间和内核空间的区别
那么一个用户进程运行在用户空间和运行在内核空间有什么区别呢?也就是说切换时需要切换什么呢?
- 首先,每个用户进程都拥有自己的用户栈和内核栈,因此需要切换栈;
- 然后,每个用户进程都有进程地址空间,进程地址空间又分别用户地址空间和内核地址空间,所有进程(包括内核线程)共享内核地址空间,但拥有自己的用户地址空间,地址空间就是一套用于翻译虚拟地址到物理地址的页表,全局页目录基地址存放在特定寄存器中(EL0与EL1对应不同的寄存器),因此需要切换硬件MMU翻译时读取的页表基址寄存器;
- 最后,用户进程在进入内核空间,然后返回用户空间时需要恢复之前的状态继续执行,因此需要save和restore硬件上下文信息,比如处理器状态,pc指针等
2.3 切换场景有哪些?
了解要切换的内容后,还需要知道切换的场景或者说切换的时机:
- 首先,用户进程如果想要主动的执行一些特权操作,如read/write某个设备文件时,便会通过各种系统调用陷入内核,完成对应操作会再次返回用户空间;
- 其次是中断,当用户进程运行在用户空间时,如果此时发生某个外设中断,会马上陷入内核,执行相应的中断处理程序,执行完后会再次返回用户空间;该机制可保证系统对外部事件(如键盘、鼠标)的快速响应,提升系统交互性能,也是操作系统夺回控制权的主要手段之一(时钟中断,也是驱动进程调度的核心);
- 上述的两种场景其实都属于异常,系统调用属于同步异常,除此之外还有数据中止、指令中止和调试异常等;中断属于异步异常,除此之外还有系统错误等,这些异常都会导致用户空间与内核空间的切换,这些异常的具体描述可参见ARMv8-A PG手册10.2小节;
- 以上场景都是用户进程陷入内核然后返回用户空间的模式,我们知道所有的用户进程都是1号用户进程或者其子孙进程fork出来的,但Linux系统初始化时创建的都是内核线程,因此还有一种场景是1号内核线程演变为1号用户进程时,会装载init程序,然后由内核空间切换到用户空间,从而形成1号用户进程。
注意:前三种情况返回用户空间时是著名的调度时机,因此返回时马上恢复运行的不一定时被打断的用户进程;另外还有特殊的fork系统调用,除了父进程按照系统调用返回路径返回用户空间外,还有子进程从ret_from_fork返回
2.4 具体切换流程
具体的切换过程需要硬件和操作系统的共同参与。
2.4.1 异常向量表设置
在发生异常时,硬件需要知道该跳转到哪些OS代码进行执行,因此arm64硬件提供了VBAR_ELx(Vector Based Address Registers),即向量基准地址寄存器(除EL0外,其他ELx都有自己的VBAR寄存器,因为没有异常会陷入到EL0进行处理),操作系统在初始化过程中,会构建异常向量表,并将其起始虚拟地址写入VBAR_EL1寄存器,具体代码在体系结构相关代码的entry.S和head.S文件:
/* arch/arm64/kernel/entry.S */
SYM_CODE_START(vectors)
kernel_ventry 1, sync // Synchronous EL1h
kernel_ventry 1, irq // IRQ EL1h
kernel_ventry 0, sync // Synchronous 64-bit EL0
kernel_ventry 0, irq // IRQ 64-bit EL0
...
/* arch/arm64/kernel/head.S */
SYM_FUNC_START_LOCAL(__primary_switched)
adr_l x8, vectors // load VBAR_EL1 with virtual
msr vbar_el1, x8 // vector table address
这样硬件就可以根据异常情况跳转到对应的异常向量进行执行(设置pc指针),比如在用户空间发生同步异常或中断会跳转到el0_sync或el0_irq,在内核空间发生同步异常或中断会跳转到el1_sync或el1_irq。
2.4.2 用户空间切换到内核空间
首先硬件会自动进行一部分工作:
- 将cpu当前状态(PSTATE,Process STATE)保存到寄存器SPSR_EL1(保存程序状态寄存器,Saved Processor State Register)
- 将返回地址保存到寄存器ELR_EL1(异常链接寄存器,Exception Link Register),注意该返回地址是返回用户空间时应该执行的第一条指令,各种异常情况下不一致:
- 系统调用返回时,执行的是系统调用指令后的指令
- 其他同步异常返回时,执行的是发生异常的指令(比如在COW机制中,发生page fault后会重新进行存取操作)
- 中断返回时,执行的是被打断执行的第一条指令
另外该返回地址也和函数调用时的lr不一致,ARMv8-A PG手册中的一幅图很直观的表示了该过程:
图二 硬件操作
- 将异常级别从EL0提升到EL1,使用的栈指针寄存器(Stack Pointer)由SP_EL0变为了SP_EL1,使用页表基址寄存器(Translation Table Base Registers)由TTBR0_EL1变为TTBR1_EL1,即切换了栈和地址空间
- 根据VBAR_EL1寄存器中的异常向量表基地址和异常原因,跳转到对应的异常向量执行(el0_sync、el0_irq)
然后开始进入软件流程,el0_sync和el0_irq都是调用宏kernel_entry进行处理:
kernel_entry 0 /* 因为在用户空间发生异常,即EL0,因此传入的参数为0 */
大致流程如下:
- 首先将通用寄存器x0~x29保存到当前进程的内核栈中,这里留下一个小问题(在2.5小节进行阐述),当前进程的内核栈是如何创建并保存到SP_EL1中的呢?
stp x0, x1, [sp, #16 * 0]
...
stp x28, x29, [sp, #16 * 14]
- 从SP_EL0、SPSR_EL1、ELR_EL1寄存器中读取用户栈栈顶地址、发生异常时的处理器状态和返回地址,将这三个值以及发生异常时的LR寄存器中的值都保存到当前进程的内核栈中:
mrs x21, sp_el0
mrs x22, elr_el1
mrs x23, spsr_el1
stp lr, x21, [sp, #S_LR]
stp x22, x23, [sp, #S_PC]
以上的硬件上下文信息都以struct pt_regs结构体的格式保存在当前进程内核栈的栈底,这样就完成了硬件上下文的save过程:
struct pt_regs {
union {
struct user_pt_regs user_regs;
struct {
u64 regs[31];
u64 sp;
u64 pc;
u64 pstate;
};
};
...
};
另外,由于此时SP_EL0寄存器在用户进程进入内核空间时处于空闲状态,在arm64体系架构中,将当前进程的进程描述符存放在该寄存器中:
ldr_this_cpu tsk, __entry_task, x20
msr sp_el0, tsk
其中__entry_task为内核静态定义的percpu变量,在进程切换时,会将next进程的进程描述符保存到该变量中,因此此处拿到的即是当前进程的进程描述符:
/* arch/arm64/kernel/process.c */
DEFINE_PER_CPU(struct task_struct *, __entry_task);
__switch_to
--> entry_task_switch
--> __this_cpu_write(__entry_task, next);
这样内核空间就可以快速的读取到当前进程的进程描述符,即current宏的实现:
/* arch/arm64/include/asm/current.h */
static __always_inline struct task_struct *get_current(void)
{
unsigned long sp_el0;
asm ("mrs %0, sp_el0" : "=r" (sp_el0));
return (struct task_struct *)sp_el0;
}
#define current get_current()
2.4.3 内核空间切换到用户空间
首先软件上,el0_sync和el0_irq最后都是调用宏kernel_exit进行返回:
kernel_exit 0
内核空间切换到用户空间基本是2.4.2小节的反向操作,即从当前进程的内核栈中,将此前保存的寄存器x0~x29、SP_EL0、SPSR_EL1、ELR_EL1、LR中的值进行restore操作,最后将内核栈也清空。
最后调用eret指令返回,该指令会使得硬件从SPSR_EL1中恢复处理器状态,从ELR_EL1恢复PC,并从EL1模式切换到EL0,栈指针寄存器采用SP_EL0,页表基址寄存器使用TTBR0_EL1,这样硬件上下文、栈和地址空间都完成了切换,用户进程开始在用户空间继续执行。
2.5 进程的内核栈如何创建并保存到SP_EL1中?
1)内核线程和用户进程最后都是调用_do_fork函数进行创建,在创建过程中会分配内核栈空间,并记录在task_struct->stack
中:
/*_do_fork --> copy_process --> dup_task_struct() */
stack = alloc_thread_stack_node(tsk, node);
tsk->stack = stack;
具体流程在dup_task_struct()函数中实现,需要注意的是目前arm64版本默认配置了CONFIG_VMAP_STACK,即会从vmalloc区域分配内核栈的地址,也就是说
物理地址不一定是连续的,那么切记不要在驱动中使用局部变量作为DMA buffer(而且局部变量也无法保证cacheline对齐,即无法保证cache和DMA的一致性),另外在alloc_thread_stack_node()函数中为了提高从vmalloc区域分配内核栈空间的效率,还做了一个软件上的小cache,读者可以自行跟读。
2)分配完内核栈后,会将内核栈栈底用于保存硬件上下文的pt_regs区域的起始地址保存到进程描述符中:
/*_do_fork --> copy_process --> copy_thread_tls() */
struct pt_regs *childregs = task_pt_regs(p); /* 1. 获取进程内核栈栈底的pt_regs */
p->thread.cpu_context.pc = (unsigned long)ret_from_fork;
p->thread.cpu_context.sp = (unsigned long)childregs; /* 2. 记录在cpu_context中 */
3)当新建进程被调度上CPU运行时,会作为next进程进行进程切换:
/* __schedule --> context_switch --> switch_to --> cpu_switch_to */
add x8, x1, x10 /* x1为next进程的描述符 */
ldp x29, x9, [x8], #16 /* 获取next->thread.cpu_context.sp */
mov sp, x9 /* 此时在内核空间,使用SP_EL1,即将next进程的内核栈写入SP_EL1 */
至此我们可以确定,kernel_entry宏中读取的sp,指向的就是当前进程的内核栈。
2.6 1号内核线程如何转变为1号用户进程?
我们都知道,除了0、1、2号内核线程,其他内核线程都是由2号线程kthreadd创建和管理的,它们的整个生命周期都运行在内核空间,不会切换到用户空间执行,那么1号内核线程又是如何进入用户空间的呢?
1)对于内核线程,在copy_thread_tls()函数中还会将内核线程执行函数的地址以及传递的参数保存在进程描述符中:
/*_do_fork --> copy_process --> copy_thread_tls() */
p->thread.cpu_context.x19 = stack_start; /* 内核线程通过调用kernel_thread进行创建,stack_start在其中被设置为fn地址 */
p->thread.cpu_context.x20 = stk_sz; /* stk_sz在kernel_thread被设置为arg */
2)新建的内核线程或者用户进程都是从ret_from_fork开始执行,其中判断到是内核线程,会转去执行内核线程处理函数fn:
/* arch/arm64/kernel/entry.S */
SYM_CODE_START(ret_from_fork)
cbz x19, 1f /* 如果不是x19为空,那么不是内核线程,直接返回到1处返回用户空间 */
mov x0, x20
blr x19 /* 否则跳转到内核线程处理函数fn开始执行 */
1: get_current_task tsk
b ret_to_user /* 其中会调用kernel_exit宏切换到用户空间 */
NOKPROBE(ret_from_fork)
3)1号内核线程的处理函数为kernel_init:
/* init/main.c */
rest_init
--> pid = kernel_thread(kernel_init, NULL, CLONE_FS);
kernel_init函数在做完一系列的初始化后,会加载init程序,即1号用户进程,加载的过程本文不详细探究,主要关注以下内容(假设init程序bin文件为elf格式):
/* fs/binfmt_elf.c */
load_elf_binary
/* regs指向当前进程的内核栈栈底的pt_regs */
--> regs = current_pt_regs();
start_thread(regs, elf_entry, bprm->p);
--> regs->pc = pc; /* 设置pc为init程序的入口地址(即elf_entry) */
regs->pstate = PSR_MODE_EL0t; /* 设置处理器状态 */
regs->sp = sp; /* 设置用户栈 */
即加载过程中会构建硬件上下文信息。
4)接下来是重点内容,即kernel_init函数是会返回的!
/* kernel_init() */
if (execute_command) {
ret = run_init_process(execute_command);
if (!ret)
return 0; /* 加载成功返回0,否则直接panic */
panic("Requested init %s failed (error %d).",
execute_command, ret);
}
因此在ret_from_fork中会继续往下执行到kernel_exit,从而返回用户空间,运行的起点即是load_elf_binary函数中设置的init程序执行入口(即main函数)。
所以也可以猜到其他内核线程都不会返回,读者可以自行跟读,他们最后都是调用do_exit自行消亡。
2.7 小结
着重来看下用户进程的内核栈在用户空间和内核空间相互切换时的变化:
- 首先用户进程创建,并被调度上CPU运行,然后从ret_from_fork返回用户空间时,内核栈的变化如下:
图三 新建用户进程调度上cpu运行
- 当该用户进程由于系统调用等陷入内核空间时,栈的变化如下:
图四 陷入内核空间
- 在内核空间运行一段时间后,返回用户空间时,该进程内核栈将回到第一步图的最右边的状态。
三、进程上下文与中断上下文相互切换
查看ARMv8-A PG手册的10.6小节可知,arm64上的中断一共由四种类型:
- 软件生成的中断(SGI):通常用于实现处理器间中断(IPI),在进程调用中的负载均衡,或者标记其他core上的current进程需要resched等情况下都会用到IPI
- 私有外设中断(PPI):每处理器的私有中断,中断号可以与其他处理器上的相同,比如每处理器的时钟中断就属于该类型
- 共享外设中断(SPI):可以被中断控制器转发到各个处理器,通常的设备驱动程序注册的中断就是这个类型
- 最后还有个LPI,平时未涉及,不清楚具体用途,且早前版本的GICv2或者GICv1不支持该功能
在系统运行过程中,经常会有中断发生,根据被打断进程当前处于的异常级别,分别对应的异常向量为el0_irq和el1_irq。
3.1 需要切换什么?
1)如果中断发生在用户空间,那么会先从用户空间切换到内核空间,具体切换过程在上一节已经进行了阐述;如果中断发生在内核空间,此时不需要切换异常级别以及对应的栈和页表基地址寄存器,但还是需要保存当时的硬件上下文到当前进程的内核栈中,具体工作还是由宏kernel_entry完成,只不过传入的参数为1(EL1),读者可自行跟读;
2)接下来的重点在于不同体系结构对于中断处理函数所使用的栈的处理不同,有些是借用被打断进程的内核栈,但有些是每处理器拥有独立的中断栈(比如本文阐述的arm64),后面一种情况中,在完成上述内容的切换和保存后,还需要将栈指针从被打断进程的内核栈切换为本地处理器的中断栈;
3)另外,在发生中断时,硬件会自动将中断关闭,在中断处理函数完成之后会再次打开,打开的过程就是调用eret指令从SPSR_EL1中restore处理器状态,其中的DAIF位包含了中断使能位。
注意:最后一步本文忽略了在中断处理函数完成(irq_exit),到调用eret指令恢复上下文过程中可能发生的一些情况:
- 处理softirq请求
- 返回用户空间时,因当前进程被标记了TIF_NEED_RESCHED标记而发生主动调度(schedule)
- 返回内核空间时,因符合内核抢占条件,发生抢占调度(preempt_schedule_irq)
这三种情况下,都会先开启硬件中断(hardirq),处理完后(或者调度回来后)会再次关闭中断,具体流程读者可自行跟读。
3.2 内核栈与中断栈的切换
本文基于arm64体系结构,因此会发生被中断进程的内核栈与本地处理器中断栈之间的切换,该过程发生在宏irq_handler中:
- 内核栈切换到中断栈
首先会将当前sp中的内核栈栈顶地址保存到x19寄存器中:
mov x19, sp
然后会从percpu变量irq_stack_ptr中获取本地处理器的中断栈地址,并将该地址写入sp,这样即完成了栈的切换过程:
ldr_this_cpu x25, irq_stack_ptr, x26 /* 获取本地处理器中断栈 */
mov x26, #IRQ_STACK_SIZE
add x26, x25, x26
mov sp, x26 /* 切换到中断栈 */
irq_stack_ptr是内核静态定义的percpu变量:
DEFINE_PER_CPU(unsigned long *, irq_stack_ptr);
在内核初始化过程中,会分配每处理器的中断栈并将地址记录在irq_stack_ptr中(注意arm64默认配置CONFIG_VMAP_STACK,也就是说中断栈也是在vmalloc区域分配的):
/* start_kernel --> init_IRQ --> init_irq_stacks */
for_each_possible_cpu(cpu) {
p = arch_alloc_vmap_stack(IRQ_STACK_SIZE, cpu_to_node(cpu)); /* 分配中断栈 */
per_cpu(irq_stack_ptr, cpu) = p; /* 初始化irq_stack_ptr */
}
- 中断栈切换到内核栈
该过程非常简单,将之前保存在x19寄存器中的内核栈地址restore即可:
mov sp, x19
3.3 为什么中断上下文不能睡眠?
首先,这是Linux的设计选择,Linux调度器基于调度实体(sched entity),而中断上下文不被认为是一种调度实体,基本该设计,如果非要在中断上下文睡眠(发生调度),那么Linux内核不保证系统的正常运行,但具体会有哪些可能的后果呢?
现在假设用户进程A在用户空间被中断打断,在中断处理函数xxx_handler中会调用schedule函数进行主动调度:
static void xxx_handler (unsigned long data)
{
schedule();
}
在执行schedule函数之前,内核栈与中断栈的状态如下:
图五 内核栈切换到中断栈
此时使用的是本地处理器的中断栈,被打断的用户进程A的用户栈保存在pt_regs区域,内核栈栈顶地址存放在本地处理器的x19寄存器中;如果此时发生进程调度,那么此时的sp寄存器的值(即中断栈栈顶地址)将会保存到进程A的进程描述符中(即p->thread.cpu_context.sp,还有其他硬件上下文也会进行保存,细节在第四节会进行阐述),由于此时进程A还是TASK_RUNNING状态,所以会放回运行队列(比如cfs_rq)等待调度。
当进程A再次被调度回cpu0运行时,可能有以下几种情况:
- 假设进程A切出cpu0到进程A重新上cpu0执行期间,没有发生中断;那么在cpu_switch_to函数中会将图中sp指向的中断栈地址restore回sp寄存器,继续执行xxx_handler函数中schedule后面的指令,然后按照入栈的次序出栈,然后从x19寄存器读取进程A此前的内核栈栈顶地址,并将sp寄存器指向该地址(即切换回内核栈),然后继续el0_irq的后续流程,最后返回用户空间继续执行;该情境下,进程A除了莫名其妙被打断了很久外,运行并没有受到影响,操作系统也运行稳定;
- 假设期间发生了中断,由于中断栈时percpu而不是per task的,因此中断栈会被新的内容覆盖,当进程A被调度回来时,restore的sp地址的内容已经被改变了,进程A也就再也回不去了;
以上还只是最简单的情况,通常我们是因为获取锁失败而进入睡眠(比如获取mutex锁):
- 假设一个内核线程B在mutex临界区被中断,然后中断处理函数中又尝试获取该mutex锁,那么此时线程B会进入睡眠;在睡眠前,线程B会被设置为TASK_UNINTERRUPTIBLE状态,根本不会被放回运行队列rq,而是被放入等待队列中等待唤醒;但mutex锁只能由持锁者释放,那么线程B将永远睡眠下去
另外还可能内核线程C在持有spinlock锁时被中断,中断处理函数中调用schedule切换next进程执行,next进程中又尝试获取该spinlock,那么将可能发生死锁。
综上所述,如果非要违背linux的设计选择在中断上下文睡眠,那么可能发生各种混乱情况,严重时甚至导致系统崩溃。
四、不同进程上下文之间切换
4.1 调度时机
进程的实际切换过程会在各种调度时机下进行,__schedule函数的注释十分详细的列举了各种调度时机:
1)在不支持内核抢占(CONFIG_PREEMPTION)的情况下:
- cond_resched函数调用以及显示的schedule函数调用
- 从系统调用或者中断返回用户空间
2)在支持内核抢占的情况下还会多出以下情况:
- preempt_enable函数调用(通常通过spin_unlock间接调用到)
- local_bh_enable函数调用
- 从中断返回内核空间
- 另外该情况下cond_resched函数变为了空实现
关于抢占调度,后续考虑专门整理出一篇文章进行讲解。
4.2 需要切换什么?
- 不同进程间的切换涉及四种场景,即内核线程与用户进程相互切换的排列组合,不同组合的主要区别在于进程用户地址空间的切换
- 硬件上下文的save与restore
4.3 具体切换流程
4.3.1 硬件上下文切换
硬件上下文切换主要在cpu_switch_to函数中实现(2.5小节已给出了调用路径):
- 将通用寄存器x19~x29、sp(prev进程内核栈)以及lr寄存器的值保存到prev进程的进程描述符(prev->thread.cpu_context)中:
mov x10, #THREAD_CPU_CONTEXT
add x8, x0, x10 /* x0中存放prev进程的进程描述符 */
mov x9, sp /* 读取prev进程的内核栈栈顶地址 */
stp x19, x20, [x8], #16
stp x21, x22, [x8], #16
stp x23, x24, [x8], #16
stp x25, x26, [x8], #16
stp x27, x28, [x8], #16
stp x29, x9, [x8], #16 /* 保存x19~x29以及sp */
str lr, [x8] /* 保存lr,即prev进程调用cpu_switch_to返回后地址 */
- 从next进程中restore上述硬件上下文:
add x8, x1, x10 /* x1中存放next进程的进程描述符 */
ldp x19, x20, [x8], #16
ldp x21, x22, [x8], #16
ldp x23, x24, [x8], #16
ldp x25, x26, [x8], #16
ldp x27, x28, [x8], #16
ldp x29, x9, [x8], #16 /* restore next进程上次被切换出去时保存的x19~x29以及sp */
ldr lr, [x8] /* restore此前的lr */
mov sp, x9 /* restore next进程的内核栈栈顶到sp */
其中restore的lr,在cpu_switch_to函数返回后会被写到pc中(见2.4.2小节的图),即完成了pc指针的切换,restore sp时即完成了内核栈的切换。
- 另外还会将next进程的进程描述符地址写入sp_el0寄存器:
msr sp_el0, x1
4.3.2 进程用户地址空间切换
所有的进程都共享内核地址空间,因此只需要处理进程的用户地址空间的切换。
- 用户进程切换到用户进程:TTBR0_EL1存储着当前进程用户空间的页全局目录地址(PGD),MMU读取该寄存器获取页表基址,然后walk页表进行虚拟地址到物理地址的翻译,因此将next进程的用户空间PGD的物理地址写入TTBR0_EL1寄存器即可:
/* __schedule --> context_switch --> switch_mm --> cpu_do_switch_mm */
unsigned long ttbr0 = phys_to_ttbr(pgd_phys);
write_sysreg(ttbr0, ttbr0_el1);
当然如果是两个用户线程共享用户地址空间,那么肯定不需要切换:
/* __schedule --> context_switch --> switch_mm */
if (prev != next) /* prev为prev->active_mm,next为next->mm */
__switch_mm(next);
- 用户进程切换到内核线程:内核线程没有用户地址空间,因此不需要切换,只是将prev进程的内存描述符记录在了next线程的active_mm成员中
- 内核线程切换到用户进程:如果是
user a --> kernel b --> user a
这种情况,那么也不需要切换用户地址空间,因为`user a --> kernel b
过程中没有修改TTBR0_EL1的值;如果是user a --> kernel b --> user c
情况,那么需要切换用户地址空间,并且会在此后的finish_task_switch函数中对user a的内存描述符(记录在prev->active_mm中)的引用计数进行dec and test操作,如果test为0,那么会将user a的用户地址空间释放:
/* __schedule --> context_switch --> finish_task_switch --> mmdrop */
if (unlikely(atomic_dec_and_test(&mm->mm_count)))
__mmdrop(mm);
- 内核线程切换到内核线程:不需要切换用户地址空间,只需要将prev->active_mm置空
4.3.3 TLB相关处理
TLB负责缓存虚拟地址到物理地址的映射关系,MMU在遍历页表前,会先去TLB中查看有没有缓存,如果cache hit,然后直接返回物理地址;如果cache miss,那么需要遍历页表,这个代价通常是高昂的。
进程在运行过程中会逐渐填充TLB,从而提高运行效率,因此在进程切换时,也希望尽量避免flush TLB,以使得prev进程再次上cpu运行时还能cache hit;首先TLB添加了nG位,用来区分进程的内核地址空间地址和用户地址空间地址(虚拟地址),这样可以避免invalid存放进程内核空间的cacheline;其次还希望多个进程的用户空间地址的映射可以同时存在TLB中,那么需要能够区分不同的进程,因此引入了ASID标识位。
因为TLB只能接受虚拟地址,所以只能是VIVT类型的cache,那么需要解决VIVT cache的歧义和别名问题:
- 别名即是多个虚拟地址映射到同一个物理地址,那么在cache的多个cahceline中可能都有某个物理地址的缓存,且缓存的值可能不同,这就造成了cache不一致问题;但由于TLB cacheline存储的是虚拟地址到物理地址的映射关系,因此不会有不一致问题,因为返回的都是一个物理地址,而不会是不同的值,即不存在别名问题;
- 歧义即是同一个虚拟地址映射到不同物理地址(不同进程的用户地址空间的同一虚拟地址映射到不同物理地址),这样如果不flush cache,可能next进程会从cache中读取到prev进程的内存信息,TLB情况与此一致,因此存在歧义问题;
如果不希望频繁flush cache,一般解决歧义的方法是使用带有键的VIVT,这个键在TLB中即是上述的ASID标识,这样就可以区分不同进程的同一虚拟地址,即解决歧义问题;另外由于进程运行过程中不会使用到所有的用户地址空间的虚拟地址,因此有可能使得不同进程的映射关系(va与pa)同时缓存在TLB中,从而避免在进程切换时flush全部的TLB。
因为硬件ASID的位数有限,但系统中的进程数很多,因此还需要软件ASID一同参与,该机制涉及ASID管理,读者可以自行跟读;另外高速缓存的基本原理以及不同类型高速缓存的区别本文也不涉及,读者可自行学习。
五、总结
以一个例子对本文进行总结,考虑以下场景:
a)用户进程a在用户空间通过系统调用陷入内核空间
b)在内核空间执行时,发生了硬件中断,转去执行中断处理程序
c)中断返回时,发生抢占调度,进程b上CPU运行
d)进程b回到用户空间(此前在执行系统调用时被调度走)
该场景下,各过程中内核栈和硬件上下文的变化如下:
图六 过程a
图七 过程b结果
图八 过程c
最后过程d中,进程b的内核栈会恢复成过程a图左侧进程a的状态。