系列目录
- 序篇
- 准备工作
- BIOS 启动到实模式
- GDT 与保护模式
- 虚拟内存初探
- 加载并进入 kernel
- 显示与打印
- 全局描述符表 GDT
- 中断处理
- 虚拟内存完善
- 实现堆和 malloc
- 第一个内核线程
- 多线程运行与切换
- 锁与多线程同步
- 进入用户态
- 进程的实现
- 系统调用
- 简单的文件系统
- 加载可执行程序
- 键盘驱动
- 运行 shell
进程 Process
在前面几篇中,我们搭建起了 thread 运行和调度的框架。本篇开始我们将在 thread
之上,实现进程 process
的管理。
关于线程 thread
和 进程 process
的概念和区别应该是老生常谈了,这里也不想赘述这些八股文。对于 scroll 这样一个小项目的实现来讲,thread 是重点,是骨架,因为 thread 才是任务运行的基本单位;而 process 只是更上层的对一个或多个 threads 的封装,它更多地是负责资源的管理,例如 Linux 系统中每个 process 管理的内容包括:
- 虚拟内存;
- 文件描述符;
- 信号机制;
- ......
我们这个项目比较简单,不会涉及复杂文件系统和信号等内容,所以 process 的最主要职责就是对内存的管理,本篇首先定义 process 的结构,然后主要围绕它的两个功能展开:
- user stack 管理;
- page 管理;
在以后的几个篇章中,将会进一步展示 OS 如何加载并运行一个用户可执行程序,这同时将伴随着系统调用 fork
/ exec
等功能的实现,这些都是以 process
为对象进行的操作。
process 结构
定义 process_struct,即 Linux 里所谓的 pcb
(process control block
):
struct process_struct {
uint32 id;
char name[32];
enum process_status status;
// map { tid -> threads }
hash_table_t threads;
// allocate user space stack for threads
bitmap_t user_thread_stack_indexes;
// exit code
int32 exit_code;
// page directory
page_directory_t page_dir;
};
typedef struct process_struct pcb_t;
这里只列出了最重要的一些字段,注释应该写的很清楚。对于目前而言,这样一个简单的结构足以满足需要了。
user stack 分配
上一篇里已经提到过,每个 process 下的多个 threads,它们在 user 空间上拥有自己的 stack,所以 process 就要负责为它的 threads 分配这些 stack 的位置,其实非常简单,这些 stacks 就是在 3GB 的下方附近依次排列:
例如我们规定一个 stack top 的位置,然后每个 stack 规定是 64 KB,这样分配 stack 就非常简单,只需要一个 bitmap
就可以搞定:
#define USER_STACK_TOP 0xBFC00000 // 0xC0000000 - 4MB
#define USER_STACK_SIZE 65536 // 64KB
struct process_struct {
// ...
bitmap_t user_thread_stack_indexes;
// ...
}
可以看到在 create_new_user_thread 函数里,有为 user thread 分配 stack 的过程:
// Find a free index from bitmap.
uint32 stack_index;
yieldlock_lock(&process->lock);
if (!bitmap_allocate_first_free(&process->user_thread_stack_indexes, &stack_index)) {
spinlock_unlock(&process->lock);
return nullptr;
}
yieldlock_unlock(&process->lock);
// Set user stack top.
thread->user_stack_index = stack_index;
uint32 thread_stack_top = USER_STACK_TOP - stack_index * USER_STACK_SIZE;
注意到这里上了锁,因为一个 process 下可能会有多个 threads 竞争。
page 管理
process
的另一个非常重要的工作就是管理该进程的虚拟内存。我们知道虚拟内存是以 process 为单位进行隔离的,每个 process 都会保存自己的 page directory
和 page tables
。在 threads 切换时,如果 thread 所属的 process 发生了改变,那么就需要重新加载 page directory,这在 scheduler 的 context switch 时体现:
void do_context_switch() {
// ...
if (old_thread->process != next_thread->process) {
process_switch(next_thread->process);
}
// ...
}
void process_switch(pcb_t* process) {
reload_page_directory(&process->page_dir);
}
复制 page table
显然每个 process 在创建时都需要创建它自己的 page directory
,不过一般来说除了 kernel 初始化时的几个原始 kernel 进程,新的 process 都是从已有的进程 fork
而来,用户态 process 更是如此。
题外话,不知道你是否想过为什么 process 非得从已有的 fork 出来,难道不能直接凭空创建,然后载入新程序运行吗?我想你应该了解 Linux 下 fork 的使用和编程范式,fork 的结果下面还要判断一下现在自己是在 parent 还是 child 进程,而且大多数情况下都是 fork
+ exec
联合使用,与其这么麻烦,为什么不一个系统调用搞定呢,例如这样:
int create_process(char* program, int argc, char** argv)
它完全可以代替 fork
+ exec
这一对组合。
这里面有 Unix 的历史原因,也有它的设计哲学的考虑,网上可以搜下有很多讨论,有人喜欢有人反对,是一个很难扯的清的问题。既然我们是菜鸟,姑且就仿照 Unix 那样,也用 fork
的方式创建进程。
完整的 fork
实现将在后面的系统调用一篇里详细展开,本篇只讨论 fork 进程中非常重要的一个步骤,即 page table 的复制。我们知道刚 fork 出来的 child 进程一开始和 parent 的虚拟内存是完全一样的,这也是为什么 fork 完后就有了两个几乎一样的进程运行,这里的原因就是 child 的 page table 是从 parent 复制而来,里面的内容是完全一样的,这从节省内存资源来说也是有利的。
然而如果 child 只是 read 内存倒还好,如果发生了 write 操作,那显然父子之间就不可以继续共享这一份内存了,必须分道扬镳,这里就涉及到了虚拟内存的 写时复制(copy-on-write
)技术,这里也会实现之。
本节用到的代码主要是 clone_crt_page_dir 函数。
首先的是创建一个新的 page directory
,大小是一个 page,这里为它分配了一个物理 frame 和一个虚拟 page,注意这个 page 必须是 page aligned,然后手动将为它们建立映射关系。后面操作这个新的 page directory,就可以直接用虚拟地址访问。
int32 new_pd_frame = allocate_phy_frame();
uint32 copied_page_dir = (uint32)kmalloc_aligned(PAGE_SIZE);
map_page_with_frame(copied_page_dir, new_pd_frame);
接下来为新的 page directory 建立 page tables 的映射。我们以前提到过,所有的进程都是共享 kernel 空间的,所以 kernel 空间的 256 个页表是共享的:
因此所有进程的 page directory 中,pde[768] ~ pde[1023]
这 256 个表项都是一样的,只要简单复制过去就可以了。
pde_t* new_pd = (pde_t*)copied_page_dir;
pde_t* crt_pd = (pde_t*)PAGE_DIR_VIRTUAL;
for (uint32 i = 768; i < 1024; i++) {
pde_t* new_pde = new_pd + i;
if (i == 769) {
new_pde->present = 1;
new_pde->rw = 1;
new_pde->user = 1;
new_pde->frame = new_pd_frame;
} else {
*new_pde = *(crt_pd + i);
}
}
然而注意有一个 pde 是特殊的,就是第 769 项。在虚拟内存初探一篇中详细讲解过,第 769 个 pde,也就是 4GB 空间中的第 769 个 4MB 空间,我们将它用来映射 1024 张 page tables 本身,所以第 769 项需要指向该进程的 page directory:
处理完 kernel 空间,接下来就是需要复制 user 空间的 page tables。这里的每张 page table 都需要复制一份出来,然后设置新 page directory 中的 pde 指向它。注意这里只复制了 page table,而没有继续往下复制 page table 所管理的 pages,这样父子进程所使用的虚拟内存实际上就完全一致:
int32 new_pt_frame = allocate_phy_frame();
// Copy page table and set ptes copy-on-write.
map_page_with_frame(copied_page_table, new_pt_frame);
memcpy((void*)copied_page_table,
(void*)(PAGE_TABLES_VIRTUAL + i * PAGE_SIZE),
PAGE_SIZE);
这里对 page table 的复制,和 page directory 一样,我们都是手动分配了物理 frame 和虚拟 page,并且建立映射,所有的内存操作都使用虚拟地址。
接下来是关键的一步,由于父子进程共享了用户空间的所有虚拟内存,但是在 write 时有需要将它们隔离,所以这里引入了 copy-on-write
机制,也就是说暂时将父子的 page table 中的所有有效 pte
都标记为 read-only,谁如果试图进行 write 操作就会触发 page fault
,在 page fault handler 中,将会复制这个 page,然后让 pte 指向这个新复制出来的 page,这样就实现了隔离:
// ...
crt_pte->rw = 0;
new_pte->rw = 0;
// ...
copy-on-write 异常处理
上面已经说到了 copy-on-write
的原理,当触发了 copy-on-write 引起的 page fault 后,就需要在 page fault handler 里解决这个问题,相应的代码在这里。
注意这种类型的 page fault 发生的判断条件是:
if (pte->present && !pte->rw && is_write)
即 page 是存在映射的,但被标记为了 read-only,而且当前引发 page fault 的操作是一个 write 操作。
我们使用了一个全局的 hash table,用来保存 frame 被 fork 过几次,即它当前被多少个 process 所共享。每次进行 copy-on-write
的处理,都会将它的引用计数减 1,如果仍然有引用,则需要 copy;否则说明这是最后一个 process 引用了,则它可以独享这个 frame 了,可以直接将它标记为 rw = true
:
int32 cow_refs = change_cow_frame_refcount(pte->frame, -1);
if (cow_refs > 0) {
// Allocate a new frame for 'copy' on write.
frame = allocate_phy_frame();
void* copy_page = (void*)COPIED_PAGE_VADDR;
map_page_with_frame_impl((uint32)copy_page, frame);
memcpy(copy_page,
(void*)(virtual_addr / PAGE_SIZE * PAGE_SIZE),
PAGE_SIZE);
pte->frame = frame;
pte->rw = 1;
release_pages((uint32)copy_page, 1, false);
} else {
pte->rw = 1;
}
总结
本篇只是 process 的开篇,主要定义了 process 的基本数据结构,实现了 process 对内存的管理功能,这也是在这个项目中 process 最重要的职责之一。后面几篇中我们将开始真正地创建 process,并且将会从磁盘上加载用户可执行文件运行,也就是 fork + exec
系统调用的经典组合。