xv6 进程
数据结构
struct context
struct context {
uint64 ra;
uint64 sp;
// callee-saved
uint64 s0;
uint64 s1;
uint64 s2;
uint64 s3;
uint64 s4;
uint64 s5;
uint64 s6;
uint64 s7;
uint64 s8;
uint64 s9;
uint64 s10;
uint64 s11;
};
-
作用:保存内核态的上下文,用于在内核中切换进程或切换到调度器。
-
字段含义
ra
:返回地址,保存调用swtch()
时的返回点。sp
:栈指针,指向当前内核栈的位置。s0-s11
:RISC-V 的被调用者保存寄存器(callee-saved),按照 ABI 约定,由被调用函数保存。- 大小:14 个 64 位字段,共 112 字节。
-
使用场景
- 内核上下文切换:
- 当进程让出 CPU(例如调用
yield()
或睡眠),swtch()
(kernel/swtch.S
)保存当前进程的context
,然后加载调度器的context
。 - 调度器(
scheduler()
)选择新进程后,swtch()
恢复新进程的context
。
- 当进程让出 CPU(例如调用
- 存储位置:
- 每个进程的
struct proc
中有struct context context
。 - 每个 CPU 的
struct cpu
中有struct context context
,用于切换到调度器。
- 每个进程的
- 内核上下文切换:
-
特点
- 只保存内核态必要的寄存器,不涉及用户态。
- 不包括临时寄存器(如
t0-t6
),因为它们是调用者保存的(caller-saved)。
struct trapframe
struct trapframe {
/* 0 */ uint64 kernel_satp; // kernel page table
/* 8 */ uint64 kernel_sp; // top of process's kernel stack
/* 16 */ uint64 kernel_trap; // usertrap()
/* 24 */ uint64 epc; // saved user program counter
/* 32 */ uint64 kernel_hartid; // saved kernel tp
/* 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;
};
-
作用:保存用户态的完整上下文,并在用户态和内核态切换时传递必要信息。
-
字段含义
- 内核相关字段(用于返回内核态):
kernel_satp
:内核页表的satp
值。kernel_sp
:进程的内核栈顶地址。kernel_trap
:陷阱处理函数地址(usertrap()
)。kernel_hartid
:当前 CPU 的 ID(保存tp
)。
- 用户相关字段:
epc
:用户程序计数器(sepc
),保存陷阱发生时的指令地址。ra
,sp
,gp
,tp
,t0-t6
,s0-s11
,a0-a7
:用户态的全套通用寄存器。
- 大小:35 个 64 位字段,共 280 字节。
- 内核相关字段(用于返回内核态):
-
使用场景
- 用户到内核切换:
- 发生陷阱(系统调用、中断、异常)时,
trampoline.S
的uservec
保存用户寄存器到trapframe
,加载内核字段,跳转到usertrap()
。
- 发生陷阱(系统调用、中断、异常)时,
- 内核到用户切换:
usertrapret()
设置trapframe
的内核字段,userret
恢复用户寄存器,切换页表,返回用户态。
- 存储位置:
- 每个进程的
struct proc
中有struct trapframe *trapframe
,指向用户页表中的固定页面(TRAMPOLINE - PGSIZE
)。
- 每个进程的
- 用户到内核切换:
-
特点
- 保存用户态所有寄存器,包括临时寄存器(
t0-t6
)和参数寄存器(a0-a7
)。 - 包含内核切换所需的信息。
- 保存用户态所有寄存器,包括临时寄存器(
struct context
和 struct trapframe
区别
在 xv6 中,struct context
和 struct trapframe
是两个关键的数据结构,用于保存寄存器状态以支持上下文切换。它们分别服务于不同的场景:context
用于内核态的上下文切换,而 trapframe
用于用户态和内核态之间的切换。以下我会详细对比它们的定义、作用、字段含义和使用场景,帮助你理解它们的区别和联系。
方面 | struct context | struct trapframe |
---|---|---|
作用 | 内核态上下文切换 | 用户态到内核态切换 |
保存内容 | 内核寄存器(ra , sp , s0-s11 ) | 用户寄存器 + 内核切换信息 |
寄存器范围 | 只保存被调用者保存寄存器 | 保存所有通用寄存器 + epc 等 |
大小 | 112 字节(14 个字段) | 280 字节(35 个字段) |
使用位置 | proc->context , cpu->context | proc->trapframe (固定页面) |
切换场景 | 进程间切换或切换到调度器 | 用户态和内核态之间的陷阱处理 |
代码实现 | swtch.S | trampoline.S |
struct cpu
// Per-CPU state.
struct cpu {
struct proc *proc; // The process running on this cpu, or null.
struct context context; // swtch() here to enter scheduler().
int noff; // Depth of push_off() nesting.
int intena; // Were interrupts enabled before push_off()?
};
extern struct cpu cpus[NCPU];
-
字段含义
proc
:指向当前 CPU 上运行的进程(struct proc),若为空则无进程运行。context
:保存 CPU 当前的内核上下文,用于切换到调度器。noff
:记录 push_off()(关闭中断)的嵌套层数。intena
:记录 push_off() 前中断是否启用,用于恢复时判断是否重新打开中断。
-
使用场景
- 多核支持:
cpus[NCPU]
是一个数组,每个 CPU 有独立的状态。 - 调度器使用
context
切换到scheduler()
。 noff
和intena
用于安全管理中断。
- 多核支持:
struct proc
enum procstate { UNUSED, USED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };
// Per-process state
struct proc {
struct spinlock lock;
// p->lock must be held when using these:
enum procstate state; // Process state
void *chan; // If non-zero, sleeping on chan 用于进程的阻塞和唤醒
int killed; // If non-zero, have been killed
int xstate; // Exit status to be returned to parent's wait
int pid; // Process ID
// wait_lock must be held when using this:
struct proc *parent; // Parent process
// these are private to the process, so p->lock need not be held.
uint64 kstack; // Virtual address of kernel stack
uint64 sz; // Size of process memory (bytes) 用户程序大小
pagetable_t pagetable; // User page table 用户页表
struct trapframe *trapframe; // data page for trampoline.S 用户态/内核态切换上下文trapframe
struct context context; // swtch() here to run process 进程之间的切换上下文context
struct file *ofile[NOFILE]; // Open files 文件描述符表
struct inode *cwd; // Current directory
char name[16]; // Process name (debugging)
};
字段含义
- 同步与状态(需持有 lock):
lock
:保护进程数据的自旋锁。state
:进程状态。chan
:睡眠时的等待通道。killed
:是否被杀死。xstate
:退出状态,传给父进程的 wait。pid
:进程 ID。
- 父进程(需持有 wait_lock):
parent
:指向父进程。
- 私有数据(无需锁):
kstack
:内核栈的虚拟地址。sz
:进程内存大小(字节)。pagetable
:用户页表。trapframe
:指向陷阱框架页面。context
:内核上下文。ofile[NOFILE]
:打开的文件描述符数组。cwd
:当前工作目录的 inode。name[16]
:进程名(调试用)。
给进程分配资源就是把资源记录在 PCB 中(比如文件描述符),进程的状态转移就是修改 PCB 的 state 字段。也就是说,管理进程就是管理 PCB。
进程状态
- UNUSED:表示该任务结构体未使用处于空闲状态,当要创建进程的时候就可以将这个结构体分配出去
- EMBRYO -> USED:该任务结构体刚分配出去,几乎什么资源都还没分配给该进程,所以设置为 USED 萌芽状态。使用这个状态是为了和 RUNNABLE 进行区分,表示 OS 正在进行进程初始化工作,初始化完毕后才会转设为 RUNNABLE。
- RUNNABLE:当进程需要的一切准备齐全之后就可以上 CPU 执行了,此时为 RUNNABLE 状态,表示就绪,能够上 CPU 执行。
- RUNNING:表示该进程正在 CPU 上执行,如果该时间片到了,则退下 CPU 变为 RUNNABLE 状态,如果运行过程中因为某些事件阻塞比如 IO 也退下 CPU 变为 SLEEPING 状态。
- SLEEPING:通常因为进程执行的过程中遇到某些事件阻塞通常就是 IO 操作,这时候调用 sleep 休眠使得进程处于 SLEEPING 状态,当事件结束比如 IO 结束之后调用 wakeup 就恢复到 RUNNABLE 状态,表明又可以上 CPU 执行了。还有一种情况是用户进程自己系统调用了 sleep,这个时候就要等待足够的时钟嘀嗒数才会 wakeup 唤醒用户进程。
- ZOMBIE:进程该干的活儿干完之后就会执行 exit 函数,也就是从 main 函数返回之后,由于进程是 shell 创建的,返回后会让他调用 exit。期间状态变为 ZOMBIE,这个状态一直持续到父进程调用 wait 来回收子进程资源。
初始化进程表
struct proc proc[NPROC];
// initialize the proc table at boot time.
void
procinit(void)
{
// 声明一个指向`struct proc`的指针,用于遍历进程表。
struct proc *p;
initlock(&pid_lock, "nextpid");
initlock(&wait_lock, "wait_lock");
for(p = proc; p < &proc[NPROC]; p++) {
initlock(&p->lock, "proc");
p->kstack = KSTACK((int) (p - proc));
}
}
分配进程
// Look in the process table for an UNUSED proc.
// If found, initialize state required to run in the kernel,
// and return with p->lock held.
// If there are no free procs, or a memory allocation fails, return 0.
static struct proc*
allocproc(void)
{
struct proc *p;
for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if(p->state == UNUSED) {
goto found;
} else {
release(&p->lock);
}
}
return 0;
found:
p->pid = allocpid();
p->state = USED;
// Allocate a trapframe page.
if((p->trapframe = (struct trapframe *)kalloc()) == 0){
freeproc(p);
release(&p->lock);
return 0;
}
// An empty user page table.
p->pagetable = proc_pagetable(p);
if(p->pagetable == 0){
freeproc(p);
release(&p->lock);
return 0;
}
// Set up new context to start executing at forkret,
// which returns to user space.
memset(&p->context, 0, sizeof(p->context));
p->context.ra = (uint64)forkret;
p->context.sp = p->kstack + PGSIZE;
return p;
}
进程的创建、退出、回收
进程创建:主要就是将 parent 进程的各种信息(用户地址空间内存及页表、用户上下文 trapframe、文件描述符等)复制给新进程。parent 进程会通过修改子进程 PCB 中的 trapframe→a0 来使子进程的 fork 系统调用返回 0。
// Create a new process, copying the parent.
// Sets up child kernel stack to return as if from fork() system call.
int
fork(void)
{
int i, pid;
struct proc *np;
struct proc *p = myproc();
// Allocate process.
if((np = allocproc()) == 0){
return -1;
}
// Copy user memory from parent to child.
if(uvmcopy(p->pagetable, np->pagetable, p->sz) < 0){
freeproc(np);
release(&np->lock);
return -1;
}
np->sz = p->sz;
// copy saved user registers.
*(np->trapframe) = *(p->trapframe);
// Cause fork to return 0 in the child.
np->trapframe->a0 = 0;
// increment reference counts on open file descriptors.
for(i = 0; i < NOFILE; i++)
if(p->ofile[i])
np->ofile[i] = filedup(p->ofile[i]);
np->cwd = idup(p->cwd);
safestrcpy(np->name, p->name, sizeof(p->name));
pid = np->pid;
release(&np->lock);
acquire(&wait_lock);
np->parent = p;
release(&wait_lock);
acquire(&np->lock);
np->state = RUNNABLE;
release(&np->lock);
return pid;
}
进程退出:xv6 进程 exit 时只是关闭了文件,并且设置退出码 xstate。调用 reparent 将该退出进程的子进程交给初始进程收养。其它资源的回收是由父进程负责的。
// Exit the current process. Does not return.
// An exited process remains in the zombie state
// until its parent calls wait().
void
exit(int status)
{
struct proc *p = myproc();
if(p == initproc)
panic("init exiting");
// Close all open files.
for(int fd = 0; fd < NOFILE; fd++){
if(p->ofile[fd]){
struct file *f = p->ofile[fd];
fileclose(f);
p->ofile[fd] = 0;
}
}
begin_op();
iput(p->cwd);
end_op();
p->cwd = 0;
acquire(&wait_lock);
// Give any children to init.
reparent(p);
// Parent might be sleeping in wait().
wakeup(p->parent);
acquire(&p->lock);
p->xstate = status;
p->state = ZOMBIE;
release(&wait_lock);
// Jump into the scheduler, never to return.
sched();
panic("zombie exit");
}
回收进程:
- wait 会遍历进程表,找到状态为 ZOMBIE 的子进程。
- 如果找到 ZOMBIE 子进程,会通过 freeproc 回收子进程占用资源,完成wait系统调用。
- 如果没有找到 ZOMBIE 子进程,父进程就会阻塞,等待某个子进程 exit 时唤醒自己,唤醒后重复上述过程(所以 wait 是阻塞的)
// Wait for a child process to exit and return its pid.
// Return -1 if this process has no children.
int
wait(uint64 addr)
{
struct proc *np;
int havekids, pid;
struct proc *p = myproc();
acquire(&wait_lock);
for(;;){
// Scan through table looking for exited children.
havekids = 0;
for(np = proc; np < &proc[NPROC]; np++){
if(np->parent == p){
// make sure the child isn't still in exit() or swtch().
acquire(&np->lock);
havekids = 1;
if(np->state == ZOMBIE){
// Found one.
pid = np->pid;
if(addr != 0 && copyout(p->pagetable, addr, (char *)&np->xstate,
sizeof(np->xstate)) < 0) {
release(&np->lock);
release(&wait_lock);
return -1;
}
freeproc(np);
release(&np->lock);
release(&wait_lock);
return pid;
}
release(&np->lock);
}
}
// No point waiting if we don't have any children.
if(!havekids || p->killed){
release(&wait_lock);
return -1;
}
// Wait for a child to exit.
sleep(p, &wait_lock); //DOC: wait-sleep
}
}
kill 进程
- kill 会遍历进程表,通过 pid 找到目标进程。
- 将目标进程 PCB 的 killed 字段置 1,如果目标进程被阻塞了,就将其 state 强制改为 RUNNABLE(就绪)
- 进程并不会被立刻杀死,进程在内核态返回用户态时(即发生在 usertrap 函数内部,比如系统调用返回)会检查自己的 killed 字段是否被置为 1,如果是,就主动执行 exit。
int
kill(int pid)
{
struct proc *p;
for(p = proc; p < &proc[NPROC]; p++){
acquire(&p->lock);
if(p->pid == pid){
p->killed = 1;
if(p->state == SLEEPING){
// Wake process from sleep().
p->state = RUNNABLE;
}
release(&p->lock);
return 0;
}
release(&p->lock);
}
return -1;
}
CPU
进程调用的本质是该进程占用 CPU,因此我们需要获取 CPU 当前的进程 PCB。如果要获取 CPU 正在运行的进程 PCB,就需要借助 CPU 表了。
struct cpu cpus[NCPU];
struct cpu {
struct proc *proc; // The process running on this cpu, or null.
struct context context; // swtch() here to enter scheduler().
int noff; // Depth of push_off() nesting.
int intena; // Were interrupts enabled before push_off()?
};
CPU 表中包含着各个 CPU 的运行信息(包含正在运行的进程)。在 OS 启动时,会将 CPU 号存储到寄存器里(kernel/start.c:50),编译器会保证内核代码不会乱动 tp 寄存器。所以在内核中,我们可以通过 tp 寄存器的值作为下标访问 CPU 表来获得当前 CPU 的信息,进而获取当前正在执行的进程(详见 myproc 函数)。
调度
操作系统通过进程调度使得各进程分时共享 CPU。进程被调度的原因有很多,比如时钟中断、被阻塞、主动让出 CPU(调用 yield 函数)等等。
调度的本质是将当前运行进程 A 的上下文保存至其进程控制块(PCB)中的 context
结构,随后从即将执行的进程 B 的 PCB 中的 context
加载其上下文,以实现进程间的无缝切换。:
- 保存当前进程的上下文:将当前运行进程(例如进程 A)的 CPU 寄存器状态保存到其 PCB 中。
- 加载新进程的上下文:从下一个要运行的进程(例如进程 B)的 PCB 中加载寄存器状态到 CPU。
- 结果:CPU 从进程 A 的执行点切换到进程 B 的执行点,看起来像是多个进程“同时”运行。
因此,进程的调度需要解决几个问题:
- 怎么选择下一个要执行的进程 => 调度算法。
- 进程调度应该由谁执行? => 调度器线程
- 怎么从当前进程切换到要执行进程 => 进程间上下文的切换
这涉及到几个重要函数:
swtch
:直接操作寄存器,完成上下文切换;是scheduler
和sched
的核心工具。sched
:进程主动调用swtch
返回调度器,表示进程的协作行为。scheduler
:调度器,选择进程并调用swtch
启动进程,表示 CPU 的主动调度行为。
进程 A (RUNNING)
|
v
sched() ----> 检查条件
| |
v v
swtch() ----> 保存 p_A->context
| |
v v
调度器 <---- 加载 c->context
|
v
循环 + intr_on()
|
v
找到进程 B (RUNNABLE)
|
v
状态 = RUNNING, c->proc = B
|
v
swtch() ----> 保存 c->context
| |
v v
进程 B <---- 加载 p_B->context
|
v
进程 B (RUNNING)
swtch
swtch 函数是由汇编语言编写的。它会将当前CPU的寄存器保存到context old中,并将 context new 加载到当前 CPU 的寄存器中。通过保存和恢复寄存器实现了上下文切换。
// 将将通用寄存器 ra 到 s11 的值保存到old->context
// 从new->context上下文结构中加载新任务的寄存器值到相应的通用寄存器中
void swtch(struct context *old, struct context *new);
# Context switch
#
# void swtch(struct context *old, struct context *new);
#
# Save current registers in old. Load from new.
.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
进程 p 通过如下调用切换到调度器线程,即上图中的步骤 2:
swtch(&p->context, &mycpu()->context);
调度器线程通过如下调用切换到进程 p,即上图中的步骤 3:
swtch(&c->context, &p->context);
sched
// Switch to scheduler. Must hold only p->lock
// and have changed proc->state. Saves and restores
// intena because intena is a property of this
// kernel thread, not this CPU. It should
// be proc->intena and proc->noff, but that would
// break in the few places where a lock is held but
// there's no process.
void
sched(void)
{
int intena;
struct proc *p = myproc();
// 检查当前进程的锁是否被持有。
if(!holding(&p->lock))
panic("sched p->lock");
// 检查当前 CPU 是否已经关闭了中断。如果没有,同样调用 panic 函数,因为 sched 函数不应该在中断未关闭的情况下被调用。
if(mycpu()->noff != 1)
panic("sched locks");
// 检查当前进程的状态是否为 RUNNING
if(p->state == RUNNING)
panic("sched running");
if(intr_get())
panic("sched interruptible");
// 保存当前 CPU 的中断状态到变量 intena 中
intena = mycpu()->intena;
// 将将通用寄存器 ra 到 s11 的值保存到p->context
// 从mycpu()->context上下文结构中加载新任务的寄存器值到相应的通用寄存器中
swtch(&p->context, &mycpu()->context);
// 恢复当前 CPU 的中断状态,使用之前保存的 intena 值
mycpu()->intena = intena;
}
作用
- sched 是进程主动切换回调度器的函数,用于让当前进程交出 CPU。
- 它在进程状态改变后调用,确保安全切换。
使用场景:在 yield、sleep、exit 等函数中调用,让进程返回调度器。
进程调度:scheduler
OS 启动后,每个 CPU 完成初始化后就进入 scheduler 函数,这个过程的控制流不属于任何进程。为了方便。可以称这些控制流属于调度器线程(scheduler thread),调度器线程进入 scheduler 后就一直在 for 循环里面不断进行进程调度。
如前文所述,当进行进程调度的时候(比如时间片用完),要先由旧进程切换到 scheduler 线程上下文,由 scheduler 线程执行进程调度算法找到新进程,再切换到新进程。
// Per-CPU process scheduler.
// Each CPU calls scheduler() after setting itself up.
// Scheduler never returns. It loops, doing:
// - choose a process to run.
// - swtch to start running that process.
// - eventually that process transfers control
// via swtch back to the scheduler.
void
scheduler(void)
{
struct proc *p;
struct cpu *c = mycpu();
c->proc = 0;
for(;;){
// Avoid deadlock by ensuring that devices can interrupt.
intr_on();
for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if(p->state == RUNNABLE) {
// Switch to chosen process. It is the process's job
// to release its lock and then reacquire it
// before jumping back to us.
p->state = RUNNING;
c->proc = p;
swtch(&c->context, &p->context);
// Process is done running for now.
// It should have changed its p->state before coming back.
c->proc = 0;
}
release(&p->lock);
}
}
}
上下文切换
下面介绍一下进程调度的整体流程,即上图中的过程:
进程 A 时间片用完,被时钟中断打断,进入内核。在内核中,进程 A 通过 swtch 切换到调度器控制流,调度器通过 swtch 切换到进程 B,完成了两个进程间的上下文切换。进程 B 切换到进程 A 同理。
时间片约 0.1 秒,即每 0.1 秒触发时钟中断。时钟中断强制当前用户进程调用 yield 函数,让出CPU。yield 将进行上下文切换,并回到调度函数 scheduler。
//
// handle an interrupt, exception, or system call from user space.
// called from trampoline.S
//
void
usertrap(void)
{
int which_dev = 0;
...
...
// give up the CPU if this is a timer interrupt.
if(which_dev == 2)
yield();
usertrapret();
}
// Give up the CPU for one scheduling round.
void
yield(void)
{
struct proc *p = myproc();
acquire(&p->lock);
p->state = RUNNABLE;
sched();
release(&p->lock);
}
// Switch to scheduler. Must hold only p->lock
// and have changed proc->state. Saves and restores
// intena because intena is a property of this
// kernel thread, not this CPU. It should
// be proc->intena and proc->noff, but that would
// break in the few places where a lock is held but
// there's no process.
void
sched(void)
{
int intena;
struct proc *p = myproc();
if(!holding(&p->lock))
panic("sched p->lock");
if(mycpu()->noff != 1)
panic("sched locks");
if(p->state == RUNNING)
panic("sched running");
if(intr_get())
panic("sched interruptible");
intena = mycpu()->intena;
swtch(&p->context, &mycpu()->context);
mycpu()->intena = intena;
}