内存布局¶
内存布局描述内核如何使用物理和虚拟地址空间。理解内存布局是理解内核启动、内存管理和进程隔离的基础。
为什么需要理解内存布局?¶
地址空间的重要性¶
在编写普通程序时,程序员不需要关心内存地址——编译器和操作系统处理一切。但在内核开发中:
- 内核必须知道硬件地址:设备寄存器在固定位置
- 内核必须设置地址映射:配置 MMU(内存管理单元)
- 内核必须管理地址空间:为进程分配独立的地址空间
物理地址 vs 虚拟地址¶
物理地址:内存芯片上的实际地址 虚拟地址:CPU 看到的地址,由 MMU 翻译
CPU 发出虚拟地址
│
▼ MMU 翻译
│
物理地址 ─── 内存芯片
在没有启用 MMU 时,虚拟地址 = 物理地址。
RISC-V 的地址空间¶
物理地址空间¶
RISC-V 64 位理论上支持 56 位物理地址(最大 64 PB),实际实现通常更小。QEMU virt 平台使用 32 位物理地址(4 GB)。
虚拟地址空间¶
Sv39 使用 39 位虚拟地址(512 GB):
虚拟地址格式:
┌──────────┬──────────┬──────────┬────────────┐
│ VPN[2] │ VPN[1] │ VPN[0] │ Offset │
│ 9位 │ 9位 │ 9位 │ 12位 │
└──────────┴──────────┴──────────┴────────────┘
VPN: 虚拟页号
Offset: 页内偏移(4KB 页)
为什么是 39 位而不是 64 位?¶
完整的 64 位地址空间太大,页表会占用过多内存。Sv39 是 RISC-V 的"平衡"选择: - 512 GB 足够大多数应用 - 三级页表,开销可控
QEMU virt 平台的内存布局¶
物理内存布局¶
QEMU virt 平台是 NoobKernel 运行的虚拟硬件,其内存布局如下:
物理地址 内容
────────────────────────────────────────────────
0x00000000 - 0x0BFFFFFF 设备 MMIO 空间
├─ PLIC (0x0C000000)
├─ UART (0x10000000)
└─ VirtIO (0x10001000-0x10008000)
0x0C000000 - 0x0FFFFFFF PLIC 中断控制器
0x10000000 - 0x1000FFFF UART 和 VirtIO
0x80000000 物理内存起始 (PM_START)
│
├─ OpenSBI 固件 (约 128 KB)
│
├─ 内核镜像 (从 0x80200000 开始)
│ ├─ .text (代码)
│ ├─ .rodata (只读数据)
│ ├─ .data (数据)
│ └─ .bss (未初始化数据)
│
├─ 早期堆 (用于启动时分配)
│
├─ Buddy 系统 (大块内存分配)
│
└─ 可用内存
0x88000000 物理内存结束 (PM_END)
总计 128 MB
关键地址的含义¶
0x80000000:为什么内存从这里开始? - RISC-V 规范没有强制,但 QEMU virt 平台选择这个地址 - 低地址预留给设备
0x80200000:为什么内核从这里开始?
- 前 2 MB 预留给 OpenSBI
- 由 QEMU 的 -kernel 参数决定
设备地址空间¶
设备(PLIC、UART、VirtIO)有自己的地址空间,通过 MMIO(内存映射 I/O)访问:
写入 0x10000000 → UART 发送字符
读取 0x0C000000 → PLIC 查询中断
关键点:访问设备地址不是访问内存,而是触发设备操作。
内核镜像的段布局¶
什么是段?¶
编译器将程序分成多个段,每段有不同的属性:
| 段 | 内容 | 属性 |
|---|---|---|
| .text | 代码 | 可读、可执行 |
| .rodata | 只读数据 | 可读 |
| .data | 已初始化数据 | 可读、可写 |
| .bss | 未初始化数据 | 可读、可写 |
链接脚本的作用¶
链接脚本(kernel.ld)决定各段的地址:
BASE_ADDRESS = 0x80200000;
SECTIONS {
. = BASE_ADDRESS;
.text : { *(.text) }
.rodata : { *(.rodata) }
.data : { *(.data) }
.bss : { *(.bss) }
}
段的对齐¶
每个段按 4 KB(页大小)对齐,便于设置页表权限:
.text : 可读可执行,不可写
.rodata: 只读
.data : 可读可写
符号的作用¶
链接脚本定义的符号用于定位段边界:
extern char s_text[], e_text[]; // 代码段起止
extern char s_bss[], e_bss[]; // BSS 段起止
// 清零 BSS 段
memset(s_bss, 0, e_bss - s_bss);
虚拟内存布局¶
当前设计:恒等映射¶
NoobKernel 使用恒等映射:虚拟地址 = 物理地址
虚拟地址 0x80200000 = 物理地址 0x80200000
优点: - 简单:无需地址转换 - 启动方便:开启 MMU 前后地址不变 - 直接访问设备:设备物理地址可直接使用
缺点: - 无隔离:所有代码共享地址空间 - 无保护:无法利用权限位
未来扩展:用户态地址空间¶
实现用户态进程后,需要更复杂的布局:
内核空间(高地址)
┌─────────────────────────────────────┐ 高地址
│ trampoline (用户态切换代码) │
├─────────────────────────────────────┤
│ 内核代码和数据 │
│ (恒等映射,全局可见) │
├─────────────────────────────────────┤
用户空间(每个进程独立)
┌─────────────────────────────────────┤
│ 用户栈(向下增长) │
├─────────────────────────────────────┤
│ 堆(向上增长) │
├─────────────────────────────────────┤
│ 数据段 │
├─────────────────────────────────────┤
│ 代码段 │
├─────────────────────────────────────┤
│ trapframe (保存用户态寄存器) │
└─────────────────────────────────────┘ 低地址
为什么用户和内核要分开?¶
- 安全:用户程序不能直接访问内核
- 隔离:进程间互不干扰
- 灵活:用户程序可以加载到任意位置
页表的工作原理¶
三级页表¶
Sv39 使用三级页表翻译地址:
虚拟地址 → [页表2] → [页表1] → [页表0] → 物理地址
每级页表包含 512 个条目(9 位索引),每个条目指向下一级页表或最终物理页。
页表项(PTE)¶
每个页表项 64 位:
┌─────────────────────────────────────┬──────┐
│ 物理页号 (PPN, 44位) │权限位│
└─────────────────────────────────────┴──────┘
权限位:
V: 有效
R: 可读
W: 可写
X: 可执行
U: 用户可访问
G: 全局映射(不随进程切换刷新)
为什么用多级页表?¶
单级页表的问题: - 512 GB 地址空间 ÷ 4 KB 页 = 1.28 亿页 - 每页一个页表项,需要 1 GB 内存存页表!
多级页表的优点: - 按需分配:只创建用到的页表 - 节省内存:稀疏地址空间不占用页表
代价:每次访问需要多次内存读取(可通过 TLB 缓存优化)。
内存分配器的布局¶
Buddy 系统的区域¶
Buddy 系统管理大块连续内存:
Buddy 区域:
┌──────────────────────────────────────┐
│ Blob 0 (8 MB) │
├──────────────────────────────────────┤
│ Blob 1 (8 MB) │
├──────────────────────────────────────┤
│ ... │
├──────────────────────────────────────┤
│ Blob N-1 (8 MB) │
└──────────────────────────────────────┘
每个 Blob 可分裂成更小的块(伙伴)
Slab 的区域¶
Slab 从 Buddy 申请大块内存,切成小对象:
Slab 块 (64 KB = 16 页):
┌─────────┬──────────────────────────────────┐
│ Slab 头 │ 对象数组 │
│ (元数据) │ [obj][obj][obj]... │
└─────────┴──────────────────────────────────┘
内存布局的权衡¶
为什么 128 MB 内存?¶
对于教学内核,128 MB 绑绰有余: - 内核代码 < 1 MB - 内核数据 < 10 MB - 剩余可用内存充足
真实系统需要根据应用场景调整。
为什么用恒等映射?¶
简化设计,聚焦核心功能。未来实现用户态时可以引入非恒等映射。
为什么设备地址在低地址?¶
硬件设计决定,内核必须适应。这也便于检测空指针访问(地址 0 是非法的)。
思考题¶
- 如果物理内存不连续(有空洞),内核如何处理?
- 为什么代码段和只读数据段要分开?
- 如何检测栈溢出?
- 多级页表如何节省内存?