内核目前对内存的使用是静态的——加载到哪就在哪运行,没有分配和释放的能力。这意味着内核连创建一个新进程都做不到,因为新进程需要动态分配内存来存放进程控制块、栈和页表。内存管理是操作系统最核心的子系统之一,本章将实现三个组件来解决这个问题:物理内存管理器(PMM)负责跟踪哪些物理页可用,虚拟内存管理器(VMM)负责建立虚拟地址到物理地址的映射,内核堆分配器(kheap)在虚拟内存之上提供任意大小的 malloc/free 接口。
内存管理概述
在早期的单任务系统中,所有程序直接访问物理内存,这种方式存在严重的问题:频繁的分配和释放会导致内存碎片化,多个程序可能同时使用相同的内存地址,程序可以直接修改内核或其他程序的内存,也难以实现动态的内存分配和释放。
现代操作系统通过内存管理解决了这些问题。每个进程拥有独立的虚拟地址空间,内存只在真正需要时才分配物理页,页表提供读写权限控制,内核和用户程序可以获得类似 malloc/free 的动态分配接口。
物理内存管理器(PMM)
操作系统需要跟踪哪些物理页是可用的,哪些已被分配。PMM 使用位图(bitmap)来管理物理内存,每个位代表一个 4KB 页面的状态。位图中的每一位对应一个物理页,位为 0 表示页面空闲,位为 1 表示页面已使用。这是物理内存管理的最底层,为上层提供基础内存分配能力。
位图的初始数据来自 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本系统的虚拟地址空间布局如下:
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 策略,在有序数组中查找合适的空闲块:先在有序数组中查找最小的足够大的空闲块,如果找到则分割块并返回,如果找不到则扩展堆空间。
释放时检查相邻块,如果都是空闲的则合并:
堆分配器的核心数据结构:
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 和内核堆之间的关系:
代码实现
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 # 内核入口└── MakefilePMM 初始化
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);}释放时标记块为空闲,检查右侧和左侧块是否空闲,如果空闲则合并,最后将合并后的块插入有序数组。
地址转换流程
下面展示虚拟地址到物理地址的完整转换过程:
运行与验证
编译运行
cd 08.kernel-mmmake all # 编译make run # 运行make clean # 清理预期输出
Started in 16-bit real mode (BIOS)Now in 32-bit protected mode (direct video)Now Enable PageHello, kernel world![GDT] Initialized.[IDT] Initialized with 256 entries.[PIC] Initialized: master offset=0x20, slave offset=0x28tick=1tick=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=3tick=4...内存管理测试
系统包含以下测试:
- kheap_test():基础分配测试
- kheap_killer():压力测试,随机分配和释放内存
- kheap_validate_print():验证堆完整性
踩坑记录
-
页错误导致崩溃
- 原因:访问未映射的虚拟地址
- 解决方案:检查页错误处理函数,确保按需分页正确实现
-
内存分配返回 NULL
- 原因:物理内存耗尽或位图初始化错误
- 解决方案:使用
pmm_dump()检查内存状态
-
堆损坏
- 原因:越界写入或 double free
- 解决方案:使用
kheap_validate_print()验证堆完整性
-
启动信息解析错误
- 原因:E820 缓冲区地址或大小配置错误
- 解决方案:检查 Loader 中 E820 调用的结果
小结
本章实现了内存管理的三层架构:PMM 用位图跟踪物理页的分配状态,VMM 通过二级页表将虚拟地址映射到物理页并处理缺页中断,内核堆分配器在 VMM 之上提供任意粒度的 malloc/free 接口。三者自底向上协作:PMM 分配物理页给 VMM,VMM 为堆分配器提供虚拟地址空间,堆分配器在空间不足时通过 VMM 扩展映射。有了这套内存管理机制,内核就可以动态创建数据结构了。下一章将实现进程管理与任务调度,包括进程控制块(PCB)、上下文切换和简单的调度算法。
参考
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






