精华内容
下载资源
问答
  • Linux进程上下文切换过程context_switch详解 日期 内核版本 架构 作者 GitHub CSDN 2016-06-14 Linux-4.6 X86 & arm gatieme LinuxDeviceDrivers Linux进程管理与调度 前面我们了解了linux进程调度器的...
    日期内核版本架构作者GitHubCSDN
    2016-06-14Linux-4.6X86 & armgatiemeLinuxDeviceDriversLinux进程管理与调度

    前面我们了解了linux进程调度器的设计思路和注意框架

    周期调度器scheduler_tick通过linux定时器周期性的被激活, 进行程序调度

    进程主动放弃CPU或者发生阻塞时, 则会调用主调度器schedule进行程序调度

    在分析的过程中, 我们提到了内核抢占和用户抢占的概念, 但是并没有详细讲, 因此我们在这里详细分析一下子

    CPU抢占分两种情况, 用户抢占, 内核抢占

    其中内核抢占是在Linux2.5.4版本发布时加入, 同SMP(Symmetrical Multi-Processing, 对称多处理器), 作为内核的可选配置。

    1 前景回顾


    1.1 Linux的调度器组成


    2个调度器

    可以用两种方法来激活调度

    • 一种是直接的, 比如进程打算睡眠或出于其他原因放弃CPU

    • 另一种是通过周期性的机制, 以固定的频率运行, 不时的检测是否有必要

    因此当前linux的调度程序由两个调度器组成:主调度器周期性调度器(两者又统称为通用调度器(generic scheduler)核心调度器(core scheduler))

    并且每个调度器包括两个内容:调度框架(其实质就是两个函数框架)及调度器类

    6种调度策略

    linux内核目前实现了6中调度策略(即调度算法), 用于对不同类型的进程进行调度, 或者支持某些特殊的功能

    • SCHED_NORMAL和SCHED_BATCH调度普通的非实时进程

    • SCHED_FIFO和SCHED_RR和SCHED_DEADLINE则采用不同的调度策略调度实时进程

    • SCHED_IDLE则在系统空闲时调用idle进程.

    5个调度器类

    而依据其调度策略的不同实现了5个调度器类, 一个调度器类可以用一种种或者多种调度策略调度某一类进程, 也可以用于特殊情况或者调度特殊功能的进程.

    其所属进程的优先级顺序为

    stop_sched_class -> dl_sched_class -> rt_sched_class -> fair_sched_class -> idle_sched_class

    3个调度实体

    调度器不限于调度进程, 还可以调度更大的实体, 比如实现组调度.

    这种一般性要求调度器不直接操作进程, 而是处理可调度实体, 因此需要一个通用的数据结构描述这个调度实体,即seched_entity结构, 其实际上就代表了一个调度对象,可以为一个进程,也可以为一个进程组.

    linux中针对当前可调度的实时和非实时进程, 定义了类型为seched_entity的3个调度实体

    • sched_dl_entity 采用EDF算法调度的实时调度实体

    • sched_rt_entity 采用Roound-Robin或者FIFO算法调度的实时调度实体

    • sched_entity 采用CFS算法调度的普通非实时进程的调度实体

    1.2 调度工作


    周期性调度器通过调用各个调度器类的task_tick函数完成周期性调度工作

    • 如果当前进程是完全公平队列中的进程, 则首先根据当前就绪队列中的进程数算出一个延迟时间间隔,大概每个进程分配2ms时间,然后按照该进程在队列中的总权重中占得比例,算出它该执行的时间X,如果该进程执行物理时间超过了X,则激发延迟调度;如果没有超过X,但是红黑树就绪队列中下一个进程优先级更高,即curr->vruntime-leftmost->vruntime > X,也将延迟调度

    • 如果当前进程是实时调度类中的进程:则如果该进程是SCHED_RR,则递减时间片[为HZ/10],到期,插入到队列尾部,并激发延迟调度,如果是SCHED_FIFO,则什么也不做,直到该进程执行完成

    延迟调度**的真正调度过程在:schedule中实现,会按照调度类顺序和优先级挑选出一个最高优先级的进程执行

    而对于主调度器则直接关闭内核抢占后, 通过调用schedule来完成进程的调度

    可见不管是周期性调度器还是主调度器, 内核中的许多地方, 如果要将CPU分配给与当前活动进程不同的另外一个进程(即抢占),都会直接或者调用调度函数, 包括schedule或者其子函数__schedule, 其中schedule在关闭内核抢占后调用__schedule完成了抢占.

    而__schedule则执行了如下操作

    __schedule如何完成内核抢占

    1. 完成一些必要的检查, 并设置进程状态, 处理进程所在的就绪队列

    2. 调度全局的pick_next_task选择抢占的进程

      • 如果当前cpu上所有的进程都是cfs调度的普通非实时进程, 则直接用cfs调度, 如果无程序可调度则调度idle进程

      • 否则从优先级最高的调度器类sched_class_highest(目前是stop_sched_class)开始依次遍历所有调度器类的pick_next_task函数, 选择最优的那个进程执行

    3. context_switch完成进程上下文切换

    即进程的抢占或者切换工作是由context_switch完成的

    那么我们今天就详细讲解一下context_switch完成进程上下文切换的原理

    2 进程上下文


    2.1 进程上下文的概念


    操作系统管理很多进程的执行. 有些进程是来自各种程序、系统和应用程序的单独进程,而某些进程来自被分解为很多进程的应用或程序。当一个进程从内核中移出,另一个进程成为活动的, 这些进程之间便发生了上下文切换. 操作系统必须记录重启进程和启动新进程使之活动所需要的所有信息. 这些信息被称作上下文, 它描述了进程的现有状态, 进程上下文是可执行程序代码是进程的重要组成部分, 实际上是进程执行活动全过程的静态描述, 可以看作是用户进程传递给内核的这些参数以及内核要保存的那一整套的变量和寄存器值和当时的环境等

    进程的上下文信息包括, 指向可执行文件的指针, 栈, 内存(数据段和堆), 进程状态, 优先级, 程序I/O的状态, 授予权限, 调度信息, 审计信息, 有关资源的信息(文件描述符和读/写指针), 关事件和信号的信息, 寄存器组(栈指针, 指令计数器)等等, 诸如此类.

    处理器总处于以下三种状态之一

    1. 内核态,运行于进程上下文,内核代表进程运行于内核空间;

    2. 内核态,运行于中断上下文,内核代表硬件运行于内核空间;

    3. 用户态,运行于用户空间。

    用户空间的应用程序,通过系统调用,进入内核空间。这个时候用户空间的进程要传递 很多变量、参数的值给内核,内核态运行的时候也要保存用户进程的一些寄存器值、变量等。所谓的”进程上下文”

    硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的 一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。所谓的”中断上下文”,其实也可以看作就是硬件传递过来的这些参数和内核需要保存的一些其他环境(主要是当前被打断执行的进程环境)。

    LINUX完全注释中的一段话

    当一个进程在执行时,CPU的所有寄存器中的值、进程的状态以及堆栈中的内容被称 为该进程的上下文。当内核需要切换到另一个进程时,它需要保存当前进程的 所有状态,即保存当前进程的上下文,以便在再次执行该进程时,能够必得到切换时的状态执行下去。在LINUX中,当前进程上下文均保存在进程的任务数据结 构中。在发生中断时,内核就在被中断进程的上下文中,在内核态下执行中断服务例程。但同时会保留所有需要用到的资源,以便中继服务结束时能恢复被中断进程 的执行.

    2.2 上下文切换

    进程被抢占CPU时候, 操作系统保存其上下文信息, 同时将新的活动进程的上下文信息加载进来, 这个过程其实就是上下文切换, 而当一个被抢占的进程再次成为活动的, 它可以恢复自己的上下文继续从被抢占的位置开始执行. 参见维基百科-[context](https://en.wikipedia.org/wiki/Context_(computing), context switch

    上下文切换(有时也称做进程切换任务切换)是指CPU从一个进程或线程切换到另一个进程或线程

    稍微详细描述一下,上下文切换可以认为是内核(操作系统的核心)在 CPU 上对于进程(包括线程)进行以下的活动:

    1. 挂起一个进程,将这个进程在 CPU 中的状态(上下文)存储于内存中的某处,

    2. 在内存中检索下一个进程的上下文并将其在 CPU 的寄存器中恢复

    3. 跳转到程序计数器所指向的位置(即跳转到进程被中断时的代码行),以恢复该进程

    因此上下文是指某一时间点CPU寄存器和程序计数器的内容, 广义上还包括内存中进程的虚拟地址映射信息.

    上下文切换只能发生在内核态中, 上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。
    Linux相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少.

    3 context_switch进程上下文切换


    linux中进程调度时, 内核在选择新进程之后进行抢占时, 通过context_switch完成进程上下文切换.

    注意 进程调度与抢占的区别

    进程调度不一定发生抢占, 但是抢占时却一定发生了调度

    在进程发生调度时, 只有当前内核发生当前进程因为主动或者被动需要放弃CPU时, 内核才会选择一个与当前活动进程不同的进程来抢占CPU

    context_switch其实是一个分配器, 他会调用所需的特定体系结构的方法

    • 调用switch_mm(), 把虚拟内存从一个进程映射切换到新进程中

      switch_mm更换通过task_struct->mm描述的内存管理上下文, 该工作的细节取决于处理器, 主要包括加载页表, 刷出地址转换后备缓冲器(部分或者全部), 向内存管理单元(MMU)提供新的信息

    • 调用switch_to(),从上一个进程的处理器状态切换到新进程的处理器状态。这包括保存、恢复栈信息和寄存器信息

      switch_to切换处理器寄存器的呢内容和内核栈(虚拟地址空间的用户部分已经通过switch_mm变更, 其中也包括了用户状态下的栈, 因此switch_to不需要变更用户栈, 只需变更内核栈), 此段代码严重依赖于体系结构, 且代码通常都是用汇编语言编写.

    context_switch函数建立next进程的地址空间。进程描述符的active_mm字段指向进程所使用的内存描述符,而mm字段指向进程所拥有的内存描述符。对于一般的进程,这两个字段有相同的地址,但是,内核线程没有它自己的地址空间而且它的 mm字段总是被设置为 NULL

    context_switch( )函数保证:如果next是一个内核线程, 它使用prev所使用的地址空间

    由于不同架构下地址映射的机制有所区别, 而寄存器等信息弊病也是依赖于架构的, 因此switch_mm和switch_to两个函数均是体系结构相关的

    3.1 context_switch完全注释


    context_switch定义在kernel/sched/core.c#L2711, 如下所示

    /*
     * context_switch - switch to the new MM and the new thread's register state.
     */
    static __always_inline struct rq *
    context_switch(struct rq *rq, struct task_struct *prev,
               struct task_struct *next)
    {
        struct mm_struct *mm, *oldmm;
    
        /*  完成进程切换的准备工作  */
        prepare_task_switch(rq, prev, next);
    
        mm = next->mm;
        oldmm = prev->active_mm;
        /*
         * For paravirt, this is coupled with an exit in switch_to to
         * combine the page table reload and the switch backend into
         * one hypercall.
         */
        arch_start_context_switch(prev);
    
        /*  如果next是内核线程,则线程使用prev所使用的地址空间
         *  schedule( )函数把该线程设置为懒惰TLB模式
         *  内核线程并不拥有自己的页表集(task_struct->mm = NULL)
         *  它使用一个普通进程的页表集
         *  不过,没有必要使一个用户态线性地址对应的TLB表项无效
         *  因为内核线程不访问用户态地址空间。
        */
        if (!mm)        /*  内核线程无虚拟地址空间, mm = NULL*/
        {
            /*  内核线程的active_mm为上一个进程的mm
             *  注意此时如果prev也是内核线程,
             *  则oldmm为NULL, 即next->active_mm也为NULL  */
            next->active_mm = oldmm;
            /*  增加mm的引用计数  */
            atomic_inc(&oldmm->mm_count);
            /*  通知底层体系结构不需要切换虚拟地址空间的用户部分
             *  这种加速上下文切换的技术称为惰性TBL  */
            enter_lazy_tlb(oldmm, next);
        }
        else            /*  不是内核线程, 则需要切切换虚拟地址空间  */
            switch_mm(oldmm, mm, next);
    
        /*  如果prev是内核线程或正在退出的进程
         *  就重新设置prev->active_mm
         *  然后把指向prev内存描述符的指针保存到运行队列的prev_mm字段中
         */
        if (!prev->mm)
        {
            /*  将prev的active_mm赋值和为空  */
            prev->active_mm = NULL;
            /*  更新运行队列的prev_mm成员  */
            rq->prev_mm = oldmm;
        }
        /*
         * Since the runqueue lock will be released by the next
         * task (which is an invalid locking op but in the case
         * of the scheduler it's an obvious special-case), so we
         * do an early lockdep release here:
         */
        lockdep_unpin_lock(&rq->lock);
        spin_release(&rq->lock.dep_map, 1, _THIS_IP_);
    
        /* Here we just switch the register state and the stack. 
         * 切换进程的执行环境, 包括堆栈和寄存器
         * 同时返回上一个执行的程序
         * 相当于prev = witch_to(prev, next)  */
        switch_to(prev, next, prev);
    
        /*  switch_to之后的代码只有在
         *  当前进程再次被选择运行(恢复执行)时才会运行
         *  而此时当前进程恢复执行时的上一个进程可能跟参数传入时的prev不同
         *  甚至可能是系统中任意一个随机的进程
         *  因此switch_to通过第三个参数将此进程返回
         */
    
    
        /*  路障同步, 一般用编译器指令实现
         *  确保了switch_to和finish_task_switch的执行顺序
         *  不会因为任何可能的优化而改变  */
        barrier();  
    
        /*  进程切换之后的处理工作  */
        return finish_task_switch(prev);
    }

    3.2 prepare_arch_switch切换前的准备工作


    在进程切换之前, 首先执行调用每个体系结构都必须定义的prepare_task_switch挂钩, 这使得内核执行特定于体系结构的代码, 为切换做事先准备. 大多数支持的体系结构都不需要该选项

    struct mm_struct *mm, *oldmm;
    
    prepare_task_switch(rq, prev, next);    /*  完成进程切换的准备工作  */

    prepare_task_switch函数定义在kernel/sched/core.c, line 2558, 如下所示

    /**
     * prepare_task_switch - prepare to switch tasks
     * @rq: the runqueue preparing to switch
     * @prev: the current task that is being switched out
     * @next: the task we are going to switch to.
     *
     * This is called with the rq lock held and interrupts off. It must
     * be paired with a subsequent finish_task_switch after the context
     * switch.
     *
     * prepare_task_switch sets up locking and calls architecture specific
     * hooks.
     */
    static inline void
    prepare_task_switch(struct rq *rq, struct task_struct *prev,
                struct task_struct *next)
    {
        sched_info_switch(rq, prev, next);
        perf_event_task_sched_out(prev, next);
        fire_sched_out_preempt_notifiers(prev, next);
        prepare_lock_switch(rq, next);
        prepare_arch_switch(next);
    }

    3.3 next是内核线程时的处理


    由于用户空间进程的寄存器内容在进入核心态时保存在内核栈中, 在上下文切换期间无需显式操作. 而因为每个进程首先都是从核心态开始执行(在调度期间控制权传递给新进程), 在返回用户空间时, 会使用内核栈上保存的值自动恢复寄存器数据.

    另外需要注意, 内核线程没有自身的用户空间上下文, 其task_struct->mm为NULL, 参见Linux内核线程kernel thread详解–Linux进程的管理与调度(十), 从当前进程”借来”的地址空间记录在active_mm中

    /*  如果next是内核线程,则线程使用prev所使用的地址空间
     *  schedule( )函数把该线程设置为懒惰TLB模式
     *  内核线程并不拥有自己的页表集(task_struct->mm = NULL)
     *  它使用一个普通进程的页表集
     *  不过,没有必要使一个用户态线性地址对应的TLB表项无效
     *  因为内核线程不访问用户态地址空间。
    */
    if (!mm)        /*  内核线程无虚拟地址空间, mm = NULL*/
    {
        /*  内核线程的active_mm为上一个进程的mm
         *  注意此时如果prev也是内核线程,
         *  则oldmm为NULL, 即next->active_mm也为NULL  */
        next->active_mm = oldmm;
        /*  增加mm的引用计数  */
        atomic_inc(&oldmm->mm_count);
        /*  通知底层体系结构不需要切换虚拟地址空间的用户部分
         *  这种加速上下文切换的技术称为惰性TBL  */
        enter_lazy_tlb(oldmm, next);
    }
    else            /*  不是内核线程, 则需要切切换虚拟地址空间  */
        switch_mm(oldmm, mm, next);

    qizhongenter_lazy_tlb通知底层体系结构不需要切换虚拟地址空间的用户空间部分, 这种加速上下文切换的技术称之为惰性TLB

    3.6 switch_to完成进程切换


    3.6.1 switch_to函数


    最后用switch_to完成了进程的切换, 该函数切换了寄存器状态和栈, 新进程在该调用后开始执行, 而switch_to之后的代码只有在当前进程下一次被选择运行时才会执行

    执行环境的切换是在switch_to()中完成的, switch_to完成最终的进程切换,它保存原进程的所有寄存器信息,恢复新进程的所有寄存器信息,并执行新的进程

    该函数往往通过宏来实现, 其原型声明如下

    /*
     * Saving eflags is important. It switches not only IOPL between tasks,
     * it also protects other tasks from NT leaking through sysenter etc.
    */
    #define switch_to(prev, next, last)
    体系结构switch_to实现
    x86arch/x86/include/asm/switch_to.h中两种实现

    定义CONFIG_X86_32宏

    未定义CONFIG_X86_32宏
    armarch/arm/include/asm/switch_to.h, line 25
    通用include/asm-generic/switch_to.h, line 25

    内核在switch_to中执行如下操作

    1. 进程切换, 即esp的切换, 由于从esp可以找到进程的描述符

    2. 硬件上下文切换, 设置ip寄存器的值, 并jmp到__switch_to函数

    3. 堆栈的切换, 即ebp的切换, ebp是栈底指针, 它确定了当前用户空间属于哪个进程

    __switch_to函数

    体系结构__switch_to实现
    x86arch/x86/kernel/process_32.c, line 242
    x86_64arch/x86/kernel/process_64.c, line 277
    arm64arch/arm64/kernel/process.c, line 329

    3.6.2 为什么switch_to需要3个参数


    调度过程可能选择了一个新的进程, 而清理工作则是针对此前的活动进程, 请注意, 这不是发起上下文切换的那个进程, 而是系统中随机的某个其他进程, 内核必须想办法使得进程能够与context_switch例程通信, 这就可以通过switch_to宏实现. 因此switch_to函数通过3个参数提供2个变量.

    在新进程被选中时, 底层的进程切换冽程必须将此前执行的进程提供给context_switch, 由于控制流会回到陔函数的中间, 这无法用普通的函数返回值来做到, 因此提供了3个参数的宏

    我们考虑这个样一个例子, 假定多个进程A, B, C…在系统上运行, 在某个时间点, 内核决定从进程A切换到进程B, 此时prev = A, next = B, 即执行了switch_to(A, B), 而后当被抢占的进程A再次被选择执行的时候, 系统可能进行了多次进程切换/抢占(至少会经历一次即再次从B到A),假设A再次被选择执行时时当前活动进程是C, 即此时prev = C. next = A.

    在每个switch_to被调用的时候, prev和next指针位于各个进程的内核栈中, prev指向了当前运行的进程, 而next指向了将要运行的下一个进程, 那么为了执行从prev到next的切换, switcth_to使用前两个参数prev和next就够了.

    在进程A被选中再次执行的时候, 会出现一个问题, 此时控制权即将回到A, switch_to函数返回, 内核开始执行switch_to之后的点, 此时内核栈准确的恢复到切换之前的状态, 即进程A上次被切换出去时的状态, prev = A, next = B. 此时, 内核无法知道实际上在进程A之前运行的是进程C.

    因此, 在新进程被选中执行时, 内核恢复到进程被切换出去的点继续执行, 此时内核只知道谁之前将新进程抢占了, 但是却不知道新进程再次执行是抢占了谁, 因此底层的进程切换机制必须将此前执行的进程(即新进程抢占的那个进程)提供给context_switch. 由于控制流会回到函数的该中间, 因此无法通过普通函数的返回值来完成. 因此使用了一个3个参数, 但是逻辑效果是相同的, 仿佛是switch_to是带有两个参数的函数, 而且返回了一个指向此前运行的进程的指针.

    switch_to(prev, next, last);

    prev = last = switch_to(prev, next);

    其中返回的prev值并不是做参数的prev值, 而是prev被再次调度的时候抢占掉的那个进程last.

    在上个例子中, 进程A提供给switch_to的参数是prev = A, next = B, 然后控制权从A交给了B, 但是恢复执行的时候是通过prev = C, next = A完成了再次调度, 而后内核恢复了进程A被切换之前的内核栈信息, 即prev = A, next = B. 内核为了通知调度机制A抢占了C的处理器, 就通过last参数传递回来, prev = last = C.

    内核实现该行为特性的方式依赖于底层的体系结构, 但内核显然可以通过考虑两个进程的内核栈来重建所需要的信息

    3.6.3 switch_to函数注释


    switch_mm()进行用户空间的切换, 更确切地说, 是切换地址转换表(pgd), 由于pgd包括内核虚拟地址空间和用户虚拟地址空间地址映射, linux内核把进程的整个虚拟地址空间分成两个部分, 一部分是内核虚拟地址空间, 另外一部分是内核虚拟地址空间, 各个进程的虚拟地址空间各不相同, 但是却共用了同样的内核地址空间, 这样在进程切换的时候, 就只需要切换虚拟地址空间的用户空间部分.

    每个进程都有其自身的页目录表pgd

    进程本身尚未切换, 而存储管理机制的页目录指针cr3却已经切换了,这样不会造成问题吗?不会的,因为这个时候CPU在系统空间运行,而所有进程的页目录表中与系统空间对应的目录项都指向相同的页表,所以,不管切换到哪一个进程的页目录表都一样,受影响的只是用户空间,系统空间的映射则永远不变

    我们下面来分析一下子, x86_32位下的switch_to函数, 其定义在arch/x86/include/asm/switch_to.h, line 27

    先对flags寄存器和ebp压入旧进程内核栈,并将确定旧进程恢复执行的下一跳地址,并将旧进程ip,esp保存到task_struct->thread_info中,这样旧进程保存完毕;然后用新进程的thread_info->esp恢复新进程的内核堆栈,用thread->info的ip恢复新进程地址执行。
    关键点:内核寄存器[eflags、ebp保存到内核栈;内核栈esp地址、ip地址保存到thread_info中,task_struct在生命期中始终是全局的,所以肯定能根据该结构恢复出其所有执行场景来]

    /*
     * Saving eflags is important. It switches not only IOPL between tasks,
     * it also protects other tasks from NT leaking through sysenter etc.
     */
    #define switch_to(prev, next, last)                                     \
    do {                                                                    \
            /*                                                              \
             * Context-switching clobbers all registers, so we clobber      \
             * them explicitly, via unused output variables.                \
             * (EAX and EBP is not listed because EBP is saved/restored     \
             * explicitly for wchan access and EAX is the return value of   \
             * __switch_to())                                               \
             */                                                             \
            unsigned long ebx, ecx, edx, esi, edi;                          \
                                                                            \
            asm volatile("pushfl\n\t" /* save flags 保存就的ebp、和flags寄存器到旧进程的内核栈中*/   \
                         "pushl %%ebp\n\t"          /* save    EBP   */     \
                         "movl %%esp,%[prev_sp]\n\t"        /* save ESP  将旧进程esp保存到thread_info结构中 */ \
                         "movl %[next_sp],%%esp\n\t"        /* restore ESP 用新进程esp填写esp寄存器,此时内核栈已切换  */ \
                         "movl $1f,%[prev_ip]\n\t"  /* save EIP 将该进程恢复执行时的下条地址保存到旧进程的thread中*/     \
                         "pushl %[next_ip]\n\t"     /* restore EIP 将新进程的ip值压入到新进程的内核栈中 */     \
                         __switch_canary                                    \
                         "jmp __switch_to\n"        /* regparm call  */     \
                         "1:\t"                                             \
                         "popl %%ebp\n\t"           /* restore EBP 该进程执行,恢复ebp寄存器*/     \
                         "popfl\n"                  /* restore flags  恢复flags寄存器*/     \
                                                                            \
                         /* output parameters */                            \
                         : [prev_sp] "=m" (prev->thread.sp),                \
                           [prev_ip] "=m" (prev->thread.ip),                \
                           "=a" (last),                                     \
                                                                            \
                           /* clobbered output registers: */                \
                           "=b" (ebx), "=c" (ecx), "=d" (edx),              \
                           "=S" (esi), "=D" (edi)                           \
                                                                            \
                           __switch_canary_oparam                           \
                                                                            \
                           /* input parameters: */                          \
                         : [next_sp]  "m" (next->thread.sp),                \
                           [next_ip]  "m" (next->thread.ip),                \
                                                                            \
                           /* regparm parameters for __switch_to(): */      \
                           [prev]     "a" (prev),                           \
                           [next]     "d" (next)                            \
                                                                            \
                           __switch_canary_iparam                           \
                                                                            \
                         : /* reloaded segment registers */                 \
                            "memory");                                      \
    } while (0)

    3.7 barrier路障同步


    witch_to完成了进程的切换, 新进程在该调用后开始执行, 而switch_to之后的代码只有在当前进程下一次被选择运行时才会执行.

    /*  switch_to之后的代码只有在
     *  当前进程再次被选择运行(恢复执行)时才会运行
     *  而此时当前进程恢复执行时的上一个进程可能跟参数传入时的prev不同
     *  甚至可能是系统中任意一个随机的进程
     *  因此switch_to通过第三个参数将此进程返回
    */
    
    
    /*  路障同步, 一般用编译器指令实现
     *  确保了switch_to和finish_task_switch的执行顺序
     *  不会因为任何可能的优化而改变  */
    barrier();
    
    /*  进程切换之后的处理工作  */
    return finish_task_switch(prev);

    而为了程序编译后指令的执行顺序不会因为编译器的优化而改变, 因此内核提供了路障同步barrier来保证程序的执行顺序.

    barrier往往通过编译器指令来实现, 内核中多处都实现了barrier, 形式如下

    // http://lxr.free-electrons.com/source/include/linux/compiler-gcc.h?v=4.6#L15
    /* Copied from linux/compiler-gcc.h since we can't include it directly 
     * 采用内敛汇编实现
     *  __asm__用于指示编译器在此插入汇编语句
     *  __volatile__用于告诉编译器,严禁将此处的汇编语句与其它的语句重组合优化。
     *  即:原原本本按原来的样子处理这这里的汇编。
     *  memory强制gcc编译器假设RAM所有内存单元均被汇编指令修改,这样cpu中的registers和cache中已缓存的内存单元中的数据将作废。cpu将不得不在需要的时候重新读取内存中的数据。这就阻止了cpu又将registers,cache中的数据用于去优化指令,而避免去访问内存。
     *  "":::表示这是个空指令。barrier()不用在此插入一条串行化汇编指令。在后文将讨论什么叫串行化指令。
    */
    #define barrier() __asm__ __volatile__("": : :"memory")

    关于内存屏障的详细信息, 可以参见 Linux内核同步机制之(三):memory barrier

    3.8 finish_task_switch完成清理工作


    finish_task_switch完成一些清理工作, 使得能够正确的释放锁, 但我们不会详细讨论这些. 他会向各个体系结构提供了另一个挂钩上下切换过程的可能性, 当然这只在少数计算机上需要.

    前面我们谅解switch_to函数的3个参数时, 讲到
    注:A进程切换到B, A被切换, 而当A再次被选择执行, C再次切换到A,此时A执行,但是系统为了告知调度器A再次执行前的进程是C, 通过switch_to的last参数返回的prev指向C,在A调度时候需要把调用A的进程的信息清除掉

    由于从C切换到A时候, A内核栈中保存的实际上是A切换出时的状态信息, 即prev=A, next=B,但是在A执行时, 其位于context_switch上下文中, 该函数的last参数返回的prev应该是切换到A的进程C, A负责对C进程信息进行切换后处理,比如,如果切换到A后,A发现C进程已经处于TASK_DEAD状态,则将释放C进程的TASK_STRUCT结构

    函数定义在kernel/sched/core.c, line 2715中, 如下所示

    /**
     * finish_task_switch - clean up after a task-switch
     * @prev: the thread we just switched away from.
     *
     * finish_task_switch must be called after the context switch, paired
     * with a prepare_task_switch call before the context switch.
     * finish_task_switch will reconcile locking set up by prepare_task_switch,
     * and do any other architecture-specific cleanup actions.
     *
     * Note that we may have delayed dropping an mm in context_switch(). If
     * so, we finish that here outside of the runqueue lock. (Doing it
     * with the lock held can cause deadlocks; see schedule() for
     * details.)
     *
     * The context switch have flipped the stack from under us and restored the
     * local variables which were saved when this task called schedule() in the
     * past. prev == current is still correct but we need to recalculate this_rq
     * because prev may have moved to another CPU.
     */
    static struct rq *finish_task_switch(struct task_struct *prev)
            __releases(rq->lock)
    {
            struct rq *rq = this_rq();
            struct mm_struct *mm = rq->prev_mm;
            long prev_state;
    
            /*
             * The previous task will have left us with a preempt_count of 2
             * because it left us after:
             *
             *      schedule()
             *        preempt_disable();                    // 1
             *        __schedule()
             *          raw_spin_lock_irq(&rq->lock)        // 2
             *
             * Also, see FORK_PREEMPT_COUNT.
             */
            if (WARN_ONCE(preempt_count() != 2*PREEMPT_DISABLE_OFFSET,
                          "corrupted preempt_count: %s/%d/0x%x\n",
                          current->comm, current->pid, preempt_count()))
                    preempt_count_set(FORK_PREEMPT_COUNT);
    
            rq->prev_mm = NULL;
    
            /*
             * A task struct has one reference for the use as "current".
             * If a task dies, then it sets TASK_DEAD in tsk->state and calls
             * schedule one last time. The schedule call will never return, and
             * the scheduled task must drop that reference.
             *
             * We must observe prev->state before clearing prev->on_cpu (in
             * finish_lock_switch), otherwise a concurrent wakeup can get prev
             * running on another CPU and we could rave with its RUNNING -> DEAD
             * transition, resulting in a double drop.
             */
            prev_state = prev->state;
            vtime_task_switch(prev);
            perf_event_task_sched_in(prev, current);
            finish_lock_switch(rq, prev);
            finish_arch_post_lock_switch();
    
            fire_sched_in_preempt_notifiers(current);
            if (mm)
                    mmdrop(mm);
            if (unlikely(prev_state == TASK_DEAD))  /*  如果上一个进程已经终止,释放其task_struct 结构  */
            {
                    if (prev->sched_class->task_dead)
                            prev->sched_class->task_dead(prev);
    
                    /*
                     * Remove function-return probe instances associated with this
                     * task and put them back on the free list.
                     */
                    kprobe_flush_task(prev);
                    put_task_struct(prev);
            }
    
            tick_nohz_task_switch();
            return rq;
    }
    展开全文
  • 进程切换过程之一:上下文切换

    千次阅读 2020-01-26 22:32:25
    进程切换过程之一:上下文切换 注:下面给出的源码均出自https://github.com/mit-pdos/xv6-public 在进程进行切换时,需要进行上下文切换,也就是寄存器的值的保存。 对于上下文的切换,有两种情况:硬件自动...

    进程切换过程之一:上下文切换

    注:下面给出的源码均出自https://github.com/mit-pdos/xv6-public

     

     

    在进程进行切换时,需要进行上下文切换,也就是寄存器的值的保存。

    对于上下文的切换,有两种情况:硬件自动(隐式)完成、操作系统显式保存。

    下面分析两段xv6的源代码,都是上下文切换部分,版本有所不同:

    注:每个进程都有一个用来维护过程调用的栈,有的书把这个栈叫做内核栈;在编译原理中,这个栈的术语叫活动记录,在下文中,提到的栈都是说的这个保存活动记录的栈。每个进程都有自己的活动记录(当然,如果是多CPU的情况下,就会引入线程的概念,则每个线程有一个活动记录)

    较老的版本

    #   void swtch(struct context *old, struct context *new);
    #  
    # Save current register context in old
    # and then load register context from new.
    
    .globl swtch
    swtch:
      # Save old registers
      movl 4(%esp), %eax
      popl 0(%eax)  # %eip
      movl %esp, 4(%eax)
      movl %ebx, 8(%eax)
      movl %ecx, 12(%eax)
      movl %edx, 16(%eax)
      movl %esi, 20(%eax)
      movl %edi, 24(%eax)
      movl %ebp, 28(%eax)
    
      # Load new registers
      movl 4(%esp), %eax  # not 8(%esp) - popped return address above
    
      movl 28(%eax), %ebp
      movl 24(%eax), %edi
      movl 20(%eax), %esi
      movl 16(%eax), %edx
      movl 12(%eax), %ecx
      movl 8(%eax), %ebx
      movl 4(%eax), %esp
      pushl 0(%eax)  # %eip   
    

    这个版本的上下文结构struct context的内容如下:

    struct context {
        int eip;    //程序计数器
        int esp;    //栈指针
        int ebp;
        int ecx;
        int edx;
        int esi;
        int edi;
        int ebp;
    };
    

    分析一下上下文切换的源代码:

    源代码的上半段是将“老”的进程的上下文保存,下半段将“新”的进程(要切换到的)的上下文恢复。

    执行swtch的指令时,栈的状态:

                                                             

    movl 4(%esp), %eax
    

    将old(参数)的值放入寄存器%eax。

    popl 0(%eax)
    

    然后将栈顶的值保存到0(%eax)的位置。也就是将返回地址(%eip)的值保存到old指向的context空间中

    之后,就是将其他的寄存器放入old指向的context空间中。

    通过上面的源码,可以得到context在内存中分配空间的方式:

                                          

    下面的代码就是从new指向的context空间中,将各个寄存器的值恢复,就不详细说明了。

    较新的版本:就像一开始提到的,硬件会自动保存一部分

    硬件会保存一部分的寄存器,还有剩余的需要进行手动保存。

    # Context switch
    #
    #   void swtch(struct context **old, struct context *new);
    # 
    # Save the current registers on the stack, creating
    # a struct context, and save its address in *old.
    # Switch stacks to new and pop previously-saved registers.
    
    .globl swtch
    swtch:
      movl 4(%esp), %eax
      movl 8(%esp), %edx
    
      # Save old callee-saved registers
      pushl %ebp
      pushl %ebx
      pushl %esi
      pushl %edi
    
      # Switch stacks
      movl %esp, (%eax)
      movl %edx, %esp
    
      # Load new callee-saved registers
      popl %edi
      popl %esi
      popl %ebx
      popl %ebp
      ret
    

    开始的两条代码,将old与new放入%eax和%edx,方便后面通过指针的方式访问内存。

    接下来的四条压栈指令,直接将要手动保存的寄存器压入栈中。

    执行到这里,“老”进程和“新”进程对应的栈的示意图如下:

                                                  

    与之前旧版本的有些许不同,新版本的直接将上下文保存在进程自己的栈中;旧版本的保存在了其他的地方(暂时就理解到这个程度,由于没有分析过所有的源码,这里的分析可能比较片面,但这是我整个的分析过程,如有有大佬看出哪里不会,请指正)

    展开全文
  • 1.进程上下文切换过程: @1.由中断、异常、系统调用等触发中断,将cpu切换为内核模式,将eip,esp,eflages压入内核堆栈; @2.保存硬件未来得及保存的现场信息; @3.调用中断服务程序; @4.检查need_resched标志...

    更多

    进程上下文切换过程

    1. 中断异常系统调用等触发中断,将cpu切换为内核模式,将eipespeflages压入内核堆栈
    2. 保存硬件未来得及保存的现场信息;
    3. 调用中断服务程序;
    4. 检查need_resched标志位,若有效则进行以下步骤@5;
    5. 调用schedule()函数完成进程调度,schedule()会执行以下步骤@6,@7;
    6. 调用pick_next_task()根据相关调度算法得到下一个待运行的进程;
    7. 调用context_switch()执行以下步骤@8,@9;
    8. 调用switch_mm()将虚拟内存地址映射到待运行进程,恢复内存管理相关信息。
    9. 调用switch_to()保存原来进程的cpu现场信息,恢复待运行进程的cpu现场信息;
    10. 中断返回,弹出eip,esp,eflages,将cpu切换为用户工作模式;
    展开全文
  • 都知道操作系统的一个重要功能就是...本文主要关注进程管理的一个切入点,那就是进程的上下文切换,来理解linux内核是如何进程进程上下文切换的,从而揭开上下文切换的神秘面纱。进程地址空间切换和处理器状态切换。

    目录

    1.进程上下文的概念

    2.上下文切换详细过程

    2.1 进程地址空间切换

    2.2 处理器状态(硬件上下文)切换

    3.ASID机制

    4. 普通用户进程、普通用户线程、内核线程切换的差别

    5. 进程切换全景视图

    6. 总结


     

    都知道操作系统的一个重要功能就是进行进程管理,而进程管理就是在合适的时机选择合适的进程来执行,在单个cpu运行队列上各个进程宏观并行微观串行执行,多个cpu运行队列上的各个进程之间完全的并行执行。进程管理是个复杂的过程,例如进程的描述、创建和销毁、生命周期管理、进程切换、进程抢占、调度策略、负载均衡等等。本文主要关注进程管理的一个切入点,那就是进程的上下文切换,来理解linux内核是如何进程进程上下文切换的,从而揭开上下文切换的神秘面纱。
    (注意:本文以linux-5.0内核源码讲解,采用arm64架构

     

    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 AArch64. 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成员,也就是共享地址空间的线程则也不需要切换地址空间。

    1. ->所有的进程线程之间进行切换都需要切换处理器状态。
    2. ->对于普通的用户进程之间进行切换需要切换地址空间。
    3. ->同一个线程组中的线程之间切换不需要切换地址空间,因为他们共享相同的地址空间。
    4. -> 内核线程在上下文切换的时候不需要切换地址空间,仅仅是借用上一个进程mm_struct结构。

    有以下场景:

    • 约定:我们将进程/线程统称为任务,其中U表示用户任务(进程/线程),K表示内核线程,带有数字表示同一个线程组中的线程。
    • 有以下任务:Ua1 Ua2 Ub Uc Ka Kb (eg:Ua1为用户进程, Ua2为和Ua1在同一线程组的用户进程,Ub普通的用户进程,Ka普通的内核线程 )。

    如果调度顺序如下:

    Uc -> Ua1 -> Ua2 -> Ub -> Ka -> Kb -> Ub
    1. 从Uc -> Ua1 由于是不同的进程,需要切换地址空间。
    2. 从 Ua1 -> Ua2 由于是相同线程组中的不同线程,共享地址空间,在切换到Ua1的时候已经切换了地址空间,所有不需要切换地址空间。
    3. 从 Ua2 -> Ub 由于是不同的进程,需要切换地址空间。
    4. 从 Ub -> Ka 由于切换到内核线程,所以不需要切换地址空间。
    5. 从Ka -> Kb 俩内核线程之前切换,不需要切换地址空间。
    6. 从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开始进行,一切对用户来说就好像没有发生一样,简直天衣无缝。

     

    6. 总结


    进程管理中最重要的一步要进行进程上下文切换,其中主要有两大步骤:地址空间切换和处理器状态切换(硬件上下文切换),前者保证了进程回到用户空间之后能够访问到自己的指令和数据(其中包括减小tlb清空的ASID机制),后者保证了进程内核栈和执行流的切换,会将当前进程的硬件上下文保存在进程所管理的一块内存,然后将即将执行的进程的硬件上下文从内存中恢复到寄存器,有了这两步的切换过程保证了进程运行的有条不紊,当然切换的过程是在内核空间完成,这对于进程来说是透明的。

     

    韩传华,就职于南京大鱼半导体有限公司,主要从事linux相关系统软件开发工作,负责Soc芯片BringUp及系统软件开发,乐于分享喜欢学习,喜欢专研Linux内核源代码。

     

    展开全文
  • 上下文切换详解 首先谈谈上下文切换的概念。...上下文切换只能发生在内核态中,上下文切换过程中的信息被保存在进程控制块(PCB)中。 上下文切换的信息会一直被保存在CPU的内存中,直到被再次使用。 ...
  • 深入理解Linux内核进程上下文切换

    千次阅读 多人点赞 2020-09-28 08:39:19
    本文主要关注进程管理的一个切入点,那就是进程的上下文切换,来理解linux内核是如何进程进程上下文切换的,从而揭开上下文切换的神秘面纱。 (注意:本文以linux-5.0内核源码讲解,采用arm64架构) 本文目录: 1....
  • 进程上下文切换与线程上下文切换

    千次阅读 2020-04-06 22:45:33
    进程上下文切换 进程上下文包含了进程执行所需要的所有信息。 用户地址空间:包括程序代码,数据,用户堆栈等; 控制信息:进程描述符,内核栈等; 硬件上下文:进程恢复前,必须装入寄存器的数据统称为硬件上下文...
  • 上下文切换就是从当前执行任务切换到另一个任务执行的过程。但是,为了确保下次能从正确的位置继续执行,在切换之前,会保存上一个任务的状态。 2. 然后,需要明白进程与线程的区别?(网上很多,这里简单说明) 1)...
  • Linux内核进程上下文切换深入理解

    千次阅读 2020-09-28 16:01:42
    本文主要关注进程管理的一个切入点,那就是进程的上下文切换,来理解linux内核是如何进程进程上下文切换的,从而揭开上下文切换的神秘面纱。 (注意:本文以linux-5.0内核源码讲解,采用arm64架构)
  • 对于服务器的优化,很多人都有自己的经验和见解,但就我观察,有两点常常会被人忽视 – 上下文切换 和 Cache Line同步 问题,人们往往都会习惯性地把视线集中在尽力减少内存拷贝,减少IO次数这样的问题上,不可否认...
  • 上下文切换就是从当前执行任务切换到另一个任务执行的过程。但是,为了确保下次能从正确的位置继续执行,在切换之前,会保存上一个任务的状态。 2、 然后,需要明白进程与线程的区别?(网上很多,这里简单说明) 1)...
  • 文章目录1 进程上下文与中断上下文2 进程上下文切换2.1 概念2.2 步骤3 处理器模式切换3.1 概念3.2 步骤4系统任务与用户进程 1 进程上下文与中断上下文 进程上下文 所谓的“进程上下文”,就是一个进程在执行...
  • ##################################### 作者:张卓 原创作品转载请注明出处:《Linux操作系统分析》MOOC课程 ...##################################### 1. 前言 一篇我们详细地分析一
  • 进程切换分两步 1.切换页目录以使用新的地址空间 ...线程上下文切换进程上下问切换一个最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下文切换的处理都是通
  • 上下文切换就是从当前执行任务切换到另一个任务执行的过程。但是,为了确保下次能从正确的位置继续执行,在切换之前,会保存上一个任务的状态。 然后,需要明白进程与线程的区别?(网上很多,这里简单说明) 1)....
  • 进程上下文切换分析

    2018-07-15 20:59:00
    我今天分析一下进程上下文切换,也就是进程调度时,怎么由当前进程切换到另一个进程的。 1、概述  进程调度的时机,也就是进程是在啥时候切换,触发因数是什么。  中断发生时,进入中断处理中断服务程序——...
  • 进程、线程上下文切换

    千次阅读 2020-05-24 22:12:02
    上下文切换可以认为是内核(操作系统的核心)在 CPU 上对于进程(包括线程)进行以下的活动:(1)挂起一个进程,将这个进程在 CPU 中的状态(上下文)存储于内存中的某处,(2)在内存中检索下一个进程的上下文并将...
  • 进程切换进程上下文和中断上下文)详解

    千次阅读 多人点赞 2019-04-17 10:02:46
    进程上下文VS中断上下文 1.内核空间和用户空间 内核空间和用户空间是现代操作系统的两种工作模式,内核模块运行在内核空间,而用户态应用程序运行在用户空间。它们代表不同的级别,而对系统资源具有不同的访问权限...
  • 先在主机通过top、free、ps、iostat 等常用工具分析了下主机的CPU、内存、IO使用情况,发现三者都不高。通过vmstat 1 查看的结果如下:   从vmstat的输出结果来看,io项的block in 和block ...
  • 线程上下文切换与进程上下文切换

    千次阅读 2017-09-27 18:29:24
    进程上下文包含了进程执行所需要的所有信息。 用户地址空间:包括程序代码,数据,用户堆栈等; 控制信息:进程描述符,内核栈等; 硬件上下文:(注意中断也要保存硬件上下文只是保存的方法不同)。 2、进程...
  • 进程、线程上下文切换的开销

    千次阅读 2020-09-10 11:21:07
    进程、线程上下文切换的开销 虚拟内存与地址空间映射关系 虚拟内存是操作系统为每个进程提供的一种抽象,每个进程都有属于自己的、私有的、地址连续的虚拟内存,当然我们知道最终进程的数据及代码必然要放到物理...
  • 各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行,那么这个一个进程切换到另一个进程运行,称为进程上下文切换进程是由内核管理和调度的,所以进程的切换只能发生...
  • 进程上下文切换

    2015-11-06 21:38:00
    以前老是看到进程上下文切换,只知道是时一些信息从内存和cpu里换出,概念很模糊,今天翻了下书,记录下 上下文切换:操作系统内核用一种称为上下文切换的较高形式的异常控制流来实现多任务 内核为每一个进程维护...
  • linux中进程上下文频繁切换导致load average过高处理 在linux中进程上下文频繁切换导致load average过高问题我们要如何来解决呢今天小编来为各位介绍一下此问题的解决办法 现网有两台虚拟机主机95%的cpu处于idle状态...
  • vmstat:是查看系统的整体上下文切换情况,想看具体的...-w:进程上下文切换情况。 -u :进程cpu使用情况 5:是每五秒输出一组数据。 pidstat -wt 1 -wt :输出线程的上线文切换情况。 1:每1秒输出一组数据。 中断次数
  • (2)进程切换过程中的上下文信息保存与恢复,必须在内核态吗?(3)信号量和管程机制是否可以相互实现,有没有确定的答案和证明?首先理解以下概念:一、进程控制块进程控制块:PCB是操作系统管理控制进程运行所有...
  • 什么是上下文切换

    千次阅读 多人点赞 2019-04-29 13:46:56
    文章目录什么是上下文切换上下文上下文切换系统调用进程上下文切换线程上下文切换中断上下文切换感谢 上下文 首先,需要讲清楚什么是上下文。 每个任务运行前,CPU 都需要知道任务从哪里加载、又从哪里开始运行,这...

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 112,887
精华内容 45,154
关键字:

进程上下文切换过程