蓝色步行者

每个人都有自己的梦想


  • 首页

  • 归档

  • 标签

  • 分类

  • 搜索

笔记017 - HW8: User-level threads

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

Switching threads

本次练习是在用户程序uthread.c中模拟进程之间的切换。
进程结构为:

1
2
3
4
5
6
7
struct thread {
int sp; /* curent stack pointer */
char stack[STACK_SIZE]; /* the thread's stack */
int state; /* running, runnable, waiting */
};

static thread_t all_thread[MAX_THREAD];

sp变量用于保存切换进程时“当前进程”的esp。初始化进程的时候,留出栈顶4字节空间保存函数mythread地址,如果进程切换时切换到的进程尚未执行过,则会执行该函数;然后留出32字节的空间,并更新sp变量指向距离栈顶36字节位置,如果进程切换时切换到的进程尚未执行过,则将这32字节弹出到通用寄存器。stack即为模拟的进程执行过程中所使用的栈。

uthread.c定义了进程树组all_thread,并在初始化过程中指定

1
2
current_thread = &all_thread[0];
current_thread->state = RUNNING;

这样,第一个进程被假设为正在执行,它永远不会被真正执行,因为之后的进程切换都在其他RUNNABLE进程中选取。

本次作业主要要完成进程切换的工作,即完成uthread_switch.S。每次跳转到thread_switch执行进程切换时,由于是运行在“当前进程”current_thread栈上,所以直接在当前栈上保存通用寄存器(pushal),然后将esp保存到“当前进程”current_thread的sp上,这样下次切换为该进程时直接将sp赋值给esp之后就可以执行popal。之后跳转到next_thread的sp指向的位置(进程第一次被调用时,sp指向的是距离栈顶36字节位置),并将current_thread指向next_thread,将next_thread置0,然后弹出通用寄存器,执行ret。

执行ret之后会有什么效果呢?分两种情况:1、进程未被执行过时,sp指向距离栈顶36字节位置,弹出32字节到通用寄存器之后,执行ret会执行绑定的函数mythread。2、此后,进程在“当前进程”栈上执行,当再次次跳转到thread_switch执行进程切换时,首先将下一条指令的eip进栈,然后再pushal保存通用寄存器,并将最新的esp保存到“当前进程”的sp变量中。这样后续切换到该进程时,则弹出通用寄存器后执行ret会执行保存eip,即恢复执行调用thread_switch的下一条指令。

需要注意的是:由于current_thread一开始被初始化为all_thread[0],且没有被真正执行过,所以第一次跳转到thread_switch执行pushal的时候是保存通用寄存器到用户程序uthread的用户栈上的;后续进程切换的时候,跳转到thread_switch后执行pushal才真正地在“当前进程”栈上保存通用寄存器,但是这对于本程序的模拟没有影响。

uthread_switch.S如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
	.text

/* Switch from current_thread to next_thread. Make next_thread
* the current_thread, and set next_thread to 0.
* Use eax as a temporary register, which should be caller saved.
*/
.globl thread_switch
thread_switch:
/* YOUR CODE HERE */

# We are running on current_thread's stack, push all registers to save
# attention: first time we call thread_switch, we actually do not run on current_thread's stack
# because current_thread point to all_thread[0], and we just fake it is running, but it does not matter
pushal

# Save current thread's stack pointer
movl current_thread, %eax
movl %esp, (%eax)

# Switch stacks to next thread
movl next_thread, %eax
movl (%eax), %esp #point to sp'value, if is every thread's first-time-running, it already leaves the space to save registers

# Set current_thread to next_thread
movl %eax, current_thread
# Set next_thread to 0
movl $0, next_thread

# Pop next thread's registers from the stack into the appropriate registers
popal

// exec current_thread
ret /* pop return address from stack */

调试内置用户程序技巧

启动qemu之后,可以使用以下方法调试内置的用户程序:

1
2
3
4
5
6
7
8
9
(gdb) symbol-file _uthread
Load new symbol table from "/Users/kaashoek/classes/6828/xv6/_uthread"? (y or n) y
Reading symbols from /Users/kaashoek/classes/6828/xv6/_uthread...done.
(gdb) b thread_switch
Breakpoint 1 at 0x204: file uthread_switch.S, line 9.
(gdb) c

(gdb) print /x *next_thread
$1 = {sp = 0x4d48, stack = {0x0 , 0x61, 0x1, 0x0, 0x0}, state = 0x1}

Challenge exercises

上述模拟的进程切换是通过进程主动调用thread_switch完成的,有以下缺点:
1、假如用户进程A阻塞等待系统调用结果,用户进程B不会被执行,因为xv6的调度器不知道用户进程A被取消执行。
2、不同进程不能在不同核上并发执行,因为xv6的调度器没有将多个进程并发执行的策略。在当前的实现下,进程不能被并发执行,例如不同处理器同时调用thread_schedule的话会导致错误。

有以下解决方法:
1、scheduler activations
2、one kernel thread per user-level thread (as Linux kernels do)
借助locks、condition variables、barriers等,尝试在xv6实现其中一种方式。

1
???

笔记016 - Lab4: Preemptive Multitasking

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

Part A: Multiprocessor Support and Cooperative Multitasking

任务:扩展jos使之支持多处理器;添加系统调用支持用户进程创建新进程;实现协作性循环调度,当前进程自愿放弃cpu或exit的时候允许内核由一个进程切换到另一个进程。

Multiprocessor Support

jos支持“对称多处理”(SMP:symmetric multiprocessing),这是一种多处理器模型,所有cpu都能等价访问系统资源,如内存和I/O总线。在SMP中,尽管所有cpu在功能上是相同的,在引导过程中仍然可以将它们分为两类:1、引导处理器BSP(the bootstrap processor):负责初始化系统和启动操作系统;2、应用程序处理器APs(the application processors):在操作系统启动之后由BSP激活,然后开始运行。硬件和BIOS决定哪个处理器是BSP。到目前为止,所有jos代码都是运行在BSP上的。

在SMP系统中,每个cpu都有一个附带的局部高级可编程中断处理器LAPIC(local APIC:Advanced Programmable Interrupt Controller)。LAPIC负责在整个系统中传递中断。LAPIC为其所连接的cpu提供一个唯一的标识符。在lab4中,我们使用LAPIC单元的三个基本功能:
1、读取LAPIC标识符APIC ID以识别我们的代码当前运行在哪个cpu上。(见cpunum())
2、从BSP处发送STARTUP处理器间中断IPI(interprocessor interrupt)到APs,启动其他cpu。(见lapic_startap())
3、part C将编程LAPIC的内置计时器,触发时钟中断以支持抢占式多任务调度。(见apic_init())

处理器通过内存映射I/O MMIO(memory-mapped I/O)来访问附带的LAPIC。在MMIO中,一部分物理内存是硬连线到一些I/O设备的寄存器上的,所以可以通过使用相同的用以访问内存的load/store指令来访问设备寄存器。关于IO hole,我们已经使用了物理地址0xA0000来写VGA display缓存。LAPIC存放的IO hole起始物理地址是0xFE000000(4G空间中的32m),使用在KERNBASE的直接映射来访问的话,这个地址太高了。在jos中,虚拟内存映射在MMIOBASE位置留出了4MB空间以映射设备。

1
Exercise 1: 实现kern/pmap.c的mmio_map_region函数,在kern/lapic.c的lapic_init中查看mmio_map_region函数的使用情况。需要做完Exercise 2之后才能通过对于mmio_map_region的测试。
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
//
// Reserve size bytes in the MMIO region and map [pa,pa+size) at this
// location. Return the base of the reserved region. size does *not*
// have to be multiple of PGSIZE.
//
// jianzzz: 我们可能会多次调用mmio_map_region(),每一次给出pa和size,将会将pa映射到递增的va,映射范围是size(以PGSIZE为大小向上对齐)。若映射地址超出MMIOLIM则越界。
void *
mmio_map_region(physaddr_t pa, size_t size)
{
static uintptr_t base = MMIOBASE;

// Reserve size bytes of virtual memory starting at base and
// map physical pages [pa,pa+size) to virtual addresses
// [base,base+size). Since this is device memory and not
// regular DRAM, you'll have to tell the CPU that it isn't
// safe to cache access to this memory. Luckily, the page
// tables provide bits for this purpose; simply create the
// mapping with PTE_PCD|PTE_PWT (cache-disable and
// write-through) in addition to PTE_W. (If you're interested
// in more details on this, see section 10.5 of IA32 volume
// 3A.)
//
// Be sure to round size up to a multiple of PGSIZE and to
// handle if this reservation would overflow MMIOLIM (it's
// okay to simply panic if this happens).
// Your code here:
uint32_t sizeup = (uint32_t)ROUNDUP((char *) size, PGSIZE);
if(base + sizeup > MMIOLIM){
panic("mmio_map_region: requested size to map went over MMIOLIM");
}
boot_map_region(kern_pgdir,base,sizeup,pa,PTE_PCD | PTE_PWT | PTE_W | PTE_P);
base += sizeup;
//panic("mmio_map_region not implemented");
return (void *)(base-sizeup);
}

Application Processor Bootstrap

在启动APs之前,BSP从BIOS的内存区域中读取MP配置表,收集多处理器系统的信息,比如CPUs数目、APIC IDs、LAPIC单元的MMIO地址。见kern/mpconfig.c的mp_init()。

kern/init.c的boot_aps()函数驱动AP启动程序的运行。APs在实模式下启动,与boot/boot.S的引导过程相似:boot_aps()将AP入口代码(kern/mpentry.S)拷贝到实模式下的一个可寻址的内存位置。与boot/boot.S的引导过程不同的是,jos会控制AP将会在哪里开始执行代码;jos将入口代码拷贝到0x7000(MPENTRY_PADDR),但640KB以下的任何unused、page-aligned的物理地址都被使用。

此后,boot_aps()逐一激活APs,通过给匹配AP的LAPIC发送STARTUP IPIs以及一个初始CS:IP地址,AP将在该地址上(即MPENTRY_PADDR)执行入口代码。kern/mpentry.S在简单设置之后将AP运行模式设为保护模式,开启分页,然后调用c函数mp_main()(also in kern/init.c)。boot_aps()等待AP发送CPU_STARTED信号(见struct CpuInfo的cpu_status域),然后激活下一个AP。

1
Exercise 2. 阅读boot_aps()和mp_main(),以及kern/mpentry.S,了解APs启动过程的control flow transfer。修改kern/pmap.c的page_init()实现,避免在MPENTRY_PADDR分配页,保证APs启动代码的拷贝、运行安全。
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
void
page_init(void)
{
// LAB 4:
// Change your code to mark the physical page at MPENTRY_PADDR
// as in use

// The example code here marks all physical pages as free.
// However this is not truly the case. What memory is free?
// 1) Mark physical page 0 as in use.
// This way we preserve the real-mode IDT and BIOS structures
// in case we ever need them. (Currently we don't, but...)
// 第一页数据是被使用的,该页保存了实模式IDT和BIOS数据结构
// 2) The rest of base memory, [PGSIZE, npages_basemem * PGSIZE)
// is free.
// 第二页至基本内存结束位置(640K)是可用的
// 3) Then comes the IO hole [IOPHYSMEM, EXTPHYSMEM), which must
// never be allocated.
// [IOPHYSMEM, EXTPHYSMEM),即[0x0A0000,0x100000)--[640K,1MB)是被使用的
// 4) Then extended memory [EXTPHYSMEM, ...).
// Some of it is in use, some is free. Where is the kernel
// in physical memory? Which pages are already in use for
// page tables and other data structures?
// 扩展内存开始位置至当前pages数组存储的末端为不可使用的部分,其中包括页目录空间和映射内存的pages数组空间!!!
// 当前pages数组存储的末端开始至可用内存末端即为可用的
//
// Change the code to reflect this.
// NB: DO NOT actually touch the physical memory corresponding to
// free pages!

// uint32_t page_IOPHYSMEMBEGIN = npages_basemem;
uint32_t page_IOPHYSMEMBEGIN = (uint32_t)IOPHYSMEM / PGSIZE;

// uint32_t num_pages_io_hole = 96;
// uint32_t page_EXTPHYSMEMBEGIN = npages_basemem + num_pages_io_hole;
uint32_t page_EXTPHYSMEMBEGIN = (uint32_t)EXTPHYSMEM / PGSIZE;

// 内核代码存放于0x100000处,即1M处,KERNBASE是内核代码存储起点的虚拟地址
// 对于存于物理地址1M后的数据,其虚拟地址减去KERNBASE的值相当于该处与内核代码存储起点虚拟地址位置的差,
// 而不是与0x0的差, 需要加上1M的空间
// 1M = 256 page
// uint32_t num_kernelpages = (((uint32_t) boot_alloc(0)) - KERNBASE) / PGSIZE;
//uint32_t page_KernelPageInfoEnd = npages_basemem + num_pages_io_hole + num_kernelpages;
uint32_t page_KernelPageInfoEnd = (((uint32_t) boot_alloc(0)) - KERNBASE) / PGSIZE + 256;

page_free_list = NULL;
size_t i;
//pages数组与实际物理空间存在映射关系,但没有使用实际空间存储物理页位置
for (i = 0; i < npages; i++) {
if(i==0 ||
//Lab4 : page at MPRENTRY_PADDR, reserved for CPU startup code . e.g. 0x7000
i == PGNUM(MPENTRY_PADDR) ||
//(i>=page_IOPHYSMEM && i<page_EXTPHYSMEMBEGIN) ||
//(i>=page_EXTPHYSMEMBEGIN && i<page_KernelPageInfoEnd) ||
(i>=page_IOPHYSMEMBEGIN && i<page_KernelPageInfoEnd)
){
pages[i].pp_ref = 1;
continue;
}
pages[i].pp_ref = 0;
//往回指的指针pp_link要越过非空物理页,这样page_free_list才能通过pp_link往回获取空闲空间
//双向链表
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}
}
1
Question 1. 比较kern/mpentry.S和boot/boot.S。请记住:kern/mpentry.S被编译和链接后是运行在KERNBASE之上的,就像其他程序一样。设计MPBOOTPHYS宏的目的是什么?为什么boot/boot.S不需要它?
1
2
kern/mpentry.S的内容原先是加载在内核程序的页空间中的,在BSP激活其他APs之前,BSP会将这部分内容移到MPENTRY_PADDR物理页中。所以AP在执行kern/mpentry.S时,kern/mpentry.S中的标记符物理地址需要以MPENTRY_PADDR为基础重新计算。MPBOOTPHYS宏就是用于确定kern/mpentry.S中的标识符物理地址。
对于boot/boot.S来说,它是由bios加载到0x7c00的,然后开始执行这部分代码,由于当时尚未开启分页,链接地址即为物理地址,所以boot/boot.S中标识符的物理地址是直接由链接地址确定的。

Per-CPU State and Initialization

多处理器操作系统很重要的一点是区分每个处理器私有的处理器状态和整个系统共享的全局状态。kern/cpu.h定义了per-CPU状态的大部分,包括struct CpuInfo(存储per-CPU变量)。cpunum()返回的是调用它的cpu的ID,该值可以索引cpus数组。thiscpu是个宏定义,指向当前cpu的struct CpuInfo。
以下是部分per-CPU状态:
1、Per-CPU kernel stack:多个cpu可以同时trap到内核中,为了防止干扰彼此的运行,需要为每个处理器设置单独的栈空间。内核物理空间中数组percpu_kstacks[NCPU][KSTKSIZE]保存了NCPU个栈空间。BSP的内核栈地址是bootstack,映射到虚拟地址KSTACKTOP之下的KSTKSIZE大小的空间。紧随着,越过KSTKGAP字节之后,映射CPU1栈空间,以此类推。
2、Per-CPU TSS and TSS descriptor:TSS任务状态段用于寻位每个cpu的内核栈。CPU i的TSS存于cpus[i].cpu_ts中,相关联的TSS描述符定义在GDT入口gdt[(GD_TSS0 >> 3) + i]。kern/trap.c定义的全局ts变量不再生效。
3、Per-CPU current environment pointer:因为每个cpu可以同时运行不同的用户进程,因此重新定义curenv指向cpus[cpunum()].cpu_env,它指向的是运行在当前cpu上的当前进程。
4、所有的寄存器,包括系统寄存器,对每个cpu来说都是私有的。因此,初始化这些寄存器的指令,比如lcr3(), ltr(), lgdt(), lidt()等,都必须在每个寄存器上执行一次。env_init_percpu()和trap_init_percpu()正是用于这个目的。

1
Exercise 3. 修改kern/pmap.c的mem_init_mp()函数,将每个cpu的栈映射到KSTACKTOP以下部分,参考inc/memlayout.h。每个栈的大小是KSTKSIZE+KSTKGAP字节。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
static void
mem_init_mp(void)
{
// Permissions: kernel RW, user NONE
//
// LAB 4: Your code here:
int i=0;
uint32_t bottom_stack = KSTACKTOP - KSTKSIZE;
for (i = 0; i < NCPU; i++) {
boot_map_region(kern_pgdir, bottom_stack, KSTKSIZE, PADDR(percpu_kstacks[i]), PTE_W | PTE_P);
bottom_stack -= KSTKGAP; // guard
bottom_stack -= KSTKSIZE;
}
}
1
Exercise 4. kern/trap.c的trap_init_percpu()函数初始化了BSP的TSS和TSS描述符,修改这部分代码,使之支持所有cpu。提示:新代码将不会使用到全局变量ts。
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
void
trap_init_percpu(void)
{
// ltr sets a 'busy' flag in the TSS selector, so if you
// accidentally load the same TSS on more than one CPU, you'll
// get a triple fault. If you set up an individual CPU's TSS
// wrong, you may not get a fault until you try to return from
// user space on that CPU.
//
// LAB 4: Your code here:

// Setup a TSS so that we get the right stack
// when we trap to the kernel.
//ts.ts_esp0 = KSTACKTOP;
//ts.ts_ss0 = GD_KD;
thiscpu->cpu_ts.ts_esp0 = (uintptr_t) percpu_kstacks[thiscpu->cpu_id] + KSTKSIZE;
thiscpu->cpu_ts.ts_ss0 = GD_KD;

// Initialize the TSS slot of the gdt.
//gdt[GD_TSS0 >> 3] = SEG16(STS_T32A, (uint32_t) (&ts),
// sizeof(struct Taskstate) - 1, 0);
//gdt[GD_TSS0 >> 3].sd_s = 0;
gdt[(GD_TSS0 >> 3) + thiscpu->cpu_id] = SEG16(STS_T32A, (uint32_t) (&thiscpu->cpu_ts),
sizeof(struct Taskstate) - 1, 0);
gdt[(GD_TSS0 >> 3) + thiscpu->cpu_id].sd_s = 0;

// Load the TSS selector (like other segment selectors, the
// bottom three bits are special; we leave them 0)
//ltr(GD_TSS0);
//ltr(((GD_TSS0 >> 3) + thiscpu->cpu_id) << 3);
ltr(GD_TSS0 + (thiscpu->cpu_id << 3));

// Load the IDT
lidt(&idt_pd);
}

多处理器总结要点

jos支持多处理器之后,大概的启动过程如下:
1、BSP调用mem_init()函数,主要完成创建页目录、pages数组、envs数组、映射pages数组/envs数组/BSP内核栈等到页目录中。
2、BSP调用env_init()函数,初始化envs数组;同时调用env_init_percpu()函数加载当前cpu的GDT和gs/fs/es/ds/ss段描述符。
3、BSP调用trap_init()函数,初始化IDT表;同时调用trap_init_percpu()函数初始化当前cpu的TSS和IDT。
4、BSP调用mp_init()函数,mp_init()函数通过调用mpconfig()从BIOS中读取浮动指针mp,从mp中找到struct mpconf多处理器配置表,然后根据这个结构体内的entries信息(processor table entry)对各个cpu结构体进行配置(主要是cpuid)。如果proc->flag是MPPROC_BOOT,说明这个入口对应的处理器是用于启动的处理器,我们把结构体数组cpus[ncpu]地址赋值给bootcpu指针。注意这里ncpu是个全局变量,那么这里实质上就是把cpus数组的第一个元素的地址给了bootcpu。如果出现任何entries匹配错误,则认为处理器的初始化失败了,不能用多核处理器进行机器的运行。
5、BSP调用lapic_init()函数,映射lapic物理地址到页目录(映射的虚拟地址递增),设置时钟,允许APIC接收中断。
6、BSP调用pic_init()函数,初始化8259A中断控制器,允许生成中断。
7、BSP调用boot_aps()函数,复制cpu启动代码到0x7000,然后对每个cpu分别确定内核栈地址后调用lapic_startap()函数。lapic_startap()函数命令对应cpu从加载代码处开始执行。BSP使用轮询等待目标cpu启动完成(目标cpu使用自旋锁通知),然后激活下一个cpu。
8、AP(cpu)从加载代码处启动,加载gdt表、手动设置初始页表、开启保护模式、分页等,然后跳转到mp_main()函数;mp_main()函数直接切换到页目录,然后调用lapic_init()函数映射lapic物理地址到页目录(映射的虚拟地址递增),并允许APIC接收中断;调用env_init_percpu()函数加载当前cpu的GDT和gs/fs/es/ds/ss段描述符;调用trap_init_percpu()函数初始化当前cpu的TSS和IDT,然后使用自旋锁设置启动完成标识,并调用sched_yield()函数尝试开始执行可执行进程。
9、BSP上自行创建进程,并调用sched_yield()函数尝试开始执行可执行进程。

需要注意到:每个cpu都有自己独立的寄存器和CPU栈、TSS,但是整个内核中只有一份envs数组、pages数组、内核页目录、idt、gdt、lapic物理地址。

1
问题是:lapic物理地址只有一个,每次启动cpu的过程中是怎么改变lapic的呢?还有cpunum()的实现方式不明白。。。。。。
1
???

Locking

启动APs之后,我们需要解决多处理器并发执行内核代码的竞争条件。实现这一点的最简单的方法是使用一个内核锁,该内核锁是一个全局锁,一旦某个进程进入内核模式则尝试获取该锁,当进程返回到用户模式时释放该锁。在这个模型中,在用户模式下的进程可以并发在任何可用cpu上运行,但至多一个进程可以运行在内核模式下,其他试图进入内核模式的进程被迫等待。
kern/spinlock.h定义了内核锁kernel_lock,并提供lock_kernel()和unlock_kernel()方法用于获取和释放锁。需要在以下四个地方设置内核锁:
1、i386_init():在BSP激活其他cpu之前获取内核锁。
2、mp_main():初始化AP之后获取内核锁,然后调用sched_yield()在该AP上运行进程。
3、trap():如果是由用户模式trap到内核的,获取内核锁。通过tf_cs的低两位判断是用户模式还是内核模式。
4、env_run():在切换为用户模式之前释放内核锁。

1
Exercise 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
i386_init():
// Acquire the big kernel lock before waking up APs
// Your code here:
lock_kernel();
// Starting non-boot CPUs
boot_aps();

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_kernel();
sched_yield();

trap():
if ((tf->tf_cs & 3) == 3) {
// Trapped from user mode.
// Acquire the big kernel lock before doing any
// serious kernel work.
// LAB 4: Your code here.
lock_kernel();
assert(curenv);

env_run():
lcr3(PADDR(e->env_pgdir));
//lab4
unlock_kernel();
env_pop_tf(&(e->env_tf));//never return

在sched_yield()中实现循环调度时会调用env_run()函数,进而释放内核锁。经过上述加锁、释放锁操作后,只有当BSP激活所有APs并且开始调用sched_yield()运行用户程序时,其他CPU才可能开始执行用户程序。

1
Question 2. 使用内核锁可以保证一次只有一个CPU可以运行内核代码。那么,为何还需要将每个cpu的CPU栈分开?请描述一个使用了内核锁和共享内核栈后出错的场景。

如果使用共享的内核栈的话,当出现中断时,硬件会先自动将相关寄存器进栈,然后才执行锁的检查,共享内核栈可能会导致系统崩溃。
支持多个cpu的时候,只有一份内核页目录,所有cpu都会使用这个页目录映射CPU栈。不同的CPU栈映射到不同的虚拟地址上。
在此需要注意的是另一个现象:不同的用户进程是可以同时将用户栈物理地址映射到UXSTACKTOP上的。这是因为每一个用户进程都有一份独立的页目录,创建用户进程的时候会分配和映射一页用户栈物理页到UXSTACKTOP上,多个cpu同时运行多个用户进程的时候,实际上都是使用各自的页目录进行寻址和存储数据到各自的用户栈物理页上的。

1
2
3
4
5
6
Challenge: 内核锁易于使用,但它消除了内核模式下的所有并发。大多数现代操作系统使用不同的锁来保护共享状态的不同部分,使用的方法称为细粒度锁(fine-grained locking)。细粒度锁可以显著提高性能,但更难以实现且容易出错。
若要在jos实现细粒度锁,可以自由决定锁的粒度(即每个锁保护的数据量大小)。可以使用自旋锁来实现互斥访问以下jos内容:
The page allocator:页分配器
The console driver:控制台驱动
The scheduler:调度器
The inter-process communication (IPC) state:进程间通信状态,将在part C实现。

Round-Robin Scheduling

此部分内容将修改jos内核使之可以以循环机制(round-robin)交替调度多个进程。jos的循环机制如下:
1、kern/sched.c的sched_yield()函数负责选择一个新的进程来执行。它从上一个运行进程之后开始,以循环方式顺序搜索envs[]数组,选择第一个状态为ENV_RUNNABLE的进程,并调用env_run()函数跳转到该进程。
2、sched_yield()不能同时在两个cpu上执行调度同一个进程。区分某个进程是否正在某个cpu上运行的方法是:该进程的状态是ENV_RUNNING。
3、用户进程可以调用sys_yield()系统调用(该函数调用sched_yield()函数),自愿放弃cpu资源给其他进程。

1
2
3
Exercise 6. 在sched_yield()中实现循环调度。在syscall()中分发sys_yield()。确保在mp_main()中调用sched_yield()。在kern/init.c中创建三个或三个以上进程,执行user/yield.c。在yield程序退出之后,系统将不存在可执行进程,并陷入到monitor中。
如果CPUS=1,所有进程将会成功执行。
如果CPUS>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
void
sched_yield(void)
{
struct Env *idle;

// Implement simple round-robin scheduling.
//
// Search through 'envs' for an ENV_RUNNABLE environment in
// circular fashion starting just after the env this CPU was
// last running. Switch to the first such environment found.
//
// If no envs are runnable, but the environment previously
// running on this CPU is still ENV_RUNNING, it's okay to
// choose that environment.
//
// Never choose an environment that's currently running on
// another CPU (env_status == ENV_RUNNING). If there are
// no runnable environments, simply drop through to the code
// below to halt the cpu.

// LAB 4: Your code here.
int i;
int current_env_idx = curenv ? ENVX(curenv->env_id) : 0;
int idx = curenv ? (current_env_idx + 1) % NENV : 0; // start by looking at the next process

for (i = 0; i < NENV; i++) {
if (envs[idx].env_status == ENV_RUNNABLE)
env_run(&envs[idx]);
idx = (idx + 1) % NENV;
}
if (curenv != NULL && curenv->env_status == ENV_RUNNING)
env_run(curenv);

// sched_halt never returns
sched_halt();
}
1
Question 3.env_run()修改了参数e的成员状态之后就会调用lcr3切换到进程的页目录,这时候内存管理单元MMU所使用的寻址上下文立即发生了变化。在地址切换前后,为什么参数e仍能够被引用?

因为对于所有env来说,内核空间的虚拟地址是相同的,这部分地址也被映射进env的页目录。参数e本身也是内核空间的一部分。

1
Question 4.每次内核从一个进程切换到另一个进程的时候,需要保存旧进程的寄存器以便后面恢复,怎么做?什么时候这样做?

由内核模式进入trap()函数时,只管调用trap_dispatch()就行,就算调用之后会执行新的env,也不涉及旧env寄存器存储的问题。但如果是由用户模式进入trap()函数时(trapframe上的cs表明是否来自于用户模式),可能会导致进程切换,因此在调用trap_dispatch()之前需要保存旧env的寄存器。具体做法是:curenv->env_tf = *tf;。之后如果切换到另一个进程,则会调用env_run()函数,并更新curenv变量。为什么这样子就可以保存呢?看一下变量的定义:1、#define curenv (thiscpu->cpu_env),2、#define thiscpu (&cpus[cpunum()])、3、thiscpu的cpu_env定义是:struct Env *cpu_env;。由上可知,cpu_env是一个指针,始终指向envs数组的一员,而它的更新时机在于调用env_run()函数的时候,实际上就是更新当前cpu执行的进程env。所以在trap()函数中通过执行curenv->env_tf = *tf;就可以将旧进程保存在栈上的trapframe值复制到cpu_env所指向envs数组的一员上,下次若调用到该进程,又可以恢复寄存器环境了。

1
Challenge! 将调度方式扩展为固定优先级调度,优先级高的进程将先于优先级低的进程被调度。编写测试程序测试调用顺序是正确的。

扩展Env数据结构,添加优先级变量,然后修改调度函数,如下:

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
// Choose a user environment to run and run it.
void
sched_yield(void)
{
struct Env *idle;

// Implement simple round-robin scheduling.
//
// Search through 'envs' for an ENV_RUNNABLE environment in
// circular fashion starting just after the env this CPU was
// last running. Switch to the first such environment found.
//
// If no envs are runnable, but the environment previously
// running on this CPU is still ENV_RUNNING, it's okay to
// choose that environment.
//
// Never choose an environment that's currently running on
// another CPU (env_status == ENV_RUNNING). If there are
// no runnable environments, simply drop through to the code
// below to halt the cpu.
// LAB 4: Your code here.
// use priority, lab4 challenge
enum EnvPriority priority;
int i = 0;;
int current_env_idx = curenv ? ENVX(curenv->env_id) : 0;
int idx = curenv ? (current_env_idx + 1) % NENV : 0; // start by looking at the next process
for (priority = ENV_PRIORITY_HIGH; priority <= ENV_PRIORITY_LOW; priority++) {
for (i = 0; i < NENV; i++) {
if (envs[idx].env_status == ENV_RUNNABLE && envs[idx].priority == priority){
if (curenv != NULL && curenv->env_status == ENV_RUNNING && curenv->priority < envs[idx].priority){
env_run(curenv);
}else{
env_run(&envs[idx]);
}
}
idx = (idx + 1) % NENV;
}
}
if (curenv != NULL && curenv->env_status == ENV_RUNNING){
env_run(curenv);
}

// sched_halt never returns
sched_halt();
}

测试程序为user/envpriority.c,注意需要在kern/Makefrag中添加KERN_BINFILES += user/envpriority。测试程序使用到下面添加的系统调用,整体思路是由当前root进程fork多个子进程,每次fork之后设置子进程的优先级,然后调用sys_yield()系统调用放弃cpu,观察内核调度进程结果。

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
// test env priority

#include <inc/string.h>
#include <inc/lib.h>

envid_t dumbfork_priority(uint32_t priority);

void
umain(int argc, char **argv)
{
envid_t who,root;
int i,p;
root = sys_getenvid();
for (p = 1; p <= 5; ++p) {
// fork a child process
if(root == sys_getenvid()){
who = dumbfork_priority(p);

// print a message and yield to the other a few times
for (i = 0; i < 3; i++) {
cprintf("%d: I am the %s! my env priority is %d\n", i, who ? "parent" : "child", sys_env_get_priority());
sys_yield();
}
}
}
}

void
duppage(envid_t dstenv, void *addr)
{
int r;

// This is NOT what you should do in your fork.
//alloc a page mapping at child's addr
if ((r = sys_page_alloc(dstenv, addr, PTE_P|PTE_U|PTE_W)) < 0)
panic("sys_page_alloc: %e", r);
//map child's new page(mapping at addr) at parent's UTEMP
if ((r = sys_page_map(dstenv, addr, 0, UTEMP, PTE_P|PTE_U|PTE_W)) < 0)
panic("sys_page_map: %e", r);
//copy page data mapping at parent's addr to page mapping at parent's UTEMP
//as a result, it fills in child's page
memmove(UTEMP, addr, PGSIZE);
if ((r = sys_page_unmap(0, UTEMP)) < 0)
panic("sys_page_unmap: %e", r);
}

envid_t
dumbfork_priority(uint32_t priority)
{
envid_t envid;
uint8_t *addr;
int r;
extern unsigned char end[];

// Allocate a new child environment.
// The kernel will initialize it with a copy of our register state,
// so that the child will appear to have called sys_exofork() too -
// except that in the child, this "fake" call to sys_exofork()
// will return 0 instead of the envid of the child.
envid = sys_exofork();
if (envid < 0)
panic("sys_exofork: %e", envid);
if (envid == 0) {
// We're the child.
// The copied value of the global variable 'thisenv'
// is no longer valid (it refers to the parent!).
// Fix it and return 0.
thisenv = &envs[ENVX(sys_getenvid())];
return 0;
}


// We're the parent.
//set child's prioroty
sys_env_set_priority(envid,priority);
// Eagerly copy our entire address space into the child.
// This is NOT what you should do in your fork implementation.
for (addr = (uint8_t*) UTEXT; addr < end; addr += PGSIZE)
duppage(envid, addr);

// Also copy the stack we are currently running on.
duppage(envid, ROUNDDOWN((void*)USTACKTOP - PGSIZE, PGSIZE));
// Start the child environment running
if ((r = sys_env_set_status(envid, ENV_RUNNABLE)) < 0)
panic("sys_env_set_status: %e", r);

return envid;
}

运行结果:

1
Challenge! jos当前不支持应用使用x86处理器的x87 floating-point单元(FPU)、MMX指令或Streaming SIMD Extensions(SSE)。扩展Env数据结构,添加浮点状态成员(floating point state),并在进程切换代码中保存和恢复浮点状态。可使用FXSAVE和FXRSTOR指令,它们在最近的处理器才被介绍,在旧的i386用户手册中没有介绍。
1
???

System Calls for Environment Creation

sys_exofork:创建一个进程,创建页目录(映射内核空间,UTOP以下部分在页目录中的映射为空)、填充trapframe(包括env_alloc()中用户栈指针指向e->env_tf.tf_esp = USTACKTOP;,请注意,子进程只有将esp指向USTACKTOP,并没有再次为栈分配空间,而根父进程的栈空间是在env_create()的时候通过调用env_alloc()之后调用load_icode()实现的)、复制父进程的trapframe…,没有用户程序地址映射到地址空间、状态为不可执行。新进程的寄存器状态与调用sys_exofork的进程一样。调用sys_exofork的父进程返回子进程id,子进程返回0(具体原因可参考x86的fork实现)。
sys_env_set_status:设置进程状态为ENV_RUNNABLE或ENV_NOT_RUNNABLE,通常是进程的地址映射和寄存器状态设置完毕之后设置进程可被执行。
sys_page_alloc:分配一页物理页,并在指定进程的地址空间上映射到给定的虚拟地址。
sys_page_map:将A进程中映射到虚拟地址va_a的物理页映射到B进程的虚拟地址va_b,设置给出的权限。从而两个进程可以以不同的权限访问同一个物理页。
sys_page_unmap:在指定进程的地址空间上解除给定的虚拟地址上的映射。

1
Exercise 7.完成以上内容。jos的envid2env()函数可以根据id获取对应的进程结构,并且传入0可以获取当前进程。
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
// 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.
//panic("sys_exofork not implemented");
struct Env *newEnv;
//set kernal stack, page dir, trapframe...
int r = env_alloc(&newEnv,curenv->env_id);
if(r<0) return r;
newEnv->env_status = ENV_NOT_RUNNABLE;
newEnv->env_tf = curenv->env_tf;
//or : memmove((void *) &newEnv->env_tf, (void *) &curenv->env_tf, sizeof(struct Trapframe));
//clear child's eax,so it will return pid 0
newEnv->env_tf.tf_regs.reg_eax = 0;
return newEnv->env_id;
}
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
// 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: 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.
//panic("sys_env_set_status not implemented");
struct Env *e;
// check whether the current environment has permission to set envid's status.
// i.e.,the specified environment must be either the
// current environment or an immediate child of the current environment.
int r = envid2env(envid,&e,true);
if(r<0) return r;//-E_BAD_ENV
if(status != ENV_RUNNABLE && status != ENV_NOT_RUNNABLE) return -E_INVAL;
e->env_status = status;
return 0;
}
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
// 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)
{
// LAB 4: Your code here.
//panic("sys_page_alloc not implemented");
struct Env *e;
// check whether the current environment has permission to change envid.
// i.e.,the specified environment must be either the
// current environment or an immediate child of the current environment.
int r = envid2env(envid,&e,true);
if(r<0) return r;//-E_BAD_ENV

if((uint32_t)va >= UTOP || (uint32_t)va%PGSIZE != 0) return -E_INVAL;

if((perm & PTE_U) != PTE_U || (perm & PTE_P) != PTE_P) return -E_INVAL;
//#define PTE_SYSCALL (PTE_AVAIL | PTE_P | PTE_W | PTE_U)
if ((perm & ~PTE_SYSCALL) != 0) //no other bits may be set
return -E_INVAL;

struct PageInfo *page = page_alloc(ALLOC_ZERO);
if(page == NULL) return -E_NO_MEM;

//store page's mapping physical address and perm in va's mapping page table entry
r = page_insert(e->env_pgdir, page, va, perm);
if(r<0) {
page_remove(e->env_pgdir,va);
return r;//-E_NO_MEM
}
return 0;
}
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
// 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)
{
// LAB 4: Your code here.
//panic("sys_page_map not implemented");
struct Env *srce,*dste;
// check whether the current environment has permission to change envid.
// i.e.,the specified environment must be either the
// current environment or an immediate child of the current environment.
int r = envid2env(srcenvid,&srce,true);
if(r<0) return r;//-E_BAD_ENV
r = envid2env(dstenvid,&dste,true);//envid 0 means "the current environment."
if(r<0) return r;//-E_BAD_ENV

if((uint32_t)srcva >= UTOP || (uint32_t)srcva%PGSIZE != 0) return -E_INVAL;
if((uint32_t)dstva >= UTOP || (uint32_t)dstva%PGSIZE != 0) return -E_INVAL;

//return the page mapped at virtual address 'va'
//pte_store stores the address of the page table entry for this page
pte_t *pte_store;
struct PageInfo *page = page_lookup(srce->env_pgdir, srcva, &pte_store);
if(page == NULL) return -E_INVAL;//no page mapped at srcva

if((perm & PTE_U) != PTE_U || (perm & PTE_P) != PTE_P) return -E_INVAL;
//#define PTE_SYSCALL (PTE_AVAIL | PTE_P | PTE_W | PTE_U)
if ((perm & ~PTE_SYSCALL) != 0) //no other bits may be set
return -E_INVAL;

if((*pte_store & PTE_W) != PTE_W && ((perm & PTE_W) == PTE_W)) return -E_INVAL;//srcva is read-only but perm is writable

//store page's mapping physical address and perm in dstva's mapping page table entry
r = page_insert(dste->env_pgdir, page, dstva, perm);// allow to create page table
if(r<0) {
page_remove(dste->env_pgdir,dstva);
return r;//-E_NO_MEM
}
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 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)
{
// LAB 4: Your code here.
//panic("sys_page_unmap not implemented");
struct Env *e;
// check whether the current environment has permission to change envid.
// i.e.,the specified environment must be either the
// current environment or an immediate child of the current environment.
int r = envid2env(envid,&e,true);
if(r<0) return r;//-E_BAD_ENV

if((uint32_t)va >= UTOP || (uint32_t)va%PGSIZE != 0) return -E_INVAL;

page_remove(e->env_pgdir,va);
return 0;
}
1
Challenge! 添加系统调用以读取某个进程的所有重要状态。实现一个用户程序,该程序fork一个子进程,运行该程序(即循环迭代sys_yield()),然后获取子进程的完整snapshot或checkpoint,然后执行子程序一段时间。最后,恢复子进程在检查点的状态,然后继续执行子进程。这相当于从某个中间状态replaying子进程的执行。使用sys_cgetc()或readline()函数保证子进程与用户之间进行交互,以便用户可以查看和改变其内部状态。确保子进程在检查点处恢复执行时,子进程"forget"了之前任何发生在检查点之后的内容。
1
???

Part B: Copy-on-Write Fork

xv6实现的fork()将父进程所有物理页的内容拷贝给了分配给子进程的新物理页,类似于jos的dumbfork()函数。fork()过程中最“昂贵”的操作就是将父进程的地址空间拷贝给子进程。然而在子进程中,通常是紧接着就执行exec(),以新程序替代子进程执行。在这种情况下,父进程拷贝地址空间给子进程所花时间将被大大浪费了,因为子进程在执行exec()之前只使用了一小部分内存。鉴于此,更新版本的unix利用虚拟内存硬件允许父子进程共享分别映射到它们各自地址空间的物理内存,直至其中一方修改物理内存,这种技术称为“写时复制”。fork()将父进程地址空间的映射情况复制到子进程中,而不是复制父进程映射的物理页内容,并将共享的物理页标记为“只读”。当父子进程其中一方尝试写入到任一页共享的内存页,该进程将触发页错误。在这种情况下,内核识别出该页是“写时复制”副本,并为触发页错误的进程创建新的私有的可写入页。

User-level page fault handling

将Copy-on-Write Fork实现为一个用户空间库例程有以下优点:1、内核更为简单,更为正确;2、允许用户进程定义fork()的语义,用户程序可以很容易地提供一个稍有不同的实现版本,例如dumbfork()这种昂贵的always-copy版本,或者父子进程共享内存的版本。用户级别的Copy-on-Write Fork需要能够识别出在写保护页上触发的页错误。

典型的unix内核需要知道在进程地址空间的每一个区域上触发页错误时应该采取什么方法,例如:栈空间触发页错误时应该分配和映射新的物理页,在大多数unix内核中,一开始只给新进程分配一页的栈空间,用户程序在栈空间不足的情况下访问未映射的栈地址会触发页错误,从而导致内核分配并映射新物理页到栈空间;BSS空间触发页错误时应该分配新的一页、填充0并映射到地址空间;如果系统支持按需分配物理页的可执行文件,text region触发页错误时将从磁盘读取相应二进制页并映射到地址空间。

上述情况中内核需要跟踪许多信息。我们不采取传统的unix方法,而是在用户空间中决定对每个页错误所采取的行动。这种设计的好处是用户程序在定义内存区域时有极大的灵活性,我们将在后续使用用户级页错误处理程序来映射和访问基于硬盘的文件系统上的文件。

Setting the Page Fault Handler

1
Exercise 8. 实现sys_env_set_pgfault_upcall系统调用,该系统调用用于注册用户程序触发页错误时的处理函数入口。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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.
//panic("sys_env_set_pgfault_upcall not implemented");
struct Env *e;
// check whether the current environment has permission to change envid.
// i.e.,the specified environment must be either the
// current environment or an immediate child of the current environment.
int r = envid2env(envid,&e,true);
if(r<0) return r;//-E_BAD_ENV
e->env_pgfault_upcall = func;
return 0;
}

Normal and Exception Stacks in User Environments

jos正常执行进程过程中,用户进程所使用的用户栈是[USTACKTOP-PGSIZE,USTACKTOP-1]。然而,当用户程序触发页错误时,内核将在另一个栈[UXSTACKTOP-PGSIZE,UXSTACKTOP-1]上执行设计好的页错误处理函数,这个栈称为user exception stack。类似于x86为jos设计的由用户模式切换到内核模式时的栈切换,我们将在jos内核为用户程序设计自动栈切换。
当执行用户设计的页错误处理函数时,可以根据页错误原因使用jos的系统调用来映射新物理页或调整映射情况。之后页错误处理函数返回,通过汇编语言返回到原先触发页错误的代码。
每一个需要使用用户级别页错误处理流程的用户程序需要自行为user exception stack申请内存空间。

Invoking the User Page Fault Handler

用户程序触发页错误时,用户进程的状态称为“the trap-time state”。如果没有注册页错误处理程序,内核将销毁进程。否则,内核在异常栈上设置struct UTrapframe(见inc/trap.h),UTrapframe的值主要来源于内核栈的trap frame。之后,内核安排用户级页错误处理程序在异常栈上执行。
如果发生页错误的时候用户进程已经在异常栈上执行(tf->tf_esp在[UXSTACKTOP-PGSIZE, UXSTACKTOP-1]内),说明页错误处理程序本身触发了页错误。这种时候应该在tf->tf_esp而不是UXSTACKTOP的以下部分存储UTrapframe,而且需要先留空4个字节,再存储UTrapframe,具体原因下面讲。

1
Exercise 9. 完成kern/trap.c的page_fault_handler函数,该函数主要负责处理页错误,如果触发页错误的是用户进程,则检查页错误处理程序是否已设置、页错误处理程序地址是否合法、异常栈指针是否越界、是否递归触发页错误、是否已为异常栈分配物理页,然后在异常栈上存储UTrapframe,将栈指针指向异常栈,并跳转执行用户级页错误处理程序入口。
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
void
page_fault_handler(struct Trapframe *tf)
{
uint32_t fault_va;

// Read processor's CR2 register to find the faulting address
fault_va = rcr2();

// Handle kernel-mode page faults.

// LAB 3: Your code here.
if ((tf->tf_cs&3) == 0)
panic("Kernel page fault!");
// We've already handled kernel-mode exceptions, so if we get here,
// the page fault happened in user mode.

// LAB 4: Your code here.
//1. test if has page fault upcall
if (curenv->env_pgfault_upcall == NULL ||
//2. test if the exception stack overflows
tf->tf_esp > UXSTACKTOP || (tf->tf_esp > USTACKTOP && tf->tf_esp < (UXSTACKTOP - PGSIZE))){
// Destroy the environment that caused the fault.
cprintf("[%08x] user fault va %08x ip %08x\n",
curenv->env_id, fault_va, tf->tf_eip);
print_trapframe(tf);
env_destroy(curenv);
}

// 1. test if page fault upcall valid
user_mem_assert(curenv, (void *)curenv->env_pgfault_upcall, 1, 0);

//determine user exception stack pointer
uint32_t exception_stack_top = 0;
if(tf->tf_esp <= USTACKTOP){
exception_stack_top = UXSTACKTOP - sizeof(struct UTrapframe);
}else{
//+4 : leave an extra word between the current top of the exception stack
exception_stack_top = tf->tf_esp - sizeof(struct UTrapframe) - 4;
}

//2. test if the exception stack overflows
if(exception_stack_top < (UXSTACKTOP - PGSIZE)){
// Destroy the environment that caused the fault.
cprintf("[%08x] user fault va %08x ip %08x\n",
curenv->env_id, fault_va, tf->tf_eip);
print_trapframe(tf);
env_destroy(curenv);
}

//3. test if allocate a page for its exception stack or if can write to it
user_mem_assert(curenv, (void *) exception_stack_top, 1, PTE_W | PTE_U);//1 is enough, may not return

//write UTrapframe in the stack
struct UTrapframe* utf = (struct UTrapframe*)exception_stack_top;
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;

//branch to curenv->env_pgfault_upcall, that means we should change trapframe's esp and eip
tf->tf_esp = (uintptr_t) exception_stack_top;
tf->tf_eip = (uintptr_t) curenv->env_pgfault_upcall;
env_run(curenv);
}

User-mode Page Fault Entrypoint

xv6的定时函数是在内核trap时将esp所指单元向下移动一个单元,然后存储tf的eip(触发中断的下一条指令),然后将tf的eip指向处理函数,然后弹出tf寄存器执行定时函数,定时函数返回时弹出trap设置的eip,进而执行用户函数。这种情况下esp仍然指向用户栈。
jos的用户级页错误处理程序入口是_pgfault_upcall,包括调用执行真正的处理程序和恢复执行用户函数。_pgfault_upcall将通过系统调用设置为页错误处理程序入口,内核trap时将tf的相关值复制到异常栈上,将tf的esp指向异常栈,然后弹出tf寄存器执行页错误处理程序入口。这种情况下执行处理函数的时候esp指向的是异常栈,设置异常栈可以方便传递参数。
用户级页错误处理程序执行完后,将返回执行原先触发页错误的用户代码(如果是页错误处理程序触发页错误,则应返回执行原先触发页错误的页错误处理程序)。lib/pfentry.S完成这个任务。

1
Exercise 10. 完成lib/pfentry.S。触发页错误时内核实际上是跳转执行该文件声明的入口_pgfault_upcall。pfentry.S主要是在用户态的page_fault_handler结束后如何恢复现场并跳回原程序执行。

内核在异常栈上存储UTrapframe之后安排用户级页错误处理程序在异常栈上执行。异常栈上的UTrapframe布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
                    <-- UXSTACKTOP or tf->tf_esp
trap-time esp
trap-time eflags
trap-time eip
trap-time eax start of struct PushRegs
trap-time ecx
trap-time edx
trap-time ebx
trap-time oesp
trap-time ebp
trap-time esi
trap-time edi end of struct PushRegs
tf_err (error code)
fault_va <-- %esp when handler is run

分两种情况分析:
1、非递归触发页错误。由于此时是在异常栈上,想要恢复执行触发页错误的用户代码时,需要弹出通用寄存器,并由异常栈跳转到用户栈,最后执行ret恢复执行。因此,关键点是如何在弹出通用寄存器和eflags之前将eip存储到用户栈上。需要借助通用寄存器存储PushRegs的位置和eip的值,再跳到用户栈,存储eip,然后再跳回PushRegs位置,弹出通用寄存器和eflags,最后跳回用户栈执行ret(实验发现最后跳回用户栈之后,此时已经存了eip,如果执行subl向下跳到存eip的位置会改变eflags,因此在跳到用户栈存储eip之后,将最新的esp位置存到异常栈上,最后由异常栈直接跳到用户栈执行ret)。
2、递归触发页错误。与情况1不同的是,“用户栈”其实就是异常栈本身。因此,在跳到“用户栈”存储eip的时候,由于弹出的trap-time esp值恰好就是存储trap-time esp所在的位置(trap的时候esp指向异常栈该位置,然后跳到内核栈,内核再从异常栈该位置开始存储UTrapframe),存储eip会将trap-time esp覆盖,从而导致最后不能成功再跳回用户栈。这就是为什么如果是递归触发页错误的话,需要先留空4个字节,再存储UTrapframe,主要是为了防止存储eip的时候覆盖了原先的esp值。

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
#include <inc/mmu.h>
#include <inc/memlayout.h>

// Page fault upcall entrypoint.

.text
.globl _pgfault_upcall
_pgfault_upcall:
// Call the C page fault handler.
pushl %esp // function argument: pointer to UTF
movl _pgfault_handler, %eax
call *%eax
addl $4, %esp // pop function argument

// Now the C page fault handler has returned and you must return
// to the trap time state.
// Push trap-time %eip onto the trap-time stack.
//
// Explanation:
// We must prepare the trap-time stack for our eventual return to
// re-execute the instruction that faulted.
// Unfortunately, we can't return directly from the exception stack:
// We can't call 'jmp', since that requires that we load the address
// into a register, and all registers must have their trap-time
// values after the return.
// We can't call 'ret' from the exception stack either, since if we
// did, %esp would have the wrong value.
// So instead, we push the trap-time %eip onto the *trap-time* stack!
// Below we'll switch to that stack and call 'ret', which will
// restore %eip to its pre-fault value.
//
// In the case of a recursive fault on the exception stack,
// note that the word we're pushing now will fit in the
// blank word that the kernel reserved for us.
//
// Throughout the remaining code, think carefully about what
// registers are available for intermediate calculations. You
// may find that you have to rearrange your code in non-obvious
// ways as registers become unavailable as scratch space.
//
// LAB 4: Your code here.

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

// 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.

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

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

addl $8, %esp
movl %esp,%eax
addl $32,%esp
popl %ebx
addl $4,%esp
movl %esp, %ebp
popl %esp
pushl %ebx
movl %esp, 0x0(%ebp)
movl %eax,%esp
popal
addl $4,%esp
popf
popl %esp
ret

// do not use the following, cause I find that 'subl $4,%esp' will change eflags!

// addl $8, %esp
// movl %esp,%eax
// addl $32,%esp
// popl %ebx
// addl $4,%esp
// popl %esp
// pushl %ebx
// movl %eax,%esp
// popal
// addl $4,%esp
// popf
// popl %esp
// subl $4,%esp
// ret

1
Exercise 11. 完成lib/pgfault.c的set_pgfault_handler()。该函数主要完成页错误处理程序的注册工作。如果是第一次注册,则申请分配并映射异常栈。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void
set_pgfault_handler(void (*handler)(struct UTrapframe *utf))
{
int r;

if (_pgfault_handler == 0) {
// First time through!
// LAB 4: Your code here.
//panic("set_pgfault_handler not implemented");
if (sys_page_alloc(sys_getenvid(), (void *)(UXSTACKTOP-PGSIZE), PTE_P|PTE_U|PTE_W) < 0)
panic("in set_pgfault_handler, sys_page_alloc failed");
sys_env_set_pgfault_upcall(0, (void*) _pgfault_upcall);
}

// Save handler pointer for assembly to call.
_pgfault_handler = handler;
}

综上所述,用户级别的页错误处理流程如下:
1、用户程序通过lib/pgfault.c的set_pgfault_handler()函数注册页错误处理函数入口_pgfault_upcall(见lib/pfentry.S),并指定页错误处理函数_pgfault_handler,该函数指针将在_pgfault_upcall中被使用。第一次注册_pgfault_upcall时将申请分配并映射异常栈。
2、用户程序触发页错误,切换到内核模式。
3、内核检查页错误处理程序是否已设置、异常栈指针是否越界、是否递归触发页错误、是否已为异常栈分配物理页,然后在异常栈上存储UTrapframe,将栈指针指向异常栈,并跳转执行当前进程的env_pgfault_upcall(被设为用户级页错误处理程序入口_pgfault_upcall)。
4、用户模式下_pgfault_upcall调用_pgfault_upcall,执行用户级别页错误处理函数,在用户态的page_fault_handler结束后恢复现场并跳回原程序执行。

Testing

user/faultalloc.c涉及了递归触发页错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// test user-level fault handler -- alloc pages to fix faults

#include <inc/lib.h>

void
handler(struct UTrapframe *utf)
{
int r;
void *addr = (void*)utf->utf_fault_va;

cprintf("fault %x\n", addr);
if ((r = sys_page_alloc(0, ROUNDDOWN(addr, PGSIZE),
PTE_P|PTE_U|PTE_W)) < 0)
panic("allocating at %x in page fault handler: %e", addr, r);
snprintf((char*) addr, 100, "this string was faulted in at %x", addr);
}

void
umain(int argc, char **argv)
{
set_pgfault_handler(handler);
cprintf("%s\n", (char*)0xDeadBeef);
cprintf("%s\n", (char*)0xCafeBffe);
}

首先,snprintf的定义是int snprintf(char *buf, int n, const char *fmt, ...),向buf缓存区中按照格式填充字符串。
出错的原因是:当执行cprintf("%s\n", (char*)0xCafeBffe);时,触发了页错误。页错误处理函数在虚拟地址ROUNDDOWN(addr, PGSIZE)即cafeb000上分配和映射物理页,此时可访问的虚拟地址空间为[0xcafeb000,0xcafec000-1]。之后,调用snprintf向0xCafeBffe内存地址上填充字符串,由于字符串过长,导致越界访问0xcafec000,再次触发页错误。第二次页错误处理程序往0xcafec000填充字符串,返回到第一次页错误处理程序,第一次页错误处理程序往0xCafeBffe填充字符串,返回到用户程序,执行cprintf进行输出。

1
Challenge! 扩展jos内核,对运行用户程序代码时能触发的所有类型的处理器异常,都能重定向到用户模式的异常处理程序。编写用户模式的测试程序,测试divide-by-zero、general protection fault、illegal opcode等。
1
???

Implementing Copy-on-Write Fork

jos使用Copy-on-Write Fork时,将会扫描父进程的整个地址空间,并设置子进程的相关页映射,但不会复制页内容(dumpfork()复制页内容)。当父/子进程尝试写入某一页时,将申请新的一页并拷贝页内容到该页上。
fork()的基本流程如下:
1、父进程调用set_pgfault_handler()注册页错误处理函数pgfault()。
2、父进程调用sys_exofork()创建子进程。
3、对于[UTEXT,UTOP)每一个可写或写时复制的页,父进程调用duppage分别在子进程和自身的地址空间内映射该页为写时复制PTE_COW。
注意:不能将user exception stack映射为写时复制,而是应该为父子进程分别映射对应的物理页。这是因为页错误处理程序将在user exception stack上执行,当将user exception stack设为写时复制时,一旦发生页错误,内核将尝试往user exception stack写数据,由于user exception stack不可写而导致失败。
4、父进程为子进程注册页错误处理函数。
注意:我们不在子进程中注册页错误处理函数。首先,我们不在子进程中调用set_pgfault_handler()(该函数注册页错误处理函数,并在页错误处理函数未注册的情况下申请user exception stack),因为子进程是由父进程fork而来的,而父进程已经注册过页错误处理函数,所以在子进程中注册页错误处理函数的话子进程不会再申请user exception stack(if (_pgfault_handler == 0)条件判断失败)。其次,我们不在子进程中调用sys_page_alloc()和sys_env_set_pgfault_upcall()申请user exception stack,这是因为这涉及了函数调用,而父进程将子进程的用户栈映射为写时复制了,所以会触发页错误,但是此时还没有分配user exception stack,因此出错。
5、设置子进程状态为runnable。

页错误处理函数的处理流程如下:
1、内核跳转执行_pgfault_upcall,_pgfault_upcall将调用页错误处理函数pgfault()。
2、pgfault()确认fault是可写的(错误码的FEC_WR),并且引起页错误的虚拟地址对应的页表项权限为PTE_COW。
3、pgfault()为申请新的一页,映射到一个临时地址,将faulting page的内容复制到该新页,调用sys_page_map将映射到临时地址的物理页映射到引起页错误的虚拟地址,设置权限为可写。

1
Exercise 12.实现lib/fork.c的fork、duppage、pgfault。

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
// PTE_COW marks copy-on-write page table entries.
// It is one of the bits explicitly allocated to user processes (PTE_AVAIL).
#define PTE_COW 0x800
extern void _pgfault_upcall(void);

//
// 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.
if ((err & FEC_WR) == 0)
panic("pgfault: faulting address [%08x] not a write\n", addr);

if( (uvpd[PDX(addr)] & PTE_P) != PTE_P ||
(uvpt[PGNUM(addr)] & PTE_P) != PTE_P ||
(uvpt[PGNUM(addr)] & PTE_COW) != PTE_COW){
//cprintf("(uvpt[PTX(addr)] & PTE_P)=%x,PTE_P=%x\n",(uvpt[PGNUM(addr)] & PTE_P),PTE_P);
panic("not copy-on-write");
}
// 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.

//panic("pgfault not implemented");
addr = ROUNDDOWN(addr, PGSIZE);
if ((r = sys_page_alloc(0, PFTEMP, PTE_P|PTE_U|PTE_W)) < 0)
panic("allocating at %x in page fault handler: %e", addr, r);
memmove(PFTEMP, addr, PGSIZE);
//cprintf("in user pgfault,envid = %x\n",thisenv->env_id);
if ((r = sys_page_map(0, PFTEMP, 0, addr, PTE_P|PTE_U|PTE_W)) < 0)
panic("sys_page_map: %e", r);
if ((r = sys_page_unmap(0, PFTEMP)) < 0)
panic("sys_page_unmap: %e", r);
}

//
// 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.
//panic("duppage not implemented");
void *addr = (void *)(pn*PGSIZE);
if( (uvpt[pn] & PTE_W) == PTE_W ||
(uvpt[pn] & PTE_COW) == PTE_COW){
if ((r = sys_page_map(0, (void *)addr, envid, (void *)addr, PTE_P|PTE_U|PTE_COW)) < 0){
panic("sys_page_map: %e", r);
}
if ((r = sys_page_map(0, (void *)addr, 0, (void *)addr, PTE_P|PTE_U|PTE_COW)) < 0){
panic("sys_page_map: %e", r);
}
}else{
if ((r = sys_page_map(0, (void *)addr, envid, (void *)addr, PTE_P|PTE_U)) < 0){
panic("sys_page_map: %e", r);
}
}
return 0;
}

//
// 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.
//panic("fork not implemented");
set_pgfault_handler(pgfault);

envid_t envid;
unsigned addr;
int r;
extern unsigned char end[];

envid = sys_exofork();
if (envid < 0)
panic("sys_exofork: %e", envid);
if (envid == 0) {
// We're the child.
// DO NOT CALL set_pgfault_handler DIRECTLY! Cause we are forked from parent who has
// called "set_pgfault_handler", so the variable named "_pgfault_handler" in pgfault.c has been set,
// and child-env will not call "sys_page_alloc" and "sys_env_set_pgfault_upcall"
// do not call: set_pgfault_handler(pgfault);

// DO NOT SET USER EXCEPTION STACK IN CHILD! Cause we set COW on user stack(not user exception stack), so function call
// like "sys_page_alloc" and "sys_env_set_pgfault_upcall"(which want to stack user excettion stack)
// will cause page fault, and kern will use user excettion stack, but it has not been set!!
// do not call:
// if (sys_page_alloc(0, (void *)(UXSTACKTOP-PGSIZE), PTE_P|PTE_U|PTE_W) < 0)
// panic("in fork, sys_page_alloc failed");
// if (sys_env_set_pgfault_upcall(0, (void*) _pgfault_upcall) < 0)
// panic("in fork, sys_env_set_pgfault_upcall failed");
//

thisenv = &envs[ENVX(sys_getenvid())];
return 0;
}

// We're the parent.
// map the page copy-on-write into the address space of the child
// and then remap the page copy-on-write in its own address space
for (addr = UTEXT; addr < (unsigned)end; addr += PGSIZE){
duppage(envid, PGNUM(addr));
}
// map the stack we are currently running on.
duppage(envid, PGNUM(USTACKTOP - PGSIZE));
// also can:
/*
for (addr = 0; addr < USTACKTOP; addr += PGSIZE)
if ((uvpd[PDX(addr)] & PTE_P) && (uvpt[PGNUM(addr)] & PTE_P)
&& (uvpt[PGNUM(addr)] & PTE_U)) {
duppage(envid, PGNUM(addr));
}
*/

if (sys_page_alloc(envid, (void *)(UXSTACKTOP-PGSIZE), PTE_P|PTE_U|PTE_W) < 0)
panic("in fork, sys_page_alloc failed");
if (sys_env_set_pgfault_upcall(envid, (void*) _pgfault_upcall) < 0)
panic("in fork, sys_env_set_pgfault_upcall failed");

// Start the child environment running
if ((r = sys_env_set_status(envid, ENV_RUNNABLE)) < 0)
panic("sys_env_set_status: %e", r);

return envid;
}

关于uvpd和uvpt数组

为了能通过虚拟地址访问到页表和页目录,jos设计了UVPT和UVPD(见Lab3笔记)。一旦设置uvpt数组并指向UVPT,uvpt相当于包含1M个页表项的数组。对于[0, 4G)的线性地址,其在通过地址转换过程中找到的页表项,刚好是以线性地址前20位为索引在uvpt中的项,即uvpt[PGNUM(线性地址)]。对于[UVPT, UVPT+4M)的线性地址,其在uvpt数组中索引到的刚好是页目录这张页表中的项。
一旦设置uvpd数组并指向(UVPT+(UVPT>>12)*4),则uvpd相当于包含1K个页目录项的数组。

另外,注意到每个进程访问uvpt[]和uvpd[]时,都是彼此独立的。这是因为:如访问uvpt[i],最终是访问uvpt[i]相关联的UVPT之上的一个虚拟地址,每个进程的页目录和页表都是根据自身情况请求分配页之后的映射结果,如果进程A在该虚拟地址上没有映射页,则uvpt[i]为空,而如果进程B在该虚拟地址上有映射页,则uvpt[i]不为空。

1
Challenge! 完成sfork,使父子进程能共享内存,除了用户栈和用户异常栈。

请注意,sys_exofork创建的子进程页目录只映射了内核空间,我们需要自行映射子进程的地址空间。另一方面,页表项低12位表示权限,但是sys_page_map中不支持设置PTE_AVAIL | PTE_P | PTE_W | PTE_U以外的权限位,因此需要对页表项权限提取进行&处理。

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
int
sfork(void)
{
set_pgfault_handler(pgfault);

envid_t envid;
unsigned addr;
int r;
extern unsigned char end[];

envid = sys_exofork();
if (envid < 0)
panic("sys_exofork: %e", envid);
if (envid == 0) {
thisenv = &envs[ENVX(sys_getenvid())];
return 0;
}

// We're the parent.
// share memory
for (addr = UTEXT; addr < (unsigned)end; addr += PGSIZE){
// pte's low 12 bits,that means perm,
// however, in sys_page_map, no other bits may be set except PTE_SYSCALL
// #define PTE_SYSCALL (PTE_AVAIL | PTE_P | PTE_W | PTE_U)
int perm = uvpt[PGNUM(addr)] & PTE_SYSCALL;
if ((r = sys_page_map(0, (void *)addr, envid, (void *)addr, perm)) < 0){
panic("sys_page_map: %e", r);
}
}
// map the stack we are currently running on, set copy-on-write.
duppage(envid, PGNUM(USTACKTOP - PGSIZE));

if (sys_page_alloc(envid, (void *)(UXSTACKTOP-PGSIZE), PTE_P|PTE_U|PTE_W) < 0)
panic("in fork, sys_page_alloc failed");
if (sys_env_set_pgfault_upcall(envid, (void*) _pgfault_upcall) < 0)
panic("in fork, sys_env_set_pgfault_upcall failed");

// Start the child environment running
if ((r = sys_env_set_status(envid, ENV_RUNNABLE)) < 0)
panic("sys_env_set_status: %e", r);

return envid;
}

1
Challenge! fork()使用了大量的系统调用接口。为了降低成本,扩展jos的系统调用接口,使之支持批量调用系统调用,并让fork使用该接口。可参考IA32的RDTSC指令。
1
???

Part C: Preemptive Multitasking and Inter-Process communication (IPC)

Clock Interrupts and Preemption

user/spin程序中子进程一旦获取CPU将永远嵌入循环。为了保证内核能够抢占正在运行的进程,需要扩展内核以支持时钟硬件的外部硬件中断。

Interrupt discipline

外部中断被称为IRQs,共有16个,编号为0-15。IRQs到IDT表项的映射不是固定的,picirq.c的pic_init通过IRQ_OFFSET-IRQ_OFFSET+15将IRQs 0-15映射到IDT项中。在inc/trap.h,IRQ_OFFSET被定义为32,所以IDT的32-47项映射到IRQs 0-15。
在jos中,一个关键的简化是一旦处于内核模式就禁用了外部设备中断。外部中断由eflags的FL_IF位控制。虽然有多种方式可以修改该位,但由于jos的简化处理,我们仅需要在进入和退出用户模式的时候通过保存和恢复eflags寄存器来处理它即可。

1
Exercise 13. 修改kern/trapentry.S和kern/trap.c,初始化相关的IDT项,并提供IRQs 0-15的处理函数。然后修改kern/env.c的env_alloc(),在允许外部中断的情况下执行用户进程。当调用硬件中断处理函数的时候,处理器不会将错误码进栈,也不会检查IDT项的DPL。

相关资料可查询 section 9.2 of the 80386 Reference Manual 或 section 5.8 of the IA-32 Intel Architecture Software Developer’s Manual, Volume 3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// kern/trapentry.S
// 处理器不会将错误码进栈
# IRQs 0
TRAPHANDLER_NOEC(entry_point32,IRQ_OFFSET+IRQ_TIMER)
# IRQs 1
TRAPHANDLER_NOEC(entry_point33,IRQ_OFFSET+IRQ_KBD)
# IRQs 4
TRAPHANDLER_NOEC(entry_point36,IRQ_OFFSET+IRQ_SERIAL)
# IRQs 7
TRAPHANDLER_NOEC(entry_point39,IRQ_OFFSET+IRQ_SPURIOUS)
# IRQs 14
TRAPHANDLER_NOEC(entry_point46,IRQ_OFFSET+IRQ_IDE)

// kern/trap.c, trap_init()
for (i = 0; i < 16; ++i){
SETGATE(idt[IRQ_OFFSET+i], 0, GD_KT, entry_points[IRQ_OFFSET+i], 0);
}

// kern/env.c, env_alloc()
// 创建用户进程的时候设置允许中断标志,这样就可以在运行用户进程的时候通过中断来控制进程。
e->env_tf.tf_eflags |= FL_IF;

Handling Clock Interrupts

lapic_init和pic_init进行了时钟和中断的相关设置,以生成中断。需要对中断进行相关处理。

1
Exercise 14. 修改trap_dispatch()函数,在每次触发时间中断的时候调用sched_yield()函数切换执行其他进程。测试案例为user/spin。

1
2
3
4
5
6
7
8
9
10
11
// trap_dispatch() 
switch(tf->tf_trapno) {
...
// Handle clock interrupts. Don't forget to acknowledge the
// interrupt using lapic_eoi() before calling the scheduler!
// LAB 4: Your code here.
case (IRQ_OFFSET + IRQ_TIMER):
lapic_eoi();
sched_yield();
break;
...

Inter-Process communication (IPC)

进程隔离和进程通信是两个重要课题,Unix的管道模型是典型的进程通信例子。进程间有许多通信模型,jos将实现一种简单的IPC机制。

IPC in JOS

jos实现的IPC机制是扩展系统调用接口sys_ipc_recv和sys_ipc_try_send,并实现库函数ipc_recv和ipc_send。jos的进程间通信信息包括两个部分:一个32位数和一个可选的页面映射。允许传递页面映射的做法一方面可以传递更多数据,一方面进程可以更方便地设置和安排内存共享。

Sending and Receiving Messages

进程希望接收消息时,调用sys_ipc_recv。该系统调用取消进程的执行,直到接收到消息。如果一个进行在等待接收消息,任何进程都可以发消息给它,而不局限于特定进程或有父子关系的进程。因此,Part A的权限检查不适用于IPC,因为IPC的设计确保进程A发送消息给进程B不能导致进程B发生故障。
进程希望发送消息时,调用sys_ipc_try_send,并传递目标进程号和参数。如果目标进程调用了sys_ipc_recv并且还没收到消息,系统调用将递交消息并返回0,否则返回-E_IPC_NOT_RECV。
库函数ipc_recv主要负责调用sys_ipc_recv和查找接收到的信息中所需要的部分。
库函数ipc_send主要负责重复调用sys_ipc_try_send,直至发送成功。

Transferring Pages

当进程携带dstva参数调用sys_ipc_recv时,表明它希望收到一个页映射。这样当发送者发送一个页时,接收者需要将dstva映射到自己的地址空间(如果原来已经有了页映射,则解除原来的映射)。
当进程携带srcva参数调用sys_ipc_try_send时,表明它希望将当前映射到srcva的页发送出去,权限是perm。发送成功后,发送者保持srcva的原有映射,接收者则有了新的页映射。从而达到共享页的目的。
如果发送者和接收者都没有指明要传递页,则没有页被传递。对于任何一个IPC,内核都会在接收者的Env结构体中的env_ipc_perm上设置接收页的权限。

1
Exercise 15. 实现kern/syscall.c的sys_ipc_recv和sys_ipc_try_send。调用envid2env的时候将checkperm设为0,表示任何进程都可以发送IPC消息。实现lib/ipc.c的ipc_recv和ipc_send。测试案例为user/pingpong和user/primes。

大体上的思路是:ipc_send负责调用sys_ipc_try_send,如果调用失败(如目标进程尚未准备好接收)则循环尝试,但为了不霸占资源,在循环体内调用sys_yield由内核选择进程的执行。目标进程尚未准备好接收的情况下尝试循环send,对其他返回的错误直接panic。原型为void ipc_send(envid_t to_env, uint32_t val, void *pg, int perm),表示发送参数val,并将映射到pg的物理页映射到目标进程地址空间上,权限设为perm。
sys_ipc_try_send负责检查目标进程是否存在、目标进程是否准备好接收信息、目标进程是否要求接收页映射。如果目标进程要求接收页映射,则检查发送者提供的虚拟地址是否有效并页对齐、发送者提供的映射权限是否合理、发送者提供的虚拟地址是否有映射页、是否尝试将只读页映射为可写、是否尚有物理页可供接收者分配和映射,检查通过后将物理页映射到目标进程指定的虚拟地址上。最后设置目标进程env结构体相关参数,以说明由谁发送、接收的值是什么,更改目标进程接收信息标识为已接收,将目标进程设为可执行,解除其阻塞状态。原型为static int sys_ipc_try_send(envid_t envid, uint32_t value, void *srcva, unsigned perm)。
ipc_recv负责调用sys_ipc_recv,原型为int32_t ipc_recv(envid_t *from_env_store, void *pg, int *perm_store),如果pg为空,代表接收者不希望接收页映射,否则表示希望发送者将页映射到pg上。调用sys_ipc_recv之后进程状态被更改为不可执行、阻塞,直至有进程发送消息给它并将其状态更改为可执行。接收到消息之后,存储发送者进程id以及映射权限,然后返回接收到的32位的ipc值。
sys_ipc_recv负责将给定的虚拟地址设置到env结构体的env_ipc_dstva上,表示希望发送者将页映射到其上,然后更改接收信息标识为等待接收,并更改进程的运行状态为不可执行。该系统调用将不会返回到用户模式,直至有发送者发送消息给它并解除其阻塞状态。

如果接收者要求接收页映射而发送者没有发送页映射,则出错;如果接收者不要求接收页映射而发送者发送页映射,不会出错。
接收者期望映射的虚拟地址不必与发送者发送的虚拟地址一致。
无需指定ipc_recv和ipc_send的先后调用顺序,因为即使接收者未准备好接收,发送者仍会判断sys_ipc_recv的返回结果,一旦是由于接收者未准备好接收而引起的错误,发送者会循环尝试发送。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void
ipc_send(envid_t to_env, uint32_t val, void *pg, int perm)
{
// LAB 4: Your code here.
//panic("ipc_send not implemented");
int r;
if (!pg){
pg = (void *)IPC_NOPAGE;
}
while((r = sys_ipc_try_send(to_env, val, pg, perm))){
if(r == 0){
break;
}
if(r != -E_IPC_NOT_RECV){
panic("sys_ipc_try_send error, not -E_IPC_NOT_RECV, result: %d",r);
}
sys_yield();
}
}

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
static int
sys_ipc_try_send(envid_t envid, uint32_t value, void *srcva, unsigned perm)
{
// LAB 4: Your code here.
// panic("sys_ipc_try_send not implemented");
struct Env *e;
int r = envid2env(envid,&e, 0);
if(r<0) return r;//-E_BAD_ENV
// target is not blocked, waiting for an IPC.
if(!e->env_ipc_recving) return -E_IPC_NOT_RECV;
// if receiver asking for one page
if ((uint32_t)e->env_ipc_dstva != IPC_NOPAGE && (uint32_t)e->env_ipc_dstva < UTOP) {
// if srcva < UTOP but srcva is not page-aligned.
if((uint32_t)srcva >= UTOP || (uint32_t)srcva%PGSIZE != 0) return -E_INVAL;
// if srcva < UTOP and perm is inappropriate
if((perm & PTE_U) != PTE_U || (perm & PTE_P) != PTE_P) return -E_INVAL;
//#define PTE_SYSCALL (PTE_AVAIL | PTE_P | PTE_W | PTE_U)
if ((perm & ~PTE_SYSCALL) != 0) //no other bits may be set
return -E_INVAL;
// if srcva < UTOP but srcva is not mapped in the caller's address space
pte_t *pte_store;
struct PageInfo *page = page_lookup(curenv->env_pgdir, srcva, &pte_store);
if(page == NULL) return -E_INVAL;//no page mapped at srcva
// if (perm & PTE_W), but srcva is read-only in the current environment's address space
if((perm & PTE_W) == PTE_W && (*pte_store & PTE_W) == 0) return -E_INVAL;

// if there's not enough memory to map srcva in envid's address space
// or if e->env_ipc_dstva invalid
r = page_insert(e->env_pgdir, page, e->env_ipc_dstva, perm);
if (r < 0) return r;// -E_NO_MEM
e->env_ipc_perm = perm;
}else{
e->env_ipc_perm = 0;
}
e->env_ipc_recving = 0;
e->env_ipc_from = curenv->env_id;
e->env_ipc_value = value;
e->env_status = ENV_RUNNABLE;
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
int32_t
ipc_recv(envid_t *from_env_store, void *pg, int *perm_store)
{
// LAB 4: Your code here.
//panic("ipc_recv not implemented");
if(!pg) pg = (void *)IPC_NOPAGE;
int r = sys_ipc_recv(pg);
if(r < 0) return r;
if(from_env_store) *from_env_store = thisenv->env_ipc_from;
if(perm_store) *perm_store = thisenv->env_ipc_perm;
return thisenv->env_ipc_value;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static int
sys_ipc_recv(void *dstva)
{
// LAB 4: Your code here.
//panic("sys_ipc_recv not implemented");
// if dstva < UTOP but dstva is not page-aligned.
if((uint32_t)dstva == IPC_NOPAGE){
curenv->env_ipc_dstva = (void*)IPC_NOPAGE;
}else if((uint32_t)dstva >= UTOP || (uint32_t)dstva%PGSIZE != 0) {
return -E_INVAL;
}else{
curenv->env_ipc_dstva = dstva;
}
curenv->env_ipc_recving = 1;
curenv->env_status = ENV_NOT_RUNNABLE;
return 0;
}
1
Challenge! 为何ipc_send需要循环尝试发送?

原因见上分析。

1
Challenge! prime sieve只是大量并发程序之间消息传递的一种灵巧应用。阅读 C. A. R. Hoare, ``Communicating Sequential Processes,'' Communications of the ACM 21(8) (August 1978), 666-667 ,实现矩阵乘法的例子。

1
Challenge! Doug McIlroy开发的幂级数计算器是典型的消息传递例子,阅读  M. Douglas McIlroy, ``Squinting at Power Series,'' Software--Practice and Experience, 20(7) (July 1990), 661-683 (http://plan9.bell-labs.com/who/rsc/thread/squint.pdf),实现该计算器并计算sin(x+x^3)的幂级数。
1
Challenge! 阅读 Improving IPC by Kernel Design (http://dl.acm.org/citation.cfm?id=168633),改进jos的IPC效率。可扩展系统调用接口。

笔记015 - HW7: Locking

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

spin lock

锁的错误实现:

1
2
3
4
5
6
7
8
9
struct lock { int locked; }
acquire(lk) {
while(1){
if(lk->locked == 0){ // A
lk->locked = 1; // B
return;
}
}
}

这种方法存在问题,因为如果lk->locked为0时,第一个线程进入到if内,正打算执行lk->locked = 1,这时发生了一个时钟中断,切换到第二个线程。对于第二个线程,这时lk->lock依旧为0,因此也会进入到if语句内。这是因为A和B不是原子操作造成的。

实现A和B为原子操作:

1
2
3
4
5
6
7
acquire(lk){
while(1){
if(xchg(&lk->locked, 1) == 0){
break
}
}
}

xchg是两个寄存器之间,或寄存器和内存变量之间内容的交换指令(两个操作数不能同时为内存变量。)。xv6的xchg函数是借助xchg指令完成以下工作:
假如lk->locked已经是1,xchg再次设置lk->locked为1并且返回1,循环继续;
假如lk->locked是0,至多只有一次xchg会遇到0它将设置lk->locked为1并且返回0,跳出循环,其他的xchgs将会返回1,继续循环。

这就是xv6的自旋锁。

使用锁的过程中关中断

使用xv6的自旋锁的过程中需要关中断。
以IDE驱动为例。ide.c/iderw维护着一个disk请求的队列,处理器会并发地将新请求加入到队列中。为了维护该驱动的队列和其他变量,iderw在操作过程中会请求idelock锁,并在操作完成时释放锁。另一方面,假如发生了中断,会调用ideintr函数。

acquire请求idelock锁的过程中会屏蔽中断,假如iderw调用了acquire保持了对idelock锁的持有,如果这时候发生了ide中断(比如在acquire之后我们尝试调用sti()开中断),则会调用中断处理函数ideintr,ideintr函数也需要调用acquire请求idelock锁,此时发现锁被其他程序持有,则会一直等待锁的释放。然而,iderw函数不会继续执行,因为中断处理函数ideintr不会返回。这就造成了死锁,所以使用xv6的自旋锁的过程中需要关中断。

另外,需要注意的是必须在执行xchg之前关中断,因为如果是在xchg之后关中断仍有可能出现:iderw在操作过程中会请求idelock锁,执行xchg之后关中断之前,就发生了中断,这样仍会导致死锁。

release将1、先释放锁,然后2、开中断。释放锁的过程就是使lk->locked = 0,但不能直接使用c语言赋值,因为其对应的指令可能不是原子操作,且处理器可能会对内存进行排序,导致开中断先于释放锁操作的执行,倘若此时又发生中断,将可能导致死锁。xv6使用以下指令保证释放锁是原子操作:asm volatile("movl $0, %0" : "+m" (lk->locked) : );。

避免使用递归锁

递归锁是指在某个CPU上有了一次acquire锁操作,之后还允许在该CPU上的第二次acquire操作。避免递归锁是必要的,这是因为acquire锁的过程中可能是在维护某些数据结构,如果允许了递归锁,CPU可能暂停当前维护数据结构的程序,并切换执行另一段代码,假如该代码是以“被维护的数据结构是完整的”的前提进行工作,则可能发生错误。因此,一旦发现递归锁,则panic。所以,两次acquire(&lk);会导致panic。

ide.c/iderw开中断

假如在ide.c/iderw中acquire()之后添加sti(),并在release()之前添加cli(),将会出现两种panic情况。panic定义在console.c中,会以输出字符串在栈中的位置为基准,往回输出函数调用的eip值,根据这些eip值在kernel.asm查询,可以查看到函数的调用顺序,有以下两种情况:
情况1:panic->acquire->ideintr->trap->iderw->bread->initlog->trapret(由后往前输出)
该情况是在iderw的执行过程中发生了ide中断,最终导致递归锁的出现而发生panic。
情况2:panic->sched->yield->trap->iderw->bread->readsb->iinit->trapret(由后往前输出)
该情况是在iderw的执行过程中发生了时钟中断,调用了yield,yield请求yieldlock,然后调用sched,在sched中检测了cpu->ncli这个变量,因为这个cpu获得了两把锁,所以这个值是2,于是就panic了。

pcs存储的是eip值,通过getcallerpcs实现,getcallerpcs的参数v代表当前栈内的最后一个参数。我们知道,函数调用的时候栈的布局如下:

因此,ebp的位置是v的位置减去2。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//see in spinlock.c
// Record the current call stack in pcs[] by following the %ebp chain.
void
getcallerpcs(void *v, uint pcs[])
{
uint *ebp;
int i;

ebp = (uint*)v - 2;
for(i = 0; i < 10; i++){
if(ebp == 0 || ebp < (uint*)KERNBASE || ebp == (uint*)0xffffffff)
break;
pcs[i] = ebp[1]; // saved %eip
ebp = (uint*)ebp[0]; // saved %ebp
}
for(; i < 10; i++)
pcs[i] = 0;
}

file.c/filealloc开中断

假如在file.c/filealloc中acquire()之后添加sti(),并在每一个release()之前添加cli(),同时添加#include "x86.h",却不会发生panic现象。原因是file_table_lock保护的临界区足够短,时钟中断还来不及触发。如果在该临界区中加一段长时间的while循环,就会panic。

xv6 lock implementation

release()函数中,lk->pcs[0]和lk->cpu的清除工作必须在lk->locked置零前进行,这是因为如果在清除lk->locked之后清除,那么就会有一小段空隙,在这个空隙里调度到其他等待锁的程序,就会成功获得这个锁,然后设置lk->pcs[]和lk->cpu;此时再调度到原来的进程,原来的进程继续将lk->pcs[]和lk->cpu清除,就会导致出错。

补充

自旋锁与互斥量的区别:互斥量通过休眠使进程阻塞,不阻塞中断;自旋锁在获取锁之前一直处于忙等(自旋)状态,除了提供互斥机制以外,它们会阻塞中断(如果不阻塞中断,如果在获取自旋锁后发生中断,中断处理程序尝试获取同一个锁,并忙等待不返回)。

自旋锁可用于以下情况:锁被持有的时间短,而且线程并不希望在重新调度上花费太多的成本。

自旋锁常用场景:非抢占式内核,中断程序。

非抢占式内核使用自旋锁而不是互斥量的原因:(这里讨论的自旋锁包括xchg互斥和关中断两个部分)中断程序要求快速响应,不能阻塞。如果内核使用互斥量的概念,则可能获取锁之后发生中断,从而导致中断程序阻塞等待锁释放的情况发生。

笔记014 - HW6: Threads and Locking

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

题意

提供一个指针数组struct entry *table[NBUCKET],启动多个thread按照key % NBUCKET的结果往指针数组的某一个项的链表插入entry。由于并发过程中没有进行锁控制,如果多个thread同时操作同一个链表,可能会导致某个线程插入失败,例如:

1
2
3
4
5
6
7
8
t-A: calls insert() on bucket 1 (key = 6, 6 % NBUCKETS = 1)
t-B: calls insert() on bucket 1 (key = 21, 21 % NBUCKETS = 1)
...
...
t-A: executes e->next = n
t-B: executes e->next = n
t-A: executes *p = e
t-B: executes *p = e

t-A在执行e->next = n之后、执行*p = e之前,t-B执行了e->next = n,从而导致t-A没有成功插入。

解决思路

可参考的函数使用:

1
2
3
4
5
互斥锁的使用:
pthread_mutex_t lock; // declare a lock
pthread_mutex_init(&lock, NULL); // initialize the lock
pthread_mutex_lock(&lock); // acquire lock
pthread_mutex_unlock(&lock); // release lock

在put函数中添加锁控制。

1
2
3
4
5
6
7
8
9
10
static 
void put(int key, int value)
{
pthread_mutex_lock(&lock); // acquire lock

int i = key % NBUCKET;
insert(key, value, &table[i], table[i]);

pthread_mutex_unlock(&lock); // release lock
}

编译并运行:

1
2
$ gcc -g -O2 ph.c -pthread
$ ./a.out 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
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
#include <assert.h>
#include <pthread.h>
#include <sys/time.h>

#define SOL
#define NBUCKET 5
#define NKEYS 100000

struct entry {
int key;
int value;
struct entry *next;
};
struct entry *table[NBUCKET];
int keys[NKEYS];
int nthread = 1;
volatile int done;

pthread_mutex_t lock; // declare a lock

double
now()
{
struct timeval tv;
gettimeofday(&tv, 0);
return tv.tv_sec + tv.tv_usec / 1000000.0;
}

static void
print(void)
{
int i;
struct entry *e;
for (i = 0; i < NBUCKET; i++) {
printf("%d: ", i);
for (e = table[i]; e != 0; e = e->next) {
printf("%d ", e->key);
}
printf("\n");
}
}

static void
insert(int key, int value, struct entry **p, struct entry *n)
{
struct entry *e = malloc(sizeof(struct entry));
e->key = key;
e->value = value;
e->next = n;
*p = e;
}

static
void put(int key, int value)
{
pthread_mutex_lock(&lock); // acquire lock

int i = key % NBUCKET;
insert(key, value, &table[i], table[i]);

pthread_mutex_unlock(&lock); // release lock
}

static struct entry*
get(int key)
{
struct entry *e = 0;
for (e = table[key % NBUCKET]; e != 0; e = e->next) {
if (e->key == key) break;
}
return e;
}

static void *
thread(void *xa)
{
long n = (long) xa;
int i;
int b = NKEYS/nthread;
int k = 0;
double t1, t0;

// printf("b = %d\n", b);
t0 = now();
for (i = 0; i < b; i++) {
// printf("%d: put %d\n", n, b*n+i);
put(keys[b*n + i], n);
}
t1 = now();
printf("%ld: put time = %f\n", n, t1-t0);

// Should use pthread_barrier, but MacOS doesn't support it ...
__sync_fetch_and_add(&done, 1);
while (done < nthread) ;

t0 = now();
for (i = 0; i < NKEYS; i++) {
struct entry *e = get(keys[i]);
if (e == 0) k++;
}
t1 = now();
printf("%ld: lookup time = %f\n", n, t1-t0);
printf("%ld: %d keys missing\n", n, k);
return NULL;
}

int
main(int argc, char *argv[])
{
pthread_mutex_init(&lock, NULL); // initialize the lock

pthread_t *tha;
void *value;
long i;
double t1, t0;

if (argc < 2) {
fprintf(stderr, "%s: %s nthread\n", argv[0], argv[0]);
exit(-1);
}
nthread = atoi(argv[1]);
tha = malloc(sizeof(pthread_t) * nthread);
srandom(0);
assert(NKEYS % nthread == 0);
for (i = 0; i < NKEYS; i++) {
keys[i] = random();
}
t0 = now();
for(i = 0; i < nthread; i++) {
assert(pthread_create(&tha[i], NULL, thread, (void *) i) == 0);
}
for(i = 0; i < nthread; i++) {
assert(pthread_join(tha[i], &value) == 0);
}
t1 = now();
printf("completion time = %f\n", t1-t0);
}

笔记013 - HW5: xv6 CPU alarm

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

任务

添加一个alarm(interval, handler)系统调用,表示每过interval ticks个CPU time,内核将会调用handler函数。之后,程序将会在调用alarm系统调用的地方恢复执行。
alarmtest.c示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include "types.h"
#include "stat.h"
#include "user.h"

void periodic();

int
main(int argc, char *argv[])
{
int i;
printf(1, "alarmtest starting\n");
alarm(10, periodic);
for(i = 0; i < 50*500000; i++){
if((i++ % 500000) == 0)
write(2, ".", 1);
}
exit();
}

void
periodic()
{
printf(1, "alarm!\n");
}

alarm对应的系统调用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int
sys_alarm(void)
{
int ticks;
void (*handler)();

if(argint(0, &ticks) < 0)
return -1;
if(argptr(1, (char**)&handler, 1) < 0)
return -1;
proc->alarmticks = ticks;
proc->alarmhandler = handler;
return 0;
}

执行结果类似于:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ alarmtest
alarmtest starting
.....alarm!
....alarm!
.....alarm!
......alarm!
.....alarm!
....alarm!
....alarm!
......alarm!
.....alarm!
...alarm!
...$

思路

参考其他系统调用如uptime的实现,查看alarm系统调用需要声明的位置:

alarmtest处理为可执行用户程序,在Makefile中添加:

注意点:
1、proc->alarmticks记录用户程序指定的CPU time间隔,proc->alarmhandler记录内核将会调用的函数入口地址。此外还需要proc->alarmticked记录是否达到指定的CPU time间隔个数。这三个添加到proc中,见proc.h。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Per-process state
struct proc {
uint sz; // Size of process memory (bytes)
pde_t* pgdir; // Page table
char *kstack; // Bottom of kernel stack for this process
enum procstate state; // Process state
int pid; // Process ID
struct proc *parent; // Parent process
struct trapframe *tf; // Trap frame for current syscall
struct context *context; // swtch() here to run process
void *chan; // If non-zero, sleeping on chan
int killed; // If non-zero, have been killed
struct file *ofile[NOFILE]; // Open files
struct inode *cwd; // Current directory
char name[16]; // Process name (debugging)

//add by jianzzz, for alarm...
int alarmticks;
int alarmticked;
void (*alarmhandler)();
};

在allocproc中初始化,见proc.h。

1
2
3
4
5
6
7
8
9
10
11
12
13
//在进程表中寻找slot,成功的话更改进程状态embryo和pid,并初始化进程的内核栈
static struct proc*
allocproc(void)
{
...
found:
...
//add by jianzzz,for alarm
p->alarmticks = 0;
p->alarmticked = 0;
p->alarmhandler = 0;
return p;
}

2、每过一个tick的CPU time,硬件clock将会触发一个中断,并在trap()的case T_IRQ0 + IRQ_TIMER下被处理。同时,需要注意我们只在有用户程序被执行并且alarm中断来自用户态的情况下才对alarm中断进行处理。
3、需要保证当alarm中断函数执行完之后,成功返回并恢复执行用户程序。
4、可以通过make CPUS=1 qemu通知qemu只使用一个CPU。

实现代码如下:

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
//see in trap.c
void
trap(struct trapframe *tf)
{
...
switch(tf->trapno){
case T_IRQ0 + IRQ_TIMER:
if(cpunum() == 0){
acquire(&tickslock);
ticks++;
wakeup(&ticks);
release(&tickslock);
}
lapiceoi();

if(proc && (tf->cs & 3) == 3){
//(*proc->alarmhandler)();
proc->alarmticked += 1;
if(proc->alarmticked == proc->alarmticks) {
proc->alarmticked = 0;
tf->esp -= 4;
*(uint *) tf->esp = tf->eip;
tf->eip = (uint) proc->alarmhandler;
}
}

break;
...
}

补充说明

1、如果直接执行在case T_IRQ0 + IRQ_TIMER执行(*proc->alarmhandler)();会怎样?
结果应该是可以调用到handler但是结果是错误的。可以调用到handler是因为:xv6将内核空间映射到了用户进程的页目录中(见vm.c setupkvm()),而在用户进程调用系统调用并由用户态切换到内核态并执行系统调用的时候,一直使用的是用户进程的页目录(需要确定执行用户进程过程中发生中断是否也一直使用用户进程的页目录!),因此中断发生的时候,alarm(interval, handler)指定的用户函数handler对内核是可见的。调用结果是错误的是因为:由alarmtest.c可以看出用户函数handler会调用printf函数,printf函数会调用write系统调用。在内核态下处理中断时,使用的是进程的内核栈,内核调用write是不会切换栈的,此刻write的参数放到内核栈中而不是用户栈中;但是write的系统函数sys_write会从tf->esp所指向的用户栈来获取参数,从而无法访问正确的栈。
换句话说,handler应该是在用户态而不是在内核态下被执行。

2、如何确保能成功执行handler,并返回用户程序?
发生中断时,触发中断的指令的下一条指令位置会被记录,对应于tf->eip。tf->esp指向用户栈空间。因此,将tf->esp指向的用户栈位置向低位移出一个空位,然后存储触发中断的指令的下一条指令位置,并将tf->eip指向handler入口。这样子会中断返回之后将根据tf->eip在用户态下执行handler,handler函数执行完毕后自动调用ret,从用户栈弹出值作为eip,该值就是触发中断的指令的下一条指令位置,从而返回用户程序。

笔记012 - x86 v6 book | Chapter 3 Traps, interrupts, and drivers

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

Systems calls, exceptions, and interrupts

术语词:本章中trap和interrupt概念是可以互换的。但trap通常由当前在一个处理器上执行的程序所引起(例如当前程序调用系统调用并生成trap);interrupt通常由设备引起,与当前执行程序没有关系。比如,磁盘可能在为某个程序检索完一个block之后生成interrupt,但interrupt生成的时候可能处理器正在执行其他程序。interrupt同其他活动可能是并发发生的,所以比trap的情况更复杂。尽管x86的官方术语是interrupt,xv6源码将所有情况都参考为trap,很大原因是因为PDP11/40使用了这个术语,这也是传统的Unix术语。

X86 protection

xv6有4个保护级别,编号为0-3,0代表最高权限。实际上大多数OS只使用两个级别,0为内核模式,3为用户模式。xv6在执行指令的时候,当前特权级别保存在%cs中的CPL域。

IDT定义了xv6的中断处理程序,共有256项,处理相关中断的时候,IDT项给出了相应的%cs和%eip。

xv6通过int n的方式调用系统调用,n表示IDT的第n项,int指令包括以下步骤:
1、从IDT中找到第n个描述符,n是int指令的参数。
2、检查是否满足CPL <= DPL,CPL在%cs中,DPL是描述符的特权级别。
3、如果目标段选择子的PL < CPL,则将%esp和%ss保存到CPU的内部寄存器中。
4、从任务段描述符task segment descriptor中加载%esp和%ss。
5、Push %ss(old user’s)。
6、Push %esp(old user’s)。
7、Push %eflags。
8、Push %cs。
9、Push %eip。
10、清除%eflags的一些位。
11、设置%cs和%eip为IDT项给出的值。

int指令执行之后的内核栈变化:

xv6使用Perl脚本生成IDT项指向的处理程序入口点entry point。如果处理器没有将错误码进栈,entry会将错误码进栈,之后中断号进栈,然后跳转到alltraps。

Code: C trap handler

假如引发的trap是T_SYSCALL,调用系统调用处理函数syscall。如果trap不是系统调用,也不是硬件设备关注的,xv6假设它是由不正确行为引起的。如果是由用户程序引起的trap,xv6会打印出相关细节信息,并设置cp->killed确保用户程序被清理。如果是由内核引起的trap,则是内核bug,xv6打印出细节信息之后调用panic。

Code: System calls

参考之前的博客中关于xv6系统调用的介绍。

Code: Interrupts

主板上的设备可以产生中断,CPU需要设置硬件来处理这些中断。
interrupt类似于系统调用,除了设备可以在任何时候生成interrupt。主板上的硬件在需要的时候会给CPU发送信号,因此必须给设备编程使之可以产生中断,且CPU可以接收该中断。

例子:timer设备和timer中断。
假设timer设备每秒产生100次中断,保证内核可以追踪时间的流逝并在多个执行进程间进行时间分片。

xv6早期的主板有一个可编程中断控制器PIC,见picirq.c。
PIC全称Programmable Interrupt Controller,通常是指Intel 8259A双片级联构成的最多支持15个interrupts的中断控制系统。
而随着多处理器PC主板的出现,需要一种新方式来处理中断,因为每个CPU都需要一个中断控制器来处理发送给它的中断。同时,还需要一种将中断路由给CPU的方法,这种方法包括两部分,一部分位于I/O系统(the IO APIC, ioapic.c),一部分位于每个CPU(the local APIC, lapic.c)。xv6被设计为支持多处理器PC主板,需要编程每个处理器使之能接收到中断。
APIC全称Advanced Programmable Interrupt Controller,APIC是为了多核平台而设计的。它由两个部分组成IOAPIC和LAPIC,其中IOAPIC通常位于南桥中用于处理桥上的设备所产生的各种中断,LAPIC则是每个CPU都会有一个。IOAPIC通过APICBUS(现在都是通过FSB/QPI)将中断信息分派给每颗CPU的LAPIC,CPU上的LAPIC能够智能的决定是否接受系统总线上传递过来的中断信息,而且它还可以处理Local端中断的pending、nesting、masking,以及IOAPIC于Local CPU的交互处理。

为了能在单处理器机上正确运行,xv6对PIC进行编程。每个PIC最多可以处理8个设备中断,并在处理器的中断引脚上多路传输。为了能够支持多于8个设备,主板上通常级联至少两个PIC。xv6使用inb和outb指令规划master生成IRQ0-7,slave生成IRQ8-15。初始化PIC阶段屏蔽所有中断。timer.c设置定时器并使之在PIC上生效。
基于Intel 80x86的PC使用两片8259A级联的方式组成了可以管理15级中断向量的一个中断系统,下图是它的一个连接示意图。在每台电脑的系统中,是由一个中断控制器8259或是8259A的芯片(现在此芯片大都集成到其它的芯片内)来控制系统中每个硬件的中断控制。目前共有16组IRQ,去掉其中用来作桥接的一组IRQ,实际上只有15组IRQ可供硬件调用。两片8259A,一片为Master,另一片为Slaver。其中Slaver的INT接到Master的IRQ2上。8259A有两种工作模式分别为编程和操作模式。BIOS初始化的时候会先通过IO port对8259A进行编程配置,在此之后8259A就可以响应来自外部设备的中断请求了。Master的IO address是0x20 0x21; Slaver的IO address是0xA0 0xA1。

这16组IRQ的主要用途如下表:

多处理器环境下,xv6必须编程IOAPIC和每个CPU的LAPIC。IOAPIC有一张表,处理器通过内存映射I/O来编程表中的项,而不是使用inb和outb指令。初始化过程中,xv6映射 interrupt 0到IRQ 0,以此类推,但禁用它们。特定设备将允许对应的中断,并告知该中断将路由给哪个处理器,例如xv6将keyboard中断路由到处理器0。LAPIC内嵌定时器芯片,所以每个处理器都可以单独接收定时器中断,xv6在lapicinit函数中使该芯片起作用。
Intel APIC由一组中断输入信号,一个24*64bit的Programmable Redirection Table(PRT),一组register和用于从APIC BUS(FSB/QPI)上传送APIC MSG的部件组成,当南桥的IO device通过IOAPIC的interrupt lines产生interrupt,IOAPIC将根据内部的PRT table格式化成中断请求信息,并将该信息发送给目标CPU的LAPIC,再由LAPIC通知CPU进行处理。下图是一个基于Intel APIC的连接示意图,如下图所示IOAPIC上有24个interrupt pin,每一个pin都对应一个RTE,所以针对每一个interrupt pin都可以单独设定它的mask,触发方式(level,edge trigger),中断管脚的极性,传送方式,传送状态,目的地,中断向量等。

处理器通过设置eflags的IF位可以控制是否接收中断,cli指令清除IF,禁用中断,sti指令置位IF,允许中断。xv6在启动主处理器和其他处理器过程中关中断,每个处理器的scheduler将开中断。为了控制某些代码片段不被中断,xv6在执行这些代码片段过程中禁用中断。

定时器中断对应中断向量32(xv6选择中断向量32来处理IRQ 0),在idtinit进行设置。定时器中断向量32和系统调用中断向量64的唯一区别是定时器中断向量32是由interrupt gate进行处理而不是trap gate。所以处理定时器中断的时候会清除IF以屏蔽其他中断。trap函数在处理一次定时器中断时,做了两个工作:增加时间ticks和调用wakeup函数。

Drivers

Add a network driver

笔记011 - Lab3: User Environments

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

The UVPT

x86将虚拟地址转换为物理地址:CR3指向页目录,PDX是页目录索引,PTX是二级页表索引。对于处理器而言,并没有页目录、页表、页的概念,相反它只负责计算:pd = lcr3(); pt = (pd+4PDX); page = (pt+4PTX);
页目录是“特殊”的页表,假设我们在页目录中某一索引项V存储页目录本身的地址:

当设计PDX=PTX=V时,则可以通过pd = lcr3(); pt = (pd+4PDX); page = (pt+4PTX);后可以获得页目录的某一项。在jos中,V=0x3BD,所以(0x3BD<<22)|(0x3BD<<12) = 0xef400000 = UVPD。当PDX=V而PTX!=V时,通过pd = lcr3(); pt = (pd+4PDX); page = (pt+4PTX);后将指向到某一个页表的某一项,即:所有PDX=V的虚拟页刚好是所有页表本身,在jos中,V=0x3BD,所以UVPT = (0x3BD<<22) = 0xef400000,从该地址起的4M虚拟地址空间映射了jos的所有页表。设计UVPT的意义即在于通过虚拟地址可以访问到页表,但其前提在于在页目录中索引项V存储页目录本身的地址。

Part A: User Environments and Exception Handling

在本实验中,术语environment和process是可以互换的,两者都是允许运行程序的一个抽象。jos的environment和unix的process提供不同的接口,因此提供了不同的语义,jos更加强调environment而不是process。
关于environment,jos提供了三个全局变量:

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

jos内核最多支持“同时”执行NENV(inc/env.h)个活跃环境,所有不活跃的Env数据结构保存在env_free_list中。

1
2
3
4
5
6
7
8
9
10
11
12
13
//inc/env.h
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:见inc/trap.h,当环境没被执行时(即当前执行内核代码或其他环境的代码),保存了环境的被保存的寄存器值。内核在从用户态切换到内核态的时候保存这些寄存器,确保环境可以被恢复执行。
env_link:连接到下一个Env,该Env位于env_free_list上,env_free_list上指向第一个空闲的环境。
env_id:当前使用Env数据结构的环境的唯一标识。当一个用户环境终止时,内核可以重新分配相同的Env数据结构给不同的环境,但是env_id不会相同。
env_parent_id:创建本环境的父环境id,可用于构建“family tree”,进而安全控制哪个环境可以对其他哪些环境做什么任务。
env_type:用于区分特殊的环境,大部分时候是ENV_TYPE_USER。
env_status:以下几种值之一:

1
2
3
4
5
ENV_FREE:表明Env数据结构不活跃,位于env_free_list中。
ENV_RUNNABLE:表明Env数据结构所代表的环境正等待处理器执行。
ENV_RUNNING:表明Env数据结构所代表的环境正被执行。
ENV_NOT_RUNNABLE:表明Env数据结构所代表的环境是活跃的,但还不能执行,比如它正在等待其他环境的interprocess communication (IPC)。
ENV_DYING:表明Env数据结构所代表的环境是一个zombie。下一次trap到内核的时候,zombie环境将被释放。

env_pgdir:保存了环境的页目录的内核虚拟地址。

与unix对比:jos的environment也包含了thread和address space两个概念,thread的定义主要是被保存的寄存器(env_tf),address space的定义主要是页目录和页表的指向(env_pgdir)。
与xv6对比:jos的struct Env类似于xv6的proc,两者都保存了用户模式寄存器状态,但是jos的环境并没有自己的内核栈,因为jos一次只有一个环境是活跃的,所以只需要一个内核栈。

Allocating the Environments Array

i386_init()函数之后,并没有进入循环,而是相应的对进程结构初始化和中断初始化,i386_init()函数最后会调用env_run(&envs[0]);运行一个进程。一个进程的执行不能对内核(kernel)和其他进程产生干扰,当进程执行特权指令时,需要处理器产生中断,从用户态切换到内核态,完成任务后中断返回到用户态。

1
Exercise 1.修改kern/pmap.c中mem_init()函数,分配并映射envs数组(NENV instances),参考pages数组的分配和映射。pages数组基址映射到UPAGES虚拟地址,envs数组基址映射到UENVS虚拟地址,两者的二级页表项权限都是用户只读,用户环境不能修改数组本身。顺利完成的话将通过check_kern_pgdir()检查。

解决方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//pmap.c
...
//////////////////////////////////////////////////////////////////////
// Make 'envs' point to an array of size 'NENV' of 'struct Env'.
// LAB 3: Your code here.
envs = (struct Env*)boot_alloc(NENV*sizeof(struct Env));
memset(envs,0,NENV*sizeof(struct Env));
...
//////////////////////////////////////////////////////////////////////
// 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,PTSIZE,PADDR(envs),PTE_U | PTE_P);
...

这里注意到一个问题:pages和envs本身作为内核代码的数组,拥有自己的虚拟地址,且内核可对其进行读写。boot_map_region函数将两个数组分别映射到了UPAGES和UENVS起4M空间的虚拟地址,这相当于另外的映射镜像,其二级页表项权限被设为用户/内核可读,因此通过UPAGES和UENVS的虚拟地址去访问pages和envs的话,只能读不能写。
页面管理空间的布局情况如下:

Creating and Running Environments

由于还没实现文件系统,因此没办法从inode里读取程序内容,而是设置成由内核加载内嵌到内核的静态二进制镜像,且为ELF可执行格式,将嵌入到内核中的用户程序取出释放到相应链接器指定好的用户虚拟空间里。如果想了解二进制文件怎么连接进内核里,可以在build 内核之后查看kern/Makefrag、obj/kern/kernel.sym等文件。在JOS系统里面,采用和管理页相同的方式来管理进程,即用一个进程表来管理所有的进程,空闲的进程通过env_link相连接,用env_free_list指向空闲进程表的头节点。

1
2
3
4
5
6
7
Exercise 2.在kern/env.c中完成以下函数:
env_init():初始化进程数据结构数组,将所有数据结构加到env_free_list上。调用env_init_percpu函数为特权级别0(内核)和特权级别3(用户)将段式硬件配以单独的段。
env_setup_vm():分配页目录,初始化进程的内核部分地址空间。
region_alloc():分配和映射物理空间。
load_icode():解析ELF二进制镜像,加载进新进程的地址空间。
env_create():通过env_alloc分配一个新进程,调用load_icode加载ELF二进制镜像。
env_run():在用户模式下执行新进程。

进程的管理方法和页面的管理方法是相同的,都是用一组结构体的数组来管理,在这种管理的方式下,对于空闲进程的申请和添加,只需要用env_free_list这个参数就可以了。进程表的初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//env.c
void
env_init(void)
{
// Set up envs array
// LAB 3: Your code here.
env_free_list = NULL;
int i;
for( i = NENV -1; i>=0; i--){
envs[i].env_status = ENV_FREE;
envs[i].env_id = 0;
envs[i].env_link = env_free_list;
env_free_list = &envs[i];
}
// Per-CPU part of the initialization
env_init_percpu();
}

第一个用户进程的建立:

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
//init.c
ENV_CREATE(user_hello, ENV_TYPE_USER);

//env.h
#define ENV_PASTE3(x, y, z) x ## y ## z

#define ENV_CREATE(x, type) \
do { \
extern uint8_t ENV_PASTE3(_binary_obj_, x, _start)[]; \
env_create(ENV_PASTE3(_binary_obj_, x, _start), \
type); \
} while (0)


//env.c
void
env_create(uint8_t *binary, enum EnvType type)
{
// LAB 3: Your code here.
struct Env *newEnv;
//Allocates a new env
int i = env_alloc(&newEnv,0);
if(i<0) panic("env_create");
//loads the named elf binary into
load_icode(newEnv,binary);
//set env_type
newEnv->env_type = type;
}

env_alloc向进程表申请空闲的进程,在env_alloc的函数中,向进程表申请空闲的进程,对新申请的进程的struct Env(进程描述符)进行初始化,主要是对段寄存器的初始化。内核要开始的第一个用户进程不是通过中断等方法来进入到内核的,而是由内核直接载入的。对进程的进程描述符进行初始化,是为了模仿int x指令的作用,模拟第一个进程是通过中断进入了内核,在内核处理完了相应的操作之后,才返回用户态的。xv6的做法是也是类似载入程序内容后对进程的进程描述符进行初始化,不过它之后是通过int指令来中断,执行exec。

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
//env.c
int
env_alloc(struct Env **newenv_store, envid_t parent_id)
{
int32_t generation;
int r;
struct Env *e;

if (!(e = env_free_list))
return -E_NO_FREE_ENV;

// Allocate and set up the page directory for this environment.
if ((r = env_setup_vm(e)) < 0)
return r;

//todo...?????????????????????
// Generate an env_id for this environment.
generation = (e->env_id + (1 << ENVGENSHIFT)) & ~(NENV - 1);
if (generation <= 0) // Don't create a negative env_id.
generation = 1 << ENVGENSHIFT;
e->env_id = generation | (e - envs);

// Set the basic status variables.
e->env_parent_id = parent_id;
e->env_type = ENV_TYPE_USER;
e->env_status = ENV_RUNNABLE;
e->env_runs = 0;

// Clear out all the saved register state,
// to prevent the register values
// of a prior environment inhabiting this Env structure
// from "leaking" into our new environment.
memset(&e->env_tf, 0, sizeof(e->env_tf)); //env_tf是Trapframe结构体,不是Trapframe结构体指针!

// Set up appropriate initial values for the segment registers.
// GD_UD is the user data segment selector in the GDT, and
// GD_UT is the user text segment selector (see inc/memlayout.h).
// The low 2 bits of each segment register contains the
// Requestor Privilege Level (RPL); 3 means user mode. When
// we switch privilege levels, the hardware does various
// checks involving the RPL and the Descriptor Privilege Level
// (DPL) stored in the descriptors themselves.
e->env_tf.tf_ds = GD_UD | 3;
e->env_tf.tf_es = GD_UD | 3;
e->env_tf.tf_ss = GD_UD | 3;
e->env_tf.tf_esp = USTACKTOP;
e->env_tf.tf_cs = GD_UT | 3;
// You will set e->env_tf.tf_eip later.

// commit the allocation
env_free_list = e->env_link;
*newenv_store = e;

cprintf("env_id, %x\n", e->env_id);
cprintf("[%08x] new env %08x\n", curenv ? curenv->env_id : 0, e->env_id);
return 0;
}

env_setup_vm创建进程页目录。在xv6中,每个进程都有自己的内核栈,进程的trapframe也是存在内核栈中。但是jos中用户进程是使用同一个内核栈虚拟地址的。

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
//env.c
static int
env_setup_vm(struct Env *e)
{
int i;
struct PageInfo *p = NULL;

// Allocate a page for the page directory
if (!(p = page_alloc(ALLOC_ZERO)))
return -E_NO_MEM;

// LAB 3: Your code here.
//在没有具体的映射之前,物理地址都是直接加上KERNBASE作为虚拟地址的!!!
//内核空间的变量的虚拟地址也是如此
e->env_pgdir = (pde_t *) page2kva(p); //pa + KERNBASE !!!!!!
p->pp_ref = 1;
//use kern_pgdir as a template
/*
由于每个用户进程都需要共享内核空间,所以对于用户进程而言,在UTOP以上的部分,和系统内核的空间是完全一样的。
因此在pgdir开始设置的时候,只需要在一级页表目录上,把共享部分的一级页表目录部分复制进用户进程的地址空间就可以了,
这样,就实现了页面的共享。因为一级页目录里面存储的是二级页表目录的物理地址,其直接映射到物理内存部分,
而共享的内核部分的二级页目录在前期的内核操作中,已经完成了映射,所以二级页目录是不需要初始化的。
简单来说,不需要映射二级页表的原因是,用户进程可以和内核共用这些二级页表。

UTOP以下部分清空: 注意4G虚拟地址空间是由低到高每4M按顺序映射到页目录的一项的,因此需要取出UTOP的PDX索引部分,将前PDX项清空。
*/
memcpy(e->env_pgdir,kern_pgdir,PGSIZE);
memset(e->env_pgdir,0,UTOP >> PTSHIFT);

// UVPT maps the env's own page table read-only.
// Permissions: kernel R, user R
e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_P | PTE_U;

return 0;
}

load_icode将用户进程的文件载入内存。由于还没有实现文件系统,所以用户进程实际的存放的位置实际上是在内存中的,文件载入内存,实际上是内存之间的数据的复制而已。程序载入内存的时候,需要把pgdir设置为用户进程的页目录,这样,这些程序才会载入用户进程所属的地址空间,而且在载入的过程中,根据elf文件中程序头表中载入内存的va和memsz,还需要为用户空间申请新的地址映射,在这个过程中,会建立新的页表。加载完ELF文件之后,为进程再申请一页物理页作为用户栈,并映射到USTACKTOP - PGSIZE,之前tampframe的esp也指向了USTACKTOP。

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
//env.c
static void
load_icode(struct Env *e, uint8_t *binary)
{
// LAB 3: Your code here.
struct Elf *elf = (struct Elf *)binary;
if (elf->e_magic != ELF_MAGIC)
panic("load_icode");
struct Proghdr *ph = (struct Proghdr *)(binary+elf->e_phoff);

lcr3(PADDR(e->env_pgdir));

int i;
for (i = 0; i < elf->e_phnum; ++i)
{
if(ph->p_type != ELF_PROG_LOAD) {//不可载入段
ph++;
continue;
}
//xv6分配用户空间是连续的, 给出起始地址va和结束地址,然后ROUNDUP(va),根据结束地址分配了足够的页空间,
//va的值是结束地址,而不是当前空间顶端。读取下一段的时候,新的开始地址是上次结束地址, 新的结束地址是ph.vaddr + ph.memsz。
//jos分配用户空间不是连续的,而是根据ph->p_va作为每次的开始地址,以p_memsz为长度进行页分配。
region_alloc(e,(void*)ph->p_va,ph->p_memsz);
//read into env's memory , need env's pgdir
memcpy((char*)ph->p_va,(char*)(binary + ph->p_offset),ph->p_filesz);
ph++;
}
// Now map one page for the program's initial stack
// at virtual address USTACKTOP - PGSIZE.

// LAB 3: Your code here.
region_alloc(e,(void*)USTACKTOP - PGSIZE,PGSIZE);

//todo...binary的数据位于内核空间的哪个节?
e->env_tf.tf_eip = elf->e_entry; // main

lcr3(PADDR(kern_pgdir));
}

注意到一个现象,加载程序分配用户空间时,在xv6中,分配的地址空间是连续的,进程的用户地址空间也是通过加载过程中分配的地址空间范围来决定。而在jos中,用户空间是不一定连续的,每次在加载段的时候,都是从新的段虚拟地址开始映射,不关心之前段的结束地址。jos中也没有设置进程的地址空间大小。因此,jos和xv6为进程分配物理空间的函数有些不同,xv6会记录上次分配的结束地址,而jos不会。region_alloc为va申请物理空间,并且完成映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//env.c
static void
region_alloc(struct Env *e, void *va, size_t len)
{
// LAB 3: Your code here.
if(ROUNDUP((pte_t)va + len, PGSIZE) >= KERNBASE){
panic("region_alloc panic, out of memory1");
}
int npages = (ROUNDUP((pte_t)va + len, PGSIZE) - ROUNDDOWN((pte_t)va, PGSIZE)) / PGSIZE;
struct PageInfo *p = NULL;
int i=0;
for(; i<npages; i++){
//加上ALLOC_ZERO标志则物理页内容初始化为'\0',如果这里没有初始化,则最好在分配物理页并且将内容拷贝进来后,将剩余的空间置0
if (!(p = page_alloc(ALLOC_ZERO))){
panic("region_alloc panic, out of memory2");
}
//map, use page_insert
if(page_insert(e->env_pgdir,p,(void*)((pte_t)va+i*PGSIZE),PTE_U|PTE_W)!=0){
panic("region_alloc panic, out of memory3");
}
}
}

之后env_run开始准备运行进程,先切换到用户进程的地址空间,然后调用env_pop_tf载入寄存器的值。

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
//env.c
void
env_run(struct Env *e)
{
// Step 1: If this is a context switch (a new environment is running):
// 1. Set the current environment (if any) back to
// ENV_RUNNABLE if it is ENV_RUNNING (think about
// what other states it can be in),
// 2. Set 'curenv' to the new environment,
// 3. Set its status to ENV_RUNNING,
// 4. Update its 'env_runs' counter,
// 5. Use lcr3() to switch to its address space.
// Step 2: Use env_pop_tf() to restore the environment's
// registers and drop into user mode in the
// environment.
// LAB 3: Your code here.
if(!e) panic("env_run panic");
if(curenv && curenv->env_status == ENV_RUNNING){
curenv->env_status = ENV_RUNNABLE;
}
curenv = e;
e->env_status = ENV_RUNNING;
e->env_runs += 1;
lcr3(PADDR(e->env_pgdir));

env_pop_tf(&(e->env_tf));//never return
//panic("env_run not yet implemented");
}

void
env_pop_tf(struct Trapframe *tf)
{
__asm __volatile("movl %0,%%esp\n"
"\tpopal\n"
"\tpopl %%es\n"
"\tpopl %%ds\n"
"\taddl $0x8,%%esp\n" /* skip tf_trapno and tf_errcode */
"\tiret"
: : "g" (tf) : "memory");
panic("iret failed"); /* mostly to placate the compiler */
}

movl %0,%%esp,这里出现了占位符%0,通过后面的参数可以看到这里的占位符代表的意思是memory中的变量tf,即Trapframe的指针地址。iret之后发生特权级的改变(即由内核态转到了用户态),所以iret总共会压出5个寄存器,依次是eip、cs、eflags、esp、ss,即iret会从栈弹出代码段选择子及指令指针分别到CS与IP寄存器,并弹出标志寄存器内容到EFLAGS寄存器,然后弹出esp和ss寄存器的值,这些函数在env_alloc()以及load_icode()中都设置好了,其中EIP为用户程序入口地址,CS为用户程序代码段基地址。完成iret之后,eip就指向了程序的入口地址,cs也由内核态转向了用户态, esp也由内核栈转到了用户栈。

1
2
调试技巧:
调用env_pop_tf后,将开始执行用户代码,并且不会返回。使用b *0x...在env_pop_tf处设置断点,然后单步调试直至执行用户代码。参考obj/user/hello.asm,在sys_cputs()中的int $0x30处设置断点,如果以上函数补充正确的话,运行至此的指令均不会出错。下面将对中断进行处理。

Handling Interrupts and Exceptions

在没有实现系统调用之前,处理器一旦进入用户模式将没有办法返回。只有实现基本的异常和系统调用处理,才能使内核从用户模式代码中恢复内核对处理器的控制。另外,诸如异常、陷阱、中断、错误、中止(exception, trap, interrupt, fault, abort)等概念,在架构或操作系统中并没有标准意义,因此在一个特定的体系结构中如xv6,经常不考虑它们之间的细微差别。

Basics of Protected Control Transfer

exceptions和interrupts都是“protected control transfers”(受保护控制转移)的,它们将导致处理器从用户态切换为内核态,但用户代码不会影响其他进程以及内核。在Intel的术语中,interrupt通常指由外部异步事件引起的受保护控制转移(外部是相对于处理器而言),如外部设备I/O活动的通知,exception通常指由当前执行代码同步引起的受保护控制转移,如除0或非法内存访问。
为了保护控制转移,处理器仅在一定的控制条件下允许进入内核。x86使用两种保护机制:1、中断描述符表IDT,2、任务状态段TSS。
中断描述符表interrupt descriptor table:x86允许多达256个不同的中断或异常的内核入口点,每一个都有不同的中断向量。一个向量是一个0到255之间的数字。IDT表设置在内核的私有内存中,CPU使用向量在IDT表中进行索引,找到入口点后,处理器将会加载对应的值到eip(instruction pointer register)中,该值指向处理异常的内核代码,同时加载对应的值到cs(code segment register)中,该值的0-1位表明了执行异常处理代码的特权级别。在jos中,所有异常都在内核模式下处理,特权级别为0。
任务状态段TSS:处理器需要在调用执行异常处理代码之前保存好旧寄存器的状态,保证异常处理完毕后能恢复用户代码的执行。当interrupt或trap导致从用户态到内核态的特权级别改变时,x86处理器还将切换到内核内存的栈。task state segment即TSS指定了段选择子(ss)和栈地址(esp)。处理器将在新的栈上push SS、ESP、EFLAGS、CS、EIP,和一个可选的error code,然后从中断描述符加载cs和ip,并设置esp和ss引用新的栈。TSS可以用于各种各样的目的,但jos只使用它来定义处理器从用户模式切换到内核模式后的内核栈。因为jos的内核模式在特权级别0上,所以处理器使用ESP0和SS0来定义进入内核模式的内核栈。不同于xv6的设计,jos只用一个内核栈地址,其栈顶被映射到KSTACKTOP上,而xv6的每一个进程都有对应的内核栈。
TSS描述的是一个task在执行中的状态信息,重在描述代码切换之间权限的转换,用于保护机制。Env对应的是一个用户进程的状态,主要用于保持用户进程的独立,这里的进程是一个抽象程度较高的概念,包括PCB(jos中 evns 数组就等价于 PCB 表)、地址空间等,不仅仅是一段用户程序代码。
TSS结构如下所示:

Exceptions and Interrupts Example

假设处理器正在执行用户进程代码,并遇到了一条尝试除以0的divide指令:
1、处理器切换到TSS指定的SS0和ESP0指向的栈,jos中分别是GD_KD和KSTACKTOP。
2、处理器将相关寄存器保存到内核栈,从KSTACKTOP开始。

1
2
3
4
5
6
7
+--------------------+ KSTACKTOP             
| 0x00000 | old SS | " - 4
| old ESP | " - 8
| old EFLAGS | " - 12
| 0x00000 | old CS | " - 16
| old EIP | " - 20 <---- ESP
+--------------------+

3、除0异常在x86上是中断向量0,处理器读取IDT表的第0项,根据该项描述的信息设置CS:EIP,指向处理函数。
4、处理函数获得控制权,开始处理异常,比如终止用户进程。

对于某些x86异常,处理器可能会将错误码进栈,比如页错误。布局如下:

1
2
3
4
5
6
7
8
+--------------------+ KSTACKTOP             
| 0x00000 | old SS | " - 4
| old ESP | " - 8
| old EFLAGS | " - 12
| 0x00000 | old CS | " - 16
| old EIP | " - 20
| error code | " - 24 <---- ESP
+--------------------+

注意,将相关寄存器(和某些异常的错误码)保存到内核栈后,处理器才读取IDT表,寻找处理函数。

Nested Exceptions and Interrupts

处理器可以处理来自内核或用户模式的中断和异常。只有由用户模式切换到内核模式的时候,处理器才会在保存旧寄存器之前自动切换栈,并根据IDT调用合适的处理函数。如果中断或异常发生的时候,处理器已经处于内核模式(CS的低2位为0),将只在原来的栈上保存数据。这种情况下处理器可以优雅地处理由内核代码本身引起的内嵌异常。这种能力也是实施保护的工具,详见后面系统调用。
如果处理器已经在内核模式中且需要处理一个内嵌的异常,因为不需要切换栈,处理器不保存旧SS或ESP寄存器。对于不会导致错误码进栈的异常,进入异常处理程序的时候内核栈的样子如下:

1
2
3
4
5
+--------------------+ <---- old ESP
| old EFLAGS | " - 4
| 0x00000 | old CS | " - 8
| old EIP | " - 12
+--------------------+

对于需要将错误码进栈的异常,进入异常处理程序的时候内核栈的样子如下:

1
2
3
4
5
6
+--------------------+ <---- old ESP
| old EFLAGS | " - 4
| 0x00000 | old CS | " - 8
| old EIP | " - 12
| error code | " - 16
+--------------------+

假如处理器处于内核模式,且由于某些原因(如缺乏栈空间)导致无法保存处理器的旧状态,那么处理器将没有办法恢复原先状态。内核应该设计为不会发生这种情况。

JOS 系统中断过程的控制流如下所示:

Setting Up the IDT

kern/trap.h包含了内核专属的定义,inc/trap.h包含了用户级别的程序和库可用的定义。

1
2
Exercise 4: 在kern/trapentry.S中定义每个中断对应的中断处理程序,在kern/trap.c中根据定义好的中断处理程序初始化IDT。
每个中断对应的中断处理程序实际上是在内核栈中设置好Trapframe的布局结构,然后将这个结构传递给trap()函数进行处理,最后在trap_dispatch()中进行具体中断处理程序的分发。

关于中断处理程序的定义:

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
//see in kern/trapentry.S
/* TRAPHANDLER defines a globally-visible function for handling a trap.
* It pushes a trap number onto the stack, then jumps to _alltraps.
* Use TRAPHANDLER for traps where the CPU automatically pushes an error code.
*
* You shouldn't call a TRAPHANDLER function from C, but you may
* need to _declare_ one in C (for instance, to get a function pointer
* during IDT setup). You can declare the function with
* void NAME();
* where NAME is the argument passed to TRAPHANDLER.
*/
#define TRAPHANDLER(name, num) \
.globl name; /* define global symbol for 'name' */ \
.type name, @function; /* symbol type is function */ \
.align 2; /* align function definition */ \
name: /* function starts here */ \
pushl $(num); \
jmp _alltraps

/* Use TRAPHANDLER_NOEC for traps where the CPU doesn't push an error code.
* It pushes a 0 in place of the error code, so the trap frame has the same
* format in either case.
*/
#define TRAPHANDLER_NOEC(name, num) \
.globl name; \
.type name, @function; \
.align 2; \
name: \
pushl $0; \
pushl $(num); \
jmp _alltraps

这两个宏的功能是接受一个函数名和中断向量编号,定义出相应的以该函数名命名的中断处理程序,中断处理程序的执行流程是向栈里压入相关错误码和中断号,然后跳转到_alltraps把Trapframe剩下的部分在栈中设置好。对于某些中断,处理器将向栈中放入对应的中断错误码。当系统没有放入错误码的时候,中断处理函数则使用TRAPHANDLER_NOEC手动补齐。当用户使用int指令手动调用中断时,处理器不会放入错误码。具体的中断处理程序定义如下:

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
//see in kern/trapentry.S
/*
* Lab 3: Your code here for generating entry points for the different traps.
*/
TRAPHANDLER_NOEC(entry_point0,T_DIVIDE)
TRAPHANDLER_NOEC(entry_point1,T_DEBUG)
TRAPHANDLER_NOEC(entry_point2,T_NMI)
TRAPHANDLER_NOEC(entry_point3,T_BRKPT)
TRAPHANDLER_NOEC(entry_point4,T_OFLOW)
TRAPHANDLER_NOEC(entry_point5,T_BOUND)
TRAPHANDLER_NOEC(entry_point6,T_ILLOP)
TRAPHANDLER_NOEC(entry_point7,T_DEVICE)
TRAPHANDLER(entry_point8,T_DBLFLT)
# TRAPHANDLER(entry_point9,T_COPROC)
TRAPHANDLER(entry_point10,T_TSS)
TRAPHANDLER(entry_point11,T_SEGNP)
TRAPHANDLER(entry_point12,T_STACK)
TRAPHANDLER(entry_point13,T_GPFLT)
TRAPHANDLER(entry_point14,T_PGFLT)
# TRAPHANDLER(entry_point15,T_RES)
TRAPHANDLER_NOEC(entry_point16,T_FPERR)

TRAPHANDLER_NOEC(entry_point48,T_SYSCALL)

/*
* Lab 3: Your code here for _alltraps
*/
.globl _alltraps
_alltraps:
# Build trap frame. //将用户进程的寄存器保存
pushl %ds
pushl %es
pushal
//load GD_KD into %ds and %es
movw $(GD_KD), %ax
movw %ax, %ds
movw %ax, %es
pushl %esp
call trap

# entry point table
.data
.globl entry_points
entry_points:
.long entry_point0
.long entry_point1
.long entry_point2
.long entry_point3
.long entry_point4
.long entry_point5
.long entry_point6
.long entry_point7
.long entry_point8
# .long entry_point9
.long 0 #attendion: instead we should fill the hole with 0, or it will cause entry_points array's wrong index
.long entry_point10
.long entry_point11
.long entry_point12
.long entry_point13
.long entry_point14
# .long entry_point15
.long 0 #attendion: instead we should fill the hole with 0, or it will cause entry_points array's wrong index
.long entry_point16
.long 0
...
.long entry_point48
...

为了方便,将中断处理程序函数名定义到函数名数组entry_points中。之后初始化IDT:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//see in kern/trap.c
void
trap_init(void)
{
extern struct Segdesc gdt[];

// LAB 3: Your code here.
/*
* add by jianzzz
*/
//entry_points store handler-function's address
extern uint32_t entry_points[]; //see in trapentry.S
int i;
for (i = 0; i < 16; ++i)
{
if (i==T_BRKPT)
SETGATE(idt[i], 0, GD_KT, entry_points[i], 3)
else if(i!=9 && i!=15)
SETGATE(idt[i],0,GD_KT,entry_points[i],0);//todo...why GD_KT???
}
SETGATE(idt[T_SYSCALL],0,GD_KT,entry_points[T_SYSCALL],3);
// Per-CPU setup
trap_init_percpu();
}

SETGATE第三个参数cs设为内核的代码段GD_KT。最后一个参数是用户特权级。到此为止,就完成了中断响应机制的建立。
中断处理过程图示如下:

中断门格式如下:

其结构 struct Gatedesc 是在 inc/mmu.h 中定义的。由其结构可知,中断或异常也有特权级别,由中断门描述符中的 DPL 约束,门描述符中的段选择子中的 CPL 说明中断处理程序运行的特权级别。

1
2
3
4
5
Questions:
1、为什么需要设计为每一个中断/异常都有单独的中断处理程序?如果所有的中断/异常都传递到相同的处理程序,会丢失什么特性?(这里指的中断处理程序是指跳转到_alltraps之前的处理过程)
中断处理程序在真正的处理之前会将中断号放入内核栈以组织成Trapframe的结构,如果所有的中断/异常都跳到同一个处理程序,那么无法正确设置中断/异常的中断号等。
2、user/softint程序的预想结果是抛出general protection fault (trap 13),但程序代码是执行int $14,如果内核实际允许该程序执行int $14并调用页错误处理程序,会发生什么情况?
中断向量14 Page fault的调用权限为0,只能由内核抛出,直接在softint中用int指令调用将会产生一般保护错误。

Part B: Page Faults, Breakpoints Exceptions, and System Calls

Handling Page Faults

当处理器遇到页错误时,会将导致页错误的线性地址保存到处理器控制寄存器CR2中。

1
Exercise 5.修改trap_dispatch(),将页错误分发给page_fault_handler()处理。

1
2
3
4
5
//see in kern/trap.c, trap_dispatch()
switch(tf->tf_trapno) {
case (T_PGFLT):
page_fault_handler(tf);
break;

The Breakpoint Exception

中断向量3(T_BRKPT)断点异常通常是用于允许调试器通过用单字节的int3软件中断指令临时替换相关程序指令的方式在程序代码中插入断点。jos中我们将这个异常变成一个粗糙的系统调用,任何用户程序都可以调用它从而嵌入到jos内核监控,在这个角度上jos相当于一个粗糙的调试器。

1
Exercise 6.修改trap_dispatch(),使得断点异常嵌入到内核监控程序。

1
2
3
4
5
6
7
8
9
10
//see in kern/trap.c, trap_dispatch()
switch(tf->tf_trapno) {
case (T_BRKPT):
//print_trapframe(tf);
monitor(tf);
break;

//see in kern/trap.c, trap_init()
if (i==T_BRKPT)
SETGATE(idt[i], 0, GD_KT, entry_points[i], 3)
1
Challenge! 熟悉EFLAGS,修改jos内核监控程序,当因为int3发生断点中断嵌入内核监控的时候,能够在当前位置执行“continue”操作和一次单步执行一条指令。

参考https://www.zhihu.com/question/40555332/answer/87130016?from=profile\_answer\_card ,一般情况下,在指令被执行前,断点指令会产生调试异常,如果异常处理程序在返回前没有移除断点的话,处理器在重启指令之前会再次发现断点,进而生成另一个调试异常。为了防止重复进入调试中断,Intel 64和IA-32架构使用RF标志控制处理器对指令断点的响应。RF置1则禁用断点指令产生调试异常,但是其它情况仍可以产生调试异常。RF置0则断点指令会产生调试异常。调试软件必须在用IRETD指令返回到被中断程序之前,将栈中的EFLAGES映象中的该位置为1,以阻止断点指令产生另外的调试异常。在返回并成功执行断点指令之后,处理器会自动清零该位,从而允许继续通过断点指令产生调试异常。
在jos中,情况有所不同。jos遇到断点指令后直接陷入了监控程序,而不会产生调试异常,因此跟RF没有什么关系。为了能够单步调试,需要EFLAGS的TF(Trap Flag)跟踪标志,置1则开启单步执行调试模式,置0则关闭。在单步执行模式下,处理器在每条指令后产生一个调试异常,这样在每条指令执行后都可以查看执行程序的状态。当然,为了达到观察每次调试结果,我们也同样需要将调试异常嵌入内核监控。

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
//see in kern/trap.c, trap_dispatch()
switch(tf->tf_trapno) {
case (T_DEBUG):
//print_trapframe(tf);
monitor(tf);
break;

//see in kern/monitor.c
int mon_continue(int argc,char **argv,struct Trapframe *tf){
uint32_t eflags;
if(tf==NULL){
cprintf("No trapped environment\n");
return 0;
}
eflags=tf->tf_eflags;
eflags &= ~FL_TF;
tf->tf_eflags=eflags;
return -1;
}

int mon_step(int argc,char **argv,struct Trapframe *tf){
uint32_t eflags;
if(tf==NULL){
cprintf("No trapped environment\n");
return 0;
}
eflags=tf->tf_eflags;
eflags |= FL_TF;
tf->tf_eflags=eflags;
return -1;
}

假设mon_continue对应的指令的continue,mon_step对应的指令是step。那么每次执行step的话,就相当于执行一次单步调试,这是因为:当我们执行int3时,eip记录了int3指令的下一条用户指令的位置,然后断点异常嵌入内核监控,此时如果输入step,内核会执行对应的mon_step函数,设置TF标志,然后返回-1;由于返回-1,内核会跳出监控程序返回用户程序,执行eip所指向的用户指令,记录下一条指令位置,然后发生调试异常嵌入内核监控,重复上述步骤即相当于每次执行一次单步调试。我们可以借助eip值和用户程序的asm文件查看每次执行的用户指令。如果输入continue会发生什么事呢:由于用户程序由lib/entry.S开始执行,然后调用lib/libmain.c中的libmain函数,可以看到最后libmain函数会调用exit,因此如果输入continue将会返回用户程序,最终触发一次系统调用并结束用户程序。

System calls

jos的系统调用是通过int 0x30实现的。程序将会使用寄存器传递系统调用号和系统调用参数,这样就不需要在用户环境的栈或指令流中进行查找。系统调用号存在%eax中,系统调用参数存在%edx, %ecx, %ebx, %edi, %esi中,最多传递5个参数。xv6使用宏定义将用户调用转换为系统调用,并使用用户栈传递参数;不同于xv6,用户程序调用系统调用时,jos通过汇编指令同时完成参数传递和调用转换,见lib/syscall.c

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
//see in lib/syscall.c
//用户代码通过该接口进行系统调用
static inline int32_t
syscall(int num, int check, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
int32_t ret;

// Generic system call: pass system call number in AX,
// up to five parameters in DX, CX, BX, DI, SI.
// Interrupt kernel with T_SYSCALL.
//
// The "volatile" tells the assembler not to optimize
// this instruction away just because we don't use the
// return value.
//
// The last clause tells the assembler that this can
// potentially change the condition codes and arbitrary
// memory locations.

asm volatile("int %1\n"
: "=a" (ret)
: "i" (T_SYSCALL),
"a" (num),
"d" (a1),
"c" (a2),
"b" (a3),
"D" (a4),
"S" (a5)
: "cc", "memory");

if(check && ret > 0)
panic("syscall %d returned %d (> 0)", num, ret);

return ret;
}

1
2
3
4
5
Exercise 7.为系统调用添加处理程序,包括:
1、kern/trapentry.S定义“预先”处理程序。
2、kern/trap.c的trap_init()函数初始化idt表。
3、trap_dispatch()调用syscall(),执行系统调用。
4、实现kern/syscall.c的syscall()。
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
//see in kern/trapentry.S
#define TRAPHANDLER_NOEC(name, num) \
.globl name; \
.type name, @function; \
.align 2; \
name: \
pushl $0; \
pushl $(num); \
jmp _alltraps

.text
TRAPHANDLER_NOEC(entry_point48,T_SYSCALL)

//see in kern/trap.c, trap_init()
SETGATE(idt[T_SYSCALL],0,GD_KT,entry_points[T_SYSCALL],3);

//see in kern/trap.c,trap_dispatch()
switch(tf->tf_trapno) {
case (T_SYSCALL):
ret_code = 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);
tf->tf_regs.reg_eax = ret_code;//attention
break;

/see in kern/syscall.c, syscall()
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");

switch (syscallno) {
case SYS_cputs:{
sys_cputs((char *)a1,a2); //refer to lib/syscall.c
return 0;
}
case SYS_cgetc:{
return sys_cgetc();
}
case SYS_getenvid:{
return sys_getenvid();
}
case SYS_env_destroy:{
return sys_env_destroy(a1);
}
case NSYSCALLS:
default:
return -E_NO_SYS;
}
}
1
2
3
4
5
6
7
8
9
Challenge! 使用sysenter和sysexit指令代替int 0x30和iret指令来实现系统调用。sysenter/sysexit的速度快于int/iret,因为使用了寄存器而不是栈。jos中可以这样实现:在kern/trapentry.S中添加sysenter_handler,用以保存返回用户程序的信息、设置内核环境、将syscall()参数进栈,然后直接调用syscall()。syscall()返回时,设置好返回信息后执行sysexit指令。同时,在kern/init.c设置model specific registers (MSRs),可参考Section 6.1.2 in Volume 2 of the AMD Architecture Programmer's Manual和the reference on SYSENTER in Volume 2B of the Intel reference manuals。最后,lib/syscall.c必须支持sysenter指令。
使用sysenter时,寄存器的布局可能是:
eax - syscall number
edx, ecx, ebx, edi - arg1, arg2, arg3, arg4
esi - return pc
ebp - return esp
esp - trashed by sysenter
GCC的内联汇编程序将在被直接告知加载值的时候自动保存寄存器。内联汇编程序不支持保存%ebp,需要添加代码来保存和恢复。返回地址可以通过使用类似于leal after_sysenter_label, %%esi的指令存在%esi中。
注意到这个方法最多支持4个参数,不同于原来可支持5个参数的方法。另外这个方法不更新当前进程的trapframe,不适合后续lab添加的一些system call。
1
???

User-mode startup

用户程序由lib/entry.S开始执行,经过一些设置之后调用lib/libmain.c中的libmain函数。libmain函数会调用umain,umain是用户程序的主函数入口。

1
Exercise 8. lib/entry.S已经定义envs指向UENVS,需要在libmain函数中初始化全局变量thisenv指向当前进程的struct Env结构,用户程序通过thisenv获取进程的相关信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//see in lib/libmain.c
void
libmain(int argc, char **argv)
{
// set thisenv to point at our Env structure in envs[].
// LAB 3: Your code here.
thisenv = 0;
//envs see in inc/lib.h
thisenv = &envs[ENVX(sys_getenvid())];

// 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); //see in user/*.c

// exit gracefully
exit();
}

Page faults and memory protection

操作系统通常依赖于硬件支持来实现内存保护。当程序试图访问一个无效的或者没有权限的地址,处理器停止导致故障的指令然后陷入到内核。如果故障是可以解决的,内核可以修复它并让程序继续运行。如果故障是不可以解决的,那么程序将不能继续执行,因为引起故障的指令永远不会被越过。
故障可以被解决的一个例子是:自动扩展栈。在许多系统中最初只分配了一页的栈空间。当程序错误访问到栈地址时(further down the stack),内核自动分配这些页并且恢复程序执行。基于此,内核按需分配栈空间,但是程序在拥有任意大栈的“错觉”下正常工作。
系统调用给内核留下了一个内存保护的问题:许多系统调用接口让用户程序传递指针到内核中,这些指针指向了可读写的用户缓冲区,内核在执行系统调用时会间接引用这些指针。这会引发两个问题:
1、在内核中发生页错误比在用户程序中发生页错误更加严重。如果内核在操作自己的数据结构时发生了页错误,页错误处理程序应该panic内核,因为这是内核的bug。但如果是间接引用用户程序传递的指针引起的页错误,需要一种方式记住这些间接引用导致的页错误都是代表用户程序的。
2、内核通常比用户程序拥有更多的内存权限。用户程序传递给内核的指针指向的内存对于内核来说可能是可读写的,但对于用户程序来说是不能读写的。内核应当注意不要暴露一些私有信息或破坏内核完整性。

1
Exercise 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
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
问题1的解决:在kern/trap.c的page_fault_handler函数中识别页错误是发生在内核模式还是用户模式中(判断tf_cs的低2位),如果是内核模式,直接panic;如果是用户模式,销毁进程。
//see in kern/trap.c
void
page_fault_handler(struct Trapframe *tf)
{
uint32_t fault_va;

// Read processor's CR2 register to find the faulting address
fault_va = rcr2();

// Handle kernel-mode page faults.

// LAB 3: Your code here.
if ((tf->tf_cs&3) == 0)
panic("Kernel page fault!");
// We've already handled kernel-mode exceptions, so if we get here,
// the page fault happened in user mode.

// Destroy the environment that caused the fault.
cprintf("[%08x] user fault va %08x ip %08x\n",
curenv->env_id, fault_va, tf->tf_eip);
print_trapframe(tf);
env_destroy(curenv);
}

问题2的解决:查看kern/pmap.c的user_mem_assert函数,并实现user_mem_check。然后在kern/syscall.c中检查系统调用的参数。user_mem_check函数主要是根据给定的权限查看给定的[va, va+len)是否有对应的访问权限。
//see in kern/pmap.c
//
// Check that an environment is allowed to access the range of memory
// [va, va+len) with permissions 'perm | PTE_P'.
// Normally 'perm' will contain PTE_U at least, but this is not required.
// 'va' and 'len' need not be page-aligned; you must test every page that
// contains any of that range. You will test either 'len/PGSIZE',
// 'len/PGSIZE + 1', or 'len/PGSIZE + 2' pages.
//
// A user program can access a virtual address if (1) the address is below
// ULIM, and (2) the page table gives it permission. These are exactly
// the tests you should implement here.
//
// If there is an error, set the 'user_mem_check_addr' variable to the first
// erroneous virtual address.
//
// Returns 0 if the user program can access this range of addresses,
// and -E_FAULT otherwise.
//
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);
uint32_t begin = (uint32_t)ROUNDDOWN((char*)va,PGSIZE);
uint32_t end = (uint32_t)ROUNDUP((char*)(va+len),PGSIZE);
uint32_t i=0;
for(i=begin;i<end;i+=PGSIZE){
pte_t *pte = pgdir_walk(env->env_pgdir,(void *)i,false);
if(i>=ULIM || !pte || !(*pte & PTE_P) || (*pte & perm) != perm){
user_mem_check_addr = (i<(uint32_t)va?(uint32_t)va:i);
return -E_FAULT;
}
}
cprintf("user_mem_check success va: %x, len: %x\n", va, len);
return 0;
}

//
// Checks that environment 'env' is allowed to access the range
// of memory [va, va+len) with permissions 'perm | PTE_U | PTE_P'.
// If it can, then the function simply returns.
// If it cannot, 'env' is destroyed and, if env is the current
// environment, this function will not return.
//
void
user_mem_assert(struct Env *env, const void *va, size_t len, int perm)
{
if (user_mem_check(env, va, len, perm | PTE_U) < 0) {
cprintf("[%08x] user_mem_check assertion failure for "
"va %08x\n", env->env_id, user_mem_check_addr);
env_destroy(env); // may not return
}
}

//see in kern/syscall.c
// Print a string to the system console.
// The string is exactly 'len' characters long.
// Destroys the environment on memory errors.
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.
//see in kern/env.c : int envid2env(envid_t envid, struct Env **env_store, bool checkperm);
//struct Env* e ;
//envid2env(sys_getenvid(),&e,1);
user_mem_assert(curenv, s, len, PTE_U);//see in kern/pmap.c
// Print the string supplied by the user.
cprintf("%.*s", len, s);
}

遗留的问题

1、二进制文件怎么连接进内核里?
2、如何生成唯一pid?以及pid与ENVX的关系
3、binary的数据位于内核空间的哪个节?
4、为什么所有中断都设置为中断门?
5、Exercise 7的Challenge实现
6、实际用户程序编译之后的地址与所谓的段选择子:偏移的对应关系?
7、各种门的使用情况

笔记10 - HW4: xv6 lazy page allocation

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

Part One: Eliminate allocation from sbrk()

xv6应用程序通过调用sbrk()系统调用向内核申请heap内存空间,该系统调用将分配物理内存并map到进程的页目录(虚拟地址空间)。鉴于许多程序申请物理空间后并不使用,因此复杂的内核通常会设计为:在应用程序试图使用不存在的页面(访问到的虚拟地址不存在物理页映射)的时候,通过捕获产生页错误信号,然后分配物理内存使得程序可以继续执行,这称为“lazy page allocation”。而在申请heap内存空间的时候,仅仅是增大用户空间的值,即proc->sz。

修改后的代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//sysproc.c
int
sys_sbrk(void)
{
int addr;
int n;

if(argint(0, &n) < 0)
return -1;
addr = proc->sz;
//if(growproc(n) < 0)
// return -1;
proc->sz += n;
return addr;
}

sys_sbrk()是通过growproc()函数来增加物理空间和改变用户空间大小的,注意到一个事实:用户空间的栈顶指针并没有发生改变。init.c通过fork子进程,然后子进程exec执行sh的时候,sh程序的栈顶指针是指向新分配的用户空间顶端的。之后sh程序fork子进程执行命令,子进程保持了该栈顶指针,子进程增加heap内存空间的时候,栈顶指针也没有变化。
这样子的话,在sh中执行echo hi将会出现页错误,具体的发生时机是:
sh.c读取输入命令后,fork子进程执行命令,在解析命令的过程中调用execcmd,execcmd函数调用malloc()函数(见umalloc.c),malloc()函数根据空间使用情况将调用morecore()函数,morecore()函数调用sbrk()函数分配物理空间,进而内核执行sys_sbrk()系统调用。sys_sbrk()只返回分配前用户空间顶部的逻辑地址,因此malloc操作的地址空间其实是位于原用户空间之上。这里我们没有实际分配物理空间,因此后续在morecore()函数中执行hp->s.size = nu;时将发生页错误(还有其他地方也可能产生页错误,只要是访问到的虚拟地址没有映射到物理页),系统会产生T_PGFLT信号,trap.c中将输出相关错误信息,说明内核不知如何处理此类问题。

Part Two: Lazy allocation

我们将在trap.c对缺页信号进行处理,具体思路是:获取发生页错误的虚拟地址,将其向下4K页对齐,然后分配一页的物理空间并映射到进程页目录中。注意到,这并不会产生地址重映射的问题,因为如果重映射,说明发生页错误的虚拟地址向下4K页对齐后的对应的页之前已经被映射进页目录了,而这样的话该地址是不会产生页错误的。代码如下:

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
//trap.c
void
trap(struct trapframe *tf)
{
.
.
.
switch(tf->trapno){
.
.
.
//PAGEBREAK: 13
default:
if(proc == 0 || (tf->cs&3) == 0){
// In kernel, it must be our mistake.
cprintf("unexpected trap %d from cpu %d eip %x (cr2=0x%x)\n",
tf->trapno, cpunum(), tf->eip, rcr2());
panic("trap");
}

//By jianzzz
//处理页错误T_PGFLT
if(tf->trapno == T_PGFLT){
//rcr2() --> 导致页错误的虚拟地址
uint va = rcr2();
uint sz = PGROUNDDOWN(va);
cprintf( "T_PGFLT:%x\n",va);
if((sz = allocuvm(proc->pgdir, sz, sz + PGSIZE)) == 0)
panic("trap T_PGFLT");
break;
}

// In user space, assume process misbehaved.
cprintf("pid %d %s: trap %d err %d on cpu %d "
"eip 0x%x addr 0x%x--kill proc\n",
proc->pid, proc->name, tf->trapno, tf->err, cpunum(), tf->eip,
rcr2());
proc->killed = 1;
}
.
.
.
}

xv6的堆分配思想见【The C Programming Language 8.7.实例–存储分配程序】。
由上可知,堆是在用户态实现的,使用链表进行管理。在xv6中,使用malloc并不一定分配物理空间。如果堆管理发现可用空间不够,则调用sbrk()函数申请分配物理空间,此时系统只累加进程空间大小,并返回当前用户空间顶部的逻辑地址。堆管理根据申请的大小调整链表记录,然后尝试对“分配”的空间进行访问,产生页错误,内核对其进行页分配和映射,返回用户态后程序正常运行。之后只要继续在链表记录的大小内使用malloc,若遇到未分配地址则继续产生页错误,否则使用已有的空间;而如果超出了链表记录大小,则再次调用调用sbrk()函数申请分配物理空间并更新链表大小记录。对于不再使用的空间,调用free()并入到空闲链表中,没有实际归还到操作系统。等到程序结束的时候,所有映射到页表的堆空间将被释放。

遗留的问题

申请新的heap空间后,特殊情况下栈顶指针会不会指向到heap空间(比如人为一直pop)?即这里存在一个疑问:栈底是固定的吗???

笔记09 - xv6 部分系统调用的实现

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

exec

exec指令用新程序替代当前进程的内存和寄存器,但保留文件描述符、进程id、父进程不变。

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
//exec.c
int
exec(char *path, char **argv)
{
char *s, *last;
int i, off;
uint argc, sz, sp, ustack[3+MAXARG+1];
struct elfhdr elf;
struct inode *ip;
struct proghdr ph;
pde_t *pgdir, *oldpgdir;

begin_op();

if((ip = namei(path)) == 0){//寻找name对应的inode
end_op();
return -1;
}
ilock(ip);
pgdir = 0;

// Check ELF header
//读取程序的elf
if(readi(ip, (char*)&elf, 0, sizeof(elf)) < sizeof(elf))// Read data from inode.
goto bad;
if(elf.magic != ELF_MAGIC)
goto bad;
//创建内核页目录,映射内核空间。实际上是创建二级页表,并在二级页表项上存储物理地址。
//页目录项所存页表的权限是用户可读写,二级页表项所存物理页的权限按照kmap设定
if((pgdir = setupkvm()) == 0)
goto bad;

// Load program into memory.
//根据inode和elf程序头表偏移量, 按段读取程序
sz = 0;
for(i=0, off=elf.phoff; i<elf.phnum; i++, off+=sizeof(ph)){
//读取段的程序头表项
if(readi(ip, (char*)&ph, off, sizeof(ph)) != sizeof(ph))
goto bad;
if(ph.type != ELF_PROG_LOAD)
continue;
if(ph.memsz < ph.filesz)
goto bad;
if(ph.vaddr + ph.memsz < ph.vaddr)
goto bad;
//根据段的虚拟地址及段占据的内存大小分配物理空间,该过程会将程序的虚拟地址映射到分配到的物理页地址上
//二级页表项所存物理页的权限是用户可读写
if((sz = allocuvm(pgdir, sz, ph.vaddr + ph.memsz)) == 0)
goto bad;
if(ph.vaddr % PGSIZE != 0)
goto bad;
//根据程序头表项信息读取段到内存中
if(loaduvm(pgdir, (char*)ph.vaddr, ip, ph.off, ph.filesz) < 0)
goto bad;
}
iunlockput(ip);
end_op();
ip = 0;

// Allocate two pages at the next page boundary.
// Make the first inaccessible. Use the second as the user stack.
sz = PGROUNDUP(sz);
//继续为程序分配两页物理空间,二级页表项所存物理页的权限是用户可读写
//取消第一页用户可读写权限,第二页作为用户栈
if((sz = allocuvm(pgdir, sz, sz + 2*PGSIZE)) == 0)
goto bad;
clearpteu(pgdir, (char*)(sz - 2*PGSIZE));
sp = sz;

// Push argument strings, prepare rest of stack in ustack.
for(argc = 0; argv[argc]; argc++) {
if(argc >= MAXARG)
goto bad;
sp = (sp - (strlen(argv[argc]) + 1)) & ~3;
if(copyout(pgdir, sp, argv[argc], strlen(argv[argc]) + 1) < 0)
goto bad;
ustack[3+argc] = sp;
}
ustack[3+argc] = 0;

ustack[0] = 0xffffffff; // fake return PC
ustack[1] = argc;
ustack[2] = sp - (argc+1)*4; // argv pointer

sp -= (3+argc+1) * 4;
if(copyout(pgdir, sp, ustack, (3+argc+1)*4) < 0)
goto bad;

// Save program name for debugging.
for(last=s=path; *s; s++)
if(*s == '/')
last = s+1;
safestrcpy(proc->name, last, sizeof(proc->name));

// Commit to the user image.
oldpgdir = proc->pgdir;
proc->pgdir = pgdir;
proc->sz = sz;
proc->tf->eip = elf.entry; // main
proc->tf->esp = sp;
switchuvm(proc);//设置cpu环境后,加载进程的页目录地址到cr3
freevm(oldpgdir);
return 0;

bad:
if(pgdir)
freevm(pgdir);
if(ip){
iunlockput(ip);
end_op();
}
return -1;
}

注意到,exec改变的有进程的页目录、用户空间内容、用户空间size、进程的tf->eip、tf->esp、用户栈、进程名,没有改变的有进程文件描述符、进程pid、父进程、内核栈、进程状态、当前目录等。

exec的整体思路是:1、根据程序路径查找对应的inode;2、根据inode读取程序的elf信息;3、调用setupkvm()创建页目录,并映射内核空间(页目录项所存页表的权限是用户可读写,二级页表项所存物理页的权限按照kmap设定);4、根据inode和elf程序头表偏移量,按段读取程序。每次循环时:4.1、先读取段的程序头表项,程序头表项包含段的虚拟地址及段占据的内存大小,4.2、根据信息调用allocuvm()分配出物理空间,分配过程会将程序的虚拟地址映射到分配到的物理页地址上,二级页表项所存物理页的权限是用户可读写,4.3、根据程序头表项信息(段偏移及段的文件大小)读取段到内存中;5、继续为程序分配两页物理空间,二级页表项所存物理页的权限是用户可读写,取消第一页用户可读写权限,第二页将作为用户栈。6、构建用户栈数据(参数等,见下);7、更新进程的页目录、用户空间size、进程的tf->eip为elf.entry(main)、tf->esp为用户栈指针当前位置,然后调用switchuvm()函数设置cpu环境,加载进程的页目录地址到cr3,释放旧页目录空间;8、返回trapret处,切换为用户态,返回执行新程序代码,不返回执行旧程序代码。

fork

fork用于创建新进程,并且复制当前进程的页目录、tramframe、内存空间大小、文件描述符、当前目录等。

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
//proc.c
int
fork(void)
{
int i, pid;
struct proc *np;

// Allocate process.
//在进程表中寻找slot,成功的话更改进程状态和pid,并初始化进程的内核栈
//注意子进程使用的是新的内核栈
if((np = allocproc()) == 0){
return -1;
}

// Copy process state from p.
//copy一个进程的页目录对应的所有内容,思路是:首先创建新的页目录;
//在进程的用户空间虚拟地址范围内,由虚拟地址0x0开始,取得映射的物理页地址,然后申请新的物理页,将前者物理页的内容copy到后者;
//取出二级页表项的flag,当前虚拟地址映射到新物理页地址,其二级页表项权限设为flag。
if((np->pgdir = copyuvm(proc->pgdir, proc->sz)) == 0){ //see in vm.c
kfree(np->kstack);
np->kstack = 0;
np->state = UNUSED;
return -1;
}
np->sz = proc->sz; //设置进程的用户空间范围
np->parent = proc; //设置进程的父进程
*np->tf = *proc->tf; //设置进程的tramframe,这是结构体的值传递???

// Clear %eax so that fork returns 0 in the child.
np->tf->eax = 0;
//复制fd
for(i = 0; i < NOFILE; i++)
if(proc->ofile[i])
np->ofile[i] = filedup(proc->ofile[i]);
np->cwd = idup(proc->cwd);//复制当前目录

safestrcpy(np->name, proc->name, sizeof(proc->name));

pid = np->pid;

acquire(&ptable.lock);

np->state = RUNNABLE;//父进程必须在子进程的所有内容设置完毕后,才改为runnable,才能被cpu调度

release(&ptable.lock);

return pid;
//对于父进程而言,其系统调用的结果(子进程的pid)将在syscall.c中赋值到tf->eax中,
//然后在trapasm.S中弹出到eax,最终被用户进程获取。
//对于子进程而言,其复制了父进程的用户空间内容(包括代码),并使用新的内核栈,
//内核栈中trapframe的内容复制于父进程(包括下一条指令的eip,这条指令往往是从eax获取系统调用结果),
//并将eax的值改为0,这样子当cpu调度到子进程的时候,内核会将子进程的trapframe弹出,子进程将执行eip指向的指令,即从eax获取系统调用结果,所以获取到的pid是0。
}

注意到,fork新建并复制的内容有进程的内核栈(copy了tramframe)、页目录、用户空间内容,改变的内容有进程的父进程、进程的tf->eax、进程状态,复制的有用户空间size、进程的tramframe、进程的文件描述符、当前目录、进程名等。

fork的过程是:1、在进程表中寻找slot,成功的话更改进程状态和pid,并初始化进程的内核栈。注意子进程使用的是新的内核栈。2、copy当前进程的页目录对应的所有内容,思路是:首先创建新的页目录;在进程的用户空间虚拟地址范围内,由虚拟地址0x0开始,取得映射的物理页地址,然后申请新的物理页,将前者内容copy到后者;取出二级页表项的flag,当前虚拟地址映射到新物理页地址,其二级页表项权限设为flag。3、设置进程的用户空间范围、进程的父进程、进程的tramframe(值传递)。4、清除新进程tf->eax为0,所以子进程的fork返回值为0。5、复制fd、当前目录、进程名。6、设置进程状态为RUNNABLE。7、父进程返回pid。

遗留的问题

1
1、inode以及目录项的设计

笔记08 - HW3: xv6 system calls

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

Part One: System call tracing

任务:修改syscall.c的syscall()函数,输出系统调用函数名和执行结果。
解决方案如下:
根据xv6源码分析,可以知道inidcode.S执行movl $SYS_exec, %eax把trapno放到%eax里面,之后int的时候会跳转到alltraps,其会执行pushal将包括eax在内的通用寄存器进栈(其实是保存到proc->tf),之后在syscall函数就可以通过proc->tf->eax获取trapno了。而系统调用的执行结果也放在了eax中。从proc->tf->eax中可以拿到syscall number,然后做个映射表输出就可以了。
另外,syscall.c提供argint、argptr和argstr等工具函数,用于在进程用户空间获得第 n 个系统调用参数。argint用于获取第n个整数,存储在int指针中(通过fetchint工具函数完成)。argptr用于获取第n个整数,将char指针指向它。argstr用于获取第n个整数,该参数是字符串起始地址值,使用char指针指向它,函数本身返回字符串长度(通过fetchstr工具函数完成)。argint利用用户空间的%esp寄存器定位第n个参数:%esp指向系统调用结束后的返回地址,参数就恰好在%esp之上(%esp+4),因此第n个参数就在%esp+4+4*n。
此处不对系统调用参数进行输出,因为每个系统调用所传递的参数个数和类型不同,而如果直接在工具函数中输出的话,则无法判断是地址值还是参数值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const char* syscall_name[] = { "fork","exit","wait","pipe","read","kill","exec","fstat","chdir","dup","getpid","sbrk","sleep","uptime","open","write","mknod","unlink","link","mkdir","close" };

void
syscall(void)
{
int num;
num = proc->tf->eax;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
proc->tf->eax = syscalls[num]();
//trapno放到%eax里面,系统调用的执行结果也放在了eax中
//打印系统调用名称和结果
//cprintf("\n%s -> %d\n", syscall_name[num], proc->tf->eax);
} else {
cprintf("%d %s: unknown sys call %d\n",
proc->pid, proc->name, num);
proc->tf->eax = -1;
}
}

Part Two: Date system call

系统调用实现思路如下:
有一个.h文件暴露接口,有一个.c文件来实现接口,在x86上实现方法是c语言层内联汇编int指令(或者直接汇编实现),把系统调用号放入eax,内核中有一个系统调用表,根据eax的值来索引这个表得到vectorXXX地址,vectorXXX jmp过去alltraps,进入内核模式,执行trap函数,trap函数将执行系统调用函数(如果系统调用叫xxx,内核对应的函数一般叫sys_xxx)。
在xv6中,想要添加新的系统调用并在shell中调用,需要了解五个文件:

1
2
3
4
5
user.h 定义了可以通过shell调用的函数
usys.S 使用宏定义将用户调用转换为系统调用
syscall.h 定义系统调用的位置向量,从而可以连接到系统调用的实现
syscall.c 根据syscall.h 定义的系统调用位置向量,调用系统调用的实现函数
sysproc.c 增加了系统调用的真正实现

添加系统调用函数date(),返回当前UTC时间。参考cmostime()(defined in lapic.c)函数,其功能是读取实时时钟;参考date.h,该头文件定义了struct rtcdate,该结构体作为cmostime()的指针形参。
技巧:参考uptime的实现,执行grep -n uptime *.[chS]参考其在对应的文件中的定义。然后在Makefile中添加_date到UPROGS中。创建date.c,输入以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include "types.h"
#include "user.h"
#include "date.h"

int
main(int argc, char *argv[])
{
struct rtcdate r;

if (date(&r)) {
printf(2, "date failed\n");
exit();
}

// your code to print the time in any format you like...
printf(1, "%d/%d/%d %d:%d:%d\n", r.year, r.month, r.day, r.hour, r.minute, r.second);
exit();
}

这样之后编译执行后,在命令行输入date应该能输出时间。
其工作原理是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//see in usys.S
#define SYSCALL(name) .globl name; name: movl $SYS_ ## name, %eax; int $T_SYSCALL; ret
## 操作符将会连接$SYS_和name。
所以,声明SYSCALL(date)将被扩展为:
.globl date; date: movl $SYS_date, %eax; int $T_SYSCALL; ret

如果在控制台中(用户进程)执行date(&r),其中r的定义是struct rtcdate r,将会将r的地址进栈,eip进栈,然后寻找到 date: 标识位置,执行int系统调用。syscall.c根据SYS_date的值调用sys_date()函数。sys_date()函数的实现是:

int
sys_date(void)
{
struct rtcdate *pr;
if (argptr(0, (void*)&pr, sizeof(struct rtcdate)) < 0) {
return -1;
}
cmostime(pr);
return 0;
}

其将会定义struct rtcdate类型的pr指针,然后使用argptr函数获取proc->tf->esp(进程用户栈)指向的第一个32-bit参数(跳过esp指向的eip),将pr指针指向该值,由上述可知这是调用date函数的参数地址,所以存储到pr相当于存储到参数r中。

在Makefile中添加_date到UPROGS中后,将生成_date的可执行文件,其将作为内核的内置函数。

Challenge

add a dup2() system call
实现的方法如上。区别是将sys_dup2(void)的实现放在sysfile.c中,与sys_dup(void)放在一起。

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
int
sys_dup2(void)
{
struct file *oldfile, *newfile;
int newfd;
//取出第一个参数fd,其应该对应到一个file对象
if (argfd(0, 0, &oldfile) < 0) {
return -1;
}
//取出第二个参数fd
if (argint(1, &newfd) < 0) {
return -1;
}
if(newfd < 0 || newfd >= NOFILE) {
return -1;
}

//newfd文件描述符没有对应的file对象,可以安全使用,使新旧fd指向同一个file对象
if (proc->ofile[newfd] == 0) {
goto final;
} else if (argfd(1, &newfd, &newfile) < 0) { //newfd文件描述符有对应的file对象,取出file对象
return -1;
}
//两个fd指向同个file对象,返回
if (oldfile == newfile) {
return newfd;
}
//关闭文件
if (newfile->ref > 0) {
fileclose(newfile);
}

final:
proc->ofile[newfd] = oldfile;
filedup(oldfile);

return newfd;
}

dup2test.c比较疑惑的是创建的文件不知道在哪里,所以使用了read函数进行测试。(在xv6虚拟的文件系统中)

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
//dup2test.c
/*
* Tests the functionality of the `dup2` system call. The first argument is the
* original file the we are duplicating. If a second argument is passed in we
* open this file, write a string to it, then duplicate it. This tests that our
* kernel is properly closing and duplicating the file. In both cases the original
* file should have the string "foobar\n". If not, then something went wrong
*/

#include "types.h"
#include "stat.h"
#include "user.h"
#include "fcntl.h"

int
main(int argc, char *argv[]) {
int origfd, newfd = 0;
if (argc < 2) {
printf(1, "%s: Not enough arguments\n", argv[0]);
printf(1, "Usage: origfile [newfile]\n");
exit();
}

unlink(argv[1]);

if ((origfd = open(argv[1], O_CREATE|O_RDWR)) < 0) {
printf(1, "Cannot open '%s'\n", argv[1]);
exit();
}
if (argc > 2) {
unlink(argv[2]);
if ((newfd = open(argv[2], O_CREATE|O_RDWR)) < 0) {
printf(1, "Cannot open '%s'\n", argv[2]);
exit();
}
write(newfd, "ignored\n", 8);
}

write(origfd, "foo", 3);
if (dup2(origfd, newfd) < 0) {
printf(1, "dup2 error\n");
}

write(newfd, "bar\n", 4);

close(origfd);
close(newfd);
if ((newfd = open(argv[1], O_RDWR)) < 0) {
printf(1, "Cannot open '%s'\n", argv[2]);
exit();
}
char buf[256];
int n = read(newfd,buf,256);
printf(1, "%d: %s\n", n,buf);

exit();
}

其他问题

如何拿到系统调用的结果,比如dup(fd)?
我们知道,系统调用的结果最终是放在进程tf结构的eax中,然后在trapret阶段弹出到寄存器中。而返回到用户空间后会往eax中拿取结果,这是一种默认约定。




遗留的问题

为什么需要转换成系统调用?实质区别在哪里(关于硬件特权)?
为什么类似printf等不需要实现为系统调用?

1…4567
zoro

zoro

如果我后退的话,我曾重视的誓言和约定就会全部消失,然后,再也不会回来这个地方了

65 日志
12 分类
18 标签
© 2020 zoro
由 Hexo 强力驱动
|
主题 — NexT.Gemini