操作系统实验4:基于内核栈的进程切换
实验基本内容:修改进程切换方式,由TSS模式切换到栈模式。
1.修改
kernel/system_call.s
,switch_to所在地,本次实验核心。
- 增加内核栈的部分,定义了
tss
的全局结构体变量kernalstack
还不够,要修改硬编码。- 实现PCB切换,指针重写,内核栈地址,LDT,切换CS,IP等寄存器,ret
- ret部分要添加first_return_from_kernel从内核中断处返回:弹栈+
iret
2.修改
kernel/fork.c
函数
- 增加使用
kernelstack
指针,初始化时,去除task中tss
结构体赋值- 拷贝父进程的寄存器,push返回标号地址与寄存器
3.修改
sched.h
和sched.c
。
修改PCB的task_struct结构,增加内核栈的部分,为结构体添加
kernelstack
变量。改变switch_to传参,传递PCB的指针
pnext,LDT(n)
而非tss(n)
sched.h
,修改初始化的宏,为kernelstack
赋初值4.实验报告
零、思维导图
一、编写switch_to(),40%
新的switch_to()函数是系统调用函数,所以要将函数重写在汇编文件system_call.s
。这个函数依次主要完成如下功能:
- 由于是 C 语言调用汇编,所以需要首先在汇编中处理栈帧,即处理
ebp
寄存器 - 接下来要取出表示下一个进程 PCB 的参数,并和
current
做一个比较,如果等于 current,则什么也不用做。不等于 current,就开始进程切换 - 进程切换(我们要做的)
- 完成 PCB 的切换
- TSS 中的内核栈指针的重写
- 内核栈的切换
- LDT 的切换以及 PC 指针(即 CS:EIP)的切换
完整代码如下:
! system_call.s
! 汇编语言中定义的方法可以被其他调用需要
.globl switch_to
.globl first_return_kernel
! 硬编码改变 these are offsets into the task-struct
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:
pushl %ebp
movl %esp,%ebp
pushl %ecx
pushl %ebx
pushl %eax
movl 8(%ebp),%ebx
cmpl %ebx,current
je 1f
! switch_to PCB
movl %ebx,%eax
xchgl %eax,current
! rewrite TSS pointer
movl tss,%ecx
addl $4096,%ebx
movl %ebx,ESP0(%ecx)
! switch_to system core stack
movl %esp,KERNEL_STACK(%eax)
movl 8(%ebp),%ebx
movl KERNEL_STACK(%ebx),%esp
! switch_to LDT
movl 12(%ebp), %ecx
lldt %cx
movl $0x17,%ecx
mov %cx,%fs
! nonsense
cmpl %eax,last_task_used_math
jne 1f
clts
1: popl %eax
popl %ebx
popl %ecx
popl %ebp
ret
.align 2
first_return_kernel:
popl %edx
popl %edi
popl %esi
pop %gs
pop %fs
pop %es
pop %ds
iret
下面逐条解释:
1.PCB的切换
PCB 的切换可以采用下面两条指令,其中ebx
是从参数中取出来的下一个进程的 PCB 指针,
movl %ebx,%eax
xchgl %eax,current
经过这两条指令以后,eax
指向现在的当前进程,ebx
指向下一个进程,全局变量 current 也指向下一个进程。
2.TSS 中的内核栈指针的重写
中断处理时需要寻找当前进程的内核栈,否则就不能从用户栈切到内核栈(中断处理没法完成),内核栈的寻找是借助当前进程TSS中存放的信息来完成的。
重写可以用下面三条指令完成,其中宏 ESP0 = 4
,struct tss_struct *tss = &(init_task.task.tss);
也是定义了一个全局变量,和 current 类似,用来指向那一段 0 号进程的 TSS 内存。此时唯一的tss
的目的就是:在中断处理时,能够找到当前进程的内核栈的位置。
movl tss,%ecx
addl $4096,%ebx
movl %ebx,ESP0(%ecx)
3.内核栈的切换
完成内核栈的切换也非常简单,和我们前面给出的论述完全一致,将寄存器 esp
(内核栈使用到当前情况时的栈顶位置)的值保存到当前 PCB 中,再从下一个 PCB 中的对应位置上取出保存的内核栈栈顶放入 esp
寄存器,这样处理完以后,再使用内核栈时使用的就是下一个进程的内核栈了。修改代码如下:
movl %esp,KERNEL_STACK(%eax)
! 再取一下 ebx,因为前面修改过 ebx 的值
movl 8(%ebp),%ebx
movl KERNEL_STACK(%ebx),%esp
4.LDT的切换
指令 movl 12(%ebp),%ecx
负责取出对应 LDT(next)的那个参数,指令 lldt %cx
负责修改 LDTR 寄存器,一旦完成了修改,下一个进程在执行用户态程序时使用的映射表就是自己的 LDT 表了,地址空间实现了分离。
5.PC 指针(即 CS:EIP)的切换
关于 PC 的切换,和前面论述的一致,依靠的就是 switch_to
的最后一句指令 ret。
这里还有一个地方需要格外注意,那就是 switch_to 代码中在切换完 LDT 后的两句,即:
! 切换 LDT 之后
movl $0x17,%ecx
mov %cx,%fs
FS的作用。通过FS操作系统才能访问进程的用户态内存。这里LDT切换完成意味着切换到了新的用户态地址空间,所以需要重置FS。
二、修改fork.c
,30%
准则:把进程的用户栈和内核栈通过内核栈中的 SS:ESP
,CS:IP
关联在一起,方式:压栈。但由于 fork() 这个叉子的含义就是要让父子进程共用同一个代码、数据和堆栈,故为内核栈的复制加切换。如图
完整代码如下:
//fork.c
//6th
extern void first_return_kernel(void);
//fork.c copy_process()
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;
long * krnstack;
//1st
p = (struct task_struct *) get_free_page();
if (!p)
return -EAGAIN;
task[nr] = p;
*p = *current; /* NOTE! this doesn't copy the supervisor stack */
p->state = TASK_UNINTERRUPTIBLE;
p->pid = last_pid;
p->father = current->pid;
p->counter = p->priority;
p->signal = 0;
p->alarm = 0;
p->leader = 0; /* process leadership doesn't inherit */
p->utime = p->stime = 0;
p->cutime = p->cstime = 0;
p->start_time = jiffies;
if (last_task_used_math == current)
__asm__("clts ; fnsave %0"::"m" (p->tss.i387));
if (copy_mem(nr,p)) {
task[nr] = NULL;
free_page((long) p);
return -EAGAIN;
}
//2nd
krnstack = (long *) (PAGE_SIZE + (long) p);
*(--krnstack) = ss & 0xffff;
*(--krnstack) = esp;
*(--krnstack) = eflags;
*(--krnstack) = cs & 0xffff;
*(--krnstack) = eip;
//3rd
*(--krnstack) = first_return_kernel;
//4th
*(--krnstack) = ebp;
*(--krnstack) = ecx;
*(--krnstack) = ebx;
*(--krnstack) = 0;
//5th
p->kernelstack = krnstack;
for (i=0; i<NR_OPEN;i++)
if ((f=p->filp[i]))
f->f_count++;
if (current->pwd)
current->pwd->i_count++;
if (current->root)
current->root->i_count++;
if (current->executable)
current->executable->i_count++;
set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss));
set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt));
p->state = TASK_RUNNING; /* do this last, just in case */
return last_pid;
}
下面阐释:
- 1.子进程的内核栈的初始化。申请子线程PCB的指针p,内核栈位置为p指针+页面大小。然后初始化
- 2.关联子进程的用户栈和内核栈
ss,esp
等都是copy_process的参数,因此给子线程的krnstack
赋值等于拷贝父子内核栈。
- 3.设置ret地址代码。为之后准备:父线程执行switch_to到了最后一步,此时PCB的
esp
已经切换到了子线程,基地址是对的,只需设置相对地址,所以无需jmp
。 - 4.继续压栈,保护子线程现场。子线程要有这些才能后续switch_to弹栈恢复现场。
- 5.
p->kernelstack = krnstack;
设置结构体指针,把switch_to中要的东西存进去 - 6.fork.c中添加该PC跳转函数的声明
三、修改sched.h
和sched.c
,10%。
之前的进程控制块(pcb
)中是没有保存内核栈信息的寄存器的,所以需要在sched.h
中的task_struct(也就是pcb
)中添加kernelstack
,而宏 KERNEL_STACK
就是你加的那个位置,但kernelstack
千万不要放置在 task_struct 中的第一个位置,有结构体硬编码的要求,要放在其他位置,然后修改 kernal/system_call.s
中的那些硬编码就可以了。
task_struct 的定义:
// 在 include/linux/sched.c 中
struct task_struct {
long state;
long counter;
long priority;
long kernelstack;
...
//......
由于这里将 PCB 结构体的定义改变了,所以在产生 0 号进程的 PCB 初始化时也要跟着一起变化,需要修改 #define INIT_TASK
,即在 PCB 的第四项中增加关于内核栈栈指针的初始化。代码如下:
#define INIT_TASK \
/* state etc */ { 0,15,15,PAGE_SIZE+(long)&init_task, \
/* signals */ 0,{{},},0, \
/* ec,brk... */ 0,0,0,0,0,0, \
/* pid etc.. */ 0,-1,0,0,0, \
/* uid etc */ 0,0,0,0,0,0, \
/* alarm */ 0,0,0,0,0,0, \
/* math */ 0, \
/* fs info */ -1,0022,NULL,NULL,NULL,0, \
/* filp */ {NULL,}, \
{ \
{0,0}, \
/* ldt */ {0x9f,0xc0fa00}, \
{0x9f,0xc0f200}, \
}, \
/*tss*/ {0,PAGE_SIZE+(long)&init_task,0x10,0,0,0,0,(long)&pg_dir,\
0,0,0,0,0,0,0,0, \
0,0,0x17,0x17,0x17,0x17,0x17,0x17, \
_LDT(0),0x80000000, \
{} \
}, \
}
基于堆栈的切换程序要做到承上启下:
- 承上:基于堆栈的切换,要用到当前进程(current指向)与目标进程的PCB,当前进程与目标进程的内核栈等
- Linux 0.11 进程的内核栈和该进程的 PCB 在同一页内存上(一块 4KB 大小的内存),其中 PCB 位于这页内存的低地址,栈位于这页内存的高地址
- 启下:要将next传递下去,虽然 TSS(next)不再需要了,但是 LDT(next)仍然是需要的。
修改 schedule()
函数(在 kernal/sched.c
中),代码如下:
//这里使用switch_to还需要在schedule.c的前面声明一下
extern long switch_to(struct task_struct *p, unsigned long address);
//还需在初始化`pnext`时,一定要赋值初始化任务的指针哈,不然,系统是无法跑起来的。
// linux-0.11/kernel/schd.c 在schedule()函数前面定义
struct task_struct ** p;
// do not forget it that initing the pointer of pnext
struct task_struct * pnext = &(init_task.task);
//tss需要在sched.c中定义
struct task_struct *tss= &(init_task.task.tss);
//...
while (1) {
c = -1;
next = 0;
i = NR_TASKS;
p = &task[NR_TASKS];
while (--i) {
if (!*--p)
continue;
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
c = (*p)->counter, next = i,pnext=*p; //保存要调度到的pcb指针
}
if (c) break;
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
if (*p)
(*p)->counter = ((*p)->counter >> 1) +
(*p)->priority;
}
switch_to(pnext,_LDT(next));
}
原本的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
指令),直接将整个宏注释掉即可
实验结果:
四、实验报告,20%
回答下面三个题:
问题 1
针对下面的代码片段:
movl tss,%ecx
addl $4096,%ebx
movl %ebx,ESP0(%ecx)
回答问题:
- (1)为什么要加 4096;
- 由于Linux 0.11进程的内核栈和该进程的PCB在同一页内存上(一块4KB大小的内存),其中PCB位于这页内存的低地址,栈位于这页内存的高地址;加4096就可以得到内核栈地址。
- (2)为什么没有设置
tss
中的 ss0。- tss.ss0是内核数据段,现在只用一个
tss
,因此不需要设置了。
- tss.ss0是内核数据段,现在只用一个
问题 2
针对代码片段:
*(--krnstack) = ebp;
*(--krnstack) = ecx;
*(--krnstack) = ebx;
*(--krnstack) = 0;
回答问题:
- (1)子进程第一次执行时,
eax
=?为什么要等于这个数?哪里的工作让eax
等于这样一个数?eax =0
。为了与父进程区分开 copy_process(),成功初始化进程copy_process后赋值eax
得到
- (2)这段代码中的
ebx
和ecx
来自哪里,是什么含义,为什么要通过这些代码将其写到子进程的内核栈中?- 让
eax=0
这段代码中的ebx
和ecx
来自copy_process()
的形参,是段寄存器。fork函数决定,让父子的内核栈在初始化时完全一致
- 让
- (3)这段代码中的
ebp
来自哪里,是什么含义,为什么要做这样的设置?可以不设置吗?为什么?ebp
是用户栈地址,一定要设置,不设置子进程就没有用户栈了
问题 3
为什么要在切换完 LDT 之后要重新设置 fs=0x17?而且为什么重设操作要出现在切换完 LDT 之后,出现在 LDT 之前又会怎么样?
这两句代码的含义是重新取一下段寄存器fs的值,这两句话必须要加,也必须要出现在切换完LDT之后,这是因为通过fs访问进程的用户态内存,LDT切换完成就意味着切换了分配给进程的用户态内存地址空间,所以前一个fs指向的是上一个进程的用户态内存,而现在需要执行下一个进程的用户态内存,所以就需要用这两条指令来重取fs。 出现在LDT之前访问的就还是上一个进程的用户态内存
最后补番外链接:
0.感谢这些博友的精彩博文,深受启发
https://blog.csdn.net/qq_41708792/article/details/89637248
https://blog.csdn.net/xubing716/article/details/53412647
还有这位高人的代码参考:
https://github.com/zouzhitao/hitOSlab
1.实验楼(操作系统原理与实践)
https://www.shiyanlou.com/courses/115
2.网易云课堂:哈尔滨工业大学,国家级精品课程,操作系统
https://mooc.study.163.com/course/1000002004#/info
3.推荐markdown神器,本文由此写成
https://typora.io/
(完)