mit6.828笔记 - lab3 Part B:页面故障、断点异常和系统调用

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 测试一下:
image.png

断点异常

接下来按照 lab3 手册的指引,完成断点异常的处理:

中断点异常,即中断向量 3(T_BRKPT),通常用于允许调试程序在程序代码中插入断点,方法是用特殊的 1 字节 int3 软件中断指令临时替换相关的程序指令。在 JOS 中,我们将略微滥用这个异常,把它变成一个原始的伪系统调用,任何用户环境都可以用它来调用 JOS 内核监控器。如果我们把 JOS 内核监视器看作一个原始的调试器,那么这种用法实际上是比较恰当的。例如,lib/panic.cpanic() 的用户模式实现就会在显示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,
image.png
而调用门则是,压入栈中的参数

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 的定义了。现在我们要做的是:

  1. 在 kern/trap_init.c 于IDT中创建入口
  2. 在 kern/trapentry.S 中完成创建syscall 的 handler
  3. 在 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

image.png

可以看到,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.clib/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,用户进程应该是可以顺利执行了
image.png

总结:syscall流程

现在可以看清整个异常处理的全貌了
image.png

做到这里,有一个疑惑,上图红色箭头,发生中断的时候,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
image.png
可以看到,内核栈中确实是有一定数据的,然后我们再打一个断点,打在 handler_SYSCALL,
b handler_SYSCALL
这里是中断发生后,操作系统第一时间获得控制权的地方,之前是CPU的操作(上面流程图的红色字体部分描述的),让程序继续运行
c
先看一眼 esp ,如果没猜错的话,应该在距离 0xf000_0000 很近的较低处
p $esp
image.png

确实如此,接下来再看看 内核栈中的内容x/32wx 0xf0000000-0x70

对比一下env_run时 打印的
image.png
可以看到,真的是直接覆盖了内核栈。。。然后覆盖了5个双字,这五个双字应该就是中断时,CPU自动执行的操作,即
image.png
也就是说,现在的内核栈的情况如下

内核栈地址 内容 对应含义
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
image.png
如上图,然而这里的整个过程都在覆盖中断前的内核栈
image.png
内核栈已经面目全非了。那么疑惑解开,内核栈相当于在中断时就会被清空,好家伙。

不过都到这了,继续调试看看吧,我还比较好奇内核如何将权限归还给用户进程,ring0 怎么变成 ring3

调试:返回用户进程

好,看看返回给用户进程是怎么做的,直接 c,因为之前在 env_run 下断点了,直接停在env_run,然后步进到env_pop_tf之前
image.png
继续 si步进
image.png
可以看到进入内联汇编后,esp甚至都跑到 envs 数组里了(curenv的trapframe),然后后面一通pop,将各个寄存器还原成中断前的状态。
然后最后一句关键的iret,实现内核态到用户态的跨级执行权转移,来看看iret前后的变化:
image.png
可以看到,这句iret,不是想象中改变 CS和IP那么简单,连着esp和SS都变了,这是跨级执行权转移,iret 从栈中弹出数据,还原cs、eip、ss、esp等,我们再调试看看iret前后栈的变化:
image.png
上图是 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 的:
image.png
然后实现 user_mem_check

  1. 检查 va 开始之后大小为 len 的内存空间范围,确认其权限是否为 perm
  2. 除此之外的限制:
    1. 低于 ULIM
    2. 该页具备权限
  3. 如果发生错误,则将 user_mem_check_addr 设置为第一个有问题的页
  4. 如果没有问题,则返回 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

image.png

posted @ 2024-05-15 17:26  toso  阅读(6)  评论(0编辑  收藏  举报