共计 9412 个字符,预计需要花费 24 分钟才能阅读完成。
这篇文章主要讲解了“Linux 内核进程上下文切换怎么理解”,文中的讲解内容简单清晰,易于学习与理解,下面请大家跟着丸趣 TV 小编的思路慢慢深入,一起来研究和学习“Linux 内核进程上下文切换怎么理解”吧!
1. 进程上下文的概念
进程上下文是进程执行活动全过程的静态描述。我们把已执行过的进程指令和数据在相关寄存器与堆栈中的内容称为进程上文,把正在执行的指令和数据在寄存器与堆栈中的内容称为进程正文,把待执行的指令和数据在寄存器与堆栈中的内容称为进程下文。
实际上 linux 内核中,进程上下文包括进程的虚拟地址空间和硬件上下文。
进程硬件上下文包含了当前 cpu 的一组寄存器的集合,arm64 中使用 task_struct 结构的 thread 成员的 cpu_context 成员来描述,包括 x19-x28,sp, pc 等。
如下为硬件上下文存放示例图:
2. 上下文切换详细过程
进程上下文切换主要涉及到两部分主要过程:进程地址空间切换和处理器状态切换。地址空间切换主要是针对用户进程而言,而处理器状态切换对应于所有的调度单位。下面我们分别看下这两个过程:
__schedule // kernel/sched/core.c - context_switch - switch_mm_irqs_off // 进程地址空间切换 - switch_to // 处理器状态切换
2.1 进程地址空间切换
进程地址空间指的是进程所拥有的虚拟地址空间,而这个地址空间是假的,是 linux 内核通过数据结构来描述出来的,从而使得每一个进程都感觉到自己拥有整个内存的假象,cpu 访问的指令和数据最终会落实到实际的物理地址,对用进程而言通过缺页异常来分配和建立页表映射。进程地址空间内有进程运行的指令和数据,因此到调度器从其他进程重新切换到我的时候,为了保证当前进程访问的虚拟地址是自己的必须切换地址空间。
实际上,进程地址空间使用 mm_struct 结构体来描述,这个结构体被嵌入到进程描述符(我们通常所说的进程控制块 PCB)task_struct 中,mm_struct 结构体将各个 vma 组织起来进行管理,其中有一个成员 pgd 至关重要,地址空间切换中最重要的是 pgd 的设置。
pgd 中保存的是进程的页全局目录的虚拟地址(本文会涉及到页表相关的一些概念,在此不是重点,不清楚的可以查阅相关资料,后期有机会会讲解进程页表),记住保存的是虚拟地址,那么 pgd 的值是何时被设置的呢? 答案是 fork 的时候,如果是创建进程,需要分配设置 mm_struct,其中会分配进程页全局目录所在的页,然后将首地址赋值给 pgd。
我们来看看进程地址空间究竟是如何切换的,结果会让你大吃一惊(这里暂且不考虑 asid 机制,后面有机会会在其他文章中讲解):
代码路径如下:
context_switch // kernel/sched/core.c - switch_mm_irqs_off - switch_mm - __switch_mm - check_and_switch_context - cpu_switch_mm - cpu_do_switch_mm(virt_to_phys(pgd),mm) //arch/arm64/include/asm/mmu_context.h arch/arm64/mm/proc.S 158 /* 159 * cpu_do_switch_mm(pgd_phys, tsk) 160 * 161 * Set the translation table base pointer to be pgd_phys. 162 * 163 * - pgd_phys - physical address of new TTB 164 */ 165 ENTRY(cpu_do_switch_mm) 166 mrs x2, ttbr1_el1 167 mmid x1, x1 // get mm- context.id 168 phys_to_ttbr x3, x0 169 170 alternative_if ARM64_HAS_CNP 171 cbz x1, 1f // skip CNP for reserved ASID 172 orr x3, x3, #TTBR_CNP_BIT 173 1: 174 alternative_else_nop_endif 175 #ifdef CONFIG_ARM64_SW_TTBR0_PAN 176 bfi x3, x1, #48, #16 // set the ASID field in TTBR0 177 #endif 178 bfi x2, x1, #48, #16 // set the ASID 179 msr ttbr1_el1, x2 // in TTBR1 (since TCR.A1 is set) 180 isb 181 msr ttbr0_el1, x3 // now update TTBR0 182 isb 183 b post_ttbr_update_workaround // Back to C code... 184 ENDPROC(cpu_do_switch_mm)
代码中最核心的为 181 行,最终将进程的 pgd 虚拟地址转化为物理地址存放在 ttbr0_el1 中,这是用户空间的页表基址寄存器,当访问用户空间地址的时候 mmu 会通过这个寄存器来做遍历页表获得物理地址(ttbr1_el1 是内核空间的页表基址寄存器,访问内核空间地址时使用,所有进程共享,不需要切换)。完成了这一步,也就完成了进程的地址空间切换,确切的说是进程的虚拟地址空间切换。
内核处理的是不是很简单,很优雅,别看只是设置了页表基址寄存器,也就是将即将执行的进程的页全局目录的物理地址设置到页表基址寄存器,他却完成了地址空间切换的壮举,有的小伙伴可能不明白为啥这就完成了地址空间切换? 试想如果进程想要访问一个用户空间虚拟地址,cpu 的 mmu 所做的工作,就是从页表基址寄存器拿到页全局目录的物理基地址,然后和虚拟地址配合来查查找页表,最终找到物理地址进行访问(当然如果 tlb 命中就不需要遍历页表),每次用户虚拟地址访问的时候(内核空间共享不考虑),由于页表基地址寄存器内存放的是当前执行进程的页全局目录的物理地址,所以访问自己的一套页表,拿到的是属于自己的物理地址(实际上,进程是访问虚拟地址空间的指令数据的时候不断发生缺页异常,然后缺页异常处理程序为进程分配实际的物理页,然后将页帧号和页表属性填入自己的页表条目中),就不会访问其他进程的指令和数据,这也是为何多个进程可以访问相同的虚拟地址而不会出现差错的原因,而且做到的各个地址空间的隔离互不影响(共享内存除外)。
其实,地址空间切换过程中,还会清空 tlb,防止当前进程虚拟地址转化过程中命中上一个进程的 tlb 表项,一般会将所有的 tlb 无效,但是这会导致很大的性能损失,因为新进程被切换进来的时候面对的是全新的空的 tlb,造成很大概率的 tlb miss, 需要重新遍历多级页表,所以 arm64 在 tlb 表项中增加了非全局 (nG) 位区分内核和进程的页表项,使用 ASID 区分不同进程的页表项,来保证可以在切换地址空间的时候可以不刷 tlb,后面会主要讲解 ASID 技术。
还需要注意的是仅仅切换用户地址空间,内核地址空间由于是共享的不需要切换,也就是为何切换到内核线程不需要也没有地址空间的原因。
如下为进程地址空间切换示例图:
2.2 处理器状态 (硬件上下文) 切换
前面进行了地址空间切换,只是保证了进程访问指令数据时访问的是自己地址空间(当然上下文切换的时候处于内核空间,执行的是内核地址数据,当返回用户空间的时候才有机会执行用户空间指令数据 **,地址空间切换为进程访问自己用户空间做好了准备 **),但是进程执行的内核栈还是前一个进程的,当前执行流也还是前一个进程的,需要做切换。
arm64 中切换代码如下:
switch_to - __switch_to ... // 浮点寄存器等的切换 - cpu_switch_to(prev, next) arch/arm64/kernel/entry.S: 1032 /* 1033 * Register switch for AArch74. The callee-saved registers need to be saved 1034 * and restored. On entry: 1035 * x0 = previous task_struct (must be preserved across the switch) 1036 * x1 = next task_struct 1037 * Previous and next are guaranteed not to be the same. 1038 * 1039 */ 1040 ENTRY(cpu_switch_to) 1041 mov x10, #THREAD_CPU_CONTEXT 1042 add x8, x0, x10 1043 mov x9, sp 1044 stp x19, x20, [x8], #16 // store callee-saved registers 1045 stp x21, x22, [x8], #16 1046 stp x23, x24, [x8], #16 1047 stp x25, x26, [x8], #16 1048 stp x27, x28, [x8], #16 1049 stp x29, x9, [x8], #16 1050 str lr, [x8] 1051 add x8, x1, x10 1052 ldp x19, x20, [x8], #16 // restore callee-saved registers 1053 ldp x21, x22, [x8], #16 1054 ldp x23, x24, [x8], #16 1055 ldp x25, x26, [x8], #16 1056 ldp x27, x28, [x8], #16 1057 ldp x29, x9, [x8], #16 1058 ldr lr, [x8] 1059 mov sp, x9 1060 msr sp_el0, x1 1061 ret 1062 ENDPROC(cpu_switch_to)
其中 x19-x28 是 arm64 架构规定需要调用保存的寄存器,可以看到处理器状态切换的时候将前一个进程 (prev) 的 x19-x28,fp,sp,pc 保存到了进程描述符的 cpu_contex 中,然后将即将执行的进程 (next) 描述符的 cpu_contex 的 x19-x28,fp,sp,pc 恢复到相应寄存器中,而且将 next 进程的进程描述符 task_struct 地址存放在 sp_el0 中,用于通过 current 找到当前进程,这样就完成了处理器的状态切换。
实际上,处理器状态切换就是将前一个进程的 sp,pc 等寄存器的值保存到一块内存上,然后将即将执行的进程的 sp,pc 等寄存器的值从另一块内存中恢复到相应寄存器中,恢复 sp 完成了进程内核栈的切换,恢复 pc 完成了指令执行流的切换。其中保存 / 恢复所用到的那块内存需要被进程所标识,这块内存这就是 cpu_contex 这个结构的位置(进程切换都是在内核空间完成)。
由于用户空间通过异常 / 中断进入内核空间的时候都需要保存现场,也就是保存发生异常 / 中断时的所有通用寄存器的值,内核会把“现场”保存到每个进程特有的进程内核栈中,并用 pt_regs 结构来描述,当异常 / 中断处理完成之后会返回用户空间,返回之前会恢复之前保存的“现场”,用户程序继续执行。
所以当进程切换的时候,当前进程被时钟中断打断,将发生中断时的现场保存到进程内核栈(如:sp, lr 等),然后会切换到下一个进程,当再次回切换回来的时候,返回用户空间的时候会恢复之前的现场,进程就可以继续执行(执行之前被中断打断的下一条指令,继续使用自己用户态 sp),这对于用户进程来说是透明的。
如下为硬件上下文切换示例图:
3.ASID 机制
前面讲过,进程切换的时候,由于 tlb 中存放的可能是其他进程的 tlb 表项,所有才需要在进程切换的时候进行 tlb 的清空工作(清空即是使得所有的 tlb 表项无效,地址转换需要遍历多级页表,找到页表项,然后重新加载页表项到 tlb),有了 ASID 机制之后,命中 tlb 表项,由虚拟地址和 ASID 共同决定(当然还有 nG 位),可以减小进程切换中 tlb 被清空的机会。
下面我们讲解 ASID 机制,ASID(Address Space Identifer 地址空间标识符),用于区别不同进程的页表项,arm64 中,可以选择两种 ASID 长度 8 位或者 16 位,这里以 8 位来讲解。
如果 ASID 长度为 8 位,那么 ASID 有 256 个值,但是由于 0 是保留的,所有可以分配的 ASID 范围就为 1 -255,那么可以标识 255 个进程,当超出 255 个进程的时候,会出现两个进程的 ASID 相同的情况,因此内核使用了 ASID 版本号。
内核中处理如下(参考 arch/arm64/mm/context.c):
1)内核为每个进程分配一个 64 位的软件 ASID,其中低 8 位为硬件 ASID,高 56 位为 ASID 版本号,这个软件 ASID 存放放在进程的 mm_struct 结构的 context 结构的 id 中, 进程创建的时候会初始化为 0。
2)内核中有一个 64 位的全局变量 asid_generation,同样它的高 56 位为 ASID 版本号,用于标识当前 ASID 分配的批次。
3)当进程调度,由 prev 进程切换到 next 进程的时候,如果不是内核线程则进行地址空间切换调用 check_and_switch_context,此函数会判断 next 进程的 ASID 版本号是否和全局的 ASID 版本号相同(是否处于同一批次),如果相同则不需要为 next 进程分配 ASID, 不相同则需要分配 ASID。
4)内核使用 asid_map 位图来管理硬件 ASID 的分配,asid_bits 记录使用的 ASID 的长度,每处理器变量 active_asids 保存当前分配的硬件 ASID,每处理器变量 reserved_asids 存放保留的 ASID,tlb_flush_pending 位图记录需要清空 tlb 的 cpu 集合。
硬件 ASID 分配策略如下:
(1)如果进程的 ASID 版本号和当前全局的 ASID 版本号相同(同批次情况),则不需要重新分配 ASID。
(2)如果进程的 ASID 版本号和当前全局的 ASID 版本号不相同(不同批次情况),且进程原本的硬件 ASID 已经被分配,则重新分配新的硬件 ASID,并将当前全局的 ASID 版本号组合新分配的硬件 ASID 写到进程的软件 ASID 中。
(3)如果进程的 ASID 版本号和当前全局的 ASID 版本号不相同(不同批次情况),且进程原本的硬件 ASID 还没有被分配,则不需要重新分配新的硬件 ASID,只需要更新进程软件 ASID 版本号,并将当前全局的 ASID 版本号组合进程原来的硬件 ASID 写到进程的软件 ASID 中。
(4)如果进程的 ASID 版本号和当前全局的 ASID 版本号不相同(不同批次情况),需要分配硬件 ASID 时,发现硬件 ASID 已经被其他进程分配完(asid_map 位图中查找,发现位图全 1),则这个时候需要递增全局的 ASID 版本号, 清空所有 cpu 的 tlb, 清空 asid_map 位图,然后分配硬件 ASID,并将当前全局的 ASID 版本号组合新分配的硬件 ASID 写到进程的软件 ASID 中。
下面我们以实例来看 ASID 的分配过程:
如下图:
我们假设图中从 A 进程到 D 进程,有 255 个进程,刚好分配完了 asid,,从 A 到 D 的切换过程中使用的都是同一批次的 asid 版本号。
则这个过程中,有进程会创建的时候被切换到,假设不超出 255 个进程,在切换过程中会为新进程分配硬件的 ASID,分配完后下次切换到他时由于他的 ASID 版本号和当前的全局的 ASID 版本号相同,所以不需要再次分配 ASID,当然也不需要清空 tlb。
注:这里说的 ASID 即为硬件 ASID 区别于 ASID 版本号。
情况 1 -ASID 版本号不变 属于策略(1):从 C 进程到 D 进程切换,内核判断 D 进程的 ASID 版本号和当前的全局的 ASID 版本号相同,所以不需要为他分配 ASID(执行快速路径 switch_mm_fastpath 去设置 ttbrx_el1))。
情况 2 - 硬件 ASID 全部分配完 属于策略(4):假设到达 D 进程时,asid 已经全部分配完(系统中有 255 个进程都分配到了硬件 asid 号),这个时候新创建的进程 E 被调度器选中,切换到 E,由于新创建的进程的软件 ASID 被初始化为 0,所以和当前的全局的 ASID 版本号不同(不在同一批次),则这个时候会执行 new_context 为进程分配 ASID,但是由于没有可以分配的 ASID,所以会将全局的 ASID 版本号加 1(发生 ASID 回绕),这个时候全局的 ASID 为 801,然后清空 asid_map, 置位 tlb_flush_pending 所有 bit 用于清空所有 cpu 的 tlb,然后再次去分配硬件 ASID 给 E 进程,这个时候分配到了 1 给他(将 ASID 版本号)。
情况 3 -ASID 版本号发生变化,进程的硬件 ASID 可以再次使用 属于策略(3):假设从 E 切换到了 B 进程,而 B 进程之前已经在全局的 ASID 版本号为 800 的批次上分配了编号为 5 的硬件 ASID,但是 B 进程的 ASID 版本号 800 和现在全局的 ASID 版本号 801 不相同,所有需要 new_context 为进程分配 ASID,分配的时候发现 asid_map 中编号为 5 没有被置位,也就是没有其他进程分配了 5 这个 ASID,所有可以继续使用原来分配的硬件 ASID 5。
情况 4 – ASID 版本号发生变化,有其他进程已经分配了相同的硬件 ASID 属于策略(2): 假设从 B 进程切换到 A 进程,而 B 进程之前已经在全局的 ASID 版本号为 800 的批次上分配了编号为 1 的硬件 ASID,但是 B 进程的 ASID 版本号 800 和现在全局的 ASID 版本号 801 不相同,所有需要 new_context 为进程分配 ASID,分配的时候发现 asid_map 中编号为 1 已经被置位,也就是其他进程已经分配了 1 这个 ASID,需要从 asid_map 寻找下一个空闲的 ASID,则分配了新的 ASID 为 6。
假设从 A 到 E,由于 E 的 ASID 版本号和全局的 ASID 版本号(同一批次),和情况 1 相同,不需要分配 ASID。但是之前原来处于 800 这个 ASID 版本号批次的进程都需要重新分配 ASID,有的可以使用原来的硬件 ASID,有的重新分配硬件 ASID,但是都将 ASID 版本号修改为了现在全局的 ASID 版本号 801。但是,随着硬件 ASID 的不断分配,最终处于 801 这一批次的硬件 ASID 也会分配完,这个时候就是上面的情况 2,要情况所有 cpu 的 tlb。
我可以看到有了 ASID 机制之后,由于只有当硬件 ASID 被分配完了(如被 255 个进程使用),发生回绕的时候才会清空所有 cpu 的 tlb, 大大提高了系统的性能(没有 ASID 机制的情况下每次进程切换需要地址空间切换的时候都需要清空 tlb)。
4. 普通用户进程、普通用户线程、内核线程切换的差别
内核地址空间切换的时候有一下原则:看的是进程描述符的 mm_struct 结构,即是成员 mm:
1)如果 mm 为 NULL, 则表示即将切换的是内核线程,不需要切换地址空间(所有任务共享内核地址空间)。
2)内核线程会借用前一个用户进程的 mm,赋值到自己的 active_mm(本身的 mm 为空),进程切换的时候就会比较前一个进程的 active_mm 和当前进程的 mm。
3)如果前一个任务的和即将切换的任务,具有相同的 mm 成员,也就是共享地址空间的线程则也不需要切换地址空间。
– 所有的进程线程之间进行切换都需要切换处理器状态。
– 对于普通的用户进程之间进行切换需要切换地址空间。
– 同一个线程组中的线程之间切换不需要切换地址空间,因为他们共享相同的地址空间。
– 内核线程在上下文切换的时候不需要切换地址空间,仅仅是借用上一个进程 mm_struct 结构。
有一下场景:
约定:我们将进程 / 线程统称为任务,其中 U 表示用户任务(进程 / 线程),K 表示内核线程,带有数字表示同一个线程组中的线程。
有以下任务:Ua1 Ua2 Ub Uc Ka Kb (eg:Ua1 为用户进程, Ua2 为和 Ua1 在同一线程组的用户进程,Ub 普通的用户进程,Ka 普通的内核线程)。
如果调度顺序如下:
Uc – Ua1 – Ua2 – Ub – Ka – Kb – Ub
从 Uc – Ua1 由于是不同的进程,需要切换地址空间。
从 Ua1 – Ua2 由于是相同线程组中的不同线程,共享地址空间,在切换到 Ua1 的时候已经切换了地址空间,所有不需要切换地址空间。
从 Ua2 – Ub 由于是不同的进程,需要切换地址空间。
从 Ub – Ka 由于切换到内核线程,所以不需要切换地址空间。
从 Ka – Kb 俩内核线程之前切换,不需要切换地址空间。
从 Kb – Ub 从内核线程切换到用户进程,由于 Ka 和 Kb 都是借用 Ub 的 active_mm,而 Ub 的 active_mm 等于 Ub 的 mm, 所以这个时候 Kb 的 active_mm 和 Ub 的 mm 相同,所有也不会切换地址空间。
如下为多任务地址空间切换示例图:
5. 进程切换全景视图
我们以下场景为例:
A,B 两个进程都是普通的用户进程,从进程 A 切换到进程 B, 简单起见我们在这里不考虑其他的抢占时机,我们假设 A,B 进程只是循环进行一些基本的运算操作,从来不调用任何系统调用,只考虑被时钟中断,返回用户空间之前被抢占的情况。
下面给出进程切换的全景视图:
视图中已经讲解很清楚,需要强调 3 个关键点:
1. 发生中断时的保存现场,将发生中断时的所有通用寄存器保存到进程的内核栈,使用 struct pt_regs 结构。
2. 地址空间切换将进程自己的页全局目录的基地址 pgd 保存在 ttbr0_le1 中,用于 mmu 的页表遍历的起始点。
3. 硬件上下文切换的时候,将此时的调用保存寄存器和 pc, sp 保存到 struct cpu_context 结构中。做好了这几个保存工作,当进程再次被调度回来的时候,通过 cpu_context 中保存的 pc 回到了 cpu_switch_to 的下一条指令继续执行,而由于 cpu_context 中保存的 sp 导致当前进程回到自己的内核栈,经过一系列的内核栈的出栈处理,最后将原来保存在 pt_regs 中的通用寄存器的值恢复到了通用寄存器,这样进程回到用户空间就可以继续沿着被中断打断的下一条指令开始执行,用户栈也回到了被打断之前的位置,而进程访问的指令数据做地址转化 (VA 到 PA) 也都是从自己的 pgd 开始进行,一切对用户来说就好像没有发生一样,简直天衣无缝。
感谢各位的阅读,以上就是“Linux 内核进程上下文切换怎么理解”的内容了,经过本文的学习后,相信大家对 Linux 内核进程上下文切换怎么理解这一问题有了更深刻的体会,具体使用情况还需要大家实践验证。这里是丸趣 TV,丸趣 TV 小编将为大家推送更多相关知识点的文章,欢迎关注!