BUAA-OS-Probe-Lab3
Lab3 - 进程与异常
Lab3 中主要涉及到以下内容:
- 进程的创建
- 时钟中断与内核态
- 进程调度与进程切换
- 数据进程控制块
Env
进程控制块与初始化
- 由于没有在 MOS 操作系统中实现线程,所以进程既是基本的分配单元,也是基本的执行单元。
- 进程是一个活动中的实体,拥有自己的虚拟地址空间。
- 程序是非活动的实体,执行中的程序就是进程
进程控制块 - PCB
struct Env { |
env_status
:-
ENV_FREE
:进程控制块处于空闲链表中,值为0 -
ENV_NOT_RUNNABLE
:阻塞态,可转变为就绪状态,值为1 -
ENV_RUNNABLE
:就绪、执行状态(等待调度/运行中),值为2
-
- 在 MOS 中,进程控制块的物理地址已经被分配好了(
envs
数组)
这里的初始化使用了__attribute__函数,做完再看 其中的结构体 TrapFrame
在 Lab4 中作用比较大,但在 Lab3 中没有必要过于关注,结构就不再过多介绍了 Env 块中存在两个链表(env_free_list
和env_sched_list
),TAILQ
结构在 Lab2 的 Probe 中已经提过了,该结构为双向的有尾列表,支持在头尾进行元素增删操作。
跨页地址映射 - map_segment
- Exercise 3.2
- 函数作用如下:物理地址映射到指定进程的虚拟地址中(更大的
page_insert
) - 将物理地址 pa 按页映射到指定进程页表中(va)
- 映射大小 size 必须是页面大小的整数倍
- 设置用到的页表项权限位为 perm
这个函数在上面的 env_init()
中使用过,其将内核中 pages 与 envs 所在的物理地址映射到内核页表中,我们可以根据下面这个实例补充代码
map_segment(base_pgdir, 0, PADDR(pages), UPAGES, ROUND(npage * sizeof(struct Page), |
代码中注释已经给好方向,使用page_insert()
,将 [va, va+size)
所涉及到的每个页面都映射到 pa 开始的页面中
/* Overview: |
进程块队列初始化 - env_init
- Exercise 3.1
- 函数功能:初始化 envs 链表
- 完成 Env 控制块的空闲队列、调度队列的初始化功能
- 空闲队列需要倒序插入,用来优先分配小序号的进程控制块 Env
- 临时存放 内核结构 ‘pages’ 与 ‘envs’ ,为后续映射做准备(详见 Exercise 3.3)
void env_init(void) { |
代码实现较简单,注意要区分 Env 结构体中存在的链表,env_free_list 链表需要使用 env_link 连接,使用LIST类宏操作;而 env_sched_list 需要使用 env_sched_link 连接,使用 TAILQ 类宏操作
env_id 与进程表示
在进程管理块 Env 中,有三个与 id
相关的字段,它们从不同的方式代表进程
-
env_id
:(进程标识符,unique environment identifier)MOS 操作系统中使用env_id
唯一地表示不同进程,在创建进程时被mkenvid
函数赋值 env_asid
:(地址空间标识符,Address-Space IDentifier)ASID也可以唯一地标记进程,同时为进程提供相应的地址保护- 在 MOS 实验系统中使用 <VirtualPageNumber, ASID> 作为索引在 TLB 中查询映射
- 在 MOS 中,使用了位图法管理了64个 ASID ,具体位于 asid_alloc 函数中
-
env_parentid
:(env_id of this env’s parent)顾名思义是创建本进程的父进程的env_id
对于id
与asid
:id
指的是控制块和线程自己的属性;asid
则在 TLB 、地址管理上用的比较多 env_id
的值从同文件中的 mkenvid()
函数中得来,asid由同文件中的 asid_alloc()
得到
Linux 中的 ASID 分代机制 (有空再补)
加载二进制镜像
程序想要成为进程,必须要把对应的的 ELF 文件(此处为可执行文件)中所有需要加载的 程序段(Segment) 分配进虚拟内存空间中。但在 lab3 中我们还不能直接操作磁盘中的文件,所以 ELF 文件被转化为C数组的形式,再通过编译到内核完成加载 这里可以使用部分函数操作 ELF 文件,加载整个文件进入内存、获取其文件头、加载 segment 至内存
ELF 文件函数
-
load_icode()
:加载可执行文件至指定进程内存(调用的最外层函数) -
elf_from()
:解析 ELF 文件头,获取段位置 -
elf_load_seg()
:加载 ELF 程序段 load_icode_mapper()
:分配一页物理页,在 env 块对应的页表中建立映射- 可能需要复制 src 处的数据到该物理页面中
加载 ELF 文件 - load_icode
- Exercise 3.6
- 函数作用:调用相关函数,将 ELF 文件加载进指定进程中
- 解析 ELF 头获取段信息
- 使用
elf_load_seg
加载每个程序段 - 初始化 EPC 指向程序入口点地址
static void load_icode(struct Env *e, const void *binary, size_t size) { |
-
ELF_FOREACH_PHDR_OFF
:在上述的顶层函数中,调用了一个宏,其展开后对 ELF 程序段进行循环:
elf_load_seg
- 函数作用:把 ELF 程序段加载到
data
处; MOS 中在load_icode
内配合load_icode_mapper
调用,也就是把程序段加载到进程内存里 - 按页划分,分别映射每一页至 data 中
- 回调函数
map_page
决定映射的方式
/* Overview: |
页映射回调函数 - load_icode_mapper
- Exercise 3.5
- 函数作用:作为
load_icode
中使用的回调函数,它决定了页映射的方式 - 申请一个物理页
page_alloc
- 复制一下src内容(注意不要复制多/少了)
memcpy
- 把这个复制好的物理页映射到目标 env 里
page_insert
/* Overview: |
进程创建
创建进程的过程主要由 env_alloc
函数实现,其步骤大致如下:
- 申请一个空闲的进程控制块
- 初始化这个空白的控制块
- 初始化进程页目录
- 从 env_free_list 中取出该控制块
用户栈是在使用过程中动态分配的
进程页目录初始化 - env_setup_vm
- Exercise 3.3
- 函数作用:初始化进程页目录(共享只读段映射和自映射)
- 形成进程页表,初始化新进程的虚拟地址空间
- 把 UTOP 至 UVPT 两段地址间的内核页表 base_pgdir 拷贝到进程页表中,借此暴露这段由所有进程共享的只读空间( Exercise 3.1完成了 base_pgdir 的建立与映射)
关于共享只读的空间,指导书这样解释:
在MOS 操作系统特意将一些内核的数据暴露到用户空间,使得进程不需要切换到内核态就能访问,这是MOS 特有的设计。在Lab4 和Lab6 中将用到此机制。而这里我们要暴露是UTOP 往上到UVPT 之间所有进程共享的只读空间,也就是把这部分内存对应的内核页表base_pgdir 拷贝到进程页表中。从UVPT 往上到ULIM 之间则是进程自己的页表。
拷贝的这一段空间,具体来说存放的是 envs 和 pages 两个经常使用的结构体,使其共享只读确实能够减少大量对内核空间的访问;不然用户进程申请个物理页都要进内核看 pages 了
/* Overview: |
- 使用 memcpy 时虽然只进行了页目录的拷贝,但两个页目录此时指向了相同的二级页表(物理页),之后再寻址就一样了
- 页表自映射细节在 Thinking 3.1 中,可以回 Report 看一眼
申请并初始化进程块 - env_alloc
- Exercise 3.4
- 函数作用如下:
- 从空闲控制块链表中申请一个进程控制块(类似于
page_alloc
申请页) - 使用
env_setup_vm
函数和赋值语句对控制块进行初始化 - 把申请好的控制块从链表中摘除并返回
/* Overview: |
初始化两个 ID 时,使用了两个不需要填空的函数,体现了操作系统生成不重复的 ID 的方式和位图法保存 ASID 的应用,这边也来看一下吧:
// 计算一个 ASID 并存入参数指针内 |
在设置寄存器初始化时,我们使用了两个赋值语句,这两句关键语句需要解释一下:
e->env_tf.cp0_status = STATUS_IM4 STATUS_KUp STATUS_IEp
- 初始化 CP0 的 SR 寄存器, IM4 代表允许响应4号中断、 KUp 代表处于用户态、 IEp 代表允许中断,这里实际上是初始化了中断响应机制所需的寄存器条件
e->env_tf.regs[29] = USTACKTOP - sizeof(int) - sizeof(char **)
- 在 USTACKTOP 下存放的实际上就是用户栈,还记得倒置的 mips 栈吗,这里就是栈顶
对于 SR 寄存器,更具体的解释如下: 指导书P66
创建内核进程 - env_create
-Exercise 3.7
- 函数作用:创建一个内核进程,并加载 ELF 文件
- 申请没有父进程的进程控制块
- 初始化 priority 和 ENV_RUNNABLE
- 加载 ELF 文件并插入调度队列
/* Overview: |
-
ENV_CREATE_PRIORITY
:MOS 创建内核示例进程时使用的宏,定义在include/env.h
中
这里用到的 ##x##
可以理解成变量替换后的字符串拼接
// priority = y |
再探 mmu.h
至此,一个新的内核进程创建过程就结束了,在 Lab4 中还会用部分函数创建用户进程。回顾一下。在申请进程之前,我们先初始化了进程控制块链表(env_init
),在申请过程中初始化了它的内存空间、页表(env_setup_vm
)与进程控制块字段,最后返回(env_alloc
)。 借助 Lab2 已有的布局和进程建立的过程,我们可以大致构建起一个不断完善的内存体系,这时候 include/mmu.h
内的内存布局图就可以再拿出来用了。不过实际上,仍有一些字段我们没有使用,这些字段在 Lab4 中会再加以利用。
/* |
- UPAGES 和 UENVS 处于用户可见区内,包含内核的页面控制块和进程控制块,每个进程拷贝成了相同的内容
执行进程 - env_run
- Exercise 3.8
- 函数作用:切换并运行指定进程
/* Overview: |
- 返回用户态,实际上是个精妙的过程,只不过我们的填空不需要完成这部分,但实在是值得研究研究
env_pop_tf
- 修改 CP0 寄存器和 sp 寄存器,为跳回用户态提供数据
LEAF(env_pop_tf) |
ret_from_exception
- 刚才在
env_pop_tf
的最后一句中,我们跳转到了这个汇编函数,它的作用是离开异常处理程序,回到用户态,虽然当前调用不算是异常处理,但是想要完成的作用都一样:恢复用户态现场,返回用户态执行
FEXPORT(ret_from_exception) |
- 这里的 EPC 在初始化进程块时就被赋值成了 ELF 程序入口点,也就是说会直接 jr 到加载的 ELF 程序开始
- 其中最关键的是 lw sp这一句,把用户栈指针从 tf 中取出,恢复了用户栈
- 最后使用的 rfe 指令也刚好把
env_alloc
时初始化的二重栈用上了。每个进程都需要由此启动,所以确实都需要执行一次 rfe 指令 - md,太妙了
中断和异常
P7 的痛苦回忆又回来了.jpg
我们实验里认为中断是异常的一种,并且是仅有的一种异步异常。
异常处理逻辑
- 设置 EPC 指向返回地址
- 设置 SR 寄存器,强制 CPU 进入内核态
- 设置 Cause 寄存器记录异常原因
- 跳转到异常处理程序入口,执行处理
异常分发程序 - exc_gen_entry
- Exercise 3.9
- 异常分发程序:根据发生的异常跳转到异常处理程序
.section .text.exc_gen_entry |
-
lw t0, exception_handlers(t0)
指令明明是个访存指令,如何做到获取处理函数地址的呢?
实际上这是由于 exception_handlers
这个异常向量组以数组的形式保存在内存中。只需要将 t0 寄存器作为下标,我们就能直接访问了这个数组的对应内容(这个内容存放的就是处理函数的地址) 为了程序能找到这个分发程序和处理 TLB Miss 的程序,我们在 kernal.lds
中放置了它们所在的字段:
. = 0x80000000; |
(虽然处理 TLB 的函数会直接跳到主异常分发程序,没啥用(
异常向量组 - exception_handlers
- 异常分发程序通过
exception_handlers
数组定位中断处理程序,而定义在kern/traps.c
中的exception_handlers
就称作异常向量组。
这部分指导书的逻辑很明确,就直接借用了:
extern void handle_int(void); |
通过把相应处理函数的地址填到对应数组项中,我们初始化了如下异常: 0 号异常的处理函数为handle_int,表示中断,由时钟中断、控制台中断等中断造成 1 号异常的处理函数为handle_mod,表示存储异常,进行存储操作时该页被标记为只读 2 号异常的处理函数为handle_tlb,表示TLB load 异常 3 号异常的处理函数为handle_tlb,表示TLB store 异常 8 号异常的处理函数为handle_sys,表示系统调用,用户进程通过执行syscall 指令陷 入内核
通过访问对应的异常下标,就能在异常分发程序中进入对应的处理函数(目前中断还不行),最后调用 ret_from_exception
返回用户态
中断处理
- 中断处理的流程
- 进入异常分发程序,判断为中断
- 进入中断处理程序
handle_int
,判断中断种类,再进行分发 - 处理中断结束后,进入
ret_from_exception
返回用户态
目前我们的 MOS 只能处理一种时钟中断,所以中断处理就只有一种选择()
NESTED(handle_int, TF_SIZE, zero) |
时钟中断 - kclock_init
- Exercise 3.11
MOS 系统产生定时的时钟中断,并根据这些中断分配每个进程运行的时间片(限制进程一次性运行的时间长度)
- 初始化并启用时钟中断
kern/kclock.S
中的 kclock_init
函数完成了时钟中断的初始化,该函数向 KSEG1 DEV_RTC_ADDRESS DEV_RTC_HZ
位置写入200,其中 KSEG1 DEV_RTC_ADDRESS
是模拟器(GXemul)映射实时钟的位置。偏移量为 DEV_RTC_HZ
表示设置实时钟中断的频率,200 表示1 秒钟中断200 次。 随后再调用 kern/env_asm.S
中的 enable_irq
函数开启中断。
LEAF(kclock_init) |
enable_irq
函数在 Report 里也有,文章太长就不写力
- 时钟中断的处理
- 中断产生,进入异常分发程序
- 判断为中断,进入中断处理程序
- 判断为时钟中断,执行处理函数
- 执行
schedule(0)
,进行进程调度
进程调度 - schedule
- Exercise 3.12
- 函数功能:根据参数
yield
和当前进程状态进行进程调度
/* Hints: |
- 在
schedule
函数中,我们没有使用return
,而直接进入了新一轮的env_run
,保存当前已运行的部分内容,开始新的进程。 - 这也说明了为什么只有
timer_irq
没有调用ret_from_exceprion
函数,因为不用回到原本执行进程的用户态了
Lab3 大体到这里就结束了,要写的要看的也太多了,有点逆天。