操作系统实现(二):分页和物理内存管理
上一篇从 Bootloader 开始到内核载入使用的都是平坦内存,即所有地址对应实际的物理地址。现代操作系统都使用分页来管理内存,分页可以让每个进程都有完整的虚拟地址空间,进程间的虚拟地址空间相互隔离以提供页层级的保护。另外分页可以让物理内存少于虚拟地址空间,同时可以使用磁盘存储暂时未使用的内存页,提供更多的「内存」。
分页
分页通过 CPU 的 MMU(Memory Management Unit) 完成,MMU 通过当前的分页表完成虚拟地址到物理地址的转换。在 x86 下 MMU 通过两级分页表(也可以开启三级)完成地址转换,这两级分别是页目录(Page Directory)和页表(Page Table)。在 x86 下,由 cr3 寄存器存储页目录的地址(物理地址),页目录和页表都包含 1024 项,每项 4 字节,因此页目录和页表大小为 4KB ,按照 4KB 一页的话,刚好占用一页。
MMU 将虚拟地址转换成物理地址的方式是,取虚拟地址的 22~31bits 表示页目录的下标,获得页目录项定位到页表,再取 12~21bits 表示页表的下标,获得页表项定位到页,最后取 0~11bits 表示页偏移。页目录项和页表项的下标分别用 10bits 表示,刚好最大 1024 项,页内偏移用 12bits 表示,刚好 4KB。
页目录项结构如下:
其中 S 表示页大小是 4KB 还是 4MB,P 表示页表是否在内存中,如果在内存中,那么 12~31 bits 存储了 4KB 对齐的页表地址(同样是物理地址),其它 bit 的含义请参考这里。
页表项结构如下:
同样的,P 表示此页是否在内存中,如果在内存中,12~31 bits 存储了页的地址。
我们知道了页目录和页表的结构,准备好页目录和页表,就可以开启分页了,开启分页只需把页目录地址放到 cr3 寄存器中,并把 cr0 的最高 bit 置 1。通过页目录项,我们可以发现页表不需要都存在内存当中,当访问一个虚拟地址,它对应的页表或者页不存在内存中时会触发 Page Fault 异常,我们可以在异常处理函数中完成页表或者页的分配,理论上开启分页只需要准备好页目录。
分页前后
准备好页目录页表,设置 cr3 和 cr0,开启了分页之后,内核的所有地址都变成了虚拟地址,所有的地址都要通过 MMU 映射到物理地址再访问内存。这一变化是需要小心注意的,开启分页前,访问的所有地址是物理地址,开启分页之后,所有的地址都变成了虚拟地址,因此,如果分页由内核来完成,那么内核就需要考虑到前后的变化,即有一部分代码运行在物理地址下,其它代码都运行在虚拟地址下;如果分页由 Bootloader 完成,那么 Bootloader 需要注意这个变化,并正确跳转到内核,让内核完整运行在虚拟地址下。
上一篇我把内核展开到从 0x100000 开始的物理内存中,编译链接内核的时候也把代码段的地址指定到 0x100000 的地址。开启分页之后,内核一般运行在高地址(比如 Linux 内核地址从 0x80000000 开始,Windows 从 0xC0000000 开始),而内核同样是展开到从 0x100000 开始的物理内存中。我选择把内核的虚拟地址链接到从 0xC0100000 开始,并把这个虚拟地址映射到 0x100000 的物理地址,开启分页之前运行的代码,凡是涉及到地址的操作,我都会把虚拟地址调整为物理地址再操作,开启分页之后,所有虚拟地址就可以正常运行了。
物理内存管理
操作系统采用分页方式管理内存,因此物理内存的管理也需按照页的方式管理,在 Page Fault 异常触发时,在异常处理函数中分配新的物理页并把它映射到分页表中。这里牵涉到空闲物理内存页的分配和释放,我们很容易想到一种直观的方法,把所有空闲内存页用链表串联起来,分配释放一页只需对链表进行操作。这种方式管理对进程的物理页分配简单有效,但是对内核本身使用的内存分配释放会导致内存利用率不高,因为这种方式管理的最大连续内存是一页,而内核中经常会分配大对象,连续多页的物理内存有更好的利用率。Linux 采用 Buddy memory allocation 方式管理物理内存,使用 Slab/Slub 管理内核对象的分配释放。
我的实现也采用 Buddy 方式管理物理内存,把空闲内存页用多层级的 Buddy 方式管理,分别是 order 0 ~ order 10,表示 2^order 页连续内存页块,即 order 0 管理单页的空闲内存块,order 10 管理连续 1024 页的空闲内存块。分配内存时,算出最佳的 order,在相应的 order 层级里分配一块内存块,如果当前 order 中没有可用的空闲内存块,就向 order + 1 层级中借一块,并把借来的空闲内存块平分成 2 块 order 层级的空闲内存块,其中一块当作分配结果返回,另一块放入到 order 层级中待以后分配使用。当第 order 块的内存使用完释放时,把这块释放的内存块放入 order 层级时,判断与它相连的同样大小的内存块是否在 order 层级中,如果存在,把它和它的 Buddy 合并成一个 order + 1 的内存块放入到 order + 1的层级中。
内存管理器初始化之前
在内存管理初始化之前,内核没有动态内存分配能力,因此很多时候我们需要使用静态全局变量。内存管理器初始化时,可能会使用到动态内存分配,这就出现鸡与蛋的问题,为了解决这个问题,通常会实现一个简单的 Boot Allocator 用在内存管理器初始化之前分配动态内存。我的实现是从内核展开的末尾位置开始建立一个只分配不释放的 Boot Allocator,等到内存管理器初始化完成之后,Boot Allocator 的使命便完成了。
另外还有一个问题,我们管理物理内存,需要知道安装了多少物理内存,因此我们要探测安装了多少物理内存,这里介绍了几种探测方法,我使用的是 BIOS 的 INT 0x15, EAX = 0xE820 函数,它由 Bootloader 调用完成,最后通过参数把它传递给操作系统内核。