Appearance
内存管理
这一章讲内存管理的实现细节。第三章里我已经在 Loader 中建立了基本的分页机制,把内核加载到了高地址空间。但那只是开始——进入内核后,我需要一套完整的内存管理系统来动态分配和释放内存。
我会从底往上讲:先是位图这个基础数据结构,然后是物理内存池和虚拟内存池的设计,接着是页表操作的实现,再到页分配器,最后是 Arena 机制和 malloc/free 的实现。
内存布局概述
在开始写代码之前,我需要先搞清楚物理内存是怎么划分的。这个布局决定了后面所有内存管理代码的设计。
物理内存划分
我的系统假设总共有 32MB 物理内存。这 32MB 被分成几个区域:
最前面的 1MB(0x00000000 到 0x000FFFFF)是传统的低端内存区域。这里面有 BIOS 数据区、显存、还有我的 MBR 和 Loader 代码。这块内存在 Loader 阶段就已经被使用了,内核不会去动它。
从 1MB 开始(0x00100000)是页表区域。Loader 在这里建立了页目录表和初始的页表,总共占用 256 个页框(1MB)。这块区域也不能被动态分配。
从 2MB 开始(0x00200000)才是真正可以动态分配的物理内存。我把这块内存对半分:前一半给内核用(内核物理内存池),后一半给用户进程用(用户物理内存池)。按 32MB 总内存来算,去掉前面 2MB,剩下 30MB,内核和用户各 15MB。
| 地址范围 | 大小 | 用途 |
|---|---|---|
0x00000000–0x000FFFFF | 1MB | 低端内存 |
0x00100000–0x001FFFFF | 1MB | 页目录表 + 页表 |
0x00200000–0x010FFFFF | 15MB | 内核物理内存池 |
0x01100000–0x01FFFFFF | 15MB | 用户物理内存池 |
物理内存区域划分(32MB)
包括 BIOS 数据区、显存、MBR、Loader 等。

关键地址常量
代码里有几个重要的地址常量:
c++
// 位图存放的起始地址(虚拟地址)
auto constexpr MEM_BITMAP_BASE = 0xc009a000;
// 内核堆的起始地址(虚拟地址)
auto constexpr K_HEAP_START = 0xc0100000;MEM_BITMAP_BASE 是 0xc009a000,这个地址是我算出来的。内核主线程的栈顶在 0xc009f000,PCB 在 0xc009e000,所以位图就放在更前面一点。一个页框(4KB)的位图可以管理 $4096 \times 8 = 32768$ 个页,也就是 128MB 内存。我预留了 4 个页框的位图空间,足够管理 512MB 内存了。
K_HEAP_START 是 0xc0100000,也就是虚拟地址 3GB + 1MB 的位置。低端 1MB 的虚拟地址(0xc0000000 到 0xc00fffff)已经映射到物理内存的低端 1MB 了,所以内核堆要从 1MB 之后开始。
虚拟地址空间
虚拟地址空间的划分在第三章已经讲过了:低 3GB 给用户进程,高 1GB 给内核。内核的虚拟地址从 0xc0000000 开始,堆空间从 0xc0100000 开始向上增长。
位图管理
位图是内存管理的基础数据结构。它用一个 bit 代表一个页框的使用状态:0 表示空闲,1 表示已分配。这样管理 32MB 内存只需要 $32MB / 4KB / 8 = 1KB$ 的位图空间。
位图结构
我定义的 bitmap 结构体很简单:
c++
export struct bitmap
{
u8* bits; // 位图数据
size_t sz; // 位图大小(字节数)
};bits 指向位图数据的起始地址,sz 是位图占用的字节数。如果要管理 $n$ 个页框,位图大小就是 $n / 8$ 字节。
核心操作
我用 C++ 的面向对象设计,这些操作都是 bitmap 的成员函数。位图需要支持三个核心操作:检测位、设置位、扫描连续的空闲位。
检测某一位的状态很简单:
c++
auto bitmap::test(size_t bi) -> bool
{
auto x = bi / 8; // 字节偏移
auto y = bi % 8; // 位偏移
return bits[x] >> y & 1;
}设置某一位的值:
c++
auto bitmap::set(size_t bi, bool value) -> void
{
auto x = bi / 8;
auto y = bi % 8;
if(value) {
bits[x] |= 1 << y; // 置 1
} else {
bits[x] &= ~(1 << y); // 置 0
}
}连续位扫描
分配内存时经常需要找连续的空闲页。scan 函数的思路是:先跳过全 1 的字节(小小的剪枝优化),然后逐位检查找到足够长的连续 0 序列。
c++
auto bitmap::scan(size_t cnt) -> optional<size_t>
{
size_t i = 0;
// 跳过全 1 的字节
while(0xff == bits[i] and i != sz) {
++i;
}
if(i == sz) {
return nullopt; // 没找到
}
// 找到第一个 0 bit
size_t bi = 0;
while(bits[i] >> bi & 1) {
++bi;
}
auto start = i * 8 + bi;
if(cnt == 1) {
return start;
}
// 继续找连续的 0
auto count = size_t{ 1 };
auto it = start + 1;
while(it < sz * 8) {
if(not test(it)) {
++count;
} else {
count = 0;
}
if(count == cnt) {
return it - cnt + 1;
}
++it;
}
return nullopt;
}这个算法的时间复杂度是 $O(n)$,对于内存管理来说足够了。如果需要更高效的实现,可以考虑用 buddy system 或者红黑树。

内存池设计
有了位图之后,我需要在它基础上构建内存池。内存池分两种:物理内存池管理物理页框的分配,虚拟地址池管理虚拟地址的分配。
物理内存池
物理内存池继承自 bitmap,在位图的基础上增加了起始物理地址:
c++
struct pool : bitmap
{
u32 phy_addr_start; // 本池管理的物理内存起始地址
mutex mtx; // 保护并发访问的锁
auto palloc() -> void*; // 分配一个物理页
};我一共创建了两个物理内存池:kernel_pool 和 user_pool。内核线程从 kernel_pool 分配内存,用户进程从 user_pool 分配。
palloc 函数分配一个物理页框:
c++
auto pool::palloc() -> void*
{
mtx.lock();
auto start = scan(1); // 在位图中找一个空闲位
if(not start) {
return nullptr;
}
set(*start, true); // 标记为已使用
mtx.unlock();
auto page_phyaddr = phy_addr_start + *start * PG_SIZE;
return reinterpret_cast<void*>(page_phyaddr);
}虚拟地址池
虚拟地址池同样继承自 bitmap,但管理的是虚拟地址:
c++
export struct virtual_addr : bitmap
{
u32 vaddr_start; // 虚拟地址起始
auto get(u32 pg_cnt) -> void*; // 申请连续虚拟页
};get 函数分配连续的虚拟页:
c++
auto virtual_addr::get(u32 pg_cnt) -> void*
{
auto start = scan(pg_cnt);
if(not start) {
return nullptr;
}
set(*start, *start + pg_cnt, true);
auto vaddr = vaddr_start + *start * PG_SIZE;
return reinterpret_cast<void*>(vaddr);
}内核只有一个虚拟地址池 kernel_vaddr,所有内核线程共享。用户进程则每个都有自己的虚拟地址池,存在进程的 PCB 里。
初始化
内存池的初始化在 mem_pool_init 函数中完成:
c++
auto mem_pool_init(u32 all_mem) -> void
{
auto page_table_size = PG_SIZE * 256; // 页目录 + 页表
auto used_mem = page_table_size + 0x100000; // 加上低端 1MB
auto free_mem = all_mem - used_mem;
auto all_free_pages = free_mem / PG_SIZE;
// 对半分
auto kernel_free_pages = all_free_pages / 2;
auto user_free_pages = all_free_pages - kernel_free_pages;
// 位图长度
auto kbm_length = kernel_free_pages / 8;
auto ubm_length = user_free_pages / 8;
// 物理地址起点
auto kp_start = used_mem;
auto up_start = kp_start + kernel_free_pages * PG_SIZE;
// 初始化内存池
kernel_pool.init((char*)MEM_BITMAP_BASE, kbm_length, kp_start);
user_pool.init((char*)(MEM_BITMAP_BASE + kbm_length),
ubm_length, up_start);
// 内核虚拟地址池
kernel_vaddr.init(
(char*)(MEM_BITMAP_BASE + kbm_length + ubm_length),
kbm_length,
K_HEAP_START
);
}三个位图在内存中紧挨着存放,从 MEM_BITMAP_BASE 开始:先是内核物理池位图,然后是用户物理池位图,最后是内核虚拟地址池位图。

页表操作
分页机制的页表结构在第三章已经建立好了,这里要实现的是运行时动态操作页表的函数。核心问题是:知道一个虚拟地址,怎么找到它对应的页表项?
索引计算
32 位虚拟地址分成三部分:高 10 位是页目录索引,中间 10 位是页表索引,低 12 位是页内偏移。这个划分方式是 x86 分页机制决定的。

页目录有 1024 个表项,每个表项指向一个页表。每个页表也有 1024 个表项,每个表项指向一个 4KB 的物理页。所以总共可以映射 $1024 \times 1024 \times 4KB = 4GB$ 的地址空间,正好覆盖 32 位地址能表示的全部范围。
给定一个虚拟地址,我需要从中提取出页目录索引和页表索引。pde_idx 函数取高 10 位:
c++
auto pgtable::pde_idx(u32 addr) -> u32
{
// 0xffc00000 = 11111111110000000000000000000000b
// 与运算保留高 10 位,右移 22 位得到索引值(0~1023)
return (addr & 0xffc00000) >> 22;
}pte_idx 函数取中间 10 位:
c++
auto pgtable::pte_idx(u32 addr) -> u32
{
// 0x003ff000 = 00000000001111111111000000000000b
// 与运算保留中间 10 位,右移 12 位得到索引值(0~1023)
return (addr & 0x003ff000) >> 12;
}举个例子:虚拟地址 0xc0100000(内核堆起始地址),它的二进制是:
1100_0000_0001_0000_0000_0000_0000_0000
高 10 位是 1100000000(十进制 768),中间 10 位是 0000000001(十进制 1),低 12 位全是 0。所以这个地址通过页目录第 768 项找到页表,再通过页表第 1 项找到物理页。
自映射技巧
现在问题来了:我知道虚拟地址的索引了,但页目录和页表本身也在内存里,它们在哪?怎么访问?
在 Loader 中,我把页目录放在物理地址 0x100000(1MB 处)。但进入保护模式开启分页后,所有内存访问都要经过 MMU 的地址转换。如果我想修改页表,我需要知道页表的虚拟地址。
这里用到一个巧妙的技巧:让页目录的最后一项(第 1023 项)指向页目录自身。这样做有什么效果呢?
当 CPU 访问虚拟地址 0xffc00000 到 0xffffffff 这 4MB 范围时:
高 10 位是 1023,MMU 用它索引页目录,得到的是页目录自身的物理地址
这意味着页目录被当成了"页表"来使用
中间 10 位用来在"页表"(实际是页目录)中索引,得到的是某个真正页表的物理地址
利用这个特性,pte_ptr 函数可以计算任意虚拟地址对应的页表项的虚拟地址:
c++
auto pgtable::pte_ptr(u32 vaddr) -> u32*
{
// 0xffc00000:高 10 位全 1,让 MMU 第一次索引到 PDE[1023]
// (vaddr & 0xffc00000) >> 10:把 vaddr 的高 10 位变成中间 10 位
// 这样 MMU 第二次索引时会找到 vaddr 所属的页表
// pte_idx(vaddr) * 4:在页表中的偏移(每个表项 4 字节)
return reinterpret_cast<u32*>(
0xffc00000 +
((vaddr & 0xffc00000) >> 10) +
pte_idx(vaddr) * 4
);
}类似地,pde_ptr 函数计算页目录项的虚拟地址。这次我让 MMU 两次都索引到 1023,这样最终访问的就是页目录本身:
c++
auto pgtable::pde_ptr(u32 vaddr) -> u32*
{
// 0xfffff000:高 10 位和中间 10 位都是全 1
// MMU 两次索引都用 1023,最终得到页目录的虚拟地址 0xfffff000
// 再加上 pde_idx(vaddr) * 4 就是对应 PDE 的地址
return reinterpret_cast<u32*>(
0xfffff000 + pde_idx(vaddr) * 4
);
}这个自映射技巧让我可以在任何时候、任何进程上下文中访问和修改页表,而不需要关心页表的物理地址在哪。
虚拟地址转物理地址
有了上面的函数,把虚拟地址转成物理地址就很简单了。页表项的格式是:高 20 位存物理页框的基地址,低 12 位存各种标志位。所以只需要取出高 20 位,再加上虚拟地址的页内偏移:
c++
auto pgtable::addr_v2p(u32 vaddr) -> u32
{
auto pte = pte_ptr(vaddr);
// *pte & 0xfffff000:取 PTE 的高 20 位,得到物理页框地址
// vaddr & 0x00000fff:取虚拟地址的低 12 位,即页内偏移
return (*pte & 0xfffff000) + (vaddr & 0x00000fff);
}这个函数在调试和内存释放时很有用。比如释放一块虚拟内存时,我需要先通过这个函数找到对应的物理页框,然后把物理页框还给物理内存池。

检测映射是否存在
在添加新的页表映射之前,我需要检查 PDE 或 PTE 是否已经存在。x86 的页表项用最低位(P 位,Present)表示该项是否有效:
c++
auto pgtable::contains(u32* addr) -> bool
{
return *addr & 0x00000001; // 检查 P 位
}如果 P 位是 0,说明这个页表项还没建立,访问它会触发缺页异常。在分配新页面时,我会先检查 PDE 是否存在,如果不存在就先分配一个页表,然后再填充 PTE。
页分配器
有了内存池和页表操作函数,就可以实现页分配器了。分配一块内存需要三个步骤:从虚拟地址池分配虚拟地址、从物理内存池分配物理页框、在页表中建立映射。这三个步骤缺一不可。
核心分配函数
malloc_page 是页分配的核心函数。它接受两个参数:内存池类型(内核还是用户)和需要分配的页数。函数的返回值是分配到的虚拟地址,如果分配失败则返回 nullptr。
c++
auto malloc_page(pool_flags pf, u32 pg_cnt) -> void*
{
// 第一步:从虚拟地址池分配连续的虚拟页
// 虚拟地址必须是连续的,这样上层才能把它当作连续的内存使用
auto vaddr_start = get_vaddr(pf, pg_cnt);
if(not vaddr_start) {
return nullptr;
}
auto vaddr = reinterpret_cast<u32>(vaddr_start);
auto& pool = get_pool(pf);
// 第二步:逐页分配物理内存并建立映射
// 注意:虚拟地址是连续的,但物理地址可以不连续
// 这正是分页机制的优势——可以把不连续的物理内存映射成连续的虚拟内存
for(auto i = 0; i < pg_cnt; ++i) {
auto page_phyaddr = pool.palloc();
if(not page_phyaddr) {
// TODO: 分配失败需要回滚已分配的页
return nullptr;
}
// 第三步:在页表中建立虚拟地址到物理地址的映射
page_table_add((void*)vaddr, page_phyaddr);
vaddr += PG_SIZE; // 移动到下一个虚拟页
}
return vaddr_start;
}这里有个重要的细节:虚拟地址是一次性分配连续的,但物理页是逐个分配的。每分配一个物理页,就立即建立对应的页表映射。这样即使物理内存不连续,上层代码看到的虚拟内存空间依然是连续的。

页表项添加
page_table_add 函数负责在页表中建立映射关系。这个函数需要处理两种情况:如果页表不存在,要先创建页表。
c++
auto page_table_add(void* __vaddr, void* __page_phyaddr) -> void
{
auto vaddr = reinterpret_cast<u32>(__vaddr);
auto page_phyaddr = reinterpret_cast<u32>(__page_phyaddr);
// 获取这个虚拟地址对应的 PDE 和 PTE 的地址
auto pde = pgtable::pde_ptr(vaddr);
auto pte = pgtable::pte_ptr(vaddr);
// 检查 PDE 是否存在
// 如果 PDE 不存在,说明对应的页表还没创建
if(not pgtable::contains(pde)) {
// 从内核物理内存池分配一个页框作为新页表
// 注意:页表必须从内核池分配,不能用用户池
// 因为页表是内核数据结构,必须常驻内存
auto pde_phyaddr = kernel_pool.palloc();
if(pde_phyaddr == 0) {
PANIC("page_table_add: kernel_pool.palloc failed");
}
// 填充 PDE:物理地址 + 属性位
// PG_US_U:用户可访问 PG_RW_W:可写 PG_P_1:存在
*pde = (u32)pde_phyaddr | PG_US_U | PG_RW_W | PG_P_1;
// 新分配的页表必须清零!
// 否则里面可能有垃圾数据,会导致随机的内存映射
// pte 指向的是页表的第一项,把高 20 位清零后加上 000 就是页表起始地址
memset((void*)((u32)pte & 0xfffff000), 0, PG_SIZE);
}
// 现在 PDE 肯定存在了,检查 PTE 是否已经被占用
ASSERT(not pgtable::contains(pte));
// 填充 PTE:物理页地址 + 属性位
*pte = page_phyaddr | PG_US_U | PG_RW_W | PG_P_1;
}封装函数
为了方便使用,我封装了两个高层函数。它们加了互斥锁保护,并且会把分配的内存清零:
c++
auto get_kernel_pages(u32 pg_cnt) -> void*
{
// 获取锁,保证多线程安全
auto lcg = lock_guard{ kernel_alloc_mtx };
auto vaddr = malloc_page(pool_flags::KERNEL, pg_cnt);
if(vaddr) {
// 把分配的内存清零
// 这既是安全考虑(防止信息泄露),也是使用习惯(很多代码假设新内存是零)
memset(vaddr, 0, pg_cnt * PG_SIZE);
}
return vaddr;
}
auto get_user_pages(u32 pg_cnt) -> void*
{
auto lcg = lock_guard{ user_alloc_mtx };
auto vaddr = malloc_page(pool_flags::USER, pg_cnt);
memset(vaddr, 0, pg_cnt * PG_SIZE);
return vaddr;
}页释放
释放页面是分配的逆过程:先找到物理地址,回收物理页框,清除页表映射,最后回收虚拟地址。
c++
auto mfree_page(pool_flags pf, void* _vaddr, size_t pg_cnt) -> void
{
auto vaddr = (u32)_vaddr;
for(auto i = 0; i < pg_cnt; ++i) {
// 通过页表找到这个虚拟地址对应的物理地址
auto pg_phy_addr = pgtable::addr_v2p(vaddr);
// 1. 把物理页框还给物理内存池
pfree(pg_phy_addr);
// 2. 清除页表项,并刷新 TLB
page_table_pte_remove(vaddr);
vaddr += PG_SIZE;
}
// 3. 把虚拟地址还给虚拟地址池
vaddr_remove(pf, _vaddr, pg_cnt);
}清除页表项时有个重要的步骤——刷新 TLB。TLB 是 CPU 内部的页表缓存,如果不刷新,CPU 可能还会用旧的映射关系,导致访问到已经释放的物理内存:
c++
auto page_table_pte_remove(u32 vaddr) -> void
{
auto pte = pgtable::pte_ptr(vaddr);
*pte &= ~PG_P_1; // 把 P 位置 0,表示该映射无效
// invlpg 指令刷新 TLB 中特定地址的缓存条目
asm volatile("invlpg %0" : : "m"(vaddr) : "memory");
}Arena 与小块分配
页分配器每次至少分配一个页(4KB),对于小内存请求来说太浪费了。比如分配一个 32 字节的结构体,如果直接分配一整页,利用率只有 $32 / 4096 = 0.78\%$。为了解决这个问题,我实现了 Arena 机制:把一页内存切成多个小块来分配。
内存块描述符
我预定义了 7 种规格的小内存块:16B、32B、64B、128B、256B、512B、1024B。每种规格用一个 mem_block_desc 结构体来描述:
c++
export struct mem_block_desc
{
u32 block_size; // 这种规格的块大小
u32 block_per_arena; // 一个 arena 能容纳多少这种块
list free_list; // 空闲块的链表
};
auto constexpr DESC_CNT = 7; // 7 种规格为什么选择 16B 起步、每级翻倍?这是经过权衡的设计:
16B 是最小的有意义大小,因为
mem_block结构体本身需要存一个链表节点(8 字节),加上对齐考虑,16B 是合理的下限每级翻倍可以保证内部碎片不超过 50%(申请 17B 分配 32B,浪费 15B)
1024B 是合理的上限,更大的请求直接分配整页更划算
初始化时,需要计算每种规格在一页中能切多少块:
c++
auto block_desc_init(mem_block_desc* desc_array) -> void
{
u16 sz = 16; // 从 16B 开始
for(auto i = 0; i < DESC_CNT; ++i) {
desc_array[i].block_size = sz;
// 一页减去 arena 头的空间,剩下的除以块大小
// 结果向下取整,保证不会越界
desc_array[i].block_per_arena = (PG_SIZE - sizeof(arena)) / sz;
desc_array[i].free_list.init(); // 初始化空闲链表
sz *= 2; // 下一级翻倍
}
}以 32B 块为例:$(4096 - 12) / 32 = 127$,一个 arena 可以切出 127 个 32B 的块。
Arena 结构
Arena 就是实际分配的一页内存。页的开头是一个管理头,记录元信息;后面的空间被切成等大的块:
c++
export struct arena
{
mem_block_desc* desc; // 指向对应的描述符
u32 cnt; // 如果 large=true,表示占用的页数
// 否则表示剩余的空闲块数
bool large; // 是否大块分配(> 1024B)
// 计算第 idx 个块的地址
auto block(size_t idx) -> mem_block*
{
// arena 头之后是连续的块,每个块大小相同
return (mem_block*)((u32)this + sizeof(arena) + idx * desc->block_size);
}
};large 字段区分两种用法:
large = false:小块分配模式,cnt表示这个 arena 还剩多少空闲块large = true:大块分配模式,cnt表示这次分配占用了多少页
每个空闲的块里存着一个链表节点,用于串在 free_list 上:
c++
export struct mem_block
{
list::node free_elem; // 链表节点,串到 free_list 上
};当块被分配出去时,这个链表节点就被用户数据覆盖了,这是节省空间的常见技巧。
从内存块找回 Arena
释放内存时,用户只给了块的地址,我怎么知道它属于哪个 Arena?
答案很简单:Arena 总是页对齐的,把地址的低 12 位清零就能得到 Arena 的起始地址:
c++
auto ofarena(mem_block* b) -> arena*
{
// 0xfffff000 = 清除低 12 位
// 这样就回到了页的起始位置,也就是 arena 头的位置
return (arena*)((u32)b & 0xfffff000);
}这个技巧之所以可行,是因为 malloc_page 分配的内存总是页对齐的。

malloc/free 实现
有了页分配器和 Arena 机制,就可以实现通用的 malloc 和 free 了。
malloc 实现
malloc 的逻辑分两个分支:大块直接分配页框,小块从 Arena 的空闲链表取。
c++
auto malloc(size_t size) -> void*
{
auto cur_thread = running_thread();
auto pf = pool_flags{};
auto desc = (mem_block_desc*){};
// 判断是内核线程还是用户进程
if(cur_thread->pgdir == nullptr) {
pf = pool_flags::KERNEL;
desc = k_block_descs;
} else {
pf = pool_flags::USER;
desc = cur_thread->u_block_desc;
}
auto lcg = lock_guard{ get_mutex(pf) };
// 大块分配:直接分页
if(size > 1024) {
auto page_cnt = div_ceil(size + sizeof(arena), PG_SIZE);
auto a = (arena*)malloc_page(pf, page_cnt);
if(a == nullptr) return nullptr;
memset(a, 0, page_cnt * PG_SIZE);
a->desc = nullptr;
a->cnt = page_cnt;
a->large = true;
return (void*)(a + 1); // 跳过 arena 头
}
// 小块分配:从 Arena 取
auto i = 0;
for(; i < DESC_CNT; ++i) {
if(size <= desc[i].block_size) break;
}
auto& list = desc[i].free_list;
// 如果没有空闲块,创建新的 Arena
if(list.empty()) {
auto a = (arena*)malloc_page(pf, 1);
memset(a, 0, PG_SIZE);
a->desc = &desc[i];
a->cnt = desc[i].block_per_arena;
a->large = false;
// 把所有块加入空闲链表
for(auto k = 0; k < desc[i].block_per_arena; ++k) {
list.push_back(&a->block(k)->free_elem);
}
}
// 从链表取一个块
auto block = list.front();
list.pop_front();
auto b = container_of(block, mem_block, free_elem);
auto a = ofarena(b);
--a->cnt;
return (void*)b;
}free 实现
free 也是两个分支:大块直接释放页框,小块放回空闲链表。
c++
auto free(void* ptr) -> void
{
if(ptr == nullptr) return;
auto pf = pool_flags{};
if(running_thread()->pgdir == nullptr) {
pf = pool_flags::KERNEL;
} else {
pf = pool_flags::USER;
}
auto lcg = lock_guard{ get_mutex(pf) };
auto b = (mem_block*)ptr;
auto a = ofarena(b);
// 大块:直接释放页框
if(a->desc == nullptr and a->large) {
mfree_page(pf, a, a->cnt);
return;
}
// 小块:放回空闲链表
auto& list = a->desc->free_list;
list.push_back(&b->free_elem);
// 如果这个 Arena 全空,释放整页
if(++a->cnt == a->desc->block_per_arena) {
for(auto i = 0; i < a->desc->block_per_arena; ++i) {
list.erase(&a->block(i)->free_elem);
}
mfree_page(pf, a, 1);
}
}设计权衡
这个实现有几个特点:
小块分配使用固定规格(16B 到 1024B,每级翻倍),会有一定的内部碎片。比如申请 17 字节,实际分配 32 字节。这是用空间换时间的权衡。
Arena 全空时会释放整页,这样可以避免长期占用不用的内存。但如果反复分配释放,可能会频繁创建销毁 Arena,有一定开销。
大块分配时,Arena 头也占用空间。申请 4096 字节实际需要 2 页,因为 Arena 头和数据加起来超过了 1 页。
上机测试
理论讲完了,现在来验证内存管理系统是否正常工作。我写了一个测试函数,覆盖 malloc/free 的主要使用场景。
测试代码
测试函数在 kernel/main.cpp 中实现:
c++
auto test_malloc_free() -> void
{
console::println("=== Testing malloc/free ===");
// 测试1: 小块分配 (16B ~ 1024B,走 Arena)
console::println("[1] Small block allocation (Arena):");
auto p1 = malloc(32);
auto p2 = malloc(64);
auto p3 = malloc(128);
console::println(" malloc(32) = 0x{x}", (u32)p1);
console::println(" malloc(64) = 0x{x}", (u32)p2);
console::println(" malloc(128) = 0x{x}", (u32)p3);
// 测试2: 大块分配 (>1024B,直接分页)
console::println("[2] Large block allocation (Page):");
auto p4 = malloc(2048);
auto p5 = malloc(4096);
console::println(" malloc(2048) = 0x{x}", (u32)p4);
console::println(" malloc(4096) = 0x{x}", (u32)p5);
// 测试3: 写入数据验证
console::println("[3] Write/Read verification:");
auto arr = (int*)malloc(10 * sizeof(int));
for(int i = 0; i < 10; ++i) {
arr[i] = i * 100;
}
console::print(" Data: ");
for(int i = 0; i < 10; ++i) {
console::print("{} ", arr[i]);
}
console::println();
// 测试4: 释放内存
console::println("[4] Free memory:");
free(p1);
free(p2);
free(p3);
free(p4);
free(p5);
free(arr);
console::println(" All freed successfully!");
// 测试5: 释放后重新分配(验证复用)
console::println("[5] Reallocation after free:");
auto p6 = malloc(32);
auto p7 = malloc(64);
console::println(" malloc(32) again = 0x{x}", (u32)p6);
console::println(" malloc(64) again = 0x{x}", (u32)p7);
free(p6);
free(p7);
console::println("=== malloc/free test PASSED ===");
}这个测试覆盖了 5 个场景:
小块分配:分配 32B、64B、128B,这些小于 1024B 的请求会走 Arena 路径
大块分配:分配 2048B、4096B,这些大于 1024B 的请求会直接分配整页
读写验证:分配一个 int 数组,写入数据后读出来,验证内存确实可用
内存释放:释放所有分配的内存,测试 free 功能
地址复用:释放后再分配同样大小的内存,如果返回相同地址说明复用机制生效
运行结果
在 Bochs 中运行内核,控制台输出如下:

如果看到 "=== malloc/free test PASSED ===" 说明内存管理系统工作正常。
结果分析
从输出的地址可以观察到几个特点:
小块分配的地址:32B、64B、128B 的分配结果地址间隔很近,都在同一页内。这是因为它们来自不同规格的 Arena,但 Arena 本身都是从内核堆连续分配的。
大块分配的地址:2048B 和 4096B 的地址是页对齐的(低 12 位是 0 或接近 0),因为大块分配会跳过 arena 头直接返回数据区域。
地址复用:释放后重新分配 32B 和 64B,地址应该和第一次分配相同或很接近。这说明释放的内存块被正确地放回了 free_list,下次分配时优先复用。