ucore lab5

之前的实验均在内核态中执行,不涉及在用户态的操作,所以提供各种操作系统的内核线程只能在cpu核心态工作。然而应用程序员也需要在计算机系统上运行自己的应用软件,将应用软件都作为内核线程则无法保证系统的安全性。基于以上原因,ucore在lab5中提供用户态进程的创建和执行机制,给应用程序提供一个用户态的运行环境。

创建用户进程

应用程序的组成和编译

以hello.c程序为例,

1
2
3
4
5
6
7
8
9
#include <stdio.h>
#include <ulib.h>

int main(void) {
cprintf("Hello world!!.\n");
cprintf("I am process %d.\n", getpid());
cprintf("hello pass.\n");
return 0;
}

执行make后输出如下

1
2
3
4
5
6
7
+ cc user/hello.c

gcc -Iuser/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Iuser/include/ -Iuser/libs/ -c user/hello.c -o obj/user/hello.o

ld -m elf_i386 -nostdlib -T tools/user.ld -o obj/__user_hello.out obj/user/libs/initcode.o obj/user/libs/panic.o obj/user/libs/stdio.o obj/user/libs/syscall.o obj/user/libs/ulib.o obj/user/libs/umain.o obj/libs/hash.o obj/libs/printfmt.o obj/libs/rand.o obj/libs/string.o obj/user/hello.o
……
ld -m elf_i386 -nostdlib -T tools/kernel.ld -o bin/kernel obj/kern/init/entry.o obj/kern/init/init.o …… -b binary …… obj/__user_hello.out

在make最后一步执行了一个ld命令

1
把hello应用程序的执行码obj/__user_hello.out连接在了ucore kernel的末尾。ld命令会在kernel中会把__user_hello.out的位置和大小记录在全局变量_binary_obj___user_hello_out_start和_binary_obj___user_hello_out_size中,这样这个hello用户程序就能够和ucore内核一起被 bootloader 加载到内存里中,并且通过这两个全局变量定位hello用户程序执行码的起始位置和大小。

用户进程的虚拟地址空间

tools/user.ld描述了用户程序的用户虚拟空间的执行入口虚拟地址:

1
2
3
SECTIONS {
/* Load programs at this address: "." means the current address */
. = 0x800020;

tools/kernel.ld描述了操作系统的内核虚拟空间的起始入口虚拟地址:

1
2
3
SECTIONS {
/* Load the kernel at this address: "." means the current address */
. = 0xC0100000;

这样ucore将用户进程的虚拟地址空间分为了两个部分,一部分是所有用户进程共享的内核地址空间,映射到同样的物理地址空间中。将内核代码放到此空间中,用户进程从用户态到内核态时,内核可以统一应对不同的内核程序。另外一部分是用户虚拟地址空间,映射到不同且没有交集的物理内存空间中。用户进程的执行代码和数据放到用户地址空间时确保各个进程不会非法访问到其他进程的物理空间。

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
/* *
* Virtual memory map: Permissions
* kernel/user
*
* 4G ------------------> +---------------------------------+
* | |
* | Empty Memory (*) |
* | |
* +---------------------------------+ 0xFB000000
* | Cur. Page Table (Kern, RW) | RW/-- PTSIZE
* VPT -----------------> +---------------------------------+ 0xFAC00000
* | Invalid Memory (*) | --/--
* KERNTOP -------------> +---------------------------------+ 0xF8000000
* | |
* | Remapped Physical Memory | RW/-- KMEMSIZE
* | |
* KERNBASE ------------> +---------------------------------+ 0xC0000000
* | Invalid Memory (*) | --/--
* USERTOP -------------> +---------------------------------+ 0xB0000000
* | User stack |
* +---------------------------------+
* | |
* : :
* | ~~~~~~~~~~~~~~~~ |
* : :
* | |
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* | User Program & Heap |
* UTEXT ---------------> +---------------------------------+ 0x00800000
* | Invalid Memory (*) | --/--
* | - - - - - - - - - - - - - - - |
* | User STAB Data (optional) |
* USERBASE, USTAB------> +---------------------------------+ 0x00200000
* | Invalid Memory (*) | --/--
* 0 -------------------> +---------------------------------+ 0x00000000
* (*) Note: The kernel ensures that "Invalid Memory" is *never* mapped.
* "Empty Memory" is normally unmapped, but user programs may map pages
* there if desired.
*
* */

创建并执行用户进程

lab5中第一个进程是由第二个内核线程initproc通过把hello应用程序执行码覆盖到initproc的用户执行空间来创建的,相关代码如下所示:

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
#define __KERNEL_EXECVE(name, binary, size) ({                          \
cprintf("kernel_execve: pid = %d, name = \"%s\".\n", \
current->pid, name); \
kernel_execve(name, binary, (size_t)(size)); \
})

#define KERNEL_EXECVE(x) ({ \
extern unsigned char _binary_obj___user_##x##_out_start[], \
_binary_obj___user_##x##_out_size[]; \
__KERNEL_EXECVE(#x, _binary_obj___user_##x##_out_start, \
_binary_obj___user_##x##_out_size); \
})

#define __KERNEL_EXECVE2(x, xstart, xsize) ({ \
extern unsigned char xstart[], xsize[]; \
__KERNEL_EXECVE(#x, xstart, (size_t)xsize); \
})

#define KERNEL_EXECVE2(x, xstart, xsize) __KERNEL_EXECVE2(x, xstart, xsize)

// user_main - kernel thread used to exec a user program
static int
user_main(void *arg) {
#ifdef TEST
KERNEL_EXECVE2(TEST, TESTSTART, TESTSIZE);
#else
KERNEL_EXECVE(exit);
#endif
panic("user_main execve failed.\n");
}

proc_init函数中,通过kernel_thread来创建第二个内核线程init_maininit_main又调用kernel_thread来创建子进程user_main 。user_main在缺省的情况下执行宏KERNEL_EXECVE(exit); ,而这个宏最后是调用kernel_execve 来调用SYS_execve系统调用。由于ld在链接exit应用程序执行码时定义了两全局变量:

1
2
_binary_obj___user_exit_out_start:exit执行码的起始位置
_binary_obj___user_exit_out_size中:exit执行码的大小

kernel_execve把这两个变量作为SYS_exec系统调用的参数,让ucore来创建此用户进程。当ucore收到此系统调用后,将依次调用如下函数

1
2
vector128(vectors.S)-->__alltraps(trapentry.S)-->trap(trap.c)-->trap_dispatch(trap.c)--
-->syscall(syscall.c)-->sys_exec(syscall.c)-->do_execve(proc.c)

最终通过do_execve函数来完成用户进程的创建工作。

do_execve的流程分析如下:

首先为加载新的执行码做好用户态内存空间清空准备。如果mm不为NULL,则设置页表为内核空间页表,且进一步判断mm的引用计数减1后是否为0,如果为0,则表明没有进程再需要此进程所占用的内存空间,为此将根据mm中的记录,释放进程所占用户空间内存和进程页表本身所占空间。最后把当前进程的mm内存管理指针为空。由于此处的 initproc是内核线程,所以mm为NULL,整个处理都不会做。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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);

if (mm != NULL) {
lcr3(boot_cr3);
if (mm_count_dec(mm) == 0) {
exit_mmap(mm);
put_pgdir(mm);
mm_destroy(mm);
}
current->mm = NULL;
}

加载应用程序执行码到当前进程的新创建的用户态虚拟空间中。这里涉及到读ELF格式的文件,申请内存空间,建立用户态虚存空间,加载应用程序执行码等。load_icode函数完成了整个复杂的工作。

1
2
3
4
5
int ret;
if ((ret = load_icode(binary, size)) != 0) {
goto execve_exit;
}
set_proc_name(current, local_name);

do_execv函数调用load_icode(位于kern/process/proc.c中)来加载并解析一个处于内存中的ELF执行文件格式的应用程序,建立相应的用户内存空间来放置应用程序的代码段、数据段等,且要设置好proc_struct结构中的成员变量trapframe中的内容,确保在执行此进程后,能够从应用程序设定的起始执行地址开始执行。需设置正确的trapframe内容。

设置trapframe部分的代码如下:

1
2
3
4
5
6
7
8
9
static int load_icode(unsigned char *binary, size_t size) {
struct trapframe *tf = current->tf;
memset(tf, 0, sizeof(struct trapframe));
tf_cs=USER_CS;
tf_ds=tf_es=tf_ss=USER_DS;
tf_esp=USTACKTOP;
tf_eip=elf->e_entry;
tf_eflags=FL_IF;//使能中断
}

首先将中断帧全部设置为0,其次修改中断帧中内容如下:

  • tf_cs:用户态代码段寄存器,设置为USER_CS
  • tf_ds,tf_es,tf_ss:用户态数据段寄存器,设置为USER_DS
  • tf_esp:设置为宏USTACKTOP(用户栈),即0xB0000000
  • tf_eip:用户态的指令指针,设置为用户程序的起始位置(即user.ld中0x00800020
  • tf_eflags:设置使能中断

描述当创建一个用户态进程并加载了应用程序后,CPU是如何让这个应用程序最终在用户态执行起来的。即这个用户态进程被ucore选择占用CPU执行(RUNNING态)到具体执行应用程序第一条指令的整个经过。

当一个用户态进程被选择占用cpu执行后,执行proc_init,流程如下:

  1. 关闭中断
  2. 将当前进程指针指向该进程
  3. 调用switch_to切换进程上下文,跳转到forkrets
  4. 跳转到__trapret,恢复各段寄存器

新建一个进程有两种方式:

  1. 在用户态系统调用sys_fork
  2. 在内核态函数调用do_fork

二者均会系统调用sys_exec来创建一个新进程,应用程序具体执行的第一条指令由trapframe中eip的值给定。在__trapret返回后即转到新进程中的第一条指令执行。

进程退出和回收

进程执行完它的工作后就执行退出操作来释放进程占用的资源。进程自身无法回收所有的资源,只要进程还在执行,内核栈的空间无法释放,对应的进程控制块也无法释放,由其父进程来释放这两个资源。所以ucore首先由进程本身完成大部分资源的占用内存回收工作,然后由父进程完成剩余资源的回收工作。

为此在用户态函数库中提供了exit函数,访问sys_exit系统调用接口让操作系统来帮助当前进程执行退出过程中部分资源的回收。

进程退出操作流程如下:

首先sys_exit将退出码error_code传递给ucore,ucore通过执行do_exit来处理当前退出进程,回收大部分当前进程所占用的资源并通知父进程完成最后的回收工作。

具体流程如下:

1. 如果current->mm != NULL,表示是用户进程,则开始回收此用户进程所占用的用户态虚拟内存空间;

a) 首先执行lcr3(boot_cr3),切换到内核态的页表上,这样当前用户进程目前只能在内核虚拟地址空间执行了,这是为了确保后续释放用户态内存和进程页表的工作能够正常执行;

b) 如果当前进程控制块的成员变量mm的成员变量mm_count减1后为0(表明这个mm没有再被其他进程共享,可以彻底释放进程所占的用户虚拟空间了),则开始回收用户进程所占的内存资源:

i. 调用exit_mmap函数释放current->mm->vma链表中每个vma描述的进程合法空间中实际分配的内存,然后把对应的页表项内容清空,最后还把页表所占用的空间释放并把对应的页目录表项清空;

ii. 调用put_pgdir函数释放当前进程的页目录所占的内存;

iii. 调用mm_destroy函数释放mm中的vma所占内存,最后释放mm所占内存;

c) 此时设置current->mm为NULL,表示与当前进程相关的用户虚拟内存空间和对应的内存管理成员变量所占的内核虚拟内存空间已经回收完毕;

1
2
3
4
5
6
7
8
9
10
struct mm_struct *mm = current->mm;
if (mm != NULL) {
lcr3(boot_cr3);
if (mm_count_dec(mm) == 0) {
exit_mmap(mm);
put_pgdir(mm);
mm_destroy(mm);
}
current->mm = NULL;
}

2. 这时,设置当前进程的执行状态current->state=PROC_ZOMBIE,当前进程的退出码current->exit_code=error_code。此时当前进程已经不能被调度了,需要此进程的父进程来做最后的回收工作(即回收描述此进程的内核栈和进程控制块);

3. 如果当前进程的父进程current->parent处于等待子进程状态:current->parent->wait_state==WT_CHILD,则唤醒父进程(即执行“wakup_proc(current->parent)”),让父进程帮助自己完成最后的资源回收;

1
2
current->state = PROC_ZOMBIE;
current->exit_code = error_code;

4. 如果当前进程还有子进程,则需要把这些子进程的父进程指针设置为内核线程initproc,且各个子进程指针需要插入到initproc的子进程链表中。如果某个子进程的执行状态是PROC_ZOMBIE,则需要唤醒initproc来完成对此子进程的最后回收工作。

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
bool intr_flag;
struct proc_struct *proc;
local_intr_save(intr_flag);
{
proc = current->parent;
if (proc->wait_state == WT_CHILD) {
wakeup_proc(proc);
}
while (current->cptr != NULL) {
proc = current->cptr;
current->cptr = proc->optr;

proc->yptr = NULL;
if ((proc->optr = initproc->cptr) != NULL) {
initproc->cptr->yptr = proc;
}
proc->parent = initproc;
initproc->cptr = proc;
if (proc->state == PROC_ZOMBIE) {
if (initproc->wait_state == WT_CHILD) {
wakeup_proc(initproc);
}
}
}
}
local_intr_restore(intr_flag);

5. 执行schedule()函数,调度后选择新进程执行。

父进程通过syswait系统调用接口来让ucore完成最后的资源回收工作。

do_wait具体流程如下:
**1.**如果pid!=0,表示只找一个进程id号为pid的退出状态的子进程,否则找任意一个处于退出状态的子进程;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if (pid != 0) {
proc = find_proc(pid);
if (proc != NULL && proc->parent == current) {
haskid = 1;
if (proc->state == PROC_ZOMBIE) {
goto found;
}
}
}
else {
proc = current->cptr;
for (; proc != NULL; proc = proc->optr) {
haskid = 1;
if (proc->state == PROC_ZOMBIE) {
goto found;
}
}
}

**2.**如果此子进程的执行状态不为PROC_ZOMBIE,表明此子进程还没有退出,则当前进程只好设置自己的执行状态为PROC_SLEEPING,睡眠原因为WT_CHILD(即等待子进程退出),调用schedule()函数选择新的进程执行,自己睡眠等待,如果被唤醒,则重复跳回步骤1处执行;

1
2
3
4
5
6
7
8
9
if (haskid) {
current->state = PROC_SLEEPING;
current->wait_state = WT_CHILD;
schedule();
if (current->flags & PF_EXITING) {
do_exit(-E_KILLED);
}
goto repeat;
}

**3.**如果此子进程的执行状态为PROC_ZOMBIE,表明此子进程处于退出状态,需要当前进程(即子进程的父进程)完成对子进程的最终回收工作,即首先把子进程控制块从两个进程队列proc_listhash_list中删除,并释放子进程的内核堆栈和进程控制块。自此,子进程才彻底地结束了它的执行过程,消除了它所占用的所有资源。

1
2
3
4
5
6
7
8
local_intr_save(intr_flag);
{
unhash_proc(proc);
remove_links(proc);
}
local_intr_restore(intr_flag);
put_kstack(proc);
kfree(proc);

系统调用

系统调用为用户进程提供操作系统服务的统一接口层,简化用户的实现。

初始化中断向量描述符

在ucore的kern_init中调用了idt_init来初始化中断向量描述符表,并设置专门用于用户进程访问系统调用的中断门。

idt_init部分:

1
2
3
4
5
6
7
8
9
10
void
idt_init(void) {
extern uintptr_t __vectors[];
int i;
for (i = 0; i < sizeof(idt) / sizeof(struct gatedesc); i ++) {
SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
}
SETGATE(idt[T_SYSCALL], 1, GD_KTEXT, __vectors[T_SYSCALL], DPL_USER);
lidt(&idt_pd);
}

一旦用户执行int 0x80 后,CPU从用户态切换到内核态,保存相关寄存器并跳转到对应的中断服务例程处执行。

1
vector128(vectors.S)-->__alltraps(trapentry.S)-->trap(trap.c)-->trap_dispatch(trap.c)---->syscall(syscall.c)

建立系统调用的用户库准备

用户态的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
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);

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;
}
  • eax中存放系统调用号
  • edx、ecx、ebx、edi、esi中按照顺序存放前五个参数
  • 返回值存放在eax中

应用程序调用的exit/fork/wait/getpid等库函数最后都会调用syscall函数,只是调用参数不同而已。

内核态的syscall为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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) {
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);
}

可以看到,传递参数的顺序和用户态syscall的顺序一致。

进程执行 fork/exec/wait/exit

父进程复制自己的内存空间给子进程

创建子进程的函数do_fork在执行中将拷贝当前进程(即父进程)的用户内存地址空间中的合法内容到新进程中(子进程),完成内存资源的复制。具体是通过copy_range函数(位于kern/mm/pmm.c中)实现的,请补充copy_range的实现,确保能够正确执行。

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
int
copy_range(pde_t *to, pde_t *from, uintptr_t start, uintptr_t end, bool share) {
assert(start % PGSIZE == 0 && end % PGSIZE == 0);
assert(USER_ACCESS(start, end));
//以页为单元进行复制
do {
//call get_pte to find process A's pte according to the addr start
pte_t *ptep = get_pte(from, start, 0), *nptep;
if (ptep == NULL) {
start = ROUNDDOWN(start + PTSIZE, PTSIZE);
continue ;
}
//call get_pte to find process B's pte according to the addr start. If pte is NULL, just alloc a PT
if (*ptep & PTE_P) {
if ((nptep = get_pte(to, start, 1)) == NULL) {
return -E_NO_MEM;
}
uint32_t perm = (*ptep & PTE_USER);
//get page from ptep
struct Page *page = pte2page(*ptep);
// alloc a page for process B
struct Page *npage=alloc_page();
assert(page!=NULL);
assert(npage!=NULL);
int ret=0;
//memcopy传入参数为void*类型,故二者均初始化为void *类型
void* src_kvaddr=page2kva(page);
void* dst_kvaddr=page2kva(npage);
memcpy(dst_kvaddr, src_kvaddr, PGSIZE);
ret = page_insert(to, npage, start, perm);
assert(ret == 0);
}
start += PGSIZE;
} while (start != 0 && start < end);
return 0;
}

请分析fork/exec/wait/exit在实现中是如何影响进程的执行状态的?

  • do_fork 调用alloc_proc后进程状态为 UNINIT,然后将原进程信息复制过去,再覆盖原来的内核栈、eax、esp、eflags。调用函数 wakeup_proc 之后进程状态为RUNNABLE。

  • do_execve 清除当前内存布局,再调用load_icode从二进制elf文件中读入内存布局,进程的状态不发生改变。

  • do_wait 当前进程无子进程时错误退出,若有子进程,则判定子进程的状态是否为则判定是否为ZOMBIE 。若是则释放子进程的资源,并返回子进程的退出状态码。

  • do_exit 首先清除当前进程除内核栈和进程控制块以外的资源,状态设置为ZOMBIE。若存在正在等待的父进程则唤醒父进程,父进程的状态从 SLEEPING 转变为 RUNNABLE。随后将该进程所有的子进程的父进程设置为initproc,由initproc进行资源的回收。若initproc正在等待子进程,则唤醒initproc

请给出ucore中一个用户态进程的执行状态生命周期图

proc中的用户态进程执行状态生命周期图如下:

1
2
3
4
5
6
7
8
9
alloc_proc                                 RUNNING
+ +--<----<--+
+ + proc_run +
V +-->---->--+
PROC_UNINIT -- proc_init/wakeup_proc --> PROC_RUNNABLE -- try_free_pages/do_wait/do_sleep --> PROC_SLEEPING --
A + +
| +--- do_exit --> PROC_ZOMBIE +
+ +
-----------------------wakeup_proc----------------------------------