4. 进程地址空间:页表与虚拟内存布局、用户栈与内核栈、trapframe结构
进程地址空间是操作系统为每个进程提供的独立虚拟内存视图。在 xv6 中,RISC-V 架构采用 Sv39 分页机制,将 64 位虚拟地址的高 25 位作为保留位,仅使用低 39 位进行地址翻译。每个进程拥有独立的页表,确保进程间内存隔离,同时为 exec 和 fork 的协同工作提供基础。
4.1 虚拟内存布局总览
xv6 的虚拟地址空间从 0x0 到 MAXVA(0x3FFFFFFFE000),分为用户空间和内核空间两大部分。用户空间从低地址向上增长,内核空间从高地址向下映射。下图展示了 xv6 进程的完整虚拟内存布局:
4.2 页表结构与地址翻译
xv6 使用三级页表(Sv39 模式),每级页表包含 512 个页表项(PTE),每个 PTE 占 8 字节。虚拟地址被划分为以下字段:
| 字段 | 位范围 | 说明 |
|---|---|---|
| 保留位 | 63–39 | 必须为全 0 或全 1(符号扩展) |
| L2 索引 | 38–30 | 顶级页目录索引(9 位) |
| L1 索引 | 29–21 | 中间页目录索引(9 位) |
| L0 索引 | 20–12 | 末级页表索引(9 位) |
| 页内偏移 | 11–0 | 物理页框内偏移(12 位) |
页表项(PTE)包含物理页框号(PPN)和权限标志位。xv6 使用的主要标志位包括:
- V(Valid):页表项有效
- R(Readable):可读
- W(Writable):可写
- X(Executable):可执行
- U(User):用户模式可访问
4.3 用户栈与内核栈
每个进程拥有两个独立的栈:用户栈和内核栈。它们在地址空间中的位置和用途截然不同。
| 属性 | 用户栈 | 内核栈 |
|---|---|---|
| 所在空间 | 用户空间(低地址) | 内核空间(高地址) |
| 大小 | 动态增长(通常 1–8 页) | 固定大小(每进程 1 页,4096 字节) |
| 增长方向 | 向下增长(向低地址) | 向下增长(向低地址) |
| 访问权限 | 用户模式可读写 | 仅内核模式可访问 |
| 主要用途 | 函数调用、局部变量、返回地址 | 系统调用处理、中断处理、异常处理 |
| 分配时机 | exec 时分配初始栈页 | fork 或进程创建时分配 |
内核栈的关键特性是每个进程拥有独立的物理页,且内核栈的底部紧邻 trapframe 结构。当进程陷入内核时,硬件自动将栈指针切换到内核栈,内核通过 trapframe 保存和恢复用户寄存器状态。
4.4 trapframe 结构详解
trapframe 是 xv6 中至关重要的数据结构,位于每个进程内核栈的顶部(最低地址处)。当用户进程通过 ecall 指令陷入内核,或发生中断/异常时,硬件和软件协同将用户寄存器状态保存到 trapframe 中,以便在返回用户空间时精确恢复。
trapframe 在 kernel/proc.h 中定义如下:
struct trapframe {
/* 0 */ uint64 kernel_satp; // 内核页表地址
/* 8 */ uint64 kernel_sp; // 内核栈指针
/* 16 */ uint64 kernel_trap; // usertrap 函数地址
/* 24 */ uint64 epc; // 用户程序计数器(sepc)
/* 32 */ uint64 kernel_hartid;// 当前 CPU 核心 ID
/* 40 */ uint64 ra;
/* 48 */ uint64 sp;
/* 56 */ uint64 gp;
/* 64 */ uint64 tp;
/* 72 */ uint64 t0;
/* 80 */ uint64 t1;
/* 88 */ uint64 t2;
/* 96 */ uint64 s0;
/* 104 */ uint64 s1;
/* 112 */ uint64 a0;
/* 120 */ uint64 a1;
/* 128 */ uint64 a2;
/* 136 */ uint64 a3;
/* 144 */ uint64 a4;
/* 152 */ uint64 a5;
/* 160 */ uint64 a6;
/* 168 */ uint64 a7;
/* 176 */ uint64 s2;
/* 184 */ uint64 s3;
/* 192 */ uint64 s4;
/* 200 */ uint64 s5;
/* 208 */ uint64 s6;
/* 216 */ uint64 s7;
/* 224 */ uint64 s8;
/* 232 */ uint64 s9;
/* 240 */ uint64 s10;
/* 248 */ uint64 s11;
/* 256 */ uint64 t3;
/* 264 */ uint64 t4;
/* 272 */ uint64 t5;
/* 280 */ uint64 t6;
};
trapframe 的前 5 个字段(kernel_satp、kernel_sp、kernel_trap、epc、kernel_hartid)由内核在进程初始化时设置,用于陷入内核后的快速上下文切换。其余字段保存 32 个通用寄存器(x1–x31)的值。
epc 字段保存的是用户程序的异常返回地址(即 sepc CSR 的值)。当内核完成系统调用处理后,通过 epc 恢复用户程序计数器,确保用户程序从陷入点之后继续执行。在 fork 系统调用中,子进程的 trapframe 会复制父进程的 trapframe,因此子进程返回用户空间时,a0 寄存器被内核修改为 0(子进程 PID 为 0),而父进程的 a0 保持子进程 PID。
4.5 地址空间在 exec 与 fork 中的角色
exec 和 fork 对地址空间的操作截然不同,但都依赖页表和 trapframe 机制:
| 系统调用 | 地址空间操作 | 页表处理 | trapframe 处理 |
|---|---|---|---|
| exec | 完全替换:释放旧地址空间,创建新地址空间 | 分配新根页表,逐级映射 ELF 段、栈、trapframe | 重置 trapframe,设置新的 epc 为 ELF 入口点 |
| fork | 完整复制:创建父进程地址空间的精确副本 | 分配新根页表,逐页复制映射并拷贝物理页内容 | 复制父进程 trapframe,仅修改 a0 为 0 |
fork 通过 uvmcopy 函数逐页复制用户地址空间,父子进程的页表完全独立,但初始内容相同。exec 则通过 proc_pagetable 分配新页表,再调用 uvmalloc 和 uvmclear 建立映射,最后释放旧页表。
4.6 关键数据结构关系
进程地址空间的管理涉及多个内核数据结构的协同工作。下表总结了核心数据结构及其在地址空间管理中的角色:
| 数据结构 | 定义位置 | 地址空间相关字段 | 作用 |
|---|---|---|---|
struct proc |
kernel/proc.h | pagetable_t pagetableuint64 szstruct trapframe *trapframe |
进程控制块,持有页表指针、地址空间大小和 trapframe 指针 |
struct trapframe |
kernel/proc.h | 所有用户寄存器 + 内核切换信息 | 保存/恢复用户上下文,位于内核栈顶部 |
pagetable_t |
kernel/riscv.h | 根页表物理地址(pte_t*) | 三级页表的根节点,指向 L2 页目录 |
struct usyscall |
kernel/memlayout.h | uint64 pid |
用户空间可读的 PID 映射页(加速 getpid) |