基于内核栈完成进程的切换

linux0.11使用intel提供的TSS切换方式,这种切换方式效率很低,我们在这里使用内核栈的切换方式,效率更高

一、内核栈切换的五阶段

1.引发中断

中断是用户态进入操作系统的唯一途径

我们可以通过操作系统调用来进入内核

void A(){
   
	void B();
}
void B(){
   
    
    fork();
}

这里使用fork()的系统调用,完成进入内核和创建新进程的功能

(1)fork()核心分析

fork()是一个系统调用,想要真正的使用操作系统内核一定需要对应的内核函数,这时候就需要根据函数表来查找fork()对应的内核函数 sys_fork

(a)引发中断
mov %eax,__NR_fork
int 0x80
mov res,%eax

int 0x80将当前用户栈的栈段基址和偏移以及用户代码段基址和偏移压入内核栈

(b)进入内核

system_call:
push %ds....%fs
pushl %edx....
call sys_fork
pushl %eax

system_call完成将当前进程的用户栈的执行现场压入当前内核栈

(c)执行内核函数sys_fork(作用一)

作用一:

判断当前进程是否阻塞或者超时,如果阻塞或者超时,就切换进程

sys_fork:
movl _current,%eax
cmpl $0,state(%eax)
jne reschedule
cmpl $0,counter(%eax)
je reschedule
ret_from_sys_call:

2.调用schedule切换TCB/TSS

(d)执行reschedule切换进程
reschedule:
pushl $ret_from_sys_call
jmp _schedule

先将ret_from_sys_call地址压入栈,跳转到schedule的c函数中调用switch_to 执行切换TCB/TSS**(由于本实验要求使用内核栈来切换,那么我们就需要把schedule函数的根据TSS的切换方式,改为根据PCB的切换方式)** ,切换完成后,遇到函数的**}**,即执行ret指令,将栈顶的ret_from_sys_call函数地址弹出,然后执行该函数

3.切换内核栈

使用内核函数——switch_to,后面修改后再详细说明

4.iret中断返回

(e)执行ret_from_sys_call 完成切换
ret_from_sys_call:
popl %eax
popl %ebx...
pop %fs..%ds
iret

进程切换以后,现在的栈顶指针指向的是切换以后的内核栈栈顶,要退出内核态进入用户态首先要做的就是恢复用户态的执行现场,就是一系列pop,然后调用iret弹出用户栈的基本信息,即cs:eip\ss:esp,显然就回到了用户态了。

4.为什么只分析这四个阶段

在这里插入图片描述

明明有五个阶段呢,第五个去哪里了呢,为什么不说明呢?

这五个阶段为:

1.引发中断

2.调用schedule函数

3.内核栈切换

4.iret中断返回

5.用户栈切换

而iret返回时,弹出了用户栈以及用户代码段的全部地址,自然而然地就切换到用户态执行了,所以事实上可以将四五阶段和为一个,但是这里分开表述更为清晰,也是为了后面的内容做铺垫。

二、实现基于内核栈切换完成进程切换的必要修改

由于linux-0.11使用的是基于TSS完成进程的切换,这种切换效率低,我们实验要将其改为内核栈切换

既然是修改切换方式,那么显然需要从schedule函数和 switch_to这两个切换函数下手了

当然,在那之前,要先修改fork(),因为fork()有一个重要作用是初始化子进程的内核栈,由于子进程和父进程共用同一个用户栈,所以直接使用copy_process内核函数将父进程内核栈的信息拷贝到子进程内核栈中,唯一不同的就是eax

1.修改fork(在copy_process中修改)

主要就是修改对子进程内核栈的初始化,因为之前用TSS切换,不用内核栈切换

krnstack=(long*)(PAGE_SIZE+(long)p);	//找到子进程内核栈顶的位置,内核栈顶指针在一页内存的最上方
//初始化内核栈(与用户栈链接)
*(-krnstack)=ss&0xffff;
*(-krnstack)=esp;
*(-krnstack)=eflags;
*(-krnstack)=cs&0xffff;
*(-krnstack)=eip;
//初始化用户态执行现场
*(-krnstack)=ebp;
*(-krnstack)=ecx;
*(-krnstack)=ebx;
*(-krnstack)=0;————》//eax,作为返回值区分父子进程

2.修改schedule

以前的schedule一定是切换TSS表的,找到下一个进程的TSS表在GDT表中的位置,然后将这个位置传给switch_to(n)内核函数完成切换的

所以就可以知到schedule原来的核心代码大概这样子

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

p指向的是结构体PCB

每个进程都需要自己的一套LDT(确保地址分离机制),而在linux-0.11中,LDT存储在TSS当中,所以,需要将目标进程的LDT表一并放入switch_to函数中

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

3.修改switch_to函数

由于此处是c语言调用的汇编函数,所以需要现在汇编中处理栈帧**(c函数调用都会有栈帧)**

然后进行PCB的切换

接着修改TSS表中的指针

然后切换内核栈

最后切换LDT

switch_to:
pushl %ebp
movl %esp,%ebp
pushl %ecx
pushl %ebx
pushl %eax
movl 8(%ebp),%ebx	!获取第一个参数,指向目标PCB的指针
cmpl %ebx,current
je 1f
!切换PCB
movl %ebx,%eax
xchgl %eax,current
!上面两条指令,使eax指向未完成切换的当前进程,ebx和current指向目标进程
!TSS内核栈指针的重写
!由于中断处理需要寻找当前进程的内核栈,内核栈的寻找依然需要借助TSS来完成,但是此时只需要一个TSS即可
!因为,现在的TSS只用于寻找当前内核栈,而下一个内核栈依靠查找PCB中的kernelstack域即可,所以所有进程可以
!共用同一个内核栈,那么就可以将TR一直指向0号进程的TSS描述符在GDT表中的位置
!struct tss_struct *tss=&(init_task.task.tss);
!然后就可以重写TSS的内核栈指针了
movl tss,%ecx
addl $4096,%ebx  !内核栈在task_struct那一页内存的顶部
movl %ebx,ESP0(%ecx)	!ESP0是宏,值为4,因为TSS内核栈指针esp0就放在偏移为4的地方
!找到他,将内核栈指针赋给该处
!内核栈的切换
movl %esp,KERNEL_STACK(%eax)	!当前esp放入kernelstack域
movl 8(%ebp),%ebx	!再取一次ebx,因为之前修改过ebx的值
movl KERNEL_STACK(%ebx),%esp
!切换LDT
movl $0x17,%ecx
mov %cx,fs
cmpl %eax,last_task_used_math

jne 1f
clts
1:
ret

ret返回到switch_to调用的下一条指令,书上还有一些其他的步骤,但是不太理解为什么要这么做,这是理论上谈的,做完实验后如果有错误,会更正

重置fs!!

这里一定要重置fs,因为在GDT表中查到段基址以后,会将段基址保存在段选择子的隐藏区域,下次再执行段选择子相同的查询时,就直接将隐藏部分的段基址取出。放到这里也就是上一个用户态内存的数据段基址。

5.补充:task_struct结构体中kernelstack域的增加

PCB中是没有内核栈指针的,所以要自己修改加上

KERNEL_STACK=12
struct task_struct{
	long state;
	long counter;
	long priority;
	long kernelstack;
	.......
}

由于这里改变了task_struct结构体的定义,所以0号进程的结构体初始化时也要一起改变

#define INIT_TASK{
     0,15,15,PAGE_SIZE+(long)&init_task,0,0....}

书上还有一些其他的步骤,但是不太理解为什么要这么做,这是理论上谈的,做完实验后如果有错误,会更正