Skip to content

内存布局

内存布局描述内核如何使用物理和虚拟地址空间。理解内存布局是理解内核启动、内存管理和进程隔离的基础。

为什么需要理解内存布局?

地址空间的重要性

在编写普通程序时,程序员不需要关心内存地址——编译器和操作系统处理一切。但在内核开发中:

  1. 内核必须知道硬件地址:设备寄存器在固定位置
  2. 内核必须设置地址映射:配置 MMU(内存管理单元)
  3. 内核必须管理地址空间:为进程分配独立的地址空间

物理地址 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 (保存用户态寄存器)         │
└─────────────────────────────────────┘ 低地址

为什么用户和内核要分开?

  1. 安全:用户程序不能直接访问内核
  2. 隔离:进程间互不干扰
  3. 灵活:用户程序可以加载到任意位置

页表的工作原理

三级页表

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 是非法的)。

思考题

  1. 如果物理内存不连续(有空洞),内核如何处理?
  2. 为什么代码段和只读数据段要分开?
  3. 如何检测栈溢出?
  4. 多级页表如何节省内存?

下一步