进程
进程控制块
进程控制块(PCB)是系统专门设置用来管理进程的数据结构, 它可以记录进程的外部特征, 描述进程的变化过程.
系统利用 PCB 来控制和管理进程, 所以 PCB 是系统感知进程存在的唯一标志.
进程与 PCB 是一一对应的。
结构:
// Control block of an environment (process).
struct Env {
struct Trapframe env_tf; // saved context (registers) before switching
LIST_ENTRY(Env) env_link; // intrusive entry in 'env_free_list'
u_int env_id; // unique environment identifier
u_int env_asid; // ASID of this env
u_int env_parent_id; // env_id of this env's parent
u_int env_status; // status of this env: ENV_FREE, ENV_NOT_RUNNABLE, ENV_RUNNABLE
Pde *env_pgdir; // page directory
TAILQ_ENTRY(Env) env_sched_link; // intrusive entry in 'env_sched_list'
u_int env_pri; // schedule priority
// Lab 4 IPC
u_int env_ipc_value; // the value sent to us
u_int env_ipc_from; // envid of the sender
u_int env_ipc_recving; // whether this env is blocked receiving
u_int env_ipc_dstva; // va at which the received page should be mapped
u_int env_ipc_perm; // perm in which the received page should be mapped
// Lab 4 fault handling
u_int env_user_tlb_mod_entry; // userspace TLB Mod handler
// Lab 6 scheduler counts
u_int env_runs; // number of times we've been env_run'ed
};
struct Trapframe {
/* Saved main processor registers. */
unsigned long regs[32];
/* Saved special registers. */
unsigned long cp0_status;
unsigned long hi;
unsigned long lo;
unsigned long cp0_badvaddr;
unsigned long cp0_cause;
unsigned long cp0_epc;
}; // 用于在发生进程切换或者陷入内核时, 保存进程上下文存放进程控制块的物理内存在系统启动后就已经分配好, 就是 envs 全局数组.
其管理形式与页控制块的数组存实体,链表连逻辑类似.
因为系统允许同时存在的进程有上限, 所以与页控制块数组不同, envs 被定义为数组类型, 而非指针类型.
可以看到, 关于进程块, 有两个队列:
- 调度队列
env_sched_link: 用于管理已分配进程与调度. 在进程创建时要为其分配进程块并加入该队列, 释放时移出. - 空闲队列
env_free_list: 用于快速分配空闲中的进程块, 将其串联起来为一个队列, 分配后移出, 释放后加入, 与 页控制块的管理逻辑类似.
初始化
/* Overview:
* Mark all environments in 'envs' as free and insert them into the 'env_free_list'.
* Insert in reverse order, so that the first call to 'env_alloc' returns 'envs[0]'.
*/
void env_init(void)
{
int i;
/* Step 1: Initialize 'env_free_list' with 'LIST_INIT' and 'env_sched_list' with
* 'TAILQ_INIT'. */
LIST_INIT(&env_free_list);
TAILQ_INIT(&env_sched_list);
/* Step 2: Traverse the elements of 'envs' array, set their status to 'ENV_FREE' and insert
* them into the 'env_free_list'. Make sure, after the insertion, the order of envs in the
* list should be the same as they are in the 'envs' array. */
for (int i = 0; i < NENV; i++) {
envs[i].env_status = ENV_FREE;
LIST_INSERT_HEAD(&env_free_list, &envs[i], env_link);
}
/*
* We want to map 'UPAGES' and 'UENVS' to *every* user space with PTE_G permission (without
* PTE_D), then user programs can read (but cannot write) kernel data structures 'pages' and
* 'envs'.
*
* Here we first map them into the *template* page directory 'base_pgdir'.
* Later in 'env_setup_vm', we will copy them into each 'env_pgdir'.
*/
struct Page *p;
panic_on(page_alloc(&p));
p->pp_ref++;
base_pgdir = (Pde *)page2kva(p);
map_segment(base_pgdir, 0, PADDR(pages), UPAGES,
ROUND(npage * sizeof(struct Page), PAGE_SIZE), PTE_G);
map_segment(base_pgdir, 0, PADDR(envs), UENVS,
ROUND(NENV * sizeof(struct Env), PAGE_SIZE), PTE_G);
}段地址映射
在进程块队列初始化最后, 我们为模板页表 base_pgdir 分配了一页页表,
并用 map_segment 在该页表中把内核的 pages 数组和 envs 数组映射到了用户空间的 UPAGES 和 UENVS 处.
在之后的 env_setup_vm 函数中, 我们会将这部分模板页表复制到每个进程的页表中.
o 4G -----------> +----------------------------+------------0x100000000
o | ... | kseg2
o KSEG2 -----> +----------------------------+------------0xc000 0000
o | Devices | kseg1
o KSEG1 -----> +----------------------------+------------0xa000 0000
o | Invalid Memory | /|\
o +----------------------------+----|-------Physical Memory Max
o | ... | kseg0
o KSTACKTOP-----> +----------------------------+----|-------0x8040 0000-------end
o | Kernel Stack | | KSTKSIZE /|\
o +----------------------------+----|------ |
o | Kernel Text | | PDMAP
o KERNBASE -----> +----------------------------+----|-------0x8002 0000 |
o | Exception Entry | \|/ \|/
o ULIM -----> +----------------------------+------------0x8000 0000-------
o | User VPT | PDMAP /|\
o UVPT -----> +----------------------------+------------0x7fc0 0000 |
o | pages | PDMAP |
o UPAGES -----> +----------------------------+------------0x7f80 0000 |
o | envs | PDMAP |
o UTOP,UENVS -----> +----------------------------+------------0x7f40 0000 |
o UXSTACKTOP -/ | user exception stack | PTMAP |
o +----------------------------+------------0x7f3f f000 |
o | | PTMAP |
o USTACKTOP ----> +----------------------------+------------0x7f3f e000 |
o | normal user stack | PTMAP |
o +----------------------------+------------0x7f3f d000 |
a | | |
a ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
a . . |
a . . kuseg
a . . |
a |~~~~~~~~~~~~~~~~~~~~~~~~~~~~| |
a | | |
o UTEXT -----> +----------------------------+------------0x0040 0000 |
o | reserved for COW | PTMAP |
o UCOW -----> +----------------------------+------------0x003f f000 |
o | reversed for temporary | PTMAP |
o UTEMP -----> +----------------------------+------------0x003f e000 |
o | invalid memory | \|/
a 0 ------------> +----------------------------+ ----------------------------
o本图亦出现于 MIPS 内存布局 .
现在我们再回顾, 就有了不一样的感受.
如你所视, 这个布局涵盖 4GB, 恰好是占一个页面的页目录涵盖的范围.
而我们的 base_pgdir 实际上分配的就是这样的一个模板: 我们想要所有进程共享的,
内核的 pages 数组和 envs 数组映射到了放到了模板页表的 UPAGES 和 UENVS 处,
未来会在创建每个进程的页表的时候, 把这两项复制过去(这两项都是 “PDMAP”, 也就是 4MB, 占一个页目录项).
至于其他部分:
- UVPT: 占据一个页目录项, 是用户页表自映射, 也会在初始化的时候手动赋值.
- ULIM 之上(0x80000000~): 内核空间, 实际不需要页表映射, 而是通过最高三位置为 0 访问.
相当于我们把内核”拼接”在所有用户进程的地址高位, 从而使的用户可以在系统调用的时候正确找到相应地址. 也就是说, 和 VMS 类似, 我们把整个内核空间投影到了用户空间高地址 使得我们的用户进程, 好像自己拥有一个内核, 可以直接在陷入内核的时候, 像是访问自己的空间一样访问, 并且跳转到正确的物理地址, 执行处理程序. - UTOP 之下(~0x7f400000): 用户空间, 由程序运行时, 根据运行的数据使用.
进程标识
操作系统中有很多个进程同时存在, 他们执行不同的任务; 同时, 他们之间也要互相通信协作.
所以操作系统通过进程标识符来识别每个进程, 也就是 env_id, 由 mkenvid() 生成.
注意要与进程虚拟空间的标识符 env_asid 区分(由 asid_alloc() 生成).
设置进程控制块
进程创建的流程如下:
- 申请一个空闲的 PCB(也就是 Env 结构体), 从
env_free_list中获取一个空闲 PCB 块. - 每个进程都有独立的地址空间. 所以, 要为新进程初始化页目录.
- 在这种创建方式下, 由于没有模板进程, 所以进程拥有的所有信息都是手工设置.
而进程的信息又都存放于进程控制块的 fields 中, 所以需要手工初始化进程控制块.
并且正确设置寄存器, 后续恢复到进程时会写入 CPU. - 此时 PCB 已经被填写了很多东西, 把它从空闲链表里摘出, 就可以使用.
其中, 第三步会使用 env_setup_vm
/* Overview:
* Initialize the user address space for 'e'.
*/
static int env_setup_vm(struct Env *e)
{
/* Step 1:
* Allocate a page for the page directory with 'page_alloc'.
* Increase its 'pp_ref' and assign its kernel address to 'e->env_pgdir'.
*
* Hint:
* You can get the kernel address of a specified physical page using 'page2kva'.
*/
struct Page *p;
try(page_alloc(&p));
p->pp_ref++;
e->env_pgdir = page2kva(p);
/* Step 2: Copy the template page directory 'base_pgdir' to 'e->env_pgdir'. */
/* Hint:
* As a result, the address space of all envs is identical in [UTOP, UVPT).
* See include/mmu.h for layout.
*/
memcpy(e->env_pgdir + PDX(UTOP), base_pgdir + PDX(UTOP),
sizeof(Pde) * (PDX(UVPT) - PDX(UTOP)));
/* Step 3: Map its own page table at 'UVPT' with readonly permission.
* As a result, user programs can read its page table through 'UVPT' */
e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_V;
return 0;
}这些内容实际上, 就是把模板页表 base_pgdir 的一部分(两项 PDE)复制过来.
其实就是 UTOP 至 UVPT 之间的区域, 将其映射为所有进程共享的只读空间
自映射机制
以及注意倒数第二句:
e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_V;
这是对 UVPT 区域的映射!(如之前的图上所说, UVPT 也是 PDMAP, 占据一个 PDE).
也就是我们把用户页表本身的地址存入用户页表的 UVPT 区域, 作为一个 PDE, 因此实现了自映射.
意味着 页目录表(Page Directory) 这个物理页面,现在扮演了双重角色:
它是页目录(负责管理 1024 个页表);
它也被挂载到了 UVPT 这个位置, 成为了一个二级页表(负责管理 UVPT 这一段 4MB 的空间).
这个区域从 0x7fc00000 开始,
随后, 我们可以根据 获得 VA 对应的 PTE 对应的虚拟地址.
- 对于绝大部分用户空间(如 UTEXT):
访问 , 得到的是一个 PTE, 它指向一个存放数据的物理页.
至于为什么是 PTE, 其实是因为中间 10 位作为页目录偏移, 找到了二级页表页, 然后低 12 位(最后两位为 0)作为在页表页的偏移, 找到了 PTE. - 对于 UVPT 空间本身:
可以算出来, 页目录的基地址为: 在这个特定的 4KB 范围内, 我们再看中间 10 位, 在页目录中查找, 还是页目录,
所以再进行低 12 位的读取, 内容物理上就是env_pgdir.
因此, UVPT 里的 1024 个页面(每个 4KB,总共 4MB), 分别对应系统中的 1024 个二级页表.
其中有一个页面(也就是第 PDX(UVPT) 个页面), 它的内容物理上就是页目录表.
请注意, 这里并不是我们手动把内容存进了 UVPT 这 4MB 的空间中, 而是地址转换魔术,
我们实际上是把这 4MB 映射到所有的页表和页目录(视为一张页表)了.
进一步说明查询过程就是, 假设想看 VA 这个地址对应的页表项, 会去访问 。
- 一级寻址(查页目录):
CPU 发现这个地址的高 10 位指向PDX(UVPT). 它去页目录里一看, 发现这一项指向的物理地址竟然就是页目录自己!(代码里的赋值语句) - 二级寻址(查页表):
CPU 此时把页目录物理页当作”二级页表”来读. 它根据虚拟地址的中间 10 位, 去这个”页表”(物理上的页目录)里找. 找到对应的物理地址, 这是一个页表页. - 结果:
它找出来的结果, 物理上就是某一个 PTE 的内容.
特别地, 如果查询的”二级页表”还是页目录(也就是在 0x7fdff000 处加上 12 位偏移, 我们在页目录又打转一次), 那么读到的其实是 PDE,
本质上也可以作为 PTE 来使用, 只不过其内容是二级页表物理地址罢了.
所以请永远记住, 页目录也不过是个记录其他页表的页表; PDE 也不过是个记录页表物理地址的 PTE.
而这个 Trick 的本质, 也不过是让一级寻址原路打转一次, 让我们可以少查一层, 从具体数据退一步, 回到页表项.
也就是我们访问 UVPT 时, 这样看地址
|PDX(UVPT)[31:22]|PDX[21:12]|PTX[11:2]|00|通过在页目录原地打转, 实现把地址右移 10 位的效果.
graph TD subgraph Virtual_Address ["虚拟地址 (VA) 的 PTE 访问地址"] A["高 10 位: PDX(UVPT)"] B["中 10 位: VA_PDX"] C["低 12 位: (VA_PTX << 2)"] end subgraph MMU_Step_1 ["第一阶段:一级寻址"] D["查当前页目录 (pgdir)"] E{"页目录项 PDX(UVPT)"} F["指向 pgdir 自己的物理地址"] D --> E E -- "命中自映射项" --> F end subgraph MMU_Step_2 ["第二阶段:二级寻址"] G["将 pgdir 视为二级页表"] H{"使用中 10 位 (VA_PDX) 索引"} I["找到对应的 PDE 内容"] G --> H H -- "找到目标二级页表地址" --> I end subgraph Final_Offset ["第三阶段:字节偏移"] J["在二级页表页内移动"] K{"使用低 12 位偏移"} L["[ 最终目标:VA 对应的 PTE ]"] I --> J J --> K K -- "精准定位 4 字节" --> L end A --> D B --> G C --> J style F fill:#f96,stroke:#333,stroke-width:2px style I fill:#bbf,stroke:#333,stroke-width:2px style L fill:#9f9,stroke:#333,stroke-width:2px
其余信息的设置
除了页目录, 我们还要设置寄存器的值, 具体来说, 主要是 CP0 寄存器组的寄存器, 以及用户栈.

/* Step 4: Initialize the sp and 'cp0_status' in 'e->env_tf'.
* Set the EXL bit to ensure that the processor remains in kernel mode during context
* recovery. Additionally, set UM to 1 so that when ERET unsets EXL, the processor
* transitions to user mode.
*/
e->env_tf.cp0_status = STATUS_IM7 | STATUS_IE | STATUS_EXL | STATUS_UM;
// Reserve space for 'argc' and 'argv'.
e->env_tf.regs[29] = USTACKTOP - sizeof(int) - sizeof(char **);Status 寄存器的 IE 代表开启中断, IM7 是允许时钟中断;
当前仅当 EXL 设为 0 且 UM 设为 1, CPU 处于用户模式(EXL 会在每次异常发生后自动置为 1)
所以请额外注意一下这里 EXL 与 UM 的设计.
为什么我们给用户进程准备的寄存器, Status 没有按照用户模式写入呢? 这是因为,
在进程调度最后阶段会调用 ret_from_exception:
RESTORE_ALL
eretRESTORE_ALL 是一个宏, 会对处理器寄存器状态进行恢复, 把 tf 字段的数值写入对应寄存器, 包括 Status.
如果我们没有给 EXL 置为 1, 进行这一步后, CPU 会直接回到用户模式, 但是 PC 仍在内核地址空间(指向 eret)
这会导致触发地址错误异常, 造成内核崩溃.
所以我们要给 EXL 置为 1. 那我们如何回到用户态呢?
事实上, eret 会顺带给 EXL 清零. 所以可以保证我们在回到用户地址空间前都处于内核态, 回到用户地址空间立马回复用户态.
加载二进制镜像
要想正确加载一个 ELF 文件到内存, 只需将 ELF 文件中所有需要加载的 segment 加载到对应的虚拟地址上即可.
涉及到函数:
const Elf32_Ehdr *elf_from(const void *binary, size_t size);int elf_load_seg(Elf32_Phdr *ph, const void *bin, elf_mapper_t map_page, void *data);static void load_icode(struct Env *e, const void *binary, size_t size);static int load_icode_mapper(void *data, u_long va, size_t offset, u_int perm, const void *src, size_t len);
graph TD Start((开始 load_icode)) --> ParseELF[调用 elf_from: <br/>解析 ELF 头部信息] ParseELF --> LoopSeg{遍历每个 <br/>Program Header} LoopSeg -- 存在待加载段 --> CallLoadSeg[调用 elf_load_seg: <br/>处理该 Segment] LoopSeg -- 所有段处理完毕 --> End((结束)) subgraph "elf_load_seg 内部逻辑" CallLoadSeg --> CalcPages[根据 va, sgsize, bin_size <br/>计算所需页面] CalcPages --> PageLoop{遍历页面} PageLoop -- 数据页 --> MapData[调用 load_icode_mapper: <br/>分配内存并拷贝 bin 数据] MapData --> CheckZero{是否需补 0?} CheckZero -- 是 (bin_size < pg_size) --> FillZero[将页面剩余部分清零] CheckZero -- 否 --> NextPage[进入下一页] FillZero --> NextPage PageLoop -- 纯 BSS 页 --> MapZero[调用 load_icode_mapper: <br/>分配内存并全部清零] MapZero --> NextPage NextPage --> PageLoop PageLoop -- 段处理完 --> Return[返回 load_icode] end Return --> LoopSeg
- 分层架构: 最外层是
load_icode对段(Segment)的循环, 内层是elf_load_seg对页面(Page)的循环. - 关键分支:
- 数据填充: 当文件内容(bin)存在时, 执行拷贝.
- 边界清零: 当文件内容不足一个页面大小, 或者进入了 .bss 区域()时, 执行清零操作.
- 解耦设计:
load_icode_mapper作为回调函数(Callback), 在最底层的页面处理逻辑中被调用, 负责实际的内存映射工作.
elf_load_seg
参数 data
data 是 load_icode 调用 elf_load_seg 的时候传入的参数, 是 struct Env * 类型的, 为当前进程的控制块.
data 被用来传递上下文, 因为 elf_load_seg 是通用的 ELF 解析函数, 并不知道自己为谁加载代码,
而 load_icode_mapper 作为回调函数, 需要知道把加载了代码和数据的页映射到那个进程的页表中, 所以需要 data 传递上下文, 获取页表等信息.
不可以没有这个参数,
如果没有这个参数, load_icode_mapper 为了正确获取要要加载代码的进程, 可能要自行获取全局变量, 造成代码更加耦合.
并且这样不利于未来扩展, 可能未来会让 void * 的 data 接受不同类型的上下文信息, 然后写一个另外的回调函数配合,
但是如果没有 data, 可能就要实现不同的 ELF 解析函数, 不能让 elf_load_seg 发挥通用解析功能, 造成逻辑冗余.
要处理的页偏移情况
elf_load_seg 要处理的加载情况一共有以下几种:
- 补头: 填充
offset, 也就是函数根据va, 计算在首页的偏移, 如果va不是页面尺寸整数倍,offset不等于 0, 会从offset开始填充数据,
前面保持page_alloc分配的初始 0 值不变, 进而可以使得剩余页面全部对齐.
u_long offset = va - ROUNDDOWN(va, PAGE_SIZE);
if (offset != 0) {
if ((r = map_page(data, va, offset, perm, bin,
MIN(bin_size, PAGE_SIZE - offset))) != 0) {
return r;
}
}- 拷贝: 以页面为单位, 从刚刚补全到的位置开始, 把
bin_size之前的数据从文件拷贝到内存.
for (i = offset ? MIN(bin_size, PAGE_SIZE - offset) : 0; i < bin_size; i += PAGE_SIZE) {
if ((r = map_page(data, va + i, 0, perm, bin + i, MIN(bin_size - i, PAGE_SIZE))) !=
0) {
return r;
}
}- 填尾: 如果填充完数据, 当前页面还有剩余空间, 则在这些地方填充 0, 这个操作在上一段代码最后一次循环隐式执行
(因为page_alloc分配的页面本身就是全 0 的, 所以最后一次循环只要不在bin_size之后的区域填充任何数据即可)
除此之外, 还要给seg_size > bin_size的情况补充 0, 也就是.bss段.
while (i < sgsize) {
if ((r = map_page(data, va + i, 0, perm, NULL, MIN(sgsize - i, PAGE_SIZE))) != 0) {
return r;
}
i += PAGE_SIZE;
}load_icode
/* Overview:
* Load program segments from 'binary' into user space of the env 'e'.
* 'binary' points to an ELF executable image of 'size' bytes, which contains both text and data
* segments.
*/
static void load_icode(struct Env *e, const void *binary, size_t size)
{
/* Step 1: Use 'elf_from' to parse an ELF header from 'binary'. */
const Elf32_Ehdr *ehdr = elf_from(binary, size);
if (!ehdr) {
panic("bad elf at %x", binary);
}
/* Step 2: Load the segments using 'ELF_FOREACH_PHDR_OFF' and 'elf_load_seg'.
* As a loader, we just care about loadable segments, so parse only program headers here.
*/
size_t ph_off;
ELF_FOREACH_PHDR_OFF(ph_off, ehdr)
{
Elf32_Phdr *ph = (Elf32_Phdr *)(binary + ph_off);
if (ph->p_type == PT_LOAD) {
// 'elf_load_seg' is defined in lib/elfloader.c
// 'load_icode_mapper' defines the way in which a page in this segment
// should be mapped.
panic_on(elf_load_seg(ph, binary + ph->p_offset,
load_icode_mapper, e));
}
}
/* Step 3: Set 'e->env_tf.cp0_epc' to 'ehdr->e_entry'. */
e->env_tf.cp0_epc = ehdr->e_entry;
}回顾 ELF 格式, e_entry 记录了程序入口的虚拟地址,
e->env_tf.cp0_epc = ehdr->e_entry; 一句, 给 EPC 赋值, 从而让 CPU 知道执行进程时, 要跳转的地址.
创建进程
这里的进程创建是操作系统内核初始化的时候直接创建进程, 不是利用 fork() 系统调用创建的.
也就是手动创建, 实则就是使用我们刚刚的那些函数, 封装为一个过程:
分配一个新的 Env 结构体, 设置进程控制块, 将程序载入到目标进程的地址空间, 并将其加入可调度进程队列即可.
运行进程
在 MOS 中, env_run 是进程运行使用的基本函数, 有两部分:
- 保存当前进程上下文(如果有的话)
- 恢复要启动的进程的上下文, 并运行.
![note] 进程上下文就是进程执行的时候所有寄存器的状态, 即为
Trapframe.
包括通用寄存器, HI, LO 和 CP0 中的 Status, EPC, Cause 和 BadVAddr 寄存器.
进程控制块除了env_tf其他的字段在进程切换后还保留在原本的进程控制块中, 并不会改变, 因此不需要保存.
寄存器状态保存的地方是 KSTACKTOP(内核栈) 以下的一个sizeof(TrapFrame)大小的区域中.
curenv->env_tf = *((struct Trapframe *)KSTACKTOP - 1) 中,
curenv->env_tf 就是当前进程的上下文所存放的区域.
我们将把 KSTACKTOP 之下的 Trapframe 拷贝到当前进程的 env_tf 中, 以达到保存进程上下文的效果.
因此, env_run 的任务为:
- 保存当前进程的上下文信息到
curenv(此时curenv为刚刚执行的进程). - 切换
curenv为即将运行的进程. - 设置全局变量
cur_pgdir为当前进程页目录地址, 在 TLB 重填时将用到该全局变量. - 调用
env_pop_tf函数, 恢复现场, 异常返回.
关于 env_run
上下文保存与恢复
注意保存上下文时, 我们是把寄存器从内核栈转移到进程结构体,
这是因为在异常处理中, 异常处理程序会先把状态保存到内核栈, 再跳转至具体的处理逻辑.
因此寄存器状态是先存在于内核栈, 要保存到结构体的, 而不是相反.
具体地, “跳转”发生时(例如执行了 syscall 或发生了硬件中断), CPU 会从用户态切换到内核态. 这个过程分为两步:
- 硬件逻辑(CPU 自动化):
在 MIPS 中, 硬件会自动完成最关键的切换. 会将当前的 PC 保存到 EPC 寄存器,并切换到内核栈(KSTACKTOP). - 陷阱处理程序(汇编入口
trap_entry):
硬件跳转到内核的异常向量入口后, 第一件事就是执行一段汇编代码.
这段代码会手动将剩下的通用寄存器(a0, $t0 等)按照Trapframe的顺序一个一个压入内核栈中.
随后跳转到具体地陷阱处理逻辑(例如我们现在的进程切换逻辑).
寄存器恢复与回到用户态
由汇编函数 env_pop_tf 执行恢复现场, 回到用户态的逻辑.
LEAF(env_pop_tf)
.set reorder
.set at
mtc0 a1, CP0_ENTRY_HI
move sp, a0
RESET_KCLOCK
j ret_from_exception
END(env_pop_tf)函数签名为 void env_pop_tf(struct TrapFrame *tf, u_int asid)
所以第一个参数为 TrapFrame 结构体的地址, 被存在栈指针里,
第二个参数 asid 被存在 ENTRY_HI, 从而可以在用户态时访问进程地址空间的 TLB.
随后跳转到 ret_from_exception 函数处理剩下的任务: 将栈空间里的值以此装载回寄存器, 最后 eret,
实际内容正是 其余信息的设置 中提到的两行汇编.
异常与中断
现在, 一个进程的创建与切换的全过程已经处理完毕, 我们是时候看看进程切换的过程之中发生的事了, 也就是异常与中断.
与 CO 的 P7 有较大关系.

当时我们把寄存器设置好, 然后就跳转到异常处理程序, 交给软件处理, 这就是我们今天的起点.
软件有异常处理程序, 需要放在程序特定地址, 从而硬件可以自动跳转, 这需要用到我们过去提到过的Linker Script.
在 4Kc 中, 有 .text.exc_gen_entry 段和 .text.tlb_miss_entry 段, 共两段异常处理程序, 需要被链接器放到特定的位置: 0x80000180 和 0x80000000.
其中, 除了用户态下的 TLB Miss 跳转到后者, 剩下的 CPU 异常都会跳转到前者, 前者根据异常类型进一步分发, 这就是异常的分发.
异常的分发
当发生异常时, 处理器会进入一个用于分发异常的程序, 这个程序的作用就是检测发生了哪种异常, 并调用相应的异常处理程序.
这就是异常处理最开始阶段的分发程序. 我们做了如下几个事:
.section .text.exc_gen_entry
exc_gen_entry:
# 在刚进入异常的时候, CPU 自动给 EXL 置为 1, UM 也为 1, 进入内核态
# 此时 CPU 不响应嵌套异常和中断. 因为此时如果响应, 会导致现场没有保存, 无法回到最开始的异常触发点.
SAVE_ALL
# 保存现场, 将寄存器保存到内核栈.
mfc0 t0, CP0_STATUS
and t0, t0, ~(STATUS_UM | STATUS_EXL | STATUS_IE)
mtc0 t0, CP0_STATUS
# 这里我们把 UM, EXL, IE 都置为 0, 这样, CPU 仍保持内核态.
# 并且 EXL 与 IE 同时为 0, 使得 CPU 响应异常而不响应中断, 从而支持了嵌套异常.
mfc0 t0, CP0_CAUSE
andi t0, 0x7c
# 取出 Cause 寄存器的 Execode, 从而获取到异常原因.
lw t0, exception_handlers(t0)
jr t0
# 根据异常码, 在异常向量组里, 找到对应的异常处理程序入口并跳转.exception_handlers 是一个数组, 异常向量组.
void (*exception_handlers[32])(void) = {
[0 ... 31] = handle_reserved,
[0] = handle_int,
[2 ... 3] = handle_tlb,
[1] = handle_mod,
[8] = handle_sys,
};对于时钟中断, 会使用的是 handle_int
NESTED(handle_int, TF_SIZE, zero)
mfc0 t0, CP0_CAUSE
mfc0 t2, CP0_STATUS
and t0, t2
andi t1, t0, STATUS_IM7
bnez t1, timer_irq
timer_irq:
li a0, 0
j schedule
END(handle_int)其中, 会进一步判断 Cause 寄存器中是由几号中断位引发的中断, 然后进入不同中断对应的中断服务函数.
其中, 4Kc 会通过 CP0 内置的 Timer, 产生时钟中断, 用来触发这个 handler 并且进行调度.
handle_int 函数根据 Cause 寄存器的值判断是否是 Timer 对应的 7 号中断位引发的时钟中断.
如果是, 则执行中断服务函数 timer_irq, 跳转到 schedule 中执行, 也就是调度函数.
宏 RESET_KCLOCK 会完成对时钟的设置. 将 Count 寄存器清零并将 Compare 寄存器配置为我们所期望的计时器周期数.
这发生在调度执行每一个进程之前, 从代码角度, 就是在 env_pop_tf 中调用了宏 RESET_KCLOCK,
随后又在宏 RESTORE_ALL 中恢复了 Status 寄存器, 开启了中断.
进程调度
进程的调度由 handle_int 跳转到的 schedule 负责.
MOS 中的时间片的长度是用时钟中断衡量的, 即时间片长度被量化为 .
具体的, env 中的优先级即为这里的 N, 规定了该进程的时间片长度.
它根据追踪所有 ENV_RUNNABLE 的进程的 env_sched_list, 按顺序进行调度.
当内核创建新进程时, 将其插入调度链表的头部; 在其不再就绪(被阻塞)或退出时, 将其从调度链表中移除.
调度函数 schedule 被调用时, 当前正在运行的进程被存储在全局变量 curenv 中(在第一个进程被调度前为 NULL), 其剩余的时间片长度被存储在静态变量 count 中.
我们考虑是否需要进行进程切换, 包括以下几种情况:
- 尚未调度过任何进程(
curenv为空指针) - 当前进程已经用完了时间片
- 当前进程不再就绪(如被阻塞或退出)
yield参数指定必须发生切换
无需进行切换时, 我们只需要将剩余时间片长度 count 减去 1, 然后调用 env_run 函数, 继续运行当前进程 curenv.
在发生切换的情况下, 我们还需要判断当前进程是否仍然就绪, 如果是则将其移动到调度链表的尾部. 之后, 我们选中调度链表首部的进程来调度运行, 将剩余时间片长度设置为其优先级.