Lab3实验报告

Part1. 思考题

Thinking 3.1 对物理地址和虚拟地址的理解

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

  • UVPT是一个虚拟地址,表示用户进程页表的起始地址。PDX(UVPT)UVPT地址的页目录项的索引(页目录中第PDX(UVPT)个页表项)。
  • e->env_pgdir[PDX(UVPT)]是用户进程UVPT的页目录项。
  • PADDR(e->env_pgdir)是将页目录的虚拟地址转换为物理地址。PTE_V表示该页表项有效。
  • e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_V的含义是将页目录中第PDX(UVPT)个页表项设置为指向物理地址PADDR(e->env_pgdir),并标记为有效。这样做的目的是实现自映射,使得用户进程可以通过虚拟地址UVPT访问自己的页目录,从而实现对页表的访问和操作。

Thinking 3.2 data 的作用

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// in lib/elfloader.c
int elf_load_seg(Elf32_Phdr *ph, const void *bin, elf_mapper_t map_page, void *data) {
...
if (offset != 0) {
if ((r = map_page(data, va, offset, perm, bin,
MIN(bin_size, PAGE_SIZE - offset))) != 0) {
return r;
}
}
...
return 0;
}
// in kern/env.c
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;
if ((r = page_alloc(&p)) != 0) {
return r;
}
if (src != NULL) {
memcpy((void *)(page2kva(p) + offset), src, len);
}
return page_insert(env->env_pgdir, env->env_asid, p, va, perm);
}

static void load_icode(struct Env *e, const void *binary, size_t size) {
...
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));
}
}
...
}
  • 查阅代码可以发现,data参数的来源是load_icode函数中传入的参数e,它是一个指向struct Env类型的指针,表示当前进程的控制块指针,包含了进程的相关信息,如env_pgdirenv_asid等。
  • 如果没有data参数,那么load_icode_mapper函数将无法获取到当前进程的页目录和ASID,也就无法正确地调用page_insert函数来将物理页映射到虚拟地址空间中。所以必须要有data参数提供上下文信息,使得回调函数能够正确地执行映射操作。

Thinking 3.3 elf_load_esg 的不同情况

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

elf_load_seg 函数的实现主要负责将 ELF 文件的一个段加载到内存中。有以下几种情况需要处理:

  1. 段的起始地址未对齐到页面边界
    ELF 段的虚拟地址 ph->p_vaddr 可能不是页面大小的整数倍。如果地址未对齐(offset != 0),需要特殊处理段的第一个页面:只加载从 offset 开始的部分内容,并使用 MIN(bin_size, PAGE_SIZE - offset) 确保不会越界。
1
2
3
4
5
6
7
8
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) {
// bin_size 可能小于页面剩余部分大小
return r;
}
}
  1. 加载段的文件内容到内存
    按页面大小(PAGE_SIZE)循环加载文件内容,直到文件内容全部加载完毕。每次调用 map_page 将一页数据映射到虚拟地址空间。
1
2
3
4
5
6
7
/* Step 1: load all content of bin into memory. */
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;
}
}
  1. 段的文件大小小于内存大小,分配额外的内存页面
    当文件大小(bin_size)小于段的内存大小(sgsize)时,分配额外的页面直到达到 sgsize,传递 NULL 表示这些页面不需要初始化数据。
1
2
3
4
5
6
7
/* 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(sgsize - i, PAGE_SIZE))) != 0) {
return r;
}
i += PAGE_SIZE;
}

Thinking 3.4 EPC 的含义

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

  • 存储的是虚拟地址。env_tf.cp0_epc 保存的是发生异常时的指令地址,load_icode 函数中e->env_tf.cp0_epc设置为ehdr->e_entry,即 ELF 文件中的程序入口地址。这个地址是虚拟地址。

Thinking 3.5 异常处理函数的实现位置

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

首先查看kern/trap.c文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// kern/trap.c
// 定义了异常处理函数
extern void handle_int(void);
extern void handle_tlb(void);
extern void handle_sys(void);
extern void handle_mod(void);
extern void handle_reserved(void);

void (*exception_handlers[32])(void) = {
[0 ... 31] = handle_reserved, // 默认处理函数
[0] = handle_int, // 0号异常处理函数
[2 ... 3] = handle_tlb, // 2、3号异常处理函数
#if !defined(LAB) || LAB >= 4
[1] = handle_mod, // 1号异常处理函数
[8] = handle_sys, // 8号异常处理函数
#endif
};
// 未定义异常的处理函数
void do_reserved(struct Trapframe *tf) {
print_tf(tf);
panic("Unknown ExcCode %2d", (tf->cp0_cause >> 2) & 0x1f);
}

发现在kern/genex.S中有异常函数的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# kern/genex.S
.macro BUILD_HANDLER exception handler
NESTED(handle_\exception, TF_SIZE + 8, zero)
move a0, sp
addiu sp, sp, -8
jal \handler
addiu sp, sp, 8
j ret_from_exception
END(handle_\exception)
.endm

.text

FEXPORT(ret_from_exception)
RESTORE_ALL
eret

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)

BUILD_HANDLER tlb do_tlb_refill

#if !defined(LAB) || LAB >= 4
BUILD_HANDLER mod do_tlb_mod
BUILD_HANDLER sys do_syscall
#endif

BUILD_HANDLER reserved do_reserved
  • 0号异常处理函数handle_intkern/genex.S中实现
  • 其他异常处理函数则通过宏BUILD_HANDLER实现。BUILD_HANDLER 是一个汇编宏,用于生成异常处理函数的框架代码,其中 exception 是异常名称,用于生成我们在kern/trap.c看到的异常处理函数的名称(如 handle_tlb),handler 是实际的异常处理函数(如 do_tlb_refill),也就是我们要找的函数具体实现,do_reservedkern/trap.c 中,其余在kern/tlbex.c 中。
1
2
3
4
5
6
7
// in kern/tlbex.c
void _do_tlb_refill(u_long *pentrylo, u_int va, u_int asid){
...
}
void do_tlb_mod(struct Trapframe *tf) {
...
}

Thinking 3.6 时钟的设置

阅读 entry.Sgenex.Senv_asm.S 这几个文件,并尝试说出时钟中断在哪些时候开启,在哪些时候关闭。

  • 关闭时钟中断
    kern/entry.S中,.text.exc_gen_entry 段通过 and t0,t0, ~(STATUS_UM | STATUS_EXL| STATUS_IE) 关闭时钟中断。STATUS_UM表示用户模式,STATUS_EXL表示异常模式,STATUS_IE表示中断使能位,随后取出对应的异常码,跳转到exception_handlers 数组中的中断处理函数中。
  • 开启时钟中断
    时钟中断由 kern/genex.S中的handle_int函数处理,handle_int函数中通过bnez t1, timer_irq判断是否是时钟中断,如果是,则调用schedule函数。查看kern/sched.c中的schedule函数,发现它会调用kern/env.c中的env_run函数,env_run函数中又不返回地调用了kern/env_asm.S中的env_pop_tf函数,通过它的 RESET_KCLOCK 重设时钟中断,随后j ret_from_exception指令跳转到kern/genex.S中的ret_from_exception函数,在 RESTORE_ALL 宏中恢复了Status寄存器,开启了中断,最终调用eret指令,会将EXL设置为0,返回用户态。此时Status中的UMIE均已被设置为1,表示在用户模式下且开启中断。之后第一个进程成功以用户模式运行,这时操作系统也可以正常响应中断了。

Thinking 3.7 进程的调度

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

RESET_KCLOCK宏设定了时钟中断的周期。指导书中提到,一旦时钟中断产生,就会触发4KC硬件的异常中断处理流程。系统将PC指向0x80000180,跳转到.text.exc_gen_entry代码段执行。对于时钟引起的中断,通过.text.exc_gen_entry代码段的分发,最终会调用handle_int函数进行处理。
handle_int函数会在时钟中断时进入schedule函数进行调度。进程切换会在四种情况下发生:

  1. 进程主动放弃CPU(调用sys_yield);
  2. 进程时间片耗尽(count <= 0);
  3. 进程被阻塞或退出(e->env_status == ENV_NOT_RUNNABLE);
  4. 当前无进程运行(e == NULL)。

接着检查如果当前有进程运行(e != NULL),则从env_sched_list队列中移除当前进程,检查该进程是否仍然处于可运行状态(e->env_status == ENV_RUNNABLE),如果是,则将其插入到队列的末尾,从队首取一个新的进程(e = TAILQ_FIRST(&env_sched_list)),并根据优先级设置时间片长度(count = e->env_pri)。最后,时钟片数量减一,调用env_run函数。

Part2. 难点分析

进程与内存空间

每个进程都认为自己是独占内存空间的,因此,在实现进程时,需要为每个进程分配独立的内存空间。在env.c中,env_setup_vm函数为每个进程分配了独立的页表,并初始化了页表。env_create函数则根据envs数组中的进程信息,为每个进程分配了独立的内存空间。
值得一提的是,“MOS 操作系统特意将一些内核的数据暴露到用户空间”,因此,在env_setup_vm函数中,UTOP以上到 UVPT 之间的只读空间被拷贝到了用户进程的页表中,这样,用户进程就可以访问内核的数据了。

关于异常分发

中断处理和分发的过程用到了很多函数和宏,需要对指导书和相关代码进行仔细阅读和理解。Thinking 3.5 和 3.6 中的分析可以帮助我们更好理解异常分发的过程。

异常重入

在异常分发中,我们使用了 SAVE_ALL 宏来保存上下文到内核的异常栈中,查看include/stackframe.hSAVE_ALL 宏的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.macro SAVE_ALL
.set noat
.set noreorder
mfc0 k0, CP0_STATUS
andi k0, STATUS_UM
beqz k0, 1f
move k0, sp
/*
* If STATUS_UM is not set, the exception was triggered in kernel mode.
* $sp is already a kernel stack pointer, we don't need to set it again.
*/
li sp, KSTACKTOP
1:
subu sp, sp, TF_SIZE
...
.set at
.set reorder
.endm

SAVE_ALL宏首先判断了当前是否已经陷入内核态(异常重入),否则需要将sp寄存器指向内核异常栈(KSTACKTOP),从而实现在异常中处理异常,避免破坏已有的异常栈。

Part3. 实验体会

通过本次实验,我深入理解进程的概念并进行实践,掌握了进程的创建、调度、异常分发等过程。本次实验需要完成的代码并不算困难,但涉及的知识点较多,尤其是进程调度的实现,需要仔细阅读相关代码,理解每个函数的作用和实现原理。