哈工大-操作系统-HitOSlab-李治军-实验4-基于内核栈切换的进程切换

实验4-基于内核栈切换的进程切换

实验内容请查看蓝桥云课实验说明,实验的相关知识请查看我的上一篇文章

一、实验内容

1.schedule 与 switch_to

目前 Linux 0.11 中工作的schedule()函数是首先找到下一个进程的数组位置 next,而这个next就是 GDT中的 n,所以这个 next 是用来找到切换后目标 TSS段的段描述符的,一旦获得了这个 next值,直接调用上面剖析的那个宏展开 switch_to(next);就能完成 TSS 切换所示的切换了。

现在,我们不用 TSS进行切换,而是采用切换内核栈的方式来完成进程切换,所以在新的 switch_to 中将用到当前进程的PCB、目标进程的 PCB、当前进程的内核栈、目标进程的内核栈等信息。

因此需要将目前的 schedule() 函数(在 kernal/sched.c 中)做稍许修改,即将下面的代码:

if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
    c = (*p)->counter, next = i; /* i = NR_TASKS ,NR_TASK应该是定义在其他头文件的一个宏?*/

//......

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(pnext, _LDT(next))时,会依次将参数2 _LDT(next)、参数1 pnext、返回地址 }压栈。

当执行switch_to的返回指令ret时,就回弹出schedule()函数的}执行schedule()函数的返回指令}

2.编写switch_to汇编代码

为了将linux-0.11基于TSS切换内核线程的方式修改成基于PCB的方式,需要将原来放在 (/oslab/linux-0.11/include/linux/sched.h) 的switch_to注释掉,转而直接在 (/oslab/linux-0.11/kernel/system_call.s) 中添加由汇编代码编写的新的switch_to代码

switch_to:
    pushl %ebp
    movl %esp,%ebp
    pushl %ecx
    pushl %ebx
    pushl %eax
    movl 8(%ebp),%ebx
    cmpl %ebx,current
    je 1f
#切换PCB
    movl %ebx,%eax
	xchgl %eax,current
#重写TSS指针
    movl tss,%ecx
    addl $4096,%ebx
    movl %ebx,ESP0(%ecx)
#切换内核栈
    movl %esp,KERNEL_STACK(%eax)
    movl 8(%ebp),%ebx
    movl KERNEL_STACK(%ebx),%esp
#切换LDT
	movl 12(%ebp), %ecx
    lldt %cx
    movl $0x17,%ecx
	mov %cx,%fs
#这一段先不用管
    cmpl %eax,last_task_used_math 
    jne 1f
    clts

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

理解上述代码的核心,是理解栈帧结构和函数调用时控制转移权方式。

大多数CPU上的程序实现使用栈来支持函数调用操作。栈被用来传递函数参数、存储返回地址、临时保存寄存器原有值以备恢复以及用来存储局部数据。单个函数调用操作所使用的栈部分被称为栈帧结构。
栈帧结构的两端由两个指针来指定。寄存器ebp通常用作帧指针,而esp则用作栈指针。在函数执行过程中,栈指针esp会随着数据的入栈和出栈而移动,因此函数中对大部分数据的访问都基于帧指针ebp进行。

在执行switch_to上述这段代码前,内核栈的具体情况如下图所示:

在这里插入图片描述
如果你现在还不能理解这张图,请查看我的上一篇文章:操作系统学习笔记(四)——用户级线程和核心级线程

现在,在脑海中留着对内核栈的印象,一条条的解析switch_to的汇编代码:

	pushl %ebp

将栈帧指针ebp压入内核栈中,内核栈变为:
在这里插入图片描述

	movl %esp,%ebp

esp栈指针传递给ebp。原来ebp指针指向哪处地方我们是不在乎的,不过这句代码执行完成后,ebp指针就指向刚刚压入的ebp位置。

在这里插入图片描述

	pushl %ecx
    pushl %ebx
    pushl %eax

执行完这三句压栈代码后,内核栈的变化如图所示:
在这里插入图片描述

	movl 8(%ebp),%ebx

linux 0.11的内核栈的地址顺序从上往下看,是由高到低的。也就是说,这句代码是将ebp指针+8指向的数据传递给了ebx寄存器,也就是将pnext(下一个进程的PCB)放在ebx寄存器中:
在这里插入图片描述

	cmpl %ebx,current
    je 1f

ebx寄存器中保存的是下一个进程的PCB,current是当前进程的PCB。如果两个进程相同,跳转到1f位置处,后面的代码不用执行了,什么都不会发生。

#切换PCB
    movl %ebx,%eax
	xchgl %eax,current

把ebx的数据置给eax,交换eax和current中的内容。这两句代码执行完后,ebx和current都指向下一个进程的PCB,eax指向当前进程的PCB

#重写TSS指针
    movl tss,%ecx
    addl $4096,%ebx
    movl %ebx,ESP0(%ecx)

虽然不使用 TSS 进行任务切换了,但是 Intel 的这态中断处理机制还要保持,所以仍然需要有一个当前 TSS。

在sched.c中定义struct tss_struct *tss=&(init_task.task.tss)这样一个全局变量,即0号进程的tss,所有进程都共用这个tss,任务切换时不再发生变化。

ebx本来是指向?下一个进程的PCB,执行完指令addl $4096,%ebx后,ebx指向下一个进程的PCB。为什么偏移量是4096?4096 = 4KB。在linux0.11中,一个进程的内核栈和该进程的PCB段是放在一块大小为4KB的内存段中的,其中该内存段的高地址开始是内核栈,低地址开始是PCB段。

ESP0常量需要我们手动添加在system_call.s中,其中ESP0 = 4。看一看 TSS 的结构体定义就明白为什么是4了。

#切换内核栈
    movl %esp,KERNEL_STACK(%eax) 
    movl 8(%ebp),%ebx
    movl KERNEL_STACK(%ebx),%esp

Linux 0.11的PCB定义中没有保存内核栈指针这个域(kernelstack),所以需要我们额外添加。在(/oslab/linux0.11/include/linux/sched.h)中找到结构体task_struct的定义,对其进行如下修改:

/* linux/sched.h */
struct task_struct {
    
    
long state;
long counter;
long priority;
long kernelstack;
/* ...... */
}

由于这里将PCB结构体的定义改变了,所以在产生0号进程的PCB初始化时也要跟着一起变化,需要在(sched.h)中做如下修改:

/* linux/sched.h */
#define INIT_TASK \
/* state etc */ {
      
       0,15,15,PAGE_SIZE+(long)&init_task,\
/* signals */   0,{
      
      {
      
      },},0, \
......
}

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

/* kernel/system_call.s */
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)

不知道大家是否还记得我上一篇文章:操作系统学习笔记(四)——用户级线程和核心级线程中,对内核栈切换写的伪代码
在这里插入图片描述

#切换LDT
	movl 12(%ebp), %ecx
    lldt %cx
    movl $0x17,%ecx
	mov %cx,%fs

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

程序运行到这里,我们通过一系列的操作,让操作系统切换了PCB、切换了TSS指针以及内核栈。现在esp指针已经是指向了下一个进程的内核栈了,但是下一个进程的内核栈还是空空如也的:
在这里插入图片描述

对于得到CPU的新的进程,我们要修改(kernel/fork.c)中的copy_process()函数,将新的进程的内核栈填写成能进行PC切换的样子。

    /* ...... */
    p = (struct task_struct *) get_free_page();
    /* ...... */
    p->pid = last_pid;
    p->father = current->pid;
    p->counter = p->priority;

    long *krnstack;
    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;
    /* ...... */
    }

在(kernel/system_call.s)添加标号为first_return_from_kernel的汇编代码:

/* kernel/system_call.s */
.align 2
first_return_from_kernel:
    popl %edx
    popl %edi
    popl %esi
    pop %gs
    pop %fs
    pop %es
    pop %ds
    iret

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

具体修改如下:

system_call.s中的全局声明

.globl switch_to
.globl first_return_from_kernel

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

extern long switch_to(struct task_struct *p, unsigned long address);
extern long first_return_from_kernel(void);

二、回答问题

  1. 问题一:针对下面的代码片段:

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

    回答问题:

    • 为什么要加 4096;
      4096 = 4KB。在linux0.11中,一个进程的内核栈和该进程的PCB段是放在一块大小为4KB的内存段中的,其中该内存段的高地址开始是内核栈,低地址开始是PCB段。ebx是指向一个进程的PCB,偏移4096后便指向了另一个进程的PCB
    • 为什么没有设置 tss 中的 ss0。
      tss.ss0是内核数据段,现在只用一个tss,因此不需要设置了。
  2. 问题二:针对下面代码片段

    *(--krnstack) = ebp;
    *(--krnstack) = ecx;
    *(--krnstack) = ebx;
    *(--krnstack) = 0;
    

    回答问题:

    • 子进程第一次执行时,eax=?为什么要等于这个数?哪里的工作让 eax 等于这样一个数?
      答:eax = 0;还记得我们改写的fork.c吗?其中一句代码*(--krnstack) = 0;其实就是将内核栈用用于返回给eax寄存器的内容置为0,最后eax的内容会返回给fork函数
    • 这段代码中的 ebx 和 ecx 来自哪里,是什么含义,为什么要通过这些代码将其写到子进程的内核栈中?
      答:ebx和ecx来自copy_process()的形参,形参的来源是各个段寄存器。对于fork函数而言,子进程是父进程的拷贝,就是要让父子进程共用同一个代码、数据和堆栈。
    • 这段代码中的 ebp 来自哪里,是什么含义,为什么要做这样的设置?可以不设置吗?为什么?
      答:ebp是用户栈地址,一定要设置,不设置子进程就没有用户栈了
  3. 问题三:为什么要在切换完 LDT 之后要重新设置 fs=0x17?而且为什么重设操作要出现在切换完 LDT 之后,出现在 LDT 之前又会怎么样?
    答:这两句代码的含义是重新取一下段寄存器fs的值,这两句话必须要加,也必须要出现在切换完LDT之后,这是因为通过fs访问进程的用户态内存,LDT切换完成就意味着切换了分配给进程的用户态内存地址空间,所以前一个fs指向的是上一个进程的用户态内存,而现在需要执行下一个进程的用户态内存,所以就需要用这两条指令来重取fs。 出现在LDT之前访问的就还是上一个进程的用户态内存。

参考文献:
1.哈工大实验“官方”github仓库
2.蓝桥云课-操作系统原理与实践
3.GDT,LDT,GDTR,LDTR 详解,包你理解透彻
4.在Linux-0.11中实现基于内核栈切换的进程切换
5.(浓缩+精华)哈工大-操作系统-MOOC-李治军教授-实验4-基于内核栈的进程切换

猜你喜欢

转载自blog.csdn.net/qq_42518941/article/details/119182097