内核跑起来之后,第一个绕不开的问题就是内存。

你不知道这台机器有多少内存,哪些地址段可以用,哪些是硬件保留的。如果随便往一个地址写数据,轻则数据损坏,重则触发异常直接重启。

这一章解决一件事:让内核知道内存的全貌,然后有序地分配和回收物理页


先问 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):给内核和用户进程各自独立的地址空间,让它们互不干扰。这是保护模式真正意义的所在。

源码在这里:github.com/tongpengfei/learn-with-ai