实验4:基于内核栈切换的进程切换
线程部分的课程已经完成,花了几天时间把实验也做了一下。实验基本上是按照实验指导书做的,实验过程总体还算顺利。本次实验的需要用到的一些知识:中断时压栈的过程;函数调用时压栈的过程;LDT、GTD、TSS的工作机制、宏函数。
1 线程
1.1 用户级线程
两个执行序列共用一个栈切换执行会导致了执行过程的错乱。多个执行序列之间应该怎样切换执行才会不紊乱呢?课程由此引出用户级线程的切换。两个线程应该使用两个栈,线程在切换执行的同时将栈也一同切换,这样两个线程切换执行才不会紊乱。同理n个线程也是如此。用户级线程有什么缺陷呢?一个缺陷就是内核感知不到用户级线程的存在,只是将它当作一个进程来看,因此内核并没有因为多个用户级线程,而给这个“进程”分配跟多资源。
1.2 核心级线程
核心级线程不再停留于用户态来切换,而是进入内核,让内核来切换线程。相比于用户级线程,两个核心级线程需要使用两套栈来切换(将一个用户栈和一个内核栈关联起来组成一套栈)。从课件中截取了一套栈的样子:
图1.1的右边部分就是由一个用户栈和一个核心栈关联形成的一套栈。仔细分析图1.1中的箭头标识,就会明白一个用户栈和一个内核栈是如何关联起来组成一套栈的。
核心级线程切换的大致过程: 线程a调用系统调用,执行 int 0x80 时,硬件会将线程a用户态的ss : sp压入内核栈中,同时将eflags,cs : ip压入内核栈中。这些压栈是由于系统产生了中断 - int 0x80,硬件自动完成了执行压栈过程。可能会有一点点疑问:硬件是怎么知道线程a的内核栈的位置的呢?提示一下 :回想一下TR寄存器的作用和TSS中的内容。在线程a进入内核后,若需要切换到线程b去执行,则内核首先会将线程a的TCB切换到线程b的TCB,然后将切换到线程内核b的内核栈,利用线程b的内核栈中存放的线程b的用户态信息(如线程b的用户态的cs:ip等)弹栈到线程b的用户态去执行。
1.3 一个实际的schedule 函数
schedule函数会选出下一个需要执行的进程,然后调用 switch_to 跳转到下一个进程去执行。下面是对schedule函数的主要内容的注释,看完注释应该就能大致理解调度的过程了:
void schedule(void)
{
int i,next,c;
struct task_struct ** p;
/* check alarm, wake up any interruptible tasks that have got a signal */
......
/* this is the scheduler proper: */
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)//任务处于就绪态且任务counter值(进程已经运行的时间,
//越大说明运行的时间越短)最大的就是下一个要执行的进程
c = (*p)->counter, next = i;
}
if (c) break; //c>0,即找到了下一个需要执行的任务,则退出循环去执行下一个进程。
//若c==-1,即队列中全部是空任务,则退出循环去执行任务0(因为此时next==0)
//若C==0, 说明所有就绪态的任务的时间片都用光了,那么接着执行下面语句给所有任务更新时间片
//只有当c==0时才执行下面循环
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
if (*p) //对所有非空任务更新时间片,注意此时就绪态任务的时间片是等于0的( (*p)->counter == 0),
//而阻塞态任务的时间片是大于0的( (*p)->counter > 0 )
(*p)->counter = ((*p)->counter >> 1) +
(*p)->priority; //就绪态任务的时间片更新为(*p)->priority,而阻塞态任务的时间片更新为大
//于(*p)->priority,因此阻塞态任务在下一轮切换中优先级会更高
//(*p)->counter >> 1还有一个用意:保证时间片收敛,不会过大。
}
switch_to(next); //切换到下一个进程执行
}
2 实验内容
线程提高了系统的灵活性,本次实验也主要是在探究线程实现原理,但本次实验并不是要实现一个线程,而是通过实现一个基于内核栈切换的进程来探究线程的实现原理。本次实验内容如下:
(1) 编写汇编程序 switch_to:
(2) 完成主体框架;
(3) 在主体框架下依次完成 PCB 切换、内核栈切换、LDT 切换等;
(4) 修改 fork(),由于是基于内核栈的切换,所以进程需要创建出能完成内核栈切换的样子。
(5) 修改 PCB,即 task_struct 结构,增加相应的内容域,同时处理由于修改了 task_struct 所造成的影响。
(6) 用修改后的 Linux 0.11 仍然可以启动、可以正常使用。
(选做)分析实验 3 的日志体会修改前后系统运行的差别。
具体实验内容及实验提示请参考实验指导书:操作系统原理与实践
3 实验过程
本章的3.1、3.2、3.3节主要内容为修改内核代码的过程,是按照实验指导书的提示顺序来修改的,在看这3节的时候最好配合实验指导书一起看。3.4节整理了进程切换过程。实验过程才是最艰辛的 o(╥﹏╥)o
3.1 schedule 与 switch_to
为什么从这里开始修改呢?因为在schedule函数找到下一个要执行的进程后,便会调用 switch_to 让CPU切换到下一个进程去执行,而我们要修改的正是这个切换的过程。我们需要利用内核栈来切换进程,因此就不能再使用TSS来切换了,但LDT还是需要的因为进程间还是需要地址分离。schedule 函数修改如下:
void schedule(void)
{
int i,next,c;
struct task_struct ** p;
/*修改1、新增变量,指向下一个进程的PCB */
struct task_struct *pnext = &(init_task.task);
/* check alarm, wake up any interruptible tasks that have got a signal */
......
/* this is the scheduler proper: */
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)
/*修改2 pnext 指向下一个进程的 PCB*/
c = (*p)->counter, next = i, pnext = (*p);
}
if (c) break;
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
if (*p)
(*p)->counter = ((*p)->counter >> 1) +
(*p)->priority;
}
/*修改3 修改switch_to*/
switch_to(pnext, _LDT(next));
}
为了不报错,需要在sched.c文件中添加相关声明:
extern long switch_to(struct task_struct *p, unsigned long address);
3.2 重写 switch_to
原来的 switch_to 是利用 ’ ljmp *%0 ’ 来切换进程的,根据内嵌汇编的知识,结合switch_to代码分析,可以知道 %0 中存放了目标进程的 TSS 描述符,这些内容可以去参考实验指导书和《Linux内核完全注释》。因为我们要基于内核栈切换,所以肯定是要修改 switch_to 的,并且需要将原来的 switch_to 注释掉。switch_to的主要功能就是切换内核栈,需要进行4步,在程序注释中已经用数字序号标出。switch_to程序写在system_call.s文件中,switch_to的完整程序如下:
#...
#修改1 重新 switch_to 函数
.align 2
switch_to:
pushl %ebp
movl %esp,%ebp # c调用汇编程序,先将ebp压栈,然后更新epb(栈帧结构)
pushl %ecx
pushl %ebx
pushl %eax # 将 ecx ebx eax 压栈,后面会pop回来
movl 8(%ebp),%ebx # pnext = 8 + %ebp
cmpl %ebx,current # 检查有没有发生切换
je 1f # 若没有发生切换则返回
# 1、切换pcb
movl %ebx, %eax
xchgl %eax, current
# 2、TSS中的内核栈指针的重写
# tss是一个新定义的用于存放当前进程的tss的全局变量,原switch_to函数中是利用"ljmp %0"
# 来修改TR,从而切换tss的。下面3句程序只修改了当前tss的esp0,却没切换TR和tss,
# 这意味着所有进程将共用这个 tss ,TR也不会再更新,始终指向这个tss。这样做
# 是因为调用int中断时(如 int 0x80),CPU会利用TR找寻找当前进程的内核栈,
# 从而将用户栈的ss:sp压入内核栈中。那么问题来了,TR是什么时候指向tss的呢?
# 找一下ltr指令和任务状态段的初始化过程,可以先猜一下。
movl tss,%ecx
addl $4096,%ebx
movl %ebx,ESP0(%ecx)
# 3、切换内核栈
movl %esp, KERNEL_STACK(%eax) # 保存当前的esp到pcb中
# 再取一下 ebx,因为前面修改过 ebx 的值
movl 8(%ebp), %ebx
movl KERNEL_STACK(%ebx), %esp # 取出下一个进程的esp 覆盖掉当前的esp
# 4、切换LDT
movl 12(%ebp), %ecx
lldt %cx
movl $0x17,%ecx
mov %cx,%fs
# 和后面的 clts 配合来处理协处理器,由于和主题关系不大,此处不做论述
cmpl %eax,last_task_used_math
jne 1f
clts
1: popl %eax
popl %ebx
popl %ecx
popl %ebp
ret # 这个ret很关键,在schedule()调用switch_to()时会将返回地址压栈,ret会
# 将返回地址弹出。如果发生了进程切换,则此时栈指针(esp)已经指向了下
# 一个进程的内核栈,ret会将下一个进程的返回地址弹出,从而跳转到下一个进程的代码去执行。
其中在第3步(切换内核栈)时,由于现在的 Linux 0.11 的 PCB 定义中没有保存内核栈指针这个域(kernelstack),所以需要加上,这个在实验指导书中有详细的介绍。
在 task_struct (sched.h)添加 “内核栈” 字段:
struct task_struct {
/* these are hardcoded - don't touch */
long state; /* -1 unrunnable, 0 runnable, >0 stopped */
long counter;
long priority;
/*修改1 添加内核栈字段*/
long kernelstack;
long signal;
struct sigaction sigaction[32];
long blocked; /* bitmap of masked signals */
....
}
PCB定义在 include/linux/sched.h 中,对PCB的修改如下:
/*
* INIT_TASK is used to set up the first task table, touch at
* your own risk!. Base=0, limit=0x9ffff (=640kB)
*/
/*由于 Linux 0.11 进程的内核栈和该进程的 PCB 在同一页内存上(一块 4KB 大小的内存),
其中 PCB 位于这页内存的低地址,栈位于这页内存的高地址。可能不太好理解,但是要注意
栈是向下生长的,因此(long)&init_task 就是一页内存的起始地址(最低地址),
PAGE_SIZE+(long)&init_task 就是一页内存的结束地址(最高地址)*/
#define INIT_TASK \
/* state etc */ { 0,15,15, \
/*修改1 内核栈 */ 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, \
{} \
}, \
}
extern struct task_struct *task[NR_TASKS];
extern struct task_struct *last_task_used_math;
extern struct task_struct *current;
/*修改2 自己定义一个全局tss,所有进程共用*/
extern struct tss_struct *tss;
tss定义在sched.c文件中:
/*修改1 自己定义一个全局tss,所有进程共用*/
struct tss_struct *tss = &(init_task.task.tss);
此外 system_call.s 文件中还要修改一些硬编码,并添加相关的全局声明:
state = 0 # these are offsets into the task-struct.(这里的偏移量是按字节来算的)
counter = 4
priority = 8
#修该1 修改KERNEL_STACK、signal、sigaction、blocked、ESP0 硬编码
KERNEL_STACK = 12 # 把内核栈指针插到这里,后面的偏移量都要跟着改、
signal = 16
sigaction = 20 # MUST be 16 (=len of sigaction),sigaction是一个含32个结构体元素的数组
blocked = (37*16) # 这里不太明白为什么是(37*16),本来我写的是(20 + 32 * 16)
ESP0 = 4 # tss中esp0(内核栈的栈顶)的偏移量(按字节算)
# offsets within sigaction
sa_handler = 0
sa_mask = 4
sa_flags = 8
sa_restorer = 12
nr_system_calls = 72
/*
* Ok, I get parallel printer interrupts while using the floppy for some
* strange reason. Urgel. Now I just ignore them.
*/
.globl system_call,sys_fork,timer_interrupt,sys_execve,switch_to,first_return_from_kernel
.globl hd_interrupt,floppy_interrupt,parallel_interrupt
.globl device_not_available, coprocessor_error
#修改2 将switch_to, first_return_from_kernel做全局声明
.globl switch_to, first_return_from_kernel
3.3 修改fork
修改fork主要是为了做出子进程的的内核栈:
//......
extern void write_verify(unsigned long address);
extern void first_return_from_kernel(void); //在fork.c文件中声明first_return_from_kernel函数
long last_pid=0;
//......
/*
* Ok, this is the main fork-routine. It copies the system process
* information (task[nr]) and sets up the necessary registers. It
* also copies the data segment in it's entirety.
*/
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;
/*修改1 新增变量 krnstack */
long *krnstack;
int i;
struct file *f;
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;
/* 修改2 在调用int 0x80 的时候,int 指令已经将父进程用户态的ss,esp,eflags,cs,ip
压入父进程的内核栈中,这里我们主要是做一下子进程的内核栈,让子进程和父进
程的内核栈指向一个用户栈。*/
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;
/* 接下来的这段压栈有点费解,其实主要是为了让自己写的那个switch_to函数能够正
确的返回,仔细回想一下调用fork()后整个的压栈过程就会明白了。至于那个压栈:
*(--krnstack) = 0; 从弹栈的过程来看其实应该放入eax的,这是fork()后子进程的返回值 0 */
*(--krnstack) = (long)first_return_from_kernel;
*(--krnstack) = ebp;
*(--krnstack) = ecx;
*(--krnstack) = ebx;
/* 这个0最有意思 */
*(--krnstack) = 0;
p->kernelstack = krnstack;
p->pid = last_pid;/* last_pid是最新进程号,也就是子进程的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;
p->tss.back_link = 0;
p->tss.esp0 = PAGE_SIZE + (long) p;
p->tss.ss0 = 0x10;
p->tss.eip = eip;/* 这里的eip是在调用 int 0x80 压入的eip,也就是说
子进程在下次(或者说第一次被调度的时候)被调度执行的的时候,是从 int 0x80
后面一句指令开始执行的,而不是从copy_process()开始执行*/
p->tss.eflags = eflags;
p->tss.eax = 0;/* 子进程fork()完后返回0的原因所在*/
p->tss.ecx = ecx;
p->tss.edx = edx;
p->tss.ebx = ebx;
p->tss.esp = esp;
p->tss.ebp = ebp;
p->tss.esi = esi;
p->tss.edi = edi;
p->tss.es = es & 0xffff;
p->tss.cs = cs & 0xffff;
p->tss.ss = ss & 0xffff;
p->tss.ds = ds & 0xffff;
p->tss.fs = fs & 0xffff;
p->tss.gs = gs & 0xffff;
p->tss.ldt = _LDT(nr);
p->tss.trace_bitmap = 0x80000000;
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;
}
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;/* return会让返回值(last_pid)保存在eax中。这里是父进程在fork()完后要返回的子进程的pid。
那么子进程fork()完后要返回的0在哪里返回的呢?在 _syscall0(int,fork) 函数的那个return返回。*/
}
最后是 first_return_from_kernel 函数,该函数定义在 system_call.s 文件中:
# 修改1 开始的时候不太明白为什么要加这段函数,用switch_to在ret完后不应该是返回schedule()吗?
# 不应该是像我写的那个关于switch_to最后那句ret的注释一样去返回schedule()吗?但仔细一想,
# 在原程序中switch_to()通过那句"ljmp %0"直接切换的tss(或者是TR),然后直接跳到了下一个进程
# 去执行,并没有直接写出关于栈处理相关的指令
.align 2
first_return_from_kernel:
popl %edx
popl %edi
popl %esi
pop %gs
pop %fs
pop %es
pop %ds
iret
3.4 基于内核栈切换的进程切换过程
按照上面步骤修改完Linux0.11内核后,就可以编译、运行了:
虽然内核是重新跑起来了,不过由于修改过程中方向逐渐迷失,最后修改完了头也晕了,不知道自己做了什么,最后只好又重新整理一下基于内核栈切换的进程切换的过程。阅读修改完成后的内核代码,重新整理一下进程切换的过程。
3.4.1 父进程创建子进程时,父进程的压栈过程
在父进程创建子进程时,父进程会调用fork(),fork()是一个系统调用 ‘static inline _syscall0(int,fork)’。 因此父进程的压栈过程从fork函数中的那句 ‘int 0x80’ 开始看:
压栈的过程是地址逐渐减小的过程,因此 SS : ESP 到 addl$20,%esp 地址是在逐渐减小。copy_process 有一堆形参,这是汇编调用C(在linux内核完全注释中有介绍)的过程 ,因此上面压入栈中的内容都作为了 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)
{......}
3.4.2 父进程创建子进程后,子进程内核栈的样子
子进程的内核栈是在 copy_process 函数中做出来的,并且子进程的内核栈应该是父进程内核栈的拷贝。copy_process 将子进程的内核栈做成下面这个样子:
假设父进程返回的过程中没有发生进程调度,即没有跳转到 reschedule函数:
system_call:
......
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
ret_from_sys_call:
......
那么父进程在调用完 copy_process 函数后就会返回用户态了,出栈的顺序和入栈顺序(图3.1)恰好相反。有一点要注意一下,父进程返回过程中是不会调用 first_return_from_kernel: 的,而子进程返回却会调用, 这是为什么呢?
还有子进程的内核栈的样子好像和内核栈有一点点不一样:基本的内容一样,顺序有点不一样。接着往下分析,看看子进程被调用后,出栈的情况。
3.4.3 子进程被调度
假设 进程n 在执行的过程中发生了系统时钟中断,并且需要进行调度,恰好被调度到下一个要执行的进程就是上面创建好的子进程。这一部分从 system_call.s文件中的 timer_interrupt: 开始。首先是进程n进入系统时钟中断后内核栈的样子(样子有点粗糙,许多细节没考虑,但大致是这样的):
进程n执行 switch_to 后会将PCB,内核栈切换到上面做好的子进程那里去,然后开始了子进程返回用户态的过程,这里需要对照着图3.2 一起看:
.align 2
switch_to:
......
jne 1f
clts
1: popl %eax # 首先是 eax,ebx,ecx,ebp出栈
popl %ebx
popl %ecx
popl %ebp
ret # ret会将返回地址出栈,然后到返回地址去执行。根据子进程的压栈顺序,这里的返回地址就是 first_return_from_kernel
程序跳转到 first_return_from_kernel:
.align 2
first_return_from_kernel:
popl %edx # 将edx,edi,esi,gs,fs,es,ds出栈
popl %edi
popl %esi
pop %gs
pop %fs
pop %es
pop %ds
iret # iret会将ss, esp, eflags, cs, eip 出栈
以下是关于iret指令的介绍,这段介绍摘取自:关于IRET指令
(1)当使用IRET指令返回到相同保护级别的任务时,IRET会从堆栈弹出代码段选择子及指令指针分别到CS与IP寄存器,并弹出标志寄存器内容到EFLAGS寄存器。
(2)当使用IRET指令返回到一个不同的保护级别时,IRET不仅会从堆栈弹出以上内容,还会弹出堆栈段选择子及堆栈指针分别到SS与SP寄存器。
在执行完 iret 后 子进程就回到了用户态。子进程下次进入内核态时,内核栈的样子可能会和 图3.3 (进程n内核栈的样子 )一样,而不会再是图3.2 (子进程内核栈的样子)的样子,因此下次子进程在从内核态返回用户态时就不会在执行 first_return_from_kernel 了,可能这就是 first_return_from_kernel 取名的由来吧。
4 参考
[1] 哈工大操作系统实验四——基于内核栈切换的进程切换(极其详细)
[2] 关于IRET指令
日拱一卒无有尽,功不唐娟终入海。