kernel:linux-4.9
cpu: ARMV8
背景
在广袤的代码中堆栈无疑是一个高热度的技术用语, 就linux而言你能常观察到的几个场景有:
- 用户态堆栈
函数func_foo中用堆栈来保存寄存器、局部变量等等:
图 1 用户态堆栈实例
- 内核堆栈
在内核中也需要使用堆栈,典型的场景就是异常处理中使用堆栈保存异常现场:
图2 内核堆栈实例
有一个细思极恐的事情,在同一个cpu上这些"堆栈"都是用同一个符号"sp"来指示。
- 用户态正在使用"sp"保存局部变量, 时钟中断来了,linux进入异常处理流程, 然后又用"sp"来保存现场;
- 进程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。让我们再次把视线拉回到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软件中通过各种场景下的现场保存、恢复、初始化等等来决定的。
这部分内容本身牵涉的比较广比较多,因而讲的逻辑也不是很顺,抛砖引玉,希望能够对各位读者有所帮助 心已足以。