你好,我是 LMOS。
在前面的课程中,我们建立了物理内存页面管理器,它既可以分配单个页面,也可以分配多个连续的页面,还能指定在特殊内存地址区域中分配页面。
但你发现没有,物理内存页面管理器一次分配至少是一个页面,而我们对内存分页是一个页面 4KB,即 4096 字节。对于小于一个页面的内存分配请求,它无能为力。如果要实现小于一个页面的内存分配请求,又该怎么做呢?
这节课我们就一起来解决这个问题。课程配套代码,你可以从这里获得。 malloc 给我们的启发
首先,我想和你说说,为什么小于一个页面的内存我们也要格外珍惜?
如果你在大学学过 C 程序设计语言的话,相信你对 C 库中的 malloc 函数也不会陌生,它负责完成分配一块内存空间的功能。
下面的代码。我相信你也写过,或者写过类似的,不用多介绍你也可以明白。
这个代码流程很简单,就是分配一块 15 字节大小的内存空间,然后把字符串复制到分配的内存空间中,最后用字符串的形式打印了那个块内存,最后释放该内存空间。
但我们并不是要了解 malloc、free 函数的工作原理,而是要清楚,像这样分配几个字节内存空间的操作,这在内核中比比皆是。
页还能细分吗
是的,单从内存角度来看,页最小是以字节为单位的。但是从 MMU 角度看,内存是以页为单位的,所以我们的 Cosmos 的物理内存分配器也以页为单位。现在的问题是,内核中有大量远小于一个页面的内存分配请求,如果对此还是分配一个页面,就会浪费内存。
要想解决这个问题,就要细分“页”这个单位。虽然从 MMU 角度来看,页不能细分,但是从软件逻辑层面页可以细分,但是如何分,则十分讲究。
结合历史经验和硬件特性(Cache 行大小)来看,我们可以把一个页面或者连续的多个页面,分成 32 字节、64 字节、128 字节、256 字节、512 字节、1024 字节、2048 字节、4096 字节(一个页)。这些都是 Cache 行大小的倍数。我们给这些小块内存取个名字,叫内存对象。
我们可以这样设计:把一个或者多个内存页面分配出来,作为一个内存对象的容器,在这个容器中容纳相同的内存对象,即同等大小的内存块。你可以把这个容器,想像成一个内存对象数组。为了让你更好理解,我还给你画了张图解释。

内存对象视图
如何表示一个内存对象
前面只是进行了理论上的设计和构想,下面我们就通过代码来实现这些构想,真正把想法变成现实。
我们从内存对象开始入手。如何表示一个内存对象呢?当然是要设计一个表示内存对象的数据结构,代码如下所示:
typedef struct s_FREOBJH
{
list_h_t oh_list;
uint_t oh_stus;
void* oh_stat;
}freobjh_t;
我们在后面的代码中就用 freobjh_t 结构表示一个对象,其中的链表是为了找到这个对象。是不是很简单?没错,表示一个内存对象就是如此简单。
内存对象容器
光有内存对象还不够,如何放置内存对象是很重要的。根据前面的构想,为了把多个同等大小的内存对象放在一个内存对象容器中,我们需要设计出表示内存对象容器的数据结构。内存容器要占用内存页面,需要内存对象计数信息、内存对象大小信息,还要能扩展容量。
把上述功能综合起来,代码如下所示。
这段代码中设计了四个数据结构:kmsob_t 用于表示内存对象容器,kmbext_t 用于表示内存对象容器的扩展内存,msomdc_t 和 msclst_t 用于管理内存对象容器占用的物理内存页面。
你可能很难理解它们之间的关系,所以我为你准备了一幅图,如下所示。

内存对象容器关系
结合图示我们可以发现,在一组连续物理内存页面(用来存放内存对象)的开始地址那里,就存放着我们 kmsob_t 和 kmbext_t 的实例变量,它们占用了几十字节的空间。
初始化
因为 kmsob_t、kmbext_t、freobjh_t 结构的实例变量,它们是建立内存对象容器时创建并初始化的,这个过程是伴随着分配内存对象而进行的,所以内存对象管理器的初始化很简单。
但是有一点还是要初始化的,那就是管理 kmsob_t 结构的数据结构,它用于挂载不同大小的内存容器。现在我们就在 cosmos/hal/x86/ 目录下建立一个 kmsob.c 文件,来实现这个数据结构并初始化,代码如下所示。
上面的代码注释已经很清楚了,就是 init_kmsob 函数调用 kmsobmgrhed_t_init 函数,在其中循环初始化 koblst_t 结构体数组,不多做解释。
但是有一点我们要搞清楚:kmsobmgrhed_t 结构的实例变量是放在哪里的,它其实放在我们之前的 memmgrob_t 结构中了,代码如下所示。
HAL_DEFGLOB_VARIABLE(memmgrob_t,memmgrob);
typedef struct s_MEMMGROB
{
list_h_t mo_list;
spinlock_t mo_lock;
uint_t mo_stus;
uint_t mo_flgs;
kmsobmgrhed_t mo_kmsobmgr;
void* mo_privp;
void* mo_extp;
}memmgrob_t;
void init_memmgr()
{
init_msadsc();
init_memarea();
init_search_krloccupymm(&kmachbsp);
init_memmgrob();
init_kmsob();
return;
}
这并没有那么难,是不是?到这里,我们在内存管理初始化 init_memmgr 函数中调用了 init_kmsob 函数,对管理内存对象容器的结构进行了初始化,这样后面我们就能分配内存对象了。
分配内存对象
根据前面的初始化过程,我们只是初始化了 kmsobmgrhed_t 结构,却没初始化任何 kmsob_t 结构,而这个结构就是存放内存对象的容器,没有它是不能进行任何分配内存对象的操作的。
下面我们一起在分配内存对象的过程中探索,应该如何查找、建立 kmsob_t 结构,然后在 kmsob_t 结构中建立 freobjh_t 结构,最后在内存对象容器的容量不足时,一起来扩展容器的内存。
分配内存对象的接口
分配内存对象的流程,仍然要从分配接口开始。分配内存对象的接口很简单,只有一个内存对象大小的参数,然后返回内存对象的首地址。下面我们先在 kmsob.c 文件中写好这个函数,代码如下所示。
上面代码中,内存对象分配接口很简单,只是对分配内存对象的大小进行检查,然后调用分配内存对象的核心函数,在这个核心函数中,就是围绕我们之前定义的几个数据结构,去进行一系列操作了。
但是究竟做了哪些操作呢,别急,我们继续往下看。
查找内存对象容器
根据前面的设计,我们已经知道内存对象是放在内存对象容器中的,所以要分配内存对象,必须要先根据要分配的内存对象大小,找到内存对象容器。
同时,我们还知道,内存对象容器数据结构 kmsob_t 就挂载在 kmsobmgrhed_t 数据结构中的 ks_msoblst 数组中,所以我们要遍历 ks_msoblst 数组,我们来写一个 onmsz_retn_koblst 函数,它返回 ks_msoblst 数组元素的指针,表示先根据内存对象的大小找到挂载 kmsob_t 结构对应的 koblst_t 结构。
上述代码非常好理解,就是通过 onmsz_retn_koblst 函数,它根据内存对象大小查找并返回 ks_msoblst 数组元素的指针,这个数组元素中就挂载着相应的内存对象容器,然后由 onkoblst_retn_newkmsob 函数查询其中的内存对象容器并返回。
建立内存对象容器
不知道你发现没有,有一种情况必然会发生,那就是第一次分配内存对象时调用 onkoblst_retn_newkmsob 函数,它肯定会返回一个 NULL。因为第一次分配时肯定没有 kmsob_t 结构,所以我们在这个时候建立一个 kmsob_t 结构,即建立内存对象容器。
下面我们写一个 _create_kmsob 函数来创建 kmsob_t 结构,并执行一些初始化工作,代码如下所示。
_create_kmsob 函数就是根据分配内存对象大小,建立一个内存对象容器。
首先,这个函数会找物理内存页面管理器申请一块连续内存页面。然后,在其中的开始部分建立 kmsob_t 结构的实例变量,又在 kmsob_t 结构的后面建立 freobjh_t 结构数组,并把每个 freobjh_t 结构挂载到 kmsob_t 结构体中的 so_frelst 中。最后再把 kmsob_t 结构,挂载到 kmsobmgrhed_t 结构对应的 koblst_t 结构中去。
上面的注释已经很清楚了,我相信你看得懂。
扩容内存对象容器
如果我们不断重复分配同一大小的内存对象,那么那个内存对象容器中的内存对象,迟早要分配完的。一旦内存对象分配完,内存对象容器就没有空闲的内存空间产生内存对象了。这时,我们就要为内存对象容器扩展内存空间了。
下面我们来写代码实现,如下所示。
有了前面建立内存对象容器的经验,加上这里的注释,我们理解上述代码并不难:不过是分配了另一块连续的内存空间,作为空闲的内存对象,并且把这块内存空间加内存对象容器中统一管理。
分配内存对象
有了内存对象容器,就可以分配内存对象了。由于我们前面精心设计了内存对象容器、内存对象等数据结构,这使得我们的内存对象分配代码时极其简单,而且性能极高。
下面我们来实现它吧!代码如下所示。
分配内存对象的核心操作就是,kmsob_new_opkmsob 函数从空闲内存对象链表头中取出第一个内存对象,返回它的首地址。这个算法非常高效,无论内存对象容器中的内存对象有多少,kmsob_new_opkmsob 函数的操作始终是固定的,而如此高效的算法得益于我们先进的数据结构设计。
好了,到这里内存对象的分配就已经完成了,下面我们去实现内存对象的释放。
释放内存对象
释放内存对象,就是要把内存对象还给它所归属的内存对象容器。其逻辑就是根据释放内存对象的地址和大小,找到对应的内存对象容器,然后把该内存对象加入到对应内存对象容器的空闲链表上,最后看一看要不要释放内存对象容器占用的物理内存页面。
释放内存对象的接口
这里我们依然要从释放内存对象的接口开始实现,下面我们在 kmsob.c 文中写下这个函数,代码如下所示。
上述代码中,等到 kmsob_delete 函数检查参数通过之后,就调用释放内存对象的核心函数 kmsob_delete_core,在这个函数中,一开始根据释放内存对象大小,找到挂载其 kmsob_t 结构的 koblst_t 结构,接着又做了一系列的操作,这些操作正是我们接下来要实现的。
查找内存对象容器
释放内存对象,首先要找到这个将要释放的内存对象所属的内存对象容器。释放时的查找和分配时的查找不一样,因为要检查释放的内存对象是不是属于该内存对象容器。
下面我们一起来实现这个函数,代码如下所示。
上面的代码注释已经很明白了,搜索对应 koblst_t 结构中的每个 kmsob_t 结构体,随后进行检查,检查了 kmsob_t 结构的自身内存区域和扩展内存区域。即比较释放内存对象的地址是不是落在它们的内存区间中,其大小是否合乎要求。
释放内存对象
如果不出意外,会找到释放内存对象的 kmsob_t 结构,这样就可以释放内存对象了,就是把这块内存空间还给内存对象容器,这个过程的具体代码实现如下所示。
结合上述代码和注释,我们现在明白了 kmsob_delete_onkmsob 函数调用 kmsob_del_opkmsob 函数。其核心机制就是把要释放内存对象的空间,重新初始化,变成一个 freobjh_t 结构的实例变量,最后把这个 freobjh_t 结构加入到 kmsob_t 结构中空闲链表中,这就实现了内存对象的释放。
销毁内存对象容器
如果我们释放了所有的内存对象,就会出现空的内存对象容器。如果下一次请求同样大小的内存对象,那么这个空的内存对象容器还能继续复用,提高性能。
但是你有没有想到,频繁请求的是不同大小的内存对象,那么空的内存对象容器会越来越多,这会占用大量内存,所以我们必须要把空的内存对象容器销毁。
下面我们写代码实现销毁内存对象容器。
上述代码中,首先会检查一下内存对象容器是不是空闲的,如果空闲,就调用销毁内存对象容器的核心函数 _destroy_kmsob_core。在 _destroy_kmsob_core 函数中,首先要释放内存对象容器的扩展空间所占用的物理内存页面,最后才可以释放内存对象容器自身占用物理内存页面。
请注意。这个顺序不能前后颠倒,这是因为扩展空间的物理内存页面对应的 msadsc_t 结构,它就挂载在 kmsob_t 结构的 so_mc.mc_lst 数组中。
好了,到这里我们内存对象释放的流程就完成了,这意味着我们整个内存对象管理也告一段落了。
重点回顾
今天我们从 malloc 函数入手,思考内核要怎样分配大量小块内存。我们把物理内存页面进一步细分成内存对象,为了表示和管理内存对象,又设计了内存对象、内存对象容器等一系列数据结构,随后写代码把它们初始化,最后我们依赖这些数据结构实现了内存对象管理算法。
下面我们来回顾一下这节课的重点。
1. 我们发现,在应用程序中可以使用 malloc 函数动态分配一些小块内存,其实这样的场景在内核中也是比比皆是。比如,内核经常要动态创建数据结构的实例变量,就需要分配小块的内存空间。
2. 为了实现内存对象的表示、分配和释放功能,我们定义了内存对象和内存对象容器的数据结构 freobjh_t、kmsob_t,并为了管理 kmsob_t 结构又定义了 kmsobmgrhed_t 结构。
3. 我们写好了初始化 kmsobmgrhed_t 结构的函数,并在 init_kmsob 中调用了它,进而又被 init_memmgr 函数调用,由于 kmsobmgrhed_t 结构是为了管理 kmsob_t 结构的所以在一开始就要被初始化。
4. 我们基于这些数据结构实现了内存对象的分配和释放。
思考题
为什么我们在分配内存对象大小时要按照 Cache 行大小的倍数分配呢?
欢迎你在留言区分享你的思考或疑问。如果这节课对你有帮助,也欢迎你分享给自己的同事、朋友,跟他一起交流讨论。
好,我是 LMOS,我们下节课见!