OS-lab3

OS-lab3

本次实验重点是理解进程时如何创建的以及如何调度(不包含fork)。

进程创建与初始化

首先需要我们从一个宏观的角度去看待这件事情:我们究竟要完成的是一个什么任务?

必须明确的一个点是,我们想要最后在 MOS 中实现的有多个进程可以同时运行轮着调度。因此第一个问题:我们应该如何去保存一个进程的信息(包括运行时长,切换时的状态等)。这时引出一个进程控制块(PCB) 来实现:

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_parent_id; // env_id of this env's parent
	u_int env_status; // status of this env
	Pde *env_pgdir; // page directory
	TAILQ_ENTRY(Env) env_sched_link; // intrusive entry in 'env_sched_list'
	u_int env_pri; // schedule priority
};

那么紧接着的问题是:我们怎么知道当前的操作系统中有多少正在等待的进程呢?以及有多少还可以供新建的进程资源?这里仿照之前对页表的管理,我们有了两个队列:空闲队列 env_free_list 和调度队列 env_sched_list 来管理。但是需要注意的是,这两个队列的实现方式并不相同!其中 env_free_list 实现的是与 lab2 中相同的 LIST 结构,而 env_sched_list 实现的是一个双端队列 TAILQ定义在 include/queue.h 中,便于在头部和尾部的插入和取出,这么做当然是方便了进程调度时的顺序管理。而对后一种队列,只需要用 TAILQ 开头的宏定义操作即可。

另外,在本次实验中,有一个特殊的点在于,有一部分的内核态会直接暴露给用户态,提供可读的权限。在实验中,虚拟地址 ULIMkseg0kuseg 的分界线,是系统给用户进程分配的最高地址。ULIM 以上的地方,kseg0kseg1 两部分内存的访问不经过 TLB,这部分内存由内核管理、所有进程共享。在 MOS 操作系统特意将一些内核的数据暴露到用户空间,使得进程不需要切换到内核态就能访问,这是 MOS 特有的设计。在 Lab4Lab6 中将用到此机制。而这里我们要暴露是 UTOP 往上到 UVPT 之间所有进程共享的只读空间,也就是把这部分内存对应的内核页表 base_pgdir 拷贝到进程页表中。从 UVPT 往上到 ULIM 之间则是进程自己的页表。

env_init

这个函数用于内核态第一次启动的时候,用于初始化进程的一些信息。只在内核态第一次启动的时候起初始化的效果,后续每次创建进程的时候并不会被重复调用。那么初始化的就是两个空闲和调度队列。以及把全部的进程资源插入到空闲队列中。一共可接受多少个进程呢?代码中在 include/env.h 中有一个变量显示了可同时存在的进程数:

#define LOG2NENV 10
#define NENV (1 << LOG2NENV)

也即最多有 \(2^{10} = 1024\) 个进程允许同时存在。在完成这些之后,还需要做一个模板页表。因为上述把 UTOPUVPT 暴露给了每一个进程,因此每一个进程的页表项中应该可以直接定位到这部分的内存。而在初始化的时候,我们首先声明一个模板页表,是的模板页表中已经设置好这块内容的映射,那么后续的进程创建时我们只需将这块模板页表的内容复制进去即可。

整理后有这个函数写作:

void env_init(void) {
	int i;
	/* Step 1: Initialize 'env_free_list' with 'LIST_INIT' and 'env_sched_list' with
	 * 'TAILQ_INIT'. */
	/* Exercise 3.1: Your code here. (1/2) */
	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(i = NENV - 1; i >= 0; i--) {
		envs[i].env_status = ENV_FREE;
		LIST_INSERT_HEAD(&env_free_list, &envs[i], env_link);
	}
	/* Exercise 3.1: Your code here. (2/2) */

	/*
	 * 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++;		// 记得页表引用要 +1

	base_pgdir = (Pde *)page2kva(p); 
    // 定位使用
	map_segment(base_pgdir, 0, PADDR(pages), UPAGES, ROUND(npage * sizeof(struct Page), BY2PG),
		    PTE_G);
	map_segment(base_pgdir, 0, PADDR(envs), UENVS, ROUND(NENV * sizeof(struct Env), BY2PG),
		    PTE_G);
}

而这里的 map_segment 又是如何实现内存映射的,接下来我们仔细分析。

map_segment

段地址映射函数 void map_segment(Pde *pgdir, u_long pa, u_long va, u_long size, u_int perm),功能是在一级页表基地址 pgdir 对应的两级页表结构中做段地址映射,将虚拟地址段 [va,va+size) 映射到物理地址段 [pa,pa+size),因为是按页映射,要求 size 必须是页 面大小的整数倍。同时为相关页表项的权限为设置为 perm。它在这里的作用是将内核中的 PageEnv 数据结构映射到用户地址,以供用户程序读取。

既然是建立虚拟地址和逻辑地址的映射,那我们可以直接调用之前写好的 page_insert 函数即可。不过需要注意的是这里的 size 是页表大小的整数倍,因此需要首先确认要插入几块页表,用循环实现:

static void map_segment(Pde *pgdir, u_int asid, u_long pa, u_long va, u_int size, u_int perm) {

	assert(pa % BY2PG == 0);
	assert(va % BY2PG == 0);
	assert(size % BY2PG == 0);

	/* Step 1: Map virtual address space to physical address space. */
	for (int i = 0; i < size; i += BY2PG) {
		/*
		 * Hint:
		 *  Map the virtual page 'va + i' to the physical page 'pa + i' using 'page_insert'.
		 *  Use 'pa2page' to get the 'struct Page *' of the physical address.
		 */
		/* Exercise 3.2: Your code here. */
		page_insert(pgdir, asid, pa2page(pa + i), va + i, perm);
	}
}

好的,那么到现在为止,有关进程的初始化事情全部完成,接下来的工作是如何纯手工创建一个进程。

进程创建

考虑一个进程创建的时候都需要准备哪些内容。首先肯定是它的目的,创建这个进程为了执行这么程序完成什么功能,也就是加载对应的二进制镜像 ELF 文件到进程内存中。另外则是对于这个进程的一些控制信息,也就是 PCB进程控制块。因此如果要创建一个进程的话,流程大致为:

  • 首先从空闲队列 env_free_list 中拿一个空的 PCB
  • 填写 PCB 控制块相关信息如优先级以及进程状态等。
  • 加载对应的二进制镜像 ELF 文件
  • 最后进程创建完毕,将此时的 PCB 纳入调度队列中

实现代码:

struct Env *env_create(const void *binary, size_t size, int priority) {
	struct Env *e;
	/* Step 1: Use 'env_alloc' to alloc a new env. */
	/* Exercise 3.7: Your code here. (1/3) */
	if(env_alloc(&e, 0) < 0)   // 新获取一个 PCB 控制块
		return NULL;

	/* Step 2: Assign the 'priority' to 'e' and mark its 'env_status' as runnable. */
	/* Exercise 3.7: Your code here. (2/3) */
	e->env_pri = priority;
	e->env_status = ENV_RUNNABLE;

	/* Step 3: Use 'load_icode' to load the image from 'binary', and insert 'e' into
	 * 'env_sched_list' using 'TAILQ_INSERT_HEAD'. */
	/* Exercise 3.7: Your code here. (3/3) */
	load_icode(e, binary, size);  // 加载二进制文件
	TAILQ_INSERT_HEAD(&env_sched_list, e, env_sched_link);
    // 此处的 env_sched_link 与上节的 pp_link 作用相同

	return e;
}

那么具体来说,如何初始化并且手工填入 PCB 哪些信息,以及如何加载二进制 ELF 文件,接下来我们具体分析。

PCB 的初始化与申请

别急,我们首先回顾一个进程控制块有哪些内容:

struct Env {
	struct Trapframe env_tf; // 保存切换时的进程信息
	LIST_ENTRY(Env) env_link; // intrusive entry in '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 this env
	Pde *env_pgdir; // page directory
	TAILQ_ENTRY(Env) env_sched_link; // intrusive entry in 'env_sched_list'
	u_int env_pri; // schedule priority
};

其中最重要的是 env_pgdir。由于进程之间的独立性。对每一个进程我们都要建立一个二级的页表寻址制度。因此每一个进程控制块中都必须存一个这个进程对应的一级页表,而这个页表的地址就存在了 env_pgdir 当中。我们重点分析,建立二级页表制度的时候,首先申请一块新页表作为一级页表。紧接着还记得上面所说的内核态把一部分内容暴露给了用户态吗,因此我们需要把模板页表的部分内容复制进去,最后建立一个自映射制度即可完成对二级页表制度的建立。具体代码为:

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));  // 申请一块新页表
	/* Exercise 3.3: Your code here. */
	p->pp_ref++;
	e->env_pgdir = (Pde*)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;
}

那么自映射如何建立的呢?

thinking 3.1 请结合 MOS 中的页目录自映射应用解释代码中 e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_V 的含义。

自映射建立的是一级页表中的某一项对应的二级页表正好是自己的事情。因此这一个页表项中的内容应该是自己的物理页号再加上权限位也就是右边。那到底应该是第几个页表项呢?注意到用户新建进程页表的虚拟地址均从 UVPT 开始,因此直接对其进行操作即可得到对应的页表项。

在建立好页表的映射制度后,只需要新申请一块 PCB 并设置好其他信息即可返回一个可以用的进程控制块啦:

int env_alloc(struct Env **new, u_int parent_id) {
	int r;
	struct Env *e;

	/* Step 1: Get a free Env from 'env_free_list' */
	/* Exercise 3.4: Your code here. (1/4) */
	if(LIST_EMPTY(&env_free_list)) {
		*new = NULL;
		return -E_NO_FREE_ENV;
	}
	e = LIST_FIRST(&env_free_list);  // 新申请一个 PCB


	/* Step 2: Call a 'env_setup_vm' to initialize the user address space for this new Env. */
	/* Exercise 3.4: Your code here. (2/4) */
	r = env_setup_vm(e);  // 建立二级页表映射
	if(r < 0)
		return r;

	/* Step 3: Initialize these fields for the new Env with appropriate values:
	 *    初始化其他信息
	 *   'env_user_tlb_mod_entry' (lab4), 'env_runs' (lab6), 'env_id' (lab3), 'env_asid' (lab3),
	 *   'env_parent_id' (lab3)
	 *
	 * Hint:
	 *   Use 'asid_alloc' to allocate a free asid.
	 *   Use 'mkenvid' to allocate a free envid.
	 */
	e->env_user_tlb_mod_entry = 0; // for lab4  
	e->env_runs = 0;	       // for lab6
	/* Exercise 3.4: Your code here. (3/4) */
	e->env_id = mkenvid(e);  // 获取进程标识号
	e->env_parent_id = parent_id;
	if(asid_alloc(&(e->env_asid)) < 0)
		return -E_NO_FREE_ENV;


	/* Step 4: Initialize the sp and 'cp0_status' in 'e->env_tf'. */
	// Timer interrupt (STATUS_IM4) will be enabled.
	e->env_tf.cp0_status = STATUS_IM4 | STATUS_KUp | STATUS_IEp;
	// Keep space for 'argc' and 'argv'.
	e->env_tf.regs[29] = USTACKTOP - sizeof(int) - sizeof(char **);

	/* Step 5: Remove the new Env from env_free_list. */
	/* Exercise 3.4: Your code here. (4/4) */
	LIST_REMOVE(e, env_link);

	*new = e;
	return 0;
}

其他信息介绍在做本次实验时可以暂时不去关注,详情可以参考实验指导书。

加载二进制镜像文件

要想正确加载一个 ELF 文件到内存,只需将 ELF 文件中所有需要加载的程序段(program segment)加载到对应的虚拟地址上即可。实验中已经写好了用于解析 ELF 文件的代码中的大部 分内容,可以直接调用相应函数获取 ELF 文件的各项信息,并完成加载过程。具体来说,一个 ELF 文件分为文件头和其他的每一个段。在加载函数 load_icode 中,我们通过调用 elf_from 实现对文件头的解析,而函数 elf_load_seg 实现了对不同的文件的段的加载。

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); // 解析 ELF 文件头
	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) {  // 循环定位并加载不同的 segment
		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'. */
	/* Exercise 3.6: Your code here. */
	e->env_tf.cp0_epc = ehdr->e_entry;

}

最后一句话 e->env_tf.cp0_epc 字段指示了进程恢复运行时 PC 应恢复到的位置。我们要运行的进 程的代码段预先被载入到了内存中,且程序入口为 e_entry,当我们运行进程时,CPU 将自动 从 PC 所指的位置开始执行二进制码。

thinking 3.4 你认为这里的 env_tf.cp0_epc 存储的是物理地址还是虚拟地址?

当然是虚拟地址啦

接着我们来看每一个段到底是如何通过 elf_load_seg 函数来进行加载的。这个函数定义在了 lib/elfloader.c 中:

int elf_load_seg(Elf32_Phdr *ph, const void *bin, elf_mapper_t map_page, void *data) {
	u_long va = ph->p_vaddr;
	size_t bin_size = ph->p_filesz;
	size_t sgsize = ph->p_memsz;
	u_int perm = PTE_V;
	if (ph->p_flags & PF_W) {
		perm |= PTE_D;
	}

	int r;
	size_t i;
	u_long offset = va - ROUNDDOWN(va, BY2PG);
	if (offset != 0) {
		if ((r = map_page(data, va, offset, perm, bin, MIN(bin_size, BY2PG - offset))) !=
		    0) {
			return r;
		}
	}

	/* Step 1: load all content of bin into memory. */
	for (i = offset ? MIN(bin_size, BY2PG - offset) : 0; i < bin_size; i += BY2PG) {
		if ((r = map_page(data, va + i, 0, perm, bin + i, MIN(bin_size - i, BY2PG))) != 0) {
			return r;
		}
	}

	/* Step 2: alloc pages to reach `sgsize` when `bin_size` < `sgsize`. */
	while (i < sgsize) {
		if ((r = map_page(data, va + i, 0, perm, NULL, MIN(bin_size - i, BY2PG))) != 0) {
			return r;
		}
		i += BY2PG;
	}
	return 0;
}

每当 elf_load_seg 函数解析到一个需要加载到内存中的页面,会将有关的信息作为参数传递给回调函数,并由它完成单个页面的加载过程,而这里 load_icode_mapper 就是 map_page 的具体实现。在这个函数中主要实现了有关每一段不对齐的情况。即下图所示。

首先根据表头不难得知这一段的起始地址 va 以及大小 bin_size。首先起始地址要和 BY@PG 对齐,也即卡出来的一部分 BY2PG - offset 部分要单独装入一个页表中,不足的用零补齐。之后向后遍历时则为整数块可直接装载。最后到结尾的时候仍然有一部分可能不对齐的现象。这时候取出实际的大小并装入一块新的页表即可。

thinking 3.3 结合 elf_load_seg 的参数和实现,考虑该函数需要处理哪些页面加载的情况。

需要考虑的情况为:开头和末尾是否对其而因此产生的边角料。另外就是实际的一段是否有 BY2PG 这么大,何时截断如何对齐等。

在取下来一块单独的页表需要装载后,直接调用了 map_page 进行装载,也就是代码中的回调函数 load_icode_mapper。那么这个函数的功能就显而易见了,把某一块整个装进某一个页表中,并修改权限位。首先当然是申请一块新的页表,然后将其内容复制进去后加入二级页表制度中即可:

static int load_icode_mapper(void *data, u_long va, size_t offset, u_int perm, const void *src,
			     size_t len) {
	struct Env *env = (struct Env *)data;
	struct Page *p;
	int r;

	/* Step 1: Allocate a page with 'page_alloc'. */
	/* Exercise 3.5: Your code here. (1/2) */
	if((r = page_alloc(&p)) < 0)
		return r;

	/* Step 2: If 'src' is not NULL, copy the 'len' bytes started at 'src' into 'offset' at this
	 * page. */
	// Hint: You may want to use 'memcpy'.
	if (src != NULL) {
		/* Exercise 3.5: Your code here. (2/2) */
		memcpy((void*)(page2kva(p) + offset), src, len * sizeof(char));
	}

	/* Step 3: Insert 'p' into 'env->env_pgdir' at 'va' with 'perm'. */
	return page_insert(env->env_pgdir, env->env_asid, p, va, perm);
}

thinking 3.2 elf_load_seg 以函数指针的形式,接受外部自定义的回调函数 map_page。 请你找到与之相关的 data 这一参数在此处的来源,并思考它的作用。没有这个参数可不可以?为什么?

dataelf_load_seg 函数中被传入了 map_page 函数、也就是实际传入的 load_icode_mapper 函数,在 env = (struct Env*)data 处被使用。我们在这里使用 void* 类型传递参数,提供了类似泛型的作用;此时整个 elfloader.c 都没有用到 struct Env ,方便对其它类型的结构体进行操作;如果没有,会影响程序的灵活性。

那么至此全部的二进制文件被加载完毕,一个进程正式启动了。

进程的运行与切换

好啦,创建好了进程,就可以正常运行啦。不过如果想实现进程调度的话,就必须实现运行了一半就去运行另一个进程。此时的原进程的一些信息就需要保留。这一小节关注的是这些信息是如何被保留并且储存的。

有一个全局变量 curenv 表示当前的进程,我们只需要把这个进程当前运行的信息储存进对应的 PCB 块中即可,也即其中有一另一个结构体 env_tf。其中这个结构体的声明位于 include/trap.h 中:

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;
};

其中数组 regs 保存 32 个寄存器的信息,其他位置保存其他信息,如 cp0_epc 就保存的是 CPUEPC 的值。

那么当进程发生切换时,也就需要执行如下几步:

  • 首先讲当前进程的一些信息保存到 PCB 块中的 env_tf 当中
  • 切换到下一个需要执行的进程,紧跟着切换对应的二级页表制度
  • 最后从 PCB 中取出上次保存的寄存器以及各状态,继续执行即可。
void env_run(struct Env *e) {
	assert(e->env_status == ENV_RUNNABLE);
	pre_env_run(e); // WARNING: DO NOT MODIFY THIS LINE!

	/* Step 1:
	 *   If 'curenv' is NULL, this is the first time through.
	 *   If not, we may be switching from a previous env, so save its context into
	 *   'curenv->env_tf' first.
	 */
	if (curenv) {
		curenv->env_tf = *((struct Trapframe *)KSTACKTOP - 1);
	}

	/* Step 2: Change 'curenv' to 'e'. */
	curenv = e;
	curenv->env_runs++; // lab6

	/* Step 3: Change 'cur_pgdir' to 'curenv->env_pgdir', switching to its address space. */
	/* Exercise 3.8: Your code here. (1/2) */
	cur_pgdir = curenv->env_pgdir;

	/* Step 4: Use 'env_pop_tf' to restore the curenv's saved context (registers) and return/go
	 * to user mode.
	 *
	 * Hint:
	 *  - You should use 'curenv->env_asid' here.
	 *  - 'env_pop_tf' is a 'noreturn' function: it restores PC from 'cp0_epc' thus not
	 *    returning to the kernel caller, making 'env_run' a 'noreturn' function as well.
	 */
	/* Exercise 3.8: Your code here. (2/2) */
	env_pop_tf(&(curenv->env_tf), curenv->env_asid);

其中的 env_pop_tf 函数为一个汇编函数。

时钟中断与时间片轮转算法

首先我们要明确的是时钟中断是如何产生的:

kern/kclock.S 中的 kclock_init 函数完成了时钟中断的初始化,该函数向 KSEG1 | DEV_RTC_ADDRESS | DEV_RTC_HZ 位置写入 200,其中 KSEG1 | DEV_RTC_ADDRESS 是模拟器(GXemul)映射实时 钟的位置。偏移量为 DEV_RTC_HZ 表示设置实时钟中断的频率,200 表示 1 秒钟中断 200 次;如果写入 0,表示关闭时钟中断。时钟中断对于 GXemul 来说绑定到了 4 号中断上。注意这里的 中断号和异常号是不一样的概念,我们实验的异常包括中断,中断本身是 0 号异常。因此首先我们需要给系统设置每个时间片的时长,在文件中 kern/klock.S 中补充有:

LEAF(kclock_init)
	li      t0, 200 // the timer interrupt frequency in Hz

	/* Write 't0' into the timer (RTC) frequency register.
	 *
	 * Hint:
	 *   You may want to use 'sw' instruction and constants 'DEV_RTC_ADDRESS' and
	 *   'DEV_RTC_HZ' defined in include/drivers/dev_rtc.h.
	 *   To access device through mmio, a physical address must be converted to a
	 *   kseg1 address.
	 *
	 * Reference: http://gavare.se/gxemul/gxemul-stable/doc/experiments.html#expdevices
	 */
	/* Exercise 3.11: Your code here. */
	sw	t0, (KSEG1 | DEV_RTC_ADDRESS | DEV_RTC_HZ)

	jr      ra

thinking 3.6 阅读 init.ckclock.Senv_asm.Sgenex.S 这几个文件,并尝试说出 enable_irqtimer_irq 中每行汇编代码的作用。

对于函数 enable_irq 其主要实现的是开启中断的功能:

LEAF(enable_irq)
	li t0, (STATUS_CU0 | STATUS_IM4 | STATUS_IEc) /*其中设STATUS_IM4使得可以正常响应时钟中断*/
	mtc0 t0, CP0_STATUS /*将上面的状态值存入CP0_STATUS*/
	jr ra /*返回*/
END(enable_irq)

而对于函数 timer_irq 其主要实现的是中断跳转函数。因此首先要相应中断并且传参判断是何种中断:

timer_irq:
	sw zero, (KSEG1 | DEV_RTC_ADDRESS | DEV_RTC_INTERRUPT_ACK) /*响应时钟中断*/
	li a0, 0 /*设置传入参数*/
	j schedule /*调用函数进行调度*/	

现在时钟脉冲有了,那么我们如何检测哪里有中断呢,因此需要一个中断分发异常的程序。具体流程为:

  • 使用 SAVE_ALL 宏将将当前上下文保存到内核的异常栈中。
  • Cause 寄存器的内容拷贝到 t0 寄存器中。
  • 取得 Cause 寄存器中的 2~6 位,也就是对应的异常码,这是区别不同异常的重要标志。
  • 以得到的异常码作为索引在 exception_handlers 数组中找到对应的中断处理函数。
  • 跳转到对应的中断处理函数中,从而响应了异常,并将异常交给了对应的异常处理函数去处理。

异常分发代码在 kern/entry.S 中:

exc_gen_entry:
	SAVE_ALL
/* Exercise 3.9: Your code here. */
	mfc0	t0, CP0_CAUSE
	andi	t0, 0x7c
	lw		t0, exception_handlers(t0)
	jr		t0

那么问题来了,这段异常处理的程序我们放在哪里呢,需要在 kernel.lds 中补充有:

	/* Exercise 3.10: Your code here. */
	. = 0x80000000;
	.tlb_miss_entry : { *(.text.tlb_miss_entry) }
	. = 0x80000080;
	.exc_gen_entry : { *(.text.exc_gen_entry) }

好了。到目前为止,异常分发代码已经可以执行了。接下来就是跳转到不同的异常处理程序中去。此处代码中使用了一个异常向量组的形式进行定义,直接跳转到对应的程序进行处理。

thinking 3.5 试找出 0、1、2、3 号异常处理函数的具体实现位置。8 号异常(系统调用) 涉及的 do_syscall() 函数将在 Lab4 中实现。

  • 0号 handle_int 在 kern/genex.S 的第24-35行
  • 1, 2, 3, 8号也在 kern/genex.S ,不过没有直接写出来,而是用第5行的汇编宏函数 BUILD_HANDLER 实现的,其中 handle_modhandlerdo_tlb_modhandle_syshandlerdo_syscallhandle_tlbhandlerdo_tlb_refill (就是 Lab2TLB 重填中的 do_tlb_refill )(第37-42行)

之后处理好之后即可异常返回。

时间片轮转

好了现在我们可以创建多个进程并成功运行了,那么我们如何去切换各个进程之间的运行呢?我们用专门的函数 kern/sched.c 中的 schedule 来实现。

  • 首先需要判断是否需要轮转
  • 如果不需要则继续执行即可。其中每个进程的优先级就是它需要执行的时间片的大小。
  • 如果需要切换的话则考虑调度队列中是否还有等待的进程。如果有则将原来的进程插入队列末尾(如果仍然需要运行的话),然后取出头部新的进程,更新状态并继续运行。

具体可见代码为:

void schedule(int yield) {
	static int count = 0; // remaining time slices of current env
	struct Env *e = curenv;

	/* We always decrease the 'count' by 1.
	 *
	 * If 'yield' is set, or 'count' has been decreased to 0, or 'e' (previous 'curenv') is
	 * 'NULL', or 'e' is not runnable, then we pick up a new env from 'env_sched_list' (list of
	 * all runnable envs), set 'count' to its priority, and schedule it with 'env_run'. **Panic
	 * if that list is empty**.
	 *
	 * (Note that if 'e' is still a runnable env, we should move it to the tail of
	 * 'env_sched_list' before picking up another env from its head, or we will schedule the
	 * head env repeatedly.)
	 *
	 * Otherwise, we simply schedule 'e' again.
	 *
	 * You may want to use macros below:
	 *   'TAILQ_FIRST', 'TAILQ_REMOVE', 'TAILQ_INSERT_TAIL'
	 */
	/* Exercise 3.12: Your code here. */
	count--;
	if(yield || count == 0 || e == NULL || e->env_status != ENV_RUNNABLE) {
		if(e != NULL) {
			TAILQ_REMOVE(&env_sched_list, e, env_sched_link);
			if(e->env_status == ENV_RUNNABLE) 
				TAILQ_INSERT_TAIL(&env_sched_list, e, env_sched_link);
		}
		if(TAILQ_EMPTY(&env_sched_list))
			panic("schedule: no runnable envs");
		e = TAILQ_FIRST(&env_sched_list);
		count = e->env_pri;
	}
	env_run(e);
}

在上述代码中, yield 实现了人为控制进程的切换,其中的 curenv 是当前执行的进程。

thinking 3.7 阅读相关代码,思考操作系统是怎么根据时钟中断切换进程的。

我们有一个调度队列 env_sched_list ,一旦当前时间片走完,就执行时钟中断,将当前进程移到队列尾部,然后执行队首进程,循环往复,实现根据时钟周期切换进程。需要注意的是 env_pri 也就是优先级,表示的是会执行几个时钟周期,所以说会将其存入 count 、并每次减一。

那么至此完成了进程之间的切换,我们的 lab3 也圆满结束啦~

难点分析

其实整理一下干了些什么:

  • 首先是进程各种信息的初始化 env_init ,如何正确理解内核态向用户态暴露的那部分内容并拷贝是重难点。
  • 之后是进程的创建,不仅要创建 PCB 块,还要把相应的二进制镜像拷贝进去。梳理清楚二进制镜像如何拷贝的比较关键。
  • 最后是异常与时间片轮转。理解异常的发生以及进程之间如何调度也是一个重要的点。
posted @ 2023-04-18 03:02  Abyss7893  阅读(205)  评论(0)    收藏  举报