3. 进程控制块(proc):struct proc 结构体详解、状态字段、上下文、锁机制

进程控制块(Process Control Block, PCB)是操作系统用于描述和管理进程的核心数据结构。在 xv6 中,每个进程都由一个 struct proc 实例表示,它包含了进程运行所需的所有状态信息。本章将深入剖析该结构体的每个字段,并重点讲解状态转换、上下文切换以及锁机制的设计原理。

核心要点: struct proc 是 xv6 进程管理的基石,理解其字段布局是掌握 fork 与 exec 协同工作的前提。

3.1 struct proc 结构体全景

xv6 的 struct proc 定义在 kernel/proc.h 中,它紧凑地封装了进程的所有属性。下表列出了所有关键字段及其用途:

字段名 类型 描述 所属类别
sz uint64 进程虚拟地址空间的大小(字节数) 内存管理
pagetable_t pagetable_t 指向进程页表的指针(RISC-V 根页表物理地址) 内存管理
tf struct trapframe * 陷阱帧指针,保存中断/异常时的 CPU 寄存器状态 上下文
context struct context 进程切换时保存的 callee-saved 寄存器 上下文
kstack uint64 内核栈的虚拟地址(每个进程独立) 执行环境
state enum procstate 进程当前状态(UNUSED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE) 状态管理
pid int 进程唯一标识符 标识
parent struct proc * 指向父进程的指针 进程关系
name char[16] 进程名称(通常为可执行文件名) 调试/标识
chan void * 进程睡眠等待的通道地址 同步
killed int 标记进程是否被杀死(延迟处理) 生命周期
lock struct spinlock 保护该 proc 结构体的自旋锁 并发控制
ofile[NOFILE] struct file * 进程打开的文件表(最多 16 个) 文件系统
cwd struct inode * 当前工作目录的 inode 指针 文件系统
设计观察: xv6 将进程的“内存视图”(sz, pagetable)、“执行上下文”(tf, context, kstack)和“同步状态”(lock, chan, state)集中在一个结构体中,这种紧凑设计简化了内核代码,但也意味着对 proc 的任何操作都必须先获取锁。

3.2 进程状态字段与状态机

进程状态是操作系统调度和同步的核心。xv6 定义了 5 种状态,其转换关系如下图所示:

xv6 进程状态转换图 xv6 进程状态机 UNUSED SLEEPING RUNNABLE RUNNING ZOMBIE allocproc() scheduler() 调度 yield() 时间片用完 sleep(chan) wakeup(chan) exit() wait() 回收 状态转换说明: • allocproc() 从 UNUSED 池中分配一个新进程,状态变为 RUNNABLE • 只有 RUNNABLE 的进程才能被调度器选中进入 RUNNING;RUNNING 可因 sleep() 进入 SLEEPING 或 exit() 进入 ZOMBIE
关键约束: 状态转换必须在持有 p->lock 的情况下进行。例如,调度器在将进程状态从 RUNNABLE 改为 RUNNING 之前,必须同时持有当前 CPU 的锁和进程锁,以防止竞态条件。

3.3 上下文(context)与陷阱帧(trapframe)

xv6 使用两套独立的寄存器保存机制来支持进程切换和异常处理:

  • struct context:保存 callee-saved 寄存器(ra, sp, s0-s11)。用于 scheduler() 和 swtch() 之间的协作式切换。不保存 caller-saved 寄存器,因为这些寄存器在函数调用边界已被保存。
  • struct trapframe:保存所有通用寄存器(包括 ra, sp, gp, tp, t0-t6, s0-s11, a0-a7)以及 sepc, sstatus, satp 等控制状态寄存器。用于用户态陷入内核态时的完整上下文保存。

两者的分工如下图所示:

context 与 trapframe 的分工 进程上下文保存机制 struct context 用于进程切换(swtch) ra (返回地址) sp (栈指针) s0 - s11 (被调用者保存寄存器) struct trapframe 用于用户态↔内核态切换 所有 32 个通用寄存器 sepc, sstatus, satp 内核栈指针 (kernel_sp) 内核页表 (kernel_satp) 对比: context 只保存进程切换所需的最小寄存器集(12 个),切换成本低;trapframe 保存完整上下文(约 40 个值),用于用户态陷入时的完整恢复。
代码示例: 进程切换的核心函数 swtch() 只保存和恢复 context,不涉及 trapframe。trapframe 的保存/恢复由硬件和 usertrap()/usertrapret() 完成。
// kernel/swtch.S
.globl swtch
swtch:
    sd ra, 0(a0)
    sd sp, 8(a0)
    sd s0, 16(a0)
    sd s1, 24(a0)
    sd s2, 32(a0)
    sd s3, 40(a0)
    sd s4, 48(a0)
    sd s5, 56(a0)
    sd s6, 64(a0)
    sd s7, 72(a0)
    sd s8, 80(a0)
    sd s9, 88(a0)
    sd s10, 96(a0)
    sd s11, 104(a0)

    ld ra, 0(a1)
    ld sp, 8(a1)
    ld s0, 16(a1)
    ld s1, 24(a1)
    ld s2, 32(a1)
    ld s3, 40(a1)
    ld s4, 48(a1)
    ld s5, 56(a1)
    ld s6, 64(a1)
    ld s7, 72(a1)
    ld s8, 80(a1)
    ld s9, 88(a1)
    ld s10, 96(a1)
    ld s11, 104(a1)
    ret

3.4 锁机制:保护 proc 结构体的自旋锁

每个 struct proc 都内嵌了一个 struct spinlock 类型的自旋锁 lock。这个锁的设计遵循以下原则:

  • 细粒度锁:每个进程有自己的锁,而不是全局大锁。这允许不同进程的操作(如一个进程在 exit,另一个在 wakeup)并发执行。
  • 锁保护范围:保护 state、parent、chan、killed、ofile[] 等可变字段。不保护 pid(分配后不变)和 context(仅在切换时由调度器访问)。
  • 锁顺序:获取多个进程锁时,必须按 pid 升序获取,以避免死锁。例如,exit() 中唤醒父进程时,先锁自己,再锁父进程。

下表总结了关键操作中的锁使用模式:

操作 获取的锁 锁持有时间 说明
allocproc() p->lock 短(仅初始化) 从 UNUSED 池中分配,设置初始状态为 RUNNABLE
scheduler() p->lock + mycpu()->lock 中等(切换过程) 必须同时持有 CPU 锁和进程锁,防止进程被其他 CPU 调度
sleep() p->lock + 等待队列锁 长(直到被唤醒) 释放进程锁后进入睡眠,避免丢失 wakeup
wakeup() 遍历所有进程,获取每个 p->lock 逐个短持有 扫描进程表,找到 chan 匹配的 SLEEPING 进程
exit() p->lock → parent->lock 中等 按 pid 升序获取,避免死锁
wait() p->lock → 子进程锁 中等 查找 ZOMBIE 子进程,回收资源
常见陷阱: 在 fork() 实现中,父进程调用 allocproc() 后,子进程的 p->lock 已被获取。如果父进程在复制内存或文件表时尝试获取其他锁,必须注意不要与子进程的锁形成循环等待。xv6 通过严格的锁顺序(pid 升序)和短持有时间来规避此问题。

3.5 进程表与 proc 数组

xv6 使用一个全局数组 struct proc proc[NPROC] 来管理所有进程(NPROC 默认为 64)。这个数组在 main.c 中初始化,所有进程控制块在物理上连续存储。

进程表的访问模式:

  • 遍历:scheduler()、wakeup()、wait() 等函数需要遍历整个数组查找特定状态的进程。
  • 分配:allocproc() 线性扫描数组,找到第一个状态为 UNUSED 的槽位。
  • 回收:freeproc() 将进程状态重置为 UNUSED,释放内存和页表。
性能考量: 线性扫描 O(N) 在 NPROC=64 时开销可忽略。但在更复杂的操作系统中,通常使用链表或哈希表来管理进程表,以支持更大规模的进程数。