在lab4中接触的还是内核线程,内核线程只运行在内核态。在线性空间中,0-3G是用户空间,而3-4G是内核空间,所有的内核线程都是只用同一块内存,因此不需要为每个内核线程维护单独的内存空间,不像用户进程每个进程都拥有各自的页表,内核进程共享一个页表。
为了实现内核线程,需要设计管理线程的数据结构,进程控制块。每个内核线程对应一个进程控制块,而这些控制块都会通过链表连在一起,方便插入,删除和查找操作进行管理。
在本实验中,首先从kern_init函数开始,完成虚拟内存初始化后,调用proc_init函数,完成idleproc内核线程,就是我们所熟知的空闲线程,pid=0的系统创建的第一个进程,该线程会不停地查询。看是否有其他内核线程可以执行。后续调用kernel_thread函数创建initproc内核线程。
进程控制块数据结构
enum proc_state {
// 未初始化
PROC_UNINIT = 0,
// 休眠、阻塞状态
PROC_SLEEPING,
// 可运行、就绪状态
PROC_RUNNABLE,
// 僵尸状态(几乎已经终止,等待父进程回收其所占资源)
PROC_ZOMBIE,
};
struct proc_struct {
enum proc_state state; // 进程的状态
int pid;
int runs; // 进程被调度的次数
uintptr_t kstack; // 进程的内核栈地址
volatile bool need_resched; // 是否需要放弃CPU调度新进程
struct proc_struct *parent; // 父进程
struct mm_struct *mm; // 内存总管理器
struct context context; // 切换进程的保存的上下文快照
struct trapframe *tf; // 中断帧的指针,指向内核栈的某个位置。中断帧中的数据为发生中断前的现场情况。执行forkret时会弹出其中的数据,恢复现场
uintptr_t cr3; // 页目录表的基址(物理地址)
uint32_t flags; // 当前进程的标志位
char name[PROC_NAME_LEN + 1]; // 进程的名字
list_entry_t list_link; // 进程控制链表
list_entry_t hash_link; // 进程的控制的哈希表
};
idleproc
proc->state = PROC_UNINIT; 设置进程为“初始”态
proc->pid = -1; 设置进程pid的未初始化值
proc->cr3 = boot_cr3; 使用内核页目录表的基址
...
idleproc->pid = 0;
idleproc->state = PROC_RUNNABLE;
idleproc->kstack = (uintptr_t)bootstack;
idleproc->need_resched = 1;
set_proc_name(idleproc, "idle");
在kern_init函数中调用了proc_init函数创建了idle线程,首先就是要分配一个初始的进程控制块,一个初始化的进程控制块中状态为PROC_UNINIT,PID为-1,页目录表基址均为统一的内核页目录表基址。得到初始控制块后,可以设置pid,状态,内核栈地址,并将need_resched置为1,确保初始化后立马调度其他线程。因为idle线程的主要工作是完成内核中各个子系统的初始化,然后就通过执行cpu_idle函数(不断自旋循环,当发现还有其他线程可以调度时就调度)开始退休生活。
initproc
initproc是由idle线程通过kernel_thread函数创建的一个内核线程。在lab4中该线程就是输出一些字符串。
kernel_thread(int (*fn)(void *), void *arg, uint32_t clone_flags)
{
struct trapframe tf;
memset(&tf, 0, sizeof(struct trapframe));
tf.tf_cs = KERNEL_CS;
tf.tf_ds = tf_struct.tf_es = tf_struct.tf_ss = KERNEL_DS;
tf.tf_regs.reg_ebx = (uint32_t)fn;
tf.tf_regs.reg_edx = (uint32_t)arg;
tf.tf_eip = (uint32_t)kernel_thread_entry;
return do_fork(clone_flags | CLONE_VM, 0, &tf);
}
.text
.globl kernel_thread_entry
kernel_thread_entry: # void kernel_thread(void)
pushl %edx # push arg
call *%ebx # call fn
pushl %eax # save the return value of fn(arg)
call do_exit # call do_exit to terminate current thread
该函数首先构造了一个局部变量中断帧,并设置了其中的代码段和数据段均为内核空间的段意为中断返回后依旧是处于内核态并不会发生特权级变化,EIP为kernel_thread_entry,在返回后将会执行kernel_thread_entry,这是汇编函数,这个函数其实就是调用fn这个函数,流程同lab1中的函数堆栈那儿讲解的一样,先将函数的参数edx压入栈内,再call函数ebx即可。所以中断帧中的ebx和edx分别设置的为fn函数和arg参数。
后续调用do_fork函数,正式构建一个进程控制块从而完成一个内核线程的创建。
do_fork函数主要完成以下6件事情:
1、分配初始化进程控制块;
2、分配并初始化内核栈(注意:每个内核线程都有各自的内核栈,但是都是使用同一个内核页目录表即同一内核空间);
3、根据clone_flag标志复制或共享进程内存管理结构,是否采取COW(copy on writing);
4、设置trapframe和context,确保在切换到该内核线程时按照预定的程序执行
5、将控制块加入到hash_list和proc_list中;
6、将控制块的状态设置为就绪
7、返回PID
在copy_thread函数中,首先设置中断帧所在的地址,根据lab1中可知,在切换到内核态后,首先需要把中断帧的信息压到栈中,因此在内核栈的顶部空间应该是属于中断帧的,在此函数开始也要在内核堆栈的顶部设置中断帧大小的空间给中断帧,后续在这部分空间中将在kernel_thread中构建的局部变量中断帧的值复制进来。同时设置eax寄存器的值,设置为0,这也是为什么fork出来的子进程返回值为0。再设置ESP和EFLAGS,EFLAGS设置了FL_LF标志表示能相应中断。后续再设置context,context是idle线程在执行cpu_idle后切换到init线程后,context作为还原上下文用的。context的eip为forkret表示从idle切换到init线程后执行forkret。
线程调度的上下文切换
void
cpu_idle(void) {
while (1) {
// idle线程执行逻辑就是不断的自旋循环,当发现存在有其它线程可以被调度时
// idle线程,即current.need_resched会被设置为真,之后便进行一次schedule线程调度
if (current->need_resched) {
schedule();
}
}
}
我们可见idle线程会执行cpu_idle函数,并执行schedule函数。
/**
* 进行CPU调度
* */
void
schedule(void) {
bool intr_flag;
list_entry_t *le, *last;
struct proc_struct *next = NULL;
// 暂时关闭中断,避免被中断打断,引起并发问题
local_intr_save(intr_flag);
{
// 令current线程处于不需要调度的状态
current->need_resched = 0;
// lab4中暂时没有更多的线程,没有引入线程调度框架,而是直接先进先出的获取init_main线程进行调度
last = (current == idleproc) ? &proc_list : &(current->list_link);
le = last;
do {
if ((le = list_next(le)) != &proc_list) {
next = le2proc(le, list_link);
// 找到一个处于PROC_RUNNABLE就绪态的线程
if (next->state == PROC_RUNNABLE) {
break;
}
}
} while (le != last);
if (next == NULL || next->state != PROC_RUNNABLE) {
// 没有找到,则next指向idleproc线程
next = idleproc;
}
// 找到的需要被调度的next线程runs自增
next->runs ++;
if (next != current) {
// next与current进行上下文切换,令next获得CPU资源
proc_run(next);
}
}
// 恢复中断
local_intr_restore(intr_flag);
}
// 进行线程调度,令当前占有CPU的让出CPU,并令参数proc指向的线程获得CPU控制权
void
proc_run(struct proc_struct *proc) {
if (proc != current) {
// 只有当proc不是当前执行的线程时,才需要执行
bool intr_flag;
struct proc_struct *prev = current, *next = proc;
// 切换时新线程任务时需要暂时关闭中断,避免出现嵌套中断
local_intr_save(intr_flag);
{
current = proc;
// 设置TSS任务状态段的esp0的值,令其指向新线程的栈顶
// ucore参考Linux的实现,不使用80386提供的TSS任务状态段这一硬件机制实现任务上下文切换,ucore在启动时初始化TSS后(init_gdt),便不再对其进行修改。
// 但进行中断等操作时,依然会用到当前TSS内的esp0属性。发生用户态到内核态中断切换时,硬件会将中断栈帧压入TSS.esp0指向的内核栈中
// 因此ucore中的每个线程,需要有自己的内核栈,在进行线程调度切换时,也需要及时的修改esp0的值,使之指向新线程的内核栈顶。
load_esp0(next->kstack + KSTACKSIZE);
// 设置cr3寄存器的值,令其指向新线程的页表
lcr3(next->cr3);
// switch_to用于完整的进程上下文切换,定义在统一目录下的switch.S中
// 由于涉及到大量的寄存器的存取操作,因此使用汇编实现
switch_to(&(prev->context), &(next->context));
}
local_intr_restore(intr_flag);
}
}
.text
.globl switch_to
switch_to: # switch_to(from, to)
# save from registers
# 令eax保存第一个参数from(context)的地址
movl 4(%esp), %eax # eax points to from
# from.context 保存eip、esp等等寄存器的当前快照值
popl 0(%eax) # save eip !popl
movl %esp, 4(%eax)
movl %ebx, 8(%eax)
movl %ecx, 12(%eax)
movl %edx, 16(%eax)
movl %esi, 20(%eax)
movl %edi, 24(%eax)
movl %ebp, 28(%eax)
# restore to registers
# 令eax保存第二个参数next(context)的地址,因为之前popl了一次,所以4(%esp)目前指向第二个参数
movl 4(%esp), %eax # not 8(%esp): popped return address already
# eax now points to to
# 恢复next.context中的各个寄存器的值
movl 28(%eax), %ebp
movl 24(%eax), %edi
movl 20(%eax), %esi
movl 16(%eax), %edx
movl 12(%eax), %ecx
movl 8(%eax), %ebx
movl 4(%eax), %esp
pushl 0(%eax) # push eip
# ret时栈上的eip为next(context)中设置的值(fork时,eip指向 forkret,esp指向分配好的trap_frame)
ret
可见进程间的切换是通过切换栈,而非通过TSS段来进行切换上下文,在ucore中仅仅只是把上下文保存在内核栈中,并没有保存到TSS中。
linux不使用任务门(转载):
Intel的这种设计确实很周到,也为任务切换提供了一个非常简洁的机制。但是,由于i386的系统结构基本上是CISC的,通过JMP指令或CALL(或中断)完成任务的过程实际上是“复杂指令”的执行过程,其执行过程长达300多个CPU周期(一个POP指令占12个CPU周期),因此,Linux内核并不完全使用i386CPU提供的任务切换机制。
由于i386CPU要求软件设置TR及TSS,Linux内核只不过“走过场”地设置TR及TSS,以满足CPU的要求。但是,内核并不使用任务门,也不使用JMP或CALL指令实施任务切换。内核只是在初始化阶段设置TR,使之指向一个TSS,从此以后再不改变TR的内容了。也就是说,每个CPU(如果有多个CPU)在初始化以后的全部运行过程中永远使用那个初始的TSS。同时,内核也不完全依靠TSS保存每个进程切换时的寄存器副本,而是将这些寄存器副本保存在各个进程自己的内核栈中。
这样一来,TSS中的绝大部分内容就失去了原来的意义。那么,当进行任务切换时,怎样自动更换堆栈?我们知道,新任务的内核栈指针(SS0和ESP0)应当取自当前任务的TSS,可是,Linux中并不是每个任务就有一个TSS,而是每个CPU只有一个TSS。Intel原来的意图是让TR的内容(即TSS)随着任务的切换而走马灯似地换,而在Linux内核中却成了只更换TSS中的SS0和ESP0,而不更换TSS本身,也就是根本不更换TR的内容。这是因为,改变TSS中SS0和ESP0所花费的开销比通过装入TR以更换一个TSS要小得多。因此,在Linux内核中,TSS并不是属于某个进程的资源,而是全局性的公共资源。在多处理机的情况下,尽管内核中确实有多个TSS,但是每个CPU仍旧只有一个TSS。
为什么线程切换时要修改esp0?
ucore在设计上大量参考了早期32位linux内核的设计,因此和Linux一样也没有完全利用硬件提供的任务切换机制。整个OS周期只在内核初始化时设置了TR寄存器和TSS段的内容(gdt_init函数中),之后便不再对其进行大的修改,而是仅仅在线程上下文切换时,令TSS段中的esp0指向当前线程的内核栈顶(proc_run)。这么做的原因一是ucore只使用了ring0和ring3两个特权级,所有线程的ring0内核栈是由ucore全盘控制的,而在后续lab5之后的用户态线程其ring3栈则是由应用程序自己控制的;二是由于在发生特权级切换的中断时,80386CPU会将中断参数压入新特权级对应的栈上,如果发生用户态->内核态的切换时,esp0必须指向当前线程自己的内核栈,否则将会出现不同线程内核栈数据的混乱,造成严重后果。
代码部分
static struct proc_struct *
alloc_proc(void) {
// 分配一个进程控制块
struct proc_struct *proc = kmalloc(sizeof(struct proc_struct));
// 如果分配成功
if (proc != NULL) {
// 对分配好内存的proc结构体属性进行格式化
// 最一开始进程的状态为uninitialized
proc->state = PROC_UNINIT;
// 负数的pid是非法的,未正式初始化之前pid统一为-1
proc->pid = -1;
proc->runs = 0;
proc->kstack = 0;
proc->need_resched = 0;
proc->parent = NULL;
proc->mm = NULL;
// 清零格式化proc->context中的内容
memset(&(proc->context), 0, sizeof(struct context));
proc->tf = NULL;
// 未初始化时,cr3默认指向内核页表
proc->cr3 = boot_cr3;
proc->flags = 0;
// 清零格式化proc->name
memset(proc->name, 0, PROC_NAME_LEN);
}
return proc;
}
do_fork(uint32_t clone_flags, uintptr_t stack, struct trapframe *tf) {
int ret = -E_NO_FREE_PROC;
struct proc_struct *proc;
if (nr_process >= MAX_PROCESS) {
goto fork_out;
}
ret = -E_NO_MEM;
// 分配一个未初始化的线程控制块
if ((proc = alloc_proc()) == NULL) {
goto fork_out;
}
// 其父进程属于current当前进程
proc->parent = current;
// 设置,分配新线程的内核栈
if (setup_kstack(proc) != 0) {
// 分配失败,回滚释放之前所分配的内存
goto bad_fork_cleanup_proc;
}
// 由于是fork,因此fork的一瞬间父子线程的内存空间是一致的(clone_flags决定是否采用写时复制)
if (copy_mm(clone_flags, proc) != 0) {
// 分配失败,回滚释放之前所分配的内存
goto bad_fork_cleanup_kstack;
}
// 复制proc线程时,设置proc的上下文信息
copy_thread(proc, stack, tf);
bool intr_flag;
local_intr_save(intr_flag);
{
// 生成并设置新的pid
proc->pid = get_pid();
// 加入全局线程控制块哈希表
hash_proc(proc);
// 加入全局线程控制块双向链表
list_add(&proc_list, &(proc->list_link));
nr_process ++;
}
local_intr_restore(intr_flag);
// 唤醒proc,令其处于就绪态PROC_RUNNABLE
wakeup_proc(proc);
ret = proc->pid;
fork_out:
return ret;
bad_fork_cleanup_kstack:
put_kstack(proc);
bad_fork_cleanup_proc:
kfree(proc);
goto fork_out;
}