精华内容
下载资源
问答
  • 结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程 一,实验目标 以fork和execve系统调用为例分析中断上下文切换 分析execve系统调用中断上下文特殊之处 分析fork子进程启动执行时进程上下文...

    结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

    一,实验目标

    • 以fork和execve系统调用为例分析中断上下文的切换
    • 分析execve系统调用中断上下文的特殊之处
    • 分析fork子进程启动执行时进程上下文的特殊之处
    • 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程

    二,实验过程

    1.首先了解下中断的过程

    linux中具有中断门和系统门(相当于中断的描述符)总共有255个放在中断符描述表中,中断门包括段选择符用来到GDT中寻找对应的段描述符CS,段偏移用来描述中断服务程序的地址对应着eip的值,当中断发生时系统利用中断控制器读取的中断向量来找到对应的中断门从而找到中断服务程序的地址,在进入到中断服务例程之前首先控制单元完成一些列的操作:

    1. 确定中断向量。
    2. 利用中断向量在IDT中找到对应中断门,在中断门中得到段选择符从而可以从GDT中找到中断服务例程的段基址。
    3. 确定中断发生的特权级合法(linux只有内核态和用户态两种特权级,此步用来检查中断程序的特权是否低于引起中断的程序的特权,低优先级程序不能引起高优先级程序)
    4. 检查是否发生特权级变化(用户态陷入内核态,这时候需要设置内核的堆栈),如果发生读取当前程序的tss段(通过tr寄存器读取)来选择新特权级的ss和esp指针,然后保存旧的ss和esp指针。
    5. 若发生的是故障,用引起异常的指令地址修改cs 和eip寄存器的值,以使得这条指令在异常处理结束后能被再次执行。
    6. 在栈中保存eflags、cs和eip的内容。
    7. 如果异常产生一个硬件出错码,则将它保存在栈中。
    8. 装载cs和eip寄存器,其值分别是IDT表中第i项门 描述符的段选择符和偏移量字段。这对寄存器值 给出中断或者异常处理程序的第一条指定的逻辑 地址。

    在控制单元完成上述一些列操作后就会跳转到中断的服务例程入口,IRQn_interrupt是中断的服务例程入口,主要是做所有中断都会做的一件事,调用SAVE_ALL保存上下文环境,SAVE_ALL会按照ptreg这个数据结构来以此保存寄存器,保存完内核栈结后(返回地址是do_IRQ()执行后的返回地址)在SAVE_ALL后会调用do_IRQ()函数来执行中断服务,同时还会把ptreg结构作为参数传递给do_IRQ()

    2.其次,再了解下系统调用

    linux32位系统利用int 0x80实现系统调用,和中断没有太大区别,首先也是有控制单元保存ss,esp,cs,eflags等寄存器,然后是跳转到系统调用服务函数system_call之处,在跳转之前还会利用eax(传递系统调用号),ebx(传递第一个参数),ecx(第二个参数)等等寄存器来传递参数(64位传递方式不同),在进入sys_call函数后会将系统调用号以及一些列的cpu寄存器压栈,并且验证系统调用号的合法性,如果合法最后执行call *sys_call_table(0,%eax,4)来找到具体的系统调用服务例程,系统维护了一个sys_call_table表来存储所有的系统调用服务程序。在执行完服务程序后就会按照正常中断返回。

    3.fork函数

    头文件:#include<unistd.h>,#include<sys/types.h>
    函数原型:pid_t fork( void);
    返回值: 若成功调用一次则返回两个值,子进程返回0,父进程返回子进程ID;否则,出错返回-1
    函数说明:在Unix/Linux中用fork函数创建一个新的进程。进程是由当前已有进程调用fork函数创建,分叉的进程叫子进程,创建者叫父进程。该函数的特点是调用一次,返回两次,一次是在父进程,一次是在子进程。两次返回的区别是子进程的返回值为0,父进程的返回值是新子进程的ID。子进程与父进程继续并发运行。如果父进程继续创建更多的子进程,子进程之间是兄弟关系,同样子进程也可以创建自己的子进程,这样可以建立起定义关系的进程之间的一种层次关系。
    不管系统使用clone(),vfork(),fork()最后都是调用do_fork()实现子程序的创建,首先会从内存分配一个8k的空间用来存储进程描述符和内核栈,拷贝父进程的描述符内容到新进程的描述符中,检查拥有当前进程的用户所拥有的进程最大数,如果进程使用了模块,则增加对应模块的引用计数,更新从父进程拷贝过来的一些标志,获得一个进程id号,更新一些不能从父进程继承的描述符的内容,建立进程的fs,files,mm,sighand结构并拷贝相应的父进程的内容,利用cpu寄存器中的值来初始化子进程的内核堆栈,把eax寄存器值为0(eax保存返回值),同时保存esp和eip的值。将子进程描述符插入到进程链表中,同时插入到进程运行链表中,这时候已完成大部分工作,当系统调用结束时会调用调度程序来决定何时调用子程序,同时调度程序调度子程序时会继续完善子程序,利用上面保存的esp,eip值来装载寄存器,同时进入到ret_from_sys_call()函数中,该函数是系统调用的返回程序,会利用子进程前面初始化好的内核栈来装在cpu寄存器(恢复现场),由于子进程和父进程执行的是同样的代码,所以当系统调用结束返回时检查eax寄存器中的返回值,如果是0就给子进程,如果是pid就给父进程,所以通常当fork一个新的子进程时我们可以通过返回值来判断当前进程是父进程还是子进程,所以我们可以在fork程序下面加上if语句在配合execv系统调用来装载我们需要执行的代码。
    do_fork的部分代码如下:

    long do_fork(unsigned long clone_flags,
              unsigned long stack_start,
              unsigned long stack_size,
              int __user *parent_tidptr,
              int __user *child_tidptr)
    {
        struct task_struct *p;
        int trace = 0;
        long nr;
     
        // ...
     
        // 复制进程描述符,返回创建的task_struct的指针
        p = copy_process(clone_flags, stack_start, stack_size,
                 child_tidptr, NULL, trace);
     
        if (!IS_ERR(p)) {
            struct completion vfork;
            struct pid *pid;
     
            trace_sched_process_fork(current, p);
     
            // 取出task结构体内的pid
            pid = get_task_pid(p, PIDTYPE_PID);
            nr = pid_vnr(pid);
     
            if (clone_flags & CLONE_PARENT_SETTID)
                put_user(nr, parent_tidptr);
     
            // 如果使用的是vfork,那么必须采用某种完成机制,确保父进程后运行
            if (clone_flags & CLONE_VFORK) {
                p->vfork_done = &vfork;
                init_completion(&vfork);
                get_task_struct(p);
            }
     
            // 将子进程添加到调度器的队列,使得子进程有机会获得CPU
            wake_up_new_task(p);
     
            // ...
     
            // 如果设置了 CLONE_VFORK 则将父进程插入等待队列,并挂起父进程直到子进程释放自己的内存空间
            // 保证子进程优先于父进程运行
            if (clone_flags & CLONE_VFORK) {
                if (!wait_for_vfork_done(p, &vfork))
                    ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
            }
     
            put_pid(pid);
        } else {
            nr = PTR_ERR(p);
        }
        return nr;
    

    特殊之处:
    fork在陷⼊内核态之后有两次返回,第⼀次返回到原来的⽗进程的位置继续执⾏,但是在⼦进程中fork也返回了⼀次,会返回到⼀个特 定的点——ret_from_fork,所以它可以正常系统调⽤返回到⽤户态。

    4.execve函数

    **函数定义:int execve(const char *filename, char const argv[ ], char const envp[ ]);
    返回值:函数执行成功时没有返回值,执行失败时的返回值为-1.
    函数说明:execve()用来执行参数filename字符串所代表的文件路径,第二个参数是利用数组指针来传递给执行文件,并且需要以空指针(NULL)结束,最后一个参数则为传递给执行文件的新环境变量数组。exec函数一共有六个,其中execve为内核级系统调用,其他(execl,execle,execlp,execv,execvp)都是调用execve的库函数。

    Linux提供了execl、execlp、execle、execv、execvp和execve等六个用以执行一个可执行文件的函数(统称为exec函数,其间的差异在于对命令行参数和环境变量参数的传递方式不同)。这些函数的第一个参数都是要被执行的程序的路径,第二个参数则向程序传递了命令行参数,第三个参数则向程序传递环境变量。以上函数的本质都是调用在arch/i386/kernel/process.c文件中实现的系统调用sys_execve来执行一个可执行文件,该函数代码如下:

    asmlinkage int sys_execve(struct pt_regs regs)
    {
        int error;
        char * filename;
        // 将可执行文件的名称装入到一个新分配的页面中
        filename = getname((char __user *) regs.ebx);
        error = PTR_ERR(filename);
        if (IS_ERR(filename))
            goto out;
        // 执行可执行文件
        error = do_execve(filename,
                (char __user * __user *) regs.ecx,
                (char __user * __user *) regs.edx,
                &regs);
        if (error == 0) {
            task_lock(current);
            current->ptrace &= ~PT_DTRACE;
            task_unlock(current);
            /* Make sure we don't return using sysenter.. */
            set_thread_flag(TIF_IRET);
        }
        putname(filename);
    out:
        return error;
    }
    该系统调用所需要的参数pt_regs在include/asm-i386/ptrace.h文件中定义:
    struct pt_regs {
        long ebx;
        long ecx;
        long edx;
        long esi;
        long edi;
        long ebp;
        long eax;
        int xds;
        int xes;
        long orig_eax;
        long eip;
        int xcs;
        long eflags;
        long esp;
        int xss;
    };
    

    execve系统调用的过程:

    • execve系统调用陷入内核,并传入命令行参数和shell上下文环境
    • execve陷入内核的第一个函数:do_execve,该函数封装命令行参数和shell上下文
    • do_execve调用do_execveat_common,后者进一步调用__do_execve_file,打开ELF文件并把所有的信息一股脑的装入linux_binprm结构体
    • __do_execve_file中调用search_binary_handler,寻找解析ELF文件的函数
    • search_binary_handler找到ELF文件解析函数load_elf_binary
    • load_elf_binary解析ELF文件,把ELF文件装入内存,修改进程的用户态堆栈(主要是把命令行参数和shell上下文加入到用户态堆栈),修改进程的数据段代码段
    • load_elf_binary调用start_thread修改进程内核堆栈(特别是内核堆栈的ip指针)
    • 进程从execve返回到用户态后ip指向ELF文件的main函数地址,用户态堆栈中包含了命令行参数和shell上下文环境

    特殊之处:
    当execve在调用时陷入内核态,就执行do_execve文件,覆盖当前的可执行程序,所以 返回的是新的可执行程序的起点。main函数位置是静态链接的可执行文件,动态链接的可执行文件需要连接动态链接库后在开始执行。

    5.Linux系统的一般执行过程

    (1)正在运行的用户态进程X
    (2)发生中断——save cs:eip/esp/eflags(current) to kernel stack,then load cs:eip(entry of a specific ISR) and ss:esp(point to kernel stack).
    (3)SAVE_ALL //保存现场
    (4)中断处理过程中或中断返回前调用了schedule(),其中的switch_to做了关键的进程上下文切换
    (5)标号1之后开始运行用户态进程Y(这里Y曾经通过以上步骤被切换出去过因此可以从标号1继续执行)
    (6)restore_all //恢复现场
    (7)iret - pop cs:eip/ss:esp/eflags from kernel stack
    (8)继续运行用户态进程Y

    展开全文
  • 本文主要关注进程管理的一个切入点,那就是进程的上下文切换,来理解linux内核是如何进程进程上下文切换的,从而揭开上下文切换的神秘面纱。 (注意:本文以linux-5.0内核源码讲解,采用arm64架构)

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

    本文目录:
    1.进程上下文的概念
    2.上下文切换详细过程
     2.1 进程地址空间切换
     2.2 处理器状态(硬件上下文)切换
    3.ASID机制
    4.普通用户进程、普通用户线程、内核线程切换的差别
    5.进程切换全景视图
    6.总结

    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.S158 /*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 TTB164  */165 ENTRY(cpu_do_switch_mm)166         mrs     x2, ttbr1_el1167         mmid    x1, x1                          // get mm->context.id168         phys_to_ttbr x3, x0169170 alternative_if ARM64_HAS_CNP171         cbz     x1, 1f                          // skip CNP for reserved ASID172         orr     x3, x3, #TTBR_CNP_BIT173 1:174 alternative_else_nop_endif175 #ifdef CONFIG_ARM64_SW_TTBR0_PAN176         bfi     x3, x1, #48, #16                // set the ASID field in TTBR0177 #endif178         bfi     x2, x1, #48, #16                // set the ASID179         msr     ttbr1_el1, x2                   // in TTBR1 (since TCR.A1 is set)180         isb181         msr     ttbr0_el1, x3                   // now update TTBR0182         isb183         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 saved1034  * and restored. On entry:1035  *   x0 = previous task_struct (must be preserved across the switch)1036  *   x1 = next task_struct1037  * Previous and next are guaranteed not to be the same.1038  *1039  */1040 ENTRY(cpu_switch_to)1041         mov     x10, #THREAD_CPU_CONTEXT1042         add     x8, x0, x101043         mov     x9, sp1044         stp     x19, x20, [x8], #16             // store callee-saved registers1045         stp     x21, x22, [x8], #161046         stp     x23, x24, [x8], #161047         stp     x25, x26, [x8], #161048         stp     x27, x28, [x8], #161049         stp     x29, x9, [x8], #161050         str     lr, [x8]1051         add     x8, x1, x101052         ldp     x19, x20, [x8], #16             // restore callee-saved registers1053         ldp     x21, x22, [x8], #161054         ldp     x23, x24, [x8], #161055         ldp     x25, x26, [x8], #161056         ldp     x27, x28, [x8], #161057         ldp     x29, x9, [x8], #161058         ldr     lr, [x8]1059         mov     sp, x91060         msr     sp_el0, x11061         ret1062 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开始进行,一切对用户来说就好像没有发生一样,简直天衣无缝。

    6. 总结

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

     

     

    展开全文
  • 都知道操作系统的一个重要功能就是...本文主要关注进程管理的一个切入点,那就是进程的上下文切换,来理解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内核源代码。

     

    展开全文
  • 所谓上下文切换,实质就是寄存器堆切换过程。这其中一部分需要硬件来切换,一部分需要软件来处理。 当在用户空间发生中断时,首先由 x86 CPU 从硬件角度进行处理,然后才是 linux 内核的处理。当中断处理完毕,...

    先引用一篇博文,再总结一下相关问题。

    所谓上下文切换,实质就是寄存器堆的切换过程。这其中一部分需要硬件来切换,一部分需要软件来处理。

    当在用户空间发生中断时,首先由 x86 CPU 从硬件角度进行处理,然后才是 linux 内核的处理。当中断处理完毕,返回到用户空间时,最后的步骤也是交给 CPU 硬件来处理的。

    1、  X86 CPU 对中断的硬件支持

    CPU 从中断控制器取得中断向量

    根据中断向量从 IDT 中找到对应的中断门

    根据中断门,找到中断处理程序

    在进入中断处理程序前,需要将堆栈切换到内核堆栈。也就是将 TSS 中的 SS0、ESP0装入SS、ESP

    然后将原来的用户空间堆栈(SS, ESP)、EFLAGS、返回地址(CS, EIP)压入新的堆栈。

     以上这一系列动作都由硬件完成

     最后,才进入中断处理程序,接下来,由 linux 内核处理

     2、  Linux 内核对中断的处理

    保存中断来源号

    调用 SAVE_ALL,保存各种寄存器

    将 DS、ES 指向  __KERNEL_DS

    将返回地址 ret_from_intr入栈

    调用 do_IRQ进行中断处理

    中断处理完毕,返回到 ret_from_intr

    3、 ret_from_intr

    所有的中断处理程序在处理完之后都要走到这里;

    判断进入中断前是用户空间还是系统空间

    如果进入中断前是系统空间,则直接调用  RESTORE_ALL

    如果进入中断前是用户空间,则可能需要进行一次调度;如果不调度,则可能有信号需要处理;最后,还是走到 RESTORE_ALL

    RESOTRE_ALL 和 SAVE_ALL 是相反的操作,将堆栈中的寄存器恢复

    最后,调用 iret 指令 ,将处理权交给 CPU

    4、  iret 指令使 CPU 从中断返回

    此时,系统空间的堆栈和CPU在第1步处理完之后,交给 linux 内核时的情形是一样的,也就是保存着用户空间的返回地址(CS、EIP)、EFLAGS、用户空间的堆栈(SS、ESP)。

    CPU将 CS、EIP、EFLAGS 、SS、ESP恢复,从而返回到用户空间。


    总结一下:

    一个CPU同一时刻只能在用户空间和内核空间的一个中运行。在CPU运行在用户空间的过程中,可能以以下两种方式陷入内核 1)系统调用:这其实是一种同步中断,也称“软件中断”(注意,不是软中断),或者称为“异常”,通过int指令来实现;陷入内核后,内核代码运行在进程上下文中。2)中断(也称异步中断),是由I/O设备产生的中断,它会触发中断服务例程的执行,并往往伴随着软中断的执行,此时,CPU运行在中断上下文中。上文中描述了CPU从中断返回用户空间的过程。

    当内核即将返回用户空间时,内核会检查need_resched是否设置,如果设置,则调用schedule(),此时,发生用户抢占。一般来说,用户抢占发生几下情况:
    (1)从系统调用返回用户空间;
    (2)从中断(异常)处理程序返回用户空间。

    与用户抢占相对应的是内核抢占,在内核2.6版本以前并不支持内核抢占,现在我们用的默认一般都是抢占式内核,这可以通过编译时设定编译选项来选择是否使用抢占式内核。对于非内核抢占系统,内核代码可以一直执行,直到完成,也就是说当进程处于内核态时,是不能被抢占的。而对于抢占式内核,在硬中断返回,软中断重新使能,解锁函数,以及开启了内核抢占的系统调用中等一系列情况下都可能发生抢占和CPU的重新调度。详细内容见下一篇博客。

    展开全文
  • 这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。  另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换...
  • 线程上下文切换与进程上下文切换 进程切换分两步 ...这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。 另外一个隐藏的损耗是上下
  • 中断是在同一个进程上下文中(在中断函数中可能产生进程上下文切换) 而进程上下文切换时在两个进程之间进行切换 中断执行过程 1.内核把当前eip esp等压到内核堆栈里面去 然后把eip内容放到中断函数开始处...
  • 所谓上下文切换,实质就是寄存器堆切换过程。这其中一部分需要硬件来切换,一部分需要软件来处理。 当在用户空间发生中断时,首先由 x86 CPU 从硬件角度进行处理,然后才是 linux 内核的处理。当中断处理完毕,...
  • 切换的过程中,操作系统需要先存储当前进程的状态(包括内存空间的指针,当前执行完的指令等等),再读入下一个进程的状态,然后执行此进程。 二、用户线程 用户线程指在用户空间中实现线程,内核对线程一无所知。...
  • 上下文切换

    2021-06-08 17:02:41
    上下文切换是指内核在 CPU 上对进程或者线程进行切换:进程和线程从操作系统层面理解实际就是 CPU 分配的时间片,当一个任务的时间片用完后,就需要切换到另一个...上下文切换就是保存和切换的过程。这里有更官方的名词
  • Linux进程上下文切换过程context_switch详解 日期 内核版本 架构 作者 GitHub CSDN 2016-06-14 Linux-4.6 X86 & arm gatieme LinuxDeviceDrivers Linux进程管理与调度 前面我们了解了linux进程调度器...
  • 首先谈谈上下文切换的概念。 上下文切换指的是内核(操作系统的核心)在CPU上对进程或者线程进行切换。 上下文切换只能发生在内核态中,上下文切换过程中的信息被保存在进程控制块(PCB)中。 上下文切换的信息会...
  • 上下文切换: 指的是内核在CPU上对进程或者线程进行切换。 上下文切换过程中的信息被保存在进程控制块PCB中。PCB又被称作切换帧。...上下文切换的信息会一直被保存在CPU的内存中,直到被再次使用。 ...
  • CPU性能篇-上下文切换

    2021-02-06 13:47:00
    目录上下文切换场景进程上下文切换进程的上下文切换过程触发进程调度场景线程上下文切换中断上下文切换查看系统的上下文切换情况vmstatpidstat分析进行性能测试后用 pidstat 来看一下每秒上下文切换多少次才算正常...
  • 当一个线程时间片用完时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换上下文切换 (context switch) , 其实际含义是任务切换, 或者CPU寄存器切换。当多任务内核决定运行另外任务...
  • 所以,一次系统调用的过程,其实是发生了两次 CPU 上下文切换。不过,需要注意的是,系统调用过程中,并不会涉及到虚拟内存等进程用户态的资源,也不会切换进程。这跟我们通常所说的进程上下文切换是不一样的: ...
  • 这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。  另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上...
  • 线程上下文切换 1.上下文 是指某一时间点 CPU 寄存器和程序计数器的内容 2.切换桢 上下文切换可以认为是...3.上下文切换的活动 挂起一个进程,将这个进程在 CPU 中的状态(上下文)存储于内存中的某处。 在内存中检
  • 文章目录序言CM3内核介绍上下文切换源码分析 序言 前天学习FreeRTOS,想着通过对比着UCOSIII来理解,会更容易一点,在这对比的过程中,发现自己对UCOSIII还不是很熟悉,不仅如此,对CM3内核也不是很熟悉(学操作系统...
  • CPU中断上下文切换

    千次阅读 2019-01-25 22:38:48
    与进程上下文切换不同,中断上下文切换并不涉及到进程用户态。所以,即便中断过程打断了一个正处在用户态进程,也不需要保存和恢复这个进程虚拟内存、全局变量等用户态资源。中断上下文,其实只包括内核...

空空如也

空空如也

1 2 3 4 5 ... 16
收藏数 309
精华内容 123
关键字:

内核上下文切换的过程