《操作系统原理、实现与实践》李治军、刘宏伟编著
Linux0.11中进程切换是依靠任务状态段(task struct segment,TSS)的切换来实现的,本实践项目要修改Linux0.11实现基于内核栈的切换。
在设计x86架构时,每个任务都有一个独立的TSS,它是内存中独立的一个结构体,里面包含了几乎所有的CPU寄存器的映像。其中有一个人物寄存器TR(task register)指向当前进程对应的TSS结构体。所谓TSS切换就是将CPU中的寄存器值都复制到TR寄存器指向的TSS中保存起来,同时要找到要切换到的下一个进程对应的TSS,将其中存放的寄存器映像“覆盖”到CPU上,这就完成了执行现场的切换。
intel架构不仅给出了TSS来实现任务切换,而且只需要一条指令(ljmp)就能够完成这样的切换,ljmp指令的工作过程具体如下:
#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])); \
}
其中的核心就是ljmp指令。虽然用一条指令就能完成切换,但是这条指令的执行时间太长,需要200多个时钟周期,而通过堆栈来实现任务切换需要更少的时钟周期,且采用堆栈切换的方式还可以使用指令流水等并行优化技术,同时使CPU设计变得简单。
要实现基于内核栈的任务切换,主要完成如下的三个工作:
(1)重写switch_to()函数
(2)将重写的switch_to()函数和schedule()接在一起
(3)修改现在的fork()函数
Linux0.11中schedule()函数首先找到下一个进程的TSS在GDT表中的位置next(也就是switch函数参数中的n),获得了next之后就可以调用switch()函数进行切换了。现在不采用这种方式切换,而是采用如下图所示的内核栈切换方式,所以在新的switch函数中要用到当前进程的PCB、目标进程的PCB、当前进程的内核栈、目标进程的内核栈等信息。另外,由于当前进程的task_struct用一个全剧变量current指向,所以只需要告诉新的switch()函数一个指向目标进程task_struct的指针即可。同时,仍要将next也要传进去,虽然不再需要TSS(next)了,但是LDT(next)仍然是需要的。也就是说,现在每个进程已经不再有自己的TSS了,因为已经不使用TSS切换了,但是每个进程需要有自己的LDT,地址分离机制还是必须有的,进程切换要引起LDT的切换。综上所述,将目前的schedule()函数稍作修改,即将下面的代码
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
c = (*p)->counter, next = i;
......
switch_to(next);
改成
struct task_struct *pnext;
......
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
c = (*p)->counter, next = i,pnext = *p;
......
switch_to(pnext,LDT(next));
由于要对内核栈做精细的操作,所以用汇编代码来编写switch_to。这个新的switch_to依次完成如下功能:首先要在汇编中处理栈帧(栈帧就是一个函数执行的环境:函数参数、函数的局部变量、函数执行完后返回到哪里等),即处理EBP寄存器,这是由于C语言调用了汇编函数;接下来要取参数得到切换目标进程的task_struct和current做比较,如果等于current,说明不需要切换,直接退出;如果不等于current,就开始进程切换,要以此完成task_struct的切换、TSS中的内核栈指针的重写、内核栈的切换、LDT的切换以及PC指针的切换。代码实现如下:
#基于内核栈的switch_to实现
switch_to:
pushl %ebp
movl %esp,%ebp
pushl %ecx
pushl %ebx
pushl %eax
movl 8(%ebp),%ebx #读取第一个参数pnext放入ebx寄存器中
cmpl %ebx,current
je 1f
切换PCB
TSS中的内核栈指针的重写
切换内核栈
切换LDT
movl $0x17,%ecx
mov %cx,fs
cmpl %ecx,last_task_used_math #和后面的clts配合来处理协处理器,暂时可以不考虑
jne 1f
clts
1: popl %eax
popl %ebx
popl %ecx
popl %ebp
ret
上面的代码给出了实现思路,其中的核心代码还未实现,下面将一一实现。
movl %ebx,%eax #前面已经将第一个参数放入到ebx中,这里将第一个参数读入到eax中
xchgl %eax,current #交换exa和current的值,这样cuurent就指向了下一个任务的PCB
在中断处理时要寻找当前进程的内核栈,内核栈的寻找是借助当前进程TSS中存放的信息完成的,当然当前TSS是借助TR寄存器找到的。虽然现在不使用TSS进行进程切换了,但是Intel的中断处理机制还是要保留。所以每个进程仍然需要有一个TSS,操作系统中需要有一个当前TSS。我们采用的方案是让所有进程都共用一个TSS,这里使用0号进程的TSS。因此需要定义一个全局指针变量tss来指向0号进程的TSS,用这个TSS充当所有进程的TSS,这个TSS的作用只有一个:在中断发生时能够找到当前进程的内核栈位置。因此多任务切换时TSS不再需要变化,当然TR也不会发生变化,一直指向0号 进程的TSS描述符在GDT表中所在的位置。
struct tss_struct *tss = &(init_task.task.tss)
内核栈指针的重写可用下面的三条指令完成:
movl tss,%ecx
addl $4096,%ebx #内核栈在task_struct那一页内存的顶部
movl %ebx,ESP0(%ecx)
其中宏ESP0 = 4,因为TSS中的内核栈指针esp0就在偏移为4的地方,这是TSS结构体的定义。
内核栈的切换比较简单,将物理寄存器ESP的值保存到当前task_struct中,再从目标task_struct中取出目标内核栈的栈顶位置放入物理ESP寄存器中,这样处理完成之后,再使用内核栈时使用的就是目标进程的内核栈了。由于现在linux0.11中的task_struct结构体中没有保存内核栈指针的域,为此需要在这个结构体中加上域kernelstack,再用宏KERNEL_STACK给出这个域对应的位置。当然理论上kernelStack域加在task_struct中的任意一个位置都可以,但是在某些汇编文件中(主要在sys_call.s中),有操作这个结构体的汇编硬编码,即直接通过域位置操作这个结构体中的域。所以一旦增加了kernelStack,这些硬编码就需要跟着修改,所以kernelStack最好不要放在第一个位置。这里将这个域放在了位置12处,这里需要改动很少。
# task_struct结构体中kernelStack域的增加及处理代码
KERNEL_STACK = 12
movl %esp,KERNEL_STACK(%eax)
movl 8(%ebp),%ebx #再取一下ebx,因为前面修改过ebx的值
movl KERNEL_STACK(%ebx),%esp
struct task_struct{
long state;
long counter;
long priority;
long kernelstack;
......
}
由于这里改变了task_struct结构体的定义,所以0号进程里的task_struct初始化时也要跟着修改,由原来
#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, \
/* ec,brk... */ 0,0,0,0,0,0, \
即增加了关于内核栈指针的初始化操作。
movl 12(%ebp),%ecx #取出参数LDT(next)
lldt %cx #修改LDTR寄存器
一旦修改了LDTR,目标进程执行起来时用户程序使用的映射表就是自己的LDT表,实现了地址空间的分离。
PC的切换靠的是switch_to的最后一个指令ret,虽然看起来简单,但是ret背后发生的事却很多:
(1)由于schedule()函数的随后调用了switch_to()函数,所以ret执行后会跳转到schedule()函数的末尾去执行,接下来会遇到schedule()函数的“}”;
(2)这个”}“会再产生一个ret指令,从而回到调用schedule()的地方,由于是中断处理调用了schedule()函数,所以现在又会回到中断处理程序中,具体是中断返回那一部分程序;
(3)中断返回的核心是iret指令,执行iret指令之后就会到目标进程的用户态去执行。
这里需要额外注意的一个地方,那就是swotch_to代码中切换完LDT后要重置FS寄存器,即:
切换LDT
movl $0x17,%ecx
mov %cx,%fs
这两条代码的含义是重新设置段寄存器FS的值为0x17,这两句话必须要加,也必须出现在切换完LDT之后。FS寄存器的作用是通过FS操作系统访问进程的用户态内存。LDT切换完成意味着切换到了新的用户态内存地址空间,所以重置FS。
根据上面的分析,修改的switch_to的完整代码如下:
#基于内核栈的switch_to实现
switch_to:
pushl %ebp
movl %esp,%ebp
pushl %ecx
pushl %ebx
pushl %eax
movl 8(%ebp),%ebx #读取第一个参数pnext放入ebx寄存器中
cmpl %ebx,current
je 1f
# 切换PCB
movl %ebx,%eax #前面已经将第一个参数放入到ebx中,这里将第一个参数读入到eax中
xchgl %eax,current #交换exa和current的值,这样cuurent就指向了下一个任务的PCB
# TSS中内核栈指针的重写
movl tss,%ecx
addl $4096,%ebx #内核栈在task_struct那一页内存的顶部
movl %ebx,ESP0(%ecx)
# 切换内核栈
movl %esp,KERNEL_STACK(%eax)
movl 8(%ebp),%ebp #再取一下ebx,因为前面修改过ebx的值
movl KERNEL_STACK(%ebx),%esp
#切换LDT
movl 12(%ebp),%ecx #取出参数LDT(next)
lldt %cx #修改LDTR寄存器
movl $0x17,%ecx
mov %cx,fs
cmpl %ecx,last_task_used_math #和后面的clts配合来处理协处理器,暂时可以不考虑
jne 1f
clts
1: popl %eax
popl %ebx
popl %ecx
popl %ebp
ret
原来的switch_to是C内嵌汇编函数,放在include/linux/sched.h文件中,修改之后的switch_to是汇编函数,这里放到kernel/sys_call.s文件中,然后用globl对switch_to进行声明。
现在需要将新建进程的用户栈、用户程序地址和其内核栈关联在一起,因为用户使用TSS,切换时没有做这样的关联。fork()要求让父子进程共享用户代码、用户数据和用户堆栈,现在虽然是使用内核栈完成任务切换,但是fork()函数的基本含义不应该发生变化。对fork()修改的核心是对子进程内核栈的初始化,在fork()的核心实现copy_process中,代码p = (struct task_struct*)get_free_page();用来完成申请一页内存作为子进程管理task_struct,而p指针加上一个页面的大小就是子进程的内核栈栈位置,所以语句krnstack = (long*)(PAGE_SIZE+(long)p);就可以找到子进程的内核栈栈位置,接下来就是初始化krnstack中的内容了。
// 进程新建是内核栈的设置
*(--krnstack) = ss & 0xffff;
*(--krnstack) = esp;
*(--krnstack) = eflags;
*(--krnstack) = cs & 0xffff;
*(--krnstack) = eip;
其中的ss、esp等内容是copy_process()函数的参数,这些参数来自调用copy_process()的进程的内核栈,也就是父进程的内核栈。
接下来的需要考虑和switch_to连在一起,应该从switch_to()代码中完成“切换内核栈”的时刻开始,现在切到子进程的内核栈开始执行,switch_to接下来要做的四次弹栈以及ret都要使用子进程的内核栈。
# switch_to完成内核栈切换后开始执行的代码
1: popl %eax
popl %ebx
popl %ecx
popl %ebp
ret
能够这样弹栈,子进程的内核栈中应该初始化相应内容:
*(--krnstack) = ebp;
*(--krnstack) = ecx;
*(--krnstack) = ebx;
*(--krnstack) = 0; //这里的0作为fork()函数的返回值,即说明该进程是子进程
现在到ret指令了,这条指令要从内核栈中弹出一个32位数作为EIP跳转去执行,显然要对应一段程序。根据内核级线程切换的五段论,内核栈已经切换完成,现在该中断返回了。因此就是那个"first_return_from_kernel"汇编语句,语句
*(--krnstack) = (long)first_return_from_kernel
可以将这个地址初始化到子进程的内核栈中,现在执行ret以后就会跳转到汇编程序first_return_from_kernel去执行了。
first_return_from_kernel要完成的工作是“内核级线程切换五个阶段”中的最后一个阶段的切换了,即用户栈和用户代码的切换,这个切换主要是靠iret指令来实现的。当然在切换到用户态程序之前应该恢复用户态程序执行现场,主要是EAX,EBX,ES,DS等等寄存器的恢复。下面是first_return_from_kernel的核心代码:
first_return_from_kernel:
popl %edx
popl %edi
popl %esi
popl %gs
popl %fs
popl %es
popl %ds
iret
最后还需要将存放在task_struct中的内核栈地址修改为初始化完成时的内核栈的栈顶位置,即
p->kernelstack = krnstack;
上面的first_return_from_kernel放到kernel/sys_call.s中,然后用globl对first_return_from_kernel进行声明。
编译运行之后,总是不停的重启,用GDB调试后发现了一个错误:
这里本来应该是movl 8(%ebp),%ebx,结果不小心写错了。修改完成之后重新编译运行,发现能在之前的基础上继续运行了一段代码,但是之后还是重启了。找不到其他问题了,去找了几篇博客,看了之后也没发现问题所在,但在评论区发现有人也出现了同样的问题,别人给出的解决办法是schedule()函数中的pnext指针要初始化在函数内部!!!
想了一下,确实应该这样,因为必须保证系统至少有一个进程可以运行,每次调度前都要将pnext指向0号进程,这样如果没有可以运行的进程,系统就可以运行0号进程,否则找不到可以运行的进程,pnext不会被更新,一旦调度必然会出错,这就是问题所在!!
在调试上花了太多太多时间,但是也值得,一方面初步掌握了调试方法,另一方面在调试过程中也熟悉了进程切换的过程。