课程主页:https://www.xuetangx.com/course/THU08091000267/5883104?channel=i.area.learn_title
实验指导书:https://objectkuan.gitbooks.io/ucore-docs/content/
github:https://github.com/chyyuu/ucore_os_lab (master 分支)

前置知识

线程、状态转换。

改动点

相比于 lab3 源代码,lab4 主要是添加代码 process/* 以实现进程/线程管理功能。

练习一

此练习用于认识进程/线程控制块 PCB。

ucore 中,struct proc_struct 用于描述进程/线程:

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
struct proc_struct {
enum proc_state state; // 进程状态(创建、就绪、运行、阻塞、终止)
int pid; // 进程 ID,唯一标识此进程。
int runs; // 进程已运行时间(尚未用到)。
uintptr_t kstack; // 进程的内核栈(每个进程/线程均有一个,用以当发生中断时保存相关信息)。
volatile bool need_resched; // 该进程是否需要被重新调度。
struct proc_struct *parent; // 进程的父进程。
struct mm_struct *mm; // 进程对应的 mm。
struct context context; // 进程对应的上下文信息(用于进程间切换以保存进程状态)。
struct trapframe *tf; // 进程当前中断对应的 tf。
uintptr_t cr3; // PDT 所在地址。
uint32_t flags; // 进程标识信息(尚未用到)。
char name[PROC_NAME_LEN + 1]; // 进程名称
list_entry_t list_link;
list_entry_t hash_link; // PCB 相互链接对应的链表和 hash 表。
};

// 进程状态的具体信息
enum proc_state {
PROC_UNINIT = 0, // 创建
PROC_SLEEPING, // 阻塞
PROC_RUNNABLE, // 就绪/运行
PROC_ZOMBIE, // 终止
};

// context 还应保存各种段寄存器信息,但是此次 lab 为内核线程管理,这些寄存器取值固定,因此没有列出。
struct context {
uint32_t eip;
uint32_t esp;
uint32_t ebx;
uint32_t ecx;
uint32_t edx;
uint32_t esi;
uint32_t edi;
uint32_t ebp;
};

鉴于对于 PCB 的上述理解,我们应当在 alloc_proc() 中,如此初始化新建的 PCB:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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;
// 本 lab 所涉内核线程,其对应 PDT 均为 boot_cr3。
proc->cr3 = boot_cr3;
proc->flags = 0;
memset(&(proc->name), 0, PROC_NAME_LEN);
// 对于其余信息,默认初始化为空即可。
}

练习二

该练习用于实现 do_fork() 以为新建进程/线程分配资源 (其含义等价于实际系统中的 fork())。

对于进程/线程而言,其所需资源包括 (就目前 lab 而言):PCB、内核栈、内存资源。

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
45
46
47
48
49
50
51
52
53
54
55
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;

// 分配 PCB。
if ((proc = alloc_proc()) == NULL)
{
goto fork_out;
}

// 指明当前进程为新建进程的父进程。
proc->parent = current;

// 分配内核栈
if (setup_kstack(proc) != 0)
{
goto bad_fork_cleanup_proc;
}

// 分配 mm (具体指代该进程对应程序的 vma 集、与物理页面对应的 PDT)。
if (copy_mm(clone_flags, proc) != 0)
{
goto bad_fork_cleanup_kstack;
}

// 设置中断栈帧内容以及新建进程的 context,容许进程切换的顺利进行。
copy_thread(proc, stack, tf);

// 设置原子操作(通过关中断的方式),添加此 PCB 至进程集合。
bool intr_flag;
local_intr_save(intr_flag); // 关中断
{
proc->pid = get_pid();
list_add(&proc_list, &(proc->list_link));
hash_proc(proc);
nr_process++;
}

// 设置此进程状态为 *就绪*。
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 内部如何创建并执行内核线程(其中包括进程,在这里指等同于内核线程,切换的实现)。

do_fork() 函数完成后,新建进程的各种资源已经分配完成,此时我们简单看看 PCB 的部分内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
// struct trapframe *tf 属性的部分信息,其设置与 kernel_thread()/copy_thread() 相关。
tf.tf_cs = KERNEL_CS; // 各种段寄存器设置,表明其为内核进程。
tf.tf_ds = tf.tf_es = tf.tf_ss = KERNEL_DS;
tf.tf_regs.reg_ebx = (uint32_t)fn; // ebx/edx 取值为新建进程运行的函数和相关参数,之所以设置此两寄存器,规定而已。
tf.tf_regs.reg_edx = (uint32_t)arg;
tf.tf_eip = (uint32_t)kernel_thread_entry; // 中断返回后的执行入口。
tf.tf_regs.reg_eax = 0;
tf.tf_esp = esp;
tf.tf_eflags |= FL_IF;

// struct context context 属性的部分信息,其设置与 copy_thread() 相关。
context.eip = (uintptr_t)forkret; // 上下文切换的执行入口。
context.esp = (uintptr_t)(proc->tf); // 上下文切换的栈寄存器取值,其指向该进程内核栈的栈顶,其中存放 tf。

在uCore执行完proc_init函数后,就创建好了两个内核线程:idleproc和initproc,这时uCore当前的执行现场就是idleproc,等到执行到init函数的最后一个函数cpu_idle之前,uCore的所有初始化工作就结束了,idleproc将通过执行cpu_idle函数让出CPU,给其它内核线程执行,具体过程如下:

1
2
3
4
5
6
7
// 首先,判断当前内核线程idleproc的need_resched是否不为0,回顾前面“创建第一个内核线程idleproc”中的描述,proc_init函数在初 //始化idleproc中,就把idleproc->need_resched置为1了,所以会马上调用schedule函数找其他处于“就绪”态的进程执行。
void
cpu_idle(void) {
while (1) {
if (current->need_resched) {
schedule();
……

uCore在实验四中只实现了一个最简单的FIFO调度器,其核心就是schedule函数。它的执行逻辑很简单:

1.设置当前内核线程current->need_resched为0;

2.在proc_list队列中查找下一个处于“就绪”态的线程或进程next;

3.找到这样的进程后,就调用proc_run函数,保存当前进程current的执行现场(进程上下文),恢复新进程的执行现场,完成进程切换。

接下来,我们看看,切换进程时具体执行的 proc_run() 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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;
// 设置任务状态段tss中的特权级0下的 esp0 指针为 next 内核线程 的内核栈的栈顶
// 两者均为内核进程,而 tss->esp0 指代 CPL =0 时的内核栈顶,因此需要切换此值。
load_esp0(next->kstack + KSTACKSIZE);
// 重新加载 cr3 寄存器(页目录表基址) 进行进程间的页表切换
lcr3(next->cr3);
// 调用 switch_to 进行上下文的保存与切换
switch_to(&(prev->context), &(next->context));
}
local_intr_restore(intr_flag);
}
}
  • 在本实验的执行过程中,创建且运行了几个内核线程?
1
2
两个内核线程 一个为 idle_proc 为 第 0 个内核线程 完成内核中的初始化 然后调度执行其他进程或线程
另一个为 init_proc 本次实验的内核线程 只用来打印字符串
  • 语句local_intr_save(intr_flag);….local_intr_restore(intr_flag);在这里有何作用?请说明理由
1
关闭中断 避免进程切换的中途 再被中断(其他进程再进行调度)

进一步,追看 switch_to() 的具体实现

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
switch_to:                      
# 保存原有进程的 context 信息。
# 调用 switch_to 后栈的情况 | To esp + 8 |
# | From esp + 4 |
# | Ret Addr <- esp |
movl 4(%esp), %eax # eax points to from
# 指代将 switch_to 的返回地址 pop 作为原先进程的 context.eip
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)

# 恢复新进程的 context 信息。
movl 4(%esp), %eax # eax points to 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

# 设置函数的返回地址为 to 所指代的 eip (具体指代 forkret),那么 switch_to 返回后,便会执行 forkret 函数。
pushl 0(%eax)

ret

由上述汇编代码可知,switch_to 函数返回后会执行forkret函数,而forkret会调用位于kern/trap/trapentry.S中的forkrets函数执行

进一步,追看 forkret() -> forkrets() 的具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
__trapret: 
# restore registers from stack (从栈中弹出所有通用寄存器的值)
popal

# restore %gs, %fs, %ds and %es
popl %gs
popl %fs
popl %es
popl %ds
# get rid of the trap number and error code
addl $0x8, %esp
# 跳转到kernel_thread_entry (此时esp指向了current->tf.tf_eip,而如果此时执行的是initproc,则current->
# tf.tf_eip=kernel_thread_entry)
iret

.globl forkrets
forkrets:
# 把esp指向当前进程的中断帧
movl 4(%esp), %esp
# 此部分为中断处理的后半部分,弹出栈顶的一系列元素,返回执行中断前的指令,具体指代 tf.eip 所指。
# 对于新进程而言,其指代 kernel_thread_entry (它会调用 fn 和 arg,开始真正执行新进程的指令)。
jmp __trapret

至此,完成分析 “进程切换” 的完整步骤。

虽然本 lab 仅涉及内核线程的切换,但是其进程切换方式是比较特殊的:借助于中断实现。该种实现方式允许特权级切换,从而可以构建用户进程,从而为 lab5 打下基础。

kernel_thread_entry是entry.S中实现的汇编函数,它做的事情很简单:

1
2
3
4
5
kernel_thread_entry: # void kernel_thread(void)
pushl %edx # push arg
call *%ebx # call fn (fn就是进程的主体函数)
pushl %eax # save the return value of fn(arg)
call do_exit # call do_exit to terminate current thread

从上可以看出,kernel_thread_entry函数主要为内核线程的主体fn函数做了一个准备开始和结束运行的“壳”,并把函数fn的参数arg(保存在edx寄存器中)压栈,然后调用fn函数,把函数返回值eax寄存器内容压栈,调用do_exit函数退出线程执行。