本文参考书:操作系统真像还原、操作系统原型xv6分析与实验、其中图主要来自linux内核完全注释
本文针对断点切换迷茫的问题。
详解内核态-用户态的栈变化, 了解用户态-内核态的实现原理和代码分析
为帮助大家理解,我将模拟断点切换时的栈变化过程。
首先要知道几个基础概念
①调用约定:
C语言是用cdecl 约定,
函数参数从右到左入栈,
参数在栈中传递,EAX、ECX、EDX 寄存器由调用者保存,
其余寄存器由被调用者保存,
函数返回值存储在EAX中。
调用者清理栈空间
②内核态上下文context结构 context {
uint edi;
uint esi;
uint ebx ;
unit ebp ;
uint eip ; }
这个结构的作用在于进程在内核态中执行系统调用时可能出现的进程切换操作调用swtch函数 “后”的 被调用者保存,也就是说swtch是被调用者。
还有内核态执行调度器函数 scheduler,每个CPU都有自己独立的context,用于执行 swtch 函数 “后”的 被调用者保存,可以理解为CPU执行流的上下文。
当内核加载到内存,所有都相关数据等初始化完毕后每个CPU只是无限循环 scheduler 调度函数中的for(;;)死循环,直到调度0号进程,才切换到用户态。(有些操作系统实现不是无限循环,都可以总体上都一样细节有差别)
③ 当用户态切换入内核态出现特权级变换时,会在tss中找到当前进程的内核栈地址并进行切换,在当前进程的内核栈中,会压入 cs :ip、flags、ss:sp 等(这是硬件帮忙完成的)。
④ 中断入口,执行任何中断时,优先执行的一段代码
ntr%1entry: ; 每个中断处理程序都要压入中断向量号,所以一个中断类型一个中断处理程序,自己知道自己的中断向量号是多少 %2 ; 中断若有错误码会压在eip后面 ; 以下是保存上下文环境 push ds push es push fs push gs pushad ; PUSHAD指令压入32位寄存器,其入栈顺序是: EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI
执行汇编语言后将栈变成类似上图的形式,每个操作系统都是类似的处理方式但是也稍有不同,本文不需要扣这种细节,只需要知道为了保存用户态上下文 需要压入所有寄存器。 从ss 一直到 esp 这段栈空间也可以称为 用户态断点,用trapframe结构体描述。
一、
我们现在模拟第一个场景,内核已经加载入内存CPU开始执行scheduler 调度函数,此时进程链表中有A 、B 2个进程(实际的操作系统应该是调度init进程)。
上图为进程刚刚创建好,但是还没进行调度时,由内核创建的进程内核栈。如果是init进程,那是内核一条条写进去的,否则是fork系统调用所创建的。
为什么新创建的进程要变成这样?
这样是模拟由用户态切换入内核态时的栈情况。
这里要注意,如果是正常的用户态切换入内核态,一般是要执行系统调用, 上图的中断退出函数地址和context结构之间会有很多调用帧,并且中断退出函数地址不会出现在栈中,而且被中断入口函数替换,中断入口函数是第一个调用帧,他在下几条指令会执行中断退出函数。
我们所要关注的就是如何由内核态切换到用户态,就是其中所加入的中断退出函数。
当内核scheduler函数,选中上图的进程pcb,会执行swtch
1 scheduler(void) 2 { 3 struct proc *p; 4 5 for(;;){ 6 // Enable interrupts on this processor. 7 sti(); 8 9 // Loop over process table looking for process to run. 10 acquire(&ptable.lock); 11 for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){ 12 if(p->state != RUNNABLE) 13 continue; 14 15 // Switch to chosen process. It is the process's job 16 // to release ptable.lock and then reacquire it 17 // before jumping back to us. 18 proc = p; 19 switchuvm(p); 20 p->state = RUNNING; 21 swtch(&cpu->scheduler, p->context); 22 switchkvm(); 23 24 // Process is done running for now. 25 // It should have changed its p->state before coming back. 26 proc = 0; 27 } 28 release(&ptable.lock); 29 30 }
swtch 的参数1 old_context ,参数2 new_context
1 .globl swtch 2 swtch: 3 movl 4(%esp), %eax //获得参数old_context 4 movl 8(%esp), %edx //获得参数new_context 5 6 # Save old callee-save registers 7 pushl %ebp 8 pushl %ebx 9 pushl %esi 10 pushl %edi 11 12 # Switch stacks 13 movl %esp, (%eax) 14 movl %edx, %esp 15 16 # Load new callee-save registers 17 popl %edi 18 popl %esi 19 popl %ebx 20 popl %ebp 21 ret
问题:为什么要调度进程仅仅需要context?
答:因为context是放在栈顶的,通过栈顶就可以得到出内核栈的位置。
老的系统或者一些简单的开源系统,pcb和内核栈是放在同一个页中,可以通过屏蔽有效位计算内核栈位置,现代linux系统pcb是有单独分配器分配,不过也有类似的结构和操作计算偏移。
代码的 7-10行对于本次swtch调用,是压入context到内核的内核栈,为什么没有压入ip?
在内核调度器scheduler → swtch 时,ip作为返回地址已经压入栈中了。 此时ip指向scheduler的第22行代码。
代码13-14 行 重点是这里,此时的栈顶是new_context的地址,那弹出了 17-20后,ret 返回的ip 地址是上图的中断退出函数。由此完成了内核态到用户态的切换。
中断退出函数的作用如下图
而此时的内核的内核栈
我们现在模拟一个场景,2个进程 A , B,其中A在执行,B就绪。
叮叮叮,第一个时钟中断来啦 。
1 static void intr_timer_handler(void) { 2 struct task_struct* proc = running_thread(); //获取进程或线程pcb 3 4 ASSERT(proc->stack_magic == 0x19870916); // 检查栈是否溢出 5 6 proc->elapsed_ticks++; // 记录此线程占用的cpu时间嘀 7 ticks++; //从内核第一次处理时间中断后开始至今的滴哒数,内核态和用户态总共的嘀哒数 8 9 if (proc->ticks == 0) { // 若进程时间片用完就开始调度新的进程上cpu 10 swtch(proc->context,cpu->context) 11 } else { // 将当前进程的时间片-1 12 proc->ticks--; 13 } 14 }
从整体上看调用过程,如下图,中断入口拿中会拿到中断号,然后执行相应的中断也就是时钟中断的中断号。通过swtch 将当前栈顶切换回内核的内核栈,在swtch中执行ret时,下一条程序会执行 scheduler的第22行代码。然后重新调度下一个进程。
此时的进程A的内核栈如下图
当然如果调用其他中断函数,那中断入扣帧到context结构中间会替换为其他栈帧。