基于内核栈切换的进程切换(李治军操作系统课实验5)

分析:

TSS作用
  • 找到当前内核栈:进程内核栈在线性地址空间中的地址是由该任务的TSS段中的ss0和esp0两个字段指定的,依靠TR寄存器就可以找到当前进程的TSS。也就是说,当从用户态进入内核态时,CPU会自动依靠TR寄存器找到当前进程的TSS,然后根据里面ss0和esp0的值找到内核栈的位置,完成用户栈到内核栈的切换。TSS是沟通用户栈和内核栈的关键桥梁,这一点在改写成基于内核栈切换的进程切换中相当重要!
  • 目前 Linux 0.11 中工作的 schedule() 函数是首先找到下一个进程的数组位置 next(),而这个 next() 就是用来找到切换后目标 TSS 段的段描述符的,一旦获得了这个 next 值,直接 switch_to(next)就能完成切换(进行TSS覆盖)。
    但是

原有的Linux 0.11采用基于TSS和一条指令,虽然简单,但这指令的执行时间却很长,在实现任务切换时大概需要200多个时钟周期。而通过堆栈实现任务切换可能要快,而且采用堆栈的切换还可以使用指令流水的并行化优化技术,同时又使得CPU的设计变得简单。所以无论是Linux还是Windows,进程/线程的切换都没有使用Intel提供的这种TSS切换手段,而都是通过堆栈实现的。

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

  • 内核栈的具体样子
    执行int 0x80将SS、ESP、EFLAGS、CS、EIP入栈。
    在system_call中将DS、ES、FS、EDX、ECX、EBX入栈。

    system_call:
        cmpl $nr_system_calls-1,%eax
        ja bad_sys_call
        push %ds
        push %es
        push %fs
        pushl %edx
        pushl %ecx      # push %ebx,%ecx,%edx as parameters
        pushl %ebx      # to the system call
        movl $0x10,%edx        # set up ds,es to kernel space
        mov %dx,%ds
        mov %dx,%es
        movl $0x17,%edx        # fs points to local data space
        mov %dx,%fs
        call sys_call_table(,%eax,4)
        pushl %eax
        movl current,%eax
        cmpl $0,state(%eax)        # state
        jne reschedule
        cmpl $0,counter(%eax)      # counter
        je reschedule
    

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

    reschedule:
          pushl $ret_from_sys_call
          jmp schedule
    

在执行schedule前将ret_from_sys_call压栈,因为schedule是c函数,所以在c函数末尾的},相当于ret指令,将会弹出ret_from_sys_call作为返回地址,跳转到ret_from_sys_call执行。
总之,在系统调用结束后,将要中断返回前,内核栈的样子如下:
在这里插入图片描述

进程切换五段论

基于内核栈实现进程切换的基本思路:当进程由用户态进入内核时,会引起堆栈切换,用户态的信息会压入到内核栈中,包括此时用户态执行的指令序列EIP。由于某种原因,该进程变为阻塞态,让出CPU,重新引起调度时,操作系统会找到新的进程的PCB,并完成该进程与新进程PCB的切换。如果我们将内核栈和PCB关联起来,让操作系统在进行PCB切换时,也完成内核栈的切换,那么当中断返回时,执行IRET指令时,弹出的就是新进程的EIP,从而跳转到新进程的用户态指令序列执行,也就完成了进程的切换。
这个切换的核心是构建出内核栈的样子,要在适当的地方压入适当的返回地址,并根据内核栈的样子,编写相应的汇编代码,精细地完成内核栈的入栈和出栈操作,在适当的地方弹出适当的返回地址,以保证能顺利完成进程的切换。同时完成内核栈和PCB的关联,在PCB切换时,完成内核栈的切换。

实验:

0、实验目标

要实现基于内核栈的任务切换,主要完成如下三件工作:

(1)重写 switch_to;
(2)将重写的 switch_to 和 schedule() 函数接在一起;
(3)修改现在的 fork()。

1、找到当前进程的PCB和新进程的PCB
  • 当前进程的PCB
    当前进程的PCB是用一个全局变量current指向的(在sched.c中定义) ,所以current即指向当前进程的PCB
  • 新进程的PCB
    为了得到新进程的PCB,我们需要对schedule()函数做如下修改:
if ((*p)->state == TASK_RUNNING && (*p)->counter > c) 
      c = (*p)->counter, next = i; 
       .....
  switch_to(next);

修改为:

if ((*p)->state == TASK_RUNNING && (*p)->counter > c) 
    c = (*p)->counter, next = i, pnext = *p;
.......
switch_to(pnext, LDT(next)); 

这样,pnext就指向下个进程的PCB。
在schedule()函数中,当调用函数switch_to(pent, _LDT(next))时,会依次将参数2 _LDT(next)、参数1 pnext、返回地址}压栈。当执行switch_to的返回指令ret时,就回弹出schedule()函数的}执行schedule()函数的返回指令}。关于执行switch_to时内核栈的样子,在后面改写switch_to函数时十分重要。
此处将跳入到switch_to中执行时,内核栈的样子如下:
在这里插入图片描述

2、完成PCB的切换
3、根据PCB完成内核栈的切换
4、切换运行资源LDT

这些工作都将有改写后的switch_to完成。

将Linux 0.11中原有的switch_to实现去掉,写成一段基于堆栈切换的代码。由于要对内核栈进行精细的操作,所以需要用汇编代码来实现switch_to的编写,既然要用汇编来实现switch_to,那么将switch_to的实现放在system_call.s中是最合适的。这个函数依次主要完成如下功能:由于是c语言调用汇编,所以需要首先在汇编中处理栈帧,即处理ebp寄存器;接下来要取出表示下一个进程PCB的参数,并和current做一个比较,如果等于current,则什么也不用做;如果不等于current,就开始进程切换,依次完成PCB的切换、TSS中的内核栈指针的重写、内核栈的切换、LDT的切换以及PC指针(即CS:EIP)的切换。

switch_to(system_call.s)的基本框架如下:

  switch_to:
        pushl %ebp
        movl %esp,%ebp
        pushl %ecx
        pushl %ebx
        pushl %eax
        movl 8(%ebp),%ebx
        cmpl %ebx,current
        je 1f
    ! 切换PCB
        ! ...
    ! TSS中的内核栈指针的重写
        ! ...
    ! 切换内核栈
        ! ...
    ! 切换LDT
        ! ...
        cmpl %eax,last_task_used_math 
        jne 1f
        clts
    
    1:   popl %eax
        popl %ebx
        popl %ecx
        popl %ebp
    ret

栈帧结构:
在这里插入图片描述
所以执行完指令pushl %eax后,内核栈的样子如下:
在这里插入图片描述
注:pnext和_LDT(next)反了

代码:
1、完成PCB的切换

movl %ebx,%eax
xchgl %eax,current

2、TSS中的内核栈指针的重写
如前所述,当从用户态进入内核态时,CPU会自动依靠TR寄存器找到当前进程的TSS,然后根据里面ss0和esp0的值找到内核栈的位置,完成用户栈到内核栈的切换。虽然此时不使用TSS进行进程切换了,但是Intel的中断处理机制还是要保持。
所以每个进程仍然需要一个TSS,操作系统需要有一个当前TSS。
这里采用的方案是让所有进程共用一个TSS(这里使用0号进程的TSS),因此需要定义一个全局指针变量tss(放在system_call.s中),即0号进程的tss,所有进程都共用这个tss,任务切换时不再发生变化。
虽然所有进程共用一个tss,但不同进程的内核栈是不同的,所以在每次进程切换时,需要更新tss中esp0的值,让它指向新的进程的内核栈,并且要指向新的进程的内核栈的栈底,即要保证此时的内核栈是个空栈,帧指针和栈指针都指向内核栈的栈底。
这是因为新进程每次中断进入内核时,其内核栈应该是一个空栈。为此我们还需要定义:ESP0 = 4,这是TSS中内核栈指针esp0的偏移值,以便可以找到esp0。具体实现代码如下:

movl tss,%ecx
addl $4096,%ebx
movl %ebx,ESP0(%ecx)

为什么要加4096?
因为Linux 0.11进程的内核栈和该进程的PCB在同一页内存上(一块4KB大小的内存),其中PCB位于这页内存的低地址,栈则位于这页内存的高地址。一开始内核栈无内容,应指向内存页的最顶端。

3、内核栈的切换

Linux 0.11的PCB定义中没有保存内核栈指针这个域(kernelstack),所以需要加上,而宏KERNEL_STACK就是你加的那个位置的偏移值,当然将kernelstack域加在task_struct中的哪个位置都可以,但是在某些汇编文件中(主要是在system_call.s中)有些关于操作这个结构一些汇编硬编码,所以一旦增加了kernelstack,这些硬编码需要跟着修改,由于第一个位置,即long state出现的汇编硬编码很多,所以kernelstack千万不要放置在task_struct中的第一个位置,当放在其他位置时,修改system_call.s中的那些硬编码就可以了。

在schedule.h中将struct task_struct修改如下:

struct task_struct {
long state;
long counter;
long priority;
long kernelstack;    #add
......
}

同时在system_call.s中定义KERNEL_STACK = 12 并且修改汇编硬编码,修改代码如下:(已给出)

ESP0        = 4
KERNEL_STACK    = 12

......

state   = 0     # these are offsets into the task-struct.
counter = 4
priority = 8
kernelstack = 12
signal  = 16
sigaction = 20      # MUST be 16 (=len of sigaction)
blocked = (37*16)

switch_to中的实现代码如下:(对照栈理解)

movl %esp,KERNEL_STACK(%eax)
movl 8(%ebp),%ebx
movl KERNEL_STACK(%ebx),%esp

由于这里将 PCB 结构体的定义改变了,所以在产生 0 号进程的 PCB 初始化时也要跟着一起变化,需要将原来的 #define INIT_TASK { 0,15,15, 0,{{},},0,… 修改为 #define INIT_TASK { 0,15,15,PAGE_SIZE+(long)&init_task, 0,{{},},0,…,即在 PCB 的第四项中增加关于内核栈栈指针的初始化。

4、LDT的切换
switch_to中实现代码如下:

movl 12(%ebp),%ecx
lldt %cx

一旦修改完成,下一个进程在执行用户态程序时使用的映射表就是自己的LDT表了,地址分离实现了。

5、利用IRET指令完成用户栈的切换
  • PC的切换
    对于被切换出去的进程,当它再次被调度执行时,根据被切换出去的进程的内核栈的样子,switch_to的最后一句指令ret会弹出switch_to()后面的指令}作为返回返回地址继续执行,从而执行}从schedule()函数返回,将弹出ret_from_sys_call作为返回地址执行ret_from_sys_call,在ret_from_sys_call中进行一些处理,最后执行iret指令,进行中断返回,将弹出原来用户态进程被中断地方的指令作为返回地址,继续从被中断处执行。
  • fork()
    对于得到CPU的新的进程,我们就是要把进程的用户栈、用户程序和其内核栈通过压在内核栈中的 SS:ESP,CS:IP 关联在一起。
    另外,由于 fork() 这个叉子的含义就是要让父子进程共用同一个代码、数据和堆栈,所以修改 fork() 的核心工作就是要形成如下图所示的子进程内核栈结构。
    在这里插入图片描述
    不难想象,对 fork() 的修改就是对子进程的内核栈的初始化,在 fork() 的核心实现 *copy_process 中,p = (struct task_struct *) get_free_page();用来完成申请一页内存作为子进程的 PCB,而 p 指针加上页面大小就是子进程的内核栈位置,所以语句 krnstack = (long *) (PAGE_SIZE + (long) p); 就可以找到子进程的内核栈位置,*接下来就是初始化 krnstack 中的内容了。
*(--krnstack) = ss & 0xffff;
*(--krnstack) = esp;
*(--krnstack) = eflags;
*(--krnstack) = cs & 0xffff;
*(--krnstack) = eip;

这五条语句就完成了上图所示的那个重要的关联,因为其中 ss,esp 等内容都是 copy_proces() 函数的参数,这些参数来自调用 copy_proces() 的进程的用户栈中,就是父进程的用户栈中。

接下来的工作就需要和 switch_to 接在一起考虑了,故事从哪里开始呢?回顾一下前面给出来的 switch_to(),应该从 “切换内核栈” 完事的那个地方开始,现在到子进程的内核栈开始工作了,接下来做的四次弹栈以及 ret 处理使用的都是子进程内核栈中的东西

1: popl %eax
    popl %ebx
    popl %ecx
    popl %ebp
ret

所以,为了能够顺利完成这些弹栈工作,子进程的内核栈中应该有这些内容,所以需要对 krnstack 进行初始化:

*(--krnstack) = ebp;
*(--krnstack) = ecx;
*(--krnstack) = ebx;
// 这里的 0 最有意思,代表返回值是0,与父进程区分
*(--krnstack) = 0;

现在到了switch_to()中的 ret 指令了,这条指令要从内核栈中弹出一个 32 位数作为 EIP 跳去执行,所以需要弄一个函数地址(仍然是一段汇编程序,所以这个地址是这段汇编程序开始处的标号)并将其初始化到栈中。搞一个名为 first_return_from_kernel 的汇编标号,然后可以用语句 *(–krnstack) = (long) first_return_from_kernel; 将这个地址初始化到子进程的内核栈中,执行 ret 以后就会跳转到 first_return_from_kernel 去执行了。

first_return_from_kernel 要完成什么工作?
PCB 切换完成、内核栈切换完成、LDT 切换完成,接下来应该那个“内核级线程切换五段论”中的最后一段切换了,即完成用户栈和用户代码的切换,依靠的核心指令就是 iret,当然在切换之前应该恢复一下执行现场,主要就是eax,ebx,ecx,edx,esi,edi,gs,fs,es,ds 等寄存器的恢复。

first_return_from_kernel 的核心代码:

popl %edx
popl %edi
popl %esi
pop %gs
pop %fs
pop %es
pop %ds
iret

所以 edx 等寄存器的值也应该先初始化到子进程内核栈,即 krnstack 中。

后别忘了将存放在 PCB 中的内核栈指针修改到初始化完成时内核栈的栈顶,即:

 p->kernelstack = krnstack;

fork.c中copy_process()的具体修改如下:

long *krnstack;
p = (struct task_struct *) get_free_page();
krnstack = (long)(PAGE_SIZE +(long)p);
 *(--krnstack) = ss & 0xffff;
 *(--krnstack) = esp;
 *(--krnstack) = eflags;
 *(--krnstack) = cs & 0xffff;
 *(--krnstack) = eip;
 *(--krnstack) = ds & 0xffff;
 *(--krnstack) = es & 0xffff;
 *(--krnstack) = fs & 0xffff;
 *(--krnstack) = gs & 0xffff;
 *(--krnstack) = esi;
 *(--krnstack) = edi;
 *(--krnstack) = edx;
 *(--krnstack) = (long)first_return_from_kernel;
 *(--krnstack) = ebp;
 *(--krnstack) = ecx;
 *(--krnstack) = ebx;
 *(--krnstack) = 0;
 p->kernelstack = krnstack;
 ......

最后,注意由于switch_to()和first_return_from_kernel都是在system_call.s中实现的,要想在schedule.c和fork.c中调用它们,就必须在system_call.s中将这两个标号声明为全局的,同时在引用到它们的.c文件中声明它们是一个外部变量。

具体代码如下:

system_call.s中的全局声明

.globl switch_to
.globl first_return_from_kernel

对应.c文件中的外部变量声明:

extern long switch_to;
extern long first_return_from_kernel;
  • 14
    点赞
  • 36
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
操作系统 治军 pdf》是一本由作者治军撰写的关于操作系统的书籍的电子版PDF文件。操作系统是计算机系统中的一个核心组成部分,它负责管理和控制计算机的硬件和软件资源,使得计算机能够高效地运行各种应用程序。 在这本书中,治军详细介绍了操作系统的基本概念、原理和设计原则。他从操作系统的起源和发展历程出发,讲解了多道、分时和实时操作系统等不同类型的操作系统,并深入解析了其内核结构和功能。此外,书中还讨论了进程管理、内存管理、文件系统、输入输出控制等重要的操作系统主题,为读者提供了全面了解和深入学习的机会。 这本书的PDF版本使得读者可以更加便捷地获取其中的内容,无论是在电脑、平板还是手机上都可以进行阅读。通过阅读这本书,读者可以深入了解操作系统的基本原理和相关技术,有助于他们提升对计算机系统的理解和应用。对于学习计算机科学或相关专业的学生和从事软件开发工作的技术人员来说,这本书是一本宝贵的参考资料。 总之,《操作系统 治军 pdf》是一本全面介绍操作系统的书籍的电子版PDF文件,读者可以通过阅读它来深入了解操作系统的原理和技术。这本书的出现为学习和研究操作系统提供了便利,对于对计算机系统有兴趣的人士来说是一本值得阅读的优秀教材。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值