目录
在LAB3中,将实现运行受保护的用户模式环境(即“进程”)所需的基本内核工具。 您将增强 JOS 内核以设置数据结构以跟踪进程、创建单个进程、将程序映像加载到内核中并启动它运行。还将使 JOS 内核能够处理进程进行的任何系统调用并处理它引起的任何其他异常。
LAB3中新增了一些程序文件:
用户环境定义:inc/env.h
inc/env.h中包含了JOS对用户环境的基本定义,JOS内核使用Env来跟踪每个用户环境。
struct Env {
struct Trapframe env_tf; // 保存陷阱帧的寄存器。
struct Env *env_link; // 下一个空闲的用户环境。env_free_list指向列表中的第一个空闲环境。
envid_t env_id; // 唯一的用户环境标识符。
envid_t env_parent_id; // 父环境的标识符
enum EnvType env_type; // 是否是特殊系统环境
unsigned env_status; // 环境状态
uint32_t env_runs; // 环境运行的次数
// 地址空间
pde_t *env_pgdir; // 页面目录的内核虚拟地址
};
env_tf:在该环境没有运行时为该环境保存的寄存器值,也就是当内核或者其他环境运行时保存的该环境的寄存器的值。当从用户模式切换到内核模式时,内核会保存这些信息,以便之后恢复进程。
env_link:一个指向下一个空闲的用户环境的连接。env_free_list指向列表中的第一个空闲环境。env_id:内核在此处存储一个值,该值唯一标识当前使用此 Env 结构的环境(即,使用 envs 数组中的此特定插槽)。 用户环境终止后,内核可能会将相同的 Env 结构重新分配给不同的环境 ,但即使新环境重新使用 envs 数组中的相同插槽,新环境也将具有与旧环境不同的 env_id。
env_id包括32位,低10位是env_id对应envs数组中的下标,中间21位决定独一无二的标识,最高位被永久设置为0。因此envs数组一共1024个env,通过ENVX得到低10位,也就是对应envs的index。即便两个环境index相同,也会有不同的uniqueifier。
env_parent_id:内核在此处存储创建此环境的环境(父进程)的 env_id。 通过这种方式,环境可以形成一个“家谱”,这将有助于做出关于允许哪些环境对谁做什么的安全决策。
env_type:用于区分特殊环境,大多数环境是ENV_TYPE_USER 。
// Special environment types enum EnvType { ENV_TYPE_USER = 0, };
env_status:
// Values of env_status in struct Env enum { ENV_FREE = 0,//环境没有运行,处于env_free_list ENV_DYING,//僵尸进程,在下一次进入内核时释放,1 ENV_RUNNABLE,//等待在CPU上运行的环境,2 ENV_RUNNING,//正在运行的进程,3 ENV_NOT_RUNNABLE//当前处于活动状态的进程,但尚且没有准备好进程:例如等待另一个进程通信。,4 };
env_pgdir:环境页目录的内核虚拟地址。
分配环境数组envs
与LAB2中pages的分配一样。
// Make 'envs' point to an array of size 'NENV' of 'struct Env'.
envs=(struct Env*)boot_alloc(NENV*sizeof(struct Env));//1024个进程
memset(envs,0,sizeof(envs));
//映射到UENVS,用户只读。
boot_map_region(kern_pgdir,UENVS,(size_t)PTSIZE,PADDR(envs),PTE_U);
创建与运行环境kern/env.c
kern/env.c分析:
envs数组:所有的进程。
curenv:当前进程。
gdt:全局描述符表,其中每个元素是段描述符。为内核模式和用户模式设置具有单独段的全局描述符表(GDT)。段在 x86 上有很多用途。我们不使用它们的任何内存映射功能,但我们需要它们来切换权限级别。除了 DPL 之外,内核段和用户段是相同的。要加载 SS 寄存器,CPL 必须等于 DPL。因此,我们必须为用户和内核复制段。 特别是,gdt 定义中使用的 SEG 宏的最后一个参数指定了该描述符的描述符特权级别 (DPL):0 代表内核,3 代表用户。
由于对于段描述符的概念并不清晰,因此查阅相关文章,结合mmu.h中的定义总结如下:
段描述符详解_taolaodawho的博客-CSDN博客_段描述符
段寄存器通过可见的16位(段选择子)得到INDEX来加载GDT中的段描述符。将上图中各位的描述与代码中对应起来如下。
// Segment Descriptors struct Segdesc { unsigned sd_lim_15_0 : 16; // Low bits of segment limit unsigned sd_base_15_0 : 16; // Low bits of segment base address unsigned sd_base_23_16 : 8; // Middle bits of segment base address unsigned sd_type : 4; // Segment type (see STS_ constants),s=1且TYPE<8,因此为数据段描述符。若S=1且TYPE》8,为代码段描述符。 unsigned sd_s : 1; // 0 = system, 1 = application,0-->系统段描述符,1-->代码段或者数据段。 unsigned sd_dpl : 2; // Descriptor Privilege Level想要加载这个段,CPL<=DPL unsigned sd_p : 1; // Present,P=0时为无效描述符,其他任何操作都没有意义。 unsigned sd_lim_19_16 : 4; // High bits of segment limit unsigned sd_avl : 1; // Unused (available for software use)是否可以读取 unsigned sd_rsv1 : 1; // Reserved unsigned sd_db : 1; // 0 = 16-bit segment, 1 = 32-bit segment unsigned sd_g : 1; // Granularity: limit scaled by 4K when set粒度,g=1代表limit单位为4KB,limit不能决定段的大小,只能决定段可以访问的大小。 unsigned sd_base_31_24 : 8; // High bits of segment base address };
因此也可以得出mmu.h中剩下定义式的含义:
// Null segment空段 #define SEG_NULL { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } // Segment that is loadable but faults when used段错误,可加载但是不可以使用 #define SEG_FAULT { 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0 } // Normal segment #define SEG(type, base, lim, dpl) 段构造 \ { ((lim) >> 12) & 0xffff, (base) & 0xffff, ((base) >> 16) & 0xff, \ type, 1, dpl, 1, (unsigned) (lim) >> 28, 0, 0, 1, 1, \ (unsigned) (base) >> 24 }段构造 #define SEG16(type, base, lim, dpl) (struct Segdesc) \ { (lim) & 0xffff, (base) & 0xffff, ((base) >> 16) & 0xff, \ type, 1, dpl, 1, (unsigned) (lim) >> 16, 0, 0, 1, 0, \ (unsigned) (base) >> 24 } //剩下的部分属于段描述符类型的定义。
gdt[ ]:包括有空段,内核代码段,内核数据段,用户代码段,用户数据段以及任务状态tss段。
struct Segdesc gdt[] = { // 0x0 - unused (always faults -- for trapping NULL far pointers) SEG_NULL, // 0x8 - kernel code segment [GD_KT >> 3] = SEG(STA_X | STA_R, 0x0, 0xffffffff, 0),//如这个构造描述符,表示内核数据段类型为可读写或可执行。 // 0x10 - kernel data segment [GD_KD >> 3] = SEG(STA_W, 0x0, 0xffffffff, 0), // 0x18 - user code segment [GD_UT >> 3] = SEG(STA_X | STA_R, 0x0, 0xffffffff, 3), // 0x20 - user data segment [GD_UD >> 3] = SEG(STA_W, 0x0, 0xffffffff, 3), // 0x28 - tss, initialized in trap_init_percpu() [GD_TSS0 >> 3] = SEG_NULL };
envid2env():从进程id得到进程。
env_int():
void env_init(void) { // Set up envs array初始化envs数组 env_free_list=NULL; for(int i=NENV-1;i>=0;i--){//为了使进程在空闲列表中是一样的顺序,因此倒序 envs[i].env_id=0; envs[i].env_link=env_free_list; envs[i].env_status=ENV_FREE; env_free_list=&envs[i]; } // Per-CPU part of the initialization每个CPU进程的初始化 env_init_percpu(); }
env_setup_vm():为新进程分配页目录并初始化进程地址空间的内核部分。
static int env_setup_vm(struct Env *e) { int i; struct PageInfo *p = NULL; // 为进程分配一个页面作为页目录 if (!(p = page_alloc(ALLOC_ZERO))) return -E_NO_MEM; e->env_pgdir=page2kva(p); p->pp_ref++; memcpy(e->env_pgdir,kern_pgdir,PGSIZE);//使用kernel的页目录了来代替,因为Utop之上的空间映射相同。 e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_P | PTE_U;//UVPT用户只读。 return 0; }
env_alloc():分配创建一个新进程,其他的都没有什么,有一段关于寄存器状态的设置:
memset(&e->env_tf, 0, sizeof(e->env_tf));//清楚之前使用过的寄存器状态。 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;//用户代码段
region_alloc():为进程[va, va+len]的虚拟空间配置物理内存。
static void region_alloc(struct Env *e, void *va, size_t len) { //对扩充后的虚拟内存按照每个页面来分配物理页面 uintptr_t start=ROUNDDOWN((uint32_t)va,PGSIZE); uintptr_t end=ROUNDUP((uint32_t)(va+len),PGSIZE); struct PageInfo* pp=NULL; for(int i=0;i<(end-start)/PGSIZE;i++){ pp=page_alloc(0); if(!pp) panic("out of memory!"); int result=page_insert(e->env_pgdir,pp,(void*)(start+i*PGSIZE),PTE_U|PTE_W);//PTE_P| if(result!=0) panic("region_alloc() failed!"); } }
load_icode():包含运行用户环境(进程)所需的代码,因为还没有文件系统,因此设置内核加载嵌入内核本身的静态二进制镜像,JOS将此文件作为ELF可执行镜像嵌入内核中。解析ELF二进制映像,链接为可执行文件,将其加载到进程的用户地址空间。
仿照bootmain(),与提示:
static void load_icode(struct Env *e, uint8_t *binary) { struct Proghdr* ph,*eph; struct Elf* elf=(struct Elf*) binary;//文件按照elf格式读取 if (elf->e_magic != ELF_MAGIC) panic("not ELF files!"); ph=(struct Proghdr*)(binary+elf->e_phoff);//按照程序段来加载 eph = ph + elf->e_phnum; lcr3(PADDR(e->env_pgdir));//映射环境页目录 for(;ph<eph;ph++){ if(ph->p_filesz>ph->p_memsz) panic("load icode error\n"); if(ph->p_type!=ELF_PROG_LOAD) continue; region_alloc(e,(void*)(ph->p_va),ph->p_memsz);//为虚拟空间配置物理内存 // memcpy((void*)(ph->p_va),ph,ph->p_memsz); memcpy((void*)(ph->p_va),binary+ph->p_offset,ph->p_filesz);//将file部分copy memset((void*)(ph->p_va+ph->p_filesz),0,(ph->p_memsz-ph->p_filesz));//将其余虚拟空间置0 } // env_pop_tf(&(e->env_tf)); lcr3(PADDR(kern_pgdir)); e->env_tf.tf_eip=elf->e_entry;//eip代表程序入口,因此将elf文件中的entry赋给eip寄存器 // Now map one page for the program's initial stack // at virtual address USTACKTOP - PGSIZE. region_alloc(e,(void*)(USTACKTOP-PGSIZE),PGSIZE);//为用户栈顶分配物理内存 }
env_create():分配进程,加载elf镜像。
void env_create(uint8_t *binary, enum EnvType type) { struct Env* e; int ret=env_alloc(&e,0); if(ret!=0) panic("env alloc error!"); load_icode(e,binary); e->env_type=ENV_TYPE_USER; }
env_free():释放进程与内存空间。
env_pop_tf():存储寄存器的值,在内核中存在并开始执行进程代码。
env_run():启动在用户模式下给定的环境。按照提示完成就好。
void env_run(struct Env *e) { if(curenv&&curenv->env_status==ENV_RUNNING) curenv->env_status=ENV_RUNNABLE; // e->env_link=curenv; curenv=e; curenv->env_status=ENV_RUNNING; curenv->env_runs++; lcr3(PADDR(curenv->env_pgdir)); env_pop_tf(&curenv->env_tf); // panic("env_run not yet implemented"); }
在调用用户代码前的代码调用顺序如下:
kern/entry.s-->i386_int()-->memset(在最初的时和完成ELF加载进程,未初始化的bss段清零,保证静态变量与全局变量为0)-->cons_init(输入输出初始化)-->mem_init(内存管理,分页机制、虚拟地址)--->env_init(进程初始化)--->trap_init(中断初始化)-->env_create(创建进程)--->env_run(运行进程)...此时系统中只存在一个用户进程。
三重故障:完成后,编译内核并在 QEMU 下运行它。如果一切顺利,系统应该进入用户空间并执行 hello 二进制文件,直到它使用 int 指令进行系统调用。但是会出现麻烦,因为 JOS 还没有设置硬件来允许从用户空间到内核的任何类型的转换。当CPU发现它没有设置处理这个系统调用中断时,就会产生一个通用保护异常,发现它不能处理那个,产生一个双故障异常,发现它也不能处理那个,并最终放弃所谓的“三重故障”。通常,您会看到 CPU 复位和系统重新启动。因此使用 6.828 修补的 QEMU,将看到寄存器转储和“三重故障”。
关于检查是否进入了int $0x03,我是通过在load_icode中entry这一步打断点,然后单步调试实现的,直接在函数地址打断点不知道为什么会跳过。
处理中断与异常
在执行了 int $0x30后,进程的第一条系统调用就进入死胡同,因为一旦进入用户态,CPU就没有办法再退出,现在的JOS系统并不能处理中断与异常。因此,需要实现基本的异常与系统调用处理,以便内核可以从用户态中恢复对处理器的控制。
X86中断与异常机制
中断和异常是特殊类型的控制传输;它们的工作方式有点像未编程的 CALL。它们改变正常程序流以处理外部事件或报告错误或异常情况。中断和异常的区别在于,中断是用来处理处理器外部的异步事件,而异常处理的是处理器在执行指令的过程中检测到的情况。
中断有两个来源:可屏蔽中断,通过 INTR 引脚发出信号;不可屏蔽中断,通过 NMI(不可屏蔽中断)引脚发出信号。
异常的两个来源:检测到处理器。这些进一步分类为故障、陷阱和中止;程序。指令 INTO、 INT 3 、 INT n和 BOUND可以触发异常。这些指令通常被称为“软件中断”,但处理器将它们作为异常处理。
故障:在导致异常的指令“之前”报告的异常。在指令开始执行之前或在指令执行期间检测到故障。如果在指令期间检测到,则报告故障,机器恢复到允许指令重新启动的状态。
陷阱:是在检测到异常的指令之后立即在指令边界处报告的异常。
终止:既不允许精确定位导致异常的指令,也不允许重新启动导致异常的程序。中止用于报告严重错误,例如硬件错误以及系统表中的不一致或非法值。
中断识别
处理器将识别号与每种不同类型的中断或异常相关联。
处理器仅在一条指令结束和下一条指令开始之间处理中断和异常。当重复前缀用于重复字符串指令时,重复之间可能会发生中断和异常。因此,对长字符串的操作不会延迟中断响应。某些条件和标志设置会导致处理器在指令边界禁止某些中断和异常。
中断与异常的优先级
如果指令边界处有多个中断或异常未决,则处理器一次为其中一个提供服务。处理器首先处理来自具有最高优先级的类的挂起中断或异常,将控制转移到中断处理程序的第一条指令。较低优先级的异常被丢弃;较低优先级的中断保持挂起。当中断处理程序将控制权返回到中断点时,将重新发现丢弃的异常。
中断描述符表
中断描述符表 (IDT) 将每个中断或异常标识符与服务相关事件的指令的描述符相关联。与 GDT 和 LDT 一样,IDT 是一个 8 字节描述符数组。与 GDT 和 LDT 不同,IDT 的第一个条目可能包含一个描述符。为了形成 IDT 的索引,处理器将中断或异常标识符乘以 8。因为只有 256 个标识符,所以 IDT 不需要包含超过 256 个描述符。它可以包含少于 256 个条目;只有实际使用的中断标识符才需要条目。
IDT描述符
中断任务和中断程序
如CALL指令可以调用过程或任务一样,中断或异常可以“调用”一个过程或任务的中断处理程序。在响应中断或异常时,处理器使用中断或异常标识符来索引 IDT 中的描述符。如果处理器索引到中断门或陷阱门,它会以类似于对调用门的CALL的方式调用处理程序。如果处理器找到任务门,它会以类似于对任务门的CALL的方式引起任务切换。
更详细的中断与异常描述参见:80386 Programmer's Reference Manual -- Section 9.9
受保护控制传输基础
异常和中断都是“受保护的控制传输”,它们会导致处理器从用户模式切换到内核模式 (CPL=0),而不会给用户模式代码任何机会干扰内核或其他环境的功能。在英特尔的术语中,中断是一种受保护的控制传输,它通常由处理器外部的异步事件引起,例如外部设备 I/O 活动的通知。一个例外,与此相反,是当前正在运行的代码同步地引起受保护的控制传输,例如由于通过零除法或一个无效的存储器访问。
为了确保这些受保护的控制传输实际上受到保护,处理器的中断/异常机制被设计成使得当前运行的代码在中断或异常发生时 不能随意选择进入内核的位置或方式。相反,处理器确保只有在仔细控制的条件下才能进入内核。
1. IDT: 处理器确保中断和异常只能在内核本身确定的几个特定的、定义良好的入口点处进入 内核,而不是在发生中断或异常时运行的代码。从IDT中加载中断指令EIP的值,指向用于处理该类型异常的内核代码;加载代码段CS寄存器的值,在0-1位上确定异常处理的特权级别。(JOS中所有中断异常都在内核CPL=0的特权级处理)
2. 任务状态段:处理器需要一个地方来保存中断或异常发生之前的旧处理器状态,例如 处理器调用异常处理程序之前的EIP和CS的原始值,以便异常处理程序稍后可以恢复旧状态并恢复中断代码从它停止的地方开始。尽管 TSS 很大并且可能有多种用途,但 JOS 仅使用它来定义处理器从用户模式转换到内核模式时应切换到的内核堆栈。
异常和中断的类型
x86 处理器可以在内部生成的所有同步异常都使用 0 到 31 之间的中断向量,因此映射到 IDT 条目 0-31。例如,页面错误总是通过向量 14 引起异常。大于 31 的中断向量仅用于 软件中断,它可以由
int
指令产生,或者异步硬件中断,由外部设备在需要注意时引起。在本节中,我们将扩展 JOS 以处理向量 0-31 中内部生成的 x86 异常。在下一节中,我们将使 JOS 处理软件中断向量 48 (0x30),JOS(相当随意)将其用作其系统调用中断向量。在实验 4 中,我们将扩展 JOS 以处理外部生成的硬件中断,例如时钟中断。
嵌套异常与中断
处理器可以从内核和用户模式接受异常和中断。然而,只有当从用户模式进入内核时,x86 处理器才会在将其旧寄存器状态推送到堆栈并通过 IDT 调用适当的异常处理程序之前自动切换堆栈。如果在中断或异常发生时处理器已经处于内核模式(CS寄存器的低 2 位已经为零),那么 CPU 只会在同一内核堆栈上压入更多值。通过这种方式,内核可以优雅地处理由 内核本身内的代码引起的嵌套异常。此功能是实施保护的重要工具,我们将在稍后有关系统调用的部分中看到。
如果处理器已经处于内核模式并出现嵌套异常,由于不需要切换堆栈,它不会保存旧的SS或ESP寄存器。对于不推送错误代码的异常类型,内核堆栈因此在异常处理程序入口处如下所示: 对于推送错误代码的异常类型,处理器会像以前一样在旧的EIP之后立即推送错误代码。
处理器的嵌套异常功能有一个重要的警告。如果处理器在已经处于内核模式时发生异常,并且由于任何原因(例如堆栈空间不足)而无法将其旧状态推送到内核堆栈上,那么处理器无法进行任何恢复,因此它只会重置自己。
设置 IDT
设置 IDT 来处理中断向量 0-31(处理器异常)。
总体控制流程:每个异常或中断都应该在trapentry.S 中有自己的处理程序, 并且
trap_init()
应该用这些处理程序的地址初始化 IDT。每个处理程序都应该在堆栈上构建一个struct Trapframe
(参见inc/trap.h)并使用指向 Trapframe 的指针调用trap()
(在trap.c 中)。trap()
然后处理异常/中断或分派到特定的处理程序函数。
trapentry.S
文件中包含两个宏,TRAPHANDLER,RAPHANDLER_NOEC。
TRAPHANDLER:TRAPHANDLER 定义了一个全局可见的处理陷阱的函数。它将陷阱编号压入堆栈,然后跳转到_alltraps。 将 TRAPHANDLER 用于 CPU 自动推送错误代码的陷阱。
RAPHANDLER_NOEC:将 TRAPHANDLER_NOEC 用于 CPU 不推送错误代码的陷阱。它推送一个 0 来代替错误代码,因此陷阱帧在任何一种情况下都具有相同的格式。
exercise4:
先为每个中断与异常定义自己的处理程序,借用上面提到的两个traphander的宏。
不同中断是否包含error code需要查阅相关资料。
TRAPHANDLER_NOEC(trap_divide,T_DIVIDE); TRAPHANDLER_NOEC(trap_debug,T_DEBUG); TRAPHANDLER_NOEC(trap_nmi,T_NMI); TRAPHANDLER_NOEC(trap_brkpt,T_BRKPT); TRAPHANDLER_NOEC(trap_oflow,T_OFLOW); TRAPHANDLER_NOEC(trap_bound,T_BOUND); TRAPHANDLER_NOEC(trap_illop,T_ILLOP); TRAPHANDLER_NOEC(trap_device,T_DEVICE); TRAPHANDLER(trap_dblflt,T_DBLFLT); TRAPHANDLER(trap_tss,T_TSS); TRAPHANDLER(trap_segnp,T_SEGNP); TRAPHANDLER(trap_stack,T_STACK); TRAPHANDLER(trap_gpflt,T_GPFLT); TRAPHANDLER(trap_pgflt,T_PGFLT); TRAPHANDLER_NOEC(trap_fperr,T_FPERR); TRAPHANDLER_NOEC(trap_align,T_ALIGN); TRAPHANDLER_NOEC(trap_mchk,T_MCHK); TRAPHANDLER_NOEC(trap_simderr,T_SIMDERR);
_alltraps: 结构体trapframe:在tf_err之下都是硬件完成的加载,在TRAPHANDLER中压入了error code与trapno因此alltraps需要压入ds,es寄存器,然后加载GD_KD,push %esp传递tf指针给trap函数,然后调用trap函数。
struct Trapframe { struct PushRegs tf_regs; uint16_t tf_es; uint16_t tf_padding1; uint16_t tf_ds; uint16_t tf_padding2; uint32_t tf_trapno; /* below here defined by x86 hardware */ uint32_t tf_err; uintptr_t tf_eip; uint16_t tf_cs; uint16_t tf_padding3; uint32_t tf_eflags; /* below here only when crossing rings, such as from user to kernel */ uintptr_t tf_esp; uint16_t tf_ss; uint16_t tf_padding4; } __attribute__((packed));
_alltraps: pushl %ds pushl %es pushal movw $GD_KD,%ax movw %ax,%ds movw %ax,%es pushl %esp call trap
能够调用,因为相当于将将tf地址传递给了trap函数。
trap_init():初始化idt指向中断处理的entry,使用SETGATE宏。这里没有什么特别的,is_trap设置为中断门或者陷阱门都无所谓,区别是中断门会重置IF(interrupt_enable_flag)。
以及需要注意特权级别的设置,否则会产生与预期不符的异常。
SETGATE(idt[T_BRKPT],0,GD_KT,trap_brkpt,3);//cpl<=dpl,int $3,用户态,dpl=3
Question1:为每个中断/异常设置单独处理的函数是十分有必要的,因为有的中断/异常是需要直接中止执行,有的中断/异常是处理中断后恢复到原来的状态执行。
Question2:因为INT为系统级别指令,需要特权级别为0,而此时程序位于用户态,特权级别为3,因此不能执行,从而引发一般保护异常。
以上就是LAB3partA的所有部分,实现了运行进程以及基本的异常处理。