系列目录

开辟虚拟空间

虚拟内存初探一篇中已经在 loader 阶段初步为 kernel 建立了虚拟内存的框架,包括 page directory,page table 等。在那篇里,我们在 0xC0000000 以上的 kernel 空间已经开辟了它前三个 4MB,并且手工指定了它们的功能:

  • 0xC0000000 ~ 0xC0400000:映射初始低 1MB 内存;
  • 0xC0400000 ~ 0xC0800000:页表;
  • 0xC0800000 ~ 0xC0C00000:kernel 加载;

在这一阶段,所有的内存都是我们手动规划好的,virtual-to-physical 的映射也是手动分配的,这当然不是长久之计。后续的 virutal 内存将会以一种更灵活的方式动态分配,所映射的 physical 内存也不再是提前分配,而是按需取用,这就需要进行缺页异常(page fault)的处理。

缺页异常

page fault 的概念这里不做赘述,我们在上一篇中断处理的最后尝试了触发一个 page fault,但是它的处理函数只是一个 demo,没有做真正解决 page fault 的问题,现在我们就来解决它。

page fault 处理的核心问题有两个:

  • 确定发生 page fault 的 virtual 地址,以及异常的类型;
  • 分配物理 frame,并建立映射;

page fault 详情

第一个问题比较简单,我们直接看代码:

void page_fault_handler(isr_params_t params) {
  // The faulting address is stored in the CR2 register
  uint32 faulting_address;
  asm volatile("mov %%cr2, %0" : "=r" (faulting_address));

  // The error code gives us details of what happened.
  // page not present?
  int present = params.err_code & 0x1;
  // write operation?
  int rw = params.err_code & 0x2;
  // processor was in user-mode?
  int user_mode = params.err_code & 0x4;
  // overwritten CPU-reserved bits of page entry?
  int reserved = params.err_code & 0x8;
  // caused by an instruction fetch?
  int id = params.err_code & 0x10;

  // ...
}
  • 发生 page fault 的地址,储存在了 cr2 寄存器中;
  • page fault 的类型以及其它信息,储存在了 error code 中;

还记得 error code 吗? 上一篇中断处理中提到过,对于某些 exception 发生时,CPU 会自动 压入 error code 到 stack 中,记录 exception 的一些信息,page fault 就是这样一种:

这个 error code 很容易获取到,它就在 page_fault_handler 的参数 isr_params_t 中,还记得这个 struct 吗?它对应的正是图中绿色部分的中断 context 压栈,被当作参数传入粉色的中断 handler 中:

typedef struct isr_params {
  uint32 ds;
  uint32 edi, esi, ebp, esp, ebx, edx, ecx, eax;
  uint32 int_num;
  uint32 err_code;
  uint32 eip, cs, eflags, user_esp, user_ss;
} isr_params_t;

page_fault_handler 里对 error code 做了解析,里面记录了很多有用的信息,对我们来说有两个字段比较重要:

  • present:是否是因为 page 没有分配导致的 page fault?
  • rw:触发 page fault 的指令,对内存的访问操作是否是 write?

它们对应的是 page table 表项的两个 bit 位:

  • present 容易理解,它如果是 0,说明该 page 没有被映射到物理 frame 上,那么会引起 page fault,这是 page fault 的最常见原因;
  • 但是即使 present 位为 1,但是 rw 位为 0,如果此时对内存做写操作,也会引发 page fault,我们需要对这种情况做特殊处理;这会在后面的进程 fork 中用到的 copy-on-write 技术里详解;

分配物理 frame

physical 内存的分配之前我们都是手动规划好的,整整齐齐,目前用完了 0 ~ 3MB 的空间。但是从后面开始,剩下的 frames 我们需要建立数据结构来管理它们,需求无非是两个:

  • 分配 frame;
  • 归还 frame;

因此需要一个数据结构来记录下哪些 frame 已经被分配了,哪些还可用,这里使用了 bitmap 来完成这项工作 。bitmap 希望你并不陌生,它的原理非常简单朴实,就是用一连串 bit 位,每个 bit 位代表一个 true / false,我们这里就用它来表示 frame 是否已经被使用。

当然作为一个一穷二白的 kernel 项目,bitmap 需要我们自己实现,我的简单实现代码在 src/utils/bitmap.c,你可以看到 src/utils 目录下有我实现的各种数据结构,这在后面都会用到。

typedef struct bit_map {
  uint32* array;
  int array_size;  // size of the array
  int total_bits;
} bitmap_t;

我的 bitmap 非常简单,使用一个 int 数组作为存储:

分配时也非常简单粗暴,就是从 0 开始一个个找,找到为止。当然它有最坏 O(N) 的时间复杂度,不过性能暂时还不是我们这个项目需要考虑的因素,我们现在的目标就是简单,正确。

而且这是一个蛋鸡问题:我们的 bitmap 是用于解决 page fault 的,在 page fault 还没有解决,以及基于 heap 的动态分配内存还没实现的情况下,要实现一个复杂的数据结构是很麻烦的。复杂的数据结构势必涉及到动态分配内存,而一旦动态分配,则随时会再次引发 page fault,那我们又回到了原点。

所以一个简单,能提前分配好静态内存的数据结构对我们来说是最简单高效的实现方式。这里 bitmap,以及它内部的 array 数组是我们在 src/mem/paging.c 里定义的全局变量,它们已经被编译在 kernel 内部,属于 databss 段,分配内存的问题当然无需考虑。

static bitmap_t phy_frames_map;
static uint32 bitarray[PHYSICAL_MEM_SIZE / PAGE_SIZE / 32];

注意数组长度为 PHYSICAL_MEM_SIZE / PAGE_SIZE / 32,应该不难理解。

因此分配 frame 的问题就非常简单了,就是关于 bitmap 的操作而已:

int32 allocate_phy_frame() {
  uint32 frame;
  if (!bitmap_allocate_first_free(&phy_frames_map, &frame)) {
    return -1;
  }
  return (int32)frame;
}

void release_phy_frame(uint32 frame) {
  bitmap_clear_bit(&phy_frames_map, frame);
}

处理 page fault

万事具备,接下来处理 page fault 的问题其实已经水到渠成。page_fault_handler 会调用 map_page 函数:

page_fault_handler
  --> map_page
    --> map_page_with_frame
      --> map_page_with_frame_impl

最终来到 map_page_with_frame_impl 这个函数,这个函数略长,但逻辑是很简单的,这里以伪代码为它注释:

find pde (page directory entry)
if pde.present == false:
    allocate page table

find pte (page table entry)
if frame not allocated:
    allocate physical frame

map virtual-to-physical in page table

pdepte 的数据结构定义在了 src/mem/paging,h,有了 C 语言的帮助一切都变得很方便,不用像之前在 loader 里那样对着一个个 bit 位眼花缭乱了 : )

注意到代码里,我们对 page direcotrypage tables 的访问,全部使用了 virtual 地址,这是之前在虚拟内存初探一篇中重点讲解过的:

  • page directory 的地址为 0xC0701000
  • page tables 共 1024 张,在地址空间 0xC0400000 ~ 0xC0800000 依次排列;

进程 fork 的虚拟内存处理

代码里还涉及到了对 rwcopy-on-write 的处理,以及对当前进程的整个虚拟内存的复制,这都是后面在进程系统调用 fork 时用到的。简单来说进程 fork 时,需要对虚拟内存做几件事:

  • 复制整个 page directorypage tables,这样新 process 的内存空间实际上是老 process 的一个镜像;
  • 新老进程的 kernel 空间共享,user 空间的内存用 copy-on-write 机制隔离;

本篇不做展开,到后面进程系统调用 fork 时再把这个坑填上。

03-05 23:58