说明
- 阅读的代码是 xv6-riscv 版本的
概述
从一个用户进程(旧进程)切换到另一个用户进程(新进程)所涉及的步骤:
- 通过中断机制,从trap机制进入内核线程
- 调用swtch从该进程进行上下文切换,切换到到调度进程
- 通过调度进程切换到新进程的内核线程
- 在通过trap机制返回到新进程用户线程
上面过程就简单阐述,是怎样进行进程切换的
当然,里面会有很多细节,后面会详细介绍
从用户线程到内核线程
这一部分在上一章是有过介绍的,通过中断机制,通过uservec保存寄存器,然后进入usertrap()
usertrap
void
usertrap(void)
{
int which_dev = 0;
if((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode");
//省略 ··········
// give up the CPU if this is a timer interrupt.
if(which_dev == 2)
yield();
usertrapret();
}
其他部分就不过多赘述了(上一篇博客讲的很详细),通过判断中断类型,这是一个定时器中断,就调用yield()函数
从旧进程进入调度进程
yield
// 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);
}
该函数干的事情很简单,先获取该进程的锁,将进程状态设置为RUNNABLE
然后调用sched()函数进行后续操作
题:为什么再设置进程状态前,要先获取该进程的锁呢
因为一旦将进程状态设置为RUNNABLE,调度器就会认为该进程处于等待状态的可能会让他接下来运行,但实际上,该进程还是处于运行状态的
如果这是一个多核CPU的,就可能会发生一个进程被两个CPU同时运行,程序可能会立马崩溃。
所以我们要保证改变进程状态,保存寄存器,载入另一个进程的寄存器这三步是原子的。
sched
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;
}
- 该函数操作也很简单,进行一系列判断(防御性编程)
- 将mycpu()->intena保存下来,因为切换进程后,他会被改变
- 调用swtch切换上下文,该函数完成后就会进行入了调度进程
- 恢复mycpu()->intena(要等到该进程再次拿到CPU)
swtch
这是一段汇编代码,主要干的工作是将该进程的寄存器保存下来,然后载入调度进程的寄存器
.globl swtch
swtch:
# 保存寄存器
sd ra, 0(a0)
# 省略```````````````````
sd s11, 104(a0)
# 恢复所要切换进程的寄存器
ld ra, 0(a1)
# 省略```````````````````
ld s11, 104(a1)
ret
问题:为什么RISC-V中有32个寄存器,但是swtch函数中只保存并恢复了14个寄存器?
因为swtch函数是从C代码调用的,所以我们知道Caller Saved Register会被C编译器保存在当前的栈上。
该函数完成后,就进入了调度进程
调度
scheduler
void
scheduler(void)
{
struct proc *p;
struct cpu *c = mycpu();
c->proc = 0;
for(;;){
//通过确保设备可以中断来避免死锁。
intr_on();
for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if(p->state == RUNNABLE) {
// 切换到所选的进(释放它的锁,然后在跳回我们之前重新获取它)
p->state = RUNNING;
c->proc = p;
swtch(&c->context, &p->context);
// 进程目前已完成运行。
c->proc = 0;
}
release(&p->lock);
}
}
}
-
进程切换过来,是切换到swtch(&c->context, &p->context)这一行的,后面他会先释放锁
(避免该进程永远无法被调度,该锁是在yield()函数中获取的) -
scheduler在进程表上循环查找可运行的进程,该进程具有p->state == RUNNABLE。一旦找到一个进程,它将设置CPU当前进程变量c->proc,将该进程标记为RUNINING,然后调用swtch开始运行它
-
scheduler在一开始要获取进程锁,将RUNNABLE进程转换为RUNNING,在内核线程完全运行之前(在swtch之后,例如在yield中)绝不能释放锁。
调用swtch后就会进入另一个进程了
从调度进程进入新进程
该过程也是在完成swtch后完成切换的
他会从sched函数返回
void
sched(void)
{
int intena;
struct proc *p = myproc();
//、、、、、、、、、、、、、、
swtch(&p->context, &mycpu()->context);
mycpu()->intena = intena;
}
他会返回到swtch处,然后恢复 mycpu()->intena
从内核线程返回用户线程
这后面就是一步步返回,通过trap机制返回用户层,在上一篇博客讲的很详细,不过多赘述
XV6线程第一次调用swtch函数
allocproc
static struct proc*
allocproc(void)
{
struct proc *p;
//、、、、、、、、、、省略
memset(&p->context, 0, sizeof(p->context));
p->context.ra = (uint64)forkret;
p->context.sp = p->kstack + PGSIZE;
return p;
}
在创建进程时,会设置ra和sp
ra很重要,因为这是进程的第一个switch调用会返回的位置。同时因为进程需要有自己的栈,所以ra和sp都被设置了。这里设置的forkret函数就是进程的第一次调用swtch函数会切换到的“另一个”线程位置。
当调度线程将CPU交给该进程时,该进程会从forkret()函数处返回,执行该函数,
// A fork child's very first scheduling by scheduler()
// will swtch to forkret.
void
forkret(void)
{
static int first = 1;
// Still holding p->lock from scheduler.
release(&myproc()->lock);
if (first) {
// File system initialization must be run in the context of a
// regular process (e.g., because it calls sleep), and thus cannot
// be run from main().
first = 0;
fsinit(ROOTDEV);
}
usertrapret();
}
从代码中看,它的工作其实就是释放调度器之前获取的锁。函数最后的usertrapret函数其实也是一个假的函数,它会使得程序表现的看起来像是从trap中返回,但是对应的trapframe其实也是假的,这样才能跳到用户的第一个指令中。