第五个部分是实现进程相关的函数,相比前面几次任务难度高了不少。同时为了适应前面的内存函数,进程函数的实现基本也得靠自己了。由于用户程序增加了不少,所以我还增改了一下Makefile,并写了一个Python脚本用来生成link_app.S。
首先是Makefile,之前一直是把Makefile当shell脚本用的,这一次才真正发挥出Makefile的独特功能:
CC = riscv64-unknown-elf-gcc -ffreestanding -nostdlib -g -mcmodel=medany -Icommon
OC = riscv64-unknown-elf-objcopy --strip-all -O binary
user_obj = $(foreach i, $(filter-out user/lib.c, $(wildcard user/*.c)), build/$(basename $(notdir $i)))
run: compile
qemu-system-riscv64 -machine virt -nographic -bios common/rustsbi-qemu.bin \
-device loader,file=build/os.bin,addr=0x80200000
$(user_obj): $(wildcard user/*.c) common/common.c
ifneq (build, $(wildcard build))
mkdir build
endif
$(CC) user/$(@F).c user/lib.c common/common.c -T user/linker.ld -o $@
compile: $(user_obj)
python kernel/build.py
$(CC) kernel/*.c kernel/*.S build/link_app.S common/common.c -T kernel/linker.ld -o build/os
riscv64-unknown-elf-objcopy --strip-all -O binary build/os build/os.bin
debug: compile
qemu-system-riscv64 -machine virt -nographic -bios common/rustsbi-qemu.bin \
-device loader,file=build/os.bin,addr=0x80200000 -s -S
clean:
rm build/*
.PHONY: compile run debug clean
首先说一下我们这个项目的需求,首先检查build文件夹是否存在,不存在就创建一个,接着需要编译用户程序,编译方法是对于除了lib.c的所有C语言程序,都和lib.c以及公共源文件common.c一起编译并链接。接着运行脚本生成link_app.S,然后编译内核。最后如果直接运行make或make run则以普通模式运行qemu,如果运行make debug则以调试模式运行qemu。所以我的代码做的也是这件事,user_obj变量里先用wildcard函数枚举user文件夹下所有C程序,然后用filter-out过滤掉lib.c,再用notdir和basename函数把文件名构造成“build/程序名”的形式。
接着编译这些程序,make的规则天生自带foreach的功能,它会枚举目标文件名,将其填入含有自动变量的命令中,所以通过把user_obj变量传进规则目标,就可以实现对每个目标文件进行编译。同时对于某个规则,make会比较目标文件的修改时间和依赖文件的修改时间,如果目标文件不存在、目标文件为伪目标或者依赖文件比目标新,就会运行这个规则,所以编译用户程序的过程需要把所有用户程序源代码和common.c作为依赖文件,使得只要用户代码和公共代码一改就会重新编译。而内核代码的编译我声明了一个伪目标,因为涉及内核编译的代码太多了,通过声明伪目标可以强制该规则执行。
总结一下,makefile就三个特性,一个是枚举,通过将多个文件定义成目标来实现;一个是依赖处理,就是把所有参与目标文件生成的源代码都纳入进来,或者把目标文件声明成伪目标,很多项目都在这里出了问题,导致有时改了一些代码运行结果却没有改变,需要先make clean再重新生成才能看到正确结果,就是因为没有把这个代码设置成依赖导致没编译上;最后一个是各种文件函数,最常用的是wildcard函数,可以枚举符合通配符的所有文件或文件夹,配合各种路径处理函数非常方便。
生成link_app.S的脚本就不贴了,比较简单。
然后就是这个部分的设计了。这里我为了实现的方便,改变了一下用户页表的布局,把用户栈映射到了紧邻TRAPCONTEXT的正下方,目前看来好像和rCore和xv6都不一样。我这样设计的理由有两点:
用户栈正下方有一块没有映射的guard page,如果像rCore一样和用户静态内存放在一起,会导致用户地址空间0到base_size之间出现一个空洞,解映射的时候很不方便。如果和TRAPCONTEXT放在一起,解映射就是一句话的事,而且这两个空间的生命周期基本一致,创建进程时就一起映射上,后面exec也可以复用,直到销毁内存时才解映射;
这样映射所有用户程序的用户栈底都是TRAP_CONTEXT了,不需要额外的属性去保存这个地址,用户静态内存到栈顶的位置也是一个天然的guard page,可以减少不少麻烦。
当然只考虑解映射的话,rCore的内存管理程序把每个页表所有映射的内存段都记录了,解映射就是一个foreach,空洞不是问题;xv6的方法非常精巧,它申请了guard page的物理内存,但是用户页表里却把这段内存标记成用户态不能访问,这样起到了guard page的功能,但是内核在解映射的时候却可以当成没有空洞的完整区域进行处理,可谓是各显神通了。
为了更好地完成这些函数,需要梳理一下它们的功能。和进程创建的函数一共有三个,第一个是最开始initproc的创建,第二个是fork,第三个是exec。initproc的创建是从零到有的创建,所以需要生成一个进程控制块,然后映射trampoline,陷入上下文,用户栈、内核栈,接着映射用户静态内存,陷入上下文和进程上下文的内容都需要初始化。fork的也要从零到有创建一个进程,所以需要生成一个进程控制块,然后映射trampoline,trapcontext,用户栈、内核栈,但是用户静态内存、用户栈和陷入上下文的内容都要复制父进程的,进程上下文和内核栈的处理后面再说。exec可以复用原进程的几乎所有内容,只有用户静态内存需要重映射,陷入上下文的内容需要初始化,这里因为和进程切换没有关系,所以不用管进程上下文。
至此,我们很容易就可以提炼出能够复用的内容了,创建initproc的函数和fork函数都需要生成进程控制块、映射trampoline,陷入上下文,用户栈、内核栈,这可以作为一个函数:
TaskControlBlock *alloc_proc() {
PhysPageNum user_pagetable = frame_alloc();
TaskControlBlock *tcb = bd_malloc(sizeof(TaskControlBlock));
usize pid = pid_alloc();
// map some page
map_trampoline(user_pagetable);
VirtAddr top = TRAMPOLINE - pid * (KERNEL_STACK_SIZE + PAGE_SIZE);
VirtAddr bottom = top - KERNEL_STACK_SIZE;
extern PhysPageNum kernel_pagetable;
map_area(kernel_pagetable, bottom, top, R | W, 1);
map_area(user_pagetable, TRAP_CONTEXT, TRAMPOLINE, R | W, 1);
map_area(user_pagetable, TRAP_CONTEXT - USER_STACK_SIZE, TRAP_CONTEXT, R | W | U, 1);
// fill task context
tcb->pid = pid;
tcb->pagetable = user_pagetable;
tcb->task_status = Ready;
tcb->base_size = 0;
tcb->exit_code = 0;
tcb->task_cx_ptr = top - sizeof(TaskContext);
PageTableEntry *pte_p = find_pte(user_pagetable, FLOOR(TRAP_CONTEXT), 0);
tcb->trap_cx_ppn = PTE2PPN(*pte_p);
tcb->parent = 0;
LIST_INIT(&tcb->children);
}
创建initproc的函数和exec函数都需要从elf文件中获得用户静态内存的内容以及初始化陷入上下文,这也可以作为一个函数:
void user_init(TaskControlBlock *tcb, char *name) {
usize entry_point;
from_elf(get_app_data_by_name(name), tcb->pagetable, &tcb->base_size, &entry_point);
// fill trap context
TrapContext *trap_cx = (TrapContext *)PPN2PA(tcb->trap_cx_ppn);
memset(trap_cx->x, 0, sizeof(usize) * 32);
trap_cx->x[2] = TRAP_CONTEXT;
usize sstatus; asm volatile("csrr %0, sstatus":"=r"(sstatus));
sstatus &= ~(1L << 8); trap_cx->sstatus = sstatus;
trap_cx->sepc = entry_point;
extern PhysPageNum kernel_pagetable;
trap_cx->kernel_satp = PGTB2SATP(kernel_pagetable);
trap_cx->kernel_sp = TRAMPOLINE - tcb->pid * (KERNEL_STACK_SIZE + PAGE_SIZE);
trap_cx->trap_handler = (usize)trap_handler;
}
然后就是这几个函数各自的内容。创建initproc的函数除了调用上面两个函数,还要初始化进程上下文并把它放在内核栈顶。
这里顺带讲一下进程切换的机制,__switch函数会将老进程的相关信息压入栈中,更新一下进程的task_cx_ptr,然后从新进程的task_cx_ptr中取出信息,这意味着两点,一是在切换进程的时候,必须保证新进程的task_cx_ptr指向的内容是有效的进程上下文;二是切换后新进程的task_cx_ptr指向的内容就可以被覆盖了,反正下次切换的时候又会重新保存。所以这里需要初始化进程上下文,就是为了从调度进程切换到initproc的时候有一个有效的进程上下文。
那么调度进程的进程上下文在哪呢,答案是entry.S里的bootstack,第一次调度的时候保存进程上下文要压栈,这时压的就是bootstack,idle_task_cx_ptr保存的也是bootstack里的物理地址:
void task_init_and_run() {
TAILQ_INIT(&t_head); pid_init();
initproc = alloc_proc();
user_init(initproc, "initproc");
// fill task context
TaskContext *task_cx_ptr = (TaskContext *)initproc->task_cx_ptr;
task_cx_ptr->ra = (usize)trap_return;
memset(task_cx_ptr->s, 0, sizeof(usize) * 12);
add_task(initproc); run(&PROCESSOR);
}
fork函数处理调用alloc_proc,需要复制静态内存、用户栈和陷入上下文,后两者是连续的,就可以用一句话复制了。
注意内核栈不用复制,因为fork出来的子进程是通过__switch切换过去的,而__switch运行完会到达trap_return,然后借助复制的陷入上下文的内容返回用户态,因此父进程内核栈里的内容对子进程就没有意义了。而且fork子进程还需要初始化一个新的进程上下文放在自己的内核栈里,不能复制父进程的,因为根据刚才所说关于进程切换的第二点,父进程自己的进程上下文已经是无效的了。
虽然子进程大体可以复制父进程的陷入上下文,但还是有些许不同,比如内核栈顶,以及a0寄存器(父进程fork的返回值是pid,子进程是0)。
usize fork() {
TaskControlBlock *p = PROCESSOR.current;
TaskControlBlock *tcb = alloc_proc();
// copy user data and code
copy_virt_area(tcb->pagetable, p->pagetable, 0, 0, p->base_size);
// copy user stack and trap context
copy_virt_area(tcb->pagetable, p->pagetable, TRAP_CONTEXT - USER_STACK_SIZE, TRAP_CONTEXT - USER_STACK_SIZE, TRAMPOLINE); // fill task block
tcb->base_size = p->base_size; tcb->parent = p;
// add children to parent
tlist *x = bd_malloc(sizeof(tlist)); x->tcb = tcb;
LIST_INSERT_HEAD(&p->children, x, entries);
// fill task context
TaskContext *task_cx_ptr = (TaskContext *)tcb->task_cx_ptr;
task_cx_ptr->ra = (usize)trap_return;
memset(task_cx_ptr->s, 0, sizeof(usize) * 12);
// fill trap context
TrapContext *trap_cx = (TrapContext *)PPN2PA(tcb->trap_cx_ppn);
trap_cx->kernel_sp = TRAMPOLINE - tcb->pid * (KERNEL_STACK_SIZE + PAGE_SIZE);
trap_cx->x[10] = 0;
add_task(tcb); return tcb->pid;
}
这里用到了copy_virt_area函数,用来将一个页表中连续的一段虚拟地址的内容复制到另一个页表连续的一段虚拟地址中,复制目标的虚拟地址如果没映射,则自动新建一页物理内存映射后再复制:
void copy_virt_area(PhysPageNum dstp, PhysPageNum srcp, VirtAddr dst_st, VirtAddr src_st, VirtAddr src_en) {
VirtPageNum src_st_vpn = FLOOR(src_st), src_en_vpn = CEIL(src_en);
VirtPageNum dst_st_vpn = FLOOR(dst_st);
for (VirtPageNum i = src_st_vpn; i < src_en_vpn; i++) {
PageTableEntry *src_pte_p = find_pte(srcp, i, 0);
PhysAddr src_pa = PPN2PA(PTE2PPN(*src_pte_p));
PageTableEntry *dst_pte_p = find_pte(dstp, i - src_st_vpn + dst_st_vpn, 1);
if (!(*dst_pte_p & V)) {
PhysPageNum ppn = frame_alloc();
*dst_pte_p = PPN2PTE(ppn, PTE2FLAG(*src_pte_p));
}
PhysAddr dst_pa = PPN2PA(PTE2PPN(*dst_pte_p));
memcpy((char *)dst_pa, (char *)src_pa, PAGE_SIZE);
}
}
exec函数就简单了,因为用户静态内存需要重映射,所以需要先unmap一下用户静态内存,然后调用user_init就行了,同时由于我希望在这里就把程序不存在的错误检查出来,所以先调用一下get_app_data_by_name:
isize exec(char *name) {
if (! get_app_data_by_name(name)) return -1;
TaskControlBlock *p = PROCESSOR.current;
unmap_area(p->pagetable, 0, p->base_size, 1);
user_init(p, name);
}
三大函数完成了,接着就是一些剩余的函数。suspend_current_and_run_next函数比较简单,不表。exit_current_and_run_next函数需要处理一下当前进程的子进程,将它们悉数挂到initproc的子进程下,然后释放用户页表及其映射的除trampoline外所有空间,但是当前进程仍然处在运行当中,所以进程控制块和内核栈不能释放,需要等父进程调用waitpid的时候再释放:
void exit_current_and_run_next(int exit_code) {
TaskControlBlock *tcb = PROCESSOR.current;
tcb->task_status = Zombie;
tcb->exit_code = exit_code;
while (! LIST_EMPTY(&tcb->children)) {
tlist *child = LIST_FIRST(&tcb->children);
child->tcb->parent = initproc;
LIST_REMOVE(child, entries);
LIST_INSERT_HEAD(&initproc->children, child, entries);
}
free_uvm(tcb); usize _unused = 0; schedule(&_unused);
}
free_uvm函数就三句话:释放用户静态空间、释放用户栈和陷入上下文、释放页表,很简单。waitpid就是检查参数的pid有没有和当前进程的某个子进程的pid一样,如果有而且这个子进程已经是zombie进程了,就把子进程的内核栈和进程控制块释放掉,注意pid要单独释放,因为我给pid的管理也像物理页帧一样弄了个回收池:
isize waitpid(isize pid, int *exit_code) {
TaskControlBlock *p = PROCESSOR.current;
tlist *child;
LIST_FOREACH(child, &p->children, entries) {
if (pid == -1 || (usize)pid == child->tcb->pid) {
if (child->tcb->task_status == Zombie) {
*exit_code = child->tcb->exit_code;
pid = child->tcb->pid; // pid may be -1 before
VirtAddr top = TRAMPOLINE - pid * (KERNEL_STACK_SIZE + PAGE_SIZE);
VirtAddr bottom = top - KERNEL_STACK_SIZE;
extern PhysPageNum kernel_pagetable;
unmap_area(kernel_pagetable, bottom, top, 1);
pid_dealloc(pid); bd_free(child->tcb);
LIST_REMOVE(child, entries); bd_free(child);
return pid;
} else return -2;
}
}
return -1;
}
最后是一些实现上的点:
进程管理需要用到队列,我从系统的头文件目录找到了一个queue.h,里面实现了队列、环形队列、单双端链表等数据结构。这个文件的好处一个是完全没有调用标准库,网上的数据结构代码就是老要调用标准库,还得自己手改,太麻烦了;一个是所有内容就是一个头文件,想用直接include就行了,不用编译链接,比较方便。具体用法可以直接在终端man queue。我也把物理页帧管理、pid管理、进程控制块里的子进程管理用到的链表换成了这个头文件里的实现。
rCore里shell程序读取命令是通过用户态调用getchar来实现的,因为目前的实现里时钟中断只会在用户态里被触发,所以可能会出现这样的情况:shell调用getchar以后,内核一直阻塞在等待字符的过程,这时输入一个字符之后,会返回用户态,而距离上一次在用户态调用getchar的时刻已经过了很久,会发生时钟中断,控制权让给其他程序,这时其他程序输出了点什么,在用户看来输入的字符回显完全淹没在其他程序的输出中,体验不好。所以我干脆添加了一个gets系统调用,让shell在接收用户输入的时候就一直陷在内核态里,不会被打断,直到用户按下回车键才返回,这样即使返回以后其他程序输出什么,至少用户已经输入了一个完整的命令,不会看起来那么难受。
目前内核本身运行其实是串行的,还是因为目前的实现里时钟中断只会在用户态里被触发。这就给了我偷懒的机会,内核中所有地方都没有实现锁机制。rust语言层面支持,借助库可以直接上,所以rCore是有的,而C语言则只能自己实现(当然也可以借鉴xv6)。我打算后面如果把时钟中断扩展到内核态或者让qemu启用多核运行模式时再对这一块进行处理。