哈工大操作系统实验---lab4:基于内核栈的进程切换

实验目的

  • 深入理解进程和进程切换的概念
  • 综合应用进程、CPU管理、PCBLDT、内核栈、内核态等知识解决实际问题;
  • 开始建立系统认识。

实验内容:

Linux0.11中采用的基于TSS进程切换去掉,取而代之的是基于堆栈的切换程序,具体地说,也就是将进程切换函数schedule()函数中的switch_to()函数从原本的基于TSS切换改写成基于堆栈的切换

  • 编写汇编程序switch_to()
  • 完成主体框架;
  • 在主体框架下依次完成PCB切换、内核栈切换、LDT切换等;
  • 修改fork(),由于是基于内核栈的切换,所以进程需要创建出能完成内核栈切换的样子。
  • 修改PCB,即task_struct结构,增加相应的内容域,同时处理由于修改了task_struct所造成的影响。
  • 用修改后的Linux 0.11仍然可以启动、可以正常使用。

实验步骤:

1.修改schedual()函数
2.重写switch_to()函数(宏函数=>系统调用)
3.修改fork()函数(系统调用)

1.修改schedual()函数:函数位于sched.c文件中
将如下代码改写成:
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的指针,需要在函数前面定义(初始指向current):
struct * tast_struct pnext = current:

2.重写switch_to()函数

原本的switch_to()函数是一个宏函数,定义在头文件sched.h中:

#define switch_to(n) {\
struct {long a,b;} __tmp; \
__asm__("cmpl %%ecx,current\n\t" \
	"je 1f\n\t" \
	"movw %%dx,%1\n\t" \
	"xchgl %%ecx,current\n\t" \
	"ljmp *%0\n\t" \
	"cmpl %%ecx,last_task_used_math\n\t" \
	"jne 1f\n\t" \
	"clts\n" \
	"1:" \
	::"m" (*&__tmp.a),"m" (*&__tmp.b), \
	"d" (_TSS(n)),"c" ((long) task[n])); \
}

可以很明显的看出,该函数是基于TSS进行进程切换的(ljmp指令)
现在要改写成基于堆栈(内核栈)切换的函数,就需要删除掉该语句

然后新的switch_to()函数将它作为一个系统调用函数,所以要将函数重写在汇编文件system_call.s:

/** switch_to() 
 *  由于要对内核栈做精细的操作,所以要用汇编代码来写切换函数。
*/
.align 2
switch_to:
    //因为该汇编函数要在c语言中调用,所以要先在汇编中处理栈帧
	pushl %ebp
	movl %esp,%ebp
	pushl %ecx
	pushl %ebc
	pushl %eax

    //先得到目标进程的pcb,然后进行判断
    //如果目标进程的pcb(存放在ebp寄存器中) 等于   当前进程的pcb => 不需要进行切换,直接退出函数调用
    //如果目标进程的pcb(存放在ebp寄存器中) 不等于 当前进程的pcb => 需要进行切换,直接跳到下面去执行
	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
	
	movl $0x17,%ecx
	mov %cx,%fs
	cmpl %eax,last_task_used_math
	jne 1f
	clts
	
	//在到子进程的内核栈开始工作了,接下来做的四次弹栈以及ret处理使用的都是子进程内核栈中的东西

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

逐条解释基于堆栈切换的switch_to()函数四段核心代码:

// PCB的切换
movl %ebx,%eax
xchgl %eax,current

起始时eax寄存器保存了指向目标进程的指针,current指向了当前进程,
第一条指令执行完毕,使得ebx也指向了目标进程,
然后第二条指令开始执行,也就是将eax的值和current的值进行了交换,最终使得eax指向了当前进程,current就指向了目标进程(当前状态就发生了转移)
// TSS中内核栈指针的重写
movl tss,%ecx
addl $4096,%ebx
movl %ebx,ESP0(%ecx)

中断处理时需要寻找当前进程的内核栈,否则就不能从用户栈切到内核栈(中断处理没法完成),
内核栈的寻找是借助当前进程TSS中存放的信息来完成的,(当然,当前进程的TSS还是通过TR寄存器在GDT全局描述符表中找到的)。

虽然此时不使用TSS进行进程切换了,但是Intel的中断处理机制还是要保持。
所以每个进程仍然需要一个TSS,操作系统需要有一个当前TSS。
这里采用的方案是让所有进程共用一个TSS(这里使用0号进程的TSS),
因此需要定义一个全局指针变量tss(放在system_call.s中)来执行0号进程的TSS:
struct tss_struct * tss = &(init_task.task.tss)

此时唯一的tss的目的就是:在中断处理时,能够找到当前进程的内核栈的位置。

在内核栈指针重写指令中有宏定义ESP0,所以在上面需要提前定义好 ESP0 = 4,
(定义为4是因为TSS中内核栈指针ESP0就放在偏移为4的地方)
并且需要将: blocked=(33*16) => blocked=(33*16+4) 
//切换内核栈
movl %esp,KERNEL_STACK(%eax)
movl 8(%ebp),%ebx
movl KERNEL_STACK(%ebx),%esp

第一行:将cpu寄存器esp的值,保存到当前进程pcb的eax寄存器中(保存当前进程执行信息)
第二行:获取目标进程的pcb放入ebx寄存器中
第三行:将ebx寄存器中的信息,也就是目标进程的信息,放入cpu寄存器esp中
(其实这里有点看不懂汇编代码,为什么是将前面寄存器的值赋值给后面的)

但是之前的进程控制块(pcb)中是没有保存内核栈信息的寄存器的,所以需要在sched.h中的task_struct(也就是pcb)中添加kernelstack,
但是添加的位置就有讲究了,因为在某些汇编文件(主要是systen_call.s中),有操作这个结构的硬编码,
一旦结构体信息改变,那这些硬编码也要跟着改变,
比如添加kernelstack在第一行,就需要改很多信息了,
但是添加到第四行就不需要改很多信息,所以这里将kernelstack放到第四行的位置:
struct task_struct {
/* these are hardcoded - don't touch */
	long state;	/* -1 unrunnable, 0 runnable, >0 stopped */
	long counter;
	long priority;
	/** add  kernelstack */
	long kernelstack;
    ...
}

改动位置及信息:
将
#define INIT_TASK \
/* state etc */	{ 0,15,15,\
/* signals */	0,{{},},0, \
...
改为:
#define INIT_TASK \
/* state etc */	{ 0,15,15, PAGE_SIZE+(long)&init_task,\
/* signals */	0,{{},},0, \
...

在执行上述切换内核栈的代码之前(也就是switch_to()函数前),要设置栈的大小:KERNEL_STACK = 12
然后就执行上面的三行代码,就可以完成对内核栈的切换了。
//LDT的切换
movl 12(%ebp),%ecx
lldt %cx
movl $0x17,%ecx
mov %cx,%fs

前两条语句的作用(切换LDT):
第一条:取出参数LDT(next)
第二条:完成对LDTR寄存器的修改

然后就是对PC指针(即CS:EIP)的切换:
后两条语句的含有就是重写设置段寄存器FS的值为0x17
(类似于上面的问题:为什么是前面的值给后面的?) 

补:FS的作用:通过FS操作系统才能访问进程的用户态内存。
这里LDT切换完成意味着切换到了新的用户态地址空间,所以需要重置FS。


3.修改fork()系统调用

现在需要将新建进程的用户栈、用户程序地址和其内核栈关联在一起,因为TSS没有做这样的关联
fork()要求让父子进程共享用户代码、用户数据和用户堆栈
虽然现在是使用内核栈完成任务的切换(基于堆栈的进程切换),但是fork()的基本含义不应该发生变化。
综合分析:
修改以后的fork()要使得父子进程共享同一块内存空间、堆栈和数据代码块。

下面是修改的代码片段:
fork()系统调用的代码放在system_call.s汇编文件中,查看代码:

.align 2
sys_fork:
	call find_empty_process
	testl %eax,%eax
	js 1f
	push %gs
	pushl %esi
	pushl %edi
	pushl %ebp
	pushl %eax
	call copy_process//跳转到copy_process()函数
	addl $20,%esp
1:	ret
可以看到fork()函数的核心就是调用了copy_process(),接下来去看copy_process(),

copy_process()函数定义在fork.c中,代码和分析见注释:

int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
		long ebx,long ecx,long edx,
		long fs,long es,long ds,
		long eip,long cs,long eflags,long esp,long ss)
{
	struct task_struct *p;
	int i;
	struct file *f;

	p = (struct task_struct *) get_free_page();//用来完成申请一页内存空间作为子进程的PCB
	...

	/** 很容易看出来下面的部分就是基于tss进程切换机制时的代码,所以将此片段要注释掉

	p->tss.back_link = 0;
	p->tss.esp0 = PAGE_SIZE + (long) p;
	p->tss.ss0 = 0x10;
	...
	*/
	
	/** 然后这里要加上基于堆栈切换的代码(对frok的修改其实就是对子进程内核栈的初始化 */
	long * krnstack = (long *)(PAGE_SIZE+(long)p);//p指针加上页面大小就是子进程的内核栈位置,所以这句话就是krnstack指针指向子进程的内核栈

	//初始化内核栈(krnstack)中的内容:
	//下面的五句话可以完成对书上那个图(4.22)所示的关联效果(父子进程共有同一内存、堆栈和数据代码块)
	/*
	而且很容易可以看到,ss,esp,elags,cs,eip这些参数来自调用该函数的进程的内核栈中,
	也就是父进程的内核栈,所以下面的指令就是将父进程内核栈的前五个内容拷贝到了子进程的内核栈中
	*/
	*(--krnstack) = ss & 0xffff;
	*(--krnstack) = esp;
	*(--krnstack) = eflags;
	*(--krnstack) = cs & 0xffff;
	*(--krnstack) = eip;

	*(--krnstack) = (long) first_return_kernel;//处理switch_to返回的位置

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

	//把switch_to中要的东西存进去
	p->kernelstack = krnstack;
	...

上面的first_return_kernel(系统调用)的工作:
"内核级线程切换五段论"中的最后一段切换,即完成用户栈和用户代码的切换,依靠的核心指令就是iret,当然在切换之前应该恢复一下执行现场,主要就是eax,ebx,ecx,edx,esi,gs,fs,es,ds这些寄存器的恢复,

要将first_return_kernel(属于系统调用,而且是一段汇编代码)写在system_call.s头文件里面:

首先需要将first_return_kernel设置在全局可见:
.globl switch_to,first_return_kernel

然后需要在fork.c中添加该函数的声明:
extern void first_return_from_kernel(void);

最后就是将具体的函数实现放在system_call.s头文件里面:
first_return_kernel:
 popl %edx
 popl %edi
 popl %esi
 pop %gs
 pop %fs
 pop %es
 pop %ds
 iret

三个问题:

  1. 针对下面的代码片段:
movl tss,%ecx
addl $4096,%ebx
movl %ebx,ESP0(%ecx)

问题:
(1)为什么要加4096?
(2)为什么没有设置tss中的tss0?

答案:
(1)这里的ebx寄存器指向的是下一个进程的PCB,加上4096后,即为一个进程分配4KB(4*1024)的空间,栈顶即为内核栈的指针,栈底即为进程的PCB
(2)因为进程的切换不依靠tss,但CPU的机制造成对每个进程仍然会有TR寄存器、tss的设置等内容,所以可以让所有的进程都共用tss0的空间,所以不需要设置tss0

  1. 针对代码片段:
*(--krnstack) = ebp;
*(--krnstack) = ecx;
*(--krnstack) = ebx;
*(--krnstack) = 0

问题:
(1)子进程第一次执行时,eax=? 为什么要等于这个数?哪里的工作让eax等于这样一个数?
(2)这段代码中的ebxecx来自哪里,是什么含义,为什么要通过这些代码将其写到子进程的内核栈中?
(3)这段代码中的ebp来自哪里,是什么含义,为什么要做这样的设置?可以不设置吗?为什么?

答案:
(1)这里eax等于0,即返回值,这里设置就可以用if(!fork())来判断是不是子进程,最后一行(--krnstack) = 0完成了这件事
(2)ebxecx来自copy_process()函数传进来的参数,存放的是父进程的ebx和ecx,通过将父进程的参数压入栈中,就可以保证子进程回到用户态的时候具有和父进程相同的环境。
(3)ebp也是来自copy_process()函数传进来的参数,只是存放的是父进程的用户栈指针。即在fork()刚刚执行完copy_process()的时候,它的用户栈是父进程的用户栈,而非自己的用户栈,当子进程执行其他操作的时候,造成需要的栈将要与父进程不同了,才会创建自己的用户栈,这么做的好处是当一些子进程什么都不做时系统不用分配额外的空间,当然也可以创建子进程就为它分配一个新的栈,esp指向新的栈顶。

  1. 为什么要在切换完LDT之后要重新设置FS=0x17?而且为什么重设操作要出现在切换完LDT之后,出现在LDT之前又会怎么样?

答案:
首先要知道FS的作用: 操作系统就是通过FS在内核态中找到用户程序的地址的,切换LDT的时候,也就是会切换进程使用的用户栈,所以用户地址已经改变了,自然FS也要跟着改变,如果出现在切换LDT之前就可能会影响其他进程查找对应的用户地址。

实验总结

本实验的主要目的就是理解进程切换机制,原本的Linux 0.11用的切换机制是基于tss的,但是考虑到执行指令的时间很长(比如在fork()中基于tss的代码就比较多,在实现任务切换时大概需要200多个时钟周期),而通过堆栈实现任务切换就会更快,而且采用堆栈的切换还可以使用指令流水的并行优化技术,同时又使得cpu的设计变得更为简单,所以现在的操作系统,进程/线程的切换都是基于堆栈实现切换的。

所以本实验的目标就是将Linux 0.11中的tss改写成基于堆栈的切换。

主体部分:
改写switch_to()系统调用,从原本的宏函数(tss)改写成系统调用函数(堆栈)

其他部分:
修改schedule()函数,该函数也就是实现进程切换的直接函数,而且里面用的调度算法著名的多级反馈队列调度算法
修改fork()函数,fork()函数直接相关到copy_process()函数,所以实际就是要修改copy_process()函数,将tss的部分注释,改写成基于堆栈的代码,最后的目的就是要实现将父子进程共有同一段内存、堆栈、代码/数据段。

下面是本实验中我遇到的问题:(如果有同学能解决请评论,谢谢!)

1.对于汇编代码还不是很理解,比如mov指令:

movl %ebx,%eax //将寄存器eax的值保存到ebx寄存器中(后->前)
But
movl %esp,KERNEL_STACK(%eax) //将cup寄存器esp的值,保存到进程pcb的eax寄存器中(前->后)
or
movl $0x17,%ecx //将ecx寄存器设置为0x17(前->后)
mov %cx,%fs //重写设置段寄存器FS的值为0x17(前->后)

2.修改完代码后直接在linux目录下运行操作系统(键入:./run),但是报错,考虑还是hdc被卸载的原因。


HIT-OS-LAB参考资料:
1.《操作系统原理、实现与实践》-李治军、刘宏伟 编著
2.《Linux内核完全注释》
3.两个哈工大同学的实验源码
4.Linux-0.11源代码
(上述资料,如果有需要的话,请主动联系我))

该实验的参考资料
网课
官方文档
参考实验报告

评论 23
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值