这一部分要实现的是对多核处理器的支持,然后实现系统调用只喜欢用户应用创建新的应用,而且还要实现round-robin调度算法
Multiprocessor support
jos中对CPU进行了抽象
要描述一个CPU, 需要知道id,运行状态,当前正在运行的进程
所有的cpu数目放在cpus数组中
接下来则是对有多个cpu的处理器的抽象,这里使用了三个结构体,总之是比较乱,目前还不能完全看懂
多核处理器的初始化都在mp_init函数中完成,首先是调用mpconfig函数,主要功能是寻找一个MP 配置条目,然后对所有的CPU进行配置,找到启动的处理器
接下来就是要完成lapic(local advanced programmable interrupt controller)
apic主要是为与其连接的处理器传送中断信号
而CPU控制与其相关联的apic需要通过读写其中的寄存器,而读写寄存器则是通过映射IO(memory mapped IO)的方法来实现的
有一些物理内存是与apic的寄存器硬件相连的,这样可以通过读写内存完成对寄存器的读写
JOS中在ULIM之上,预留了4MB的内存空间来映射APIC的寄存器
所以在使用APIC之前首先要完成映射。首先需要调用mmio_map_region函数来实现
mmio_map_region函数的实现
这部分的实现还是比较简单的,注意这里的静态变量base,是记录当前还未分配空间的其实地址的
从MMIOBASE开始,分配出size大小的空间出来,调用boot_map_region即可
Exercise 2
在操作系统启动之后,需要启动其他的应用cpu(application processor, ap)
都在boot_aps中完成
这里是将启动代码放到了MPENTRY_PADDR处,而代码的来源则是在kern/mpentry.S中,这里的代码功能与boot.S中的非常类似,主要就是开启分页,转到内核栈上去,当然这个时候实际上内核栈还没建好呢
在执行完mpentry.S中的代码之后,将会跳转到mp_main函数中去
而这里需要提前做的,就是将MPENTRY_PADDR处的物理页表标识为已用,这样不会讲这一页放在空闲链表中分配出去
只需要在page_init中添加一个判断就可以了
question
这里的mpentry.S的代码是链接到KERNBASE之上的,也就是说,其中的符号的地址都是在KERNBASE之上的,但是实际上现在是将这些代码移动到了物理地址0X7000处,而且当前的AP处于实模式下,只支持1MB的物理地址寻址,所以这个时候需要计算相对于0X7000的地址,才能跳转到正确的位置上去
Exercise 3
因为从一个核变成了多个核,所以现在需要注意区分哪些是一个核独有的,哪些是共享的
每个核独有的变量应该有:
- 内核栈,因为不同的核可能同时进入到内核中执行,因此需要有不同的内核栈
- TSS描述符
- 每个核的当前执行的任务
- 每个核的寄存器
首先需要为每个核都分配一个内核栈,修改mem_init_mp代码
每个内核栈的大小是KSTKSIZE,而内核栈之间的间距是KSTKGAP,起到保护作用
Exercise 4
需要为每个核的TSS进行初始化任务
Exercise 5
在完成上述的工作之后,4个CPU都启动,但是除了BSP之外,剩下的三个AP都是在空转
因为这个时候还没有处理竞争,所以如果三个AP进入了内核代码的话,很可能会出现错误,所以首先需要解决这个问题
一般在单处理器操作系统中会采用大内核锁,就是当一个CPU需要进入内核的时候,必须获取整个内核的锁
这样的话,所有的CPU可以并行的跑用户程序,但是内核程序最多只能有一个在跑
这个当然是很粗糙的设计,但确实简单而且安全的
更好的设计是给进程表的每个条目以及其他可能出现竞争的变量单独的加锁,这样可以在内核上实现更高的并行度,但是会大福大增加设计的复杂性,比如说XV6就使用了不止一个锁
在kern/spinlock.*中实现了lock_kernel和unlock_kernel()函数,单反是进入到内核临界区之前都需要加锁,离开内核临界区之后需要尽快释放锁
- 在启动的时候,BSP启动其余的CPU之前,BSP需要取得内核锁
- Mp_main中,也就是CPU被启动之后执行的第一个函数,这里应该是调用调度函数,选择一个进程来执行的,但是在执行调度函数之前,必须获取锁
- trap函数也要修改,因为可以访问临界区的CPU只能有一个,所以从用户态陷入到内核态的话,要加锁,因为可能多个CPU同时陷入内核态
- Env_run函数,也就是启动进程的函数,之前在试验3中实现的,在这个函数执行结束之后,就将跳回到用户态,此时离开内核,也就是需要将内核锁释放
启动其他CPU之前加锁
在调用调度函数,也就是进入内核临界区的时候加锁
如果是从用户态进入到内核态的话,需要获得锁
在env_pop_tf执行结束之后,就回到用户态了,所以一定要在此之前释放
lock_kernel的调用关系图
unlock_kernel的调用关系图
Question 2
既然有了大内核锁,为什么还需要为四个CPU分配不同的内核栈
因为不同的内核栈上可能保存有不同的信息,在一个CPU从内核退出来之后,有可能在内核栈中留下了一些将来还有用的数据,所以一定要有单独的栈
Exercise 6
实现round-robin调度算法
主要是在sched_yield函数内实现,从该核上一次运行的进程开始,在进程描述符表中寻找下一个可以运行的进程,如果没找到而且上一个进程依然是可以运行的,那么就可以继续运行上一个进程
同时将这个算法实现为了一个系统调用,进程可以主动放弃CPU
上面这张图可以清楚的看到什么时候会调用调度器
- 初始化的时候,BSP选择一个进程来运行
- AP启动结束,选择一个进程运行
- 进程运行结束之后,选择下一个可运行的进程
- 进程主动调用系统调用,放弃CPU
- 产生时钟中断,当前进程CPU时间片结束
- 陷入内核之后发现当前进程是僵尸进程,杀掉
接下来是两个问题
Question 3
这里的问题问的是,在lcr3运行之后,这个CPU对应的页表就立刻被换掉了,但是这个时候的参数e,也就是现在的curenv,为什么还是能正确的解引用?就是下面这一段
这个问题比较简单,因为当前是运行在系统内核中的,而每个进程的页表中都是存在内核映射的,之前也说过,每个进程页表中虚地址高于UTOP之上的地方,只有UVPT不一样,其余的都是一样的,只不过在用户态下是看不到的
所以虽然这个时候的页表换成了下一个要运行的进程的页表,但是curenv的地址没变,映射也没变,还是依然有效的
Question 4
当然要保存,这个还用说。。。
是在trap中保存的
所以说,每次进入到内核态的时候,当前的运行状态都是在一进入的时候就保存了的
如果没有发生调度,那么之前trapframe中的信息还是会恢复回去,如果发生了调度,恢复的就是被调度运行的进程的上下文了
System calls for environment creation
就是实现最笨的fork
不过给定的函数是将功能细分的,而实现的比较笨的fork放在了user/dumbfork.c文件中
这个函数所做的事情就是将当前进程的寄存器,所有的页的内容全部都复制过来,唯一不同的地方就是返回值不同,而实现返回值不同就是将存放返回值的eax寄存器的值修改一下就可以了
一个个函数的看
sys_exofork函数
这里调用env_alloc函数,而这个函数所做的,就是做一系列的准备工作,生成存放页表的页,初始化页表内容等
然后将父进程的上下文内容全部拷贝过来,除了返回值eax
但是此时只能将这个进程设置为不可运行,因为还没有将页表映射复制过来
sys_env_set_status函数
就是将状态设置为可执行或者不可执行而已
sys_page_alloc函数
主要功能就是申请一页物理内存,然后把它映射到虚拟地址va上去
sys_page_map函数
主要功能就是将进程id为srcenvid的进程的srcva处的物理页的内容,映射到进程id为dstenvid的进程的dstva处
sys_page_unmap函数
就是解出映射
但是看上面几个函数实际上看不出啥来,还是看看dumbfork怎么实现的吧
所以这里dumbfork做的事情就是首先获取新生成的子进程的进程id
然后将父进程的进程空间中的每一页的内容都复制过去,对于处在地址addr处的一页,具体做法就是首先为子进程申请一个物理页,然后将这个物理页映射到子进程虚拟地址addr处
然后同时将这个物理页页映射到进程Id为0的进程的虚拟地址PTSIZE处,然后利用memmove,将父进程addr处的内容移动到进程id为0de进程的虚地址PTSIZE处,此时对应的物理页也就有了相同的内容
之所以这样做,是因为如果要复制内容的话,要么在内核中复制,但是也需要将两页映射到内核空间中来,完成复制之后再解映射
而这里用了一种还算是巧妙的方法,因为当前进程的PTSIZE之上是不用的,所以都映射到那去,复制内容的完成也在那
当然实际系统肯定不这么干,都是用copy on write技术,这个partB中再搞