1.(20分)
完成xv6的安装和启动,并完成sleep的编写。
题干:完成Lab: Xv6 and Unix utilities中的sleep(easy)任务,即在user/下添加sleep.c文件。在报告中提供sleep.c的代码,并提供sleep运行的屏幕截图。提示:在vmware下安装ubuntu20,可以较为顺利完成xv6安装和编译。
这里我们不选择用vmware,基于巨硬提出了我死了(WSL)的优秀Linux解决方案(还出了2),我推荐使用WSL2来完成这次作业(如果你本身就是Linux系统那忽略WSL)。
首先,安装WSL,搜索"启用或关闭Windows功能",如图:
然后勾选中”适用于Linux的Windows子系统“:
安装完毕之后,根据该网址的要求切换到WSL 2:https://docs.microsoft.com/zh-cn/windows/wsl/install-win10,从步骤2开始做起。
然后在Microsoft Store搜索Ubuntu 20.04 LTS并安装:
安装完毕后,搜索Ubuntu 20.04并选中,第一次会需要一段时间安装,安装完成后会让你注册一个UNIX账户及密码。成功后,即可通过该账户登录Ubuntu。
你也可以在命令行输入wsl或输入wsl -d Ubuntu-20.04来启动Ubuntu 20.04 LTS。
P.S. 如果安装时遇到0xc03a001a错误怎么办?
解决方案:
当我们安装完毕Ubuntu 20.04之后,第一步要做的就是换源,这里我推荐换清华源,如果还不会换源的请直接去http://mirrors.tuna.tsinghua.edu.cn上的使用帮助——Ubuntu查看如何换源,P.S.,WSL没有图形界面,所以习惯一下vim。
换源后,我们根据MIT 6.S081的提示,执行以下指令:
sudo apt-get install git build-essential gdb-multiarch qemu-system-misc gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu
这个过程校园网环境下大概持续4~5分钟
安装完毕后,git clone xv6-riscv的源码:
git clone git://github.com/mit-pdos/xv6-riscv.git
切到xv6的源码目录下,输入make qemu。
正常情况下会出现如图界面:
如果出现该界面,说明你的xv6已经在qemu成功启动了。
WSL同时还支持Visual Studio Code,只需要在插件市场搜索Remote WSL,就可以获得和Remote SSH一样的体验啦!
搞定了这些生产力工具之后,我们就要开始完成我们的sleep了。
首先在xv6-riscv/user下创建sleep.c,然后我们随便参照一个系统给的用户级应用是怎么写的,我这里选的是user/cat.c,因为它够短。
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
char buf[512];
void
cat(int fd)
{
int n;
while((n = read(fd, buf, sizeof(buf))) > 0) {
if (write(1, buf, n) != n) {
fprintf(2, "cat: write errorn");
exit(1);
}
}
if(n < 0){
fprintf(2, "cat: read errorn");
exit(1);
}
}
int
main(int argc, char *argv[])
{
int fd, i;
if(argc <= 1){
cat(0);
exit(0);
}
for(i = 1; i < argc; i++){
if((fd = open(argv[i], 0)) < 0){
fprintf(2, "cat: cannot open %sn", argv[i]);
exit(1);
}
cat(fd);
close(fd);
}
exit(0);
}
我们可以看到,应用的退出不使用return而是使用exit,同时,kernel/stat.h定义了各种的变量别名,而user/user.h定义了系统调用接口和C的一些库函数。
我们依葫芦画瓢,马上就能写出我们的sleep.c:
#include "kernel/types.h"
#include "user/user.h"
int main(int argc, char **argv)
{
// Return ASAP while there is no parameter.
if (argc != 2) {
fprintf(2,"sleep: wants 1 parameters, get %d parameter(s).n",argc-1);
exit(1);
}
int second;
second = atoi(argv[1]);
// If second == 0, return ASAP.
if (second <= 0) exit(1);
sleep(second);
exit(0);
}
我们这里判断了几种情况,1.参数数量不正确的时候,2.传入参数不能转化为数字的时候。3.成功调用的时候,使用系统提供的sleep系统调用接口。
没有事要做就赶快返回,这是编写高效应用的金科玉律。
2. (30分)
结合xv6 book 第1、2、7章,阅读xv6内核代码(kernel/目录下)的进程和调度相关文件,围绕swtch.S,proc.h/proc.c,理解进程的基本数据结构,组织方式,以及调度方法。提示:用source insight阅读代码较为方便。
- a) 修改proc.c中procdump函数,打印各进程的扩展信息,包括大小(多少字节)、内核栈地址、关键寄存器内容等,通过^p可以查看进程列表,提供运行屏幕截图。
- b) 在报告中,要求逐行对swtch.S,scheduler(void),sched(void),yield(void)等函数的核心部分进行解释,写出你对xv6中进程调度框架的理解。阐述越详细、硬件/软件接口部分理解越深,评分越高。
- c) 对照Linux的CFS进程调度算法,指出xv6的进程调度有何不足;设计一个更好的进程调度框架,可以用自然语言(可结合伪代码)描述,但不需要编码实现。
这里我们同样不使用Source Insight来看源码嗷,我们用Visual Studio Code。这里仅完成a)和b)的部分,你不会真打算看着我的教程全盘照抄吧。
a)的解答:
首先我们去看看procdump干了什么:
好我们看到procdump只输出了pid,状态和进程名字,这一点也不拉风,我们来看看struct proc有什么信息值得我们输出的:
enum procstate { UNUSED, 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
struct proc *parent; // Parent process
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
// 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
struct context context; // swtch() here to run process
struct file *ofile[NOFILE]; // Open files
struct inode *cwd; // Current directory
char name[16]; // Process name (debugging)
};
哦很多东西啊,比如父进程,pid,是否被killed,内核栈地址等等的这些信息,因为这个进程可以输出pid,那么我们认为procdump是一定在持有p->lock的这个自旋锁的,所以我们可以放心输出需要p->lock的信息。
怎么输出看个人喜好吧,这里是我的一个例子:
%u是我新加的,至于怎么加你们自己去看printf源码改改就行了,那就是个依葫芦画瓢的玩意,没啥技术含量。
效果看一下:
效果很不错呀,如果你还写了一些可以在后台挂着的应用,那么Ctrl-P就可以输出更多进程的东西了。
b)的解答:
首先我们来看看swtch.S:
# 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
首先.globl表示这是swtch函数,然后开始swtch函数的汇编实现。
因为a0会被内核刷新成当前进程的结构体起始地址,所以要对a0(a0对应函数的第一个参数,也就是old)做偏移以存放13个寄存器(ra,sp和s0~s11),因为这些寄存器都是64位的,所以每次都要偏移8个字节。sd指的是将寄存器的内容存储到存储器中。
ld则相反,会从存储器a1(a1对应函数的第一个参数,也就是new)中读取13个寄存器,完成进程上下文的切换。
这个时候ret,因为ra已经被刷新了,所以会跳转到新进程的指定语句执行新进程。
关于RISC-V的汇编知识可以参考:
https://github.com/riscv/riscv-asm-manual/blob/master/riscv-asm.mdgithub.com再来看看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;
// mycpu会返回当前的CPU状态,这个时候中断必须关闭以防止有抢占现象发生(也就是操作必须是原子性atomic的)
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++) {
// 简单轮询当前所有的进程,并挑选第一个合适的进程装载入CPU
// 首先要获得进程自旋锁,不然就不能调用swtch,会有竞争条件(你没有获得自己的自旋锁而放弃了所有其他锁,你是否还在执行?)
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.
// 设置状态为RUNNING,更改CPU当前进程为p,调用swtch切换进程上下文。
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.
// 这个时候已经执行完了进程,设置当前CPU进程为空,继续轮询,直到有就绪态的进程被发现。
c->proc = 0;
}
// 释放该进程自旋锁
release(&p->lock);
}
}
}
再来看看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)
{
// intena我这里没有一个很好的翻译,这里要去看proc.h和spinlock.c的实现,这里是一个关于自旋锁的控制函数push_off的辅助变量,push_off/pop_off和incr_off/incr_on最大的差别就是,push_off是一个有计数的incr_off,当第一次调用push_off的时候,intena会保持关闭CPU中断前的CPU中断状态;如果之前已经调用过push_off,intena不会被刷新。
// 如果当push_off的次数和pop_off对应上之后,调用最后一次pop_off会查看intena记录的调用push_off前的CPU中断状态是怎样的,如果是开着的,那就把CPU中断打开。也就是说intena是一个维持push_off发生前的CPU中断状态的一个变量。具体的实现可以看kernel/spinlock.c的L84-L110
// sched只有当进程就绪要被切换的时候才会被执行
int intena;
// 获得当前运行进程
struct proc *p = myproc();
// 这个时候p必须要持有自身的自旋锁
if(!holding(&p->lock))
panic("sched p->lock");
// noff只有当被执行第一次push_off的时候会为1,这个时候中断是关闭的。
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;
// 切换当前进程到scheduler
swtch(&p->context, &mycpu()->context);
// 执行完scheduler,还原intena
mycpu()->intena = intena;
}
最后我们来看看yield:
// Give up the CPU for one scheduling round.
void
yield(void)
{
// yield是进程主动放弃CPU占用的一个手段。
// 获得调用yield的进程
struct proc *p = myproc();
// 请求自旋锁以确认它没有在运行
acquire(&p->lock);
// 设置为就绪态
p->state = RUNNABLE;
// 切换到内核的切换函数
sched();
// 进程已经被成功切换,释放该进程自旋锁
release(&p->lock);
}
当然,进程调度肯定还不止这些,xv6是怎么把进程组织到proc数组里的?这个就是进程调度策略的核心。sched/scheduler/yield都只是内核用于切换当前CPU运行进程的手段,我们可以看到scheduler是在proc数组里线性搜索第一个RUNNABLE的进程的,当然,在这看起来就是根据pid搜进程了,因为proc数组的元素还真就是按照pid排序的。
总结一下:进程执行完了或者时间片到了,会由内核执行yield剥夺该进程的执行,设置进程为就绪态。然后该进程进入休眠,由sched判断该进程是否已经未在执行,判断完成后切换到调度程序scheduler进行进程调度。然后新进程继续执行工作直到时间片到或者时间片前执行完毕,内核继续调用yield,如此循环。
yield的执行时机可以去kerneltrap.c里面看一下,我们也知道,要yield那肯定是产生了trap,所以方向往这边找就对了。