第一幕:物理内存管理——把内存切成整齐的页
绪
CPU 有了异常处理,屏幕能输出文字。接下来,我们要面对操作系统最核心的职能之一:管理内存。
在操作系统的视角下,物理内存不是一坨连续的字节,而是以 4 KiB(4096 字节) 为单位的"页"的集合。每一页可被分配、使用、释放。我们的任务,就是实现一个物理内存管理器(Physical Memory Manager,PMM)来追踪哪些页空闲、哪些页已被占用。
Limine 提供的内存地图
在动手之前,我们需要先知道这台机器有多少物理内存、哪些区域可用。Limine 通过 memmap 请求为我们提供了详细的内存地图:
struct limine_memmap_entry {
uint64_t base; // 起始物理地址
uint64_t length; // 长度(字节)
uint64_t type; // 类型(可用、保留、ACPI 等)
};type 字段告诉我们这块区域的用途:
LIMINE_MEMMAP_USABLE:可以自由使用的常规内存- 其他类型(
RESERVED、ACPI_RECLAIMABLE、BOOTLOADER_RECLAIMABLE等):我们不应该碰
PMM 的核心工作就是:标记所有可用区域的页为空闲,然后按需分配。
位图分配器
我们选择最简单的方案:位图(Bitmap)。每一位代表一页——1 表示已分配,0 表示空闲。
static uint8_t *bitmap = NULL;
static uint64_t total_pages = 0;
static uint64_t bitmap_size = 0;位图的大小取决于物理内存总量。假设系统有 4 GiB 内存,那么:
- 总页数 = 4 GiB / 4 KiB = 1,048,576 页
- 位图大小 = 1,048,576 / 8 = 131,072 字节 ≈ 128 KiB
位图本身也是数据,它也需要占用物理内存。我们必须从内存地图中找一块足够大的可用区域来存放它。
位图操作的微观实现
在位图层面上,分配、释放和查询对应着三个基础操作:
static inline void bitmap_set(uint64_t page_idx) {
bitmap[page_idx / 8] |= (1 << (page_idx % 8));
}
static inline void bitmap_clear(uint64_t page_idx) {
bitmap[page_idx / 8] &= ~(1 << (page_idx % 8));
}
static inline bool bitmap_test(uint64_t page_idx) {
return (bitmap[page_idx / 8] >> (page_idx % 8)) & 1;
}page_idx / 8 找到对应的字节,page_idx % 8 找到该字节中对应的位。这三个函数是整个物理内存管理的基础原子操作。
还有一个范围清除函数,在初始化时用来一次性标记可用内存区域:
static inline void bitmap_clear_range(uint64_t base, uint64_t size) {
uint64_t start = (base + 4095) / 4096; // 向上取整到页边界
uint64_t end = (base + size) / 4096;
for (uint64_t i = start; i < end; ++i) {
bitmap_clear(i);
}
}注意 start 的计算用了 (base + 4095) / 4096——这是一个整数向上取整的技巧。如果 base 正好是 4096 的倍数,(base + 4095) / 4096 = base / 4096;如果不是,则结果会比 base / 4096 大 1,从而避免分配跨越页边界的部分。
HHDM:访问物理内存的桥梁
在 64 位长模式下,我们的内核运行在高半区(higher half,0xffffffff80000000 以上)。直接解引用物理地址是无效的——CPU 的 MMU 会尝试通过页表来翻译它。
Limine 为我们提供了一个关键支持:HHDM(Higher Half Direct Map)。它把一段虚拟地址区域直接 1:1 映射到物理内存,偏移量由 hhdm_request.response->offset 给出。
#define PHYS_TO_VIRT(addr) ((void *)((uint64_t)(addr) + hhdm_offset))
#define VIRT_TO_PHYS(addr) ((uint64_t)(addr) - hhdm_offset)有了这两个宏,我们就可以在物理地址和虚拟地址之间自由转换——物理地址用于 DMA 和设备通信,虚拟地址用于内核读写。
PMM 初始化
pmm_init() 是 PMM 的入口。它需要完成一系列步骤:
第一步:确定最高物理地址。 遍历所有内存地图条目,找到最大地址:
uint64_t highest_addr = 0;
for (uint64_t i = 0; i < memmap->entry_count; ++i) {
struct limine_memmap_entry *entry = memmap->entries[i];
uint64_t top = entry->base + entry->length;
if (top > highest_addr) highest_addr = top;
}第二步:分配位图内存。 根据最高地址计算位图大小,再从可用内存中找到一块位置存放:
total_pages = (highest_addr + 4095) / 4096;
bitmap_size = (total_pages + 7) / 8;
uint64_t aligned_bitmap_size = (bitmap_size + 4095) & ~4095ULL;aligned_bitmap_size 把位图大小向上对齐到 4 KiB 页边界,保持页对齐是资源管理的基本原则。
找到存放位置后,我们就地分配——直接在内存地图中把这一块"切掉":
for (uint64_t i = 0; i < memmap->entry_count; ++i) {
struct limine_memmap_entry *entry = memmap->entries[i];
if (entry->type == LIMINE_MEMMAP_USABLE &&
entry->length >= aligned_bitmap_size) {
bitmap_phys_addr = entry->base;
bitmap = PHYS_TO_VIRT(bitmap_phys_addr);
for (uint64_t j = 0; j < bitmap_size; ++j) bitmap[j] = 0xFF;
entry->base += aligned_bitmap_size;
entry->length -= aligned_bitmap_size;
break;
}
}先把位图全部填 0xFF(全部标记为已分配),再清除可用区域,这是安全策略——宁可保守地多标记为已占用,也不要让未初始化的位无意间标记为可用。
第三步:清除可用区域。 遍历所有 USABLE 类型的条目,把对应的位清除:
for (uint64_t i = 0; i < memmap->entry_count; ++i) {
if (memmap->entries[i]->type == LIMINE_MEMMAP_USABLE) {
bitmap_clear_range(entry->base, entry->length);
}
}第四步:保留低 1 MiB。 低 1 MiB(前 256 页)包含 BIOS 数据区、VGA 显存、实模式中断向量表等关键区域,我们不能碰:
for (uint64_t j = 0; j < 256; j++) {
bitmap_set(j);
}物理页的分配与释放
分配一页的逻辑是线性扫描位图,找到第一个空闲位:
uint64_t pmm_alloc() {
static uint64_t last_scanned_page = 0;
for (uint64_t i = 0; i < total_pages;) {
uint64_t check_idx = (last_scanned_page + i) % total_pages;
// 快速跳过:如果当前字节全是 0xFF,8 页都满了
if (bitmap[check_idx / 8] == 0xFF) {
uint64_t skip = 8 - (check_idx % 8);
if (check_idx + skip > total_pages)
skip = total_pages - check_idx;
i += skip;
continue;
}
if (!bitmap_test(check_idx)) {
bitmap_set(check_idx);
last_scanned_page = (check_idx + 1) % total_pages;
return check_idx * 4096;
}
i++;
}
return 0;
}这里有一个简单的性能优化:last_scanned_page 记录上次分配的位置,下次从那里继续搜索,避免每次都从第 0 页扫起。另外,当检测到一个字节全是 0xFF 时(意味着连续的 8 页都已满),直接跳过这 8 页。
释放就更简单了——直接清除对应的位即可:
void pmm_free(uint64_t ptr) {
uint64_t phys_addr = ptr;
if (phys_addr % 4096 != 0) return; // 安全检查:必须页对齐
uint64_t page_idx = phys_addr / 4096;
if (page_idx >= total_pages) return;
bitmap_clear(page_idx);
}位图分配器的代价
这个分配器简单、正确,但不是最高效的。每次分配需要 O(n) 的扫描时间——在内存紧张时可能很慢。但对于一个教学内核来说,它的优势在于:
- 实现极其简单,不超过 60 行核心代码
- 没有任何复杂的数据结构依赖
- 外部碎片为零(因为页大小固定)
未来可以考虑升级为夥伴分配器(Buddy Allocator)或 SLAB 分配器,但那是当我们真正遇到性能瓶颈时的事。
小结
PMM 是操作系统的"土地管理局"。它知道每一块物理内存在哪里、有多大、能不能用。每一页内存的分配和释放都经过它的手。有了 PMM,我们的 VMM(虚拟内存管理器)就有了物理页的供应来源——那是下一幕的主题。
