本文讲述 xv6 中的一些细节流程,还有对之前文中遗留的问题做一些补充说明,主要有以下几个问题:
- 一次完整的磁盘中断流程
- 进入调度器后的详细流程
- sched 函数中的条件判断
- scheduler 函数中为什么要周期性关中断
一次完整的磁盘流程
此节讲述完整的磁盘读写流程,读写的流程总体差不多,这里以读为例子,先看“流程图”(看代码时的笔记图)
read
int $T_SYSCALL
sys_read
fileread
readi
bread
bget
iderw
idestart
还是从 A 进程的用户态 read 函数开始:
- A 进程用户态调用 read 读取磁盘上的数据
- read 通过 INT 0x80 软件中断,通过中断门进入内核,此时会关中断(NOTE 这里我以中断门来实现系统调用为例,会关中断,xv6 源代码是以陷阱门实现系统调用,不会关中断)
- 期间多次取锁放锁,进行了多次 pushcli 和 popcli,但总是成对存在,所以目前总体还是处于 0 次 puchcli 状态
- 如果磁盘数据没有缓存,调用 iderw 来读写磁盘
A 进程内核态,iderw 函数:
- acquire(&idelock),获取磁盘锁,pushcli,cpu.IF = 0,1 次pushcli 状态
- 调用 idestart,将要读写的命令,扇区号等信息写进磁盘端口,以此来请求磁盘操作。写磁盘端口是通过 out 指令实现的。向磁盘发送命令后,磁盘就会工作,磁盘完成工作后就会向 cpu 发送中断信号。
- A 进程调用 sleep 等待磁盘操作完成。在 sleep 函数中,获取 ptable.lock,释放 idelock,然后调用 sched 让出
by the way,这里补充说明 sched 函数中的条件检查,之前的文章都一笔带过了:
void sched(void) //让出CPU,重新调度
{
int intena;
struct proc *p = myproc();
if(!holding(&ptable.lock)) // 必须持有 ptable.lock
panic("sched ptable.lock");
if(mycpu()->ncli != 1) // 1 次 pushcli 状态
panic("sched locks");
if(p->state == RUNNING) // 只可能是 SLEEPING、RUNNABLE、ZOMBIE 三种状态之一
panic("sched running");
if(readeflags()&FL_IF) // 此时肯定处于关中断状态(通过中断门进入内核会关中断,1次pushcli状态也应该对应关中断状态)
panic("sched interruptible");
intena = mycpu()->intena;
swtch(&p->context, mycpu()->scheduler);
mycpu()->intena = intena;
}
sched 函数中有 4 个条件检查:
- xv6 是个多 CPU 多任务系统,在 sched 任务调度的时候,需要持有 ptable.lock,不然进程的上下文会发生紊乱,举个简单的例子,A 时间片到了,先将 A 的状态设置为 RUNNABLE,然后调用 sched 让出 CPU,如果此时没有持有 ptable.lock,那么 A 进程便可能在另一个 CPU 上被调度,那么便出现一个进程在两 CPU 上运行的情况,Error
- 在 sched 函数中应当只有 1 次 pushcli 状态,这个条件检查感觉有点难以理解。从实践看代码确实,不管从哪条路径到达 sched 函数,都应该只有 1 次 pushcli,这是获取 ptable.lock 的锁造成的。从个人理解上说,sched 是为了调度进程,是要从 A 进程到 B 进程,那么 A 进程的开关中断(pushcli popcli 次数)不应该带入 B 进程,除了一种情况——调度,那就是 A 进程获取 ptable.lock 但是要在 B 进程中释放 ptable.lock。所以 sched 中应当只有 1 次 pushcli
- 在真正切换进程上下文之前,会首先修改旧进程的状态,在 xv6 中是 SLEEPING、RUNNABLE、ZOMBIE 三种之一
- 在 sched 中理应处于关中断状态,如果是通过中断门进入内核的,那么本身就处于关中断。如果是通过陷阱门进入内核,那么有 1 次 pushcli,也会处于关中断状态
回到磁盘中断,当 A 进程调用 sched 切换到 B 进程,这里假如 B 进程最初是因为时间片到了,调用 yield->sched->swtch 主动让出 CPU 的,则 B 进程的流程如下:
- 回到 B 进程 sched 函数中的 swtch 下一条指令处,然后释放 ptable.lock,此时 cpu.IF = 0,0 次 pushcli 状态,cpu.IF = 0 仍然处于关中断状态 是因为 A 进程通过中断门进入内核关中断造成的
- B 进程经过一些列指令后,最后执行 iret 返回 B 进程的用户态,此时会开中断
- NOTE,这里我们假设 CPU 内部逻辑:每条指令执行后都会检查是否有中断发生,如果有中断发生且开中断的情况下,则去处理中断。再假设,此前的磁盘操作已完成,已经向 CPU 发生了中断信号。但是在此之前 CPU 一直没有去处理中断,是因为在此之前一直处于关中断状态。
- Now,CPU 处于开中断状态,继续执行 B 进程的指令,开中断后的第一条指令执行完成后,检查是否有中断发生,发现有磁盘中断,那么中断 B 进程,通过中断门进入内核(该过程关中断)
- 执行磁盘中断处理程序,也就是执行 insl 指令从 0x1f0 端口将磁盘数据读取到内存,然后唤醒等待该磁盘事件的进程,在我们的例子当中就是 A 进程
- 中断执行完成,iret 返回 B 进程用户态(该过程开中断)
- 继续执行 B 进程的指令
- 时钟中断 B 进程,再次通过中断门进入内核(关中断),发现 B 进程的时间片到了,那么调用 yield->sched->swtch 重新调度进程,这里假设调度到 A 进程
回到 A 进程的内核态,准确来说回到 iderw->sleep->sched->swtch 的下一条指令
- A 进程执行 release(ptable.lock)、acquire(idelock)、release(idelock),此时状态: cpu.IF = 0,0 次 pushcli
- 将磁盘中断获取的数据 cp 到 A 进程内核态
- A 进程层层返回
- 最后 iret 返回 A 进程用户态(开中断)
系统启动进入调度器后的流程
main
userinit //准备好 initcode 进程
mpmain
scheduler // 进入调度器
调度器上下文:
- 第一次进入 scheduler,for 循环找到 RUNNABLE 进程,目前就只有一个 initcode 进程为 RUNNABLE 进程,找到并切换上下文到 initcode 进程
initcode 进程上下文:
- 执行 forkret 函数,因为是第一次执行,会首先执行 iinit 来初始化根文件系统
- 执行 readsb 从磁盘中读取超级块数据,期间会使用 iderw 读写磁盘,具体流程见第一小节。总之,initcode 进程会调用 sleep 函数让出 CPU 来等待磁盘操作
- 在 sleep->sched->swtch 中再次切换上下文到 调度器上下文
调度器上下文:
- 切换到内核页表,然后遍历任务队列,寻找 RUNNABLE 进程,但是目前只有一个且处于 SLEEPING 状态的进程,所以这里调度器会轮询空转,直到磁盘中断处理完成,initcode 进程被唤醒。
- 再次切换上下文到 initcode 进程
initcode 进程上下文
- 回到 forkret->iinit->readsb->bread->iderw->sleep->sched->swtch 的下一条指令处,然后层层返回到 forkret
- 再执行 initlog 恢复日志,这里会涉及到两次磁盘读写,道理同上,不再赘述
- 第 4 次调度到 initcode 进程后,forkret 函数执行完毕,再执行 trapret 函数,其中包含了 iret 指令,至此回到用户态,开始执行 initcode 进程的逻辑
by the way again,这里解释为什么在 scheduler 函数中需要周期性的开中断:
void scheduler(void)
{
for(;;){
sti(); // 周期性开中断
for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){ //循环找一个RUNNABLE进程
...
}
}
}
进入调度器上下文有两条路径:
- 系统刚启动进入 scheduler
- sched 函数中 swtch 上下文到调度器
回想前面说的 sched 函数,在它切换到新进程并返回用户态之前理应都处于关中断的状态。而一直处于关中断且没有开中断的话会引发死循环。
举个例子,假设没有周期性的开中断,也就是 scheduler 代码长这样的话:
void scheduler(void)
{
for(;;){
// 遍历进程列表,寻找 RUNNABLE 进程
for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
...
}
}
}
假如当前系统只有一个进程(shell进程),它需要等待键盘输入而被阻塞(state==SLEEPING),内层循环是找不到 RUNNABLE 进程的,便回到外层循环,外层循环现在相当于什么也不做,便又再次进入内层循环。如此下来死循环。
而加入周期性的开中断后,CPU 便会响应中断。当有键盘输入时,中断当前的调度上下文而进入中断上下文,执行键盘中断处理程序,唤醒 shell 进程,中断处理完成后再回到调度上下文。此时内层循环便能找到一个 RUNNABLE 进程,然后切换到它的上下文执行。
本文就先补充这么多吧,这补充系列的文章是之前做了关于 xv6、nemu 的项目,将 xv6 启动到 nemu 上,这需要对很多地方细扣,对 xv6 的理解又增加了一些,分享出来。
停更这么久啊,一直再忙工作,学习新的东西,时间不是很多,当然也有懒的原因,后面慢慢克服回归吧。OK,那有什么问题欢迎来讨论交流。