Part B 页面故障、断点异常和系统调用
虽然说,我们故事的主线是让JOS能够加载、并运行 user/hello.c 编译出来的镜像文件。
虽然说,经过Part A最后几节,我们初步实现了异常处理的基础设施。
但是对于操作系统来说,还远远不够,比如说那个 trap_dispatch 还没完成。
所以在回到故事主线之前,我们需要进一步完善异常处理的基础设施。
处理页面故障
页面故障异常,即中断向量 14 (T_PGFLT),是一个特别重要的异常,我们将在本实验和下一个实验中大量使用。当处理器发生页面故障时,它会将导致故障的线性(即虚拟)地址存储到一个特殊的处理器控制寄存器 CR2 中。在 trap.c 中,我们提供了一个特殊函数 page_fault_handler() 的雏形,用于处理页面故障异常。
根据 lab3 手册的指引,我们先处理好缺页异常的处理
Exercise 5
练习 5. 修改 `trap_dispatch()`,
将页面故障异常分派到 `page_fault_handler()`。
现在,您应该能够让 `make grade` 在 `faultread`、`faultreadkernel`、`faultwrite` 和 `faultwritekernel` 测试中成功。
如果有任何测试不成功,请找出原因并加以修复。
记住,你可以使用 `make run-x` 或 `make run-x-nox` 将 JOS 启动到特定的用户程序中。
例如,`make run-hello-nox` 运行 hello 用户程序。
按照指引,在 trap_dispatch 中,调用一下 page_fault_handler 就行。不过呢,正如手册所言,这只是 page_fault_handler 的雏形,看看 page_fault_handler 就知道,里面其实什么都没做。后面会再进一步完善缺页故障的处理。
trap_dispatch
static void
trap_dispatch(struct Trapframe *tf)
{
// Handle processor exceptions.
// LAB 3: Your code here.
if(tf->tf_trapno == T_PGFLT){
page_fault_handler(tf);
return ;
}
// Unexpected trap: The user process or the kernel has a bug.
print_trapframe(tf);
if (tf->tf_cs == GD_KT)
panic("unhandled trap in kernel");
else {
env_destroy(curenv);
return;
}
}
这里就是在其中添加了一个 if 判断,如果trapno 是却也异常就调用 page_fault_handler,
因此可以想象,之后对trap_dispatch的扩展的话,大概会是个switch-case 的多分支结构,
根据不同的 trapno 调用不同的处理函数。
make grade 测试一下:
断点异常
接下来按照 lab3 手册的指引,完成断点异常的处理:
中断点异常,即
中断向量 3(T_BRKPT)
,通常用于允许调试程序在程序代码中插入断点,方法是用特殊的 1 字节int3
软件中断指令临时替换相关的程序指令。在 JOS 中,我们将略微滥用这个异常,把它变成一个原始的伪系统调用,任何用户环境都可以用它来调用 JOS 内核监控器。如果我们把 JOS 内核监视器看作一个原始的调试器,那么这种用法实际上是比较恰当的。例如,lib/panic.c
中panic()
的用户模式实现就会在显示panic
信息后执行一个 int3。
Exercise 6
练习 6. 修改 trap_dispatch(),使断点异常调用内核监视器。现在你应该能让 make grade 在breakpoint测试中成功了。
将 trap_dispatch 改写成这样
switch (tf->tf_trapno)
{
case T_PGFLT:
page_fault_handler(tf);
return ;
case T_BRKPT:
monitor(tf);
return ;
default:
break;
}
System calls
接下来,lab3手册终于带着我们实现卡着主线故事的 int 48 了。
用户进程通过调用 system calls 来要求内核帮他们干活。
当用户进程调用一个system call, 处理器会进入内核态,处理器和内核会一起协作来保存用户进程状态。
然后,内核执行适当代码来处理系统调用。
调用完毕后,将控制返还给用户进程。
用户进程如何调用内核的具体实现,各个操作系统各不相同。
在 JOS 内核中,我们将使用
int 指令
,该指令会导致处理器中断。具体来说,我们将使用int $0x30
作为系统调用中断。我们为您定义了T_SYSCALL
常量为 48 (0x30)。您需要设置中断描述符,允许用户进程引发该中断。请注意,中断 0x30 不能由硬件产生, 因此允许用户代码生成中断不会引起歧义。
应用程序将通过寄存器传递系统调用号和系统调用参数。这样,内核就不需要在用户环境的堆栈或指令流中到处乱跑了。
系统调用号将存放在%eax
,参数(最多五个)将分别存放在%edx、%ecx、%ebx、%edi 和 %esi
。内核会将返回值传回 %eax
。调用系统调用的汇编代码已在**lib/syscall.c**
中的**syscall()**
中为您编写。
最后一段交代了用户的 syscall 是如何传参的,即,通过5个寄存器传递。那为啥不设计成像平时一样压入栈调用呢?
稍微想一下,如果压入栈,然后再把栈中的参数复制到异常栈,那不就和调用门在跨级转移控制权限的过程一样了吗。
但是,这里是通过中断实现用户进程调用内核的过程的,中断的控制转移和调用门的控制转移在栈切换的区别就在于:
中断:
在压入旧SS和旧ESP之后,压入返回地址之前,压入的是 EFLAGS,
而调用门则是,压入栈中的参数
Exercise 7
Exercise 7
练习 7. 在内核中为中断向量 `T_SYSCALL` 添加一个处理程序。您需要编辑 `kern/trapentry.S` 和 `kern/trap.c` 的 `trap_init()`。
您还需要修改 `trap_dispatch()`,通过调用` syscall()`(定义在 `kern/syscall.c`)来处理系统调用中断,并在 `%eax` 中安排将返回值传回用户进程。
最后,您需要在 `kern/syscall.c` 中实现 `syscall()`。
如果系统调用号无效,请确保 `syscall()` 返回 `-E_INVAL`。
您应该阅读并理解 `lib/syscall.c`(尤其是内联汇编例程),以确认您对系统调用接口的理解。
处理 `inc/syscall.h` 中列出的**所有系统调用** ,**为每个调用调用相应的内核函数** 。
在内核下运行用户/hello 程序(make run-hello)。它应该会在控制台上打印 "hello, world",然后在用户模式下引起页面错误。如果没有出现这种情况,很可能说明你的系统调用处理程序不太正确。现在你也应该能让 make grade 在 testbss 测试中成功了。
按照手册,我们先注册 syscall中断,在inc/trap.h 中,已经有了 T_SYSCALL 的定义了。现在我们要做的是:
- 在 kern/trap_init.c 于IDT中创建入口
- 在 kern/trapentry.S 中完成创建syscall 的 handler
- 在 kern/trap.c : trap_dispatch() 中调用 syscall
// 第一步 kern/trap.c : trap_init
void trap_init(){
//...
void handler_syscall();
//...
SETGATE(idt[T_SYSCALL], 0, GD_KT, handler_syscall, 3);
//...
// 第二步,kern/trapentry.S:
TRAPHANDLER_NOEC(handler_SYSCALL, T_SYSCALL)
// 第三步:在 kern/trap.c : trap_dispatch
//...
case T_SYSCALL:
tf->tf_regs.reg_eax = syscall(tf->tf_regs.reg_eax,
tf->tf_regs.reg_edx,
tf->tf_regs.reg_ecx,
tf->tf_regs.reg_ebx,
tf->tf_regs.reg_edi,
tf->tf_regs.reg_esi
);
return ;
//...
不过 syscall 我们还没实现,注意,我们现在要实现的是 kern/syscall.h 和 kern/syscall.c 中声明定义的syscall。而不是 user/hello 中调用的那个 lib/syscall.c
kern/syscall.c : syscall
// 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.
// panic("syscall not implemented");
int32_t ret;
switch (syscallno) {
case SYS_cputs:
sys_cputs((char *)a1, (size_t)a2);
ret = 0;
break;
case SYS_cgetc:
ret = sys_cgetc();
break;
case SYS_getenvid:
ret = sys_getenvid();
break;
case SYS_env_destroy:
ret = sys_env_destroy((envid_t)a1);
break;
default:
return -E_INVAL;
}
}
运行 make run-hello
可以看到,syscall被成功调用了,然后发生了缺页故障,显示用户访问了虚拟地址 0x0000_0048。这是为什么呢?
在 uamin 对cprintf的第二次调用中,访问了 thisenv->envid ,看起来载jos设计下,用户进程有能力访问自身的env结构体,可能是访问这个结构体出错了,那这个结构体变量究竟在哪里声明的呢,有是怎么赋值的呢?
实际上,在 user/helloc 的 umain 之前,还运行了别的代码(umain,并不是hello.c编译链接后形成的elf文件的入口),接着看手册。
用户态入门
一段用户程序在
lib/entry.S
的顶部开始运行。
经过一些设置后,这段代码会调用lib/libmain.c
中的libmain()
。
应修改libmain()
以初始化全局指针thisenv
,使其指向envs[]
数组中的环境结构 Env。(请注意,lib/entry.S
已将 envs 定义为指向您在 A 部分中设置的 UENVS 映射)。提示:在inc/env.h
中查找并使用sys_getenvid
。
先来看一看 lib/entry.S
lib/entry.S
#include <inc/mmu.h>
#include <inc/memlayout.h>
.data
// 定义全局符号 “envs”、“pages”、“uvpt ”和 "uvpd
// 这样,在 C 语言中就可以像使用普通全局数组一样使用它们。
.globl envs
.set envs, UENVS
.globl pages
.set pages, UPAGES
.globl uvpt
.set uvpt, UVPT
.globl uvpd
.set uvpd, (UVPT+(UVPT>>12)*4)
// 入口点 - 当我们最初加载到一个新环境时,
// 内核(或我们的父环境)会在这里启动我们的运行。
.text
.globl _start
_start:
// 查看堆栈中的参数是否已启动
cmpl $USTACKTOP, %esp
jne args_exist
// 如果没有,则推送假 argc/argv 参数。.
// 当我们被内核加载时,就会发生这种情况、
// 因为内核不知道要传递参数。
pushl $0
pushl $0
args_exist:
call libmain
1: jmp 1b
然后来做练习8
Exercise 8
练习 8. 在用户库中添加所需的代码,然后启动内核。
你应该会看到user/hello
打印"hello world"然后打印"i am environment 0001000".
然后,user/hello
会调用sys_env_destroy()
(请参阅lib/libmain.c
和lib/exit.c
)尝试 "退出"。
由于内核目前只支持一个用户环境,因此它应该报告已经破坏了唯一的环境,然后进入内核监视器。你应该能让make grade
在 hello 测试中取得成功。
lib/libmain.c
// Called from entry.S to get us going.
// entry.S 已经定义了 envs、pages、uvpd 和 uvpt。
#include <inc/lib.h>
extern void umain(int argc, char **argv);
const volatile struct Env *thisenv;
const char *binaryname = "<unknown>";
void
libmain(int argc, char **argv)
{
// 设置 thisenv 以指向 envs[] 中的 Env 结构。
// LAB 3: Your code here.
envid_t envid = sys_getenvid(); //练习7实现的系统调用
thisenv = &envs[ENVX(envid)];
// save the name of the program so that panic() can use it
if (argc > 0)
binaryname = argv[0];
// call user main routine
umain(argc, argv);
// exit gracefully
exit();
}
这个时候试试 make qemu
,用户进程应该是可以顺利执行了
总结:syscall流程
现在可以看清整个异常处理的全貌了
做到这里,有一个疑惑,上图红色箭头,发生中断的时候,CPU切换到TSS记录的权限为0的栈。这个栈的位置是在 trap_init -> trap_init_percpu 时确定的
ts.ts_esp0 = KSTACKTOP;
ts.ts_ss0 = GD_KD;
ts.ts_iomb = sizeof(struct Taskstate);
也就是说,这会使得esp指向KSTACK的栈底,这样不会覆盖之前的数据吗?
可以通过DEBUG看一下
调试:内核栈被清空了吗
开两个窗口,一个 make run-hello-gdb
另一个 make gdb
然后在gdb窗口里给 env_run
打上断点 b env_run
,然后运行 c
程序在在用户进程开始运行之前断下,我们看一眼 KSTACKTOP 之后32个双字的数据 x/32wx 0xf0000000-0x70
可以看到,内核栈中确实是有一定数据的,然后我们再打一个断点,打在 handler_SYSCALL,
b handler_SYSCALL
这里是中断发生后,操作系统第一时间获得控制权的地方,之前是CPU的操作(上面流程图的红色字体部分描述的),让程序继续运行
c
先看一眼 esp ,如果没猜错的话,应该在距离 0xf000_0000 很近的较低处
p $esp
确实如此,接下来再看看 内核栈中的内容x/32wx 0xf0000000-0x70
对比一下env_run时 打印的
可以看到,真的是直接覆盖了内核栈。。。然后覆盖了5个双字,这五个双字应该就是中断时,CPU自动执行的操作,即
也就是说,现在的内核栈的情况如下
内核栈地址 | 内容 | 对应含义 |
---|---|---|
0xEFFF_FFFC | 0x000_0023 | old SS |
0xEFFF_FFF8 | 0xEEBF_DFD4 | old ESP |
0xEFFF_FFF4 | 0x0000_0046 | old eflags |
0xEFFF_FFF0 | 0x0000_001b | old CS |
0xEFFF_FFEC | 0x0080_0AB0 | old EIP |
0xEFFF_FFE8 | xxxxxx | 内核栈原数据 |
说明内核栈中的数据确实被覆盖了,在往下看看呢。
接下来handler_SYSCALL 将更多的参数压入内核栈,形成了一个trapframe,然后作为参数调用 trap。然后我们继续调试 trap
b trap
如上图,然而这里的整个过程都在覆盖中断前的内核栈
内核栈已经面目全非了。那么疑惑解开,内核栈相当于在中断时就会被清空,好家伙。
不过都到这了,继续调试看看吧,我还比较好奇内核如何将权限归还给用户进程,ring0 怎么变成 ring3
调试:返回用户进程
好,看看返回给用户进程是怎么做的,直接 c
,因为之前在 env_run 下断点了,直接停在env_run,然后步进到env_pop_tf之前
继续 si
步进
可以看到进入内联汇编后,esp甚至都跑到 envs 数组里了(curenv的trapframe),然后后面一通pop,将各个寄存器还原成中断前的状态。
然后最后一句关键的iret,实现内核态到用户态的跨级执行权转移,来看看iret前后的变化:
可以看到,这句iret,不是想象中改变 CS和IP那么简单,连着esp和SS都变了,这是跨级执行权转移,iret 从栈中弹出数据,还原cs、eip、ss、esp等,我们再调试看看iret前后栈的变化:
上图是 iret 之前,可以看到 esp 正好指向 curenv->env_tf.tf_eip,iret将这些寄存器还原,从而实现跨级权限转移,将控制权还给用户进程。
页面故障和内存保护
到目前为止,我们顺利的让 user/hello.c编译出的elf 加载至我们的操作系统,并且运行起来。但为了让user目录下其他的代码也运行起来,还需要对内存进行保护。
接下来按照 手册的指引,完善内存保护措施,并完成练习9.
Exercise 9
任务内容:
1. 修改`kern/trap.c`,当页错误发生在内核态时panic。
~~~ad-note
检查`tf_cs`的低位字节可以判断fault发生在**用户态**还是**内核态**
~~~
2. 读`kern/pmap.c` 中的 `user_mem_assert` 并实现 `user_mem_check`
3. 修改 `kern/syscall.c` 对系统调用的参数进行正确性检查
4. 启动你的kernel,运行 `user/buggyhello`。 environment应当被销毁, kernel 不应当 'panic'。你应该会看到:
~~~txt
[00001000] user_mem_check assertion failure for va 00000001
[00001000] free env 00001000
Destroyed the only environment - nothing more to do!
~~~
5. 最终,修改 `kern/kdebug.c` 中的 `debuginfo_eip`,在 `usd, stabs, stabstr` 上调用 'user_mem_check'。如果你现在运行 `user/breakpoint` ,你应能够从 kernel monitor 运行 `backtrace`,并看见backtrace 在kernel panics 之前,随着一个page fault回溯到 `lib/libmain.c` 。
是什么导致了page fualt?
你不需要修复他,但是你要明白它为何发生。
在 kern/trap.c : trap() 中添加
//当页错误发生在内核态时panic
if ((tf->tf_cs & 3) == 0)
panic("page_fault_handler():page fault in kernel\n");
然后第二步,完成 kern/pmap.c: user_mem_check
user_mem_check
先看看 user_mem_assert
是怎么用 user_mem_check
的:
然后实现 user_mem_check
:
- 检查
va
开始之后大小为len
的内存空间范围,确认其权限是否为perm
- 除此之外的限制:
- 低于
ULIM
- 该页具备权限
- 低于
- 如果发生错误,则将
user_mem_check_addr
设置为第一个有问题的页 - 如果没有问题,则返回 0, 否则返回
-E_FAULT
// 检查环境是否允许以权限'perm | PTE_P'访问内存范围 [va,va+len]。
// 通常'perm'至少包含 PTE_U,但这不是必需的。
// 'va'和'len'不必进行页面对齐;您必须测试包含该范围内任何内容的每一页。 您可以测试'len/PGSIZE'、
// 'len/PGSIZE + 1' 或 'len/PGSIZE + 2' 页面。
//
// 如果 (1) 地址低于 ULIM,并且 (2) 页表允许,用户程序可以访问虚拟地址。 这些正是你应该在这里实现的测试。
//
// 如果出现错误,将 “user_mem_check_addr ”变量设置为第一个错误的虚拟地址。
//
// 如果用户程序可以访问该地址范围,则返回 0,否则返回 -E_FAULT。
//
int
user_mem_check(struct Env *env, const void *va, size_t len, int perm)
{
// LAB 3: Your code here.
cprintf("user_mem_check va: %x, len: %x\n", va, len);
pde_t * pgdir = env->env_pgdir;
uint32_t begin = (uint32_t)ROUNDDOWN(va, PGSIZE);//虽然说不必进行页面对齐,但是对齐起来代码写的更方便
uint32_t end = (uint32_t)ROUNDUP(va+len, PGSIZE);
for(uint32_t i = begin; i < end; i+= PGSIZE){
pte_t * pte = pgdir_walk(env->env_pgdir, (void*)i, 0);
if( (i>=ULIM)//检查是否
||!pte
||!(*pte&PTE_P)||((*pte&perm) != perm)
){
user_mem_check_addr = (i < (uint32_t)va ? (uint32_t)va : i); //由于对齐了,第一页的地址值可能在va之前。
return -E_FAULT;
}
}
cprintf("user_mem_check success va: %x, len: %x\n", va, len);
return 0;
}
接着,练习的第三步,观察 kern/syscall.c 中的所有系统调用,看看那个接收了来自用户进程的指针。
其实就只有 sys_cputs,补充让 user_mem_assert:
static void
sys_cputs(const char *s, size_t len)
{
// Check that the user has permission to read memory [s, s+len).
// Destroy the environment if not.
// LAB 3: Your code here.
user_mem_assert(curenv, (void *)s, len, 0);
// Print the string supplied by the user.
cprintf("%.*s", len, s);
}
第四步 修改kern/kdebug.c : debuginfo_eip
根据注释,添加代码即可
const struct UserStabData *usd = (const struct UserStabData *) USTABDATA;
// Make sure this memory is valid.
// Return -1 if it is not. Hint: Call user_mem_check.
// LAB 3: Your code here.
if (user_mem_check(curenv, usd, sizeof(struct UserStabData), PTE_U) < 0) {
return -1;
}
stabs = usd->stabs;
stab_end = usd->stab_end;
stabstr = usd->stabstr;
stabstr_end = usd->stabstr_end;
// Make sure the STABS and string table memory is valid.
// LAB 3: Your code here.
size_t stablen = stab_end - stabs + 1;
size_t strlen = stabstr_end - stabstr + 1;
if (user_mem_check(curenv, stabs, stablen, PTE_U) < 0) {
return -1;
}
if (user_mem_check(curenv, stabstr, strlen, PTE_U) < 0) {
return -1;
}
到目前为止,所有的练习都完成了。最后 make grade