3. 进程数据结构:struct proc 详解

在 xv6 内核中,每个进程都由一个 struct proc 结构体表示。该结构体集中管理了进程的所有核心属性:状态、上下文、内存映射、文件描述符 以及 调度信息。理解这个结构体是掌握进程管理的第一步。

核心要点: struct proc 是 xv6 进程控制块(PCB)的唯一实现。所有进程相关的系统调用(fork、exec、exit、wait)最终都在操作这个结构体的字段。

3.1 结构体全景图

下面这张 SVG 图展示了 struct proc 的完整字段布局,按功能划分为 5 个区域:

struct proc 字段全景图 进程状态 enum procstate state; int pid; int killed; int xstate; int exit_code; (UNUSED/USED/SLEEPING/ RUNNABLE/RUNNING/ZOMBIE) CPU 上下文 struct context context; uint64 ra; uint64 sp; uint64 s0-s11; struct trapframe *tf; (保存寄存器现场) 内存管理 pagetable_t pagetable; uint64 sz; char *kstack; uint64 trapframe_phys; (页表、堆大小、 内核栈) 文件系统 struct file *ofile[NOFILE]; struct inode *cwd; char name[16]; (打开文件表 + 当前工作目录) 调度与同步 struct spinlock lock; // 保护 proc 结构体的自旋锁 struct proc *parent; // 父进程指针(用于 wait 和 exit) int chan; // 睡眠通道(sleep/wakeup 使用) 关键设计原则 1. 每个字段都有明确的保护者:state 由 lock 保护;pagetable 由进程自身独占访问。 2. context 和 trapframe 分离:context 用于调度切换,trapframe 用于中断/系统调用入口。 3. ofile 数组固定 16 项(NOFILE=16),超出则 fork 失败。

3.2 进程状态枚举

xv6 定义了 6 种进程状态,状态转换由调度器和系统调用驱动:

状态 枚举值 含义 典型转换
UNUSED 0 进程槽空闲,可被分配 allocproc() → USED
USED 1 进程已分配,但尚未就绪 初始化后 → RUNNABLE
SLEEPING 2 进程因等待事件阻塞 sleep() → RUNNABLE (wakeup)
RUNNABLE 3 进程可被调度执行 scheduler() → RUNNING
RUNNING 4 进程正在 CPU 上执行 yield() → RUNNABLE
ZOMBIE 5 进程已退出,等待父进程回收 exit() → wait() → UNUSED
面试考点: ZOMBIE 状态是 xv6 进程管理的核心设计。子进程先 exit 变成 ZOMBIE,父进程调用 wait 读取退出状态后,内核才释放 proc 结构体。如果父进程不调用 wait,子进程会一直留在 ZOMBIE 状态,造成“僵尸进程”。

3.3 CPU 上下文:context 与 trapframe

xv6 使用两套寄存器保存机制,职责完全不同:

结构体 保存位置 保存内容 使用场景
struct context 内核栈底部 被调用者保存寄存器:ra, sp, s0-s11 进程调度切换(swtch 函数)
struct trapframe 独立物理页 所有通用寄存器、CSR、epc、sp等 用户态↔内核态切换(中断/系统调用)

下面这张 SVG 图展示了上下文切换时寄存器的流向:

上下文切换:context vs trapframe 进程 A trapframe 保存用户态寄存器 (中断/系统调用时) context 保存内核态寄存器 (调度切换时) swtch() 调度器 scheduler() 线程 保存当前 context 加载下一个 context swtch() 进程 B trapframe 保存用户态寄存器 (中断/系统调用时) context 保存内核态寄存器 (调度切换时) trapframe 在用户态↔内核态切换时保存/恢复;context 在进程↔调度器切换时保存/恢复

3.4 内存管理字段

每个进程拥有独立的地址空间,由以下字段管理:

  • pagetable_t pagetable:指向进程页表的指针(RISC-V 页表基址寄存器 satp 的值)。fork 时子进程复制父进程的页表,exec 时重新创建页表。
  • uint64 sz:进程堆的当前大小(从 0 到 sz 的虚拟地址空间已映射)。sbrk 系统调用通过增减 sz 来分配/释放堆内存。
  • char *kstack:内核栈的虚拟地址。每个进程在内核态执行时使用独立的栈,防止互相干扰。
  • uint64 trapframe_phys:trapframe 页的物理地址(用于内核页表映射)。
常见错误: 在 xv6 中,kstack 是进程在内核态使用的栈,不是用户态栈。用户态栈位于进程虚拟地址空间的顶部(由 exec 设置)。初学者容易混淆这两个栈的角色。

3.5 文件描述符表

xv6 使用固定大小的文件描述符数组:

// kernel/proc.h
#define NOFILE 16

struct file *ofile[NOFILE];   // 打开文件表
struct inode *cwd;            // 当前工作目录
char name[16];                // 进程名(调试用)

文件描述符表的关键行为:

  • fork 时共享:子进程复制父进程的 ofile 数组,每个文件引用计数 +1。
  • exec 时保留:exec 替换进程内存,但文件描述符表保持不变(实现 shell 重定向的基础)。
  • exit 时关闭:进程退出时遍历 ofile,对每个打开的文件调用 fileclose。
  • cwd 继承:子进程继承父进程的当前工作目录(inode 引用计数 +1)。

3.6 调度与同步字段

字段 类型 保护对象 使用方式
lock struct spinlock 整个 proc 结构体 所有对 proc 字段的读写必须先 acquire 锁
parent struct proc* 父进程指针 exit 时通知父进程;wait 时读取子进程状态
chan int 睡眠通道 sleep(chan) 和 wakeup(chan) 的匹配标识
锁的粒度分析: xv6 使用粗粒度锁——每个进程一把锁。这意味着访问进程状态、修改文件描述符表、更新调度字段都需要持有同一把锁。优点是实现简单,缺点是并发度低。现代内核(如 Linux)使用细粒度锁,每个字段或每组字段独立加锁。

3.7 进程生命周期与字段变化

下面这张 SVG 图展示了进程从创建到销毁过程中关键字段的变化:

进程生命周期:关键字段变化 allocproc() state = USED pid = 分配 kstack = 分配 context = 初始化 fork() pagetable = 复制 sz = 父进程 sz ofile = 复制 cwd = 继承 exec() pagetable = 重建 sz = 新程序大小 ofile = 保留 name = 更新 exit() state = ZOMBIE xstate = 退出码 字段保护规则总结 • state:由 proc.lock 保护,修改前必须 acquire • pagetable / sz:由进程自身独占访问(无锁),但 fork/exec 时需持有父进程锁 • ofile / cwd:由 proc.lock 保护,文件系统内部还有 inode 锁 • context:只在 swtch 函数中访问,swtch 本身是原子操作(关中断) • parent:由全局锁 ptable.lock 保护(xv6 使用全局进程表锁) • chan:与 state 联动,由 proc.lock 保护

3.8 代码实现:struct proc 完整定义

以下是 xv6 中 struct proc 的完整定义(来自 kernel/proc.h):

// kernel/proc.h
struct proc {
  struct spinlock lock;

  // p->lock must be held when using these:
  enum procstate state;        // Process state
  int pid;                     // Process ID
  int killed;                  // If non-zero, have been killed
  int xstate;                  // Exit status to be returned to parent's wait
  int exit_code;               // Exit code (set by exit syscall)

  // p->lock is not held for these; they are exclusive to the process:
  pagetable_t pagetable;       // User page table
  uint64 sz;                   // Size of process memory (bytes)
  char *kstack;                // Bottom of kernel stack for this process
  uint64 trapframe_phys;       // Physical address of trapframe page

  // These are private to the process, so p->lock need not be held:
  struct context context;      // swtch() here to run process
  struct trapframe *tf;        // Trapframe for current syscall/interrupt
  struct file *ofile[NOFILE];  // Open files
  struct inode *cwd;           // Current directory
  char name[16];               // Process name (debugging)
  struct proc *parent;         // Parent process
  int chan;                    // If non-zero, sleeping on chan
};
代码注释解读: xv6 源码中的注释明确标注了哪些字段受 p->lock 保护,哪些字段是进程私有的。这种注释风格是阅读内核源码的重要线索——看到 "p->lock must be held" 就表示访问该字段前必须先 acquire 锁。

3.9 本章小结