我知道操作系统的一个重要功能就是管理进程,而进程管理就是选择合适的进程在合适的时间执行,而每个进程都是宏-parallelandmicro-coordinatedonasinglecpurunningqueue串行执行,在多个cpurunqueue上完成进程间的并行执行。进程管理是一个复杂的进程,涉及进程描述、创建和销毁、生命周期管理、进程切换、进程抢占、调度策略、负载均衡等,本文主要关注进程管理的一个切入点,即进程上下文switching,了解linux内核是如何处理上下文切换的,从而揭开上下文切换的神秘面纱。(注:本文使用linux-5.0内核源码讲解,采用arm64架构)本文内容:1.进程上下文的概念2.上下文切换的详细过程2.1进程地址空间切换2.2处理器状态(硬件上下文)切换3.ASID机制4.普通用户进程、普通用户线程、内核线程切换的区别五、进程切换全景图六、小结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进程地址空间切换进程地址空间指针是进程拥有的虚拟地址空间,而这个地址空间是false,是linux内核通过数据结构来描述的,让每个进程都感觉自己拥有整个内存,访问的指令和数据的错觉cpu最后会执行到实际的物理地址,对于用户进程来说,通过pagefaultexception来分配和建立页表映射。进程地址空间中有进程运行的指令和数据,所以当调度器从其他进程切换到我这里时,为了保证当前进程访问的虚拟地址是自己的,就必须切换地址空间。实际上,进程地址空间是由mm_struct结构描述的,它嵌入在进程描述符(我们通常所说的进程控制块PCB)task_struct中。mm_struct结构将各个vma组织起来进行管理,其中一个成员pgd非常重要,地址空间切换中最重要的就是pgd的设置。pgd中保存的是进程的页全局目录的虚拟地址(本文会涉及到页表相关的一些概念,不是这里的重点,不清楚的可以参考相关资料,后面有机会再解释进程页表),记得保存的是虚拟地址,那么pgd的值是什么时候设置的呢?答案是在fork的时候,如果是创建进程,需要分配并设置mm_struct,它会分配进程page的全局目录所在的page,然后将首地址分配给pgd。下面来看看进程地址空间是如何切换的,结果会让你大吃一惊(这里不考虑asid机制,后面其他文章会讲解):代码路径如下:context_switch//kernel/计划/核心。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.harch/arm64/mm/proc.S158/*159*cpu_do_switch_mm(pgd_phys,tsk)160*161*Setthetranslationtablebasepointertobepgd_phys.162*163*-pgd_phys-physicaladdressofnewTTB164*/165ENTRY(cpu_do_switch_mm)166mrsx2,ttbr1_el1167mmidx1,x1//getmm->context.id168phys_to_ttbrx3,x0169170alternative_ifARM64_HAS_CNP171cbzx1,1f//skipCNPforreservedASID172orrx3,x3,#TTBR_CNP_BIT1731:174alternative_else_nop_endif175#ifdefCONFIG_ARM64_SW_TTBR0_PAN176bfix3,x1,#48,#16//settheASIDfieldinTTBR0177#endif178bfix2,x1,#48,#16//settheASID179msrttbr1_el1,x2//inTTBR1(sinceTCR.A1isset)180isb181msrttbr0_el1,x3//nowupdateTTBR0182isb183bpost_ttbr_update_workaround//BacktoCcode...184ENDPROC(cpu_do_switch_mm)代码的核心是181行,最后将进程的pgd虚拟地址转换成物理地址,存入ttbr0_el1,也就是用户空间的页表基地址寄存器。当访问用户空间的地址时,mmu会使用这个寄存器遍历页表获取物理地址(ttbr1_el1是内核空间页表的基地址寄存器,访问内核地址时用到空间。所有进程共享,不需要切换。)完成这一步后,进程的地址空间切换就完成了。准确的说是进程的虚拟地址。空间开关。内核处理是不是很简单优雅?不管只是设置页表基地址寄存器,也就是将要执行的进程的页全局目录的物理地址设置到页表基地址寄存器,他就完成了地址空间切换的壮举,有的朋友可能不明白为什么这样就完成了地址空间的切换?试想一下,如果一个进程要访问一个用户空间的虚拟地址,cpu的mmu所做的就是从页表基地址寄存器中获取页全局目录的物理基地址,然后配合虚拟地址去查找向上页表,最后找到访问的物理地址(当然如果tlb命中,就不用遍历页表),每次访问用户虚拟地址(不考虑内核空间共享),由于页表基地址寄存器存放的是当前正在执行的进程的页全局目录的物理地址,所以访问自己的那组页表得到的是自己的物理地址(实际上是进程在访问虚拟地址空间中的指令数据时继续出现缺页异常,然后缺页异常处理程序为进程分配实际的物理页,然后将页框号和页表属性填充到它的自己的页表项),就不会访问其他进程的指令和数据,这就是为什么多个进程可以无错地访问同一个虚拟地址,而且各个地址空间的隔离互不影响(共享内存除外)。实际上,在地址空间切换过程中,tlb也会被清空,以防止当前进程在虚拟地址转换过程中命中前一个进程的tlb表项。一般情况下,所有的tlb都会失效,但是这样会造成很大的性能损失,因为新进程切换进来的时候,面对的是一个全新的空tlb,导致tlbmiss的概率很高,需要遍历multi再次是-级页表,所以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*RegisterswitchforAArch64.Thecallee-savedregistersneedtobesaved1034*andrestored.Onentry:1035*x0=previoustask_struct(mustbepreservedacrosstheswitch)1036*x1=nexttask_struct1037*Previousandnextareguaranteednottobethesame.1038*1039*/1040ENTRY(cpu_switch_to)1041movx10,#THREAD_CPU_CONTEXT1042addx8,x0,x101043movx9,sp1044stpx19,x20,[x8],#16//storecallee-savedregisters1045stpx21,x22,[x8],#161046stpx23,x24,[x8],#161047stpx25,x26,[x8],#161048stpx27,x28,[x8],#161049stpx29,x9,[x8]],#161050strx8]1051addx8,x1,x101052ldpx19,x20,[x8],#16//restorecallee-savedregisters1053ldpx21,x22,[x8],#161054ldpx23,x24,[x8],#161055ldpx25,x26,[x8],#161056ldpx2,[x8],#161057ldpx29,x9,[x8],#161058ldrlr,[x8]1059movsp,x91060msrsp_el0,x11061ret1062ENDPROC(cpu_switch_to)其中x19-x28为需要调用保存的寄存器arm64架构,可以看到处理器状态切换,此时将上一个进程(prev)的x19-x28、fp、sp、pc保存到进程描述符的cpu_contex中,然后将要执行的进程(next)描述符的cpu_contex的x19-x28,fp,sp,pc恢复到对应的寄存器中,将下一个进程的进程描述符task_struct地址存放在sp_el0中,用于通过current查找当前进程,从而完成处理器的状态切换。处理器的状态切换其实就是把前一个进程的sp、pc等寄存器的值保存到一块内存中,再保存要执行的进程的sp、pc等。寄存器的值从另一块内存恢复到对应的寄存器,sp的恢复完成进程内核栈的切换,pc的恢复完成指令执行流的切换。其中,用于保存/恢复的那块内存需要被进程识别。这块内存就是cpu_contex结构所在的位置(进程切换都是在内核空间完成的)。由于用户空间通过异常/中断进入内核空间时需要保存场景,即发生异常/中断时保存所有通用寄存器的值,内核会保存“场景”到每个进程唯一的进程内核栈,并用pt_regs结构体来描述,当异常/中断处理完成后,会返回到用户空间,返回前会恢复之前保存的“场景”,以及用户程序将继续执行。所以当进程切换时,当前进程被时钟中断打断,中断发生时的场景被保存到进程内核栈(如:sp、lr等),然后会切换到next进程,再次切换回来,回到用户空间时,会恢复之前的站点,进程可以继续执行(执行之前被中断打断的下一条指令,继续使用自己的用户statesp),这对用户进程是透明的。下面是硬件上下文切换的示例图:3.ASID机制前面提到,进程切换时,由于tlb中可能存放着其他进程的tlb表项,所以需要在进程切换时清除tlbswitched(clearing即让所有tlb表项失效,地址转换需要遍历多级页表,找到页表表项,然后将页表表项重新加载到tlb),经过ASID机制,命中tlb表项,由虚拟地址和ASID共同决定(当然还有nG位)可以减少进程切换时tlb被清除的几率。接下来,我们将解释ASID机制。ASID(AddressSpaceIdentifer)用于区分不同进程的页表项。在arm64中,可以选择8位或16位两种ASID长度。这里我们用8位来说明。如果ASID长度为8位,那么ASID有256个值,但是由于0是保留的,所以可以分配的ASID都是1~255,那么可以识别255个进程,当超过255个进程时,会出现两个进程的ASID相同,所以内核使用ASID版本号。内核中的处理如下(参考arch/arm64/mm/context.c):1)内核为每个进程分配一个64位的软件ASID,其中低8位为硬件ASID,高8位56位是ASID版本号。ASID存放在进程的mm_struct结构体的context结构体的id中。进程创建时会初始化为0。2)内核中有一个64位的全局变量asid_generation,其高56位为ASID版本号,用于标识当前分配的batchASID。3)当进程被调度并从上一个进程切换到下一个进程时,如果不是内核线程,则地址空间切换调用check_and_switch_context,该函数会判断下一个进程的ASID版本号是否相同作为全局ASID版本号(是否在同一批),如果相同则无需为下道进程分配ASID,如果不相同则需要分配ASID。4)内核使用asid_map位图管理硬件ASID的分配,asid_bits记录使用的ASID的长度,变量active_asidsperprocessor存储当前分配的硬件ASID,变量reserved_asidsperprocessor存储保留的ASID,tlb_flush_pending位图记录需要清除tlb的cpu集合。硬件ASID分配策略如下:(1)如果进程的ASID版本号与当前全局ASID版本号相同(与批次相同),则无需重新分配ASID。(2)如果进程的ASID版本号与当前全局ASID版本号不相同(不同批次),并且进程原来的硬件ASID已经分配,??则重新分配一个新的硬件ASID,并且currentglobalASIDversion将号码组合新分配的硬件ASID写入进程的软件ASID。(3)如果进程的ASID版本号与当前全局ASID版本号不相同(不同批次),且进程原来的硬件ASID还没有分配,则不需要重新分配新的硬件ASID,只需要更新进程的软件ASID版本号,将当前全局ASID版本号结合进程原来的硬件ASID写入进程的软件ASID。(4)如果进程的ASID版本号与当前全局ASID版本号不同(不同批次),需要分配硬件ASID时,发现硬件ASID已经被其他进程分配(搜索在asid_mapbitmap中,找到bitmapAll1),那么此时需要增加全局ASID版本号, 清除所有cpus的tlb, 清除asid_map位图,然后分配硬件ASID,并将当前全局ASID版本号与新分配的硬件ASID写入进程的软件ASID。下面举个例子看看ASID的分配过程:如下图所示:我们假设图中从A进程到D进程共有255个进程,刚刚分配了asid,同批次使用从A到D的切换过程第二个辅助版本号。那么在这个进程中,当创建一个进程的时候,就会切换到。假设不超过255个进程,硬件的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(ASIDwraparound)。此时全局ASID为801,然后清空asid_map,设置tlb_flush_pending所有位清空所有cpu的tlb,然后再次给E进程分配硬件ASID,这次给他分配1(ASID版本号)。情况3-ASID版本号发生变化,进程的硬件ASID可以重新使用 属于策略(3):假设进程E切换到进程B,进程B已经在batchwithglobalASIDversionnumber800before分配了编号为5的硬件ASID,但是B进程的ASID版本号800与当前全局ASID版本号801不同,所以需要new_context为进程分配ASID。赋值的时候发现asid_map中的数字5没有设置。即没有其他进程分配了ASID5,都可以继续使用原来分配的硬件ASID5。情况4-ASID版本号改变,其他进程分配了相同的硬件ASID ,属于to策略(2):假设从进程B切换到进程A,进程B的全局ASID版本号为800一个编号为1的硬件ASID分配给batch,但是B进程的ASID版本号800与当前全局ASID版本号801,因此需要new_context为进程分配ASID。分配的时候发现asid_map中的数字1已经被设置了,也就是其他进程已经分配了ASID1,需要从asid_map中寻找下一个空闲的ASID,所以一个新的6的ASID就是分配。假设从A到E,由于E的ASID版本号与全局ASID版本号相同(同一批次),所以和情况1一样,不需要分配ASID。但是,原来ASID版本号批次为800的进程需要重新分配ASID。有的可以使用原来的硬件ASID,有的重新分配硬件ASID,只是ASID版本号改为当前全局ASID版本号801。。但是随着硬件ASID的不断分配,801这批次的硬件ASID最终还是会被分配的。这时就是上面的情况2,需要所有CPU的tlb。我可以看到,有了ASID机制,所有的cputlbs只有在分配了硬件ASID时(比如被255个进程使用)才会被清除,这大大提高了系统的性能(没有ASID机制)在这种情况下,每次进程切换需要地址空间切换时都需要清除tlb)。4、普通用户进程、普通用户线程、内核线程切换的区别。切换内核地址空间有以下几个原则:查看进程描述符的mm_struct结构体,它是成员mm:1)如果mm为NULL,表示即将切换内核线程,有无需切换地址空间(所有任务共享内核地址空间)。2)内核线程会借用上一个用户进程的mm,分配给自己的active_mm(自己的mm为空)。进程切换时,会比较上一个进程的active_mm和当前进程的mm。3)如果前一个任务和要切换的任务有相同的mm成员,即共享地址空间的线程不需要切换地址空间。->在所有进程线程之间切换需要切换处理器状态。->普通用户进程之间的切换,需要切换地址空间。->同一个线程组中的线程之间切换不需要切换地址空间,因为它们共享同一个地址空间。->内核线程在上下文切换时不需要切换地址空间,只是借用了之前进程的mm_struct结构。有以下几种场景:约定:我们统称进程/线程为任务,其中U代表用户任务(进程/线程),K代表内核线程,数字代表同一个线程组中的线程。有以下任务:Ua1Ua2UbUcKaKb(例: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相同,都不会切换地址空间。下面是多任务地址空间切换的示例图:五、进程切换全景图我们以下面的场景为例:进程A和B都是普通用户进程,不考虑从进程A切换到进程B这里为了简单起见,对于其他抢占机会,我们假设进程A和B在循环中只进行一些基本的计算操作,从不调用任何系统调用,只考虑被时钟打断被抢占后返回用户空间的情况.下面是进程切换的全景图:视图已经解释清楚了,需要强调三个重点:1.中断发生时保存场景,将中断发生时的所有通用寄存器保存到内核栈过程,并使用structpt_regs结构。2、地址空间切换将进程自身页全局目录的基地址pgd保存在ttbr0_le1中,作为mmu页表遍历的起点。3.硬件上下文切换时,将此时调用保存寄存器和pc、sp保存到structcpu_context结构体中。完成这些保存任务后,当进程再次调度回来时,cpu_context中保存的pc返回到cpu_switch_to的下一条指令继续执行,而当前进程由于cpu_context中保存的sp返回到自己的内核栈,经过一个一系列的内核出栈过程,最后将原来保存在pt_regs中的通用寄存器的值恢复到通用寄存器中,这样进程返回给用户时就可以沿着被中断打断的下一条指令继续执行space,用户栈也回到被中断前的位置,进程访问的指令数据的地址转换(VA到PA)也是从自己的pgd中进行的,一切在用户看来就像什么都没发生过,它是无缝的。6.总结进程管理中最重要的一步是进程上下文切换。主要有两个步骤:地址空间切换和处理器状态切换(硬件上下文切换)。前者保证进程回到用户空间后可以访问自己。指令和数据(包括减少tlb清零的ASID机制),后者保证进程内核栈和执行流程的切换,将当前进程的硬件上下文保存在进程管理的一块内存中,然后保存to-be-executed进程的硬件上下文从内存中恢复到寄存器中。采用两步切换流程,保证了流程的有序运行。当然,切换过程是在内核空间完成的,对进程是透明的。作者简介韩传华,就职于南京大宇半导体有限公司,主要从事linux相关系统软件开发,负责Soc芯片BringUp和系统软件开发,乐于分享,喜欢学习,喜欢专注于Linux内核源代码。本文转载自微信公众号“Linux代码阅读领域”,可通过以下二维码关注。转载本文请联系Linux代码阅读领域公众号。
