linux内核进程切换(二) - 堆栈切换

在进程创建时,内核会为进程创建一系列数据结构,其中最重要的就是上章学习的task_struct结构,它就是进程描述符,表明进程在生命周期内的所有特征。

  1. 内核为进程创建两个栈,一个是用户栈,一个是内核栈,分别处于用户态和内核态使用的栈。
  2. 当进程在用户空间运行时,cpu堆栈指针寄存器里面的内容是用户堆栈地址,使用用户栈;当进程在内核空间时,cpu堆栈指针寄存器里面的内容是内核栈空间地址,使用内核栈。
  3. 内核栈是内存中属于操作系统空间的一块区域,其主要用途为:
    1. 保存中断现场,对于嵌套中断,被中断程序的现场信息依次压入系统栈,中断返回时逆序弹出;
    2. 保存操作系统子程序间相互调用的参数、返回值、返回点以及子程序(函数)的局部变量。
  4. 用户栈是用户进程空间中的一块区域,用于保存用户进程的子程序间相互调用的参数、返回值、返回点以及子程序(函数)的局部变量。

QA:那么为什么不直接用一个栈,何必浪费那么多的空间呢?

  1. 1)如果只用系统栈。系统栈一般大小有限,如果中断有16个优先级,那么系统栈一般大小为15(只需保存15个低优先级的中断,另一个高优先级中断处理程序处于运行)。
  2.     但用户程序子程序调用次数可能很多,那样15次子程序调用以后的子程序调用的参数、返回值、返回点以及子程序(函数)的局部变量就不能被保存,用户程序也就无法正常运行了。
  3. 2)如果只用用户栈。我们知道系统程序需要在某种保护下运行,而用户栈在用户空间(即cpu处于用户态,而cpu处于内核态时是受保护的),不能提供相应的保护措施(或相当困难)。

背景
在广袤的代码中堆栈无疑是一个高热度的技术用语, 就linux而言你能常观察到的几个场景有:

用户态堆栈
函数func_foo中用堆栈来保存寄存器、局部变量等等:

图 1 用户态堆栈实例

内核堆栈
在内核中也需要使用堆栈,典型的场景就是异常处理中使用堆栈保存异常现场:

图2 内核堆栈实例

有一个细思极恐的事情,在同一个cpu上这些"堆栈"都是用同一个符号"sp"来指示。

  1. 用户态正在使用"sp"保存局部变量, 时钟中断来了,linux进入异常处理流程, 然后又用"sp"来保存现场;
  2.  进程prev正在以120码的速度欢畅的使用"sp"来调用函数运行, 然后切换到了进程next; 进程next也要用"sp"来进行自己的函数调用。

问题来了,都在用"sp", 这是万物共享时代的终极产物? 还是"sp"会分身术?

这一切都是通过上下文切换来完成的,而”sp”就是上下文中的一个小部分。

一、ARMv8堆栈指针简介

  堆栈的切换流程和硬件息息相关,我们这里以armv8为背景来进行讲述。Armv8中对异常运行级别进行了划分,不同的运行级别使用的sp可能会有所不同。

  当程序运行在EL0时使用的是SP_EL0;其他Exception level下,可以使用SP_EL0或者当前Exception level所对应的SP_ELn寄存器;具体使用SP_EL0还是SP_EL1是由PSTATE.SP决定,对应的寄存器是Spsel。若Spsel==0,那么强制使用SP_EL0,否则使用用SP_ELn。在linux中Spsel默认位1。因而异常发生时,默认会切换到SP_ELn。

二、用户态与内核态的堆栈切换

  实际上在上第一章已经可以从硬件意义上解释问题1了。就armv8的Linux而言,用户态程序(EL0异常级别)发生异常、进入到内核态(EL1异常级别) sp会从SP_EL0切换到SP_EL1。

  下面我们就结合一个例子看看Linux是如何基于cpu架构特点从软件上来完成sp的切换的。

  任务P在用户态运行时堆栈指针sp实际指向的是SP_EL0寄存器,而SP_EL0存放的就是任务P的用户态堆栈虚拟地址,其值在P的/proc/$pid/maps中的[stack]这个vma区间中。此时任务P发生了一次异常,这会引发如下的一系列连锁反应:

 异常发生 堆栈寄存器切换
          由于中断、系统调用等引发一次系统异常,运行级别从EL0切换到EL1,sp也由硬件自动从SP_EL0切换到SP_EL1,此时SP_EL1指向内核地址空间。在此之前SP_EL1中已经存放了任务P的内核态堆栈的地址,即task_struct->stack中的某个位置(注1)。

保存用户态堆栈指针SP_EL0 等异常现场
    进入异常处理流程初期,由kernel_entry宏将SP_EL0寄存器内容保存到SP_EL1指向的内核堆栈中,然后将SP_EL0挪作它用,比如current宏的实现。

恢复用户态堆栈指针 等异常现场
    在异常处理流程后期,由kernel_exit宏将之前存放到SP_EL1内核堆栈中存放的SP_EL0的值恢复到SP_EL0中;

异常返回 堆栈寄存器切换
    在异常处理处理流程的终点会执行"eret"返回到用户态,PE运行级别从EL1恢复到EL0,sp也随之从SP_EL1切换到SP_EL0。

  一图胜千言,整个流程如下所示:

图3 内核态用户态sp上下文切换

三、进程之间的堆栈切换

  了解了用户态/内核态之间的堆栈指针切换后,我们再来看看进程与进程之间的sp是如何切换的。这个过程稍显复杂,我们从简单到细致一步一步区分析。

  任务之间的切换细节对于我们分析进程之间堆栈切换有着承上启下的作用。对于缺少想象空间的我来说,举例子永远是我最喜欢的方式。下面我就例举进程prev切换到进程next的详细情况,顺便把堆栈切换的流程夹杂其中。

  要注意的是,进程之间切换一定是要在内核中发生,因而需要有异常发生。

发生异常 堆栈寄存器切换 保存异常现场
   进程prev运行过程中发生一次系统异常(系统调用、中断等等),异常级别由EL0变为EL1,sp也随之从SP_EL0切换到SP_EL1, 然后进入异常处理流程入口由kernel_entry宏将SP_EL0寄存器内容存放到SP_EL1所对应的内核堆栈中;

发生调度
   在系统异常处理流程中发生一次调度(如prev系统调用阻塞、或者被更高优先级任务抢占),进入__schedule()调度函数。

调度产生切换
   调度函数__schedule() 首先选择下一个将要运行的任务next; 然后,经过一系列准备之后调用cpu_switch_to(prev, next)函数从任务prev切换到任务next运行。

ENTRY(cpu_switch_to)  /* 两个参数:x0=prev, x1=next*/
        /* 取prev任务的cpu_context到寄存器x8; */
        mov     x10, #THREAD_CPU_CONTEXT 
        add     x8, x0, x10
 
      /* 保存prev任务的现场”x19~29, sp, lr”到prev的cpu_context */    
        mov     x9, sp
        stp     x19, x20, [x8], #16             // store callee-saved registers
        stp     x21, x22, [x8], #16
        stp     x23, x24, [x8], #16
        stp     x25, x26, [x8], #16
        stp     x27, x28, [x8], #16
        stp     x29, x9, [x8], #16
        str     lr, [x8]
 
        /* 取next任务的cpu_context到寄存器x8 */
        add     x8, x1, x10
        ldp     x19, x20, [x8], #16             // restore callee-saved registers
        ldp     x21, x22, [x8], #16
        ldp     x23, x24, [x8], #16
        ldp     x25, x26, [x8], #16
        ldp     x27, x28, [x8], #16
        ldp     x29, x9, [x8], #16
        ldr     lr, [x8]       /* 将next任务原来现场的lr,sp加载到当前现场*/
        mov     sp,  x9
        /* x9保存的是next任务的内核堆栈 */
        and     x9, x9, #~(THREAD_SIZE - 1)
        msr     sp_el0, x9       /* 确保current宏取到的是next任务 */
        ret          /* ret指令*/
ENDPROC(cpu_switch_to)
   这个函数的逻辑还是很清晰:

     [1] THREAD_CPU_CONTEXT是一个任务cpu_context相对于task_struct的偏移, 即THREAD_CPU_CONTEXT + &task_struct就是task_struct.thread.cpu_context的地址;

     [2] 将prev的现场信息(x19~29, sp, lr寄存器)保存到自己的task_struct.thread.cpu_context结构中,在下次自己切换回来时恢复取用;

     [3] 然后再从next的cpu_context结构中取出next的现场信息到当前cpu的x19~x29, sp, lr寄存器中, 这样堆栈sp, 链接寄存器lr等等寄存器都切换到了next任务;

     [4]更新sp_el0,最后执行ret指令。

 这里有两条指令需要注意:

mov sp, x9
这条指令直接将x9寄存器的内容填充到sp;由于此时系统处于EL1异常级别,因而sp指向的是SP_EL1,因而这里实际上是将原来prev的内核堆栈切换到了next任务内核堆栈的某个位置(注2);

ret
 在aarch64架构中使用bl和ret指令来实现函数的调用与返回。bl指令先将下一条指令放到lr寄存器,然后跳转到目标地址执行;ret指令执行时cpu跳转到lr寄存器中所指向的地址执行。由于arch_switch_to()的后面部分从next任务的cpu_context结构中取出了现场信息填入了lr寄存器,因而这里的ret指令会跳转到next任务lr指针地址执行(注3), ret执行完成后当前cpu上的上下文实际上已经更朝换代,火车前进的轨道由prev切换到了next,从此一去.....可能还会返。

新任务运行
   现在pc是沿着next的轨道在前进。那next的这个轨道是通向哪里呢?最终最终,就是走到异常返回的流程,即下面两个流程;

恢复新任务异常现场
   执行kernel_exit,从当前内核堆栈(已经切换至next任务的内核堆栈)中取出next的用户态堆栈恢复到SP_EL0寄存器;

返回新任务用户态
   在内核态的最后阶段指向"eret"指令返回到next用户态上下文,PE运行级别从EL1恢复到EL0,sp也由自动切换到SP_EL0,这时的SP_EL0已经是next任务的用户态堆栈(注4)了。

  总结一下任务之间的堆栈指针是如何切换的:

 图4 进程之间堆栈切换情况

四、细节

第二章和第三章已经按部就班的讲述了用户态/内核态、任务与任务之间的堆栈指针sp如何切换的。但是仍然有一些内容我们一笔带过并未认真去揣摩细节,特别是前面各个章节中的注1..注4等等注意的地方。下面我们就对这些“注”细节进行讲解。

4.1 新任务调度的情况

 新任务的特点就是创建好后从来没有运行过。这种情况一般是fork()系统调用创建好next,next挂入到就绪队列中,由prev任务进入到内核态调用__schedule()函数选择next任务运行。

 也就是说next是第一次被调度运行,所以它的用户态堆栈、它的内核栈都是全新的。

 那它的用户态堆栈、内核栈的内容是什么呢?要回答这个问题,我们需要用一个章节的时间来了解一下。

Fork新任务堆栈相关初始化流程
   当next任务通过fork()调用创建时,会执行如下两个与堆栈相关的关键流程:

图5 fork新任务堆栈初始化相关流程

  【1】在copy_process初期调用alloc_thread_stack_node()函数为新任务next分配大小为16Kb(其他架构可能大小会有差异)的内核堆栈,并赋给新任务task_struct->stack;

  【2】对于普通的fork()系统调用(为了简化讨论vfork与clone的情况暂时不考虑)copy_mm函数会将父进程的各个vma以及页表都拷贝到新任务next,也就是说新任务next与父进程的地址空间是一样的;其中[stack]也是从父进程拷贝过来的一组vma;

  【3】调用copy_thread函数初始化任务的内核堆栈和上下文结构。

int copy_thread(unsigned long clone_flags, unsigned long stack_start,
                 unsigned long stk_sz, struct task_struct *p)
{
    struct pt_regs *childregs = task_pt_regs(p);   //指向新任务内核栈顶pt_regs
    memset(&p->thread.cpu_context, 0, sizeof(struct cpu_context));
        。。。。。。。
    if (likely(!(p->flags & PF_KTHREAD))) { //我们只考虑用户态任务的情况
        *childregs = *current_pt_regs();   //拷贝父进程的内核栈内容  
        childregs->regs[0] = 0;   //子任务fork()返回值为0
        。。。。。。。
    } else {
        。。。。。。。
    }
    //设置cpu_context.pc和cpu_context.sp, 在arch_switch_to会用到
    p->thread.cpu_context.pc = (unsigned long)ret_from_fork;
    p->thread.cpu_context.sp = (unsigned long)childregs;
    ptrace_hw_copy_thread(p);
    return 0;
}
 上面的函数,注意是准备p->thread.cpu_context.pc 和 p->thread.cpu_context.sp。

 其中,p->thread.cpu_context.pc是pc指针,指向ret_from_fork函数;p->thread.cpu_context.sp是内核堆栈指针,它所指向的位置和内容由如下流程确定。

 首先,通过task_pt_regs(p)提取新任务p内核堆栈中存放struct pt_regs的起始位置(实际为(p->stack + 16kb - 16) - sizeof(struct pt_regs)的位置);

 其次,复制父进程堆栈中pt_regs中的内容到新进程堆栈的pt_regs中,但是pt_regs->regs[0]除外,因为这个是fork()系统调用的返回值,而新任务返回值为0。

*childregs = *current_pt_regs()
childregs->regs[0] = 0;
 上面的情况如下图所示:

图6 初始化后子任务的内核堆栈情况

fork新任务总结一下
对于一个新创建的任务,它的内核堆栈指针就是上图中p->thread.cpu_context.sp,这就是前面(注2)在切换到新任务的情况;同时,parent任务内核堆栈中存放的SP_EL0值也复制到新任务的的内核堆栈中来了,这样在新任务从fork()系统调用内核态返回到用户态、恢复用户态堆栈指针时,用户态堆栈指针SP_EL0实际上是等于parent的用户态堆栈指针的,这就是(注4)在切换到新任务的情况,同时fork()返回值为0。这样也解释了fork()系统调用的某些现象(这里就不详细展开)。

 我们再说说前面的(注3),上面除了了堆栈相关的操作外,还设置了新任务的p->thread.cpu_context.pc。新建的子进程PC指针被修改为加载程序的的入库地址,这个时候在进程返回用户态时,就跳转到对应的这个PC位置开始执行加载的程序了。让我们再次把视线拉回到arch_switch_to()函数。

        ldp     x29, x9, [x8], #16
        ldr     lr, [x8]       /* 新任务cpu_context.cpu加载到lr */
       mov     sp,  x9    /* 切换到新任务的内核堆栈 */
 其中x8就是新任务的p->thread.cpu_context结构,上面的两条指令就是将堆栈指针切换到next的内核堆栈;然后将cpu_context.cpu放到lr,此时的cpu_context.cpu已经初始化为ret_from_fork函数,因而arch_switch_to()函数的”ret”指令就跳转到ret_from_fork()函数了,ret_from_fork()具体细节就不展开了,但是最终还是要进行到kernel_exit、eret返回到用户态。

4.2 已有调度史任务的情况

 场景2会简单一些,大部分内容在其他章节已经由讲过,说一下next任务堆栈指针的情况:

图7 进程切换过程中内核堆栈的变化

  [1] 在异常的情况下从用户态进入内核态,此时SP从SP_EL0切换到SP_EL1,此时SP_EL1指向内核堆栈pt_regs的起始位置,这就是(注1)中的某个位置;

  [2] 在异常处理入口SP_EL1向下增长保留出一个struct pt_regs的空间以保存SP_EL0等异常现场寄存器;

  [3] 在异常处理中发生调度,调用arch_switch_to函数,SP_EL1也因为函数调用等原因向下增长。在arch_switch_to函数中会将当前SP_EL1的值保存到next任务的cpu_context结构中,然后cpu调度到其他任务执行;

  [4] 当next任务再次被调度到运行时,内核会从next任务的cpu_context中取出保存的SP_EL1和pc,继续next之前未运行完毕的arch_switch_to()以及更上层的函数;

  [5] 执行kernel_exit,next任务的SP_EL1最终恢复到pt_regs起始位置,SP0_EL1也从堆栈中恢复。

总结:
  堆栈指针sp在linux中的切换是随着异常级别在SP_EL0和SP_EL1之间变化; SP_EL0和SP_EL1的变化则是在Linux软件中通过各种场景下的现场保存、恢复、初始化等等来决定的。 

  这部分内容本身牵涉的比较广比较多,因而讲的逻辑也不是很顺,抛砖引玉,希望能够对各位读者有所帮助 心已足矣。


 

五、中断嵌套堆栈

现在Linux内核不支持中断嵌套处理,不需要考虑Linux内核中断嵌套内容。因此,对于Linux内核需要关系的是在程序运行与内核态或者是用户态的时候,中断来临,这个时候是如何交叉两种实现堆栈的转换,这个可以看博客:Linux kernel的中断子系统之(六):ARM中断处理过程_atf把中断关掉_Fireworks_light的博客-CSDN博客

Linux内核进入中断是首先通过vector_stub宏将中断现场保存在内核初始化SP栈的位置,这个是一块独立划分出来给内核的栈。然后在用户态和内核态另种状态模式切换过来的处理宏中通过svc_entry或者usr_entry将SP刚才保存的那个时候的栈信息保存在实际current进程中的pt_regs中,进而实现进程中断现场的保存。

中断或者异常的嵌套需要两点核心支持,第一个是外部GMIC中断处理器的支持,另外一点是内部中断现场保存的支持。GMIC中断控制器需要具备进行优先级排序和筛选的能力。中断现场保存嵌套M3这种级别的是不同优先级直接使用不同的堆栈,这种情况下调用栈不会出现异常,如果是从低优先级中断跳转到高优先级,高优先级保存的现场是低优先级当前的处理,本高优先级处理完毕以后恢复现场即可继续执行低优先级的内容处理了。

我们在单片机编程,嵌入式RTOS编程,甚至其他OS下的系统编程时,可能会忽略“中断嵌套”背景知识,在之前的工作和编码过程中,我也没有深入的了解或者注意过“中断嵌套”,直到当我想要深入了解嵌入式RTOS的运行原理时,才发现,原来“中断嵌套”的概念是如此的重要,以至于各种RTOS的基础配置,以及设计,都是围绕着“中断嵌套”的机制来设计和配置的。

什么是中断嵌套?

中断嵌套指中断系统正在执行一个中断服务L时,有另一个优先级更高的中断H触发,这时中断系统会暂时中止当前正在执行低优先级中断服务程序L,而去处理高级别中断H,待处理完毕,再返回被中断了的中断服务程序L继续执行。

综上,通俗的讲就是:
所谓中断嵌套,就是中断抢占机制,允许高优先级中断源抢占正在执行的低优先级中断。

嵌入式实时操作系统与中断嵌套的关系
目前比较流行的几种嵌入式实时操作系统有uC/OS、RT-Thread、FreeRTOS等,对外都宣称它们是嵌入式实时操作系统,那么什么叫实时呢?所谓【实时】 其实就是【及时】,能够及时的处理各种任务和中断,而如何实现【实时/及时】呢,本质上就是要支持:高优先级任务/中断能够抢占低优先级任务/中断,这里的【抢占】本质上就是【中断嵌套】。

中断嵌套执行细节流程
通过上面的介绍,我们大概了解了什么叫中断嵌套,说白了就是中断抢占,为了帮助大家理解【抢占】概念,我们从CPU的角度,看一下中断嵌套的整个流程以及内部细节
————————————————
版权声明:本文为CSDN博主「猪哥-嵌入式」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/u012351051/article/details/124788428

对于高端ARM内核的使用SVC实现中断的嵌套处理。这种情况下中断通过同一个地址入口进来,SP栈指针指向的位置也是同一个,嵌套软件代码处理逻辑比较复杂。

2. 中断抢占与中断嵌套的关系
先来看看ARM-GIC中断处理的粗略时序<ARM Generic Interrupt Controller V2.0-3.2>

GIC会将pending状态优先级最高的中断请求发送给CPU
CPU读取GICC_IAR寄存器来ack该中断请求并处理该中断
CPU处理完中断后,CPU写GICC_EOIR寄存器告诉GIC中断处理完成


中断抢占<ARM Generic Interrupt Controller V2.0-3.3.1>

所以说按照文字描述,中断嵌套的实质就是中断抢占机制,GIC将符合条件的高优先级中断上报到CPU,进而CPU响应高优先级中断暂停处理低优先级中断。

疑惑: 未能理解Starting to service an interrupt while another interrupt is still active is sometimes described as interrupt nesting 这句话,interrupt nesting不就是中断嵌套?

参考Linux中断管理 (1)Linux中断管理机制 - ArnoldLu - 博客园

站在GIC的角度: GIC能够在低优先级中断处理完毕之前将高优先级中断送至CPU

站在Linux的角度: Linux hard irq处理CPU全程关中断,所以即使GIC上报了高优先级中断,CPU也置之不理,需要等待低优先级中断处理完毕,再去响应上报的高优先级中断,即:

所以Linux下:

高优先级中断无法抢占正在执行的低优先级中断。
同处于pending状态的中断,优先响应高优先级中断进行处理
参考时序图如下:

3. 为什么Linux不支持中断嵌套
准确的答案应该这么讲: 历史上Linux是支持中断嵌套的,但是现在不支持了

历史上,linux中断处理有快慢中断和IRQF_DISABLED这个标记的概念,如果中断处理函数没有带IRQF_DISABLED这个标记,linux内核代码在处理中断时会打开本CPU响应中断的能力,而GIC又有能力上报优先级更高的中断到CPU,所以这时ARM+GIC组合的系统就能支持中断抢占嵌套

 在后来的一个commit中,中断处理中不再支持打开CPU中断的操作,从而不再支持中断嵌套

kernel/git/torvalds/linux.git - Linux kernel source tree

4. 总结
————————————————
版权声明:本文为CSDN博主「Andy Pines」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/liaojunwu/article/details/125015569

【@.2 中断嵌套的方法探究】

中断嵌套的核心思想是,保存当前CPU寄存器到堆栈中,即保存中断现场,并且打开中断使能位允许中断再次响应。但是由于CPU不同模式的权限区分导致这一想法不能简单的实现。比如,很多启动代码中在进入CPU前会进入没有权限的User模式,但是User模式是没办法对CPSR进行读写操作的,也就是说没办法直接在User模式中将CPSR压入到堆栈,同时不能通过向CPSR的模式位写入模式代码切换到其他的模式。

仅仅考虑irq中断,我们来回忆一下整个中断响应的流程。首先中断有输入,CPSR模式置位为irq模式,将当前PC值保存到irq模式的R14中,并且清除CPSR的irq标志位使得不能再次响应irq中断,最后跳转到irq中断入口地址。进过启动代码和我们的程序配置,最终会进入到我们编写中断服务程序中,当这个程序结束时会读取之前保存在R14_irq中的地址值给PC,程序回到原来中断处继续运行。在此基础上我们考虑集中方法实现中断嵌套。(以下假设主程序在User mode下运行)

方法一:

中断产生时会进入irq模式,所以当然有权限操作CPSR,因此如果我们仅仅清除CPSR的中断使能位使之能够在此响应中断的话看看有什么结果。图中进入中断的红色部分就是我们手动添加的代码。当IRQ1响应时,我们进入中断服务程序之前手动打开CPSR的irq中断使能位,那么如果当运行到一定时间新的中断IRQ2响应时,也会做同样的操作。而其中将PC存入到R14_irq这一步硬件帮我们做的操作就会出问题。因为这两个中断都是进入了irq模式,所以他们的R14_irq是同一个寄存器,中断2响应时会将原本中断1响应时保存在R14中的返回地址值修改,变成中断2响应之前的地址值,而这个值正是中断1的服务程序运行到被中断2打断的地方。当中断2结束返回时读取R14_irq的值,返回到中断1的被打断出,中断1继续运行,当返回时又读取R14_irq的值,这时候的值却是被中断2打断时的地址值,所以中断1会进入一个死循环。

所以,仅仅打开中断使能位而不进行现场保护操作是绝对不行的。

 

方法二:

在中断操作进入中断服务之前手动打开CPSR中断使能位,保存所有寄存器到堆栈,中断返回之前手动恢复堆栈中保存的寄存器。但是这种方法实际上并不可行,如果仅仅有IRQ1时是OK的,但是若有IRQ2,打断IRQ1,当IRQ2返回时此时硬件已经进入User Mode,而CPSR中保存的是之前IRQ1时的CPSR,其中mode位是irq。这就产生了冲突,能不能继续运行下都是个问题。其实可以这样理解,当IRQ2返回时已经进入User Mode模式,而若想返回到刚才的IRQ1,相当于要从User Mode跳到Irq模式执行IRQ1的剩余部分,而User Mode是无法自发跳转到其他模式的,除非调用软件中断,这其实就涉及到下面的方法:

方法三:

每次中断返回时调用软件中断指令,进入swi软件中断模式,这时其实已经进入SVC模式,所以有权限对CPSR进行操作,所以这种方法可以实现中断嵌套,而且实际上这也是很多OS在特定CPU上实现中断现场保护的方法。不过回过头想,所有这一切其实都是因为User Mode没有权限对CPSR进行操作所造成的,那么我们换一种思维,直接让主程序运行在有权限操作CPSR的SVC管理员模式不就好了吗?

方法四:

可以通过修改启动代码,放弃对User模式的使用,使进入main函数之前处于SVC模式,这样一来整个程序从头到尾都是处于SVC模式,具有全部权限进行寄存器的操作,这样一来,在中断返回时也不用像方法三一样,特意进入swi的异常入口进入SVC模式,并且需要保存进入SVC模式的现场。方法四中断返回时直接就是主程序运行的SVC模式,不用新进入另一个模式进行恢复现场操作,这样中断恢复时的时间也会快一些。实际上,这也是uCOS-II在ARM7系列上移植的推荐模式,很多移植的实例也是采用全程SVC模式的方式进行中断嵌套操作的。当然你也可以自己写程序用方法三的软件中断进行现场保护,是可以实现的,只是机器周期上需要多几步软件中断本身的模式切换操作。

【@.3 中断嵌套对于CPU硬件的反思】

最后我们可以分析,之所以ARM7要花这么大代价实现中断嵌套,完全是因为CPU内核的模式与特权造成的。所以如果CPU的内核没有那么多的限制,或者说CPU内核本身就支持简单的中断嵌套,我们的工作也就会好做很多了。我们可以看看Cortex M3系列的STM32F10x内核,模式和特权上就简单很多了:

 

Cortex的中断机制也跟ARM7截然不同。

 

可以看到这种内核模式与中断机制上简化了许多,很适合与OS在其上面的移植。

学习了操作系统的基本原理和调度的相关知识,开始学习进程的上下文切换,本章主要要了解一下内容:

  • 用户级和内核级上下文切换的原理
  • 前面章节学习了进程由哪些部分组成,那么进程自身的上下文切换有哪些部分组成了
  • 何时发生进程的上下文切换(主动切换和系统返回调度切换)

内核进程切换包含包含主动切换和被动切换两种,主动切换是进程调用schedule函数直接在当前调度点发生进程切换工作。被动调度指的是在操作系统现有的处理流程中系统调用返回点和中断返回点都会进行TIF_NEED_RESCHED标志位检查,通过标志位确定当前是否发生调度切换工作调用对应的调度处理函数schedule。本章中的关于切换工作主要是针对这里说道的不管是主动还是被动的切换点schedule调用流程展开,至于被动调度中置位标识符具体置位时机和schedule函数的调度点位也分散在其他几个篇章中,出现的位置我们都会强调说明。

进程间切换的线程和断点保存在哪里,结合linux0.11讲解进程切换的五部曲。

内核切换涉及堆栈基本概念查看博客:linux进程内核栈与用户栈 - 进程与线程(五)_生活需要深度的博客-CSDN博客_内核栈

1 用户级线程上下文切换

上文([进程管理(二)----线程的基本概念]((9条消息) 进程管理(二)----线程的基本概念_奇小葩-CSDN博客))中,我们讨论了何为多线程,而线程又分用户级线程和内核级线程,这节我们先来讨论一下何为用户级线程以及用户级线程的底层原理。用户级线程的切换是由我们用户来主动控制的,现在我们假设有线程1和线程2两个线程(图中红色的数字为内存的地址)

在这里插入图片描述

线程1中有A()和B()两个函数,执行流程为A()函数调用B()函数,B()函数执行完毕后返回到地址为104的语句继续往下执行

线程2中有C()和D()两个函数,执行流程为C()函数调用D()函数,D()函数执行完毕后返回到地址为304的语句继续往下执行

那么图中还有一个Yield()函数到底是什么东西呢,简单来说它就是我们用户主动来控制线程切换的一个函数,在线程1中调用Yield()函数,此时会切换到线程2,在线程2中调用了Yield()函数,此时又会回到线程1继续执行。因此,执行流程为下图所示。

在这里插入图片描述

现在我们更加深入地去剖析整个切换过程到底发生了什么有趣的事,按照我们传统的方式

线程1运行,B为函数调用,此时函数调用的吓一跳指令地址入栈,即104入栈,此时要记录函数调用结束后返回继续执行地址
此时进入B函数内部执行,发生线程切换(Yield),则204入栈,就执行Yield,切换到线程2执行,跳转到300地址
类似的执行C,调用D,304入栈,即将执行Yield,404入栈

在这里插入图片描述
此时根据调用关系,那么当从D退出的时候,应该是从404返回,而不能回到104地方执行,出错的原因是因为两个线程共用了一个栈,导致线程间切换和内部运行出现了问题,因此可以用两个解决该问题,即为每个线程分配一个独立的栈。还是上面的例子,线程1和线程2分别有自己独有的栈,各种的栈地址放在各自线程的TCB中,其流程如下

线程1运行,B为函数调用,此时将函数调用的下一条入栈,即104入栈,然后B函数中执行调用Yield函数,204入线程1的栈,然后切换到线程2中执行
线程2执行,调用C函数中的D,此时304入线程2的栈,然后D函数中执行Yield函数,404入线程2的栈,然后再切换到线程1中执行

在这里插入图片描述
当线程2执行Yield函数后,全局的栈指示变量会执行线程1的栈,此时执行出栈的操作,出的时线程1的栈,弹出204,转向204的地方执行,接着B调用完毕后,继续执行线程1的栈,此时就完美的额解决了一个栈导致的混乱问题。由此可见,用户级线程是基于在用户态分别创建一套维护的用户栈来实现进程间的切换,其特点如下:

基于library函数实现,系统不可见
线程的创建,撤销,状态转换在用户态完成
TCB在用户空间,每一个进程一个系统栈
优点是,不依赖于操作系统,调度灵活,同一进程多个线程切换速度快,不需要进入内核,不会发生上下文的切换

在这里插入图片描述
但是其缺点也很明显,对于多核的CPU,同一进程中的多个线程并不能真正的并发和并行,如果进程中的某个线程进入内核并阻塞,进程中的其它的线程将无法得到执行,例如若内核进程需要等待网卡IO,需要较长时间而导致进程阻塞,用户态的多线程将没有任何作用。

在这里插入图片描述

2 内核级线程上下文切换

现在操作系统都是多核,为了充分发挥操作系统的并行能力,所以就使用一个用户线程映射到一个内核级线程,也就是说一个用户线程就要创建一个内核级线程,随之而导致的时内核级线程的开销增大,所以系统中会限制线程数量,对于目前的windows/linux等操作系统均采用这种模型。

在这里插入图片描述

我们还是以实际的例子来说明,操作系统是如何完成内核级线程的上下文切换的原理,每个用户级线程即需要一个栈,而内核级线程需要一套栈即两个栈:用户栈 + 内核栈,仍然是之前A,B,C的例子

在这里插入图片描述

线程1,A函数调用B,首先此时要将104的地址压入线程1的栈,此时104进入用户栈,对于B函数,会调用到read接口,此时再将204入栈,由于read接口是一个系统调用,此时要立刻用户空间进入到内核态。
进入到内核态的时候,就需要保存此时用户空间的状态,以便返回的用户空间时使用,首先会将栈段寄存器SS(存放段地址,基地址)和栈指针SP(寄存器存放偏移地址),同时将此时标志寄存器(EFLAGS)进内核的栈,然后将此时的用户态用户运行到那里,包括此时运行的PC和下一条指令,页就是PC(304),CS,IP

在这里插入图片描述
然后就执行sys_read就进入内核态,启动磁盘读,将自己变成阻塞状态,然后进程切换到另外一个进程运行,内核线程调度使用switch_to() ,并切换栈

执行switch_to,cur是当前线程的TCB,next是下一个线程的TCB。当前esp(栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶)被赋值 为TCB1中的值,而当内核切换的时候线程T执行,那么esp就必须执行TCB2中的值
对于切换到线程T,就需要执行到用户态的进程,因为操作系统主要就是完成用户任务而设计,所以在线程T中的内核态运行一小段时候就要返回到用户态执行,所以线程T的内核态主要是通过iret切换到用户态的CP:PS
对于整个过程可以理解为如下图所示:

首先是蓝色的部分,线程1在运行过程中,通过系统调用进程到内核态,此时发生系统阻塞,需要调度,内核态此时保持用户态的现场到内核栈中,然后通过调度子系统调度到线程2中运行,此时发生线程控制块的切换,从TCB1切换到TCB2
切换到线程2的TCB时候,TCB中存放了内核栈的指针,此时运行在内核态,此时内核态运行一段收尾代码后,一般会通过iret指令,切换线程2的用户空间,执行用户空间的代码,就完成的用户栈的切换过程
所以对于内核级线程,分为用户态和内核态,例如process 1,用户进程中有线程A和线程B,它们共享进程的内存空间,分别有自己的用户栈,用于存放自己的调用过程,同时在内核空间,有属于自己的PCB,但是对于每一个进程有一个内核栈

在这里插入图片描述

3 进程切换的时机

对于一个进程由哪些部分组成呢?主要包括用户空间和内核空间,其如下图所示:

在这里插入图片描述

用户空间的进程地址空间一般由代码段、数据段、堆、栈组成,由task_struct的VMA维护,同时所有的内存空间都是存放在该进程的页表中,CPU中的reg也是由页表机制来管理
内核空间进程地址空间维护了一个进程的控制块PCB task_struct,主要是内核栈和用户栈信息thread_info,这两个用户维护进程的上下文切换中有大用途
这个在[进程管理(十四)–linux进程管理]((9条消息) 进程管理(十四)–linux进程管理_奇小葩-CSDN博客)章节中已经有详细介绍,要想进行进程的切换,那么OS必须首先获得控制权,其主要在以下情况下得到控制权

trap: 进程主动的切换,主要是通过执行一个system call
Exception: 被动的切换,执行了一个意外的操作,例如常见的page fault
Interrupt: 硬件设备请求OS服务 ,比如time中断,IO中断

在这里插入图片描述
4 进程切换

在这里插入图片描述
基于内核栈实现进程的切换的基本思路

1, 当进程由用户态进入内核态时,主要是通过系统调用或者中断,会引起堆栈切换,没用户信息会被压入到内核栈中,包括此时的用户的栈指针,PC和程序状态保存在内核栈中

2, 当进入到内核后,此时由于某些原因,由于该进程需要读取磁盘或者网络等信息,变成阻塞状态,或者时间片用完,此时需要让出CPU,重新引起调度时,操作系统会找到一个新的进程的PCB,并完成新进程PCB的切换

3, 当完成新进程的切换时,内核也完成了内核栈的切换,那么当中断返回时,执行IRET,弹出的就是新进程的EIP,从而跳转到新进程的用户指令进行执行。

这个切换的核心就是构建出内核栈的样子,要在适当的地方压入栈,适当的地方返回地址,并根据内核栈的样子,编写响应的汇编代码,完成内核堆栈的入栈和出站操作,以便保证顺利完成进程切换。

4.1 中断入口

操作系统负责进程的调度和切换,所以进程的切换一定是内核中发生,而用户程序是运行在内核态,所以就需要使用系统调用进入到内核态。主要的伪代码如下:

push ds; 
mov ds, 内核段号
system_call 

4.2 中断处理

用户态进入内核态,要发生堆栈的切换,系统调用的核心指令对于X86来说是指令int 0x80,这个系统调用中断。 当执行int 0x80 这条语句时由用户态进入内核态时,CPU会自动按照***SS、ESP、EFLAGS、CS、EIP***的顺序,将这几个寄存器的值压入到内核栈中,由于执行int 0x80时还未进入内核,所以压入内核栈的这五个寄存器的值是用户态时的值,其中***EIP*为int 0x80的下一条语句 "=a" (__res),这条语句的含义是将eax所代表的寄存器的值放入到_res变量中。所以当应用程序在内核中返回时,会继续执行 “=a” (__res) 这条语句。**这个过程完成了进程切换中的第一步,通过在内核栈中压入用户栈的ss、esp建立了用户栈和内核栈的联系,形象点说,即在用户栈和内核栈之间拉了一条线,形成了一套栈。

在这里插入图片描述

在system_call中执行完相应的系统调用sys_call_xx后,又将函数的返回值eax压栈。若引起调度,则跳转执行reschedule。否则则执行ret_from_sys_call。

在这里插入图片描述
在执行schedule前将ret_from_sys_call压栈,因为schedule是c函数,所以在c函数末尾的},相当于ret指令,将会弹出ret_from_sys_call作为返回地址,跳转到ret_from_sys_call执行。 总之,在系统调用结束后,将要中断返回前,内核栈主要是SS:SP指向用户栈,EFLAGS标志寄存器,返回地址EIP,还有一些其他的other Registers:EAX,EBX等,如下图所示

在这里插入图片描述

4.3 找到当前进程的PCB和新进程的PCB

当前进程的PCB 当前进程的PCB是用一个全局变量current指向的*(在sched.c中定义)* ,所以current即指向当前进程的PCB,pnext就指向下个进程的PCB。 在schedule()*函数中,当调用函数*switch_to(pent, _LDT(next))*时,会依次将返回地址**}***、参数2 ***_LDT(next)***、参数1 *pnext**压栈。当执行*switch_to*的返回指令ret时,就回弹出schedule()函数的}执行*schedule()*函数的返回指令

4.4 switch_to

对于schedule中switch_to,表示要取出表示下一个进程的PCB参数,并与当前的current做一个比较,如果是当前的current,则什么也不做;如果不等于当前的curret,则开始进程切换,以次完成PCB的切换,内核栈的切换等

在这里插入图片描述

在schedule.c中定义struct tss_struct *tss=&(init_task.task.tss)这样一个全局变量,即0号进程的tss,所有进程都共用这个tss,任务切换时不再发生变化。 虽然所有进程共用一个tss,但不同进程的内核栈是不同的,所以在每次进程切换时,需要更新tss中esp0的值,让它指向新的进程的内核栈,并且要指向新的进程的内核栈的栈底,即要保证此时的内核栈是个空栈,帧指针和栈指针都指向内核栈的栈底。

4.5 中断出口

PC的切换对于被切换出去的进程,当再次被调度的时,根据切换出去的进程的内核栈的样子,switch_to的最后一句指令ret会弹出switch_to后面的指令,作为返回地址继续执行,将弹出ret_from_sys_call作为返回地址,在ret_from_sys_call中继续进行一些处理,最后执行iret指令,进行终端返回,将弹出原来用户进程被中断的地方作为返回地址,继续被中断处执行。

5. 总结

对于进程切换不同于我们熟知的“模式切换”,模式切换,CPU还是在同一进程中运行systemcall或者中断上下文;而进程切换是CPU转向另外一个进程执行,进程切换改变当前的进程空间,其主要的工作如下:

保持当前进程的硬件上下文(PC/SP和通用寄存器等),对于linux系统而言,其硬件上下文大部分都保存在struct thread_struct thread中,但通用寄存器等都保存在内核栈中
修改当前进程的PCB,比如将其状态由运行态修改为就绪或者等待态,并将该进程PCB加入到相关队列中
调度另外一个进程,修改被调度进程的PCB,并将其状态修改为运行
将“当前进程"的管理数据改为调度进程的存储数据,如页表,TLB,同时恢复新进程的硬件上下文,让PC执行新进程的代码

在这里插入图片描述

我们知道当调用schedule函数进行主动调度时,首先会调用通过调度类找到下一个要被调度的进程,然后将当前进程切换状态放入对应调度类的调度队列里面,等待再次被唤醒。而对于被调度的这个队列我们就要对其进行上下文切换,上一章节我们学习了上下文切换的时候的基本原理后,本章主要是学习在最新的内核上基于ARM架构学习完整的进程上下文切换的过程,本文的内核版本号为linux4.9.88。

1 context_switch代码分析

在操作系统中把当前正在运行的进程挂起并恢复以前挂起的某个进程的执行,这个过程叫进程切换或者进程上下文,linux内核实现进程切换的核心代码在kernel/sched/core.c中有一个context_switch函数,该函数用来完成具体的进程切换,代码如下

在这里插入图片描述

该函数是由调度器确定了pre进程和next进程,就调用到该接口执行进程的切换工作,所以其参数情况为
rq:在多核系统中,进程切换总是发生在各个cpu core上,参数rq指向本次切换发生的那个cpu对应的run queue
prev:将要被剥夺执行权利的那个进程
next:被选择在该cpu上执行的那个进程
对于用户进程,其任务描述符(task_struct)的mm和active_mm相同,都是指向其进程地址空间的进程描述符mm_struct。对于内核线程而言,其task_struct的mm成员为NULL(内核线程没有进程地址空间),但是,内核线程被调度执行的时候,总是需要一个进程地址空间,而active_mm就是指向它借用的那个进程地址空间,所以mm为空的话,说明B进程是内核线程,这时候,只能借用A进程当前正在使用的那个地址空间(prev->active_mm)。注意:这里不能借用A进程的地址空间(prev->mm),因为A进程也可能是一个内核线程,不拥有自己的地址空间描述符。
如果要切入的B进程是内核线程,那么由于是借用当前正在使用的地址空间,因此没有必要调用switch_mm_irqs_off进行地址空间切换,只有要切入的B进程是一个普通进程的情况下(有自己的地址空间)才会调用switch_mm_irqs_off,真正执行地址空间切换。
switch_to完成了具体prev到next进程的切换,当switch_to返回的时候,说明A进程再次被调度执行了

在这里插入图片描述
虽然每个进程都可以拥有属于自己的进程空间,但是所有进程必须共享CPU寄存器等资源,所以在进程切换的时候必须把next进程在上一次挂起时保持的寄存器重新装载到CPU里,进程恢复执行前必须装入CPU寄存器的数据为硬件上下文,其主要包括以下两部分

由于对于每个进程拥有系统全部的虚拟地址空间,但是其并没有占用所有的物理地址,物理地址的访问需要页表转换完成,所以切换进程的页表到硬件页表中,此时才能切换进程地址空间,该过程是由switch_mm_irqs_off实现
切换到next进程的内核态栈和硬件上下文,是由switch_to函数实现的,硬件上下文提供了内核执行next进程所需要的所有硬件信息
2 切换前的准备工作
在进程切换之前, 首先执行调用每个体系结构都必须定义的prepare_task_switch挂钩, 这使得内核执行特定于体系结构的代码, 为切换做事先准备. 大多数支持的体系结构都不需要该选项,该接口基本没有做什么事情,暂时不介绍。

在这里插入图片描述

对于内核线程,没有空间切换,直接使用上一个进程的内核空间即可。但是如果是用户进程则调用switch_mm_irqs_off完成用户地址空间切换,switch_mm_irqs_off(或switch_mm)与体系结构相关。

对于ARM64的cpu,每个cpu core都有两个寄存器来指示当前运行在该CPU core上的进程实体的地址空间,这两个寄存器分别是ttbr0_el1(用户地址空间)和ttbr1_el1(内核地址空间)。由于所有的进程共享内核地址空间,因此所谓地址空间切换也就是切换ttbr0_el1而已。

在这里插入图片描述

TTBR0指示了进程PGD页表基址,PGD指示了PTE页表基址,PTE指示了物理地址PA。每个进程的PGD不同,因而不同进程虚拟内存对于的物理地址就隔离开了。进程切换switch_mm实质上就是完成TTBR0寄存器的改写。Linux4.99内核调用switch_mm_irqs_off切换用户进程空间,对于没有定义该函数的架构,则调用的是switch_mm。X86体系架构定义了switch_mm_irqs_off函数,ARM体系架构没有定义。

在这里插入图片描述

在这里插入图片描述

对于swapper的进程,其使用cpu_set_reserved_ttbr0接口如下

在这里插入图片描述

那么check_and_switch_context完成了进程地址空间的切换,这包括两部分内容:

ASID(Address Space ID)和TLB(Translation Lookaside Buffer)的处理;
TTBR处理。
如果next进程发生迁移,在一个新的CPU上执行,则需要flush I-Cache(Instructions Cache)。如下图所示,对于ARM SMP架构来说每个core都有独立的I-Cache和D-Cache(哈佛结构L1 Cache),因而新进程第一次运行到某Core时需要将I-Cache内容全部刷新。

在这里插入图片描述

在看代码之前,我们要学习基础的知识,ASID指示了每个TLB entry所属的进程,这样可以保证不同进程之间的TLB entry不会互相干扰,因而避免了切换进程时将TLB刷新的问题。所以ASID作用避免了进程切换时TLB的频繁刷新。

实际上,ARM TLB包含了Global和process-specific表项,即全集类型的TLB和进程独有类型的TLB

Global类型TLB entry:用于内核空间地址转换,内核空间为所以进程所共有,因而进程切换时,内核映射关系无需变化,所以其TLB entry也不用变。内核的页表基址寄存器是TTBR1,进程切换时页表不变的。

process-specific类型TLB entry:用户进程独立地址空间映射关系。即ASID用于隔离不同进程的TLB entry。

区分Global和process-specific表项则是根据PTE entry的bit11(nG位)。nG位为1时,则表示TLB entry属于进程。

在这里插入图片描述

为了支持进程独有类型的TLB,ARM架构出现了一个硬件解决方案,叫进程地址空间ID(address space ID,ASID),TLB可以识别哪些TLB属于某个进程,其方案如下:

让每个TLB表项包含一个ASID,ASID用于表示每个进程的地址空间
TLB命中查询的标准是在原来的虚拟地址判断智商,加上ASID条件,因此有了ASID硬件机制的支持,进程切换不需要刷新整个TLB,即使next进程访问了相同的虚拟地址,prev进程缓存的TLB项页不会影响到next进程
当系统中所有CPU的硬件ASID加起来超过硬件最大值时,就会发生溢出,就需要刷新全部的TLB,然后重新分配硬件ASID,并存储在asid_bits变量中
check_and_switch_context函数前面部分主要实现了ASID相关的内容,详细的内容参考进程切换分析(2):TLB处理

从mm->context.id原子的获取ASID;
asid_generation记录ASID溢出,mm->context.id低8位记录ASID,高24位记录了ASID溢出次数,如果没有发生ASID溢出则直接调用cpu_switch_mm切换TTBR0。
如果发生ASID溢出则需要为进程重新分配ASID,并记录到mm->context.id中,并刷新TLB。

在这里插入图片描述
地址空间切换过程中,会清空tlb,防止当前进程虚拟地址转化过程中命中上一个进程的tlb表项,一般会将所有的tlb无效,但是这会导致很大的性能损失,因为新进程被切换进来的时候面对的是全新的空的tlb,造成很大概率的tlb miss,需要重新遍历多级页表,所以arm64在tlb表项中增加了非全局(nG)位区分内核和进程的页表项,使用ASID区分不同进程的页表项,来保证可以在切换地址空间的时候可以不刷tlb。

cpu_switch_mm调用cpu_do_switch_mm完成进程地址空间切换,该实现在汇编arch/arm64/mm/proc.S中完成

在这里插入图片描述在这里插入图片描述

最终将进程的pgd虚拟地址转化为物理地址存放在ttbr0_el1中,这是用户空间的页表基址寄存器,当访问用户空间地址的时候mmu会通过这个寄存器来做遍历页表获得物理地址。完成了这一步,也就完成了进程的地址空间切换,确切的说是进程的虚拟地址空间切换。

3 switch_to函数

处理完TLB和页表基地址后,还需要进行栈空间切换,这样next进程才能开始运行,这个正是switch_to的目的。

extern struct task_struct *__switch_to(struct task_struct *,
                       struct task_struct *);

#define switch_to(prev, next, last)                    \
    do {                                \
        ((last) = __switch_to((prev), (next)));            \
    } while (0)

函数一共有3个参数,prev表示将要被调度出去的进程prev,next表示将要被调度进来的进程,这里有一个几个困惑

为什么switch_to要有3个参数呢?prev和next就能实现进程的切换,为什么还需要last?
switch_to函数后面的代码该由哪个进程来执行呢?什么时候执行?

在这里插入图片描述
假设现在进程A在CPU0上执行了switch_to(A, B, last)函数,以主动的方式切换进程B来执行,那么CPU0切换到进程B的硬件上下文,让进程B运行。当switch_to运行之前,prev参数指向进程A,可是当switch_to函数运行完毕之后,CPU已经运行进程B,此时prev参数就变成了进程B的prev参数,而不是进程A
当进程B执行完毕后,又会切换到哪个进程暂时不关注,但是当经历千山万水后,某个CPU上某个进程X执行了调度,A进程又被重新调度的时候,进程A被加载到CPUn上,它会从上次睡眠点开始运行,也就是A1代码片段处。
所以当切换回A进程的时候,该cpu上(也不一定是A调用switch_to切换到B进程的那个CPU)执行的上一个task是谁?这就是第三个参数的含义,实际上这个参数的名字就是last,也就是如何恢复到上一次被调度的片段处。

在这里插入图片描述

具体的切换发生在arch/arm64/kernel/entry.S文件中的cpu_switch_to,代码如下:

在这里插入图片描述

cpu_switch_to要如何保存现场呢?要保存那些通用寄存器,那么就需要符合ARM64标准的过程调用,对于该过程用到了task_struct数据结构里的一个thread_struct的数据结构,用于存放和具体架构相关的信息,对于ARM64定义在arch/arm64/include/asm/processor.h中

在这里插入图片描述

这个结构体主要保存CPU的部分状态(寄存器),用来存储内核态切换时的硬件上下文。

对于ARM64处理器来说,在进程切换的时,我们需要把prev进程的X19~X28寄存器,FP,SP以及PC寄存器保持到这个cpu_context数据结构中
然后把next进程上一次保持的cpu_context的值恢复到实际硬件的寄存器中,这样就完成了进程的上下文切换。
为什么cpu_context数据结构只包含X19X28寄存器,而没有X0X18寄存器?这是跟ARM64架构函数调用的标准和规范有关

X19~X28寄存器在函数调用过程中需要保存在栈里,因为它们是函数调用者和被调用者公用的数据
X0~X7寄存器用于传递函数参数,剩余的通用寄存器大多用于临时寄存器,在进程切换中不需要保存
cpu_context数据结构定义如下:

struct cpu_context {
    unsigned long x19;
    unsigned long x20;
    unsigned long x21;
    unsigned long x22;
    unsigned long x23;
    unsigned long x24;
    unsigned long x25;
    unsigned long x26;
    unsigned long x27;
    unsigned long x28;
    unsigned long fp;
    unsigned long sp;
    unsigned long pc;
};

进程上下文切换的过程如下图所示,在切换的过程中,将进程硬件上下文的重要的寄存器保存到prev进程的cpu_context数据结构中,进程上下文的包括X19~X28寄存器,FP寄存器,SP寄存器,PC寄存器,然后将next进程描述符的cpu_contex的x19-x28,fp,sp,pc恢复到相应寄存器中,而且将next进程的进程描述符task_struct地址存放在sp_el0中,用于通过current找到当前进程,这样就完成了处理器的状态切换。

在这里插入图片描述

那么内核是如何恢复到用户空间去执行呢?我们知道用户空间通过异常/中断进入到内核空间的时候都需要保存现场,也就是保存发生异常/中断的所有的通用寄存器,内核会将现场保存到每个进程特有的进程内核栈中,当异常/中断处理完毕后会返回到用户空间,返回到之前保存的现场,用户程序继续执行。

当进程重新被调度的时候,从原来的调度的现场恢复执行,如以切换的next进程刚好是fork进程,那么它的lr是什么呢?这个在fork的时候设置的

在这里插入图片描述

设置为了ret_from_fork的地址,当然这里也设置了sp等调度上下文(这里将进程切换保存的寄存器称之为调度上下文)。

在这里插入图片描述

刚fork的进程,从cpu_switch_to的ret指令执行后返回,lr加载到pc。于是执行到ret_from_fork:这里首先调用schedule_tail对前一个进程做清理工作,然后判断是否为内核线程如果是执行内核线程的执行函数,如果是用户任务通过ret_to_user返回到用户态。

4 finish_task_switch

A保存内核栈和寄存器,切换至B,此时prev = A, next = B,该状态会保存在栈里,等下次调用A的时候再恢复。然后调用B的finish_task_switch()继续执行下去,返回B的队列rq,该函数主要是完成任务切换后的清理工作,其注释已经解释的比较明白了

在这里插入图片描述

可以看到进程被重新调度时首先需要做的主要是:

在上下文切换前调用prepare_task_switch,在切换后调用finish_task_switch,释放之前的锁,并执行任何其他特定于体系结构的清理操作

重新使能本地中断 ,进程被重新调度时,本地cpu中断是被重新打开的!!!

如果有借用mm的情况,现在归还 如果前一个是内核线程,在进程地址空间切换时“借用了”某个进程的mm_struct,现在切换到了下一个进程,理应归还,归还做的是递减借用的mm_struct的引用计数,引用计数为0就会释放mm_struct占用的内存。

对于上一个死亡的进程现在回收最后的资源, 注意这里是递减引用计数,当引用计数为0时才会真正释放。

返回来,我们看当进程 A 在内核里面执行 switch_to 的时候,内核态的指令指针也是指向这一行的。但是在 switch_to 里面,将寄存器和栈都切换到成了进程 B 的,唯一没有变的就是指令指针寄存器。当 switch_to 返回的时候,指令指针寄存器指向了下一条语句 finish_task_switch。

但这个时候的 finish_task_switch 已经不是进程 A 的 finish_task_switch 了,而是进程 B 的 finish_task_switch 了。这样合理吗?我们如何知道进程B当时切换走到时候,执行到哪呢?恢复到B进程一定执行到这里吗?

在这里插入图片描述

当年 B 进程被别人切换走的时候,也是调用 __schedule,也是调用到 switch_to,被切换成为 C 进程的,所以,B 进程当年的下一个指令也是 finish_task_switch,这就说明指令指针指到这里是没有错的。

5 总结

进程管理中最重要的一步要进行进程上下文切换,其中主要有两大步骤:

一是切换进程空间,也即虚拟内存:保证了进程回到用户空间之后能够访问到自己的指令和数据(其中包括减小tlb清空的ASID机制)

二是切换寄存器和 CPU 上下文,地址空间切换和处理器状态切换(硬件上下文切换),保证了进程内核栈和执行流的切换,会将当前进程的硬件上下文保存在进程所管理的一块内存,然后将即将执行的进程的硬件上下文从内存中恢复到寄存器

有了这两步的切换过程保证了进程运行的有条不紊,当然切换的过程是在内核空间完成,这对于进程来说是透明的。我们以一个系统调用为例

对于用户空间,从进程A切换到进程B,用户栈要不要切换呢?当然要,其实早就已经实现了切换,对于每个进程的用户空间都有独立的用户栈,保存在进程的用户地址空间中

对于系统调用,假设进程A在用户空间要写一个文件,因为写文件的操作没办法完成,就需要通过系统调用到达内核态,在这个切换的过程中,用户态的指令指针寄存器保持在pt_regs里面,到了内核态,就开始沿着写文件逻辑一步步的执行,发现需要等待,就会调用schedule函数调度出去

因为此时内存里面存储的都是A进程的上下文信息,B是不能马上运行的,需要A进程调用switch_to函数进行上下文的切换工作,在 switch_to 里面,将寄存器和栈都切换到成了进程 B 的,唯一没有变的就是指令指针寄存器。当 switch_to 返回的时候,指令指针寄存器指向了下一条语句 finish_task_switch,B进程就从上次切换的地方重新开始运行

假设B进程之前是调用tap_do_read 读网卡的进程。它当年调用 __schedule 的时候,是从 tap_do_read 这个函数调用进去的,当调度返回的时候,页需要接着tap_do_read运行,然后在内核运行完毕,返回用户态

这个时候,B 进程内核栈的 pt_regs 也保存了用户态的指令指针寄存器,就接着在用户态的下一条指令开始运行就可以了。

下面是进程由中断原因的导致的进程切换的示意图

在这里插入图片描述
1.发生中断时的保存现场,将发生中断时的所有通用寄存器保存到进程的内核栈,使用struct pt_regs结构。

2.地址空间切换将进程自己的页全局目录的基地址pgd保存在ttbr0_le1中,用于mmu的页表遍历的起始点。

3.硬件上下文切换的时候,将此时的调用保存寄存器和pc, sp保存到struct cpu_context结构中。做好了这几个保存工作,当进程再次被调度回来的时候,通过cpu_context中保存的pc回到了cpu_switch_to的下一条指令继续执行,而由于cpu_context中保存的sp导致当前进程回到自己的内核栈,

4. 经过一系列的内核栈的出栈处理,最后将原来保存在pt_regs中的通用寄存器的值恢复到了通用寄存器,这样进程回到用户空间就可以继续沿着被中断打断的下一条指令开始执行,用户栈也回到了被打断之前的位置,而进程访问的指令数据做地址转化(VA到PA)也都是从自己的pgd开始进行,一切对用户来说就好像没有发生一样,简直天衣无缝。

用户栈

用户栈就是应用程序直接使用的栈,位于应用程序的用户进程空间的最顶端,用来存储局部、临时变量、函数调用。

以函数调用传递调用参数为例,如果选择使用CPU通用寄存器存放参数,但通用寄存器的数目是有限的,当出现函数嵌套调用时,子函数再次使用通用寄存器必然会导致冲突,因此用进程用户栈来传递参数,在调用子函数前,保存原有寄存器的值,子函数退出时再恢复寄存器的值。对于函数的返回,只需要在调用子函数前,将返回地址压入栈中,待子函数调用结束后,将函数返回地址弹出给PC指针,完成函数调用的返回。

如下是函数调用时,进程用户栈的使用情况,当运行中的程序调用另一个函数时,就要进入一个新的栈帧(栈帧是指为一个函数调用单独分配的那部分栈空间,边界由EBP和栈指针ESP界定,EBP 指向当前栈帧底部(高地址),在当前栈帧内位置固定;ESP指向当前栈帧顶部(低地址),当程序执行时ESP会随着数据的入栈和出栈而移动),原来函数的栈帧称为调用者的帧,新的栈帧称为当前帧,被调用的函数运行结束后当前帧全部收缩,回到调用者的帧:

内核在创建进程的时候,在创建task_struct的同时,会为进程创建进程用户栈,用户栈基于进程的虚拟地址空间的管理机制实现,以VMA(按照不同的访问属性和功能划分为不同的内存区域,称之为虚拟内存区域VMA)形式实现。每个进程都有自己的进程地址空间,即在内核中每个进程struct task_struct使用struct mm_struct描述整体的虚拟进程地址空间:

struct mm_struct
{
     struct vm_area_struct *mmap;    //指向虚拟区间(VMA)链表
     struct rb_root mm_rb;           //指向red_black树
     struct vm_area_struct *mmap_cache;    //找到最近的虚拟区间

     unsigned long(*get_unmapped_area)(struct file *filp,unsigned long addr,unsigned long len,unsigned long pgoof,unsigned long flags);

     void (*unmap_area)(struct mm_struct *mm,unsigned long addr);

     unsigned long mmap_base;

     unsigned long task_size;   //拥有该结构体的进程的虚拟地址空间的大小
     unsigned long cached_hole_size;
     unsigned long free_area_cache;

     pgd_t *pgd;  //指向页全局目录

     atomic_t mm_users;         //用户空间中有多少用户
     atomic_t mm_count;         //对"struct mm_struct"有多少引用

     int map_count;            //虚拟区间的个数
     struct rw_semaphore mmap_sem;
     spinlock_t page_table_lock;       //保护任务页表和mm->rss

     struct list_head mmlist;          //所有活动mm的链表
     mm_counter_t _file_rss;
     mm_counter_t _anon_rss;
     unsigned long hiwter_rss;
     unsigned long hiwater_vm;


     unsigned long total_vm,locked_vm,shared_vm,exec_vm;
     usingned long stack_vm,reserved_vm,def_flags,nr_ptes;

     unsingned long start_code,end_code,start_data,end_data;  //代码段的开始start_code ,结束end_code,数据段的开始start_data,结束end_data

     unsigned long start_brk,brk,start_stack;    //start_brk和brk记录有关堆的信息,start_brk是用户虚拟地址空间初始化,brk是当前堆的结束地址,start_stack是栈的起始地址

     unsigned long arg_start,arg_end,env_start,env_end;     //参数段的开始arg_start,结束arg_end,环境段的开始env_start,结束env_end
     unsigned long saved_auxv[AT_VECTOR_SIZE];

     struct linux_binfmt *binfmt;

     cpumask_t cpu_vm_mask;
     mm_counter_t context;
     unsigned int faultstamp;
     unsigned int token_priority;
     unsigned int last_interval;

     unsigned long flags;
     struct core_state *core_state;
}

针对虚拟进程地址空间中不同的属性区域由一个个的VMA通过一定方式组织在一起,每个VMA也就是struct vm_area_struct进行表示,比如描述栈这个内存区域也是由一个VMA进行描述其开始地址、结束地址、属性权限等等。

struct vm_area_struct {
 /* The first cache line has the info for VMA tree walking. 
 第一个缓存行具有VMA树移动的信息*/
 
 unsigned long vm_start;  /* Our start address within vm_mm. */
 unsigned long vm_end;  /* The first byte after our end address within vm_mm. */
 
 /* linked list of VM areas per task, sorted by address
 每个任务的VM区域的链接列表,按地址排序*/
 struct vm_area_struct *vm_next, *vm_prev;
 
 struct rb_node vm_rb;
 
 /*
  此VMA左侧最大的可用内存间隙(以字节为单位)。 
  在此VMA和vma-> vm_prev之间,
  或者在VMA rbtree中我们下面的一个VMA与其->vm_prev之间。 
  这有助于get_unmapped_area找到合适大小的空闲区域。
  */
 unsigned long rb_subtree_gap;
 
 /* Second cache line starts here. 
 第二个缓存行从这里开始*/
 
 struct mm_struct *vm_mm; /* 我们所属的address space*/
 pgprot_t vm_page_prot;  /* 此VMA的访问权限 */
 unsigned long vm_flags;  /* Flags, see mm.h. */
 
 /*
  对于具有地址空间(address apace)和后备存储(backing store)的区域,
  链接到address_space->i_mmap间隔树,或者链接到address_space-> i_mmap_nonlinear列表中的vma。
  */
 union {
  struct {
   struct rb_node rb;
   unsigned long rb_subtree_last;
  } linear;
  struct list_head nonlinear;
 } shared;
 
 /*
  在其中一个文件页面的COW之后,文件的MAP_PRIVATE vma可以在i_mmap树和anon_vma列表中。
  MAP_SHARED vma只能位于i_mmap树中。 
  匿名MAP_PRIVATE,堆栈或brk vma(带有NULL文件)只能位于anon_vma列表中。
  */
 struct list_head anon_vma_chain; /* Serialized by mmap_sem & * page_table_lock
          由mmap_sem和* page_table_lock序列化*/
 struct anon_vma *anon_vma; /* Serialized by page_table_lock 由page_table_lock序列化*/
 
 /* 用于处理此结构体的函数指针 */
 const struct vm_operations_struct *vm_ops;
 
 /* 后备存储(backing store)的信息: */
 unsigned long vm_pgoff;  /* 以PAGE_SIZE为单位的偏移量(在vm_file中),*不是* PAGE_CACHE_SIZE*/
 struct file * vm_file;  /* 我们映射到文件(可以为NULL)*/
 void * vm_private_data;  /* 是vm_pte(共享内存) */
 
#ifndef CONFIG_MMU
 struct vm_region *vm_region; /* NOMMU映射区域 */
#endif
#ifdef CONFIG_NUMA
 struct mempolicy *vm_policy; /* 针对VMA的NUMA政策 */
#endif
};

进程虚拟地址空间中,栈区,也就是进程栈的初始大小是由编译器和链接器计算出来的,Linux内核会随着栈情况动态增长(也就是添加新的页表),但是栈区不是无限制地增长,其有最大限制,如通过ulimit -s命令查看:

8192kB,即8M大小

关于进程栈的动态增长:进程在运行的过程中,不断地向栈区压入数据,当超出初始大小容量时,触发缺页异常page_fault,Linux内核调用:

expand_stack()-> expand_upwards() ->acct_stack_growth()检查是否还有合适的地方用于栈的增长:

static int acct_stack_growth(struct vm_area_struct *vma,
        unsigned long size, unsigned long grow)
{
 struct mm_struct *mm = vma->vm_mm;
 unsigned long new_start;

 /* address space limit tests */
 if (!may_expand_vm(mm, vma->vm_flags, grow))
  return -ENOMEM;

 /* Stack limit test */
 if (size > rlimit(RLIMIT_STACK))
  return -ENOMEM;

 /* mlock limit tests */
 if (vma->vm_flags & VM_LOCKED) {
  unsigned long locked;
  unsigned long limit;
  locked = mm->locked_vm + grow;
  limit = rlimit(RLIMIT_MEMLOCK);
  limit >>= PAGE_SHIFT;
  if (locked > limit && !capable(CAP_IPC_LOCK))
   return -ENOMEM;
 }

 /* Check to ensure the stack will not grow into a hugetlb-only region */
 new_start = (vma->vm_flags & VM_GROWSUP) ? vma->vm_start :
   vma->vm_end - size;
 if (is_hugepage_only_range(vma->vm_mm, new_start, size))
  return -EFAULT;

 /*
  * Overcommit..  This must be the final test, as it will
  * update security statistics.
  */
 if (security_vm_enough_memory_mm(mm, grow))
  return -ENOMEM;

 return 0;
}

内核栈

内核在创建进程的时候,在创建task_struct的同时,会为进程创建两个栈,第一个栈也就是上面分析到的进程用户栈,存在于用户空间使用,另外还有一个内核栈,存放在内核空间。

内核栈存在的意义:如系统调用在陷入内核后,系统调用中也是存在函数调用和自动变量,这些都需要栈支持。

每个进程都要有独自的内核栈的必要性:所有进程在运行时,都有可能通过系统调用陷入内核态继续执行,假设第一个进程陷入内核执行的时候,需要等待某个资源,主动schedule(),让出CPU,第二个进程假设也通过系统调用进入了内核态,如果进程共享内核栈,那么第二个进程在系统调用压栈时会破坏第一个进程栈数据。

关于内核栈如下:

struct task_struct {
#ifdef CONFIG_THREAD_INFO_IN_TASK
 /*
  * For reasons of header soup (see current_thread_info()), this
  * must be the first element of task_struct.
  */
 struct thread_info  thread_info;
#endif
 /* -1 unrunnable, 0 runnable, >0 stopped: */
 volatile long   state;

 /*
  * This begins the randomizable portion of task_struct. Only
  * scheduling-critical items should be added above here.
  */
 randomized_struct_fields_start

 void    *stack;
  ......
  }

进程task_struct中的stack指向该进程的内核栈

进程的stack创建过程如下:

_do_fork()->copy_process()->dup_task_struct()

static struct task_struct *dup_task_struct(struct task_struct *orig, int node)
{
 struct task_struct *tsk;
 unsigned long *stack;
 struct vm_struct *stack_vm_area;
 int err;

 if (node == NUMA_NO_NODE)
  node = tsk_fork_get_node(orig);
 tsk = alloc_task_struct_node(node);
 if (!tsk)
  return NULL;

 stack = alloc_thread_stack_node(tsk, node);
 if (!stack)
  goto free_tsk;

 stack_vm_area = task_stack_vm_area(tsk);

 err = arch_dup_task_struct(tsk, orig);

 /*
  * arch_dup_task_struct() clobbers the stack-related fields.  Make
  * sure they're properly initialized before using any stack-related
  * functions again.
  */
 tsk->stack = stack;
  ......
  }

stack通过alloc_thread_stack_node(tsk,node)创建内核栈,内核栈的大小为THREAD_SIZE,如下在x86 64位系统上定义,为16K

#define THREAD_SIZE_ORDER (2 + KASAN_STACK_ORDER)
#define THREAD_SIZE  (PAGE_SIZE << THREAD_SIZE_ORDER)
#define CURRENT_MASK (~(THREAD_SIZE - 1))

如下测试栈大小代码:

#include <linux/init.h>
#include <linux/module.h>
#include <linux/sched.h>
static int __init test_stack(void)
{
    union thread_union *test;
    int size;
    test = current->stack;
    size = sizeof(*test);
    pr_info("%d\n",size);
    return 0;

}

static void __exit test_exit(void)
{

    pr_info("end\n");
}

module_init(test_stack);
module_exit(test_exit);
MODULE_LICENSE("GPL"); 

内核中有一个union thread_union具体来表示一个内核栈,内核栈指向该共用体

union thread_union {
#ifndef CONFIG_THREAD_INFO_IN_TASK
 struct thread_info thread_info;
#endif
 unsigned long stack[THREAD_SIZE/sizeof(long)];
};

查看CONFIG_THREAD_INFO_IN_TASK是否开启(x86-64位-kernel 4.15

CONFIG_THREAD_INFO_IN_TASK=y, 说明thread_info在task_struct中,而不在thread_union。此时与体系相关的thread_info结构体作为task_struct的第一个成员, 而thread_union共用体中只有栈。

所以内核栈可以描绘以下所示:

其中struct pt_regs是跟体系结构相关,存放寄存器的数据,Linux内核就是使用它来格式化内核栈的内容,如在x86_64架构下:

struct pt_regs {
/*
 * C ABI says these regs are callee-preserved. They aren't saved on kernel entry
 * unless syscall needs a complete, fully filled "struct pt_regs".
 */
 unsigned long r15;
 unsigned long r14;
 unsigned long r13;
 unsigned long r12;
 unsigned long bp;
 unsigned long bx;
/* These regs are callee-clobbered. Always saved on kernel entry. */
 unsigned long r11;
 unsigned long r10;
 unsigned long r9;
 unsigned long r8;
 unsigned long ax;
 unsigned long cx;
 unsigned long dx;
 unsigned long si;
 unsigned long di;
/*
 * On syscall entry, this is syscall#. On CPU exception, this is error code.
 * On hw interrupt, it's IRQ number:
 */
 unsigned long orig_ax;
/* Return frame for iretq */
 unsigned long ip;
 unsigned long cs;
 unsigned long flags;
 unsigned long sp;
 unsigned long ss;
/* top of stack page */
};

压栈过程中,和上面的数据结构struct pt_regs成员一一对应(顺序固定且是倒序)

关于dump_stack():Linux内核中提供了一个可以打印出内核调用堆栈的函数 dump_stack(),该函数在我们调试内核的过程中可以打印出函数调用关系,以及让我们了解内核的调用关系。对Linux故障定位非常有帮助,在希望打印栈信息的函数中调用dump_stack()即可。该函数的主要原理是向上回溯函数栈,然后根据取得的函数地址,区内核符合表中查询对应的函数名,并最终打印。

测试Demo如下:内核模块入口函数调用aaa->bbb->ccc,并在ccc中打印内核栈中存在的函数调用关系

#include <linux/module.h>   
#include <linux/kernel.h>
#include <linux/init.h>
#include <asm/ptrace.h>
 
void aaa(int a);
void bbb(int b);
void ccc(int c);
 
void aaa(int a)
{
 int b = a + 10;
 bbb(b);
}
 
void bbb(int b)
{
 int c = b + 10;
 ccc(c);
}
 
void ccc(int c)
{
 dump_stack();
 printk("c is %d\n",c);
}
 
static int __init my_init( void )
{
 int a = 10;
 aaa(a);
 printk("my_init \n");  
  return 0;
}
 
static void __exit my_exit(void )
{
     printk("my_exit \n"); 
}
 
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL"); 

参考文献:
https://blog.csdn.net/u014426028/article/details/104446201
https://cloud.tencent.com/developer/article/1805657
https://blog.csdn.net/SweeNeil/article/details/88061381
https://mp.weixin.qq.com/s/6REkh-H_gJ3W-Br0muGPOw (版权归原作者所有,侵删)

往期精彩回顾:

1. 内核态内核栈

在每个进程的生命周期内,经常会通过系统调用(SYSCALL)或者中断进入内核。在执行系统调用后,这些内核代码所使用的栈并不是原先用户空间的栈,而是一个内核空间的栈,这个栈被称作进程的“内核栈”。

由用户态切换到内核态,内核将用户态时的堆栈寄存器的值保存在内核栈中,以便从内核栈切换回进程栈时能找到用户栈的地址。但是,从进程栈切换到内核栈时,内核是如何找到该进程的内核栈的地址信息,这部分放到后续章节中详细介绍。

对于task_struct定义在include/linux/sched.h中,有和内核栈相关的数据项

  struct task_struct {
      struct thread_info thread_info;
      ...
      void * stack;
      ...
  }

其中,thread_info是一个体系相关的描述符,不同的硬件体系所需要记录的标志是不同,因此内核将和特定的硬件体系相关的标志定义在此结构中。

每个task的栈分成用户栈和内核栈两部分,进程内核栈在kernel中的定义在include/linux/sched.h中,如下:

union thread_unoin {
    struct thread_info thread_info;
    unsigned long stack[THREAD_SIZE/sizeof(long)];
}

每个task的内核栈大小THREAD_SIZE :

//ARM架构 , 8K
#define THREAD_SIZE_ORDER    1
#define THREAD_SIZE        (PAGE_SIZE << THREAD_SIZE_ORDER)
#define THREAD_START_SP        (THREAD_SIZE - 8)

//ARM64架构, 16K
#define THREAD_SIZE        16384
#define THREAD_START_SP        (THREAD_SIZE - 16)

//X86_64, 16K
#define THREAD_SIZE_ORDER    (2 + KASAN_STACK_ORDER)
#define THREAD_SIZE  (PAGE_SIZE << THREAD_SIZE_ORDER)

Linux 给每个 task 都分配了内核栈。在 32 位系统上 arch/x86/include/asm/page_32_types.h,是这样定义的:一个 PAGE_SIZE 是 4K,左移一位就是乘以 2,也就是 8K。但是内核栈在 64 位系统上arch/x86/include/asm/page_64_types.h,是这样定义的:在 PAGE_SIZE 的基础上左移两位,也即 16K,并且要求起始地址必须是 8192 的整数倍。

2. 通过 task_struct 找内核栈

进程在内核中相关的主要数据结构有进程描述符task_struct、thread_info和mm_struct。上面的共同体thread_union 里,就有thread_info。我们都熟悉进程描述符task_struct,那么thread_info有什么用?

如果有一个task_struct的stack指针在手,你可以通过下面的函数找到这个线程的内核栈:

//sched.h (include\linux    105464    2018/3/18    592)
static inline void *task_stack_page(const struct task_struct *task)
{
    return task->stack;
}

从 task_struct 如何得到相应的 pt_regs 呢?我们可以通过下面的函数:

//processor.h    (arch\x86\include\asm)
#define task_pt_regs(task) \
({                                    \
    unsigned long __ptr = (unsigned long)task_stack_page(task);    \
    __ptr += THREAD_SIZE - TOP_OF_KERNEL_STACK_PADDING;        \
    ((struct pt_regs *)__ptr) - 1;                    \
})

你会发现,这是先从 task_struct 找到内核栈的开始位置。然后这个位置加上 THREAD_SIZE 就到了最后的位置,然后转换为 struct pt_regs,再减一,就相当于减少了一个 pt_regs 的位置,就到了这个结构的首地址。

对于arm64也同样使用

#define task_pt_regs(p) \
    ((struct pt_regs *)(THREAD_START_SP + task_stack_page(p)) - 1)

所以我们可以通过task_struct,就能够轻松得到内核栈和内核寄存器,如下图所示

在这里插入图片描述

3. 通过内核栈找 task_struct

那如果一个当前在某个 CPU 上执行的进程,你同样也可以知道 task_struct 在哪里,这个艰巨的任务要交给thread_info这个结构。

3.1. ARM架构:

查看arm架构的源码发现,前面提到的CONFIG_THREAD_INFO_IN_TASK宏是关闭的,且没有提供对外kconfig接口。也就是说在32位 arm架构中,thread_info 结构肯定在进程内核栈中。下面这种current宏适用于所有合“thread_info 结构在内核栈中”的架构:

struct thread_info {
    unsigned long        flags;        /* low level flags */
    int            preempt_count;    /* 0 => preemptable, <0 => bug */
    mm_segment_t        addr_limit;    /* address limit */
    struct task_struct    *task;        /* main task structure */
    __u32            cpu;        /* cpu */
    __u32            cpu_domain;    /* cpu domain */
    struct cpu_context_save    cpu_context;    /* cpu context */
    __u32            syscall;    /* syscall number */
    __u8            used_cp[16];    /* thread used copro */
    unsigned long        tp_value[2];    /* TLS registers */
#ifdef CONFIG_CRUNCH
    struct crunch_state    crunchstate;
#endif
    union fp_state        fpstate __attribute__((aligned(8)));
    union vfp_state        vfpstate;
#ifdef CONFIG_ARM_THUMBEE
    unsigned long        thumbee_state;    /* ThumbEE Handler Base register */
#endif
};

这里面有个成员变量 task 指向 task_struct,所以我们常用 current_thread_info()->task 来获取 task_struct。

#define get_current() (current_thread_info()->task)
static inline struct thread_info *current_thread_info(void)
{
    return (struct thread_info *)
        (current_stack_pointer & ~(THREAD_SIZE - 1));
}

而 thread_info 的位置就是内核栈的最高位置,减去 THREAD_SIZE,就到了 thread_info 的起始地址。

3.2. ARM64架构:

通过发现在ARM64架构中,其定义如下:

#define get_current() (current_thread_info()->task)
static inline struct thread_info *current_thread_info(void)
{
    unsigned long sp_el0;

    asm ("mrs %0, sp_el0" : "=r" (sp_el0));

    return (struct thread_info *)sp_el0;
}

ARM64使用sp_el0,在进程切换时暂存进程描述符地址,sp就是堆栈寄存器。在ARM64里,CPU运行在四个级别(或者叫运行空间),分别是el0、el1、el2、el3,el0则就是用户空间,el1则是内核空间。

3.3. X64架构(64位架构)

在x86上也可以采用和32位ARM类似的获取方式,然而在64位体系结构中,linux kernel一直采用的是另一种方式:使用了current_task这个每CPU变量,来存储当前正在使用cpu的进程描述符struct task_struct。

struct task_struct;

DECLARE_PER_CPU(struct task_struct *, current_task); 
static __always_inline struct task_struct *get_current(void)
{
    return this_cpu_read_stable(current_task);
}
 
#define current get_current

到这里,你会发现,新的机制里面,每个 CPU 运行的 task_struct 不通过 thread_info 获取了,而是直接放在 Per CPU 变量里面了。

4. 总结

实际上在linux kernel中,task_struct、thread_info都用来保存进程相关信息,即进程PCB信息。然而不同的体系结构里,进程需要存储的信息不尽相同,linux使用task_struct存储通用的信息,将体系结构相关的部分存储在thread_info中。

在内核态,32 位和 64 位都使用内核栈,格式也稍有不同,主要集中在 pt_regs 结构上
在内核态,32 位和 64 位的内核栈和 task_struct 的关联关系不同。
x86中32 位主要靠 thread_info,64 位主要靠 Per-CPU 变量,而ARM平台不论是32位还是64位,都是使用thread_info,其原理基本类似。

在这里插入图片描述

5. 用户栈

用户栈是应用程序直接使用的栈,位于应用程序用户进程空间的最顶端,用来存储函数调用过程中的局部变量、零食变量等,用户支持用户进程的函数调用

  • 3
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值