前言:从“硬编码地址”到“动态分配”

        在前几章中,我们所有的数据结构都依赖硬编码的绝对地址。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值含义内核可用?
1Usable RAM✅ 可分配
2Reserved❌ 不可用
3ACPI Reclaimable⚠️ 解析ACPI后可回收
4ACPI 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 = 0E820数组未正确传递 / count=0在实模式阶段kprintf打印e820_count验证
分配的地址与内核重叠Bitmap放置位置未避开内核镜像检查linker.map确认内核结束地址,Bitmap起始必须大于它
free后重新分配到不同地址Bitmap位操作错误 / 未正确清零添加单元测试:alloc→free→alloc,断言两次地址相同
串口报Out of memory但实际有RAMUsable区域未被正确标记为空闲打印每个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日志,直到你完全信任它的正确性后再条件编译关闭。下一章,我们开启分页时代!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐