系列目录

进程 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 里所谓的 pcbprocess 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 directorypage 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 系统调用的经典组合。

03-05 23:59