Skip to content

内存管理

这一章讲内存管理的实现细节。第三章里我已经在 Loader 中建立了基本的分页机制,把内核加载到了高地址空间。但那只是开始——进入内核后,我需要一套完整的内存管理系统来动态分配和释放内存。

我会从底往上讲:先是位图这个基础数据结构,然后是物理内存池和虚拟内存池的设计,接着是页表操作的实现,再到页分配器,最后是 Arena 机制和 malloc/free 的实现。

内存布局概述

在开始写代码之前,我需要先搞清楚物理内存是怎么划分的。这个布局决定了后面所有内存管理代码的设计。

物理内存划分

我的系统假设总共有 32MB 物理内存。这 32MB 被分成几个区域:

最前面的 1MB(0x000000000x000FFFFF)是传统的低端内存区域。这里面有 BIOS 数据区、显存、还有我的 MBR 和 Loader 代码。这块内存在 Loader 阶段就已经被使用了,内核不会去动它。

从 1MB 开始(0x00100000)是页表区域。Loader 在这里建立了页目录表和初始的页表,总共占用 256 个页框(1MB)。这块区域也不能被动态分配。

从 2MB 开始(0x00200000)才是真正可以动态分配的物理内存。我把这块内存对半分:前一半给内核用(内核物理内存池),后一半给用户进程用(用户物理内存池)。按 32MB 总内存来算,去掉前面 2MB,剩下 30MB,内核和用户各 15MB。

地址范围大小用途
0x000000000x000FFFFF1MB低端内存
0x001000000x001FFFFF1MB页目录表 + 页表
0x002000000x010FFFFF15MB内核物理内存池
0x011000000x01FFFFFF15MB用户物理内存池

物理内存区域划分(32MB)

包括 BIOS 数据区、显存、MBR、Loader 等。

物理内存布局

关键地址常量

代码里有几个重要的地址常量:

c++
// 位图存放的起始地址(虚拟地址)
auto constexpr MEM_BITMAP_BASE = 0xc009a000;

// 内核堆的起始地址(虚拟地址)
auto constexpr K_HEAP_START = 0xc0100000;

MEM_BITMAP_BASE0xc009a000,这个地址是我算出来的。内核主线程的栈顶在 0xc009f000,PCB 在 0xc009e000,所以位图就放在更前面一点。一个页框(4KB)的位图可以管理 $4096 \times 8 = 32768$ 个页,也就是 128MB 内存。我预留了 4 个页框的位图空间,足够管理 512MB 内存了。

K_HEAP_START0xc0100000,也就是虚拟地址 3GB + 1MB 的位置。低端 1MB 的虚拟地址(0xc00000000xc00fffff)已经映射到物理内存的低端 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_pooluser_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 分页机制决定的。

32位虚拟地址结构

页目录有 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 访问虚拟地址 0xffc000000xffffffff 这 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;
}

这里有个重要的细节:虚拟地址是一次性分配连续的,但物理页是逐个分配的。每分配一个物理页,就立即建立对应的页表映射。这样即使物理内存不连续,上层代码看到的虚拟内存空间依然是连续的。

malloc_page 分配流程

页表项添加

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 分配的内存总是页对齐的。

Arena 内存切块示意

malloc/free 实现

有了页分配器和 Arena 机制,就可以实现通用的 mallocfree 了。

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 个场景:

  1. 小块分配:分配 32B、64B、128B,这些小于 1024B 的请求会走 Arena 路径

  2. 大块分配:分配 2048B、4096B,这些大于 1024B 的请求会直接分配整页

  3. 读写验证:分配一个 int 数组,写入数据后读出来,验证内存确实可用

  4. 内存释放:释放所有分配的内存,测试 free 功能

  5. 地址复用:释放后再分配同样大小的内存,如果返回相同地址说明复用机制生效

运行结果

在 Bochs 中运行内核,控制台输出如下:

Bochs 中 malloc/free 测试运行结果

如果看到 "=== malloc/free test PASSED ===" 说明内存管理系统工作正常。

结果分析

从输出的地址可以观察到几个特点:

小块分配的地址:32B、64B、128B 的分配结果地址间隔很近,都在同一页内。这是因为它们来自不同规格的 Arena,但 Arena 本身都是从内核堆连续分配的。

大块分配的地址:2048B 和 4096B 的地址是页对齐的(低 12 位是 0 或接近 0),因为大块分配会跳过 arena 头直接返回数据区域。

地址复用:释放后重新分配 32B 和 64B,地址应该和第一次分配相同或很接近。这说明释放的内存块被正确地放回了 free_list,下次分配时优先复用。