《从零手写操作系统 (05):物理内存管理器——让内核学会“分配”》
前言:从“硬编码地址”到“动态分配”
在前几章中,我们所有的数据结构都依赖硬编码的绝对地址。GDT放在 0x800,栈顶固定在 0x90000,串口驱动直接操作 0x3F8。这种方式在代码量小时尚可维持,但随着内核功能膨胀,你会陷入无尽的地址冲突噩梦:新加一个缓冲区会不会覆盖页表?栈向下增长会不会踩到BSS段?
本章我们将彻底终结这种原始状态。通过探测硬件报告的内存布局,构建一个物理页帧分配器(Physical Page Frame Allocator),让内核首次拥有“申请内存”和“释放内存”的能力。这是迈向虚拟内存、进程管理和文件系统的地基。
本章里程碑:
- ✅ 通过E820 BIOS调用获取真实物理内存布局
- ✅ 设计并实现基于Bitmap的物理页帧分配器
- ✅ 支持4KB对齐的页帧分配与释放
- ✅ 串口打印内存统计信息,验证分配器正确性
核心概念:E820内存映射与页帧抽象
为什么不能假设内存从0开始连续?
x86 PC的内存空间从来不是连续的。BIOS ROM、显存、PCI设备MMIO区域会像岛屿一样散布在地址空间中。直接使用未探测的地址等于定时炸弹。E820是UEFI/BIOS提供的标准内存探测接口,它返回一系列 (base, length, type) 三元组,精确描述每一段内存的属性。
| Type值 | 含义 | 内核可用? |
|---|---|---|
| 1 | Usable RAM | ✅ 可分配 |
| 2 | Reserved | ❌ 不可用 |
| 3 | ACPI Reclaimable | ⚠️ 解析ACPI后可回收 |
| 4 | ACPI NVS | ❌ 不可用 |
| 5+ | Bad Memory / Undefined | ❌ 不可用 |
为什么以4KB为分配单位?
x86分页机制的最小粒度是4KB页。物理分配器必须与页大小对齐,否则无法映射到页表。我们将每个4KB块称为一个页帧(Page Frame),用唯一索引编号管理。分配器只需追踪哪些页帧空闲、哪些已占用。
实战代码
E820内存探测:memory.c
⚠️ 关键前提:E820必须在实模式下调用。你需要在Stage1或Stage2的实模式阶段完成探测,将结果存入一个全局数组,再传递给32位C代码。以下展示32位侧的数据结构和解析逻辑。
// memory.h
#ifndef MEMORY_H
#define MEMORY_H
#include <stdint.h>
#define E820_USABLE 1
typedef struct {
uint64_t base;
uint64_t length;
uint32_t type;
} __attribute__((packed)) e820_entry_t;
// 由实模式代码填充,链接器符号指向该数组
extern e820_entry_t e820_map[];
extern uint32_t e820_count;
void memory_init(void);
#endif
// memory.c - 物理内存管理器
#include "memory.h"
#include "serial.h"
#define PAGE_SIZE 4096
// Bitmap: 1=已分配, 0=空闲
static uint8_t *pmm_bitmap;
static uint32_t total_pages;
static uint32_t used_pages;
// 将字节数向上对齐到页边界
static inline uint32_t align_up(uint64_t val, uint32_t align) {
return (val + align - 1) & ~(align - 1);
}
void memory_init(void) {
uint64_t max_addr = 0;
// 第一遍:找到最大可用地址,确定Bitmap大小
for (uint32_t i = 0; i < e820_count; i++) {
if (e820_map[i].type == E820_USABLE) {
uint64_t end = e820_map[i].base + e820_map[i].length;
if (end > max_addr) max_addr = end;
}
}
total_pages = (uint32_t)(max_addr / PAGE_SIZE);
uint32_t bitmap_bytes = (total_pages + 7) / 8;
// 第二遍:在第一个足够大的Usable区域末尾放置Bitmap
// 【注意】实际实现需确保Bitmap不与内核镜像重叠
// 这里简化演示,假设0x100000处安全
pmm_bitmap = (uint8_t *)0x100000;
for (uint32_t i = 0; i < bitmap_bytes; i++) pmm_bitmap[i] = 0xFF; // 先全部标记为已占用
// 第三遍:将Usable区域对应的bit清零(标记为空闲)
for (uint32_t i = 0; i < e820_count; i++) {
if (e820_map[i].type != E820_USABLE) continue;
uint32_t start_page = align_up(e820_map[i].base, PAGE_SIZE) / PAGE_SIZE;
uint32_t end_page = (e820_map[i].base + e820_map[i].length) / PAGE_SIZE;
for (uint32_t p = start_page; p < end_page && p < total_pages; p++) {
pmm_bitmap[p / 8] &= ~(1 << (p % 8));
}
}
// 保留前1MB和Bitmap自身所在页
uint32_t reserved_end = align_up((uint64_t)pmm_bitmap + bitmap_bytes, PAGE_SIZE) / PAGE_SIZE;
for (uint32_t p = 0; p < reserved_end && p < total_pages; p++) {
pmm_bitmap[p / 8] |= (1 << (p % 8));
}
used_pages = 0;
for (uint32_t i = 0; i < bitmap_bytes; i++) {
uint8_t b = pmm_bitmap[i];
while (b) { used_pages += b & 1; b >>= 1; }
}
kprintf("[PMM] Total: %d pages (%d MB), Used: %d pages\n",
total_pages, (total_pages * PAGE_SIZE) >> 20, used_pages);
}
页帧分配与释放
// 分配单个4KB页帧,返回物理地址;失败返回0
uint32_t pmm_alloc_page(void) {
uint32_t bitmap_bytes = (total_pages + 7) / 8;
for (uint32_t i = 0; i < bitmap_bytes; i++) {
if (pmm_bitmap[i] == 0xFF) continue; // 快速跳过满字节
for (int bit = 0; bit < 8; bit++) {
uint32_t page = i * 8 + bit;
if (page >= total_pages) return 0;
if (!(pmm_bitmap[i] & (1 << bit))) {
pmm_bitmap[i] |= (1 << bit);
used_pages++;
return page * PAGE_SIZE;
}
}
}
kprintf("[PMM] ERROR: Out of physical memory!\n");
return 0;
}
// 释放单个4KB页帧
void pmm_free_page(uint32_t phys_addr) {
uint32_t page = phys_addr / PAGE_SIZE;
if (page >= total_pages) {
kprintf("[PMM] WARNING: Free invalid addr 0x%x\n", phys_addr);
return;
}
pmm_bitmap[page / 8] &= ~(1 << (page % 8));
used_pages--;
}
集成测试
在内核入口函数中添加:
void kernel_main(void) {
serial_init();
kprintf("[Kernel] Booting...\n");
memory_init();
// 测试分配与释放
uint32_t p1 = pmm_alloc_page();
uint32_t p2 = pmm_alloc_page();
kprintf("[Test] Allocated: 0x%x, 0x%x\n", p1, p2);
pmm_free_page(p1);
uint32_t p3 = pmm_alloc_page();
kprintf("[Test] After free+realloc: 0x%x (should == 0x%x)\n", p3, p1);
kprintf("[Kernel] PMM test passed.\n");
}
预期串口输出:
[PMM] Total: 32768 pages (128 MB), Used: 256 pages
[Test] Allocated: 0x110000, 0x111000
[Test] After free+realloc: 0x110000 (should == 0x110000)
[Kernel] PMM test passed.
关键细节解析
1. Bitmap为什么放在Usable区域内?
Bitmap本身也需要内存。将它放在已探测的Usable区域末尾(而非硬编码地址),可以保证不会覆盖未知设备MMIO。但必须注意:Bitmap所占的页帧必须在初始化时立即标记为已占用,否则会被分配出去导致数据损坏。
2. 为什么先全置1再清零Usable区域?
这是一种防御性编程策略。默认所有内存不可用,只显式信任E820报告为Usable的区域。如果E820探测遗漏了某段内存,它不会被误分配,最多浪费一些RAM。反之,如果默认全0,遗漏的Reserved区域被分配将导致系统崩溃。
3. 当前实现的局限性
- 线性扫描:
pmm_alloc_page最坏情况O(n),后续可优化为空闲链表或Buddy System。 - 无并发保护:单核裸机环境暂不需要锁,多核章节将补充自旋锁。
- 仅支持单页分配:连续多页分配需要扩展接口,留待虚拟内存章节。
调试Checklist:内存分配异常排查
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| Total pages = 0 | E820数组未正确传递 / count=0 | 在实模式阶段kprintf打印e820_count验证 |
| 分配的地址与内核重叠 | Bitmap放置位置未避开内核镜像 | 检查linker.map确认内核结束地址,Bitmap起始必须大于它 |
| free后重新分配到不同地址 | Bitmap位操作错误 / 未正确清零 | 添加单元测试:alloc→free→alloc,断言两次地址相同 |
| 串口报Out of memory但实际有RAM | Usable区域未被正确标记为空闲 | 打印每个E820条目的base/len/type,手动验算bit范围 |
🔧 黄金法则:永远不要相信E820返回的地址可以直接使用。每次分配后,用QEMU监控器
xp /x [addr]验证该物理地址确实可读可写。
本章小结与下一步
今天我们完成了内核资源管理的第一个里程碑:
- ✅ 掌握了E820内存探测的原理与安全实践
- ✅ 实现了生产级可用的Bitmap物理页帧分配器
- ✅ 建立了“分配-使用-释放”的内存管理范式
从此,内核不再是一个靠硬编码地址苟活的玩具,而是一个能自主管理资源的真正操作系统雏形。物理内存管理器是所有高级内存特性的基石。
下一章预告:《虚拟内存初探:启用分页,让每个程序拥有独立的4GB幻觉》
有了物理分配器,我们终于可以搭建页表、启用CR3、打开分页开关。下一章将实现恒等映射+高半核映射,让内核运行在3GB以上的虚拟地址空间,为用户态进程的隔离打下基础。
参考资料
- OSDev Wiki - Detecting Memory (E820)
- OSDev Wiki - Physical Memory Manager
- Intel SDM Vol.3 Chapter 4 (Paging)
- 本系列完整代码:[你的GitHub仓库链接](Commit:
m0n1o2p)
📝 作者注:这是《从零手写操作系统》系列的第05篇。物理内存管理器是第一个让你感受到“系统在生长”的章节。建议在分配器的每个关键路径都保留kprintf日志,直到你完全信任它的正确性后再条件编译关闭。下一章,我们开启分页时代!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)