ucore lab4

多任务即并行执行多个进程,控制流并发执行。之前的实验中,整个控制流仍为串行执行。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_initidleproc内核线程做进一步初始化:

1
2
3
4
5
6
idleproc->pid = 0;//第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函数中的处理过程。它的大致执行步骤包括:

  1. 分配并初始化进程控制块(alloc_proc函数);
  2. 分配并初始化内核栈(setup_stack函数);
  3. 根据clone_flag标志复制或共享进程内存管理结构(copy_mm函数);
  4. 设置进程在内核(将来也包括用户态)正常运行和调度所需的中断帧和执行上下文(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;//拷贝kernel_thread中的临时中断帧
    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);
    }
  5. 把设置好的进程控制块放入hash_listproc_list两个全局进程链表中;
  6. 自此,进程已经准备好执行了,把进程状态设置为“就绪”态;
  7. 返回子进程的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,指定当前执行的进程为procload_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 //from所在地址
popl 0(%eax) //将返回地址弹出到eax所指向内存单元
movl %esp, 4(%eax) //保存from的context
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
+------------------+
//恢复to的上下文
movl 4(%esp), %eax //to所在地址

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) //eip作为返回地址,转到forkret

ret

forkret再调用forkrets

1
2
3
4
static void
forkret(void) {
forkrets(current->tf);
}

forkretstrapentry.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 //把esp指向当前进程的中断帧
jmp __trapret

如何知道ucore的两个线程同在一个进程?

查看线程控制块中cr3是否一致,即查看两个线程是否为同一页目录表。

用户态或内核态下的中断处理有什么区别?在trapframe中有什么体现?

在用户态中断响应时,要切换到内核态;而在内核态中断响应时,没有这种切换;

区别在于tf_esp,tf_ss字段,内核态中断响应不会在内核堆栈压入ss和esp,而用户态中断响应时会在内核堆栈压入ss和esp。