文章目录
OSlab3学习笔记
实验目的
-
创建一个进程并成功运行
-
实现时钟中断,通过时钟中断内核可以再次获得执行权
-
实现进程调度,创建两个进程,并且通过时钟中断切换进程执行
-
在本次实验中你将运行一个用户模式的进程。你需要使用数据结构进程控制块 Env 来跟踪用户进程。通过建立一个简单的用户进程,加载一个程序镜像到进程控制块中,并让它运行起来。同时,你的MIPS 内核将拥有处理异常的能力。
重点定义
env.h
#define LOG2NENV 10
#define NENV (1<<LOG2NENV)//envs数组包含NENV个Env结构体成员
#define ENVX(envid) ((envid) & (NENV - 1))//得到envid的低10位
types.h
#define ROUNDDOWN(a,n) (((u_long)(a)) & ~((n)-1))
//当n是2的次方时,这个宏的作用是将a的低log2n位清零
PCB结构
进程控制块(PCB) 是系统为了管理进程设置的一个专门的数据结构,用它来记录进程的外部特征,描述进程的运动变化过程。进程和PCB是一一对应的,PCB的结构如下。
struct Env {
struct Trapframe env_tf; // Saved registers
LIST_ENTRY(Env) env_link; // Free LIST_ENTRY
/*
the structure of env_link:
struct{
struct Env *le_next;
struct Env **le_prev;
}
使用它和env_free_list来构造空闲进程链表。
*/
u_int env_id; // Unique environment identifier
u_int env_parent_id; // env_id of this env's parent
u_int env_status; // Status of the environment
/*
– ENV_FREE : 表明该进程是不活动的,即该进程控制块处于进程空闲链表中。
– ENV_NOT_RUNNABLE : 表明该进程处于阻塞状态,处于该状态的进程往往在等待一定的条件才可以变为就绪状态从而被CPU调度。
– ENV_RUNNABLE : 表明该进程处于就绪状态,正在等待被调度,但处于RUNNABLE状态的进程可以是正在运行的,也可能不在运行中。
*/
Pde *env_pgdir; // Kernel virtual address of page dir
u_int env_cr3; // physical address of page
LIST_ENTRY(Env) env_sched_link;// use it to construct processes in ENV_RUNNABLE status.
u_int env_pri; // the priority of the process
};
Trapeframe
struct Trapframe { //lr:need to be modified(reference to linux pt_regs) TODO
unsigned long regs[32]; // 32 个通用寄存器
/* 特殊寄存器 */
unsigned long cp0_status; // CP0 状态寄存器
unsigned long hi; // 乘(除)法高位(模)寄存器
unsigned long lo; // 乘(除)法低位(商)寄存器
unsigned long cp0_badvaddr; // 异常发生地址
unsigned long cp0_cause; // CP0 cause 寄存器
unsigned long cp0_epc; // 异常返回地址
unsigned long pc; // PC计数器,程序运行的地址
};
进程ENV_ID生成
在Dev上跑出来效果如图:因为++的优先级比<<高,所以每次都是next先加一之后再左移且左移之后next的值不变。
#include<stdio.h>
unsigned long mkenvid(unsigned int serial){
static unsigned long next = 0;
return (++next<<11) | serial;
}
int main() {
int i;
for (i=0;i<10;i++) {
printf("%u\n",mkenvid(i));
}
return 0;
}
//输出的结果是:
/*
2048
4097
6146
8195
10244
12293
14342
16391
18440
20489
*/
进程创建(设置进程控制块)
env_alloc
函数用于分配一个空闲PCB, 并进行初始化。而env_alloc
函数执行的过程中需要调用env_setup_vm
函数(该函数功能是初始化新进程地址空间,即是初始化该进程的页目录)。
在我们实验系统的地址空间中,用户态的2G是私有的,各不相同;而内核态的2G对所有进程都是一样的。因此,每个进程都需要复制这2G的内容以便有机会成为临时的内核态。
如上图所示,具体而言,ULIM以上的页目录项即和内核的页目录项完全相同;ULIM以下的为用户区特有的地址。
而ULIM=0x80000000
是操作系统分配给用户的2G地址空间的最大值,UTOP=0x7f400000
是用户能够自由读写的地址空间的最大值。UTOP
到ULIM
这段空间映射的是记录页面使用情况的4M大小的pages
数组,4M进程控制块envs
数组和用户页表域的那4M虚拟空间,用户不能写只能读,是在映射过程中留出来给用户进程查看其他进程信息的。
env_setup_vm函数
本质即为进程申请页目录并向页目录填充内容的过程。
首先申请页目录:
if ((r = page_alloc(&p)) == -E_NO_MEM){
panic("xxxxxxxx");
return r;
}
p->pp.ref ++;
pgdir = (Pde *)page2kva(p);//得到页目录地址
对于用户而言,UTOP以下的区域是用户可以自由读取的区域,因此页目录应该清零;而UTOP以上的区域应该和内核一致,所以仍然需要复制内核。
for(i = 0;i<PDX(UTOP) ;i++){
pgdir[i] = 0;
}
for(i = PDX(UTOP);i<1024;i++){
pgdir[i] = boot_pgdir[i];
}
其次为进程设置页目录和其物理地址
最后将页目录的自映射项填入:
e->env_pgdir[PDX(UVPT)] = e->env_cr3|PTE_V;
env_alloc函数
从env_free_list中申请出一个进程后手动初始化,即填入进程的信息(注意不要忘了设置PC和reg[29]),并且为新进程分配资源,包括程序数据和内存空间。最后还需要从空闲链表中移除这一不再空白的进程。
加载二进制镜像
为新进程的程序分配空间来容纳程序代码。
这一任务由 load_icode
函数来完成,它的步骤就是分配内存,并将二进制代码(ELF格式)装入分配好的内存中。
void load_icode(struct Env *e, u_char *binary, u_int size)
。binary
是需要装载的文件指针(ELF文件), size
是文件的大小。
由于load_icode
函数独自完成这个任务则任务量过大,因此将任务拆分为“解构ELF文件”和“将解析出的二进制文件加载到内存”两个部分,分别由load_elf
和load_icode_mapper
完成。
下面分别讨论着两个函数。
先说load_icode_mapper
:
要想正确加载一个ELF文件到内存,只需将ELF文件中所有需要加载的segment加载到对应的虚地址上即可, 该虚拟地址就是va。
**注意对齐的问题:va和文件大小都不一定对齐4KB;而如果一段内存不满一个页,仍然要分配一整个页面来存储。**因此最开始需要检查开头一段是否对齐。如果不对齐,申请一个页面存储这一段,同时注意拷贝也只能拷贝这一段,不能拷多了。接着申请之后对齐的页面。最后考虑binsize<sgsize
是否成立(因为有全局变量初始为0占位,所以有可能)。如果是,则将这一段全部赋0(即不用拷贝。)
load_elf
:完成对elf文件的解构,并将文件映射到内存。
load_icode
:这是真正的加载二进制镜像的函数,该函数执行过程如下:
-
分配进程的运行栈空间,注意这里是用户栈(用户栈是用户空间中的一块区域,用于保存用户进程的子程序间相互调用的参数、返回值以及局部变量等信息),为栈空间预分配一个页面。
即是分配一个物理页,然后将用户栈的地址该物理页建立映射。注意栈的增长方向是向下的,因此映射的内存空间是
(USTACKTOP - BY2PG, USTACKTOP)
-
调用
load_elf
函数把二进制文件加载到内存。 -
设置pc寄存器
要运行的进程的代码段预先被载入到了
entry_ point
为起点的内存中(也就是进程入口地址),当我们运行进程时,CPU 将自动从 pc 所指的位置开始执行二进制码, 因此将pc设为entry_point
。
创建进程
创建进程的过程很简单,就是实现对上述个别函数的封装,分配一个新的Env 结构体,设置进程控制块,并将二进制代码载入到对应地址空间即可完成。
进程切换
env_run,是进程运行使用的基本函数,它包括两部分:
• 一部分是保存当前进程上下文(如果当前没有运行的进程就跳过这一步)
• 另一部分就是恢复要启动的进程的上下文,然后运行该进程。
中断与异常
异常分发
.section .text.exc_vec3 //定义.text.exc_vec3段代码
NESTED(except_vec3, 0, sp) //我也不知道是干啥的一个函数
.set noat
.set noreorder
1:
mfc0 k1,CP0_CAUSE //将CP0_CAUSE寄存器的值存到k1寄存器中
la k0,exception_handlers//将异常分发数组的首地址赋值为k0
andi k1,0x7c //取出bit2-bit6,即是取出异常号ExcCode
addu k0,k1 //数组初地址+序号->得到对应的内存地址,这个地址存的数就是异常处理函数入口的位置
lw k0,(k0) //现在k0中得到了异常处理函数入口的地址
nop
jr k0 //跳转到异常处理函数
nop
END(except_vec3)
.set at
将.text.exec_vec3
段通过链接器放到特定的位置(即要求的0x80000080)后,一旦异常发生,就会引起该段代码的执行,开始分发异常。
异常向量组
其中,Cause Register 寄存器中保存着CPU 中哪一些中断或者异常已经发生。bit2~bit6 保存着异常码,也就是根据异常码来识别具体哪一个异常发生了。bit8~bit15 保存着哪一些中断发生了。本实验中,我们主要使用0 号异常的处理函数handle_int
,同时bit12对应的是4号中断。
时钟中断
每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。**如果在时间片结束时进程还在运行,则产生时钟中断,**该进程将挂起,需要在调度队列中选取一个合适的进程运行。
gxemul模仿时钟中断流程:
-
在set_timer函数中:
- 首先向0xb5000100 位置写入1(0xb5000000 是gxemul映射实时钟的位置)。偏移量为0x100 表示来设置实时钟中断的频率,1表示1秒钟中断1次,如果写入0,表示关闭实时钟。
- 其次将KERNEL_SP的地址存入sp寄存器中
- 设置中断,将12位置1,使得4号中断可以被响应。
- 这段代码其实主要用来触发了4 号中断。(注意这里的中断号和异常号是不一样的概念,我们实验的异常包括中断)
-
一旦实时钟中断产生,就会触发MIPS 中断,从而MIPS 将PC 指向0x80000080,从而跳转到.text.exc_vec3进行分发,最终会调用handle_int 函数来处理实时钟中断。
-
handle_int函数完成了如下任务:
-
SAVE_ALL
保存现场,把各种寄存器存储到sp寄存器所存储的地址上;-
这个宏调用了
get_sp
宏 -
.macro get_sp mfc0 k1, CP0_CAUSE // 将CP0_CAUSE 寄存器的值存到 k1 寄存器中 andi k1, 0x107C // 取出bit2 - bit6(异常码ExcCode 条件1)和 bit12(4号中断 条件2) xori k1, 0x1000 // bit12和1做异或运算 bnez k1, 1f // k1不等于0,就跳转。也就是说只要上面两个条件有一个不满足就会跳转到分支1 nop /* 这个地方就是将sp设为TIMESTACK*/ li sp, 0x82000000 j 2f nop 1: bltz sp, 2f nop lw sp, KERNEL_SP // 将sp 设为 KERNEL_SP nop 2: nop .endm
-
宏作用:如果是时钟中断,sp就会被设置为
TIME_STACK
;如果是其他异常,sp就会被设置为KERNEL_SP
。
-
-
CLI
设置SR寄存器,让CPU禁止中断,因为本实验不支持中断嵌套,因此每次只能处理一个中断; -
判断CP0_CAUSE寄存器是不是对应的4 号中断位引发的中断,如果是,则执行中断服务函数timer_irq;
-
在timer_ irq 里直接跳转到sched_ yield中执行。
-
-
sched_yield函数:
- 首先我们使用两个链表(我也不是很清楚为什么),即env_sched_list,每个链表存储着的都是待调度的进程;
- 判断当前进程状态和时间片
- 如果状态不是
ENV_RUNNABLE
,则需要切换进程; - 时间片用尽同理。
- 如果状态不是
- 将时间片清空
- 检查当前进程:
- 已经用尽该进程,则需要从当前队列里清除
- 如果当前进程的状态不是
ENV_FREE
,则需要将其加入另一个队列的队尾(时间片轮转算法,保证公平)
- 如果当前队列为空,则切换到另一队列
- 如果当前队列还是为空,那么没有可切换的进程
- 将进程切换到当前队列的首进程,并设置对应的时间片(即优先级)
- 时间片–
- 运行当前新进程。
补充:
用户栈和内核栈
内核在创建进程的时候,在创建task_struct的同时,会为进程创建相应的堆栈。每一个进程都有两个栈,一个用户栈,存在于用户空间;一个内核栈,存在于内核空间。 当进程在用户空间运行时,CPU堆栈指针寄存器里面的内容都是用户栈地址,使用用户栈; 当进程在内核空间时,CPU堆栈指针寄存器里面的内容是内核栈空间地址,使用内核栈。
当进程因为中断或者系统调用陷入到内核态时,进程所使用的堆栈也要从用户栈转到内核栈。 进程陷入到内核态后,先把用户态堆栈的地址保存在内核栈之中,然后设置堆栈指针寄存器的内容为内核栈的地址,这样就完成了用户栈向内核栈的转换; 当进程从内核态恢复到用户态之后时,在内核态之后的最后将保存在内核栈里面的用户栈的地址恢复到堆栈指针寄存器即可。这样就实现了用户栈和内核栈的互转。
那么,知道从内核转到用户态时, 用户栈的地址是在陷入内核的时候保存在内核栈里面的,但是在陷入内核的时候,如何知道内核栈的地址?关键在进程从用户态转到内核态的时候,进程的内核栈总是空的。这是因为当进程在用户态运行时,使用的用户栈,当进程陷入到内核态时,内核保存进程在内核态运行的相关信息,但是一旦进程返回到用户态后,内核栈中保存的信息无效,会全部恢复,因此每次进程从用户态陷入内核的时候得到的内核栈都是空的。 所以在进程陷入内核的时候,直接把内核栈的栈顶地址给堆栈指针寄存器就可以了。