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 种状态,其转换关系如下图所示:
关键约束: 状态转换必须在持有
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 等控制状态寄存器。用于用户态陷入内核态时的完整上下文保存。
两者的分工如下图所示:
代码示例: 进程切换的核心函数
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 时开销可忽略。但在更复杂的操作系统中,通常使用链表或哈希表来管理进程表,以支持更大规模的进程数。