xv6 进程与 struct proc / xv6 Process And struct proc
1. 这篇文章解决什么问题
proc 是 xv6 学习中最重要的词之一。
你会在很多地方看到:
struct proc *p;也会看到:
myproc()
allocproc()
fork()
exit()
wait()
scheduler()
sleep()
wakeup()这些函数都围绕进程展开。
如果 struct proc 没理解,后面调度、系统调用、文件描述符、虚拟内存都会模糊。
这篇文章的目标是:
- 解释程序和进程的区别。
- 解释
struct proc是什么。 - 解释
proc[NPROC]是什么。 - 逐类解释
struct proc的关键字段。 - 解释
trapframe和context的区别。 - 解释进程状态机。
- 解释
fork/exit/wait如何改变进程生命周期。 - 解释
ofile[]、cwd、pagetable等指针字段。 - 帮你建立“进程是资源集合和调度对象”的心智模型。
先给一句话:
进程 = 用户地址空间 + 内核进程记录 + 调度状态 + 打开资源 + 父子关系再展开:
struct proc
|
+-- identity
+-- lifecycle state
+-- user memory
+-- kernel execution support
+-- syscall/trap support
+-- open files
+-- current directory
+-- parent relation进程不是“正在运行的程序”这么简单。
正在运行只是进程的一种状态。
进程也可能正在睡眠、等待调度、已经退出但尚未被父进程回收。
1.1 本文基础词小注释
proc 是你后面会反复遇到的词。
先从最简单版本开始。
process
|
+-- 进程
+-- 一个正在被操作系统管理的程序运行实例
+-- 不等于磁盘上的程序文件
proc
|
+-- process 的缩写
+-- xv6 中常指 struct proc
+-- 内核用它记录一个进程的信息
pid
|
+-- process id
+-- 进程编号
+-- 是数字,不是 struct proc 指针
state
|
+-- 进程状态
+-- 表示进程现在能不能运行、是否睡眠、是否退出
ZOMBIE
|
+-- 已退出但还没被父进程 wait 回收的进程记录
+-- 不是还在运行的进程2. 程序和进程不是一回事
程序是静态的。
它可以是磁盘上的可执行文件。
进程是动态的。
它是操作系统正在管理的执行实例。
program
|
+-- stored as file
+-- contains instructions and data
+-- not necessarily running
process
|
+-- has address space
+-- has pid
+-- has execution state
+-- has kernel resources
+-- may run, sleep, or exit一个程序可以运行多次。
产生多个进程。
/bin/sh program
|
+-- process pid=3
+-- process pid=8
+-- process pid=12一个进程也可以通过 exec() 替换自己的程序内容。
same process pid=5
|
+-- before exec: running old program
|
+-- after exec: running new program所以不要把程序、进程、线程、CPU 混成一团。
xv6 主要用进程作为用户程序执行单位。
每个进程有自己的用户地址空间和内核记录。
3. 本篇涉及的源码文件
进程相关源码集中在:
kernel/proc.h
|
+-- enum procstate
+-- struct context
+-- struct cpu
+-- struct trapframe
+-- struct proc
kernel/proc.c
|
+-- proc table
+-- procinit
+-- allocproc
+-- freeproc
+-- userinit
+-- growproc
+-- fork
+-- exit
+-- wait
+-- scheduler
+-- sched
+-- yield
+-- sleep
+-- wakeup
+-- kill
+-- mycpu
+-- myproc
kernel/swtch.S
|
+-- context switch between scheduler and process
kernel/trap.c
|
+-- traps interact with current process
+-- timer interrupt may yield
+-- syscall returns through trapframe
kernel/vm.c
|
+-- process page table creation/copy/free
+-- copyin/copyout
kernel/file.c
|
+-- filedup and fileclose for open files
kernel/fs.c
|
+-- idup and iput for current directory inode
kernel/sysproc.c
|
+-- syscall wrapper layer for fork/exit/wait/getpid/sbrk/sleepproc.h 是结构定义。
proc.c 是生命周期和调度实现。
swtch.S 是底层切换。
vm.c、file.c、fs.c 提供进程资源的复制和释放。
4. proc[NPROC]:进程表
xv6 使用一个全局数组保存进程槽位。
概念上是:
proc[NPROC]
+---------+---------+---------+---------+---------+
| proc[0] | proc[1] | proc[2] | proc[3] | ... |
+---------+---------+---------+---------+---------+每个槽位是一个 struct proc 对象。
槽位不一定正在运行。
它可能处于不同状态。
+----------+----------+----------+----------+----------+
| UNUSED | RUNNABLE | SLEEPING | RUNNING | ZOMBIE |
+----------+----------+----------+----------+----------+allocproc() 会扫描进程表,寻找 UNUSED 槽位。
找到后把它变成可用进程。
proc table scan
|
v
find UNUSED slot
|
v
initialize fields
|
v
state = USED所以进程不是动态 malloc 出一个任意大小对象。
在 xv6 中,进程记录来自固定大小进程表。
这是教学内核的简单设计。
真实系统会更复杂。
但固定数组非常适合学习生命周期。
5. struct proc 总图
可以先把 struct proc 画成这样:
+------------------------------------------------------+
| struct proc |
+------------------------------------------------------+
| lock |
| state |
| chan |
| killed |
| xstate |
| pid |
+------------------------------------------------------+
| parent ----------------------------------> proc |
+------------------------------------------------------+
| kstack |
| sz |
| pagetable -------------------------------> page table |
| trapframe -------------------------------> trapframe |
| context |
+------------------------------------------------------+
| ofile[NOFILE] ---------------------------> files |
| cwd -------------------------------------> inode |
| name |
+------------------------------------------------------+按类别分:
synchronization
|
+-- lock
lifecycle
|
+-- state
+-- chan
+-- killed
+-- xstate
identity
|
+-- pid
+-- name
family
|
+-- parent
memory
|
+-- kstack
+-- sz
+-- pagetable
+-- trapframe
scheduling
|
+-- context
resources
|
+-- ofile[]
+-- cwd这样看就比字段列表清楚得多。
每个字段都服务于进程这个内核对象。
6. lock:保护进程状态
每个进程有自己的锁。
struct proc
|
+-- struct spinlock lock这个锁保护进程内部的一些状态。
特别是和生命周期、调度、sleep/wakeup 相关的字段。
p->lock protects
|
+-- state
+-- chan
+-- killed
+-- xstate
+-- parent interactions in some paths为什么进程需要锁?
因为 xv6 是多核内核。
多个 CPU 可能同时查看或修改进程状态。
例如:
- 一个 CPU 正在调度。
- 一个 CPU 正在唤醒进程。
- 一个 CPU 正在 kill 进程。
- 一个父进程正在 wait 子进程。
没有锁,状态变化可能乱掉。
CPU 0: scheduler sees p->state
CPU 1: wakeup changes p->state
CPU 2: exit changes p->state锁的意义不是“让代码慢一点”。
锁保护的是状态不变量。
例如:
一个进程不能同时被两个 CPU 运行。
一个睡眠进程不能错过唤醒。
一个退出进程必须被父进程正确回收。
7. state:进程状态机
state 是进程生命周期的核心字段。
典型状态:
UNUSED
USED
SLEEPING
RUNNABLE
RUNNING
ZOMBIE状态机:
UNUSED
|
| allocproc
v
USED
|
| userinit/fork completes setup
v
RUNNABLE
|
| scheduler chooses it
v
RUNNING
|
+-- yield/timer ------------> RUNNABLE
|
+-- sleep ------------------> SLEEPING
| |
| | wakeup
| v
| RUNNABLE
|
+-- exit -------------------> ZOMBIE
|
| parent wait
v
UNUSED各状态含义:
UNUSED 表示槽位空闲。
USED 表示槽位已分配,但还没准备好运行。
RUNNABLE 表示可以运行,等待调度。
RUNNING 表示正在某个 CPU 上运行。
SLEEPING 表示等待某个事件。
ZOMBIE 表示已经退出,但父进程还没回收。
状态不是注释。
调度器、wait、wakeup 都依赖状态。
所以状态变化通常要持锁。
8. pid 和 name:身份和调试
pid 是进程编号。
它是用户和内核识别进程的重要标识。
struct proc
|
+-- int pidpid 和 struct proc * 不一样。
pid 是编号。
struct proc * 是指向进程槽位的指针。
pid
|
+-- integer identity
struct proc *
|
+-- address of kernel process object进程槽位可能复用。
一个 struct proc 对象地址未来可能分配给新的 pid。
所以不要把槽位地址等同于永恒进程身份。
name 主要用于调试。
它帮助输出更易读。
pid=1 name=init
pid=2 name=sh真正的调度和资源管理不靠 name。
9. parent:父子关系
parent 是指向父进程的指针。
struct proc *parent;这不是父 pid。
它是指向另一个 struct proc 的指针。
child proc
|
+-- parent -----> parent proc父子关系用于:
wait()查找退出的子进程。exit()时把孤儿进程交给 init。- 维护进程树。
关系图:
init
|
+-- shell
|
+-- child command如果父进程退出,而子进程还活着,子进程需要重新挂到 init。
这叫 reparent。
否则子进程退出后没人 wait,会无法被回收。
parent exits
|
v
children reparented to init
|
v
init can wait and reap them这就是为什么 init 很特殊。
它不只是第一个进程。
它还负责收养孤儿进程。
10. pagetable 和 sz:用户地址空间
进程拥有用户地址空间。
pagetable 指向用户页表。
sz 表示用户内存大小。
struct proc
|
+-- pagetable
+-- sz关系:
proc
|
+-- pagetable -------> user page table
|
+-- sz --------------> size of user memory用户地址空间:
high address
+-------------------------------+ TRAMPOLINE
| trampoline |
+-------------------------------+ TRAPFRAME
| trapframe |
+-------------------------------+
| user stack |
+-------------------------------+
| heap |
+-------------------------------+
| data |
+-------------------------------+
| text |
+-------------------------------+ 0fork() 会复制用户地址空间。
exec() 会替换用户地址空间。
exit() 会释放用户地址空间。
sbrk() 会增长或缩小用户地址空间。
所以 pagetable 不是附属字段。
它是进程的核心。
没有地址空间,进程就不能承载用户程序。
11. trapframe:用户态寄存器现场
trapframe 是指针字段。
struct trapframe *trapframe;它指向保存用户寄存器的内存页。
proc
|
+-- trapframe ----+
|
v
+------------------+
| user registers |
| epc |
| a0-a7 |
| sp |
| ... |
+------------------+系统调用和 trap 都依赖它。
系统调用号在 a7。
返回值在 a0。
用户 pc 在 epc。
syscall path
|
v
trap saves registers
|
v
syscall reads trapframe->a7
|
v
syscall writes trapframe->a0
|
v
userret restores registerstrapframe 是用户态和内核态之间的桥。
它不是调度上下文。
这点要和 context 区分。
12. context:内核上下文切换现场
context 是嵌入结构体。
struct context context;它保存内核调度切换所需寄存器。
简化:
context
|
+-- ra
+-- sp
+-- s0-s11调度时:
process kernel context
|
v
swtch(&p->context, &cpu->context)
|
v
scheduler context再切回:
scheduler context
|
v
swtch(&cpu->context, &p->context)
|
v
process kernel context和 trapframe 对比:
trapframe
|
+-- saves user registers
+-- used by trap return
context
|
+-- saves kernel registers
+-- used by scheduler swtch一句话:
trapframe 是用户态现场。
context 是内核调度现场。
13. kstack:进程的内核栈
每个进程有内核栈。
kstack 保存内核栈地址。
struct proc
|
+-- kstack用户态运行时,用用户栈。
进入内核后,用内核栈。
user mode
|
+-- user stack
|
v trap
kernel mode
|
+-- process kernel stack为什么不用用户栈?
因为用户栈不可信。
用户程序可以破坏用户栈。
内核需要自己的可信栈来处理系统调用和中断。
每个进程有自己的内核栈,避免不同进程进入内核时互相覆盖。
proc A -> kernel stack A
proc B -> kernel stack B
proc C -> kernel stack Cprocinit() 会准备这些内核栈映射。
14. chan:睡眠等待的通道
chan 用于 sleep/wakeup。
void *chan;它是一个等待通道。
可以理解成“这个进程正在等待什么事件”。
process p
|
+-- state = SLEEPING
+-- chan = some addresswakeup(chan) 会唤醒睡在同一通道上的进程。
sleep(chan)
|
v
p->chan = chan
p->state = SLEEPING
wakeup(chan)
|
v
find p where p->chan == chan
set p->state = RUNNABLE为什么 chan 是 void *?
因为它只是一个标识。
不同子系统可以用不同地址作为等待通道。
例如管道可以用某个 pipe 对象地址或字段地址。
这是一种内核常见技巧。
15. killed 和 xstate
killed 表示进程是否被要求杀死。
int killed;有时候不能立刻终止进程。
内核会设置 killed。
进程在合适边界检查后退出。
kill(pid)
|
v
p->killed = 1
|
v
process later notices
|
v
exitxstate 是退出状态。
父进程 wait() 时可以拿到子进程退出状态。
child exit(status)
|
v
child->xstate = status
|
v
child becomes ZOMBIE
|
v
parent wait reads xstate这两个字段都和生命周期相关。
所以要和锁、wait、exit 一起理解。
16. ofile[]:打开文件表
每个进程有自己的文件描述符表。
struct file *ofile[NOFILE];这是一个数组。
数组里的每个元素是 struct file *。
proc.ofile
+------+-------+-------+-------+-----+
| fd 0 | fd 1 | fd 2 | fd 3 | ... |
+--+---+---+---+---+---+---+---+-----+
| | |
v v v
file file filefd 是用户程序看到的整数。
例如:
write(1, "hi", 2);这里 1 是 fd。
内核用它索引当前进程的 ofile[]。
fd integer
|
v
proc.ofile[fd]
|
v
struct filefork() 时,子进程会复制父进程打开文件引用。
不是复制文件内容。
而是让子进程的 ofile[] 指向同一些 struct file,并增加引用计数。
parent ofile[3] ----+
v
struct file ref=2
^
child ofile[3] ----+这就是为什么文件对象需要引用计数。
17. cwd:当前工作目录
cwd 指向当前工作目录 inode。
struct inode *cwd;它不是字符串路径。
而是 inode 指针。
proc
|
+-- cwd ----> inode of current directory为什么不用字符串?
因为文件系统操作最终要落到 inode。
路径解析会从某个目录 inode 开始。
当前工作目录就是相对路径解析的起点。
relative path "a/b"
|
v
start from proc->cwd
|
v
lookup a
|
v
lookup bfork() 时,子进程会通过 idup() 增加 cwd inode 引用。
exit() 时,进程会 iput() 当前目录。
这又是一个引用计数和生命周期问题。
18. allocproc():从空槽位创建进程对象
allocproc() 是进程生命周期起点。
它做的事:
allocproc()
|
+-- scan proc table
+-- find UNUSED proc
+-- set state = USED
+-- assign pid
+-- allocate trapframe
+-- create user page table
+-- initialize context
+-- return proc pointer状态变化:
UNUSED
|
v
USED注意:
allocproc() 之后,进程还不能运行。
它只是分配并初始化了内核进程对象。
要等用户地址空间、trapframe、返回路径等准备好,再变成 RUNNABLE。
如果中途失败,需要释放已经分配的资源。
这就是 freeproc() 的作用。
19. userinit():第一个进程
userinit() 创建第一个用户进程。
路径:
main()
|
v
userinit()
|
v
allocproc()
|
v
set up initial user code
|
v
state = RUNNABLE第一个进程特殊。
它不是由 fork() 创建。
它由内核手动创建。
它会运行一小段初始用户代码,再执行 /init。
kernel-created first proc
|
v
initcode
|
v
exec("/init")
|
v
user/init.c这说明:
进程系统必须先由内核“点火”。
有了第一个进程后,后续用户进程可以通过 fork() 和 exec() 扩展出来。
20. fork():复制当前进程
fork() 创建子进程。
但它不是简单复制 struct proc。
它要复制或共享多种资源。
fork()
|
+-- allocproc()
|
+-- copy user memory with uvmcopy()
|
+-- copy trapframe
|
+-- set child's trapframe->a0 = 0
|
+-- duplicate open files with filedup()
|
+-- duplicate cwd with idup()
|
+-- set parent
|
+-- set child RUNNABLE
|
+-- return child pid to parent父子返回值不同。
parent fork() returns child pid
child fork() returns 0为什么?
因为子进程的 trapframe 被修改。
child->trapframe->a0 = 0这会让子进程返回用户态时看到 fork() 返回 0。
对象关系:
parent proc
|
+-- pagetable copied to child pagetable
+-- trapframe copied to child trapframe
+-- ofile pointers duplicated with ref++
+-- cwd duplicated with ref++
|
v
child proc这就是 fork() 的精髓。
它既复制,又共享。
内存复制。
文件引用共享。
cwd 引用共享。
21. exit():进程退出
exit() 结束当前进程。
它不能简单把进程删掉。
因为父进程可能还需要通过 wait() 获取退出状态。
exit() 大致做:
exit(status)
|
+-- close all open files
+-- release cwd
+-- reparent children to init
+-- wake up parent
+-- set xstate
+-- state = ZOMBIE
+-- call sched()为什么变成 ZOMBIE?
因为进程已经停止运行。
但它的退出状态和部分记录还要留给父进程。
running process
|
v
exit
|
v
ZOMBIE
|
v
parent wait
|
v
UNUSEDZOMBIE 不是还在执行的进程。
它是等待回收的退出记录。
22. wait():父进程回收子进程
wait() 让父进程等待子进程退出。
它会扫描进程表,找当前进程的子进程。
如果有子进程是 ZOMBIE,就回收它。
wait()
|
v
scan proc table
|
+-- find child of current proc
|
+-- if child ZOMBIE
| |
| +-- copy exit status to user
| +-- freeproc(child)
| +-- return child pid
|
+-- if children exist but none zombie
|
+-- sleep waiting for child exit父子关系在这里发挥作用。
child->parent == current procwait() 不是等任意进程。
它等自己的子进程。
如果没有子进程,返回错误。
如果有子进程但都没退出,睡眠等待。
这就是为什么 exit() 要唤醒父进程。
23. scheduler():选择 RUNNABLE 进程
调度器循环扫描进程表。
寻找 RUNNABLE 进程。
找到后把它设置为 RUNNING。
然后通过 swtch() 切过去。
scheduler()
|
v
for each proc
|
+-- acquire p->lock
|
+-- if p->state == RUNNABLE
| |
| +-- p->state = RUNNING
| +-- c->proc = p
| +-- swtch(&c->context, &p->context)
| +-- c->proc = 0
|
+-- release p->lock调度器本身也有上下文。
每个 CPU 有 struct cpu。
struct cpu
|
+-- proc
+-- context关系:
CPU scheduler context
|
v
swtch
|
v
process kernel context调度器不创建进程。
它只选择能运行的进程。
进程什么时候变成 RUNNABLE,由 userinit()、fork()、wakeup() 等函数决定。
24. mycpu() 和 myproc()
内核经常需要知道当前 CPU 和当前进程。
mycpu() 返回当前 hart 对应的 struct cpu。
myproc() 返回当前 CPU 正在运行的进程。
关系:
hart id
|
v
cpus[hartid]
|
v
struct cpu
|
v
cpu->proc
|
v
current struct proc为什么这事不简单?
因为中断和调度可能改变当前 CPU 状态。
读 mycpu()、myproc() 时要注意中断开关。
内核需要避免在读取当前 CPU/进程过程中被打断并迁移状态。
初学时先记住:
myproc() 是很多系统调用找到当前进程的入口。
例如 getpid()。
sys_getpid()
|
v
myproc()
|
v
p->pid25. 进程对象关系总图
现在把所有关系合起来。
CPU / hart
|
v
struct cpu
|
+-- proc -----------------------------+
| |
+-- scheduler context |
v
+-------------------+
| struct proc |
+-------------------+
| lock |
| state |
| pid |
| parent ----------+----> parent proc
| pagetable -------+----> user page table
| trapframe -------+----> saved user regs
| context |
| kstack |
| ofile[] --------+----> struct file
| cwd ------------+----> struct inode
+-------------------+生命周期和资源:
allocproc
|
+-- allocate trapframe
+-- create pagetable
+-- initialize context
|
v
RUNNABLE
|
v
scheduler runs process
|
+-- syscall
+-- trap
+-- sleep
+-- yield
+-- exit
|
v
ZOMBIE
|
v
wait/freeproc
|
+-- free trapframe
+-- free pagetable
+-- close/release resources already handled
|
v
UNUSED如果你能看懂这两张图,proc 的大半就通了。
26. 常见误解
误解一:进程就是程序。
不是。
程序是静态代码和数据。
进程是操作系统管理的执行实例。
误解二:struct proc *p 里的 p 就是 pid。
不是。
p 是指向进程对象的指针。
p->pid 才是编号。
误解三:fork() 只是复制 struct proc。
不是。
它复制用户内存和 trapframe,也复制文件引用和 cwd 引用。
误解四:ZOMBIE 是还在运行的进程。
不是。
它已经退出,只是在等父进程回收。
误解五:trapframe 和 context 一样。
不是。
trapframe 用于用户态返回。
context 用于内核调度切换。
误解六:fd 是全局的。
不是。
fd 是进程局部编号。
它通过当前进程的 ofile[] 找到 struct file。
误解七:cwd 是字符串。
不是。
它是当前目录 inode 指针。
误解八:调度器会运行所有进程。
不准确。
调度器只选择 RUNNABLE 进程。
睡眠和僵尸进程不会被调度运行。
27. 小实验:打印进程生命周期
实验目标:
观察 allocproc()、fork()、exit()、wait() 中的状态变化。
可以打印:
allocproc: p=... pid=... state=USED
fork: parent=... child=...
exit: pid=... status=...
wait: parent=... reaped=...
freeproc: pid=...观察问题:
- 进程槽位地址是否可能复用?
- child 的 parent 指向谁?
exit()后为什么不是立刻UNUSED?wait()回收了什么?
实验用户程序:
parent starts
|
v
fork child
|
+-- child exits
|
v
parent waits把输出和状态机对照。
UNUSED -> USED -> RUNNABLE -> RUNNING -> ZOMBIE -> UNUSED28. 小实验:观察 fd 和 ofile
实验目标:
理解进程的 fd 表。
写一个用户程序:
open a file
write data
fork
child writes
parent writes
close在内核中观察:
proc.ofile[fd]。struct file *地址。file->ref。
预期:
parent fd -> file object
child fd -> same file object after fork
file ref increases这能证明:
fd 是进程局部整数。
struct file 是内核对象。
fork 后文件引用被共享。
29. 小实验:观察 trapframe 返回值
实验目标:
理解 fork() 为什么父子返回值不同。
在 fork() 中观察:
parent return value
child->trapframe->a0路径:
parent calls fork
|
v
kernel creates child
|
v
child trapframe copied from parent
|
v
child->trapframe->a0 = 0
|
v
parent returns child pid
|
v
child later returns 0这是 trapframe 和进程结合的经典例子。
它能帮助你真正理解:
系统调用返回值不是魔法。
它就是写回将来恢复到用户态的寄存器位置。
30. 本篇总结
struct proc 是 xv6 中最重要的结构之一。
它把操作系统多个机制绑在一起。
process identity
|
+-- pid
+-- name
lifecycle
|
+-- state
+-- killed
+-- xstate
memory
|
+-- pagetable
+-- sz
+-- trapframe
+-- kstack
scheduling
|
+-- context
+-- chan
resources
|
+-- ofile[]
+-- cwd
family
|
+-- parent理解 proc 后,很多 xv6 代码会连起来:
- 系统调用通过当前 proc 找到 trapframe。
- 调度器通过 proc 状态选择进程。
- fork 复制 proc 相关资源。
- exit 把 proc 变成 ZOMBIE。
- wait 回收子 proc。
- 文件系统通过 proc 的 ofile 和 cwd 连接用户请求。
- 虚拟内存通过 proc 的 pagetable 连接地址空间。
下一篇进入:
09-context-switch-and-scheduler.md那里会继续深入 scheduler()、sched()、yield()、swtch.S,解释 CPU 如何从一个进程切到另一个进程。