Lab4 - Preemptive Multitasking需要在多个同时激活的用户模式环境中实现抢占式多任务。
实验分为三个部分。第一部分要求为JOS添加多处理器支持,并实现论询调度以及基本的环境管理系统调用;第二部分需要实现一个Unix-like的fork函数,使得用户环境可以创建一份自己的拷贝;第三部分需要实现进程间通讯功能,允许不同的用户环境显式地互相通信和同步。同时还需要实现对于硬件时钟中断和抢占的支持。
2018年3月2日,完成了实验并写完了报告。

实验准备

根据官网,切换到分支lab4并合并分支lab3。在合并的过程中,发生了冲突。

1
2
user% git checkout -b lab3 origin/lab3
user% git merge lab3

根据提示,是conf/lab.mk中发生了冲突。打开后可以发现是其中记录的时间和实验数发生了变化,直接采用分支lab4的版本即可,然后提交,分支合并完成。

实验四包括如下的新文件:

  • kern/cpu.h 内核私有的关于多处理器的支持
  • kern/mpconfig.c 读取多处理器配置的代码
  • kern/lapic.c 内核驱动每个处理的的APIC(高级可编程中断控制器)的代码
  • kern/mpentry.S 非引导CPU的汇编入口代码
  • kern/spinlock.h 内核私有的自旋锁定义,包括大内核锁
  • kern/spinlock.c 内核实现自旋锁的代码
  • kern/sched.c 需要实现的调度器的代码框架

实验过程

第一部分 Multiprocessor Support and Cooperative Multitasking - 多处理器支和写作式多任务

在第一部分中,需要使JOS运行在一个多处理器系统上,并且实现新的JOS内核系统调用去允许用户级别的环境创建额外的新环境。还需要实现协同式的论询调度,允许内核当旧的用户环境自愿放弃CPU或退出时切换至一个新的用户环境。

多处理器支持

需要使JOS支持“对称多处理”(Symmetric Multiprocessing),在该模型下,所有的CPU都有着对系统资源(内存、IO总线等)的平等访问权。尽管在SMP下所有的CPU在功能上均等价,在启动时仍然分为两种类型——引导处理器(Bootstrap Processor或是BSP)负责初始化系统并且引导操作系统;应用处理器(Application Processors或是APs)仅在系统启动运行后被BSP激活。BSP处理器由硬件和BIOS决定。在此刻,所有已有的代码已经运行在了BSP上。

在SMP中,每一个CPU都有一个相伴的本地APIC(LAPIC)单元。LAPIC单元负责在整个系统中传递中断。LAPIC为相连的CPU提供了一个唯一标识。在本实验中,将利用LAPIC的一下功能(在kern/lapic.c中):

  • 读取LAPIC标识(APIC ID)以告知CPU代码运行在哪一个CPU上(参考cpunum()
  • 从BSP向APs发送STARTUP处理器间中断(Interprocessor interrupt或IPI)以唤醒其他CPU(参考lapic_startup()
  • 在第三部分中,通过编程LAPIC内置的计时器去引发时钟中断以实现抢占式多任务(参考apic_init()

处理器通过内存映射IO(Memory-mapped I/O 或是MMIO)访问LAPIC。在MMIO中,一部分物理内存被硬连接至某些IO设备的寄存器。所以相同的访问内存的load/store指令可以被用来访问设备寄存器。你可能已经见过了存在于物理地址0xA0000中IO洞(以此来写VGA显示缓存)。LAPIC存在于物理地址从0xFE000000(4064M)开始的洞中。该地址太高以至于无法在KERNBASE的直接映射访问。JOS的虚拟内存映射在MMIOBASE处留下了4MB的空隙。在之后的实验中会引入更多的MMIO区域,所以应当写一个简单的函数从该区域分配空间并映射内存。

练习1的答案如下,仅供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// In kern/pmap.c, mmio_map_region():
// Your code here:
size = ROUNDUP(size, PGSIZE);
if (base + size > MMIOLIM) {
// reservation overflog MMIOLIM
panic("reservation bytes overflows!");
}

// use boot_map_region to map [pa, pa + size) to [base, base + size)
boot_map_region(kern_pgdir, base, size, pa, PTE_W | PTE_PCD | PTE_PWT);

// update base and return
uintptr_t saved_base = base;
base += size;
return (char *)saved_base;

// panic("mmio_map_region not implemented");

应用处理器引导

BSP在引导APs之前应当首先收集多处理器系统的信息,如总CPU数,APIC IDs以及LAPIC单元的内存映射IO地址等。kern/mpconfig.c中的mp_init()函数通过读取BIOS内存中的MP配置表获取相应的信息。
kern/init.c中的boot_aps()函数驱动AP引导过程。APs在实模式中启动,所以boot_aps()kern/mpentry.S中拷贝AP入口代码到一个实模式可寻址的位置。可以在一定程度上控制AP执行开始代码的位置。在本实验中,将入口代码拷贝至0x7000(MPENTRY_PADDR),但实际上任何640KB以下的、页对齐的和未使用的物理地址均可使用。
然后,boot_aps()通过向对应AP的LAPIC单元发送STARTUP处理器间中断和初始的CS:IP地址(本实验中为MPENTRY_PADDR),依次激活APs。在简单的设置后,将AP启动分页,使得AP进入保护模式,然后调用启动例程mp_main()(在kern/init.c中。boot_aps()在唤醒下一个AP之前先等待当前AP在struct CpuInfocpu_status域中发送一个CPU_STARTED标记。
AP引导的汇编代码和C代码同实验一BSP的引导代码相似,可以比对异同。

练习2的代码如下,仅供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// In kern/pmap.c, page_init():
// code MODIFIED
size_t i;

// initialize from page 1 to page npages_basemem - 1
// set pp_ref to 0, set pp_link to last page_free_list
// and then update page_free_list

// Lab 4: remove page at MPENTRY_PADDR
size_t mp_entry_page = PGNUM(MPENTRY_PADDR);

for(i = 1 ; i < npages_basemem ; i++) {
if (i == mp_entry_page) {
continue;
}
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}

问题1:
MPBOOTPHYS作用是将给定的内核虚拟地址(在mpentry.S中)转换成相应的加载后的物理地址。这么做的原因是在mpentry.S中,保护模式与分页机制尚未开启,设置分段等需要知道相应的物理地址。

每CPU状态和初始化

当编写多处理器操作系统时,区分对于每个处理器而言私有的每CPU状态以及整个系统共享的全局状态是很重要的。kern/cpu.h定义了大部分每CPU状态,包括了存储了每CPU变量的struct CpuInfocpunum()总是返回调用它的CPU ID,能用来索引例如cpus的数组。宏thiscpu是当前CPU的struct CpuInfo的简写。

以下是你需要注意的每CPU状态:

  • 每CPU内核栈 - 多个CPU可能会同时陷入内核,因此每个处理器需要独立的内核栈以避免互相干扰。percpu_kstacks[NCPU][KSTKSIZE]NCPU个内核栈预留空间。在实验二中,将bootstack指向的物理内存作为BSP的栈映射在了KSTACKTOP下面。相似地,本实验中,需要将每个CPU的内核栈映射到该区域同时分配保护页作为它们之间的缓冲。CPU 0的栈将从KSTACKTOP开始向下增长;CPU 1的栈将从CPU 0栈下方间隔KSTKGAP处开始增长。inc/memlayout.h展示了映射约束。
  • 每CPU的TSS和TSS描述符 - 每CPU的任务状态段同样用来致命每个CPU内核栈的位置。CPU i的TSS被存储于cpus[i].cpu_ts中,相应的TSS描述符在GDT入口gdt[(GD_TSS0 >> 3) + i]处被定义。kern/trap.c中定义的ts将不再有效。
  • 每CPU的当前环境指针 - 由于每个CPU都能同步地执行不同的用户环境。重新将curenv定义为指向当前CPU(当前代码正在执行的CPU)正在执行的环境的cpus[cpunum()].cpu_env(或是thiscpu->cpu_env)。
  • 每CPU的系统寄存器 - 包括系统寄存器在内的所有寄存器都属于CPU私有。因此初始化这些寄存器的指令如lcr3, ltr, ‘lgdt’等,必须在每个CPU上都被执行。函数env_init_percpu()以及trap_init_percpu正是为此而被定义。

除此之外任何额外的用来CPU初始化的所有每CPU状态都应该在每个CPU处重复。

练习3中遇到的问题:

  • 在使用boot_map_region映射内存时忘记将kstacktop_i减去KSTKSIZE,导致未通过检查

练习3的代码如下,仅供参考:

1
2
3
4
5
6
7
8
9
10
// LAB 4: Your code here:
size_t i;

for (i = 0; i < NCPU; i++) {
// traverser through 0 to NCPU to use boot_map_region to map
// per-CPU's kernel stack to corresponding va
uintptr_t kstacktop_i = KSTACKTOP - i * (KSTKSIZE + KSTKGAP);
boot_map_region(kern_pgdir, kstacktop_i - KSTKSIZE, KSTKSIZE,
PADDR(percpu_kstacks[i]), PTE_W | PTE_P);
}

练习4中遇到的问题:

  • 使用ltr加载任务段选择子时,每个CPU应当使用不同的选择子,inc/memlayout.c中的GD_TSS0为CPU0的任务段选择子,应该加上i << 3的偏移
  • 完成trap_init_percpu()后,旧的使用ts的代码应当被注释

练习4的代码如下,仅供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// LAB 4: Your code here:
// trap_init_percpu() is called by all CPUs
// set esp0 and ss0 of task state segment to provide
// per-CPU's kernel stack access
thiscpu->cpu_ts.ts_esp0 = (uintptr_t)percpu_kstacks[cpunum()];
thiscpu->cpu_ts.ts_ss0 = GD_KD;
// set IO map base address to prevent unauthorized environments
// this line works together as we'll set TSS segment limit later
// so all ports in address space have no corresponding IOPB
thiscpu->cpu_ts.ts_iomb = sizeof(struct Taskstate);

// set TSS in gdt
// 0 means RPL and sd_s = 0 means system segment
gdt[(GD_TSS0 >> 3) + cpunum()] = SEG16(STS_T32A, (uint32_t)(&(thiscpu->cpu_ts)),
sizeof(struct Taskstate) - 1, 0);
gdt[(GD_TSS0 >> 3) + cpunum()].sd_s = 0;

// Load TSS selector
ltr(GD_TSS0 + (cpunum() << 3));

// load the IDT
lidt(&idt_pd);

同步锁

当前的代码在mp_main()中初始化完AP之后忙等待。在继续下一步之前,首先得解决多个CPU同时执行内核代码时的竞争条件。最简答的方式是使用一个“大”内核锁,大内核锁在从用户模式进入内核模式时被获取,在从环境返回用户模式时被释放。在这种模型下,用户模式环境可以在任意多个CPU上运行,但同时只能有一个环境能在内核中运行,其他想在内核中运行的环境被强制等待。

kern/spinlock.h声明了内核锁,同时提供了lock_kernel提供加锁的功能;unlock_kernel提供解锁的功能,你应当在如下4个地方应用内核锁:

  • i386_init()中,在BSP唤醒其他CPU之前加锁
  • mp_main()中,在初始化AP后加锁,然后调用sched_yield()去在当前AP上执行环境
  • trap()中,当从用户模式陷入的时候加锁,通过检查tf_cs的低位判断陷阱发生于用户模式还是内核模式
  • env_run()中,在“刚好”切换回用户模式之前解锁。太早或太晚解锁会导致严重的竞争和死锁

练习5的代码如下,仅供参考:

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
// In i386_init():
// Acquire the big kernel lock before waking up APs
// Your code here:
lock_kernel();

// In mp_main():
// Now that we have finished some basic setup, call sched_yield()
// to start running processes on this CPU. But make sure that
// only one CPU can enter the scheduler at a time!
//
// Your code here:
// lock the kernel and start running enviroments
lock_kernel();
sched_yield();

// In trap():
// Trapped from user mode.
// Acquire the big kernel lock before doing any
// serious kernel work.
// LAB 4: Your code here.
lock_kernel();

// In env_run():
// address space switch
// reference from inc/x86.h
lcr3(PADDR(e->env_pgdir));
// release kernel lock here
unlock_kernel(); // newly added code
// drop into user mode
env_pop_tf(&(e->env_tf));

问题2:
即使受到内核锁的保护,CPU之间仍然需要独立的内核栈。假设CPU0因中断陷入内核并在内核栈中保留了相关的信息,此时若CPU1也发生中断而陷入内核,在同一个内核栈的情况下,CPU0中的信息将会被覆盖从而导致出现错误。

论询调度

下一个任务是改变JOS内核使得其能按照“论询”的方式在多个环境中切换。在JOS中,论询调度按如下方式工作:

  • ‘kern/sched.c’中的sched_yield()负责选择一个新环境执行。其循环按顺序遍历envs数组,从上一次运行的环境(如果没有之前运行的环境,则从第一个环境)开始,找到第一个具有状态ENV_RUNNABLE的环境,调用env_run()执行。
  • sched_yield()绝对不能同时在两个CPU上运行相同的环境。其会告知环境当前已经正在运行在某CPU上(很可能是当前环境),因为该环境的状态将为ENV_RUNNING。
  • 将实现一个新的系统调用sys_yield(),用户环境可以通过该系统调用唤醒sched_yield()函数以主动放弃CPU。

练习6中遇到的问题:

  • 在实现sched_yield()的时候没有检查thiscpu->cpu_env是否为空,对空指针的访问导致了缺页错误
  • 一开始的实现弄错了获取env_index和自增的顺序,参考代码中第一个if处的else分支的注释
  • 练习6结束后需要将init.cmp_main()的最后一行注释

练习6的代码如下,仅供参考:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// In kern/sched.c, sched_yield():
// LAB 4: Your code here.
// get index from current CPU's env
size_t env_index;
// set curenv_flag to default true
int curenv_flag = true;

size_t i;
if (thiscpu->cpu_env == NULL) {
// no previous running environment
// start at beginning of envs array
i = 0;
env_index = NENV - 1;
// mark curenv_flag as true
curenv_flag = false;
} else {
// start at previous running environment
env_index = ENVX(thiscpu->cpu_env->env_id);
// NB: don't mess with code order here, must first retrieve
// env_index then increment
i = (env_index == NENV - 1) ? 0 : env_index + 1;
}
// traverse through envs list to find first ENV_RUNNABLE
// env
for (; i != env_index; i = ((i == NENV - 1) ? 0 : i + 1)) {
if (envs[i].env_status == ENV_RUNNABLE) {
// found, set idle and break from loop
idle = &envs[i];
break;
}
}

// if idle is NULL and curenv_flag is true,
// means no envs are runnable and last previous running env is ENV_RUNNING
// check if last previous environment is ENV_RUNNING,
// if so, choose it.
if (!idle && curenv_flag && envs[env_index].env_status == ENV_RUNNING) {
idle = &envs[env_index];
}

if (idle) {
// idle env choosed, run it directly
env_run(idle);
} else {
// failed to choose idle env, halt CPU
// sched_halt never returns
sched_halt();
}

// In kern/syscall.c, syscall():
// code added:
case SYS_yield:
// call sys_yield
sys_yield();
break;

// In kern/init.c, i386_init():
// code modified:
#if defined(TEST)
// Don't touch -- used by grading script!
ENV_CREATE(TEST, ENV_TYPE_USER);
#else
// Touch all you want.
// create three user_yield
// code MODIFIED here
ENV_CREATE(user_yield, ENV_TYPE_USER);
ENV_CREATE(user_yield, ENV_TYPE_USER);
ENV_CREATE(user_yield, ENV_TYPE_USER);
#endif // TEST*

问题3:
地址切换前后的页表中,e指向的虚拟地址都被同一块物理页映射。出现这种情况的原因在于envenv_pgdir是以kern_pgdir为原型产生的,e出于UTOP之上的地址,而UTOP以上的地址的映射关系在两个页表中是一样的。

问题4:
当发生地址转换时一定是从用户陷入内核之后,无论以何种方式陷入内核,必须要经过kern/trap.c中的trap()函数。观察该函数,可以发现,当从用户模式陷入内核时,代码将内核栈中的tf(包括页表和寄存器等)拷贝至内核间共享的对应的env中,所以之后寄存器状态才能恢复。

环境创建的系统调用

尽管内核已经可以运行并在不同环境间切换了,但内核任然被限制了只能执行内核初始设置的用户环境。
需要实现必要的系统调用使得JOS可以允许用户环境创建和启动其他的用户环境。

Unix提供了fork()作为它的进程创建原语。Unix的fork()拷贝调用进程(父进程)的整个地址空间以创建子进程。
从用户空间来看,父子进程唯一可观察的差异就是它们的进程ID和父进程ID(通过gitpid()getppid()返回。在父进程中,fork()返回子进程ID;在子进程中,fork()返回0。默认情况下,每个进程均获得其私有的地址空间,并且任意一个进程对于内存的修改对于其他进程都是不可见的。

将要实现一个不同的、更加原始的JOS系统调用原语集合去创建新的用户模式环境,通过这些系统调用,除了其他类型的环境创建以外,将能够在用户空间实现一个完整的类Unixfork()系统调用。需要实现的系统调用为:

  • sys_exofork - 创建一个几乎空白的新环境:在地址空间没有任何的用户映射,并且也无法运行。新环境将会有和父环境在执行sys_exofork系统调用时完全一致的寄存器状态。在父进程中,sys_exofork会返回新创建环境的envid_t(若环境创建错误则返回一个错误码);子进程则会返回0(因为子进程最初被标记为不可运行,直到父进程通过显式标记子进程可运行之后,sys_exofork返回。
  • sys_env_set_status - 设置指定的环境的状态为ENV_RUNNABLE或是ENV_NOT_RUNNABLE。该系统调用通常在一个新环境的地址空间和寄存器状态完全初始化之后标记其为可运行。
  • sys_page_alloc - 分配一页的物理内存并将其映射到给定环境地址空间的给定虚拟地址。
  • sys_page_alloc - 将一页映射(而不是实际的页内容)从一个环境的地址空间拷贝至另一个,共享内存使得新映射和旧映射指向同一页物理内存。
  • sys_page_unmap - 将给定环境的给定虚拟地址的页面解除映射。

上述所有的系统调用接受环境ID,内核支持将0到“当前环境”转换,在kern/env.c中的envid2env()实现。

已经在user/dumpfork.c中提供了非常原始的类Unix的fork()实现。测试程序用上述系统调用创建并运行一个当前地址空间拷贝的子进程,然后两个环境使用sys_yield()来回切换。父进程在10次迭代后退出;子进程在20次迭代后退出。

练习7的代码如下,仅供参考:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
// In sys_exofork():
// Allocate a new environment.
// Returns envid of new environment, or < 0 on error. Errors are:
// -E_NO_FREE_ENV if no free environment is available.
// -E_NO_MEM on memory exhaustion.
static envid_t sys_exofork(void) {
// Create the new environment with env_alloc(), from kern/env.c.
// It should be left as env_alloc created it, except that
// status is set to ENV_NOT_RUNNABLE, and the register set is copied
// from the current environment -- but tweaked so sys_exofork
// will appear to return 0.

// LAB 4: Your code here.
// get env_id of current environment
envid_t parent_id = thiscpu->cpu_env->env_id;

// use env_alloc to create new environment and
// do some basic setup(status and register set)
struct Env *env = NULL;
if (env_alloc(&env, parent_id) < 0) { panic("sys_exofork failed!"); }
env->env_status = ENV_NOT_RUNNABLE;
env->env_tf = thiscpu->cpu_env->env_tf;

// do the trick to set eax register of newly
// alloc environment to 0
env->env_tf.tf_regs.reg_eax = 0;

// return new environment's ID
return env->env_id;

// panic("sys_exofork not implemented");
}

// In sys_env_set_status():
// Set envid's env_status to status, which must be ENV_RUNNABLE
// or ENV_NOT_RUNNABLE.
//
// Returns 0 on success, < 0 on error. Errors are:
// -E_BAD_ENV if environment envid doesn't currently exist,
// or the caller doesn't have permission to change envid.
// -E_INVAL if status is not a valid status for an environment.
static int sys_env_set_status(envid_t envid, int status) {
// Hint: Use the 'envid2env' function from kern/env.c to translate an
// envid to a struct Env.
// You should set envid2env's third argument to 1, which will
// check whether the current environment has permission to set
// envid's status.

// LAB 4: Your code here.
// check status passed in
if (status != ENV_RUNNABLE && status != ENV_NOT_RUNNABLE) {
// invalid status
return -E_INVAL;
}

struct Env *env;
// call envid2env to translate envid passed in
if (envid2env(envid, &env, true) < 0) {
// bad environment
return -E_BAD_ENV;
}
// set status
env->env_status = status;

// panic("sys_env_set_status not implemented");

return 0;
}

// In sys_page_alloc():
// Allocate a page of memory and map it at 'va' with permission
// 'perm' in the address space of 'envid'.
// The page's contents are set to 0.
// If a page is already mapped at 'va', that page is unmapped as a
// side effect.
//
// perm -- PTE_U | PTE_P must be set, PTE_AVAIL | PTE_W may or may not be set,
// but no other bits may be set. See PTE_SYSCALL in inc/mmu.h.
//
// Return 0 on success, < 0 on error. Errors are:
// -E_BAD_ENV if environment envid doesn't currently exist,
// or the caller doesn't have permission to change envid.
// -E_INVAL if va >= UTOP, or va is not page-aligned.
// -E_INVAL if perm is inappropriate (see above).
// -E_NO_MEM if there's no memory to allocate the new page,
// or to allocate any necessary page tables.
static int sys_page_alloc(envid_t envid, void *va, int perm) {
// Hint: This function is a wrapper around page_alloc() and
// page_insert() from kern/pmap.c.
// Most of the new code you write should be to check the
// parameters for correctness.
// If page_insert() fails, remember to free the page you
// allocated!

// LAB 4: Your code here.
struct Env *env;
// check and get env
if (envid2env(envid, &env, true) < 0) {
// envid not exist or permission error
return -E_BAD_ENV;
}

// check va
if ((uintptr_t)va >= UTOP || PGOFF(va) != 0) {
// va above UTOP or va is not page-aligned
return -E_INVAL;
}

// check perm
if ((perm & (~PTE_SYSCALL)) || !(perm & PTE_U) || !(perm & PTE_P)) {
// invalid perm
return -E_INVAL;
}

// allocate page and then insert it
// check if out of memory
struct PageInfo *page;
if ((page = page_alloc(ALLOC_ZERO)) == NULL) {
// failed to allocate page
return -E_NO_MEM;
}
if (page_insert(env->env_pgdir, page, va, perm) < 0) {
// page table couldn't be allocated
// free ununsed page
page_free(page);
return -E_NO_MEM;
}

// panic("sys_page_alloc not implemented");

// page successfully allocated
return 0;
}

// In sys_page_map():
// Map the page of memory at 'srcva' in srcenvid's address space
// at 'dstva' in dstenvid's address space with permission 'perm'.
// Perm has the same restrictions as in sys_page_alloc, except
// that it also must not grant write access to a read-only
// page.
//
// Return 0 on success, < 0 on error. Errors are:
// -E_BAD_ENV if srcenvid and/or dstenvid doesn't currently exist,
// or the caller doesn't have permission to change one of them.
// -E_INVAL if srcva >= UTOP or srcva is not page-aligned,
// or dstva >= UTOP or dstva is not page-aligned.
// -E_INVAL is srcva is not mapped in srcenvid's address space.
// -E_INVAL if perm is inappropriate (see sys_page_alloc).
// -E_INVAL if (perm & PTE_W), but srcva is read-only in srcenvid's
// address space.
// -E_NO_MEM if there's no memory to allocate any necessary page tables.
static int sys_page_map(envid_t srcenvid, void *srcva, envid_t dstenvid,
void *dstva, int perm) {
// Hint: This function is a wrapper around page_lookup() and
// page_insert() from kern/pmap.c.
// Again, most of the new code you write should be to check the
// parameters for correctness.
// Use the third argument to page_lookup() to
// check the current permissions on the page.

// LAB 4: Your code here.
struct Env *srcenv, *dstenv;
// check environment
if ((envid2env(srcenvid, &srcenv, true) < 0) ||
(envid2env(dstenvid, &dstenv, true) < 0)) {
// srcenvid or dstenvid doesn't exist or
// caller doesn't have permissions
return -E_BAD_ENV;
}

// check srcva and dstva about address and page-aligned
// get srcenv and dstenv
if (((uintptr_t)srcva >= UTOP) || ((uintptr_t)dstva >= UTOP) ||
(PGOFF(srcva) != 0) || (PGOFF(dstva) != 0)) {
// addresses above UTOP or addresses not page_aligned
return -E_INVAL;
}

struct PageInfo *srcpage;
pte_t * scrpte_ptr;
// use page look up to get source page and corresponding pte_t *
if ((srcpage = page_lookup(srcenv->env_pgdir, srcva, &scrpte_ptr)) ==
NULL) {
// srcva not mapped in srcenvid's address space
return -E_INVAL;
}

// check perm passed in
if ((perm & (~PTE_SYSCALL)) || !(perm & PTE_U) || !(perm & PTE_P)) {
// invalid perm
return -E_INVAL;
}

// check if srcva is writable if perm has PTE_W
if ((perm & PTE_W) && (!((*scrpte_ptr) & PTE_W))) {
// perm has PTE_W while srcva ISN'T writable
return -E_INVAL;
}

// insert source page into dstenv's pgdir with perm
// check if out of memory
if (page_insert(dstenv->env_pgdir, srcpage, dstva, perm) < 0) {
// out of memory to allocate page table
return -E_NO_MEM;
}

// panic("sys_page_map not implemented");

// page successfully mapped
return 0;
}

// In sys_page_unmap():
// Unmap the page of memory at 'va' in the address space of 'envid'.
// If no page is mapped, the function silently succeeds.
//
// Return 0 on success, < 0 on error. Errors are:
// -E_BAD_ENV if environment envid doesn't currently exist,
// or the caller doesn't have permission to change envid.
// -E_INVAL if va >= UTOP, or va is not page-aligned.
static int sys_page_unmap(envid_t envid, void *va) {
// Hint: This function is a wrapper around page_remove().

// LAB 4: Your code here.
struct Env *env;
// check and get env
if (envid2env(envid, &env, true) < 0) {
// envid not exist or permission error
return -E_BAD_ENV;
}

// check va
if ((uintptr_t)va >= UTOP || PGOFF(va) != 0) {
// va above UTOP or va is not page-aligned
return -E_INVAL;
}

// call page_remove to unmap page
page_remove(env->env_pgdir, va);

// panic("sys_page_unmap not implemented");

// page successfully unmapped
return 0;
}

// In syscall():
// new code added
case SYS_exofork:
// call sys_exofork
return sys_exofork();
break;
case SYS_env_set_status:
// call sys_env_set_status
return sys_env_set_status((envid_t)a1, (int)a2);
break;
case SYS_page_alloc:
// call sys_page_alloc
return sys_page_alloc((envid_t)a1, (void *)a2, (int)a3);
break;
case SYS_page_map:
// call sys_page_map
return sys_page_map((envid_t)a1, (void *)a2, (envid_t)a3, (void *)a4,
(int)a5);
break;
case SYS_page_unmap:
// call sys_page_unmap
return sys_page_unmap((envid_t)a1, (void *)a2);
break;

第二部分 Copy-on-Write Fork - 写时复制Fork

Unix提供了一个fork()系统调用作为其原始的进程创建原语。fork()系统调用将调用进程的整个地址空间拷贝以创建子进程。

xv6通过将父进程页中的所有数据复制到子进程中的新页中来实现fork(),这本质上就是dumbfowk()采用的方法。对于父进程地址空间的拷贝是整个fork()操作中最“贵”的部分。

然而,对于fork()的调用通常紧跟着一个对子进程的exec()系统调用,该系统调用用一个新程序替换子进程的内存,这就是一个典型的shell所做的。在这种情况下,花费的用于复制父进程的地址空间的时间是被浪费的,因为子进程在调用exec()前只会使用很少的内存。

出于以上的原因,Unix之后的版本利用了虚拟内存硬件去允许父进程和子进程共享映射到各自地址空间的内存,直到其中一个进程实际修改为止。该技术又被称作写时复制。
为了实现写时复制,在fork()中内核仅仅从父进程拷贝地址的映射而非实际映射的页面到子进程,并且在同时将共享的页面标记为只读。
当两个进程之一尝试写入其中一个共享页面时,该过程触发页面错误。
内核意识到该页面实际上是一个“虚拟”或是“写时复制”页面,所以其会创建一个错误页面的新的,私有的,可写的页副本。
通过这种方式,直到实际写入时,独立页面的内容才被复制。
该过程使得紧接着exec()fork()调用更加节约:子进程在调用exec()很可能只会复制一页(栈的当前页)。

本实验的下一个部分要求完成一个合适的类Unix的fork()的写时复制的实现(作为用户空间库例程)。在用户空间实现fork()并且支持写时复制使得内核能保留相对简洁,因此更不容易出现严重的错误。这同样可以允许独立的用户模式程序实现自己的fork()语义。

用户级别的页错误处理

用户级别的写时复制fork()实现需要知道写保护页面的页错误。写时复制只是用户级别的页错误处理的诸多可能用处之一。

通常的做法是设置一个地址空间以便页错误指示何时需要采取某些行动。大多数Unix内核通常只会为新进程的栈区域分配一页,随着进程栈逐渐增长直至访问到了还未被映射的栈地址,触发页错误,然后内核会“按需”分配更多的栈页面。
典型的Unix内核必须追踪进程的空间中的不同区域发生页错误时所采取的行动。栈区域的页错误通常会导致新页的分配和映射。BSS区域的页错误通常会导致分配一个新页,填充0,并映射。对于具有按需分页的可执行文件的系统,text段的页错误会导致内核从磁盘上的二进制文件读取相应的页面并映射。

内核需要追踪大量的信息。与传统的Unix方法不同,本实验需要用户决定如何处理用户空间中的每个页面错误,而这些错误的损害通常不大。这种设计还为程序定义存储区域带来了极大的灵活性。之后将会应用用户级别的错误处理程序来映射和访问基于磁盘的文件系统上的文件。

设置页错误处理程序

为了处理用户环境自己的页错误,用户环境必须向JOS内核注册一个页错误处理程序入口。用户环境通过sys_env_set_pgfault_upcall()的“上行”系统调用注册自己的错误处理程序入口。已经向struct Env添加了新的域env_pgfault_upcall来记录该信息。

练习8的代码如下,仅供参考:

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
// In sys_env_set_pgfault_upcall():
// Set the page fault upcall for 'envid' by modifying the corresponding struct
// Env's 'env_pgfault_upcall' field. When 'envid' causes a page fault, the
// kernel will push a fault record onto the exception stack, then branch to
// 'func'.
//
// Returns 0 on success, < 0 on error. Errors are:
// -E_BAD_ENV if environment envid doesn't currently exist,
// or the caller doesn't have permission to change envid.
static int sys_env_set_pgfault_upcall(envid_t envid, void *func) {
// LAB 4: Your code here.
struct Env *env;
// check and translate the envid
if (envid2env(envid, &env, true) < 0) {
// envid not exist or permission error
return -E_BAD_ENV;
}

// set func
env->env_pgfault_upcall = func;

// panic("sys_env_set_pgfault_upcall not implemented");

return 0;
}

// In syscall():
// new code added
case SYS_env_set_pgfault_upcall:
// call sys_env_set_pgfault_upcall
return sys_env_set_pgfault_upcall((envid_t)a1, (void *)a2);
break;

用户环境中的正常栈和异常栈

在正常执行时,JOS中的用户环境会在正常栈中执行:正常栈的ESP寄存器指向USTACKTOP,并且压入的栈数据会存放于USTACKTOP - PGSIZE到USTACKTOP - 1的闭区域。
然而,当一个页错误在用户模式发生时,内核将重启用户环境,在一个另外的栈上运行一个特定的用户级别的页错误处理程序,该栈被称为用户异常栈。
实际上,将使JOS代表用户环境自动执行“栈切换”,就像x86处理器在用户模式转换到内核模式时已经代表JOS实现了栈切换一样。

JOS用户异常栈的大小也为一个页面大,栈顶被定义为指向虚拟地址UXSTACKTOP,用户异常栈的有效字节为UXSTACKTOP - PGSIZE到UXSTACKTOP - 1的闭区域。
当运行于异常栈上时,用户级别的页错误处理程序可以使用JOS的常规系统调用映射新的页或是调整页映射以修复任何可能导致页错误的问题。
然后用户级别的页错误处理程序通过汇编语言存根返回至原始栈上的错误代码处。

每一个想要支持用户级别页错误处理程序的用户环境需要自己为其异常栈通过sys_page_alloc()分配内存。

唤醒用户页错误处理程序

现在需要改变kern/trap.c中的页错误处理程序按特定的方式处理用户模式的页错误。
将错误时的用户环境状态叫做陷阱时状态。

若是没有页面错误处理函数被注册,则JOS内核会和之前一样,摧毁用户环境并输出一条消息。否则,内核会在异常栈设置在inc/trap.h中如struct UTrapFrame定义的陷阱帧。

然后内核会安排用户环境以页错误处理程序以上述栈帧在异常栈上恢复执行。fault_va是导致也错误的虚拟地址。

如果当异常发生时用户环境已经在用户异常栈上运行,则错误处理程序自身发生了错误。在这种情况下,你应当在当前的tf->tf_esp中而非UXSTACKTOP中重新启动栈帧。你应当首先压入一个4字节的字,然后一个struct UTrapFrame

检查tf->tf_esp是否在UXSTACKTOP - PGSIZE到UXSTACKTOP - 1的闭区间内来判断其是否已经在用户异常栈上。

练习9中遇到的问题:

  • curenv已经被重新定义为thiscpu->cpu_env,因此可以使用
  • 判断tf->tf_esp的if语句中,将与写成了或

练习9的代码如下,仅供参考:

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
41
42
43
// In trap.c, page_fault_handler():
// LAB 4: Your code here.
struct UTrapframe *utf;
uintptr_t utf_addr;

// check tf->tf_esp's location to calculate utf's address
if ((tf->tf_esp >= UXSTACKTOP - PGSIZE) && (tf->tf_esp < UXSTACKTOP)) {
// recursive case
// reason why here needs to substract a 4-byte word
// is to reserve space for reset eip/esp
utf_addr = tf->tf_esp - sizeof(struct UTrapframe) - 4;
} else {
// non-recursive case, set utf_addr to the top of
// user exception stack
utf_addr = UXSTACKTOP - sizeof(struct UTrapframe);
}

if (curenv->env_pgfault_upcall) {
// page fault upcall exist

// use mem assert to check environment allocates the exception
// stack and has write permission to it, and stack ISN'T overflow
// combine three case with only one user_mem_assert to check utf_addr
// NB: curenv HAS been redefined as thiscpu->cpu_env
user_mem_assert(curenv, (void *)utf_addr, sizeof(struct UTrapframe),
PTE_U | PTE_W | PTE_P);

// set user stack frame
utf = (struct UTrapframe *)utf_addr;
utf->utf_fault_va = fault_va;
utf->utf_err = tf->tf_err;
utf->utf_regs = tf->tf_regs;
utf->utf_eip = tf->tf_eip;
utf->utf_eflags = tf->tf_eflags;
utf->utf_esp = tf->tf_esp;

// modify stack frame to set entry for env_pgfault_upcall
// and set address for user exception stack
tf->tf_eip = (uintptr_t)curenv->env_pgfault_upcall;
tf->tf_esp = utf_addr;

env_run(curenv);
}

用户模式页错误入口

然后需要实现汇编例程,该例程调用C页错误处理程序,然后在恢复在原始的错误指令处的执行。该例程就是被sys_env_set_pgfault_upcall()注册的例程。

最后需要在C用户库实现用户模式的页错误处理机制。

练习10中遇到的问题:

  • 需要想清楚思路,即先将eip“压入”陷阱时栈中,使用一个sub后接一个mov指令来模拟该压栈过程,若为递归过程,则会将eip填入之前留下的空白。然后依次恢复寄存器以及eflags。最后pop %esp以切换栈并调用ret返回

练习10的代码如下,仅供参考:

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
// In pfentry.S:
// LAB 4: Your code here.
// push trap-time %eip to trap-time stack
// mov utf_eip to %eax
movl 0x28(%esp), %eax
// use sub and mov to *simulate* push
subl $0x4, 0x30(%esp)
movl 0x30(%esp), %ebp
movl %eax, (%ebp)
// pop the unused fault_va and err
popl %eax
popl %eax

// Restore the trap-time registers. After you do this, you
// can no longer modify any general-purpose registers.
// LAB 4: Your code here.
popal

// Restore eflags from the stack. After you do this, you can
// no longer use arithmetic operations or anything else that
// modifies eflags.
// LAB 4: Your code here.
addl $0x4, %esp
popfl

// Switch back to the adjusted trap-time stack.
// LAB 4: Your code here.
popl %esp

// Return to re-execute the instruction that faulted.
// LAB 4: Your code here.
ret

练习11中遇到的问题:

  • 注意描述,要使用_pgfault_upcall()(汇编例程包装)来注册用户页处理函数而非传入的那个函数

练习11的代码如下,仅供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// In set_pgfault_handler():
if (_pgfault_handler == 0) {
// First time through!
// LAB 4: Your code here.
// alloc one page for user exception stack
// and register user-level page fault upcall
if (sys_page_alloc(thisenv->env_id, (void *)(UXSTACKTOP - PGSIZE),
PTE_U | PTE_W | PTE_P) < 0) {
panic("failed to alloc page for user exception stack!");
}
if (sys_env_set_pgfault_upcall(thisenv->env_id, _pgfault_upcall) < 0) {
panic("failed to register page fault upcall!");
}

// panic("set_pgfault_handler not implemented");
}

faultalloc能成功而faultallocbad失败的原因在于faultallocbad直接使用系统调用输出,该输出会使用先使用内存检查而失败;而faultalloc使用了用户库中的cprintf,该函数在使用系统调用输出之前已经尝试对错误地址进行了解引用,触发了用户级别的缺页错误,在实际执行系统调用时,错误的地址已经被分配了页面,因此能通过内存检查。

实现写时复制

已经拥有了在用户空间中完整实现写时复制fork()的内核设施基础。

现在已经在lib/fork.c中给出了fork()的框架。同dumbfork()相似,fork()首先创建了一个新环境,然后扫描父进程的地址空间并在子进程中建立相同的页映射。核心区别在于dumbfork()复制页,而fork()只实现页映射的复制。fork()直到某环境尝试写时在复制对应的页。

fork()的基本控制流为:

  1. 父进程设置pgfault()作为C级别的页错误处理程序,使用实现了的set_pgfault_handler()函数
  2. 父进程调用sys_exofork()去创建子环境
  3. 对于地址空间中每一个在UTOP下的可写的或是写时复制的页面,父进程调用duppage(),该函数将该页面以写时复制映射进子进程然后在父进程的地址空间中重新映射该页面为写时复制(顺序很重要)。duppage()同时设置PTE,所以页面将不可写。通过avail域的PTE_COW区分写时复制页面和真正的只读页面。然而,异常栈却不按该方式重映射。需要为子进程分配一个新页面作为异常栈。由于页处理程序实际执行了页复制并且页处理程序运行于异常栈上,异常栈不能写时复制。fork()也需要处理存在但既不是写时复制也不是可写的页面。
  4. 父进程为子进程设置用户页错误入口。
  5. 子进程已经准备好运行了,有父进程将其标记为可运行。

每一次环境尝试向一个还不可写的写时复制的页面写入时,将出现页错误,用户页错误处理程序的控制流为:

  1. 内核将页错误传递给页错误上行调用,即调用了fork()的页错误处理程序。
  2. pgfault()检查错误为写入导致并且相应页面的PTE被标记为写时复制,否则使内核恐慌。
  3. pgfault()分配一个新的页面,映射至一个临时位置,然后复制错误页面的内容到新分配的页面,最后将新页面以读写权限映射到合适的地址,替代旧的只读页。

用户级别的lib/fork.c代码必须查询环境的页表(如查询某页面的PTE被标记为写时复制)。内核将环境的页表映射在UVPT正是为了这个目的。内核通过将页目录的指针指向自己的映射技巧使得可以为用户代码快速查找PTEs,lib/entry.S已经设置了uvpduvpd,你可以快速在lib/fork.c中查找页表信息。

练习12中遇到的问题:

  • 注意在pgfault()duppage()中,必须使用0而不是thisenv->env_id,原因已经在代码注释中标出
  • fork()中需要显式地判断用户异常页并跳过duppage()
  • 阅读给出的uvpt技巧,需要在代码中使用
  • 为了简化错误处理,避免重复代码,使用了goto
  • 假定先将父进程的用户栈标记为COW,然后再尝试将子进程的用户栈标记为COW之前,可能会出现父进程的用户栈已经被写入的情况而触发缺页错误,因此fork()会立刻分配新页,拷贝内容并重新映射。子进程被标记COW时复制的页映射已经不是原来的页映射了,因此,后来对父进程做的改动会反映在子进程上,这显然是错误的
  • fork后必须修改thisenv的值以保证用户程序行为正确

练习12的代码如下,仅供参考:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
// In lib/fork.c, fork():
//
// User-level fork with copy-on-write.
// Set up our page fault handler appropriately.
// Create a child.
// Copy our address space and page fault handler setup to the child.
// Then mark the child as runnable and return.
//
// Returns: child's envid to the parent, 0 to the child, < 0 on error.
// It is also OK to panic on error.
//
// Hint:
// Use uvpd, uvpt, and duppage.
// Remember to fix "thisenv" in the child process.
// Neither user exception stack should ever be marked copy-on-write,
// so you must allocate a new page for the child's user exception stack.
//
envid_t fork(void) {
// LAB 4: Your code here.
int r;

// use set_pgfault_handler to install pgfault() as page fault handler
set_pgfault_handler(pgfault);

// use system call to create new blank child environment
envid_t child_env_id = sys_exofork();
if (child_env_id == 0) {
// must update thisenv to make sure user program behave
// normally
thisenv = envs + ENVX(sys_getenvid());
}
if (child_env_id < 0) {
// error when create new environment, simply return
return child_env_id;
}

// traverse through parent's address space and use duppage
// to copy address mappings
uintptr_t addr;
for (addr = 0; addr < UTOP; addr += PGSIZE) {
if ((((pde_t *)uvpd)[PDX(addr)] & PTE_P) &&
(((pte_t *)uvpt)[PGNUM(addr)] & PTE_P)) {
// if both page directory entry and page table entry
// exist for one address(page-aligned), then call duppage
if (addr == (UXSTACKTOP - PGSIZE)) {
// ignore user exception stack
// for we will map a page for user exception later
continue;
}
if ((r = duppage(child_env_id, PGNUM(addr))) < 0) {
// duppage failed
// destroy child environment and return
goto error_handle;
}
}
}

// child must have its own exception stack
// so alloc page and map it for child here
if ((r = sys_page_alloc(child_env_id, (void *)(UXSTACKTOP - PGSIZE),
PTE_P | PTE_U | PTE_W)) < 0) {
// failed to set child environment's exception stack
// destroy child environment and return
goto error_handle;
}

// set child's page fault handler so that parent
// and child looks the same
// use sys_env_set_pgfault_upcall system call
extern void _pgfault_upcall(void);
if ((r = sys_env_set_pgfault_upcall(child_env_id, _pgfault_upcall)) < 0) {
// failed to set child's page fault upcall
// destroy child environment and return
goto error_handle;
}

// now child is ready to run, set child to ENV_RUNNABLE
// use sys_env_set_status system call
if ((r = sys_env_set_status(child_env_id, ENV_RUNNABLE)) < 0) {
// failed to change child environment's running status
// destroy child environment and return
goto error_handle;
}

// panic("fork not implemented");

return child_env_id;

// use goto to avoid meaningless error_handle code
// copy and paste everywhere
error_handle:
if (sys_env_destroy(child_env_id) < 0) {
// failed either, panic then
panic("failed to destroy child environment!");
}
return r;
}

// In lib/fork.c, duppage():
//
// Map our virtual page pn (address pn*PGSIZE) into the target envid
// at the same virtual address. If the page is writable or copy-on-write,
// the new mapping must be created copy-on-write, and then our mapping must be
// marked copy-on-write as well. (Exercise: Why do we need to mark ours
// copy-on-write again if it was already copy-on-write at the beginning of
// this function?)
//
// Returns: 0 on success, < 0 on error.
// It is also OK to panic on error.
//
static int duppage(envid_t envid, unsigned pn) {
int r;

// LAB 4: Your code here.
int perm = PTE_P | PTE_U;
if ((((pte_t *)uvpt)[pn] & (PTE_W | PTE_COW))) {
// check for writable or copy-on-write page
// and add perm with PTE_COW
perm |= PTE_COW;
// NB: we also use 0 to replace thisenv->env_id
// for in unix-like fork() implementation, we will copy
// address space from parent process to child process
// at current time, thisenv->env_id IS ABSOLUTELY WRONG

// map from parent environment to child environment
if (sys_page_map(0, (void *)(pn * PGSIZE), envid, (void *)(pn * PGSIZE),
perm) < 0) {
panic("failed to map page from parent to child!");
}
// remap parent's environment
if (sys_page_map(0, (void *)(pn * PGSIZE), 0, (void *)(pn * PGSIZE),
perm) < 0) {
panic("failed to remap page in parent!");
}
} else {
// for other pages, simply map from
// parent environment to child environment
if (sys_page_map(0, (void *)(pn * PGSIZE), envid, (void *)(pn * PGSIZE),
perm) < 0) {
panic("failed to map page from parent to child!");
}
}

// panic("duppage not implemented");

return 0;
}

// In lib/fork.c, pgfault():
//
// Custom page fault handler - if faulting page is copy-on-write,
// map in our own private writable copy.
//
static void pgfault(struct UTrapframe *utf) {
void * addr = (void *)utf->utf_fault_va;
uint32_t err = utf->utf_err;
int r;

// Check that the faulting access was (1) a write, and (2) to a
// copy-on-write page. If not, panic.
// Hint:
// Use the read-only page table mappings at uvpt
// (see <inc/memlayout.h>).

// LAB 4: Your code here.
// use uvpt to get PTE and check premissions
if (!(err & FEC_WR) || !(((pte_t *)uvpt)[PGNUM(addr)] & PTE_COW)) {
panic("fault isn't write or PTE is not marked as PTE_COW or both!");
}

// Allocate a new page, map it at a temporary location (PFTEMP),
// copy the data from the old page to the new page, then move the new
// page to the old page's address.
// Hint:
// You should make three system calls.

// LAB 4: Your code here.
// allocate a page and map it at PFTEMP
// use sys_page_alloc system call
// NB: use 0 instead of thisenv->env_id
// for normally when child environment scheduled by JOS
// first it tries to store 0(return value of fork()) to
// local variable child_env_id on the stack
// which will take a page fault and kernel will trasfer control
// to pgfault here
// thisenv->env_id IS ABSOLUTELY WRONG here, so use 0
// and let kernel convert from 0 to correct env_id
if ((r = sys_page_alloc(0, (void *)PFTEMP, PTE_P | PTE_U | PTE_W)) < 0) {
panic("failed to allocate page at temp location - %e, %x!", r,
thisenv->env_id);
}

addr = ROUNDDOWN(addr, PGSIZE);
// use memcpy to copy from fault address's page to PFTEMP's newly
// allocated page
memcpy((void *)PFTEMP, addr, PGSIZE);

// use sys_page_map system call to map newly allocated
// page at PFTEMP at addr with read/write permissions
if ((r = sys_page_map(0, (void *)PFTEMP, 0, addr, PTE_P | PTE_U | PTE_W)) <
0) {
panic("failed to map new page - %e!", r);
}
// use sys_page_unmap system call to unmap page at
// temp location PFTEMP
if ((r = sys_page_unmap(0, (void *)PFTEMP)) < 0) {
panic("failed to unmap page at PFTEMP - %e!", r);
}

// panic("pgfault not implemented");
}

第三部分 - Preemptive Multitasking and Inter-Process communication (IPC) 抢占式多任务和进程间通信

最后一部分要求你修改内核,实现抢占式非协作环境以及允许环境显式传递信息。

时钟中断和抢占

运行user/spin测试程序,该测试程序fork一个用户环境,生成的子环境一旦获得了CPU,就会执行一个无限循环,无论是父环境还是内核都无法重新获得CPU。
对于从错误和恶意代码中保护系统而言,这不是一个理想的情况,因为任何用户环境都可以通过一个无限循环以及永不放弃使得整个系统停机。为了允许内核抢占一个用户环境并强制从该环境处获取CPU的控制,需要扩展JOS内核以支持时钟硬件的外部硬件中断。

中断规则

外部中断(如设备中断)被称为IRQs(Interrupt request)。总共有16种可能的IRQs,分别从0编号到15。从IRQ号到IDT入口的映射并不固定。picirq.c中的pic_init()将IRQs从0到15映射到了IRQ_OFFSET到IRQ_OFFSET + 15。

inc/trap.h中,IRQ_OFFSET被定义为32,因此IDT入口32-47对应于IRQ的0-15。例如,始终中断是IRQ 0。因此,IDT[IRQ_OFFSET + 0]包含了内核中时钟中断处理程序的地址。IRQ_OFFSET设置为32后,设备中断将不会和处理器异常号重叠。

同xv6 Unix相比,JOS内核提供了一个关键性简化。在内核中时,外部设备中断永远是被禁止的(但是在用户环境中是被允许的)。外部中断被%eflags寄存器的FL_IF标记控制。当该bit被设置时,外部中断被允许。尽管有若干种方式可以修改该bit位,将在进入和退出用户模式时通过保存和恢复%eflags寄存器的过程来处理它。

必须确保在用户环境中FL_IF标记被设置,这样当中断到达后,并且将中断传递给处理器,并由内核中的中断处理程序处理。否则中断会被屏蔽和忽略之道中断重新被允许。在引导程序的第一条指定就屏蔽了外部设备中断,并且直到目前为止,还没有重新启用。

练习13中遇到的问题:

  • 注意到简化规则要求中说明必须在陷入内核时将IF置0,所以必须修改之前的代码,将所有门都改为中断门

练习13的代码如下,仅供参考:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// In kern/trapentry.S:
// externel interrupts
TRAPHANDLER_NOEC(irq_0_handler, IRQ_OFFSET + 0);
TRAPHANDLER_NOEC(irq_1_handler, IRQ_OFFSET + 1);
TRAPHANDLER_NOEC(irq_2_handler, IRQ_OFFSET + 2);
TRAPHANDLER_NOEC(irq_3_handler, IRQ_OFFSET + 3);
TRAPHANDLER_NOEC(irq_4_handler, IRQ_OFFSET + 4);
TRAPHANDLER_NOEC(irq_5_handler, IRQ_OFFSET + 5);
TRAPHANDLER_NOEC(irq_6_handler, IRQ_OFFSET + 6);
TRAPHANDLER_NOEC(irq_7_handler, IRQ_OFFSET + 7);
TRAPHANDLER_NOEC(irq_8_handler, IRQ_OFFSET + 8);
TRAPHANDLER_NOEC(irq_9_handler, IRQ_OFFSET + 9);
TRAPHANDLER_NOEC(irq_10_handler, IRQ_OFFSET + 10);
TRAPHANDLER_NOEC(irq_11_handler, IRQ_OFFSET + 11);
TRAPHANDLER_NOEC(irq_12_handler, IRQ_OFFSET + 12);
TRAPHANDLER_NOEC(irq_13_handler, IRQ_OFFSET + 13);
TRAPHANDLER_NOEC(irq_14_handler, IRQ_OFFSET + 14);
TRAPHANDLER_NOEC(irq_15_handler, IRQ_OFFSET + 15);

// In kern/trap.c, trap_init():
// set up interupt gate descriptor
SETGATE(idt[IRQ_OFFSET + 0], 0, GD_KT, irq_0_handler, 0);
SETGATE(idt[IRQ_OFFSET + 1], 0, GD_KT, irq_1_handler, 0);
SETGATE(idt[IRQ_OFFSET + 2], 0, GD_KT, irq_2_handler, 0);
SETGATE(idt[IRQ_OFFSET + 3], 0, GD_KT, irq_3_handler, 0);
SETGATE(idt[IRQ_OFFSET + 4], 0, GD_KT, irq_4_handler, 0);
SETGATE(idt[IRQ_OFFSET + 5], 0, GD_KT, irq_5_handler, 0);
SETGATE(idt[IRQ_OFFSET + 6], 0, GD_KT, irq_6_handler, 0);
SETGATE(idt[IRQ_OFFSET + 7], 0, GD_KT, irq_7_handler, 0);
SETGATE(idt[IRQ_OFFSET + 8], 0, GD_KT, irq_8_handler, 0);
SETGATE(idt[IRQ_OFFSET + 9], 0, GD_KT, irq_9_handler, 0);
SETGATE(idt[IRQ_OFFSET + 10], 0, GD_KT, irq_10_handler, 0);
SETGATE(idt[IRQ_OFFSET + 11], 0, GD_KT, irq_11_handler, 0);
SETGATE(idt[IRQ_OFFSET + 12], 0, GD_KT, irq_12_handler, 0);
SETGATE(idt[IRQ_OFFSET + 13], 0, GD_KT, irq_13_handler, 0);
SETGATE(idt[IRQ_OFFSET + 14], 0, GD_KT, irq_14_handler, 0);
SETGATE(idt[IRQ_OFFSET + 15], 0, GD_KT, irq_15_handler, 0);

// In kern/env.c, env_alloc():
// Enable interrupts while in user mode.
// LAB 4: Your code here.
// simply use bit or to set FL_IF
e->env_tf.tf_eflags |= FL_IF;

// In kern/sched.c, sched_halt():
// Reset stack pointer, enable interrupts and then halt.
asm volatile (
"movl $0, %%ebp\n"
"movl %0, %%esp\n"
"pushl $0\n"
"pushl $0\n"
// Uncomment the following line after completing exercise 13
"sti\n"
"1:\n"
"hlt\n"
"jmp 1b\n"
: : "a" (thiscpu->cpu_ts.ts_esp0));

处理时钟中断

user/spin程序中,在子环境第一次运行后,会陷入无限循环,而内核将无法得到控制权,需要对硬件编程以定时产生时钟中断,该终端会将控制权强行切回CPU,然后可以将控制权交给另一个用户环境。

对于lapic_init()pic_init()的调用已经写好,它们会设置时钟并且终端控制器会定时生成中断。需要完成代码处理这些中断。

练习14中遇到的问题:

  • 首先需要调用lapic_eoi()以确认接收到了中断,否则会一直卡住

练习14的代码如下,仅供参考:

1
2
3
4
5
6
7
8
9
10
// in kern/trap.c, trap_dispatch():
// new code added
if (tf->tf_trapno == IRQ_OFFSET + IRQ_TIMER) {
// dispatch clock interupts
// call sched_yield() to find and run a different environment
// NB: should first call lapic_eoi() to ACKNOWLEDGE interupt
lapic_eoi();
sched_yield();
// sched_yield() might not return
}

进程间通讯

到目前位置实验一直关注于操作系统的独立部分,操作系统通过虚拟化提供了一种每个环境都独占整个机器的“错觉”。然而操作系统的另一项服务是允许程序在必要的时候通讯,该功能非常强大,Unix管道就是一个典型的例子。

有许多进程间通讯的模型,即使是现在也有许多关于哪一种模型是最好的争论。我们不参与争论。相反,我们将实现一个简单的IPC模型。

JOS中的IPC

你将实现一些额外的JOS内核系统调用,这些调用共同提供一个简单的进程间通讯机制。具体的系统调用为sys_ipc_recv()sys_ipc_try_send(),然后你将实现两个库包装ipc_recv()ipc_send()

用户环境通过JOS的IPC机制向其他环境互相发送的信息包括两部分,一个32-bit的值和一个可选的单页映射。允许环境传递页映射提供了一个比传递一个32-bit的整数更有效的数据交换方式,这也可以较为方便的设置共享内存。

发送和接收消息

环境调用sys_ipc_recv()去接受消息。该系统调用取消调度当前环境并且直到一条消息被收到后才会被继续运行。当一个环境等待接收消息时,任何其他的环境可以向其发送一条消息 - 并不限于特定的环境,也不限于父子进程间传递消息,换言之,你在第一部分中实现的权限检查将不适用于IPC,因为IPC系统调用经过精心设计以保证安全,环境不会通过简单地发送消息而导致其他环境出现错误(除非目标环境也有错误)。

环境调用sys_ipc_try_send()去尝试发送一个值(并附带上接受者的环境ID),如果指定的环境实际上正在接受该值(已调用sys_ipc_recv()并且尚未获得值),则值成功发送并返回0。否则,返回-E_IPC_NOT_RECV表示目标环境当前不期望接收值。

用户空间的库函数ipc_recv()将负责调用sys_ipc_recv()并在当前环境的struct Env中查找接收值的信息。

用户空间的库函数ipc_send()将负责反复调用sys_ipc_try_send()直到发送成功。

传输页

当一个环境以一个有效的dstva参数(小于UTOP)调用sys_ipc_recv()时,环境改变状态以表明想要接受一个页映射。如果发送者发送了一个页,那么该页将会被映射到接收者地址空间的dstva处。如果接收者已经在dstva映射了一页,那么之前的页会被解映射。

当一个环境以一个有效的srcva参数(小于UTOP)调用sys_ipc_try_send()时,表明发送者想要将当前映射在srcva的页发送给接收者,并且带有权限perm。在一次成功的IPC之后,发送者在其地址空间保留在srcva处原有的页映射,但是接收者也持有最开始由发送者指定的相同的物理页面在dstva的映射。最终,该页面在发送者和接收者之间共享。

如果发送者和接收者两者之一没有表明要传输页面,那么将不会有页面被传输。在任意一次IPC之后将接收者的struct Env中新的域env_ipc_perm设置为接受页面的权限,如果没有收到页面,则为0。

实现IPC

练习15中遇到的问题:

  • sys_ipc_try_send()sys_ipc_recv()中,均不将超过UTOP的地址标记为错误,超过UTOP的地址是无效的,但并不会导致系统调用失败
  • sys_ipc_recv()中在接收到消息前将环境状态置为不可运行,那么在收到消息后,应当恢复到可运行的状态
  • sys_ipc_try_send()中调用page_lookup()时,要记住检查的是源地址的映射,因此应当使用当前环境的页表而非接收者环境的页表

练习15的代码如下,仅供参考:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
// In kern/syscall.c, sys_ipc_recv():
// Block until a value is ready. Record that you want to receive
// using the env_ipc_recving and env_ipc_dstva fields of struct Env,
// mark yourself not runnable, and then give up the CPU.
//
// If 'dstva' is < UTOP, then you are willing to receive a page of data.
// 'dstva' is the virtual address at which the sent page should be mapped.
//
// This function only returns on error, but the system call will eventually
// return 0 on success.
// Return < 0 on error. Errors are:
// -E_INVAL if dstva < UTOP but dstva is not page-aligned.
static int sys_ipc_recv(void *dstva) {
// LAB 4: Your code here.
// similar to sys_ipc_try_send, will not mark an error
// if dstva >= UTOP
if ((uintptr_t)dstva < UTOP && PGOFF(dstva) != 0) {
// if dstva < UTOP but dstva is not page-aligned
// NB: && rather than ||, we won't report error
// when dstva >= UTOP
return -E_INVAL;
}

// mark calling environment as not runnable
curenv->env_status = ENV_NOT_RUNNABLE;
// update status to mark receiver is willing to receive message
curenv->env_ipc_recving = true;
curenv->env_ipc_dstva = dstva;

// give up cpu
sys_yield();

// panic("sys_ipc_recv not implemented");

// ipc succeeds
return 0;
}

// In kern/syscall.c, sys_ipc_try_send():
// Try to send 'value' to the target env 'envid'.
// If srcva < UTOP, then also send page currently mapped at 'srcva',
// so that receiver gets a duplicate mapping of the same page.
//
// The send fails with a return value of -E_IPC_NOT_RECV if the
// target is not blocked, waiting for an IPC.
//
// The send also can fail for the other reasons listed below.
//
// Otherwise, the send succeeds, and the target's ipc fields are
// updated as follows:
// env_ipc_recving is set to 0 to block future sends;
// env_ipc_from is set to the sending envid;
// env_ipc_value is set to the 'value' parameter;
// env_ipc_perm is set to 'perm' if a page was transferred, 0 otherwise.
// The target environment is marked runnable again, returning 0
// from the paused sys_ipc_recv system call. (Hint: does the
// sys_ipc_recv function ever actually return?)
//
// If the sender wants to send a page but the receiver isn't asking for one,
// then no page mapping is transferred, but no error occurs.
// The ipc only happens when no errors occur.
//
// Returns 0 on success, < 0 on error.
// Errors are:
// -E_BAD_ENV if environment envid doesn't currently exist.
// (No need to check permissions.)
// -E_IPC_NOT_RECV if envid is not currently blocked in sys_ipc_recv,
// or another environment managed to send first.
// -E_INVAL if srcva < UTOP but srcva is not page-aligned.
// -E_INVAL if srcva < UTOP and perm is inappropriate
// (see sys_page_alloc).
// -E_INVAL if srcva < UTOP but srcva is not mapped in the caller's
// address space.
// -E_INVAL if (perm & PTE_W), but srcva is read-only in the
// current environment's address space.
// -E_NO_MEM if there's not enough memory to map srcva in envid's
// address space.
static int sys_ipc_try_send(envid_t envid, uint32_t value, void *srcva,
unsigned perm) {
// LAB 4: Your code here.
// check for existence of env with envid
struct Env *env;
if (envid2env(envid, &env, false) < 0) {
// environment envid doesn't currently exist
return -E_BAD_ENV;
}

// check receiver's status
if (!env->env_ipc_recving) {
// envid is not currently blocked in sys_ipc_recv
// or another environment managed to send first
return -E_IPC_NOT_RECV;
}

if ((uintptr_t)srcva < UTOP) {
// srcva is valid
// check srcva is page-aligned
if (PGOFF(srcva) != 0) {
// srcva is not page-aligned
return -E_INVAL;
}
// check perm passed in
if ((perm & (~PTE_SYSCALL)) || !(perm & PTE_U) || !(perm & PTE_P)) {
// invalid perm
return -E_INVAL;
}
// check srcva actually be mapped
pte_t * pgtable_entry;
struct PageInfo *page;
if ((page = page_lookup(curenv->env_pgdir, srcva, &pgtable_entry)) ==
NULL) {
// NB: check for SRCVA here, so use curenv->env_pgdir
// srcva is not mapped at caller's address space
return -E_INVAL;
}
// check PTE_W between sender and receiver
if ((perm & PTE_W) && (!((*pgtable_entry) & PTE_W))) {
// if perm has PTE_W while srcva is read-only in
// current environment's address space
return -E_INVAL;
}

if ((uintptr_t)(env->env_ipc_dstva) < UTOP) {
// receiver also expects page to be transferred
// call page_insert to actually transer the page
if (page_insert(env->env_pgdir, page, env->env_ipc_dstva, perm) <
0) {
// out of memory when try to insert page
return -E_NO_MEM;
}
// page successfully transferred
env->env_ipc_perm = perm;
}
} else {
// srcva >= UTOP
// invalid srcva, but we won't return error here
// ipc_send will panic if we do so
}

// update receiver's information
env->env_ipc_recving = false;
env->env_ipc_from = curenv->env_id;
env->env_ipc_value = value;
// mark receiver to be runnable to match what we do in sys_ipc_recv
env->env_status = ENV_RUNNABLE;
// modify trapframe's eax register to `update' return value
env->env_tf.tf_regs.reg_eax = 0;

// panic("sys_ipc_try_send not implemented");

// ipc succeeds
return 0;
}

// In lib/ipc.c, ipc_recv():
// Receive a value via IPC and return it.
// If 'pg' is nonnull, then any page sent by the sender will be mapped at
// that address.
// If 'from_env_store' is nonnull, then store the IPC sender's envid in
// *from_env_store.
// If 'perm_store' is nonnull, then store the IPC sender's page permission
// in *perm_store (this is nonzero if a page was successfully
// transferred to 'pg').
// If the system call fails, then store 0 in *fromenv and *perm (if
// they're nonnull) and return the error.
// Otherwise, return the value sent by the sender
//
// Hint:
// Use 'thisenv' to discover the value and who sent it.
// If 'pg' is null, pass sys_ipc_recv a value that it will understand
// as meaning "no page". (Zero is not the right value, since that's
// a perfectly valid place to map a page.)
int32_t ipc_recv(envid_t *from_env_store, void *pg, int *perm_store) {
// LAB 4: Your code here.
// check pg, if pg is null, then should set it to UTOP,
// for only if dstva is below UTOP, sender knows that
// receiver wants a page to be transferred
if (!pg) { pg = (void *)UTOP; }
int r;
if ((r = sys_ipc_recv(pg)) < 0) {
// system call fails
// store from env and perm if corresponding pointer is not null
if (from_env_store) { *from_env_store = 0; }
if (perm_store) { *perm_store = 0; }
// return the error
return r;
}

// system call succeeds
// extract information from thisenv's IPC fields
if (from_env_store) {
// store IPC sender's envid into *from_env_store
*from_env_store = thisenv->env_ipc_from;
}
if (perm_store) {
// store IPC sender's page permission
*perm_store = thisenv->env_ipc_perm;
}

// panic("ipc_recv not implemented");

// return the value send by the sender
return thisenv->env_ipc_value;
}

// In kern/syscall.c, syscall():
// new code added
case SYS_ipc_recv:
// call sys_ipc_recv
return sys_ipc_recv((void *)a1);
break;
case SYS_ipc_try_send:
// call sys_ipc_try_send
return sys_ipc_try_send((envid_t)a1, (uint32_t)a2, (void *)a3,
(unsigned)a4);

// In lib/ipc.c, ipc_send():
// Send 'val' (and 'pg' with 'perm', if 'pg' is nonnull) to 'toenv'.
// This function keeps trying until it succeeds.
// It should panic() on any error other than -E_IPC_NOT_RECV.
//
// Hint:
// Use sys_yield() to be CPU-friendly.
// If 'pg' is null, pass sys_ipc_try_send a value that it will understand
// as meaning "no page". (Zero is not the right value.)
void ipc_send(envid_t to_env, uint32_t val, void *pg, int perm) {
// LAB 4: Your code here.
// check pg, if pg is null, then should set it to UTOP(invalid value
// but won't result in panic)
if (!pg) { pg = (void *)UTOP; }
int r;
while (true) {
// keeps trying until succeeds or panic
r = sys_ipc_try_send(to_env, val, pg, perm);
if (r == 0) {
// message successfully sent, return
return;
}
if (r != -E_IPC_NOT_RECV) {
// error when sending, should panic
panic("failed to send messages - %e!", r);
}
// use sys_yield to avoid waste on CPU
sys_yield();
}

// panic("ipc_send not implemented");
}

实验小结

最终执行make grade,评分脚本的输出如下:

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
41
42
43
44
45
46
47
48
49
50
51
52
dumbfork:
$ make run-dumbfork-nox-gdb QEMUEXTRA+=-snapshot
OK (1.6s)
Part A score: 5/5
faultread:
$ make run-faultread-nox-gdb QEMUEXTRA+=-snapshot
OK (1.0s)
faultwrite:
$ make run-faultwrite-nox-gdb QEMUEXTRA+=-snapshot
OK (1.0s)
faultdie:
$ make run-faultdie-nox-gdb QEMUEXTRA+=-snapshot
OK (1.0s)
faultregs:
$ make run-faultregs-nox-gdb QEMUEXTRA+=-snapshot
OK (1.1s)
faultalloc:
$ make run-faultalloc-nox-gdb QEMUEXTRA+=-snapshot
OK (4.6s)
faultallocbad:
$ make run-faultallocbad-nox-gdb QEMUEXTRA+=-snapshot
OK (1.3s)
faultnostack:
$ make run-faultnostack-nox-gdb QEMUEXTRA+=-snapshot
OK (1.8s)
faultbadhandler:
$ make run-faultbadhandler-nox-gdb QEMUEXTRA+=-snapshot
OK (1.2s)
faultevilhandler:
$ make run-faultevilhandler-nox-gdb QEMUEXTRA+=-snapshot
OK (1.0s)
forktree:
$ make run-forktree-nox-gdb QEMUEXTRA+=-snapshot
OK (1.4s)
Part B score: 50/50
spin:
$ make run-spin-nox-gdb QEMUEXTRA+=-snapshot
OK (0.7s)
stresssched:
$ make run-stresssched-nox-gdb CPUS=4 QEMUEXTRA+=-snapshot
OK (3.0s)
sendpage:
$ make run-sendpage-nox-gdb CPUS=2 QEMUEXTRA+=-snapshot
OK (1.7s)
pingpong:
$ make run-pingpong-nox-gdb CPUS=4 QEMUEXTRA+=-snapshot
OK (2.1s)
primes:
$ make run-primes-nox-gdb CPUS=4 QEMUEXTRA+=-snapshot
OK (4.5s)
Part C score: 25/25
Score: 80/80

至此,实验四结束。

第四个实验是第一到第四个实验中难度最大、代码量最多的一个实验,在前三个实验中任何的代码错误(尽管可能在当时的实验评分中没有反映出来)都会导致实验四出现难以调试的错误。

实验四中每个练习遇到的问题我都已经在报告中总结,此外,也在代码中以NB标明。其中大部分都是一些小错误,还有少部分是没有将问题考虑全面导致的。尽管如此,几乎每一个错误我都花费了至少几十分钟去调试。

操作系统的调试不仅仅困难(Triple Fault会导致不打补丁的qemu重启),任何一个小错误都会导致系统崩溃。现代操作系统如Linux的鲁棒性可见一斑。

本学期的事情比起上学期只增不减,加上课程设计只要求完成四个实验,实验四结束后,MIT-6.828系列就告一段落了。

希望我的这四篇实验报告能对正在写实验的你产生些许的启发,也欢迎找我交流相关的问题。

评论和共享

Lab3 - User Environments需要实现基本的内核功能,使得一个受保护的用户环境(进程)可以运行。在本实验中,需要让内核设置数据结构以追踪用户环境,创建一个单用户环境,加载程序镜像并执行。此外,内核还需要能处理用户环境的系统调用以及引发的异常。
2018年2月26日,完成了实验并写完了报告。

实验准备

根据官网,切换到分支lab3并且合并分支lab2。在合并的过程中,发生了冲突。

1
2
user% git checkout -b lab3 origin/lab3
user% git merge lab2

根据提示,是kern/monitor.c中发生了冲突,因为仅有一处冲突,手动编辑kern/monitor.c文件,并commit,即可解决冲突并合并分支。

实验三包括如下的新文件:

  • inc/env.h 用户模式环境的公有定义
  • inc/trap.h 陷阱处理的公有定义
  • inc/syscall.h 用户环境对内核的系统调用的公有定义
  • inc/lib.h 用户模式支持的库公有定义
  • kern/env.h 用户模式环境的内核私有定义
  • kern/env.c 用户模式环境的内核代码实现
  • kern/trap.h 内核私有的陷阱处理定义
  • kern/trap.c 陷阱处理代码
  • kern/trapentry.S 汇编语言的陷阱处理程序入口
  • kern/syscall.h 系统调用处理的内核私有定义
  • kern/syscall.c 系统调用实现代码
  • lib/Makefrag 用户模式库obj/lib/libjos.a的Makefile
  • lib/entry.S 用户环境的汇编语言入口
  • lib/libmain.c entry.S调用的用户模式库安装代码
  • lib/syscall.c 用户模式系统调用的打桩函数
  • lib/console.c 用户模式的putchar和getchar实现,提供了控制台IO
  • lib/exit.c 用户模式的exit实现
  • lib/panic.c 用户模式的panic实现
  • user/* 检验内核实验三代码的测试程序

此外,lab2中的一些文件在lab3中也被添加了新的内容,可以用git diff lab2查看具体的比较信息。

内联汇编

本实验中可能会用到GCC的内联汇编特性,应当至少理解给出的代码中的内联汇编代码片段。

实验过程

第一部分 User Environments and Exception Handling - 用户环境和错误处理

inc/env.h中给出了用户环境的基本定义。内核通过struct Env追踪每一个用户环境。本实验中只需要创建一个环境,然而你需要设计JOS内核实际支持多用户环境。实验四中,你将通过允许一个用户环境fork别的用户环境来利用多用户环境的特性。

kern/env.c中可以看到,内核管理3个与环境有关的全局变量:

1
2
3
struct Env *       envs   = NULL;    // All environments
struct Env * curenv = NULL; // The current env
static struct Env *env_free_list; // Free environment list

当JOS成功运行之后,envs指针指向一个struct Env的数组,代表了系统中所有的环境。在设计上,JOS内核允许NNEV个同时激活的环境,NNEV在inc/env.h中定义。
JOS内核使用env_free_list维护所有未激活的struct Env,这样的设计简化的环境的分配和释放,它们仅仅需要从该链表上添加或移除。
JOS内核使用curenv去追踪在任意时刻当前正在执行的环境。在启动之后到第一个环境运行的时间段中,curenv被初始化为NULL

环境状态

struct Envinc/env.h中被定义:

1
2
3
4
5
6
7
8
9
10
11
12
struct Env {
struct Trapframe env_tf; // Saved registers
struct Env *env_link; // Next free Env
envid_t env_id; // Unique environment identifier
envid_t env_parent_id; // env_id of this env's parent
enum EnvType env_type; // Indicates special system environments
unsigned env_status; // Status of the environment
uint32_t env_runs; // Number of times environment has run

// Address space
pde_t *env_pgdir; // Kernel virtual address of page dir
};

  • env_tf - struct TrapFrameinc/trap.h中被定义,表示了当环境不运行时被保存的寄存器值,主要用于上下文切换
  • env_link - 指向了env_free_list上的下一个struct Envenv_free_list指向了链表中的第一个空闲环境
  • env_id - 唯一标识当前正在使用struct Env的环境。当环境终止后,struct Env可能被内核重新分配用于另一个不同的环境,但它们的env_id是不同的
  • env_parent_id - 存储了创建该环境的环境的env_id,通过该方式构建一个环境树,用于安全方面的决策
  • env_type - 用于区分特殊环境,对于大部分环境,该值为ENV_TYPE_USER,在后续Lab中会介绍其他的值
  • env_status - 状态
    • ENV_FREE - 表明struct Env处于空闲状态,应当位于env_free_list
    • ENV_RUNNABLE - 表明struct Env代表的环境正等待运行于处理器上
    • ENV_RUNNING - 表明struct Env代表的环境为正在运行的环境
    • ENV_NOT_RUNNABLE - 表明struct Env代表了一个正在运行的环境,但却没有准备好运行,如正在等待另一个环境的IPC(进程间通信)
    • ENV_DYING - 表明struct Env代表了一个僵死环境,僵死环境将在下一次陷入内核时被释放(直到Lab4才会使用该Flag)
  • env_pgdir - 保存了环境的页目录的内核虚拟地址

JOS中环境的概念综合了“线程”和“地址空间”,“线程”由env_tf域的被保存的寄存器值定义,“地址空间”由env_pgdir域指向的页目录和页表定义。为了运行一个环境,内核必须用保存的寄存器值和合适的地址空间设置CPU。

JOS的struct Env和xv6的struct proc很像,两种结构体都持有环境的用户模式寄存器状态(通过struct TrapFrame),然而,JOS中,独立的环境并不具有不同的内核栈,因为JOS内核中同时只能有一个运行的JOS环境,因此JOS只需要一个内核栈。

分配环境数组

练习1的代码如下,仅供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//////////////////////////////////////////////////////////////////////
// Make 'envs' point to an array of size 'NENV' of 'struct Env'.
// LAB 3: Your code here.
// get size of envs
uint32_t envs_size = sizeof(struct Env) * NENV;
// use boot_alloc to allocate memory
envs = (struct Env *)boot_alloc(envs_size);
// initialization
memset(envs, 0, envs_size);

//////////////////////////////////////////////////////////////////////
// Map the 'envs' array read-only by the user at linear address UENVS
// (ie. perm = PTE_U | PTE_P).
// Permissions:
// - the new image at UENVS -- kernel R, user R
// - envs itself -- kernel RW, user NONE
// LAB 3: Your code here.
boot_map_region(kern_pgdir, UENVS, envs_size, PADDR(envs), PTE_U | PTE_P);

创建并运行环境

现在将在kern/env.c中编写必要的代码去运行用户环境。由于JOS内核还不支持文件系统,所以只能配置内核以加载一个嵌入内核中静态二进制镜像。JOS内核将这个二进制镜像以ELF可执行镜像格式嵌入。

Lab3的GNUMakefileobj/user/目录下生成了一些二进制镜像。kern/Makefrag下可以看到,链接器的-b binary选项使得这些文件以原始的未被翻译二进制文件而非普通的被编译器生成的.o文件的方式链接, 通过-b binary方式链接的二进制文件就链接器而言可以为任意类型,甚至是文本文件或是图片。

如果在构建内核后观察obj/kern/kernel.sym,可以看到链接器生成了一些“奇怪”名字的符号如_binary_obj_user_hello_start_binary_obj_user_hello_end_binary_obj_user_hello_size。链接器通过二进制文件的名字生成了这些符号的名字,这些符号使得内核代码可以以某种方式引用这些嵌入的二进制文件。

练习2中遇到的问题:

  • 除了env->env_tf.tf_eip以外不要修改其他的值,因为其已经在env_alloc()中被初始化
  • region_alloc()中笔误导致只映射了一页
  • load_icode()中需要切换页表,之后加载每段仅用一个memcpy即可实现

练习2的代码如下,仅供参考:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
// In env_init():
// LAB 3: Your code here.
ssize_t i;
// loop in reverse order to keep ascending order in env free list
for (i = NENV - 1; i >= 0; i--) {
// set env_status, env_id
envs[i].env_status = ENV_FREE;
envs[i].env_id = 0;
// set env_link and insert into env_free_list
envs[i].env_link = env_free_list;
env_free_list = &envs[i];
}

// In env_setup_vm():
// LAB 3: Your code here.
// set env_pgdir and generate page directory based on kern_pgdir
e->env_pgdir = (pde_t *)page2kva(p);
memcpy(e->env_pgdir, kern_pgdir, PGSIZE);
// increase pp_ref
p->pp_ref++;

// In region_alloc():
// LAB 3: Your code here.
// (But only if you need it for load_icode.)
//
// Hint: It is easier to use region_alloc if the caller can pass
// 'va' and 'len' values that are not page-aligned.
// You should round va down, and round (va + len) up.
// (Watch out for corner-cases!)

// round va and va + len
uintptr_t start = (uintptr_t)ROUNDDOWN(va, PGSIZE);
uintptr_t end = (uintptr_t)ROUNDUP(va + len, PGSIZE);

for (; start < end; start += PGSIZE) {
// alloc page
struct PageInfo *p;
p = page_alloc(ALLOC_ZERO);
if (!p) { panic("out of memory when allocating region!"); }
// insert page into environment's page directory
if (page_insert(e->env_pgdir, p, (char *)start, PTE_W | PTE_U | PTE_P) <
0) {
panic("out of memory when allocating region!");
}
}

// In load_icode():
// LAB 3: Your code here.
// switch address space for loading program segments
lcr3(PADDR(e->env_pgdir));

struct Elf *elf = (struct Elf *)binary;
// check elf magic
if (elf->e_magic != ELF_MAGIC) { panic("invalid elf format!"); }

// set the program entry for env
e->env_tf.tf_eip = elf->e_entry;

struct Proghdr *ph, *eph;

// get the start and end of program header entry
ph = (struct Proghdr *)(binary + elf->e_phoff);
eph = ph + elf->e_phnum;
for (; ph < eph; ph++) {
if (ph->p_type == ELF_PROG_LOAD) { // if the segment is to be loaded
// alloc corresponding region(clear zero)
region_alloc(e, (char *)ph->p_va, ph->p_memsz);
// copy from ELF header to virtual addresses directly
memcpy((char *)ph->p_va, (char *)binary + ph->p_offset, ph->p_filesz);
}
}
// switch back
lcr3(PADDR(kern_pgdir));

// Now map one page for the program's initial stack
// at virtual address USTACKTOP - PGSIZE.
// LAB 3: Your code here.
// allocate a page and insert it into env's page directory
// panic when page_alloc or page_insert failed
struct PageInfo *stack_page = page_alloc(ALLOC_ZERO);
if (!stack_page) { panic("out of memory when alloc program's stack!"); }
if (page_insert(e->env_pgdir, stack_page, (char *)(USTACKTOP - PGSIZE),
PTE_W | PTE_U | PTE_P) < 0) {
panic("failed to set program's stack!");
}

// In env_create():
// LAB 3: Your code here.
struct Env *env;
// allocate new env with parent ID 0
if (env_alloc(&env, 0) < 0) { panic("failed to allocate env!"); }
// load elf binary and set env_type
load_icode(env, binary);
env->env_type = type;

// In env_run():
// LAB 3: Your code here.
if (curenv != NULL) { // context switch
if (curenv->env_status == ENV_RUNNING) {
// change to runnable if current status is running
// for not runnable, is not necessary to do this
curenv->env_status = ENV_RUNNABLE;
}
}

// set new curenv, update status and counter
curenv = e;
e->env_status = ENV_RUNNING;
e->env_runs++;
// address space switch
// reference from inc/x86.h
lcr3(PADDR(e->env_pgdir));
// drop into user mode
env_pop_tf(&(e->env_tf));

// panic("env_run not yet implemented");

成功进入用户环境后,若用户环境尝试使用int指令进行系统调用时,将产生错误,因为JOS没有设置任何从用户空间进入内核的方式。
当CPU发现无法处理系统中断调用后,会产生一个通用保护错误,发现无法处理它,然后生成一个二重错误,最终因无法处理而生成一个三重错误并放弃,然后系统重启。但是为了便于调试,patch的qemu不会重启。
关于重启的理由可以参考这里

完成后经过测试,程序在int $0x30处Triple Fault,可知本部分代码基本正确。

处理中断和异常

练习3要求阅读Intel 80386编程手册中的第九章:异常和中断

本实验中将沿用Intel关于中断和异常的术语。中断是由处理器以外的异步事件引发的,而异常是由当前正在执行的指令同步引发的。

保护控制转移基础

异常和中断均为“保护控制转移”,会引发处理器从用户到内核模式(CPL=0),从而避免给用户模式的代码干扰内核或是其他环境的机会。这要求了引发中断或者是异常的代码不能选择进入内核的地点或方式。
在x86中,主要有两种机制一起保证了内核总是在受保护的情况下进入,这两种机制为中断描述符表(Interrupt Descriptor Table)和任务状态段(Task State Segment)。

中断描述符表中,处理器保证了异常和中断仅能导致内核在若干个具体的、由内核明确定义好的入口执行,而不是在中断和异常发生时运行的代码。
x86允许最多设立256个不同的中断或异常入口,每一个入口都具有一个中断向量。中断向量为0-255的数字,中断向量由中断来源决定:不同的设备,错误条件和应用程序对内核的请求会产生不同向量的中断。CPU使用中断向量作为处理器的中断描述符表的索引。中断描述符表在内核私有的内存中建立。
处理器从该表中加载送入EIP寄存器的值(处理程序的入口),以及送入CS段寄存器的值(包括了特权级,在JOS中,所有的异常均在内核模式执行,即特权级0)。

任务状态段。处理器需要在唤醒处理程序前将在中断或异常发生前的旧的处理器状态保存起来,如旧的EIP寄存器值和旧的CS段寄存器值,以便处理程序之后能恢复现场,从中断或异常发生的地方继续执行。然而,保存旧的处理器状态的区域必须对于非特权的用户模式代码处于被保护的状态。否则,错误的或是恶意的用户代码可能会破坏内核。
因此,当x86处理器从用户模式特权级切换到内核模式时,其也会切换到内核内存中的一个栈。任务状态段指定了相应的段寄存器以及相应栈的地址。处理器将SS, EFLAGS, CS, EIP以及一个可选的错误码压入这个新栈中。然后从中断描述符中读取相应的CSEIP,并设置ESPSS指向新的栈。
尽管TSS有着多种作用,JOS仅用它来定义从用户模式切换到内核模式时的内核栈。因为JOS中的内核模式为x86的特权级0,故处理器仅使用TSSESP0SS0域。

异常和中断的类型

x86处理器能产生的所有同步异常均使用了0-31的中断向量,映射为IDT的0-31号入口。超过31的中断向量仅供由int指令或是由异步硬件中断产生的“软中断”使用。

嵌套异常和中断

处理器能同时处理内核模式和用户模式的异常。在内核模式中的异常不需要切换栈,因此,不需要压入旧的SSESP值。通过这种方式,内核可以优雅地处理内核产生的嵌套异常和中断。该能力对于实现保护是非常重要的。

若处理器已经在内核模式中并且接受了一个异常并且无法将旧值压入内核栈中时(如内存不足),那么,处理器将无论如何也无法恢复,只能重启。内核必须设计良好以避免这种情况发生。

建立IDT表

由于JOS中使用的为IA_32的陷阱码,更推荐参考IA_32的第五章

练习4中遇到的问题如下:

  • 设置陷阱门时段选择子应当为GD_KT而不是GD_KD,因为错误处理函数均被链接至了内核的text段。
  • 已存在page_fault_handler的函数,命名时需避免重名。
  • 不能直接用立即数设置段寄存器。

练习4的代码如下,仅供参考:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// In trapentry.S:
/*
* Lab 3: Your code here for generating entry points for the different traps.
*/

TRAPHANDLER_NOEC(divide_error_handler, T_DIVIDE)
TRAPHANDLER_NOEC(debug_exception_handler, T_DEBUG)
TRAPHANDLER_NOEC(non_maskable_interrupt_handler, T_NMI)
TRAPHANDLER_NOEC(breakpoint_handler, T_BRKPT)
TRAPHANDLER_NOEC(overflow_handler, T_OFLOW)
TRAPHANDLER_NOEC(bounds_check_handler, T_BOUND)
TRAPHANDLER_NOEC(invalid_opcode_handler, T_ILLOP)
TRAPHANDLER_NOEC(device_not_available_handler, T_DEVICE)
TRAPHANDLER(double_fault_handler, T_DBLFLT)
TRAPHANDLER(invalid_tss_handler, T_TSS)
TRAPHANDLER(segment_not_present_handler, T_SEGNP)
TRAPHANDLER(stack_exception_handler, T_STACK)
TRAPHANDLER(general_protection_fault_handler, T_GPFLT)
TRAPHANDLER(pagefault_handler, T_PGFLT)
TRAPHANDLER_NOEC(floating_point_error_handler, T_FPERR)
TRAPHANDLER(alignment_check_handler, T_ALIGN)
TRAPHANDLER_NOEC(machine_check_handler, T_MCHK)
TRAPHANDLER_NOEC(simd_floating_point_error_handler, T_SIMDERR)

/*
* Lab 3: Your code here for _alltraps
*/

_alltraps:
// push ds and es and general registers
push %ds
push %es
pushal

// load ds and es with GD_KD, for kernel stack locates in data
mov $GD_KD, %ax
mov %ax, %ds
mov %ax, %es

// pass tf as an argument
pushl %esp

// call trap and no need to return
call trap

// In trap.c, trap_init():

// LAB 3: Your code here.
// declare of exception handler
void divide_error_handler();
void debug_exception_handler();
void non_maskable_interrupt_handler();
void breakpoint_handler();
void overflow_handler();
void bounds_check_handler();
void invalid_opcode_handler();
void device_not_available_handler();
void double_fault_handler();
void invalid_tss_handler();
void segment_not_present_handler();
void stack_exception_handler();
void general_protection_fault_handler();
void pagefault_handler();
void floating_point_error_handler();
void alignment_check_handler();
void machine_check_handler();
void simd_floating_point_error_handler();

// set up trap gate descriptor
SETGATE(idt[T_DIVIDE], 1, GD_KT, divide_error_handler, 0);
SETGATE(idt[T_DEBUG], 1, GD_KT, debug_exception_handler, 0);
SETGATE(idt[T_NMI], 1, GD_KT, non_maskable_interrupt_handler, 0);
SETGATE(idt[T_BRKPT], 1, GD_KT, breakpoint_handler, 3);
SETGATE(idt[T_OFLOW], 1, GD_KT, overflow_handler, 0);
SETGATE(idt[T_BOUND], 1, GD_KT, bounds_check_handler, 0);
SETGATE(idt[T_ILLOP], 1, GD_KT, invalid_opcode_handler, 0);
SETGATE(idt[T_DEVICE], 1, GD_KT, device_not_available_handler, 0);
SETGATE(idt[T_DBLFLT], 1, GD_KT, double_fault_handler, 0);
SETGATE(idt[T_TSS], 1, GD_KT, invalid_tss_handler, 0);
SETGATE(idt[T_SEGNP], 1, GD_KT, segment_not_present_handler, 0);
SETGATE(idt[T_STACK], 1, GD_KT, stack_exception_handler, 0);
SETGATE(idt[T_GPFLT], 1, GD_KT, general_protection_fault_handler, 0);
SETGATE(idt[T_PGFLT], 1, GD_KT, pagefault_handler, 0);
SETGATE(idt[T_FPERR], 1, GD_KT, floating_point_error_handler, 0);
SETGATE(idt[T_ALIGN], 1, GD_KT, alignment_check_handler, 0);
SETGATE(idt[T_MCHK], 1, GD_KT, machine_check_handler, 0);
SETGATE(idt[T_SIMDERR], 1, GD_KT, simd_floating_point_error_handler, 0);

问题1:
若是使用同一个处理程序,将无法限制调用错误处理程序的代码的特权级,也无法得知中断向量的值。

问题2:
仅有内核代码允许执行页错误处理程序,尽管调用了int $14,仍然因为保护机制而生成了中断向量13。如果内核允许int $14唤醒页错误处理程序,那么恶意的程序可以因此而随意触发缺页错误,导致系统无法正常工作。

第二部分 Page Faults, Breakpoints Exceptions, and System Calls - 页错误,断点异常和系统调用

处理页错误

页错误的中断向量号为14,是非常重要的异常。当处理器接收一个页错误时,会将引发页错误的线性地址存储在处理器控制寄存器CR2中。

练习5的代码如下,仅供参考:

1
2
3
4
5
// In trap_dispatch():
if (tf->tf_trapno == T_PGFLT) {
// dispatch page fault exceptions
page_fault_handler(tf);
}

断点异常

断点异常的中断向量号为3,通常被调试器用作向程序代码中添加断点,原理是将程序中的某一条指令暂时改为1字节的int3的软中断指令。在JOS中,将大量使用这一异常来实现一个原始的伪系统调用,使得用户环境可以使用它来环境JOS内核监视器,可以把内核监视器看做一个原始的调试器。
用户模式的panic,就是通过显示panic消息后执行int3实现的。

练习6的代码如下,仅供参考:

1
2
3
4
if (tf->tf_trapno == T_BRKPT) {
// dispatch breakpoint exceptions
monitor(tf);
}

问题3:
若是用特权级0初始化断点异常的IDT,那么会触发通用保护错误,这是因为用户模式的代码无法执行特权级0(内核模式)的处理程序,需要用特权级3初始化断点异常的IDT,这样才能使得断点测试正确通过。

问题4:
这些措施都是为了保护内核和用户环境的相互独立,使得用户环境仅能在收到允许的情况下执行某些内核的代码,保证了恶意程序不会破坏内核,窃取数据。同时也能保证用户环境能从内核得到必要的功能支持。

系统调用

用户进程通过系统调用请求内核为它工作。当用户进程唤醒系统调用时,处理器进入内核模式,处理器和内核合作保存用户进程状态,内核执行合适的代码完成系统调用,并恢复至用户进程。系统调用的具体实现随平台不同而不同。

JOS内核使用int $0x30作为系统调用。需要建立相关的中断描述符,注意中断向量0x30不可能由硬件生成,毫无疑问应该允许用户执行对应的处理程序。

应用程序会将系统调用号和系统调用参数存入寄存器中。避免了内核访问用户环境栈或是指令流。系统调用号放在寄存器%eax中,最多五个参数分别被相应地放在寄存器%edx, %ecx, %ebx, %edi%esi中。内核将返回值放在寄存器%eax中。唤醒系统调用的汇编代码已经提供为lib/syscall.c中的syscall()

练习7中遇到的问题如下:

  • syscall作为软中断不会压入错误码
  • 调用syscall函数时应当使用保存的栈帧中的寄存器值而非实际的寄存器值,原因是在函数调用间某些寄存器的值会发生改变
  • 练习7需要参考lib/syscall.c中得知参数的位置关系

练习7的代码如下,仅供参考:

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
// In trapentry.S:
TRAPHANDLER_NOEC(syscall_handler, T_SYSCALL)

// In trap.c, trap_init():
void syscall_handler();

SETGATE(idt[T_SYSCALL], 1, GD_KT, syscall_handler, 3);

// In kern/syscall.c, syscall():
// LAB 3: Your code here.

// panic("syscall not implemented");

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

// will not reach here
return -E_UNSPECIFIED;

用户模式启动

一个用户在lib/entry.S的顶部开始运行,经过某些设置后,代码调用lib/libmain.c。你应当修改libmain()以初始化指向当前环境的struct Env指针thisenv(注意到lib/entry.S已经定义了指向你在第一部分映射的UENVSenvs)。

libmain()然后调用umain,对于hello程序而言,位于user/hello.c中。在打印hello, world后,它尝试访问thisenv->env_id。这也是为什么hello程序会出现fault

练习8中遇到的问题如下:

  • 可以使用宏ENVXenvid得到envenvs中的偏移量,而无需遍历整个envs
  • Lab2中pgdir_walk未设置PTE_U导致访问时出现了页错误(仅有PTE项中设置PTE_U是不足的)。

练习8的答案如下,仅供参考:

1
2
3
4
// In libmain.c, libmain():
// LAB 3: Your code here.
// get env id use system call and use ENVX to get index
thisenv = envs + ENVX(sys_getenvid());

页错误和内存保护

内存保护是操作系统的重要特性,保证了一个程序的错误不会毁坏内核或是其他的程序。
操作系统通常和硬件一起实现内存保护。操作系统负责告知硬件哪些虚拟地址是有效的、哪些是无效的。当一个程序试图去访问一个无效的地址或是一个没有权限的地址时,处理器在引起错误的指令处停止程序,然后带着相应的信息陷入内核。若错误可修复,则内核修复该错误并继续执行程序;若错误不可恢复,则程序无法继续运行。
现在考虑系统调用,系统调用允许用户程序向内核传递指针,内核在处理系统调用时将指针解引用,会出现以下两种问题:

  1. 内核的缺页错误潜在地比用户程序的缺页更加严重。若内核在操作私有的数据结构时发生缺页,那么将是内核自己的漏洞,错误处理程序应当panic内核。然而当内核解引用用户程序传递的指针时,需要某种方式标记由解引用导致的缺页实际上代表的是用户程序的利益。
  2. 内核比用户程序具有更多的权限。在这种情况下,用户程序可能传递给内核一个指针,该指针指向的内存只能被内核读写而不能被用户程序读写。在这种情况下,内核不能对该指针解引用,这么做会暴露私有数据或是破坏内核完整性。

当内核处理用户程序传递的指针时必须非常小心。内核必须检查用户传入的指针。

练习9和练习10遇到的问题如下:

  • 需要获取段寄存器的最低两位以得到段特权级

练习9和练习10的答案如下,仅供参考:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// In kern/trap.c, page_fault_handler():
// LAB 3: Your code here.
if ((tf->tf_cs & 3) == 0) {
// code that causes page fault in kernel mode
panic("page fault in kernel!");
}

// In kern/pmap.c, user_mem_check():
int user_mem_check(struct Env *env, const void *va, size_t len, int perm) {
// LAB 3: Your code here.
if ((uintptr_t)va >= ULIM) {
// condition 1 - below ULIM violated
user_mem_check_addr = (uintptr_t)va;
return -E_FAULT;
}

uintptr_t va_start = (uintptr_t)ROUNDDOWN(va, PGSIZE);
uintptr_t va_end = (uintptr_t)ROUNDUP(va + len, PGSIZE);

for (; va_start < va_end; va_start += PGSIZE) {
// note we set page directory entry with less restrict
// we will only test page table entry here
pte_t *pgtable_entry_ptr =
pgdir_walk(env->env_pgdir, (char *)va_start, false);
if ((*pgtable_entry_ptr & (perm | PTE_P)) != (perm | PTE_P)) {
// condition 2 - permission violated
if (va_start <= (uintptr_t)va) {
// va lie in the first page and not aligned, return va
user_mem_check_addr = (uintptr_t)va;
} else if (va_start >= (uintptr_t)va + len) {
// va lie in the last page and exceed va + len, return va + len
user_mem_check_addr = (uintptr_t)va + len;
} else {
// return corresponding page's initial address
user_mem_check_addr = va_start;
}

return -E_FAULT;
}
}

// pass user memory check
return 0;
}

// In kern/syscall.c, syscall():
case SYS_cputs:
// checks memory before use sys_cputs to dereference a1
user_mem_assert(curenv, (char *)a1, (size_t)a2, PTE_U);
// call sys_cputs
sys_cputs((const char *)a1, (size_t)a2);
break;

// In kern/kdebug.c, debuginfo_eip():
// 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;
}

// Make sure the STABS and string table memory is valid.
// LAB 3: Your code here.
if ((user_mem_check(curenv, stabs, stab_end - stabs, PTE_U) < 0) ||
(user_mem_check(curenv, stabstr, stabstr_end - stabstr, PTE_U) < 0)) {
return -1;
}

发生了页错误的原因还要观察memlayout.h,在USTACKTOP上方有一个位于0xeebfd000Empty Memory,该虚拟地址没有映射任何的物理页。而在mon_backtrace的最后,访问到了位于此处的虚拟地址,因而导致了一个不可处理的页错误。

实验小结

最终执行make grade,评分脚本的输出如下:

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
41
42
divzero:
$ make run-divzero-nox-gdb QEMUEXTRA+=-snapshot
OK (1.3s)
softint:
$ make run-softint-nox-gdb QEMUEXTRA+=-snapshot
OK (1.0s)
badsegment:
$ make run-badsegment-nox-gdb QEMUEXTRA+=-snapshot
OK (1.0s)
Part A score: 30/30
faultread:
$ make run-faultread-nox-gdb QEMUEXTRA+=-snapshot
OK (1.0s)
faultreadkernel:
$ make run-faultreadkernel-nox-gdb QEMUEXTRA+=-snapshot
OK (0.9s)
faultwrite:
$ make run-faultwrite-nox-gdb QEMUEXTRA+=-snapshot
OK (1.0s)
faultwritekernel:
$ make run-faultwritekernel-nox-gdb QEMUEXTRA+=-snapshot
OK (1.0s)
breakpoint:
$ make run-breakpoint-nox-gdb QEMUEXTRA+=-snapshot
OK (2.0s)
testbss:
$ make run-testbss-nox-gdb QEMUEXTRA+=-snapshot
OK (2.1s)
hello:
$ make run-hello-nox-gdb QEMUEXTRA+=-snapshot
OK (1.0s)
buggyhello:
$ make run-buggyhello-nox-gdb QEMUEXTRA+=-snapshot
OK (1.8s)
buggyhello2:
$ make run-buggyhello2-nox-gdb QEMUEXTRA+=-snapshot
OK (2.1s)
evilhello:
$ make run-evilhello-nox-gdb QEMUEXTRA+=-snapshot
OK (1.1s)
Part B score: 50/50
Score: 80/80

至此,实验三结束。

第三个实验的难度明显比实验一和实验二大。
用户环境的管理相对简单,而设置陷阱和中断的部分涉及大量的x86硬件知识,需要大量参考Intel手册,要求直接写汇编代码设置栈帧也颇有挑战。

评论和共享

寒假在家终于有时间继续处理上学期没有做完的Labs,虽然效率偏低但总不是完全没有进展。
Lab2 - Memory Management主要包括了操作系统的内存管理,具体来说可以分为两部分。第一部分需要编写内核的物理内存分配器(Physical memory allocator),这会允许内核分配和释放内存。第二部分需要修改JOS的代码去实现一个虚拟内存,将虚拟地址映射到物理内存中。
2018年2月23日,完成了实验并写完了报告。

实验准备

根据实验官网的提示,依次切换到分支lab2并且合并分支lab1,在合并的过程中,没有发生冲突。

1
2
user% git checkout -b lab2 origin/lab2
user% git merge lab1

实验二包括如下的新文件:

  • inc/memlayout.h 描述了虚拟地址空间的结构
  • kern/pmap.c 需要修改、添加代码以完成实验
  • kern/pmap.h 定义了PageInfo结构 用来管理物理页状态
  • kern/kclock.h
  • kern/kclock.c 操作电池供电的时钟以及CMOS RAM硬件

实验过程

第一部分 Physical Page Management - 物理页管理

操作系统必须追踪物理内存以得知哪些内存是空闲的,以及哪些内存是正在被使用的。JOS使用页粒度管理PC的物理内存以便利用MMU映射和保护每一片被分配的内存。

实验的第一部分需要完成一个物理页面分配器,该分配器维护了一个struct PageInfo的链表来追踪空闲的物理页面(也就是空闲链表),空闲链表已经在CSAPP中有所了解,这里不再赘述。

具体来说,需要完成kern/pmap.c中实现以下的函数:

  • boot_alloc()
  • mem_init() (完成直到check_page_free_list(1)为止)
  • page_init()
  • page_alloc()
  • page_free()

完成后可以启动JOS去观察check_page_free_list()以及check_page_alloc()函数是否成功以检查编写的分配器的正确性。

第一部分的物理页面分配器总体来说难度不大,因为编写的分配器不像CSAPP中的malloc()一样,需要处理空闲内存的合并以及拆分,分配器只需要以物理页面为固定的粒度管理所有的内存即可。

在实际的Linux系统中,内核使用Buddy System快速地管理内存。

练习1的代码如下,仅供参考:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
// In boot_alloc():
// Allocate a chunk large enough to hold 'n' bytes, then update
// nextfree. Make sure nextfree is kept aligned
// to a multiple of PGSIZE.
//
// LAB 2: Your code here.
if (n == 0) {
// return the address of next free page
return nextfree;
} else if (n > 0) {
// round up size to a multiple of PGSIZE
size_t size = ROUNDUP(n, PGSIZE);
char * addr = nextfree;
// update address of next free page(still multiple of PGSIZE)
nextfree += size;
// nextfree is a kernel virtual address, out out memory when it exceeds 4 MB
if (nextfree >= (char *)KADDR(0x400000)) {
panic("out of memory when booting!");
}
// return address
return addr;
}

// In mem_init():
//////////////////////////////////////////////////////////////////////
// Allocate an array of npages 'struct PageInfo's and store it in 'pages'.
// The kernel uses this array to keep track of physical pages: for
// each physical page, there is a corresponding struct PageInfo in this
// array. 'npages' is the number of physical pages in memory. Use memset
// to initialize all fields of each struct PageInfo to 0.
// Your code goes here:
// get size of pages
uint32_t pages_size = sizeof(struct PageInfo) * npages;
// use boot_alloc to allocate memory
pages = (struct PageInfo *) boot_alloc(pages_size);
// initialization
memset(pages, 0, pages_size);

// In page_init():
size_t i;

// initialize from page 1 to page npages_basemem - 1
// set pp_ref to 0, set pp_link to last page_free_list
// and then update page_free_list
for(i = 1 ; i < npages_basemem ; i++) {
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}

// calculate next free page and initialize corresponding pages entry
// convert from kernel virtual address to physical address
size_t next_free_page = PADDR(boot_alloc(0)) / PGSIZE;
pages[next_free_page].pp_link = &pages[npages_basemem - 1];
page_free_list = &pages[next_free_page];

// initialize remaining pages entry
for (i = next_free_page + 1 ; i < npages ; i++) {
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}

// In page_alloc():
struct PageInfo *
page_alloc(int alloc_flags)
{
// get first element in free list
struct PageInfo * page_ptr = page_free_list;
if (page_ptr != NULL) {
// NOT out of memory
// update free list
page_free_list = page_free_list->pp_link;
// get kernal virtual address from given page
char * addr = page2kva(page_ptr);
// set pp_link to NULL to allow double-free bugs check
page_ptr->pp_link = NULL;
// ALLOC_ZERO set, use memset to initialze physical page
if (alloc_flags & ALLOC_ZERO) {
memset(addr, 0, PGSIZE);
}

// return page
return page_ptr;
}

return NULL;
}

// In page_free():
void
page_free(struct PageInfo *pp)
{
// Fill this function in
// Hint: You may want to panic if pp->pp_ref is nonzero or
// pp->pp_link is not NULL.

//check pp_ref and pp_link
if (pp->pp_ref != 0) {
panic("page's reference count is non-zero!");
}
if (pp->pp_link != NULL) {
panic("double free error when free page!");
}

// return page to free list
pp->pp_link = page_free_list;
page_free_list = pp;
}

然后启动JOS,在终端观察到如下输出:

1
2
3
Physical memory: 131072K available, base = 640K, extended = 130432K
check_page_free_list() succeeded!
check_page_alloc() succeeded!

证明内存分配器工作正常。

第二部分 Virtual Memory - 虚拟内存

练习2:阅读Intel 80386 Reference Manual的第五章和第六章。主要阅读页翻译和页保护机制的部分。同时,也要对x86的分段机制有所了解。

虚拟地址、线性地址和物理地址

在x86中,虚拟地址由段选择子和段内偏移组成,通过段翻译机制,得到线性地址。线性地址经过页翻译机制,得到物理地址。物理地址就是实际的内存位置。

在实验1中,已经将线性地址0-4M映射到了虚拟地址0-4MKERNBASE-KERNBASE+4M。在本实验中,要将物理内存的低256M映射到从KERNBASE开始的虚拟地址。此外,还要映射其他几个虚拟地址空间。(KERNBASE0xf0000000

练习3:在QEMU监视器中使用xp命令检查物理内存0x00100000处的字,在gdb中使用x命令检查虚拟内存0xf0100000处的字,观察结果:

1
2
3
4
(qemu) xp 0x00100000
0000000000100000: 0x1badb002
(gdb) x/x 0xf0100000
0xf0100000 <_start+4026531828>: 0x1badb002

可以得出,物理地址0x00100000和虚拟地址0xf0100000上具有相同的数据。

info pginfo mem的输出如下:

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
(qemu) info pg
VPN range Entry Flags Physical page
[00000-003ff] PDE[000] ----A----P
[00000-00000] PTE[000] --------WP 00000
[00001-0009f] PTE[001-09f] ---DA---WP 00001-0009f
[000a0-000b7] PTE[0a0-0b7] --------WP 000a0-000b7
[000b8-000b8] PTE[0b8] ---DA---WP 000b8
[000b9-000ff] PTE[0b9-0ff] --------WP 000b9-000ff
[00100-00103] PTE[100-103] ----A---WP 00100-00103
[00104-00119] PTE[104-119] --------WP 00104-00119
[0011a-0011a] PTE[11a] ---DA---WP 0011a
[0011b-0011c] PTE[11b-11c] --------WP 0011b-0011c
[0011d-003ff] PTE[11d-3ff] ---DA---WP 0011d-003ff
[f0000-f03ff] PDE[3c0] ----A---WP
[f0000-f0000] PTE[000] --------WP 00000
[f0001-f009f] PTE[001-09f] ---DA---WP 00001-0009f
[f00a0-f00b7] PTE[0a0-0b7] --------WP 000a0-000b7
[f00b8-f00b8] PTE[0b8] ---DA---WP 000b8
[f00b9-f00ff] PTE[0b9-0ff] --------WP 000b9-000ff
[f0100-f0103] PTE[100-103] ----A---WP 00100-00103
[f0104-f0119] PTE[104-119] --------WP 00104-00119
[f011a-f011a] PTE[11a] ---DA---WP 0011a
[f011b-f011c] PTE[11b-11c] --------WP 0011b-0011c
[f011d-f03ff] PTE[11d-3ff] ---DA---WP 0011d-003ff

(qemu) info mem
0000000000000000-0000000000400000 0000000000400000 -r-
00000000f0000000-00000000f0400000 0000000000400000 -rw

在JOS内核中,uintptr_t代表虚拟地址,physaddr_t代表物理地址,它们都只是uint32_t的别名。因此,不能直接对其(int类型)解引用,需要先将其转换成指针类型。
对物理地址的解引用是错误的,因为MMU会将其当做虚拟地址,你将无法得到正确的内存内容。

问题1:
x的类型应当为uintptr_t,因为对其进行了解引用操作,所以x应当为虚拟地址。

有些时候,内核必须要处理物理地址,这也是要把0-4M映射到KERNBASE-KERNBASE+4M的理由。
为了能使内核读写物理地址,必须要加上KERNBASE,可以通过宏KADDR(pa)实现。
同理,有时候需要能通过虚拟地址找到物理地址,需要减去KERNBASE,可以通过宏PADDR(va)实现。

引用计数

在未来的实验中很可能将同样的物理页映射到多个虚拟地址,你需要在struct PageInfopp_ref域中保持物理页被引用的次数,当引用计数归0,页面不再被使用,可以被释放。
总的来说,引用计数应当等于物理页面在UTOP之下出现的次数(UTOP之上的物理页面大多在启动时被内核分配并且永远不应当被释放,所以不需要对其进行引用计数)。
引用计数也会被用来追踪指向页目录页的指针数,以及页目录对页表页的引用数。

在使用page_alloc()时应当小心,返回页的pp_ref应当很快被递增,如被别的函数如(page_insert())递增,在某些情况下,调用page_insert()的函数必须手动增加引用计数。

页表管理

需要编写管理页表的例程:插入和删除线性地址到物理地址的映射,当需要时创建页表等。

在练习4中遇到的问题总结如下:

  • pgdir_walk在设置页表项时应当使用物理地址,在返回指针时应当返回虚拟地址。这是因为此时实际的二级页表还未被设置应用,且0-4M被映射为只读,而KERNBASE-KERNBASE+4M被映射为可读可写。需要使用给出的宏而不要自己加减
  • 将PTE_P笔误成PTE_G
  • 在page_insert中提前将引用计数pp_ref自增就可以避免边界情况的错误
  • pgdir_walk中对于页目录的设置可以更宽松,否则可能会对之后的实验产生影响

练习4的代码如下,仅供参考:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
// In pgdir_walk():
pte_t *pgdir_walk(pde_t *pgdir, const void *va, int create) {
// get page directory index and page directory entry
size_t pgdir_index = PDX(va);
pde_t pgdir_entry = pgdir[pgdir_index];
// get page table index
size_t pgtable_index = PTX(va);
if ((pgdir_entry & PTE_P) == 1) {
// page table page exists
// get address of page table
pte_t *pgtable = KADDR(PTE_ADDR(pgdir_entry));
return (pte_t *)(pgtable + pgtable_index);
} else {
// page table page doesn't exist
if (create == false) {
// create flag is false, returns NULL
return NULL;
} else {
// allocate a new page table page
struct PageInfo *page = page_alloc(ALLOC_ZERO);
if (page == NULL) {
// allocation fails
return NULL;
} else {
// increase physical page's reference count
page->pp_ref++;
// get physical address of physical page
physaddr_t page_pa = page2pa(page);
// insert page table into page directory
pgdir[pgdir_index] = page_pa | PTE_W | PTE_U | PTE_P;
// NB: returns KERNEL VIRTUAL ADDRESS here, for 0-4M is NOT
// writable
return (pte_t *)page2kva(page) + pgtable_index;
}
}
}
return NULL;
}

// In boot_map_region():
static void boot_map_region(pde_t *pgdir, uintptr_t va, size_t size,
physaddr_t pa, int perm) {
// round up size
size = ROUNDUP(size, PGSIZE);
// get the number of pages to be mapped
size_t page_num = PGNUM(size);

size_t i;
for (i = 0; i < page_num; i++) {
// iterate through to get each pte_t * through pgdir_walk given virtual
// address
pte_t *pgtable_entry_ptr =
pgdir_walk(pgdir, (char *)(va + i * PGSIZE), true);
// set pte_t according to physical address and permission flags
*pgtable_entry_ptr = (pa + i * PGSIZE) | perm | PTE_P;
}
}

// In page_lookup():
struct PageInfo *page_lookup(pde_t *pgdir, void *va, pte_t **pte_store) {
// Fill this function in
pte_t *pgtable_entry_ptr = pgdir_walk(pgdir, va, false);
if (pgtable_entry_ptr && ((*pgtable_entry_ptr) & PTE_P) == 1) {
if (pte_store != NULL) {
// pte_store not zero, store the address of pte of the page in it
*pte_store = pgtable_entry_ptr;
}
// converts from pa to page and returns
physaddr_t pa = PTE_ADDR(*pgtable_entry_ptr);
return pa2page(pa);
} else {
// no page mapped at va
return NULL;
}
return NULL;
}

// In page_remove():
void page_remove(pde_t *pgdir, void *va) {
// Fill this function in
pte_t *pgtable_entry_ptr = NULL;
// use page_lookup to get struct PageInfo
struct PageInfo *page = page_lookup(pgdir, va, &pgtable_entry_ptr);
if (page && pgtable_entry_ptr) {
// if page mapped at va
// decrease and try to free page
page_decref(page);
// set page table entry to 0
*pgtable_entry_ptr = 0;
// invalidate tlb
tlb_invalidate(pgdir, va);
}
}

// In page_insert():
int page_insert(pde_t *pgdir, struct PageInfo *pp, void *va, int perm) {
// Fill this function in
// try to get pointer to page table entry, created if needed
pte_t *pgtable_entry_ptr = pgdir_walk(pgdir, va, true);
if (!pgtable_entry_ptr) {
// page table cannot be allocated
return -E_NO_MEM;
}

// increase reference ahead of insertion to process CORNER CASE
pp->pp_ref++;

if (((*pgtable_entry_ptr) & PTE_P) != 0) {
// exists page mapped at va
// remove page and invalidate tlb
page_remove(pgdir, va);
}

// get physical address of page
physaddr_t pa = page2pa(pp);
// insert page into page table
*pgtable_entry_ptr = pa | perm | PTE_P;

// update permission flags of corresponding page directory
pgdir[PDX(va)] = pgdir[PDX(va)] | perm;

return 0;
}

第三部分 Kernel Address Space - 内核地址空间

JOS将32位的地址空间划为了两部分——用户环境(在Lab3中加载和运行)将控制低地址空间、而内核总是完全地控制高地址空间,由ULIM显式地划分地址空间。内核将占据约256M的地址空间。

尽管内核和用户内存位于同一地址空间,仍需要在x86的页表中使用权限位去保证用户代码仅能访问低地址空间。否则用户代码可能会覆写内核数据,导致故障。此外,用户代码还可能能从窃取其他用户、内核的私有数据。
在JOS中,用户环境将不具有ULIM以上任何内存的权限;只有内核可以读写这些内存。对于[UTOP, ULIM),内核和用户都仅能读而不能写,该段内存主要用于向用户暴露某些内核的只读数据结构;最后,UTOP以下的地址空间由用户环境使用,由用户环境自行设置这些内存的权限。

练习5的代码如下,仅供参考:

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
//////////////////////////////////////////////////////////////////////
// Now we set up virtual memory

//////////////////////////////////////////////////////////////////////
// Map 'pages' read-only by the user at linear address UPAGES
// Permissions:
// - the new image at UPAGES -- kernel R, user R
// (ie. perm = PTE_U | PTE_P)
// - pages itself -- kernel RW, user NONE
// Your code goes here:
boot_map_region(kern_pgdir, UPAGES, pages_size, PADDR(pages), PTE_U | PTE_P);

//////////////////////////////////////////////////////////////////////
// Use the physical memory that 'bootstack' refers to as the kernel
// stack. The kernel stack grows down from virtual address KSTACKTOP.
// We consider the entire range from [KSTACKTOP-PTSIZE, KSTACKTOP)
// to be the kernel stack, but break this into two pieces:
// * [KSTACKTOP-KSTKSIZE, KSTACKTOP) -- backed by physical memory
// * [KSTACKTOP-PTSIZE, KSTACKTOP-KSTKSIZE) -- not backed; so if
// the kernel overflows its stack, it will fault rather than
// overwrite memory. Known as a "guard page".
// Permissions: kernel RW, user NONE
// Your code goes here:
boot_map_region(kern_pgdir, KSTACKTOP - KSTKSIZE, KSTKSIZE, PADDR(bootstack),
PTE_W | PTE_P);

//////////////////////////////////////////////////////////////////////
// Map all of physical memory at KERNBASE.
// Ie. the VA range [KERNBASE, 2^32) should map to
// the PA range [0, 2^32 - KERNBASE)
// We might not have 2^32 - KERNBASE bytes of physical memory, but
// we just set up the mapping anyway.
// Permissions: kernel RW, user NONE
// Your code goes here:
boot_map_region(kern_pgdir, KERNBASE, 0xffffffff - KERNBASE, 0, PTE_W | PTE_P);

问题2:

Enrty Base Virtual Address Points to (logically)
1023 0xFFC00000 Physical Memory
……….
960 0xF0000000 First 4 MB on Physical Memory
959 0xEFC00000 Kernel Stack
957 0xEF400000 Kernel Page Directory
956 0xEF000000 Pages

问题3:
页保护机制实现了这一点,用户将不能访问未设置PTE_U的页(对于页目录和页表皆是如此)。

问题4:
注意到pages最多只有4MB,而一个struct PageInfo为8字节(对齐),所以实际上最多只有512K个项,总大小为2GB。

问题5:
使用了页表来管理虚拟内存,页表完整地映射了4GB的地址空间。使用pages追踪物理页面的情况,由于最初只映射了4MB,使得内存被限制在了2GB。

问题6:
执行转换的代码在entry.S中,和32位保护模式时相似:

1
2
mov	$relocated, %eax
jmp *%eax

实际的跳转是通过一次jmp实现的,得以继续执行的原因是引导时不仅将0-4M映射到了KERNBASE-KERNBASE+4M的读写,同时也映射到了0-4M的只读。不这么做的话将出现无法处理的SIGTRAP信号而导致系统Triple Fault。
不通过转换就无法在高地址执行内核代码,将内核映射在高地址主要是为了完整地映射内核的约256M空间。

实验小结

最终执行make grade,评分脚本的输出如下:

1
2
3
4
5
6
7
8
running JOS:
$ make qemu-nox-gdb
(1.3s)
Physical page allocator: OK
Page management: OK
Kernel page directory: OK
Page management 2: OK
Score: 70/70

至此,实验二结束。

第二个实验主要关注于操作系统的内存管理的基本概念,使用的也是简单的分配器,但是完整地描述了操作系统从引导直至内核初始化完毕是如何一步步设置页表,管理内存的。

由于已经在CSAPP(CMU 15213)中接触过相关的内存概念,本实验难度并不是很大。

评论和共享

前言

MIT6.828操作系统工程Lab1-Booting a PC实验报告中,出现了高版本gcc(> 7.1.1-3)编译出的JOS内核不能使用的问题(Triple Fault)。此外,gcc无法编译生成32位的JOS内核。因此需要手动降级到低版本并且开启gcc-multilib。

根据arch-dev-public,gcc-multilib已经被合并至了core/gcc之中。当然仍然可以通过Arch Linux Archive获取旧版本的gcc-multilib。

然而Arch是一个崇尚“Modernity”的发行版(参考Arch之道):

Arch尽全力保持软件处于最新的稳定版本,只要不出现系统软件包破损,都尽量用最新版本。

Arch Linux strives to maintain the latest stable release versions of its software as long as systemic package breakage can be reasonably avoided.

综合arch-dev-public对于gcc所做的改动和Arch之道,保持系统gcc为旧版本的gcc-multilib显然不是一个“优雅”的做法。最终,我决定按照6.828 Lab Tools的指导,手动编译gcc 4.6.1作为JOS内核的交叉编译工具链。

问题

编译gcc 4.6.1中遇到的问题以及解决方案总结如下:

  1. 网站上给出的部分软件包的下载链接已经过期。
  2. 网站上的教程默认将工具链安装在了/usr/local/下,这里为了管理上的方便没有采用默认的做法,而是将gcc直接安装在了home目录下(通过configure时的–prefix参数指定)。
  3. 接2,使用./comfigure –help获取帮助。在某些包configure时,需要使用如–with-mpc=… –with-gmp=…等参数来告知相应包的位置。
  4. 由于texinfo版本过高导致低版本的gcc不能正确编译,需要在configure时加上MAKEINFO=missing(当然,你也可以降级texinfo)。
  5. gcc 4.6.1的源码存在一个作用域错误问题。找到gcc/gengtype.c的write_field_root函数,将struct pair new;移动到函数的起始位置。
  6. 编译gcc时出现了libmpc.so.2库找不到的问题。需要修改环境变量LD_LIBRARY_PATH并添加相应的目录,或者也可以在configure的时候用参数–libdir指定库位置。
  7. gcc 4.6.1的源码存在一个数组访问越界错误,具体参考这里

Modules

包管理软件与环境变量

提一个简单的问题,当我们使用包管理软件(例如pacman)安装某个包(例如unzip)之后,在终端中输入unzip …即可解压zip文件。那么包管理软件究竟做了什么呢?

如果是从yaourt安装AUR的包,情况又会发生什么变化呢?

包管理软件主要管理各种包以及处理包与包之间的依赖问题。在安装包的时候,包管理软件从镜像源获取包,将包解压并且将内容复制到系统对应的位置。
诸如yaourt这样的包管理软件使用ABS(Arch Build System),根据pkgbuild文件的指导在本机下载源码、编译并打包,并且将生成的包安装到系统中。

以unzip-natspec为例(这是一个可以识别zip编码格式的unzip版本),我们使用pacman查看包的内容以及对应的位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pacman -Ql unzip-natspec                       
unzip-natspec /usr/
unzip-natspec /usr/bin/
unzip-natspec /usr/bin/funzip
unzip-natspec /usr/bin/unzip
unzip-natspec /usr/bin/unzipsfx
unzip-natspec /usr/bin/zipgrep
unzip-natspec /usr/bin/zipinfo
unzip-natspec /usr/share/
unzip-natspec /usr/share/licenses/
unzip-natspec /usr/share/licenses/unzip/
unzip-natspec /usr/share/licenses/unzip/LICENSE
unzip-natspec /usr/share/man/
unzip-natspec /usr/share/man/man1/
unzip-natspec /usr/share/man/man1/funzip.1.gz
unzip-natspec /usr/share/man/man1/unzip.1.gz
unzip-natspec /usr/share/man/man1/unzipsfx.1.gz
unzip-natspec /usr/share/man/man1/zipgrep.1.g

可以看出unzip被放在/usr/bin目录下。可是为什么我们可以直接在终端中输入unzip …执行解压的命令而不是输入/usr/bin/unzip …呢?这就涉及到了环境变量PATH。

环境变量是一个动态命名的值,用来影响计算机上进程运行的行为。PATH设置了一组包含可执行文件的目录。在输入unzip的时候,终端默认会在PATH所包含的目录中寻找unzip,如果找到了就执行它。我们使用echo命令打印当前终端的环境变量。

1
2
echo $PATH
/home/user/intel/vtune_amplifier_2018.1.0.535340/bin64 /bin /usr/bin /usr/local/bin /usr/local/sbin /usr/lib/jvm/default/bin /usr/bin/site_perl /usr/bin/vendor_perl /usr/bin/core_perl /usr/lib/smlnj/bin /usr/bin/site_perl /usr/bin/vendor_perl /usr/bin/core_perl

6.828网站上默认将工具链安装在了/usr/local目录下,而/usr/local/bin是位于环境变量PATH中的。
因此,我们的qemu以及make可以直接找到编译好的工具链并调用。
现在,我们将工具链安装在了一个不属于PATH的目录中。对于这种情况,我们必须使用某种方式(如shell脚本),将相应目录添加PATH中。

对于我们编译的工具链,还需要设置环境变量LD_LIBRARY_PATH才可正常工作。LD_LIBRARY_PATH涉及linux的动态链接库(共享库),可以自行去搜索相关知识。

当我们安装的程序变多,脚本也会越变越多,管理诸如PATH的环境变量也变得不方便。
我们的jos内核使用的工具链加上了“i386-jos-elf”的前缀。若没有该前缀,添加环境变量后,终端会优先使用gcc 4.6.1作为默认的gcc,这显然不是我们期望的结果。
因此,我们可能还需要写另一个脚本,将相应的目录从PATH中移除。

此外,根据脚本的是否在终端启动时被执行,还可能出现不同终端环境不同的问题。

那么,有没有一种方法可以快速方便的管理不同软件以及他们所需要的环境变量呢?Modules正是一个可行的方法。

简介

Modules(Environment Modules)通过modulefile动态地改变用户环境。可以在Arch中安装env-modules(AUR)以使用Modules。

安装完毕后,需要编辑终端的配置文件来启用Modules。Modules的shell初始化放在/etc/modules/init文件夹下。

Modules使用Tcl作为脚本语言,编写modulefile时可以直接man modulefile获取帮助。

实例

给出管理jos工具链的modulefile,文件放置在/etc/modules/modulefiles文件夹下。使用module load jos加载环境,module unload jos取消环境。modulefile中的prepend-path扩展了环境变量PATH以及LD_LIBRARY_PATH。在调用unload的时候prepeng-path会自动被替换成remove-path。

jos

1
2
3
4
5
6
7
8
9
10
11
#%Module1.0
##
## modulefile
##
proc ModulesHelp {} {
puts stderr "Adds cross compile toolchain(gcc 4.6.1 i386) for MIT jos to user environment"
}
module-whatis "adds cross compile toolchain(gcc 4.6.1 i386) for MIT jos to user environment"
set root_dir /home/chenzhihao/6.828/gcc
prepend-path PATH $root_dir/bin
prepend-path LD_LIBRARY_PATH $root_dir/lib

评论和共享

  • 第 1 页 共 1 页
作者的图片

码龙黑曜

iOS开发者/计算机科学/兽人控


华中科技大学 本科在读


Wuhan, China