实验要求与实验指导见 实验楼。
实验环境为 配置本地实验环境。
基于内核栈实现进程切换的大致过程如下:
当系统发生中断从用户态进入内核态时,CPU 通过 TR 寄存器找到 TSS 的位置,根据 TSS 中保存的 ss0:esp0
的值切换到内核栈,并自动将用户栈的 ss
、esp
、eflags
、cs
、eip
的值保存在内核栈中。中断处理完成后,此时若调度函数找到了需要切换的进程,此时就该将进程的其他寄存器信息也保存至内核栈中,然后切换到目的进程的 PCB、内核栈、LDT,最后从目的进程的内核栈中恢复寄存器的值,并从中断返回,此时 iret
将弹出目的进程的 cs:eip
,从而能跳转到目的进程中继续执行,这样就完成了进程的切换。
在原 Linux-0.11 中,schedule()
函数将找出目的进程,然后 switch_to()
函数进行 tss 的切换。用内核栈切换时,switch_to()
函数将需要切换 PCB、内核栈、LDT。所以该函数需要两个进程的 PCB、内核栈、LDT等信息,需要通过汇编语言来实现精准控制。
在 kernel/sched.c
中,Linux-0.11 将进程的 PCB 和内核栈定义在了一起:
union task_union {
struct task_struct task;
char stack[PAGE_SIZE];
};
且在 kernel/fork.c:copy_process()
函数中为 PCB 分配了一页内存,即进程的内核栈和该进程的 PCB 在同一页 4KB 大小的内存上,其中 PCB 位于这页内存的低地址,内核栈位于这页内存的高地址。另外,kernel/sched.c
中定义了一个全局变量 current 一直指向当前进程的 PCB,于是 switch_to()
函数需要的参数为:目的进程的 PCB 指针、目的进程的 LDT。故函数的外部原型定义如下,这个定义需要添加到 kernel/sched.c
头部:
extern void switch_to(struct task_struct *pnext, unsigned long ldt);
switch_to()
函数主要完成如下功能:由于是 C 语言调用汇编,所以需要首先在汇编中处理栈帧,即处理 ebp 寄存器;接下来获取下一个进程 PCB 的参数,并和 current 比较,如果等于 current,则不用切换;如果不等于 current,就开始进程切换,依次完成 PCB 的切换、TSS 中的内核栈指针的重写、内核栈的切换、LDT 的切换等。
函数调用同样需要栈来传递参数、保存寄存器值和返回地址,栈帧结构如下:
可以知道两个参数的位置分别在 8(%ebp) 和 12(%ebp) 处。
代码框架大致如下:
switch_to:
pushl %ebp
movl %esp,%ebp
pushl %ecx
pushl %ebx
pushl %eax
movl 8(%ebp),%ebx # 获取参数 pnext
cmpl %ebx,current
je 1f
# 切换PCB
...
# TSS中的内核栈指针的重写
...
# 切换内核栈
...
# 切换LDT
...
movl $0x17,%ecx
mov %cx,%fs
cmpl %eax,last_task_used_math
jne 1f
clts
1: popl %eax
popl %ebx
popl %ecx
popl %ebp
ret
Linux-0.11 当前正在运行的任务的 PCB 指针为 current
,需要切换的下一个进程的 PCB 指针为 pnext
,在上述代码中被保存到 ebx 寄存器中。所以这里需要把 ebx 寄存器的值放在 current
中:
movl %ebx,%eax # eax = pnext
xchgl %eax,current # current = pnext;eax 保存旧进程PCB
上述提到,发生中断时 CPU 将通过 TR 寄存器找到 TSS 然后从用户栈切换到内核栈,所以 TSS 仍然需要保留。但此时系统不再通过 TSS 进行任务切换,所以不需要每个进程都保留 TSS,只需要保留一份,用于中断时从用户栈进入内核栈,于是把它设为 struct tss_struct *tss = &(init_task.task.tss)
,在 kernel/sched.c
处定义。而不同进程的内核栈位置是不同的,所以每次进程切换时,同时需要更新唯一 TSS 中的 ss0:esp0
的值,从而使它一直指向当前进程的内核栈。TSS 的存储格式(部分)如下图,可知esp0 的位置为 tss + 4
。
movl tss,%ecx # ecx = init_task.task.tss
addl $4096,%ebx # ebx = pnext 内核栈的栈顶
movl %ebx,4(%ecx) # 把tss中内核栈指针esp0设为 pnext 的内核栈的栈顶
当前 PCB 中没有记录内核栈顶的状态变量,需要额外添加。
进程的 PCB 和进程 0 定义 include/linux/sched.h
中,在这里添加一个 kernelstack
变量表示进程的内核栈栈顶位置:
struct task_struct {
long state;
long counter;
long priority;
long kernelstack; /* add */
long signal;
struct sigaction sigaction[32];
long blocked;
...
}
任务数据结构改了之后,需要对 INIT_TASK
的宏定义页进行修改,使 kernelstack = PAGE_SIZE+(long)&init_task
:
#define INIT_TASK \
/* state etc */ { 0,15,15,PAGE_SIZE+(long)&init_task, \
/* signals */ 0,{{},},0, \
...
}
最后修改 kernel/system_call.s
中定义的变量偏移值:
state = 0 # these are offsets into the task-struct.
counter = 4
priority = 8
KERNEL_STACK = 12 # add
signal = 16
sigaction = 20
blocked = (33*16+4)
这样一个变量就添加完成了,在 switch_to()
中根据这个变量记录的位置进行内核栈的切换:
# 切换内核栈
movl %esp, KERNEL_STACK(%eax) # 将esp(内核栈栈顶位置)保存到旧进程PCB
movl 8(%ebp), %ebx # 再使ebx = pnext
movl KERNEL_STACK(%ebx), %esp # 再从 pnext 取出内核栈栈顶位置,这样esp就是切换后进程的内核栈
切换 LDT 只需要使用 lldt
指令改变 LDTR 寄存器的值即可:
movl 12(%ebp),%ecx # 获取的参数ldt(next)
lldt %cx # 修改 LDTR 寄存器(注意 lldt 的参数是段选择符是16位)
综上,完整的 switch_to
代码如下:
.align 2
switch_to:
pushl %ebp
movl %esp, %ebp
pushl %ecx
pushl %ebx
pushl %eax
movl 8(%ebp),%ebx # ebx = pnext
cmpl %ebx,current
je 1f
# 切换PCB
movl %ebx,%eax # eax = pnext
xchgl %eax,current # eax=old_current, current=pnext
# TSS中的内核栈指针的重写
movl tss,%ecx # ecx = init_task.task.tss
addl $4096,%ebx # ebx = the top of pnext kernel stack
movl %ebx,4(%ecx) # 把tss中内核栈指针esp0设为的内核栈的栈顶
# 切换内核栈
movl %esp, KERNEL_STACK(%eax) # 将寄存器esp(内核栈使用到当前情况时的栈顶位置)的值保存到当前PCB中
movl 8(%ebp), %ebx # 再取一下ebx,因为前面修改了ebx的值. ebx=current(pnext)
movl KERNEL_STACK(%ebx), %esp # 再从 pnext 取出内核栈栈顶位置,这样esp就是切换后进程的内核栈
# 切换LDT
movl 12(%ebp),%ecx # 获取switch_to()的参数ldt(next)
lldt %cx # 修改 LDTR 寄存器
# 加载切换后进程的用户数据空间
movl $0x17,%ecx # 0x17 都是fs,但需要查的LDT表不一样。为了刷新FS寄存器的隐藏部分:段基地址和段限长
mov %cx,%fs
cmpl %eax,last_task_used_math # 处理数学协处理器
jne 1f
clts
1:
popl %eax
popl %ebx
popl %ecx
popl %ebp
ret
首先需要将 switch_to()
的外部原型定义需要添加到 kernel/sched.c
头部:
extern void switch_to(struct task_struct *pnext, unsigned long ldt);
schedule()
需要为 switch_to()
提供两个参数pnext
、ldt
,后者找到 next 即可,而前者还需要添加一个 PCB 指针,为了让系统无事可做时去执行任务0,所以将它初始化为 struct task_struct *pnext = &(init_task.task)
。更改后的代码如下:
void schedule(void)
{
int i,next,c;
struct task_struct ** p;
struct task_struct *pnext = &(init_task.task); // add
...
while (1) {
...
while (--i) {
if (!*--p)
continue;
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
// change
// c = (*p)->counter, next = i;
c = (*p)->counter, next = i, pnext = *p;
}
}
// change
//switch_to(next);
switch_to(pnext, _LDT(next));
}
新的 switch_to()
函数将在 kernel/system_call.s
实现,所以需要在 sched.c
头部加入外部函数声明。此外,还需要新增一个 tss_struct
指针指向进程 0 的 tss:
//add tss pointer
struct tss_struct *tss = &(init_task.task.tss);
sched.c
修改完成。
copy_process()
函数是 fork()
的主要处理过程,这个函数将设置子进程的 PCB 及 TSS,使子进程与父进程共用数据段和代码段。所以先设置子进程的内核栈内容为:
long *kernelstack = (long *)(PAGE_SIZE + (long)p);
*(--kernelstack) = ss & 0xffff;
*(--kernelstack) = esp;
*(--kernelstack) = eflags;
*(--kernelstack) = cs & 0xffff;
*(--kernelstack) = eip;
这样当子进程开始执行时,iret
指令将从上述位置获取 cs:eip
的值继续执行。
在用 TSS 进行任务切换时,TSS 会存储进程执行期间各寄存器的值,当恢复到这个进程执行时,寄存器的值会从 TSS 中恢复;现在使用内核栈进行任务切换时,进程的寄存器的值需要从内核栈中恢复,因此这里也需要将各个寄存器的值保存到栈中。
结合 copy_process()
函数的参数和设置子进程的 TSS 的代码就能发现哪些寄存器的信息是必须保存的:
CPU 执行中断指令压入的用户栈地址 ss:esp、标志寄存器 eflags、返回地址 cs:eip;
刚进入 system_call()
时入栈的寄存器 ds、es、fs、edx、ecx、ebx;
调用copy_process()
前入栈的 gs、esi、edi、ebp、eax 值。
接下来要结合 switch_to()
函数进行考虑:假设要切换的目的进程是这个刚新建的进程,当 switch_to()
把 PCB、内核栈、LDT 切换完毕后,会跳转到标号 “1” 执行:
1:
popl %eax
popl %ebx
popl %ecx
popl %ebp
ret
它将依次恢复寄存器 eax、ebx、ecx、ebp 的值。所以在内核栈的栈顶需要按顺序保存它们的值。
*(--kernelstack) = ebp;
*(--kernelstack) = ecx;
*(--kernelstack) = ebx;
*(--kernelstack) = 0; // 即 eax。子进程需要将 eax 设为 0
p->kernelstack = (long)kernelstack; // 新进程内核栈初始化完毕后,记录栈顶位置
此时子进程构建的内核栈空间如下:
其中标号 ① 为上述 ret
的目的地址,之后程序将跳转到目的地址处的代码继续执行。那么目的地址的代码需要执行的是:继续将栈中保存的寄存器的值恢复到寄存器中。于是在 system_call.s
中添加一个函数,用来继续恢复寄存器的值:
first_return_from_kernel:
popl %edx
popl %edi
popl %esi
pop %gs
pop %fs
pop %es
pop %ds
iret
实验指导中提供的这段代码将剩下的寄存器的值都恢复完了,并通过 iret
指令恢复到用户态。根据这段代码的顺序,子进程构建时内核栈的内容得以完善。
于是修改后的 copy_process()
函数的代码如下:
int copy_process(...)
{
struct task_struct *p;
int i;
struct file *f;
long *kernelstack; // add
p = (struct task_struct *) get_free_page();
...
/* add */
kernelstack = (long *)(PAGE_SIZE + (long)p);
*(--kernelstack) = ss & 0xffff;
*(--kernelstack) = esp;
*(--kernelstack) = eflags;
*(--kernelstack) = cs & 0xffff;
*(--kernelstack) = eip;
*(--kernelstack) = ds & 0xffff;
*(--kernelstack) = es & 0xffff;
*(--kernelstack) = fs & 0xffff;
*(--kernelstack) = gs & 0xffff;
*(--kernelstack) = esi;
*(--kernelstack) = edi;
*(--kernelstack) = edx;
*(--kernelstack) = (long)first_return_from_kernel;
*(--kernelstack) = ebp;
*(--kernelstack) = ecx;
*(--kernelstack) = ebx;
*(--kernelstack) = 0; /* eax */
p->kernelstack = (long)kernelstack;
/* added */
// 删除下列操作 TSS 的代码
/*
p->tss.back_link = 0;
...
p->tss.trace_bitmap = 0x80000000;
*/
...
return last_pid;
}
于是,子进程的内核栈构建完成,如下图所示:
在 fork.c
首部,还需要添加 first_return_from_kernel()
的外部函数原型定义:
extern void first_return_from_kernel(void);
最后还需要在 system_call.s
中将两个新增的函数设为全局的:
.globl first_return_from_kernel, switch_to
现在 switch_to()
函数已经在 kernel/system_call.s
中实现,这里的宏定义直接去掉;然后需要修改上述提到的 PCB 的结构,最后添加上 tss 的原型定义。代码如下:
// change
struct task_struct {
long state;
long counter;
long priority;
long kernelstack; /* add */
long signal;
struct sigaction sigaction[32];
long blocked;
...
}
// change
#define INIT_TASK \
/* state etc */ { 0,15,15,PAGE_SIZE+(long)&init_task, \
/* signals */ 0,{{},},0, \
...
}
// add
extern struct tss_struct *tss; // add
// delete switch_to
/*
#define switch_to(n) {\
... \
}
*/
改完后,重新编译启动,运行成功。
在 Linux-0.11 中,进程的切换依靠 TSS 的切换。每一个进程都有一个 TSS,里面包含了几乎所有寄存器的快照。CPU 有一个 TR 寄存器指向当前进程的 TSS 结构体的内存位置。
intel 提供了多条指令可以执行切换 TSS 的操作:
Linux-0.11 使用 ljmp
指令来执行进程切换,其工作过程为(实验楼的指导与《Linux-0.11内核完全注释》一书表述不一致,此处参考注释一书):
ljmp
的操作数取得目的进程的 TSS 段选择符;cs:eip
的值执行目的进程。如下图所示:
回答下面三个题:
针对下面的代码片段:
movl tss,%ecx
addl $4096,%ebx
movl %ebx,ESP0(%ecx)
回答问题:
(1)为什么要加 4096;
(2)为什么没有设置 tss 中的 ss0。
(1).
Linux-0.11 中进程的 PCB 和内核栈在同一页内存上,PCB 在低地址,内核栈在高地址,一页的大小为 4K = 4096。之前 ebx 寄存器保存的是 pnext 指针的值,是代切换进程的 PCB 的地址,加上 4096 后就到内核栈的栈顶位置了。(2). 经过改动后,所有进程共用一个 tss,tss.ss0 在初始化 INIT_TASK 时已设置,无需再次设置。
针对代码片段:
*(--krnstack) = ebp;
*(--krnstack) = ecx;
*(--krnstack) = ebx;
*(--krnstack) = 0;
回答问题:
(1)子进程第一次执行时,eax=?为什么要等于这个数?哪里的工作让 eax 等于这样一个数?
(2)这段代码中的 ebx 和 ecx 来自哪里,是什么含义,为什么要通过这些代码将其写到子进程的内核栈中?
(3)这段代码中的 ebp 来自哪里,是什么含义,为什么要做这样的设置?可以不设置吗?为什
么?
(1).
子进程第一次执行时 eax = 0。把它设置为0后,子进程中fork()
的返回值为0,与父进程区分开了。将它设为 0 的代码为*(--kernelstack) = 0
,上面已有解释。(2).
这段代码的 ebx 和 ecx 来自copy_process()
的参数,根据内核栈往前探究可知它来自 system_call,它们的含义是通用寄存器,用来保存系统调用存放的参数。这里是为了完全复制父进程的上下文且配合switch_to()
的弹栈过程而设计。(3).
这段代码的 ebp 来自copy_process()
的参数,由sys_fork()
函数将其压栈。这里是为了完全复制父进程的上下文且配合switch_to()
的弹栈过程而设计。所以不能不设置。
为什么要在切换完 LDT 之后要重新设置 fs=0x17?而且为什么重设操作要出现在切换完 LDT 之后,出现在 LDT 之前又会怎么样?
重新设置 fs 是为了为了刷新 fs 寄存器的隐藏部分:段基地址和段限长,下次用 fs 访问用户数据段时不必再次查询 GDT 表,提高了执行效率。由于当前任务已经改变,如果在切换 LDT 前旧重新设置,可能 fs 的隐藏部分就是上一个进程的用户空间内存的基地址和段限长。