mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2088 字
5 分钟
内存管理:PMM、VMM 与内核堆
2021-06-16

内核目前对内存的使用是静态的——加载到哪就在哪运行,没有分配和释放的能力。这意味着内核连创建一个新进程都做不到,因为新进程需要动态分配内存来存放进程控制块、栈和页表。内存管理是操作系统最核心的子系统之一,本章将实现三个组件来解决这个问题:物理内存管理器(PMM)负责跟踪哪些物理页可用,虚拟内存管理器(VMM)负责建立虚拟地址到物理地址的映射,内核堆分配器(kheap)在虚拟内存之上提供任意大小的 malloc/free 接口。

内存管理概述#

在早期的单任务系统中,所有程序直接访问物理内存,这种方式存在严重的问题:频繁的分配和释放会导致内存碎片化,多个程序可能同时使用相同的内存地址,程序可以直接修改内核或其他程序的内存,也难以实现动态的内存分配和释放。

现代操作系统通过内存管理解决了这些问题。每个进程拥有独立的虚拟地址空间,内存只在真正需要时才分配物理页,页表提供读写权限控制,内核和用户程序可以获得类似 malloc/free 的动态分配接口。

物理内存管理器(PMM)#

操作系统需要跟踪哪些物理页是可用的,哪些已被分配。PMM 使用位图(bitmap)来管理物理内存,每个位代表一个 4KB 页面的状态。位图中的每一位对应一个物理页,位为 0 表示页面空闲,位为 1 表示页面已使用。这是物理内存管理的最底层,为上层提供基础内存分配能力。

graph LR A[物理内存] --> B[位图管理] B --> C[页0<br/>位=1<br/>已用] B --> D[页1<br/>位=0<br/>空闲] B --> E[页2<br/>位=1<br/>已用] B --> F[页3<br/>位=0<br/>空闲] style C fill:#ff6b6b style D fill:#51cf66 style E fill:#ff6b6b style F fill:#51cf66

位图的初始数据来自 BIOS 的 E820 调用,它提供系统的内存映射信息,告诉操作系统哪些区域是可用的 RAM,哪些是保留区域:

E820 内存区域类型:
- Type 1: 可用 RAM(唯一可用于分配的类型)
- Type 2: 保留区域(BIOS、硬件映射)
- Type 3: ACPI 可回收
- Type 4: ACPI NVS
- Type 5: 损坏内存

PMM 的核心数据结构如下:

#define PAGE_SIZE 4096
#define MAX_BITMAP_SIZE_BYTES (128 * 1024) // 管理 4GB 内存
static uint8_t pmm_bitmap[MAX_BITMAP_SIZE_BYTES];
static uint32_t pmm_total_pages; // 总物理页数
static uint32_t pmm_free_pages; // 空闲页数
static uint32_t pmm_last_alloc_index; // Next Fit 策略
// 地址转换宏
#define P2V(paddr) ((uintptr_t)(paddr) + 0xC0000000)
#define V2P(vaddr) ((uintptr_t)(vaddr) - 0xC0000000)

PMM 提供的接口包括:pmm_init(boot_info) 初始化物理内存管理器,pmm_alloc_page() 分配一个物理页,pmm_free_page(paddr) 释放一个物理页,pmm_get_free_page_count() 获取空闲页数。

虚拟内存管理器(VMM)#

物理内存管理器解决了”哪些物理页可用”的问题,但内核和用户程序需要的是虚拟地址。虚拟内存管理器在物理页之上建立了一层地址映射,提供地址空间隔离、按需分页和内存保护。

x86 处理器使用二级页表结构,将 32 位虚拟地址分为三个部分:

虚拟地址(32位):
+--------+--------+--------+
| 页目录 | 页表 | 页内偏移 |
| 索引 | 索引 | |
| 10位 | 10位 | 12位 |
+--------+--------+--------+
位 31-22:页目录索引(PD Index)
位 21-12:页表索引(PT Index)
位 11-0 :页内偏移(Offset)

每个页表项包含 32 位,其中低 20 位存储物理页号,高 12 位为标志位:

+------+------+------+------+-------+-------+------+----------+
| 存在 | 读写 | 用户 | 缓存 | 已访问| 脏位 | 保留 | 物理页号 |
| P | R/W | U/S | 属性 | A | D | | |
+------+------+------+------+-------+-------+------+----------+
bit0 bit1 bit2 bit3 bit5 bit6 bit12-31

本系统的虚拟地址空间布局如下:

graph TD A[虚拟地址空间 0x00000000 - 0xFFFFFFFF] --> B[用户空间<br/>0x00000000 - 0xBFFFFFFF] A --> C[页表映射区<br/>0xC0000000 - 0xC03FFFFF] A --> D[页表区自映射<br/>0xC0400000 - 0xC07FFFFF] A --> E[内核代码区<br/>0xC0800000 - 0xC0BFFFFF] A --> F[内核堆区<br/>0xC0C00000 - 0xDFFFFFFF] A --> G[保留/设备映射<br/>0xE0000000 - 0xFFFFFFFF] style B fill:#51cf66 style E fill:#ffd43b style F fill:#ff922b

VMM 的核心数据结构是页表项:

typedef struct page_table_entry
{
uint32_t present : 1; // 存在位
uint32_t rw : 1; // 读写位
uint32_t user : 1; // 用户/内核位
uint32_t writethrough : 1; // 写穿透
uint32_t cache_disable : 1; // 禁用缓存
uint32_t accessed : 1; // 已访问
uint32_t dirty : 1; // 脏位
uint32_t pat : 1; // 页属性表
uint32_t global : 1; // 全局页
uint32_t avail : 3; // 可用位
uint32_t frame_addr : 20; // 物理页号
} __attribute__((packed)) page_table_entry_t;
// 页表项标志
#define PAGE_PRESENT (1 << 0)
#define PAGE_RW (1 << 1)
#define PAGE_USER (1 << 2)
#define PAGE_KERNEL_FLAGS (PAGE_PRESENT | PAGE_RW)

VMM 提供的接口包括:vmm_init() 初始化虚拟内存管理器,vmm_map_page(virt_addr, phys_addr, flags) 映射页面,vmm_unmap_page(virt_addr) 取消映射,vmm_get_phys_addr(virt_addr) 虚拟地址转物理地址,vmm_page_fault_handler() 页错误处理(按需分页)。

内核堆分配器#

VMM 提供了页级别的映射能力,但程序需要的是任意大小的内存分配。堆分配器在 VMM 之上实现了 malloc/free 接口,支持比页粒度更细的内存分配。内核需要动态内存分配来创建各种数据结构,包括进程控制块、文件缓冲区等。

每个分配的内存块由三部分组成:块头、用户数据区和块尾。

+-----------------+-----------------+-----------------+
| 块头 (Header) | 用户数据区 | 块尾 (Footer) |
| 9 bytes | size bytes | 8 bytes |
+-----------------+-----------------+-----------------+
Header 结构:
struct kheap_block_header {
uint32_t magic; // 魔数用于验证
uint8_t is_hole; // 1=空闲,0=已分配
uint32_t size; // 用户数据区大小
};
Footer 结构:
struct kheap_block_footer {
uint32_t magic;
struct kheap_block_header *header; // 指向块头
};

分配算法使用 First-Fit 策略,在有序数组中查找合适的空闲块:先在有序数组中查找最小的足够大的空闲块,如果找到则分割块并返回,如果找不到则扩展堆空间。

释放时检查相邻块,如果都是空闲的则合并:

graph LR A[释放前] --> B[已分配] B --> C[释放块] C --> D[空闲块] E[释放后] --> F[已分配] F --> G[合并后的大空闲块] style B fill:#ff6b6b style C fill:#ff922b style D fill:#51cf66 style G fill:#51cf66

堆分配器的核心数据结构:

typedef struct kernel_heap
{
ordered_array_t index; // 空闲块索引(按大小排序)
uint32_t start_address; // 堆起始虚拟地址
uint32_t end_address; // 堆当前结束地址
uint32_t size; // 堆当前大小
uint32_t max_address; // 堆最大地址
uint8_t supervisor; // 内核态标志
uint8_t readonly; // 只读标志
} kheap_t;
#define KHEAP_START 0xC0C00000
#define KHEAP_MIN_SIZE 0x300000 // 3MB
#define KHEAP_MAX 0xE0000000

堆分配器提供的接口包括:init_kheap() 初始化内核堆,kmalloc(size) 分配内存,kmalloc_aligned(size) 分配页对齐的内存,kfree(ptr) 释放内存。

启动信息(Boot Info)#

内核启动时需要知道物理内存布局、内核大小等信息。Boot Info 结构由 Loader 填充,传递给内核,这是 Loader 和内核之间的主要通信桥梁。

typedef struct e820_entry
{
uint64_t addr; // 起始地址
uint64_t size; // 区域大小
uint32_t type; // 类型:1=可用RAM
uint32_t acpi; // ACPI 扩展属性
} __attribute__((packed)) e820_entry_t;
typedef struct boot_info
{
uint32_t magic;
uint32_t e820_count;
e820_entry_t e820_map[128];
kernel_section_info_t kernel_sections;
} boot_info_t;

内存管理整体架构#

下面展示 PMM、VMM 和内核堆之间的关系:

graph TB A[Boot Loader] -->|E820 内存映射| B[Boot Info] B --> C[PMM<br/>物理内存管理器] C -->|分配物理页| D[VMM<br/>虚拟内存管理器] C -->|地址转换| E[页表] D -->|按需分页| F[Page Fault<br/>中断 14] D -->|虚拟地址| G[内核堆<br/>kheap] G -->|扩展堆| D H[kmalloc/kfree] --> G I[vmm_map_page] --> D style C fill:#ffd43b style D fill:#ff922b style G fill:#ff6b6b style F fill:#51cf66

代码实现#

08.kernel-mm/
├── boot/
│ ├── mbr.S # 主引导记录
│ └── loader.S # 加载器
├── kernel/
│ ├── include/
│ │ ├── types.h # 基础类型
│ │ ├── vga.h # VGA 驱动
│ │ ├── interrupt.h # 中断接口
│ │ ├── timer.h # 定时器
│ │ ├── gdt.h # GDT
│ │ ├── pmm.h # 物理内存管理
│ │ ├── vmm.h # 虚拟内存管理
│ │ ├── kheap.h # 内核堆
│ │ ├── boot_info.h # 启动信息
│ │ ├── bitmap.h # 位图操作
│ │ └── ordered_array.h # 有序数组
│ ├── mem/
│ │ ├── pmm.c # PMM 实现
│ │ ├── vmm.c # VMM 实现
│ │ ├── kheap.c # 堆分配器
│ │ ├── boot_info.c # 启动信息处理
│ │ └── gdt.c # GDT
│ ├── interrupt/
│ │ ├── interrupt.c # 中断处理
│ │ └── interrupt.S # 中断汇编
│ ├── lib/
│ │ ├── string.c # 字符串函数
│ │ └── ordered_array.c # 有序数组
│ └── kernel.c # 内核入口
└── Makefile

PMM 初始化#

PMM 初始化是内存管理的第一步,它根据 E820 内存映射初始化位图。

void pmm_init(boot_info_t *boot_info)
{
uint64_t max_phys_addr = 0; // 物理地址空间上限
uint64_t max_ram_addr = 0; // 可分配内存上限
// 1. 遍历所有 E820 条目,计算物理地址空间的上限
for (uint32_t i = 0; i < boot_info->e820_count; ++i) {
e820_entry_t *entry = &boot_info->e820_map[i];
uint64_t entry_end = entry->addr + entry->size;
if (entry_end > max_phys_addr) {
max_phys_addr = entry_end;
}
}
// 2. 遍历 E820 中的 RAM 条目,计算可分配内存的上限
for (uint32_t i = 0; i < boot_info->e820_count; ++i) {
e820_entry_t *entry = &boot_info->e820_map[i];
if (entry->type == 1) { // 只关心 RAM
uint64_t entry_end = entry->addr + entry->size;
if (entry_end > max_ram_addr) {
max_ram_addr = entry_end;
}
}
}
// 3. 设置关键变量
pmm_total_pages = (uint32_t)(max_phys_addr / PAGE_SIZE);
pmm_max_ram_page = (uint32_t)(max_ram_addr / PAGE_SIZE);
pmm_bitmap_size_bytes = (pmm_total_pages + 7) / 8;
// 4. 检查位图空间
if (pmm_bitmap_size_bytes > MAX_BITMAP_SIZE_BYTES) {
vga_printf("PMM: Bitmap space insufficient!");
PANIC();
}
// 5. 初始化位图为"所有页空闲"
memset(pmm_bitmap, 0x00, pmm_bitmap_size_bytes);
pmm_free_pages = pmm_total_pages;
// 6. 标记所有非 RAM 区域为已使用
for (uint32_t i = 0; i < boot_info->e820_count; ++i) {
e820_entry_t *entry = &boot_info->e820_map[i];
if (entry->type != 1) {
pmm_mark_region_used(entry->addr, entry->size);
}
}
// 7. 标记内核自身占用的区域
pmm_mark_region_used(boot_info->kernel_sections.kernel_phys_base,
boot_info->kernel_sections.kernel_size);
// 8. 保留低 2MB 内存(包含 BIOS 数据区和页表)
pmm_mark_region_used(0, LOW_MEMORY_SIZE);
}

初始化过程先计算物理地址空间上限来确定位图大小,再计算可分配内存上限来限制分配搜索范围,然后将非 RAM 区域、内核区域和低内存标记为已使用,分配时使用 Next Fit 策略优化性能。

物理页分配#

uint32_t pmm_alloc_page(void)
{
if (pmm_free_pages == 0 || pmm_max_ram_page == 0)
return 0; // 内存耗尽
// Next Fit 策略:从上次分配的位置开始搜索
uint32_t start = pmm_last_alloc_index % pmm_max_ram_page;
uint32_t i = start;
do {
// 跳过低 2MB 区域
if (i >= (LOW_MEMORY_SIZE / PAGE_SIZE) && !pmm_test_bit(i)) {
pmm_set_bit(i); // 标记为已使用
pmm_free_pages--; // 更新空闲页计数
pmm_last_alloc_index = i; // 记录分配位置
return i * PAGE_SIZE; // 返回物理地址
}
i = (i + 1) % pmm_max_ram_page;
} while (i != start);
return 0; // 未找到可用页
}

分配使用 Next Fit 策略,从上次分配的位置继续搜索,跳过低 2MB 保留区域,返回物理页地址(页号 × 页大小)。

虚拟内存映射#

bool_t vmm_map_page(uint32_t virt_addr, uint32_t phys_addr, uint32_t flags)
{
// 获取页表项
page_table_entry_t *page = get_page(virt_addr);
if (page == NULL) {
vga_printf("VMM: Failed to map page 0x%x. Page table not present.\n", virt_addr);
return false; // 页表不存在
}
// 设置页表项
page->frame_addr = phys_addr >> 12;
page->present = (flags & PAGE_PRESENT) ? 1 : 0;
page->rw = (flags & PAGE_RW) ? 1 : 0;
page->user = (flags & PAGE_USER) ? 1 : 0;
page->writethrough = (flags & PAGE_WRITETHROUGH) ? 1 : 0;
page->cache_disable = (flags & PAGE_CACHE_DISABLE) ? 1 : 0;
// 刷新 TLB
invalidate_page(virt_addr);
return true;
}

映射函数利用自映射机制访问任意页表,设置页表项的各个标志位,然后刷新 TLB 确保更改生效。

页错误处理与按需分页#

void vmm_page_fault_handler(interrupt_frame_t *frame)
{
// 1. 从 CR2 寄存器读取导致故障的线性地址
uint32_t faulting_addr;
asm volatile("mov %%cr2, %0" : "=r"(faulting_addr));
// 2. 检查地址有效性(内核不应该访问低内存)
if (faulting_addr < KERNEL_LOAD_VIRTUAL_ADDR) {
vga_printf("\n!!!!! KERNEL PAGE FAULT (Null Pointer?) !!!!!\n");
vga_printf("Faulting address: 0x%x\n", faulting_addr);
PANIC();
}
// 3. 对齐地址到页边界
uint32_t aligned_addr = PAGE_ALIGN_DOWN(faulting_addr);
// 4. 按需分配页面
if (!vmm_alloc_and_map_page(aligned_addr, PAGE_KERNEL_FLAGS)) {
vga_printf("Page fault: Out of memory.");
PANIC();
}
}

CR2 寄存器存储导致页错误的虚拟地址,处理函数检查地址是否在内核虚拟地址空间内,然后按需分配物理页并映射到虚拟地址,这实现了内核堆的按需分页机制。

堆内存分配#

void *alloc(kheap_t *this, uint32_t size, uint8_t page_align)
{
uint32_t alloc_pos;
int32_t iterator = find_hole(this, size, page_align, &alloc_pos);
if (iterator < 0) {
// 没有合适的空闲块,需要扩展堆
uint32_t old_end_address = this->end_address;
uint32_t extended_size = kheap_expand(this, size + BLOCK_META_SIZE);
// 检查最后一个块是否是空闲块
kheap_block_footer_t *last_footer = (kheap_block_footer_t *)(old_end_address - FOOTER_SIZE);
kheap_block_header_t *last_header = last_footer->header;
if (last_header->is_hole) {
// 扩展最后一个空闲块
make_block((uint32_t)last_header, last_header->size + extended_size, IS_HOLE);
ordered_array_remove_element(&this->index, last_header);
ordered_array_insert(&this->index, last_header);
} else {
// 在末尾添加新的空闲块
kheap_block_header_t *new_last_header =
make_block(old_end_address, extended_size - BLOCK_META_SIZE, IS_HOLE);
ordered_array_insert(&this->index, (type_t)new_last_header);
}
// 扩展后重试分配
return alloc(this, size, page_align);
}
kheap_block_header_t *header = (kheap_block_header_t *)ordered_array_get(&this->index, iterator);
uint32_t block_size = header->size;
ordered_array_remove(&this->index, iterator);
// 处理页对齐需求
if (page_align) {
kheap_block_header_t *alloc_block_header = (kheap_block_header_t *)(alloc_pos - HEADER_SIZE);
if (alloc_block_header > header) {
uint32_t cut_block_size = (uint32_t)alloc_block_header - (uint32_t)header;
if (cut_block_size > BLOCK_META_SIZE) {
make_block((uint32_t)header, cut_block_size - BLOCK_META_SIZE, IS_HOLE);
ordered_array_insert(&this->index, (type_t)header);
block_size -= cut_block_size;
header = alloc_block_header;
}
}
}
// 使用此块
uint32_t remain_size = block_size - size;
if (remain_size <= BLOCK_META_SIZE) {
size = block_size; // 剩余空间太小,不分割
remain_size = 0;
}
make_block((uint32_t)header, size, NOT_HOLE);
// 如果有剩余空间,创建新的空闲块
if (remain_size > BLOCK_META_SIZE) {
kheap_block_header_t *remain_hole_header = make_block(
(uint32_t)header + BLOCK_META_SIZE + size, remain_size - BLOCK_META_SIZE, IS_HOLE);
ordered_array_insert(&this->index, (type_t)remain_hole_header);
}
return (void *)(alloc_pos);
}

分配使用 First-Fit 策略查找合适的空闲块,如果没有合适的块则扩展堆空间。它支持页对齐分配,如果块足够大则分割为已分配块和空闲块。

堆内存释放与合并#

void free(kheap_t *this, void *ptr)
{
if (ptr == NULL) {
return;
}
kheap_block_header_t *header = (kheap_block_header_t *)((uint32_t)ptr - HEADER_SIZE);
kheap_block_footer_t *footer = (kheap_block_footer_t *)((uint32_t)ptr + header->size);
// 标记为空闲
header->is_hole = 1;
kheap_block_header_t *new_hole = header;
// 向右合并
kheap_block_header_t *right_header = (kheap_block_header_t *)((uint32_t)footer + FOOTER_SIZE);
if (right_header->magic == KHEAP_MAGIC && right_header->is_hole) {
make_block((uint32_t)header, header->size + right_header->size + BLOCK_META_SIZE, IS_HOLE);
ordered_array_remove_element(&this->index, right_header);
}
// 向左合并
kheap_block_footer_t *left_footer = (kheap_block_footer_t *)((uint32_t)header - FOOTER_SIZE);
if (left_footer->magic == KHEAP_MAGIC && left_footer->header->is_hole == 1) {
kheap_block_header_t *left_header = left_footer->header;
make_block((uint32_t)left_header, left_header->size + header->size + BLOCK_META_SIZE, IS_HOLE);
ordered_array_remove_element(&this->index, left_header);
new_hole = left_header;
}
ordered_array_insert(&this->index, (type_t)new_hole);
}

释放时标记块为空闲,检查右侧和左侧块是否空闲,如果空闲则合并,最后将合并后的块插入有序数组。

地址转换流程#

下面展示虚拟地址到物理地址的完整转换过程:

flowchart TD A[虚拟地址 0xC0C00000] --> B[提取页目录索引<br/>bit 31-22] A --> C[提取页表索引<br/>bit 21-12] A --> D[提取页内偏移<br/>bit 11-0] B --> E[访问页目录<br/>CR3 + PD_Index × 4] E --> F{页表存在?} F -->|否| G[页错误异常] F -->|是| H[访问页表<br/>PDE_Address + PT_Index × 4] H --> I{页面存在?} I -->|否| G I -->|是| J[提取物理页号<br/>PTE bit 12-31] J --> K[物理地址 = 物理页号 × 4096 + 偏移] K --> L[访问物理内存] style G fill:#ff6b6b style L fill:#51cf66

运行与验证#

编译运行#

cd 08.kernel-mm
make all # 编译
make run # 运行
make clean # 清理

预期输出#

Started in 16-bit real mode (BIOS)
Now in 32-bit protected mode (direct video)
Now Enable Page
Hello, kernel world!
[GDT] Initialized.
[IDT] Initialized with 256 entries.
[PIC] Initialized: master offset=0x20, slave offset=0x28
tick=1
tick=2
[PMM] Initialized. Total pages: 262144, Free pages: 258048
[VMM] Initialized with fixed page tables.
Page Dir @ 0x101000 (Virt: 0xc0701000)
Page Tables @ 0xc0400000
Page Fault handler registered for on-demand paging.
kheap stress test ... OK
*************************** kheap *****************************
[]--- start:0xc0c001f8 end:0xc0c02008 size: 8100
start:0xc0c02008 end:0xc0c02160 size: 344
[]--- start:0xc0c02160 end:0xc0c061a0 size: 16560
***************************************************************
tick=3
tick=4
...

内存管理测试#

系统包含以下测试:

  • kheap_test():基础分配测试
  • kheap_killer():压力测试,随机分配和释放内存
  • kheap_validate_print():验证堆完整性

踩坑记录#

  1. 页错误导致崩溃

    • 原因:访问未映射的虚拟地址
    • 解决方案:检查页错误处理函数,确保按需分页正确实现
  2. 内存分配返回 NULL

    • 原因:物理内存耗尽或位图初始化错误
    • 解决方案:使用 pmm_dump() 检查内存状态
  3. 堆损坏

    • 原因:越界写入或 double free
    • 解决方案:使用 kheap_validate_print() 验证堆完整性
  4. 启动信息解析错误

    • 原因:E820 缓冲区地址或大小配置错误
    • 解决方案:检查 Loader 中 E820 调用的结果

小结#

本章实现了内存管理的三层架构:PMM 用位图跟踪物理页的分配状态,VMM 通过二级页表将虚拟地址映射到物理页并处理缺页中断,内核堆分配器在 VMM 之上提供任意粒度的 malloc/free 接口。三者自底向上协作:PMM 分配物理页给 VMM,VMM 为堆分配器提供虚拟地址空间,堆分配器在空间不足时通过 VMM 扩展映射。有了这套内存管理机制,内核就可以动态创建数据结构了。下一章将实现进程管理与任务调度,包括进程控制块(PCB)、上下文切换和简单的调度算法。

参考#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

内存管理:PMM、VMM 与内核堆
https://blog.souloss.com/posts/os/memory-management-pmm-vmm/
作者
Souloss
发布于
2021-06-16
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时