多任务即并行执行多个进程,控制流并发执行。之前的实验中,整个控制流仍为串行执行。ucore lab4在之前实验的基础上进行cpu的虚拟化,即让ucore实现分时共享cpu,实现多条指令流的并发执行。lab4首先涉及的是内核线程管理,指令执行流的单位称为线程,对线程管理就是线程调度和分派。进程作为操作系统分配资源的单位对线程提供支持。
分配并初始化进程控制块
创建第0个内核线程idleproc
proc_init
首先初始化哈希表,调用alloc_proc
,通过kmalloc
分配proc_struct
结构的一块内存块,初始化设置idleproc
进程控制块的值如下所示。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| static struct proc_struct * alloc_proc(void) { struct proc_struct *proc = kmalloc(sizeof(struct proc_struct)); if (proc != NULL) { proc->state = PROC_UNINIT; proc->pid = -1; proc->runs = 0; proc->kstack = 0; proc->need_resched = 0; proc->parent = NULL; proc->mm = NULL; memset(&(proc->context), 0, sizeof(struct context)); proc->tf = NULL; proc->cr3 = boot_cr3; proc->flags = 0; memset(proc->name, 0, PROC_NAME_LEN); } return proc; }
|
寄存器状态、堆栈、当前指令指针等信息是线程控制块的;mm, vma等内存管理字段是进程控制块的;在实际操作系统中,内核线程常驻内存,不需要考虑换入换出页的问题,所以不需要用到mm,所以初始化为NULL。内核线程的cr3等于boot_cr3
,即ucore启动时建立的内核虚拟空间的页目录表首地址。
此后,proc_init
对idleproc
内核线程做进一步初始化:
1 2 3 4 5 6
| idleproc->pid = 0; idleproc->state = PROC_RUNNABLE; idleproc->kstack = (uintptr_t)bootstack; idleproc->need_resched = 1; set_proc_name(idleproc, "idle"); nr_process ++;
|
idleproc
主要工作是初始化各个子系统,然后调用cpu_idle
等待调度。
创建第1个内核线程initproc
1 2 3 4 5 6 7 8 9 10 11
| int 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.tf_es = tf.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); }
|
其中EIP寄存器里存储CPU下次要执行的指令的地址,EBP寄存器里存储栈的栈底指针,通常叫栈基址,EBX存储调用参数。在kernel_thread
中中断帧的ebx设置为fn的函数地址,在跳转到kernel_thread_entry
后,通过call *%ebx
来调用fn。
1 2 3 4 5 6 7
| 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
|
请说明proc_struct中struct context context和struct trapframe *tf成员变量含义和在本实验中的作用是啥?
context
用于进程切换,存储了当前cpu的上下文(进程切换时需要保存的寄存器),在进程切换 switch_to
中用到。
trapframe
为中断帧,当进程发生特权级转换的时候中断帧记录了进入中断时用户态的上下文,便于中断返回时恢复环境。
为新创建的内核线程分配资源
创建一个内核线程需要分配和设置好很多资源。kernel_thread
函数通过调用do_fork
函数完成具体内核线程的创建工作。do_kernel
函数会调用alloc_proc
函数来分配并初始化一个进程控制块,但alloc_proc
只是找到了一小块内存用以记录进程的必要信息,并没有实际分配这些资源。ucore一般通过do_fork
实际创建新的内核线程。do_fork
的作用是,创建当前内核线程的一个副本,它们的执行上下文、代码、数据都一样,但是存储位置不同。在这个过程中,需要给新内核线程分配资源,并且复制原进程的状态。
在kern/process/proc.c
中的do_fork
函数中的处理过程。它的大致执行步骤包括:
- 分配并初始化进程控制块(
alloc_proc
函数);
- 分配并初始化内核栈(
setup_stack
函数);
- 根据
clone_flag
标志复制或共享进程内存管理结构(copy_mm
函数);
- 设置进程在内核(将来也包括用户态)正常运行和调度所需的中断帧和执行上下文(
copy_thread
函数);
copy_thread
在内核堆栈的顶部设置中断帧大小的一块栈空间。1 2 3 4 5 6 7 8 9 10
| static void copy_thread(struct proc_struct *proc, uintptr_t esp, struct trapframe *tf) { proc->tf = (struct trapframe *)(proc->kstack + KSTACKSIZE) - 1; *(proc->tf) = *tf; proc->tf->tf_regs.reg_eax = 0; proc->tf->tf_esp = esp; proc->tf->tf_eflags |= FL_IF; proc->context.eip = (uintptr_t)forkret; proc->context.esp = (uintptr_t)(proc->tf); }
|
- 把设置好的进程控制块放入
hash_list
和proc_list
两个全局进程链表中;
- 自此,进程已经准备好执行了,把进程状态设置为“就绪”态;
- 返回子进程的id号。
do_fork
实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| int 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; } proc->parent=current; if(setup_kstack(proc)!=0){ goto bad_fork_cleanup_proc; } if(copy_mm(clone_flags,proc)!=0){ goto bad_fork_cleanup_kstack; } copy_thread(proc,stack,tf); bool intr_flag; local_intr_save(intr_flag); { proc->pid=get_pid(); hash_proc(proc); list_add(&proc->list,&(proc->list_link)); nr_process++; } local_intr_restore(intr_flag); 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; }
|
请说明ucore是否做到给每个新fork的线程一个唯一的id?
是的,在do_fork
中用get_pid
分配pid,不允许中断,不会分配当前未被销毁且已经分配过进程的pid。
进程切换
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| void proc_run(struct proc_struct *proc) { if (proc != current) { bool intr_flag; struct proc_struct *prev = current, *next = proc; local_intr_save(intr_flag); { current = proc; load_esp0(next->kstack + KSTACKSIZE); lcr3(next->cr3); switch_to(&(prev->context), &(next->context)); } local_intr_restore(intr_flag); } }
|
proc_run
函数首先将全局变量current
指针指向proc
,指定当前执行的进程为proc
,load_esp0
函数将TSS段中对应的成员变量设置为proc的内核栈顶指针,建立好内核线程或将来用户线程在执行特权态切换时的内核堆栈。。lcr3函数将当前的页表切换为proc的页表。之后再调用switch_to
汇编函数,将proc的上下文读取到CPU中,在switch_to
返回之后,进程正式切换完毕。
在本实验的执行过程中,创建且运行了几个内核线程?
本次实验创建了2个内核堆栈,第0个内核线程idleproc初始化内核各个子系统,第1个内核线程在屏幕上输出字符串。
语句local_intr_save(intr_flag);….local_intr_restore(intr_flag);在这里有何作用?请说明理由
关闭中断,保证在进程切换的重要时刻不被打断。
switch_to
汇编代码分析
通过调用 switch_to(from,to)
完成from和to之间的上下文切换,context结构体如下所示:
1 2 3 4 5 6 7 8 9
| struct context { uint32_t eip; uint32_t ebx; uint32_t ecx; uint32_t edx; uint32_t esi; uint32_t edi; uint32_t ebp; };
|
调用switch栈的的具体内存分布如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| +------------------+ | to | <- rsp+8 | from | <- rsp+4 | return address | <- rsp +------------------+ switch_to: # switch_to(from, to)
# save from's registers movl 4(%esp), %eax popl 0(%eax) 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)
此时栈空间与之前不同, +------------------+ | to | <- rsp+4 | from | <- rsp +------------------+ movl 4(%esp), %eax 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)
ret
|
forkret
再调用forkrets
1 2 3 4
| static void forkret(void) { forkrets(current->tf); }
|
forkrets
在 trapentry.S
中,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| .globl __trapret __trapret: # restore registers from stack popal
# restore %ds, %es, %fs and %gs popl %gs popl %fs popl %es popl %ds
# get rid of the trap number and error code addl $0x8, %esp iret
.globl forkrets forkrets: # set stack to this new process's trapframe movl 4(%esp), %esp jmp __trapret
|
如何知道ucore的两个线程同在一个进程?
查看线程控制块中cr3是否一致,即查看两个线程是否为同一页目录表。
用户态或内核态下的中断处理有什么区别?在trapframe中有什么体现?
在用户态中断响应时,要切换到内核态;而在内核态中断响应时,没有这种切换;
区别在于tf_esp
,tf_ss
字段,内核态中断响应不会在内核堆栈压入ss和esp,而用户态中断响应时会在内核堆栈压入ss和esp。