6

从零开始写 OS 内核 - 进程的实现

 3 years ago
source link: https://segmentfault.com/a/1190000040384269
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

进程 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,这样就实现了隔离:

// Mark copy-on-write: increase copy-on-write ref count.
for (int j = 0; j < 1024; j++) {
  pte_t* crt_pte = (pte_t*)(PAGE_TABLES_VIRTUAL + i * PAGE_SIZE) + j;
  pte_t* new_pte = (pte_t*)copied_page_table + j;
  if (!new_pte->present) {
    continue;
  }
  crt_pte->rw = 0;
  new_pte->rw = 0;
  int32 cow_refs = change_cow_frame_refcount(new_pte->frame, 1);
}

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 系统调用的经典组合。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK