内核跑起来之后,第一个绕不开的问题就是内存。
你不知道这台机器有多少内存,哪些地址段可以用,哪些是硬件保留的。如果随便往一个地址写数据,轻则数据损坏,重则触发异常直接重启。
这一章解决一件事:让内核知道内存的全貌,然后有序地分配和回收物理页。
先问 BIOS:你有多少内存?
我们用 E820(BIOS INT 0x15 AX=E820h,一种标准接口,用来查询物理内存的分布和类型)来探测内存。
这事必须在实模式下做——切换到长模式之后就再也访问不到 BIOS 服务了。所以探测代码放在 boot.asm 里,在进入长模式之前完成。
每次调用 INT 0x15,BIOS 填一条 24 字节的记录:
base (8字节) — 这段内存的起始物理地址
length (8字节) — 这段内存的长度(字节)
type (4字节) — 类型:1=可用,2=保留,其它=别动
ACPI (4字节) — 扩展属性,一般忽略
循环调用直到 EBX 变成 0,表示枚举完毕。所有记录写入固定地址 0x8000,第一个 4 字节存条目数量。
mov di, MMAP_ADDR + 4 ; 从 0x8004 开始存条目
xor ebx, ebx
xor bp, bp ; bp 记条目数量
.e820_loop:
mov eax, 0xE820
mov ecx, 24
mov edx, 0x534D4150 ; "SMAP" 魔数,BIOS 验证用
int 0x15
jc .e820_done ; CF=1 表示出错或结束
inc bp
add di, 24
test ebx, ebx
jz .e820_done ; ebx=0 表示最后一条
jmp .e820_loop
.e820_done:
mov dword [MMAP_ADDR], ebp ; 把条目数量写到 0x8000
QEMU 上跑出来的内存地图一般长这样:
[0] base=0x0000000000000000 len=0x000000000009FC00 type=1 ← 低 640KB 可用
[1] base=0x000000000009FC00 len=0x0000000000000400 type=2 ← BIOS 保留
[2] base=0x00000000000F0000 len=0x0000000000010000 type=2 ← ROM 区域
[3] base=0x0000000000100000 len=0x0000000007EF0000 type=1 ← 1MB 以上全部可用
[4] base=0x00000000FFFC0000 len=0x0000000000040000 type=2 ← BIOS 固件
Type=1 的就是可以用的内存,其余的碰都别碰。
物理内存管理器(PMM)
有了内存地图,下一步是管理它。最简单的方案:位图(Bitmap)。
把物理内存按 4KB 一页切割。每一页用 1 bit 表示状态——0 空闲,1 已用。
一个 bit 管一页(4096 字节),1MB 的位图能管理 1MB × 8 × 4KB = 32GB 内存,绰绰有余。
位图本身放在物理地址 0x20000(128KB 处,这段地址没人用,刚好空着)。
#define BITMAP_ADDR 0x20000
#define MAX_PAGES (128 * 1024) // 最多管理 512MB
#define PAGE_SIZE 4096
#define bitmap ((uint8_t *)BITMAP_ADDR)
三个基础操作:
static void bitmap_set(uint64_t page) { bitmap[page/8] |= (1 << (page % 8)); }
static void bitmap_clear(uint64_t page) { bitmap[page/8] &= ~(1 << (page % 8)); }
static int bitmap_test(uint64_t page) { return bitmap[page/8] & (1 << (page % 8)); }
初始化:三步走
思路是:先把所有内存都标为"不可用",再把 E820 说可用的那些开放出来,最后把低 1MB 保护起来——这里住着 Bootloader、GDT、内核代码,动了就崩。
void pmm_init(mmap_entry_t *mmap, uint32_t count) {
// step1: 全部标为已用(默认不可用)
for (uint64_t i = 0; i < MAX_PAGES / 8; i++)
bitmap[i] = 0xFF;
// step2: 把 E820 type=1 的页标为空闲
for (uint32_t i = 0; i < count; i++) {
if (mmap[i].type != 1) continue;
uint64_t start = (mmap[i].base + PAGE_SIZE - 1) & ~(uint64_t)(PAGE_SIZE - 1);
uint64_t end = (mmap[i].base + mmap[i].length) & ~(uint64_t)(PAGE_SIZE - 1);
for (uint64_t addr = start; addr < end; addr += PAGE_SIZE) {
uint64_t page = addr / PAGE_SIZE;
if (page < MAX_PAGES) {
bitmap_clear(page);
free_pages_count++;
}
}
}
// step3: 保护低 1MB(页表、内核代码、位图本身都在这里)
for (uint64_t page = 0; page < 256; page++) {
if (!bitmap_test(page)) {
bitmap_set(page);
free_pages_count--;
}
}
}
Step1 先全部标为已用,这很重要。E820 返回的地图不是全集——那些没有出现在地图里的地址段,状态不明,默认不可用最安全。
Step2 把 type=1 的范围按页对齐后清位,标为空闲。注意起始地址向上对齐(+ PAGE_SIZE - 1) & ~(PAGE_SIZE - 1)),结束地址向下对齐——不足一页的零头不能用,否则可能越界。
Step3 保护低 1MB。这段地址里住着:Bootloader、GDT、页表、E820 内存地图、位图本身、内核代码……全部标为已用,永不分配。
分配和释放
实现很简单:分配就是线性扫描找第一个空闲位,释放就是把那一位清零。这叫 first-fit 策略,不是最快的,但够用。
void *pmm_alloc() {
for (uint64_t page = 0; page < MAX_PAGES; page++) {
if (!bitmap_test(page)) { // 找到空闲页
bitmap_set(page); // 标为已用
free_pages_count--;
return (void *)(page * PAGE_SIZE); // 返回物理地址
}
}
return 0; // 内存耗尽
}
void pmm_free(void *addr) {
uint64_t page = (uint64_t)addr / PAGE_SIZE;
if (page < MAX_PAGES && bitmap_test(page)) {
bitmap_clear(page);
free_pages_count++;
}
}
pmm_alloc 从低地址向高扫描,找到第一个空闲页返回其物理地址。这是最简单的 first-fit(首次适应) 策略。
实际操作系统里会用更复杂的结构(伙伴系统、per-CPU freelist)来减少碎片和提升性能,但现在这个够用。
在内核里验证
// kernel.c
void kernel_main() {
pmm_init(mmap, mmap_count);
kprint("total pages: ");
kprint_hex(pmm_total_pages());
kprint("\nfree pages: ");
kprint_hex(pmm_free_pages());
kprint("\n");
void *p1 = pmm_alloc();
void *p2 = pmm_alloc();
kprint("alloc p1="); kprint_hex((uint64_t)p1); kprint("\n");
kprint("alloc p2="); kprint_hex((uint64_t)p2); kprint("\n");
pmm_free(p1);
void *p3 = pmm_alloc();
kprint("free p1, alloc p3="); kprint_hex((uint64_t)p3); kprint("\n");
// p3 应该等于 p1
}
输出:
total pages: 0x0000000000001FEF
free pages: 0x0000000000001DEF
alloc p1=0x0000000000100000
alloc p2=0x0000000000101000
free p1, alloc p3=0x0000000000100000
p1分配到0x100000(1MB 处),是第一个可用页p2紧随其后0x101000- 释放
p1后再分配,p3 == p1,复用成功
内核现在能管理内存了。
这一章之后
有了物理内存分配,下一步是 虚拟内存(VMM):给内核和用户进程各自独立的地址空间,让它们互不干扰。这是保护模式真正意义的所在。