4. 进程地址空间:页表与虚拟内存布局、用户栈与内核栈、trapframe结构

进程地址空间是操作系统为每个进程提供的独立虚拟内存视图。在 xv6 中,RISC-V 架构采用 Sv39 分页机制,将 64 位虚拟地址的高 25 位作为保留位,仅使用低 39 位进行地址翻译。每个进程拥有独立的页表,确保进程间内存隔离,同时为 exec 和 fork 的协同工作提供基础。

4.1 虚拟内存布局总览

xv6 的虚拟地址空间从 0x0 到 MAXVA(0x3FFFFFFFE000),分为用户空间和内核空间两大部分。用户空间从低地址向上增长,内核空间从高地址向下映射。下图展示了 xv6 进程的完整虚拟内存布局:

xv6 进程虚拟内存布局 xv6 进程虚拟内存布局 内核空间 (Kernel Space) trampoline · trapframe · 内核栈 · 内核代码/数据 MAXVA 用户栈 (User Stack) 向下增长 · 包含局部变量、函数调用帧、返回地址 空闲区域 (Heap 扩展方向 ↑ / 栈扩展方向 ↓) 堆 (Heap) 动态分配 · sbrk 系统调用扩展 · 向上增长 数据段 (Data Segment) 已初始化数据 (.data) · 未初始化数据 (.bss) 代码段 (Text Segment) 只读 · 包含 ELF 可执行指令 · 从 0x0 开始 地址 0x0 sbrk 扩展 栈向下增长
关键观察:用户栈和堆相向生长,中间的空闲区域用于容纳两者的动态扩展。当栈或堆耗尽空闲区域时,进程将触发缺页异常或分配失败。

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):用户模式可访问
地址翻译流程:MMU 从 satp 寄存器读取根页表物理地址 → 使用 L2 索引查找顶级页目录 → 使用 L1 索引查找中间页目录 → 使用 L0 索引查找末级页表 → 将 PPN 与页内偏移拼接得到物理地址。若任意一级 PTE 的 V 位为 0,则触发缺页异常。

4.3 用户栈与内核栈

每个进程拥有两个独立的栈:用户栈和内核栈。它们在地址空间中的位置和用途截然不同。

属性 用户栈 内核栈
所在空间 用户空间(低地址) 内核空间(高地址)
大小 动态增长(通常 1–8 页) 固定大小(每进程 1 页,4096 字节)
增长方向 向下增长(向低地址) 向下增长(向低地址)
访问权限 用户模式可读写 仅内核模式可访问
主要用途 函数调用、局部变量、返回地址 系统调用处理、中断处理、异常处理
分配时机 exec 时分配初始栈页 fork 或进程创建时分配

内核栈的关键特性是每个进程拥有独立的物理页,且内核栈的底部紧邻 trapframe 结构。当进程陷入内核时,硬件自动将栈指针切换到内核栈,内核通过 trapframe 保存和恢复用户寄存器状态。

用户栈与内核栈关系 用户栈与内核栈切换关系 用户栈 用户空间 局部变量 函数调用帧 返回地址 参数传递 sp 指向栈顶 陷入内核 ecall / 中断 内核栈 内核空间 trapframe 指针 内核函数调用 系统调用参数 中断上下文 sp 指向栈顶 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)的值。

重要:trapframe 的 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 建立映射,最后释放旧页表。

exec 与 fork 地址空间操作对比 exec 与 fork 地址空间操作对比 exec 系统调用 1. 分配新页表 (proc_pagetable) 2. 加载 ELF 段 (uvmalloc) 3. 分配用户栈页 4. 释放旧页表 (uvmfree) fork 系统调用 1. 分配新进程结构 (allocproc) 2. 复制地址空间 (uvmcopy) 3. 复制 trapframe 4. 设置子进程 a0 = 0

4.6 关键数据结构关系

进程地址空间的管理涉及多个内核数据结构的协同工作。下表总结了核心数据结构及其在地址空间管理中的角色:

数据结构 定义位置 地址空间相关字段 作用
struct proc kernel/proc.h pagetable_t pagetable
uint64 sz
struct 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)
实战要点:在 exec 实现中,新地址空间的创建顺序至关重要——必须先分配新页表并建立映射,确认成功后才能释放旧页表。如果中途失败(如 ELF 解析错误),必须回滚并保留原地址空间,保证进程状态一致性。fork 的 uvmcopy 采用“复制即映射”策略,逐页分配物理内存并拷贝内容,确保父子进程完全隔离。