lab5 — 用户进程管理

前置知识

进程、虚拟存储、中断机制、系统调用。

改动点

相比于 lab4 源代码,lab5 主要做了如下改动:

  • memlayout.h 增加用户空间的图形表示和宏定义

    宏定义 USERTOP/USERBASE 等指明用户虚拟地址空间范围,图形表示显示哪些用户空间属于合法空间。

  • pmm.[ch] 添加若干与页表项、页面相关的拷贝函数

  • vmm.[ch] 添加若干与 mm/vma/pgdir 相关的函数

    值得注意的是: struct mm_struct 属性内容发生变化:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    struct mm_struct {
    list_entry_t mmap_list;
    struct vma_struct *mmap_cache;
    pde_t *pgdir;
    int map_count;
    void *sm_priv;
    int mm_count; // 共享此 mm 的进程数量
    lock_t mm_lock; // 互斥锁, dup_map() 实现时使用,
    };
  • proc.h 扩展 proc_struct 数据结构

    proc_struct 的扩展点具体如下:

    1
    2
    3
    4
    5
    6
    7
    struct proc_struct {
    ... // 同以往结构的属性
    int exit_code; // 当前进程的退出码,供父进程使用。
    uint32_t wait_state; // 当前进程的等待状态,供子进程退出时调用。
    struct proc_struct *cptr, *yptr, *optr;
    // 若干指针存放与其他进程间的关系(孩子进程指针(总是指向最新的子进程)、更年轻的(或称为前一个)兄弟进程指针、更年长的(或称为后一个)兄弟进程指针(后面两个指针构建兄弟进程的双向链表))
    };
  • proc.c 增加若干与 SYSCALL 相关的实现函数

ucore 中,进程与线程采用同一套管理机制,它们间的区别可能仅在于:进程之间不共享地址空间,而线程间共享某进程的地址空间 (通过共享 mm 结构即可),因此两个术语基本可以混用。

练习一

该练习用于了解 do_exec() 的具体实现。

在 lab4 中,我们已经知道:如何创建一个内核线程,并通过调度使得其正常执行。那么我们首先看一下 lab5 中所涉的内核线程:

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
// proc_init() -> 构建 idleproc 和 initproc
---
int pid = kernel_thread(init_main, NULL, 0); // 此句构建 initproc,其执行 initmain 函数。
---

// initmain() -> 构建 user_mainproc
---
static int init_main(void *arg) {
size_t nr_free_pages_store = nr_free_pages();
size_t kernel_allocated_store = kallocated();


int pid = kernel_thread(user_main, NULL, 0); // 此句构建 user_mainproc,属于第三个内核线程,它是 initproc 的子线程。
if (pid <= 0) {
panic("create user_main failed.\n");
}

while (do_wait(0, NULL) == 0) { // 等待 user_mainproc 结束,并回收其剩余资源(此为后话)。
schedule();
}
return 0;
}
---

// user_main
---
static int user_main(void *arg) {
#ifdef TEST
KERNEL_EXECVE2(TEST, TESTSTART, TESTSIZE);
#else
KERNEL_EXECVE(exit); // 实际执行 sys_exec 系统调用,使用 do_exec() 重新配置当前进程的内存空间,并执行 exit 程序。
#endif
panic("user_main execve failed.\n");
}
---

接下来,我们主要看看 do_exec() 的实现细节 (do_exec 等价于实际系统中的 exec() ):

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
int do_execve(const char *name, size_t len, unsigned char *binary, size_t size) {
struct mm_struct *mm = current->mm;
if (!user_mem_check(mm, (uintptr_t)name, len, 0)) {
return -E_INVAL;
}
if (len > PROC_NAME_LEN) {
len = PROC_NAME_LEN;
}

char local_name[PROC_NAME_LEN + 1];
memset(local_name, 0, sizeof(local_name));
memcpy(local_name, name, len);

// 清空原有 mm。
if (mm != NULL) {
// 首先切换页目录表,以防访问到不该访问的,使得访问异常。
lcr3(boot_cr3);
// mm 共享数减一,如此此时为 0,则需要删除此 mm,具体包括 vma集合、页目录表和页表、实际物理空间、mm 结构。
if (mm_count_dec(mm) == 0) {
exit_mmap(mm);
put_pgdir(mm);
mm_destroy(mm);
}
current->mm = NULL;
}
int ret;
// load_icode 的主要工作包括:创建 mm、填充 mm 各属性、填充与代码段,数据段,栈相关的物理页、重新设置 tf,使得能够正确返回至用户空间,并开始执行相关代码。具体涉及 ELF 文件格式,可不细理。
if ((ret = load_icode(binary, size)) != 0) {
goto execve_exit;
}
set_proc_name(current, local_name);
return 0;

execve_exit:
do_exit(ret);
panic("already exit: %e.\n", ret);
}

// load_icode 涉及重置 tf 部分:
struct trapframe *tf = current->tf;
memset(tf, 0, sizeof(struct trapframe));
// 更新 cs 等段寄存器值为用户态段。那么当执行中断返回时,系统便可基于此判断是否存在特权级切换,从而正确弹栈。
tf->tf_cs = USER_CS;
tf->tf_ds = tf->tf_es = tf->tf_ss = USER_DS;
tf->tf_esp = USTACKTOP;
tf->tf_eip = elf->e_entry;
tf->tf_eflags = FL_IF;

练习二

该练习用于了解 do_fork()->copy_mm() 的具体实现。

copy_mm() 具体指代复制当前进程的 mm 给新建 procmm 结构,具体实现细节直接看源代码即可,也没什么好说的。

练习三

该练习用于理解各系统调用的实现机理。

首先看用户空间的系统调用:

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
56
57
58
static inline int syscall(int num, ...) {
va_list ap;
va_start(ap, num);
uint32_t a[MAX_ARGS];
int i, ret;
// 获取相关参数
for (i = 0; i < MAX_ARGS; i ++) {
a[i] = va_arg(ap, uint32_t);
}
va_end(ap);

// 实施中断,并将相关参数传给相关寄存器,它们会进一步放置于 tf 内部。
asm volatile (
"int %1;"
: "=a" (ret)
: "i" (T_SYSCALL),
"a" (num),
"d" (a[0]),
"c" (a[1]),
"b" (a[2]),
"D" (a[3]),
"S" (a[4])
: "cc", "memory");
return ret;
}

// 如下为用户程序可调用的各种函数,它们统一使用 syscall 实现其功能。
int sys_exit(int error_code) {
return syscall(SYS_exit, error_code);
}

int sys_fork(void) {
return syscall(SYS_fork);
}

int sys_wait(int pid, int *store) {
return syscall(SYS_wait, pid, store);
}

int sys_yield(void) {
return syscall(SYS_yield);
}

int sys_kill(int pid) {
return syscall(SYS_kill, pid);
}

int sys_getpid(void) {
return syscall(SYS_getpid);
}

int sys_putc(int c) {
return syscall(SYS_putc, c);
}

int sys_pgdir(void) {
return syscall(SYS_pgdir);
}

接下来,硬件得到 T_SYSCALL 中断号,并调用相关中断处理程序进行处理:

1
2
3
case T_SYSCALL:
syscall();
break;

接下来,看看内核程序 syscall() 的具体实现:

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
static int sys_exit(uint32_t arg[]) {
int error_code = (int)arg[0];
return do_exit(error_code);
}

static int sys_fork(uint32_t arg[]) {
struct trapframe *tf = current->tf;
uintptr_t stack = tf->tf_esp;
return do_fork(0, stack, tf);
}

static int sys_wait(uint32_t arg[]) {
int pid = (int)arg[0];
int *store = (int *)arg[1];
return do_wait(pid, store);
}

static int sys_exec(uint32_t arg[]) {
const char *name = (const char *)arg[0];
size_t len = (size_t)arg[1];
unsigned char *binary = (unsigned char *)arg[2];
size_t size = (size_t)arg[3];
return do_execve(name, len, binary, size);
}

static int sys_yield(uint32_t arg[]) {
return do_yield();
}

static int sys_kill(uint32_t arg[]) {
int pid = (int)arg[0];
return do_kill(pid);
}

static int sys_getpid(uint32_t arg[]) {
return current->pid;
}

static int sys_putc(uint32_t arg[]) {
int c = (int)arg[0];
cputchar(c);
return 0;
}

static int sys_pgdir(uint32_t arg[]) {
print_pgdir();
return 0;
}

// 上述函数为 SYS_xx 的具体实现,它们会获取相关参数,并调用相关 do_xxx 进行处理,最后返回。

static int (*syscalls[])(uint32_t arg[]) = {
[SYS_exit] sys_exit,
[SYS_fork] sys_fork,
[SYS_wait] sys_wait,
[SYS_exec] sys_exec,
[SYS_yield] sys_yield,
[SYS_kill] sys_kill,
[SYS_getpid] sys_getpid,
[SYS_putc] sys_putc,
[SYS_pgdir] sys_pgdir,
};

#define NUM_SYSCALLS ((sizeof(syscalls)) / (sizeof(syscalls[0])))

void syscall(void) {
struct trapframe *tf = current->tf;
uint32_t arg[5];
int num = tf->tf_regs.reg_eax;
if (num >= 0 && num < NUM_SYSCALLS) {
if (syscalls[num] != NULL) {
// 从 tf 中获取相关参数,放置于 arg[] 内部,并调用相应的 SYS_xxx 进行处理。
arg[0] = tf->tf_regs.reg_edx;
arg[1] = tf->tf_regs.reg_ecx;
arg[2] = tf->tf_regs.reg_ebx;
arg[3] = tf->tf_regs.reg_edi;
arg[4] = tf->tf_regs.reg_esi;
tf->tf_regs.reg_eax = syscalls[num](arg);
return ;
}
}
print_trapframe(tf);
panic("undefined syscall %d, pid = %d, name = %s.\n",
num, current->pid, current->name);
}

对于上述 sys_xxx,简要做如下分析:

  • do_fork(0, stack, tf)

    第一个参数为 0,表明其会完全复制当前进程的地址空间,第二三参数,表明其会完全复制当前进程的用户栈及中断帧。如此实现,含义与实际 fork() 相同。

    由于之前早已说明 do_fork() 的实现细节,在此不再赘述。

  • do_execve(name, len, binary, size)

    练习一已经说明其含义,在此不再赘述。

  • do_exit(error_code)

    do_exit() 用于回收该进程的内存资源,并设置 PROC_ZOMBIE 状态以等待父进程处理。如果当前进程存在子进程,需要将它们置为 initproc 的子进程。

  • do_wait(pid, store)

    do_wait() 用于等待子进程处于 PROC_ZOMBIE 状态,并回收其剩余资源 (指代内核栈、PCB)。

    某进程资源的回收工作具体分为两步:do_exit() 自主回收内存资源,do_wait() 由父进程回收内核资源。之所以如此实现,原因在于:进程回收内核资源,则其必定在使用内核栈,而内核资源包括内核栈,因此存在一个矛盾。另外,父进程可通过回收资源过程,获取其子进程的运行结果,然后动态施行相关操作。

  • do_yield()

    do_yield() 用于释放 CPU,并让其调度处理其他进程。

  • do_kill(pid)

    do_kill() 用于设置特定进程的 flags |= PF_EXITING;

  • sys_getpid/sys_putc/sys_pgdir

    此三者都比较简单,没什么可说的。

需要注意:do_yield()do_kill() 功能实现的部分代码在函数内部,部分代码位于 trap() 内部,trap() 内部的实现如下:

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
void trap(struct trapframe *tf) {
if (current == NULL) {
trap_dispatch(tf);
}
else {
// 如此保存旧 tf,保证实现中断嵌套。
struct trapframe *otf = current->tf;
current->tf = tf;

bool in_kernel = trap_in_kernel(tf);

trap_dispatch(tf);

current->tf = otf;
if (!in_kernel) {
// do_kill 发挥作用就比较慢,当特定进程再次进入中断后,才会让它触动回收。
// 进程进入中断的方式主要存在两种:1. 系统调用 2. 时钟中断。
if (current->flags & PF_EXITING) {
do_exit(-E_KILLED);
}

// do_yield 设置字段后,中断返回前即会重新调度。
if (current->need_resched) {
schedule();
}
}
}
}

扩展练习一

该练习用于实现 Copy on Write(COW) 机制。

COW 机制实现比较复杂,并没有实现。在此简要说明其思想。

当执行 do_fork(0, stack, tf) -> copy_mm() 时,复制 vma 集合、页目录表等内容,共享当前进程的物理页,并设置相关条目为只读 (而非原先的复制物理页)。

当新旧进程需要写共享物理页时,触发中断,执行 do_pgfault()。如果该页面为只读,且其共享数大于 1,则表明存在新旧进程在共享物理页,因此复制该物理页给当前进程,并设置相关条目为读写。

如何创建用户线程?

借助于 do_fork()/do_execve(),我们成功创建用户进程 (也可称作该进程对应的主线程),它所对应的栈直接位于虚拟地址空间中的栈区。

用户进程的栈大小默认为 8M。当栈所占实际空间小于 8M 时,它会因缺页中断而动态增长;当栈所占实际空间大于 8M 时,便会触发 栈溢出

对于用户线程而言,它们需要共享用户进程资源 (例如:地址空间、打开的文件列表),而独占栈空间、寄存器组等资源。

经过上面的实现,共享进程资源比较简单,无非就是设置相关指针以指向相同结构体;独占寄存器组也比较简单,使用 proc.context 保存即可;唯一难点在于:如何独占栈空间的同时,实现共享该进程的地址空间?

Linux 系统的解决方案是这样的:创建用户线程之时,在当前地址空间的堆区直接分配栈空间,以此作为该线程的栈。

用户线程的栈大小默认为 16K,最大为 2M。

注:Linux 的解决方案中,线程栈空间直接分配,它不会动态增长。