MIT6.828-Lab3-总结

Lab3的过程记录放在了我自己的博客上MIT6.828-Lab3,总结放在这里。

用户态环境的建立与管理以及异常处理机制

用户环境

JOS使用一个数组管理所有的环境(或称进程),最多有1024个struct Env,其使用链表记录所有freeenv,并使用currenv来记录当前正在运行的环境。当前的物理内存布局如下:

接下来介绍一下JOS中第一个用户环境的创建流程:此处我觉得最妙的是在env_initenv_pop_tf的这段过程中,我们使用Trapframe模拟了从用户态进入内核态时的栈的内容(因为第一个用户进程,不存在从用户态进内核态的过程,所以我们需要模拟),随后我们将tf的首地址赋值给esp寄存器(发生在env_poptf),随后便是convention when switching from kernel to user mode

异常处理机制

在我们做完了上面的事情后,我们会创建一个hello的用户进程,其尝试使用int $0x30来invoke一个系统调用的中断,接下来我们做的事情便是为了使得JOS可以处理CPU内部异常(类似于divezero,pagefault等等CPU定义的异常)和软中断(用户程序可以发起的系统调用)。

中断处理过程

  • 首先中断肯定是通过int $num来调用的,所以CPU会知道中断向量号,此中断向量号作为一个索引并结合IDTR寄存器中保存的IDT表首地址,来查询此中断对应的中断门描述符(一般称Interrupt Gate/ Trap Gate/ Task Gate)。其中Interrupt Gate和Trap Gate的唯一区别即Interrupt Gate通过将EFLAGS寄存器中的IF标志位置0使其不可屏蔽,而Trap Gate则无此特性。

  • 然后根据门描述符的段选择子加上偏移便可找到中断程序所在的位置

中断栈帧

我们都知道,当操作系统call一个函数时,会把参数,eip,ebp入栈使得函数结束时得以回到原始位置。同样的,当操作系统invoke一个interrupt时,也会建立一个栈帧,用于保存上下文。
下面是一个常见的中断栈帧:图源

  • 我们需要明白的是只有从用户态进入内核态时,才会push ss和esp用于保存用户栈位置,接着会将TSS中的ss0字段和esp0字段赋值给ss和esp寄存器。
  • error code和trap no是由入口程序压入的(即trapentry.S)
  • 在_alltrap中最后压入esp是用来将其作为函数参数传递给trap,*需要注意的是上图有点小问题,蓝色部分esp(tf)应该改成指向edi的旧esp

中断返回

1
2
3
4
5
6
7
8
9
# 将栈中的通用寄存器的值保存回对应的寄存器中
popal
# 恢复es和ds寄存器
popl %es
popl%ds
# 省略error code和trap no
addl $0x8, %esp
# 调用iret将关键的ss,esp,eflags,cs,eip恢复回寄存器中
iret

tips

  • 当用户程序想要使用int $14来invoke page fault时,实际上kernel会执行general protection fault,以防止对os恶意的操作
  • 关于如何设置段描述符的dpl,出发点为是用户int $num还是内核做这件事,比如系统调用,用户态都是进到lib/syscall.c中的syscall函数然后int $0x30,即是通过用户态发起中断请求, 所以系统调用的中断描述符的dpl必须设置为3

Page Faults, System Calls and mem protection

缺页中断

  • 当产生一个缺页中断的错误时,出错的线性地址会保存在cr2寄存器中
  • kernel mode下的缺页中断是一种bug,可以通过Trapframe的cs字段判断其特权级来判断是kernel还是user mode

系统调用

Linux中有超过256个的系统调用,而中断向量号由于历史兼容性是8位的无符号整数,所以JOS采用将syscallno(用于区分不同系统调用)通过硬件保存在eax寄存器中,其余5个系统参数保存在edx,ecx,ebx,edi,esi寄存器中。

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
// Dispatches to the correct kernel function, passing the arguments.
int32_t
syscall(uint32_t syscallno, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
// Call the function corresponding to the 'syscallno' parameter.
// Return any appropriate return value.
// LAB 3: Your code here.

switch (syscallno) {
case SYS_cputs:
sys_cputs((const char *)a1, (size_t)a2);
break;
case SYS_cgetc:
sys_cgetc();
break;
case SYS_getenvid:
sys_getenvid();
break;
case SYS_env_destroy:
sys_env_destroy((envid_t)a1);
break;
default:
return -E_INVAL;
}
return 0;
}

内存保护

为了防止用户程序通过系统调用访问内核空间,我们需要在系统调用函数中加上内存检查(比如传递过来的参数是否超出ULIM,对应的pte的权限部分是否允许用户读或写等等)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int
user_mem_check(struct Env *env, const void *va, size_t len, int perm)
{
uint32_t start = (uint32_t)ROUNDDOWN((char *)va, PGSIZE);
uint32_t end = (uint32_t)ROUNDUP((char *)va+len, PGSIZE);
if ((uint32_t)start + end < ULIM) {
for (; (uint32_t)start < (uint32_t)end; start += PGSIZE) {
pte_t *pte = pgdir_walk(env->env_pgdir, (void *)start, 0);
if (!pte || !((*pte & (perm | PTE_P)) == (perm | PTE_P))) {
user_mem_check_addr = (start < (uint32_t)va ? (uint32_t)va : start);
return -E_FAULT;
}
}
}
else {
user_mem_check_addr = (uint32_t)va;
return -E_FAULT;
}
return 0;
}

关于系统调用还需再提一嘴

在内核模式和用户模式下调用系统调用的过程是不一样的。
就拿cprintf函数来说吧,下面是用户模式下调用cprintf的过程:

1
2
3
4
5
6
7
8
9
10
- call function `cprintf`
- cprintf in `lib/printf.c`
- sys_cputs in `lib/syscall.c`
- syscall in `lib/syscall.c`
- set the `syscallno` by put it in `eax` register
- push left parameters(up to 5) into specified registers
- use `int $0x30` to trap into the kernel
- then, syscall_handler(defined in `kern/trapentry.S`) --> _alltraps --> trap
--> trap_dispatch --> **syscall(parameters set by tf)** in `kern/syscall.c/FUNC:syscall`
--> cprintf in kernel mode --> finally in `kern/console.c/FUNC::cons_putc`

而如果是在内核模式下调用cprintf的话,就直接从cprintf in kernel mode开始即可


MIT6.828-Lab3-总结
http://bugeater.space/2024/03/09/MIT6-828-Lab3-总结/
Author
BugEater
Posted on
March 9, 2024
Licensed under