lab4 — 内核线程管理

前置知识

线程、状态转换。

改动点

相比于 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。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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->esp0 指代 CPL =0 时的内核栈顶,因此需要切换此值。

// 再次回顾 tss 的作用:用于保存当前进程的内核栈信息 (包括 ss0 和 esp0,ss0 指代栈段寄存器,esp0 指代栈顶)。
// pizza 谈及:每次进程进入内核态,得到的内核栈都是空的。
// 如果切换之时,进程处于用户态,那么完全可以使用此代码进行初始化。
// 如果切换之时,进程处于内核态,context.esp 即是内核栈顶,tf.esp 为中断对应栈顶,这样来看,tss.esp0 初始化为任意值都是可以的。
// 有意思的是:当用户态与内核态切换时,硬件自动加载和保存 tss.ss0 和 tss.esp0 至 ss 和 esp,因此这时候它们的值都是正确无误的。
// 因此,这句代码是没有什么问题的。
load_esp0(next->kstack + KSTACKSIZE);
// 加载新进程的 PDT。
lcr3(next->cr3);
// 执行上下文切换
switch_to(&(prev->context), &(next->context));
}
local_intr_restore(intr_flag);
}
}

进一步,追看 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
switch_to:                      
# 保存原有进程的 context 信息。
movl 4(%esp), %eax # eax points to from
# 指代将 switch_to 的返回地址 pop 作为原先进程的 context.eip
# (这里似乎有些不对,如果下次 to 进程切换至 from 进程,切换结果便是 proc_run 中 switch_to 的下一条指令,明显不符合所学内容)。
# 目前可能只是权宜之计,后续会有所改动。
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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
__trapret: 
popal

popl %gs
popl %fs
popl %es
popl %ds

addl $0x8, %esp
iret

.globl forkrets
forkrets:
# 最初新进程的 esp 指向内核栈的栈顶,其中存放 tf。上下文切换完成后,凡是函数调用,消耗的都是新进程的 esp,因此其栈顶会发生变化。如此调整,以保证 esp 仍指向原先存放的 tf。
movl 4(%esp), %esp
# 此部分为中断处理的后半部分,弹出栈顶的一系列元素,返回执行中断前的指令,具体指代 tf.eip 所指。
# 对于新进程而言,其指代 kernel_thread_entry (它会调用 fn 和 arg,开始真正执行新进程的指令)。
jmp __trapret

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

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