从零开始写 OS 内核 - 实现堆和 malloc

系列目录

  • 序篇
  • 准备工作
  • BIOS 启动到实模式
  • GDT 与保护模式
  • 虚拟内存初探
  • 加载并进入 kernel
  • 显示与打印
  • 全局描述符表 GDT
  • 中断处理
  • 虚拟内存完善
  • 实现堆和 malloc
  • 创建第一个内核线程
  • 多线程运行与切换
  • 锁与多线程同步
  • 进程的实现
  • 进入用户态
  • 一个简单的文件系统
  • 加载可执行程序
  • 系统调用的实现
  • 键盘驱动
  • 运行 shell
黑盒 malloc
到目前为止,所有的内存都是静态分配的,这显然不是一种高效的做法,不可能满足后续 kernel 开发的需求,动态分配内存也就是 malloc 是我们必须要实现的功能。
想必你在以前 C 编程里用过 malloc,知道它是用来分配一块指定大小的内存返回给你,用完还得手动 free;如果你对它还感到神秘,不知道它内部到底做了什么事情的,或者说连它分配出来的内存大概在什么地方都不知道的,甚至连 malloc 这个单词是什么的缩写都不知道的,那需要反省一下了。
哈别紧张,这个项目的意义就在于带你审视这些底层知识和原理。malloc 的确是我们平时一直用,但可能从未真正详察过的东西,这一篇将解读并实现一个简单的 malloc 库函数。
堆 heap
malloc 分配的内存是在 堆 heap 上的,这里的 heap 不是数据结构里讲的大顶堆小顶堆那种,它只是一块单纯的内存区域而已,例如在用户空间的 heap 位于 stack 和程序加载区域之间:
从零开始写 OS 内核 - 实现堆和 malloc
文章图片

而在 kernel 空间要实现 malloc,同样需要在一大块 heap 上进行操作。回到我们的 kernel 空间,它目前的状态是这样:
从零开始写 OS 内核 - 实现堆和 malloc
文章图片

前三个 4MB 已经被占用,从 0xC0C00000 开始是自由空间,我们不妨就将 kernel 的 heap 空间从这里开始划定,到某处结束,即图中粉红色区域,以后这里就是我们挖内存的快乐星球。
heap 上的 malloc heap 是一个大池子,malloc 做的事情就是在 heap 里圈地盘挖内存,例如你需要 32 bytes 内存,它就在 heap 上找一段还没有被使用的,长度为 32 bytes 的区域给你,就是这样而已。
从零开始写 OS 内核 - 实现堆和 malloc
文章图片

这看上去非常简单,然而实际上却是一个很庞大复杂的课题。这里有几个核心问题需要解决:
  • 建立一个数据结构管理内存块的分配和释放,确保一切都正确有序;
  • 速度快,内存碎片少;
先看第一个问题,建立一个数据结构来管理这块内存,这并不是像它看上的那么简单。这里的数据结构并不是在别的地方额外建立的,而是本身就坐落在 heap 上的,也就是说它是内建的,因为这仍然是一个蛋鸡问题。通常意义上的数据结构,例如链表,树,hash 表之类的,一般都是需要动态分配内存的,但是 heap 的本意就是用来解决动态分配内存的问题的,这样又回到了原点。所以管理 heap 自己的数据结构,是在 heap 内部自我编织的,这与以后要讲的的磁盘文件系统很类似。
对于第二个问题,本质上就是性能问题,这并不是我们这个项目的重点。我们首先需要保证的是正确性,否则一切性能都无从谈起。关于内存动态分配管理的问题实在是一个太宏大复杂的课题,有各种各样的技术论文和实现方式在讨论这些问题,光是 C 标准库里 malloc 实现就及其复杂了。我们限于时间和自身水平,不会在这方面深挖,而是采用一种最简单的实现方式。
kmalloc 设计思路
这里加了一个 k,叫 kmalloc,也就是 kernel malloc,表示这是 kernel 空间的 malloc 函数,核心 API 有这几个:
void* kmalloc(uint32 size); void* kmalloc_aligned(uint32 size); void free(void* ptr);

其中 kmalloc_aligned 表示分配出来的内存块,起始地址是 page aligned 的,这在 kernel 开发中是一个常见需求。
我这里采用的实现方式,是照搬了之前推荐的教程 JamesM's kernel development tutorials 里的方法,因为这确实应该是最简单弱智的方式了。不过上面教程里的代码我实测下来应该是有问题的,所以我使用它的思路完全自己实现了一遍,并且加上了测试,目前来看是没什么大问题,当然如有 bug 在所难免,欢迎指正,代码在 src/mem/kheap.c。
实现思路很简单,就是将所有的空白区域的位置用一个有序数组存起来:
从零开始写 OS 内核 - 实现堆和 malloc
文章图片

这样当你需要分配一块指定大小为 t 的内存时,就从这个数组里找到第一个大于 t 的空白区域,就可以从上面割下一块大小为 t 的区域,然后将剩下的空白部分还回去,并且使数组仍然有序。
其实数组并不强求有序,有序的目的只是为了能更高效地查找合适的空白块。你可以一个个遍历,也可以使用二分查找。
当你要 free 一块已经分配的内存,就将它重新放回数组里就可以了。当然这里也有一些特殊情况,例如 free 的区域的左邻右舍正好也是空白区域,那么可以将它们合并成一个大的空白区域。对于 heap 来讲,当然是更欢迎大块的空白区域,因为大空白的可切分能力更强,这意味着更少的 碎片 (fragment),而碎片是令人讨厌的。
从零开始写 OS 内核 - 实现堆和 malloc
文章图片

因此我们的 heap,可以规划为两个区域:
  1. 上面提到的有序数组 ordered array
  2. 大片的内存区域,用来实际分配内存的,我们称之为内存池 memory pool
从零开始写 OS 内核 - 实现堆和 malloc
文章图片

而内存池中的每一块(白色或者灰色),也不是 100% 完全拿出来给使用者的,它里面也包含了关于这个色块的一些信息:
从零开始写 OS 内核 - 实现堆和 malloc
文章图片

我们将 memory pool 里的每一块区域叫 block,它的放大如上图所示,它有一个 header,定义为:
struct kheap_block_header { uint32 magic; uint8 is_hole; uint32 size; } __attribute__((packed));

其中:
  • magic 是用来标识 header 的;
  • is_hole 表示这个 block 是被使用的(灰色),还是自由的(白色);
  • size 表示的是这个 block 里真正可分配出去的内存区域大小,即图中蓝色斜线部分;
对应的,block 还有一个 footer
struct kheap_block_footer { uint32 magic; kheap_block_header_t* header; } __attribute__((packed));

其中:
  • magic 和 header 里的一样;
  • header 是指向 header 的指针,因为某些情况下我们需要从 footer 出发,定位到 header 的位置;
好了,设计思路部分到此结束,接下来就是实现。
kmalloc 实现
有序数组 ordered_array 首先需要实现 ordered array 的结构,我们可以将它封装为一个抽象类,我的代码实现在 src/utils/ordered_array.c 里,可供参考。
typedef void* type_t; typedef struct ordered_array { type_t array; uint32 size; uint32 max_size; comparator_t comparator; } ordered_array_t;

对于这个类,需要认识到的核心概念是,这是一个指针数组,字段 array 即表示这个数组,因此它是一个泛型,这个类还需要传入一个 comparator 用于为数组排序:
// An comparator function is used to sort elements. // // Returns -1, 0 or 1 if the first element // is <, == or > the second, respectively. typedef int32 (*comparator_t)(type_t, type_t);

具体的实现细节不多说,比较简单,就是插入排序而已;
另外注意这个数组的大小是动态可变的,一开始是 0,随着插入和删除元素,size 不断变化;最大 capacity 由 max_size 控制;
heap 结构 接下来定义 kheap 的结构:
typedef struct kernel_heap { ordered_array_t index; uint32 start_address; uint32 end_address; uint32 size; uint32 max_address; } kheap_t;

这里定义了一些关于 heap 的大小的一些字段:start_addressend_address 表示当前 heap 的起始和结束位置。这里要注意,heap 一开始是比较小的,然后在使用过程中可以逐渐变大。当找不到一块合适的空白区域分配内存时,可以扩展(expand)当前 heap:
从零开始写 OS 内核 - 实现堆和 malloc
文章图片

接下来,就是之前说的 ordered_array,字段命名为 index,用于有序保存所有的空白 block。注意一下这里的 index 初始化的方式,它的内部指针数组 array 起始地址就是整个 heap 的起始地址,kheap_block_comparator 比较的是两个 block 的大小:
kheap_t create_kheap(uint32 start, uint32 end, uint32 max) { kheap_t kheap; // Initialize the index array. kheap.index = ordered_array_create( (type_t*)start, KHEAP_INDEX_NUM, &kheap_block_comparator); // ...make_block(start, end - start - BLOCK_META_SIZE, IS_HOLE); ordered_array_insert(&kheap.index, (type_t)start); // ... }

整个 heap 初始化,只有 1 个大的空白 block:
从零开始写 OS 内核 - 实现堆和 malloc
文章图片

malloc 实现 有了上面的设计和铺垫,malloc 实际上已经水到渠成,只要从 index 数组里找到一个合适的白色 block,在上面切下需要的内存,将它变成一个灰色的 block 分配给用户,然后将剩下的白色部分重新放回 index 数组即可。它的实现核心函数是 alloc。
从零开始写 OS 内核 - 实现堆和 malloc
文章图片

alloc 的难点在于切割后剩下部分的处理,并不是任何情况下剩余的部分都需要返还给 kheap,最起码的,剩余部分的大小需要大于 header + footer,这个应该很好理解。
另外,这里比较值得讨论的是当要求 page_align 时,即调用 kmalloc_aligned,情况会变得复杂。一般情况的 free block 不太可能正好是 page aligned,所以在它内部寻找到第一个 page aligned 的地方,从那里开始切割:
从零开始写 OS 内核 - 实现堆和 malloc
文章图片

虚线标出了 page aligned 的点位。这样的分割带来一个问题就是切割下来部分的前面和后面都可能有余留区域,所以需要将它们也重新返还 kheap。在我的代码实现里,规定了它前面那块预留区域的大小,必须能容纳一个完整的 block(大于 header + footer),否则会到下一个 page aligned 的地方开始切割。
从零开始写 OS 内核 - 实现堆和 malloc
文章图片

free 实现 free 函数的实现相对而言比较简单,需要考虑的地方就是被 free 的 block,如果左邻右舍也是 free 的,那么可以 merge 成一个大的 free block:
从零开始写 OS 内核 - 实现堆和 malloc
文章图片

总结
【从零开始写 OS 内核 - 实现堆和 malloc】这一篇其实算是一个很独立的篇章,即使不放在 kernel 这个大项目里,也可以单独拿出来做一个课题。动态内存分配是水很深的一个课题,我们这里只是采用了一种最简单的实现方式,但即使它是最简单的,要正确地实现它仍然并不容易。我在开发中花了几天时间,并加以大量测试才基本调通。就像实现 paging 那样,kheap 也必须保证绝对的正确,因为在后续的开发中,它将是我们分配内存的主战场。

    推荐阅读