操作系统学习16 物理内存探测与分配器(PMM)
操作系统学习16 物理内存探测与分配器(PMM)

一、本章说明
1. 前置知识
本章接续 lesson15(8253 PIT + 抢占式多任务)。前面已经能进保护模式、处理中断并做任务切换,但物理内存地址仍依赖“假定机器有多少 RAM”的硬编码——换一台虚拟机配置就可能踩到空洞或设备预留区。
2. 本章目标
完成本章后,我们将能够:
- 在实模式下用 BIOS
INT 15h, AX=E820h探测真实物理内存布局(ARDS) - 把杂乱的内存段清洗为 4KB 物理页帧(Page Frame)
- 用**位图(Bitmap)**实现物理内存分配器(PMM):
pmm_init/pmm_alloc_page/pmm_free_page - 在内核中打印 ARDS、总内存与分配/释放结果,在 Bochs 中验证
3. 目录结构
lesson16/
├── Makefile
├── boot/
│ ├── loader.s # 读盘 + E820 探测 + 进保护模式
│ └── pm.inc # 段描述符宏
├── kernel/
│ ├── main.c # 打印 ARDS / PMM 状态,演示 alloc/free
│ ├── pmm.c / pmm.h # 位图物理页分配器
│ ├── kernel_end.s # 导出 kernel_end(位图紧随内核放置)
│ ├── utilities.s # 显存输出等(沿用前几章)
│ └── utilities.h
└── README.md
4. 与 lesson15 的差异
| 项目 | lesson15 | lesson16 |
|---|---|---|
| 引导 | 读盘后直接进 PM | 进 PM 前增加 E820 探测 |
| 内存管理 | 无(栈/任务栈静态数组) | Bitmap PMM |
| 多任务 | task.c / task.s + IRQ0 |
本章演示不再依赖调度 |
| 内核入口 | 创建 A/B 任务并切换 | 初始化 PMM 并打印分配结果 |
二、为什么不能直接“使用”所有内存?
系统开机时,物理 RAM 并不是连续且完全可用的。部分区域被固件、显存或主板设备占用:
0x00000000 +-----------------------------------+
| IVT / BIOS Data Area | <- 需谨慎使用
0x000A0000 +-----------------------------------+
| Video RAM (VGA 640KB~1MB) | <- 不可作普通 RAM
0x00100000 +-----------------------------------+
| Extended Memory(内核通常在此) | <- 可用 Usable
| ... |
+-----------------------------------+
| ACPI / Reserved / Device MMIO | <- 硬件预留
+-----------------------------------+
盲目向 0xA0000 写数据会花屏;写入 Reserved 区可能直接崩溃。因此:先探测、后分配。
三、BIOS INT 15h(E820)探测物理内存
1. ARDS 是什么?
ARDS 全称 Address Range Descriptor Structure(地址范围描述符结构)。
可以把它理解成 BIOS 给出的一张「内存地契」:一条 ARDS = 物理地址空间里的一段连续区域,说明这段从哪开始、有多长、能不能当普通 RAM 用。
E820 不会一次把整张内存地图塞给你,而是迭代返回多条 ARDS;把它们拼起来,就是整机的物理内存布局。例如典型机器可能返回:
| 条目 | 大致范围 | Type | 含义 |
|---|---|---|---|
| #0 | 0x00000000 起一小段 |
1 | 可用低端 RAM |
| #1 | 0x000A0000 附近 |
2 | 显存 / 设备预留,不可当堆用 |
| #2 | 0x00100000 起大段 |
1 | 可用扩展内存(内核常在此) |
| … | … | 3/4 等 | ACPI 等特殊用途 |
内核只把 Type = 1(Usable) 的页交给分配器;其它类型一律视为不可分配。
如何判定「无效 / 空」条目(检测为 0)
BIOS 不会用「全 0 结构」作为官方结束符——正常结束看的是 EBX == 0(或 CF=1)。
但缓冲区里仍可能出现全 0 槽位(未写入的残留、被跳过的洞)。判定方式:
/* type==0 或 length==0 → 无效,直接跳过 */
static inline int ards_is_valid(const ards_t* e)
{
return e->type != 0 && e->length != 0;
}
| 字段 | 为 0 时含义 |
|---|---|
type == 0 |
不是合法 E820 类型(合法值从 1 起),视为空槽 |
length == 0 |
零长度区间,无意义,跳过 |
base 与 length 皆 0 |
典型的未初始化填充,与上面两条一并过滤 |
截图里
[6][7]的type=0就是这类脏槽。真正的条目数应以 loader 写入的 ARDS count 为准;若 count 被破坏,再按ards_is_valid过滤并钳制上限。
2. ARDS 结构(20 字节)
每条 ARDS 固定 20 字节(本课按经典 E820 请求;部分 BIOS 还可返回 24 字节扩展属性,此处不展开):
| 偏移 | 大小 | 名称 | 含义 |
|---|---|---|---|
0x00 |
8 | BaseAddr |
该段物理起始地址(64 位) |
0x08 |
8 | Length |
该段长度(字节,64 位) |
0x10 |
4 | Type |
1=可用 RAM;2=预留;3=ACPI 可回收;4=ACPI NVS;0=无效 |
本课约定内存地图布局(均在内核 0xC200 之前):
| 地址 | 内容 |
|---|---|
0x8000 |
uint16 有效 ARDS 条数 |
0x8010 |
ARDS 数组(每项 20 字节) |
3. 迭代查询流程
每次调用返回一条 ARDS,并用 EBX 作为 continuation;EBX == 0 表示结束。
4. 本课实现(boot/loader.s)
在读盘成功、enter_pm 之前调用 detect_memory:
ARDS_COUNT EQU 0x8000
ARDS_BUFFER EQU 0x8010
detect_memory:
xor ebx, ebx
xor ax, ax
mov ds, ax
mov es, ax
mov di, ARDS_BUFFER
mov word [ARDS_COUNT], 0
.e820_loop:
mov eax, 0xE820
mov ecx, 20
mov edx, 0x534D4150 ; 'SMAP'
int 0x15
jc .e820_done
cmp eax, 0x534D4150
jne .e820_error
; type==0 或 length==0 → 跳过(不前进 DI,下次覆盖写)
cmp dword [es:di + 16], 0
je .e820_skip
cmp dword [es:di + 8], 0
jne .e820_accept
cmp dword [es:di + 12], 0
je .e820_skip
.e820_accept:
add di, 20
inc word [ARDS_COUNT]
.e820_skip:
test ebx, ebx
jnz .e820_loop
.e820_done:
ret
对应 C 侧判定(与 20 字节条目一一对应):
typedef struct {
uint64_t base_addr;
uint64_t length;
uint32_t type;
} __attribute__((packed)) ards_t;
/* 检测是否为 0 / 无效条目 */
static inline int ards_is_valid(const ards_t* e)
{
return e->type != 0 && e->length != 0;
}
四、物理页分配器(PMM)
x86 常见以 4KB 为一页。若物理上界为 128MB:
总页数 = 128 × 1024 × 1024 4096 = 32768 \text{总页数} = \frac{128 \times 1024 \times 1024}{4096} = 32768 总页数=4096128×1024×1024=32768
1. 位图约定
- 1 bit ↔ 1 个 4KB 页
0= 空闲,1= 已占用 / 不可用
8 bit 管理 (8 \times 4\text{KB} = 32\text{KB});管理 128MB 仅需约 4KB 位图。
2. 数据结构(kernel/pmm.h)
typedef struct {
uint8_t* bitmap;
uint32_t bitmap_size;
uint32_t total_pages;
uint32_t free_pages;
uint32_t max_mem_size;
} pmm_manager_t;
kernel_end 由链接末尾的 kernel_end.s 导出,位图放在页对齐后的内核末尾,避免踩内核代码/数据。
3. 初始化四步(pmm_init)
- 扫描
type == 1的 ARDS,得到最大物理上界 →total_pages/bitmap_size - 位图默认全部置
0xFF(全部占用,安全第一) - 再把可用段对应页 清 0,并累计
free_pages - 把 低端内存 + 内核 + 位图自身 重新置 1
半页处理:可用段起始页向上对齐、结束页向下对齐,避免把非完整页标成空闲。
4. 分配与释放
void* pmm_alloc_page(void); /* 找第一个空闲 bit,置 1,返回页物理地址 */
void pmm_free_page(void* p); /* 页对齐检查后清 0,free_pages++ */
分配是线性扫描位图(教学实现,清晰优先;后续可改为 buddy / 空闲链表等)。
5. kernel_end
kernel_end.s 本身
链接时把一个空标签放到内核映像最后,标签的地址就是“内核结束地址”。
对于kernel_end.s本身:
- global kernel_end:把符号导出,给 C 用
- kernel_end::只是一个标签,后面没有任何指令/数据
- 所以它不占有效内容,只是一个地址标记
它靠链接顺序“钉”在末尾:
kernel.bin: main.o utilities.o pmm.o kernel_end.o
$(LD) $(LDFLAGS) -Ttext 0xC200 ... $(TARGET)main.o $(TARGET)utilities.o $(TARGET)pmm.o $(TARGET)kernel_end.o
链接器按命令行顺序排布:
[0xC200] main.o → utilities.o → pmm.o → kernel_end.o
↑
kernel_end 落在这里
-Ttext 0xC200 指定内核从 0xC200 开始;kernel_end.o 放在最后,所以 kernel_end 的值 ≈ 内核映像末尾地址。
c 侧使用
extern uint8_t kernel_end[]; /* 由 kernel_end.s 提供,链在内核映像末尾 */
pmm_init(ards, ards_count, (uint32_t)kernel_end);
- 声明成数组,取的是地址,不是内容
- (uint32_t)kernel_end 就是内核结束地址
uint32_t bitmap_addr = align_up(kernel_end_addr, PAGE_SIZE);
把位图放在 kernel_end 向上对齐到页边界之后,这样位图紧挨内核,又不会踩到内核代码/数据。
对齐说明
向上对齐是为了在页边界上安全地放下位图,且不踩内核。
假设 kernel_end = 0xC345,页大小 0x1000:
| 方向 | 结果 | 问题 |
|---|---|---|
| 向下对齐 | 0xC000 |
落进内核,会覆盖代码/数据 |
| 不齐 | 0xC345 |
位图和内核尾巴挤在同一页 |
| 向上对齐 | 0xD000 |
越过内核,从整页开始 |
PMM 按整页管理(分配、回收、预留都是 PAGE_SIZE)。位图放在页对齐地址后:
- 内核最后那半页只属于内核,不会和位图混在一起
- 后面按页预留时更干净:
uint32_t reserved_end = align_up(
(uint32_t)pmm.bitmap + pmm.bitmap_size, PAGE_SIZE);
uint32_t reserved_pages = reserved_end / PAGE_SIZE;
一句话:向下会覆盖内核;不齐会让一页里既有内核又有位图;向上才是“紧随内核之后、又按页对齐”的安全位置。
五、内核演示(kernel/kernel.c)
void main(void)
{
ards_t* ards = (ards_t*)ARDS_BUFFER_ADDR; /* 0x8010 */
uint16_t ards_count = *(uint16_t*)ARDS_COUNT_ADDR; /* 0x8000 */
clear_screen();
/* 打印若干条 ARDS */
pmm_init(ards, ards_count, (uint32_t)kernel_end);
/* 打印 Total Memory / Total Pages / Free Pages */
void* p1 = pmm_alloc_page();
void* p2 = pmm_alloc_page();
pmm_free_page(p1);
/* ... */
}
Bochs 中典型输出(内存大小随配置变化):
Lesson16: Physical Memory Manager (PMM)
========================================
ARDS count: ...
[0] base=0x00000000 len=... type=1
...
=== PMM Init ===
PMM state:
mem=31MB pages=8176 used=113 free=8063_
bitmap@0x0000F000 size=1022_
regions:
[USED] 0x00000000-0x00010000 [#]
[FREE] 0x00010000-0x0009F000 [.]
[USED] 0x0009F000-0x00100000 [#]
[FREE] 0x00100000-0x01FF0000 [...................]
Allocated Page 1: 0x00010000_
Allocated Page 2: 0x00011000_
Freed Page 1. Free Pages: 8062_
构建与运行:
make
make run

六、本章小结
| 步骤 | 实现内容 | 关键点 |
|---|---|---|
| 1. 探测 | loader.s 中 E820 迭代 |
实模式、SMAP、ARDS @ 0x8000 |
| 2. 页框 | 4KB 粒度 | 上界 → total_pages |
| 3. 位图 | 先全占再按 ARDS 释放 | 保护内核与位图自身 |
| 4. API | alloc / free |
bit 的 set / clear / test |
至此内核掌握了物理页的分配权。以后可以建立页表,把“物理页”映射为进程可用的虚拟地址空间,从而实现隔离与更灵活的内存布局。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)