Preparation
我们从本篇文章中去探寻如下几个问题
- 页表是怎么为每个进程空间提供专用空间和地址?
- 页表如何确定内存地址含义,以及可以访问的物理内存部分?
- 页表如何实现操作系统隔离不同进程的地址空间,并将它们复用到单个物理内存上。比如:在多个地址空间中映射相同的内存(一个跳板页),并通过未映射页保护内核和用户堆栈。
在开启这篇文章之前,我们先理清一下刚才的一个术语,也就是跳板页是什么?
在多个地址空间中映射相同的内存(一个跳板页)是一种技术,通过它可以在操作系统中为不同的进程提供相同的内存区域。这种技术允许操作系统在多个进程的地址空间中共享一段物理内存,而无需为每个进程单独分配新的物理内存。
跳板页(trampoline page)通常用于上下文切换过程。在上下文切换中,操作系统需要保存和恢复进程的状态。通过在内核和用户态之间共享跳板页,可以简化这种转换过程。
一、分页硬件
我们首先要理清两个概念:
- RISC-V指令(用户态和内核态)都是操作虚拟地址。
- 机器的RAM(即物理内存,临时存储器),通过物理地址进行索引。
那么,虚拟地址和物理地址转换是由什么完成的? 就是由 RISC-V的页表硬件将每个虚拟地址映射到物理地址上。
以 Sv39 RISC-V 架构中的虚拟地址到物理地址的转换过程为例子,讲解一下这个是怎么运行的。
首先我们要知道,Sv39 是 RISC-V 架构中的一种地址映射方式,它支持 64 位虚拟地址,其中 只有底部的 39 位被用于虚拟地址的实际映射,顶部的 25 位会被忽略。
- RISC-V 页表其实是一个【2^27】个的页表项(page table entries , PTEs),每个页表项由44 bits的物理页表号码(PPN)和 10 bits的 flags 组成。
- 分页硬件会将这39位中的 top 27 bits 去页表中寻找到页表项(PTE),然后制造出一个 56 bit的物理地址。
- 这56位的top 44 bits 来自 PTE的PPN, bottom 12 bits 则是复制来自原始虚拟地址。
- 页表让操作系统以【2^12】 bytes 的对齐 chunks 的粒度控制虚拟地址到物理地址的转换。这样的chunks称为Page。
RISC-V CPU 将 虚拟地址 转换为 物理地址 的时,使用了一个类似B+树+字典树的三层树结构来存储页表。
在这种三层树结构中,页表的每一层存储了指向下一层页表的物理地址,直到最后一层,这些页表项(PTE)最终指向物理页。
- 根页表:根页表位于物理内存中,是一个 4096 字节(即 4KB)的页面,包含 512 个 PTE。这些 PTE 指向树中下一层的页表。
- 第二层和第三层页表:每一层的页表也包含 512 个 PTE,最终指向 物理页。
那么是如何使用虚拟地址的27位查找的呢?
- 虚拟地址的 前 9 位 用于在根页表中查找一个 PTE。
- 中间的 9 位 用于在第二层的页表中查找一个 PTE。
- 底部的 9 位 用于在第三层的页表中查找一个 PTE。
如果三个PTE任意一个不存在,分页硬件就会触发故障,由内核处理。
因为三层递归寻找物理地址似乎代价过高,所以一般处理器都会对虚拟地址的翻译结果进行缓存,也称为 TLB。
既然硬件会完成 pageTable 的查找,为什么还需要 walk()函数进行同样的获取物理地址的操作?
在某些操作系统(XV6)内核有它自己的 Page Table,用户进程也有自己的 Page Table。
内核会通过用户进程 Page Table,将用户的虚拟地址翻译得到物理地址,这样内核可以读写相应的物理内存地址。
另外操作系统可以对地址翻译有绝对控制就可以实现不同的功能。比如:一个 PTE 无效,硬件就会返回一个 page fault,针对 page fault 我们可以做各种事情(页面懒加载,COW 等等)。
二、内核地址空间
内核通过建立虚拟地址与物理地址的恒等映射(即虚拟地址=物理地址)实现快速内存访问。也就是将物理内存(RAM)与MMIO寄存器直接映射至内核虚拟地址空间,消除了常规页表查询开销(如访问0x80000000物理地址时直接使用同值虚拟地址)。
有几个内核虚拟地址不是直接映射的:跳板页和内核栈页。
跳板页作为跨特权级执行的安全通道,在用户/内核页表中均以镜像映射形式存在:
- 物理单副本:驻留于物理内存固定区域(如0x1000)
- 双重虚拟映射:
-
- 直接映射(0x1000→0x1000)用于内核初始化
- 高位映射(如0x3FFFFF000)占据虚拟地址空间顶端,构成【用户-内核】模式切换的统一入口点。
该设计保证进程在系统调用/中断时无需切换页表即可触发特权级跃迁,同时通过地址隔离(高位虚拟地址)规避用户空间非法访问。
每个进程持有双栈结构:
- 用户栈:运行用户态代码时使用,受用户页表管理
- 内核栈:进程陷入内核态时启用,具有以下特性:
-
- 物理连续性:从PHYSTOP起始向下分配单页(4KB)空间
- 虚拟重映射:除直接映射外,在虚拟地址空间高位建立独立映射域(如0xFFFF0000),形成逻辑隔离
- 保护性间隙(Guard Page):在每个内核栈虚拟映射底部插入无效页(PTE_V=0),构建硬件级溢出检测屏障。
保护页触发缺页异常(Page Fault)的优先级高于物理内存越界访问,确保栈溢出时立即引发可控内核异常(panic)
- 通过虚拟地址空间的二次映射冗余,实现:
a) 物理内存零浪费(单物理页对应多虚拟页)
b) 逻辑隔离强化(高位映射域天然远离用户/内核常规数据区)
c) 权限粒度控制(独立设置不同虚拟映射区域的访问权限)
三、Code: creating an address space
核心数据结构和关键函数
pagetable_t: 是一个指向 RISC-V 根页表页的指针。它可能是内核页表,或者是每个进程的页表之一。
walk: 查找虚拟地址的页表项(PTE)。它模仿RISC-V硬件的页表遍历。
mappages: 为新的映射安装页表项。它调用 walk 来查找每个虚拟地址的PTE,并初始化PTE。
初始化过程
xv6内核页表初始化与地址转换机制的核心流程可概括如下,这里其实也包含着一个进程的页表是怎么建立的:
- 页表初始化阶段
kvminit由main函数在系统启动初期调用,创建内核页表。
其核心通过kvmmake实现:分配物理页作为根页表页,调用kvmmap建立内核所需的地址映射(如UART、CLINT等设备内存)。该阶段直接操作物理地址,未启用分页硬件。
- 地址映射机制
kvmmap调用mappages将连续的虚拟地址区域映射到物理地址,通过逐页设置PTE实现。
关键特性是采用"直接映射"——内核虚拟地址与物理地址线性对应(如虚拟地址0x80000000对应物理地址0x0),这简化了内核代码对物理内存的操作。
- 页表遍历与维护
walk函数递归遍历三级页表结构,利用虚拟地址的9位索引逐层查找PTE。
若中间页表页不存在且alloc参数为真,会自动分配新页并更新上级PTE。该过程依赖直接映射特性,使用PTE中的物理地址直接作为虚拟地址访问下层页表页。
- 硬件交互与TLB管理
kvminithart将根页表物理地址写入satp寄存器,激活分页机制。
每次页表变更后需执行sfence.vma指令刷新当前CPU的TLB缓存,确保地址转换一致性。虽然RISC-V支持ASID优化TLB刷新,但xv6未使用该特性。
- 进程相关扩展
proc_mapstacks为每个进程分配内核栈,通过kvmmap在页表中建立映射,同时设置未映射的保护页防止栈溢出。系统调用copyin/copyout直接利用内核页表完成用户空间与内核空间的数据传输。
四、Code: exec
内存布局示例:
假设你有一个简单的程序,其中包含一个文本段(代码段)和一个数据段,内存中的布局可能如下所示:
+-------------------------+
| 代码段 (Text Segment) | <- 虚拟地址 0x0000 (程序的起始地址)
+-------------------------+
| 空白区域(可能是BSS等) |
+-------------------------+
| 数据段 (Data Segment) | <- 虚拟地址 0x1000 (数据段的起始地址)
+-------------------------+
| 堆栈段 (Stack Segment) | <- 虚拟地址(通常位于较高地址)
+-------------------------+
0x0000 是程序的起始地址,通常用于 文本段,即程序的可执行代码。这是因为操作系统通常将代码加载到内存的最底部。通常是只读。它在内存中的位置通常是从 0x0000 开始,即从虚拟地址的起始位置开始加载。
0x1000 是 数据段 的起始地址,它位于第二个 4KB 页面(即第一个页面后)。这个地址符合内存对齐要求,使得操作系统和硬件能高效地管理内存。可读可写,但是会要求对齐4KB
栈段 和 堆段 则位于内存的其他部分,栈通常位于高地址,堆段则在中间。
exec系统调用加载和初始化应用程序的过程是什么样子呢?
- ELF文件拆解与内存布局
-
- 像搭积木一样,把ELF文件拆解成代码块(.text段)、数据块(.data段)和未初始化数据块(.bss段)
- 代码段被标记为"只读可执行",数据段标记为"可读写
- 如果文件中的代码段比内存分配区域小,系统会自动用零填充剩余空间
- 栈空间的精妙布置
-
- 系统会先预留4KB的初始栈空间
- 把命令行参数像叠汉堡一样逐个压入栈顶,最上面放个"停止标志"(空指针)
- 在栈底藏了个"地雷页"——任何超过4KB的栈操作都会触发系统警报(页面错误)
- 安全防护机制
-
- 检查每个内存段的地址是否"越界"
- 动态链接的程序会被引导先执行"安全员"(动态链接器)检查所有依赖库
- 对内核空间设置"隐形防护罩",用户程序访问会立即被拦截
- 容错与恢复设计
-
- 整个过程像组装模型——只有确认所有零件正确才会拆除旧模型
- 发现任何零件异常(如文件损坏),立刻停止组装并清理现场
- 参数传递使用专用通道(a0/a1寄存器),避免内存混乱
- 内存管理诀窍
-
- 未初始化变量区(.bss)采用"按需分配"策略,节省内存资源
- 堆空间可以弹性扩展,而栈空间有严格限制防止失控
- 通过"页表"实现内存隔离,每个程序都以为自己独享整个内存空间