MIT6.828 Lab3

x86架构与JOS

目录

x86架构与JOS

 Part A:用户环境和异常处理

用户环境创建

分配环境数组

创建和运行环境

小结

处理中断和异常

基础知识

实现

 Part A:页面错误,断点异常和系统调用


 Part A:用户环境和异常处理

        实现操作系统的一些基本功能,来实现用户环境下的进程的正常运行。你将会加强JOS内核的功能,增添用来记录用户进程环境的数据结构创建一个单一的用户环境,并且加载一个程序运行它。你也可以让JOS内核能够完成用户环境所作出的任何系统调用,以及处理用户环境产生的各种异常。

用户环境创建

        在kern/env.c中,内核用ENV数据结构维护了三个关于环境的全局变量:一旦JOS系统开始运行,*envs指向一个保存当前系统中所有环境变量的Env结构体数组。JOS内核将所有未使用的Env结构体放*在env_free_list链表中,以便用户环境的分配和回收。*curenv指向JOS内核中任意时刻正在执行的环境。当内核启动、还没有任何用户环境运行时,它被初始化为NULL。

        JOS和Unix中的进程一样结合了线程和地址空间的概念,线程通常是由被保存的寄存器的值(env_tf)来定义的,而地址空间主要通过保存页表目录和页表(eng_pgdir)来定义。所以为了运行一个用户环境,内核必须设置合适的寄存器的值以及合适的地址空间。
        在JOS系统中,环境并没有在内核中拥有各自独立的栈。因为任意时刻只能有一个环境处于活跃状态,因此JOS内核只需要一个内核栈。

分配环境数组

       之前在mem_init()函数中分配pages数组的地址空间,用于记录内核中所有页的信息。现在在该函数中补充对envs数组的内存分配。然后需要建立envs的映射关系:虚拟地址为UENVS,权限为用户可读。

创建和运行环境

        编写 kern/env.c 文件来运行一个用户环境。由于现在还没有文件系统,因此JOS将一些用户程序的静态二进制文件作为ELF文件嵌入在内核中,以便被载入和执行。在读取和运行这些二进制文件前,要完成能够设置这些代码的运行用户环境的功能,主要完成以下函数:
        env_init()(初始化Env结构体,调用env_init_percpu这个函数配置段式内存管理系统,让他管理的段,有两种访问优先级分别是内核运行的0优先级,用户运行时的3优先级)
        env_setup_vm()(为新用户分配页目录表,初始化这个用户环境的地址空间和内核相关的部分)
        region_alloc()(为用户环境分配物理地址空间)
        load_icode()(分析一个ELF文件,类似boot loader一样把内容加载到用户环境)
        env_create()(利用env_alloc和load_icode加载一个ELF文件到用户环境中)
        env_run()(在用户模式下开始运行一个用户环境)。

        env_init()先遍历envs数组中的Env结构体,将env_id(这是为了唯一确定使用这个结构体的用户环境是什么)置0,并将所有的Env数组放入空闲链表中。然后调用env_init_percpu()配置段式内存管理系统,它所做的事包括:重新载入GDT表;初始化数据段寄存器GS、FS(留给用户数据段使用)、ES、DS、SS(在用户态和内核态切换使用);初始化内核的代码段寄存器CS;初始化LDT表为0。
        env_setup_vm()主要是初始化新的用户环境的页目录表,但是只设置页目录表中和操作系统内核相关的页目录项,这里只初始化新的用户环境的内核部分,所以直接把内核部分的页表复制即可,可以参照 kern_pgdir 中的内容来设置 env_pgdir 中的内容。并且在UTOP地址以下的地址不用设置,只用设置UTOP地址以上的,因为物理页只映射UTOP以上的(对应的物理地址用户究竟能不能访问靠页表项中通过权限位控制)。
        region_alloc()主要是为了给用户环境分配物理空间,首先应该先把起始地址和终止地址对齐这样分配的页表才没有空间浪费,然后以4K页为单位从起始到终止分配内存。
        load_icode()解析ELF二进制文件,加载其内容到新环境的用户地址空间中,主要为了给每一个用户进程设置它的初始代码区,堆栈以及处理器标识位。(每个用户程序都是ELF文件)这个函数仅在内核初始化期间、第一个用户态环境运行前被调用。
        env_create()利用env_alloc函数和load_icode创建一个内存页表然后把ELF文件加载进来。
        env_run()开始运行一个用户环境:(1)如果是环境切换(即有环境正在运行):如果当前环境curenv的env_status为ENV_RUNNING,设置它为ENV_RUNNABLE;设置curenv为新的环境;设置新的环境的env_status为ENV_RUNNING;更新env_runs计数;利用lcr3()切换运行地址空间(2)利用env_pop_tf()设置新环境的重要寄存器,然后进入用户环境执行代码。函数调用iret时:从esp指向的栈中顺序出栈eip, cs, eflags(标志寄存器), esp, ss赋值到相应寄存器,然后程序跳转到cs:eip处继续执行,因此这个函数正常状态下不会返回。

小结

一.至此为止,进入运行一个新环境的函数调用如下:

  • start (kern/entry.S)
  • i386_init(kern/init.c)
    • cons_init
    • mem_init
    • env_init
    • trap_init (还未实现)
    • env_create
    • env_run
      • env_pop_tf

二.启动内核,创建并进入一个环境的过程大约如下:

  1. 启动内核,开启分页,设置栈区(kern/entry.S,之前lab1做的)
  2. 初始化.bss段,初始化一系列硬件(cons_init,之前lab1做的)
  3. 虚拟内存初始化(mem_init,之前lab2做的,lab3刚刚对其进行补充:增加初始化envs数组)
    1. 用CMOS检测可用的物理内存
    2. 为kernel的页表目录分配内存,将页表目录kern_pgdir作为页表插入到页表项UVPT处(以便内核以外的环境在UVPT处能够查找到自己的页表目录,在lab4中会详细讲到)
    3. 初始化pages数组,将物理内存以页为单位记录到pages数组中并利用page_free_list管理空闲页面,编写页表分配、释放、映射的相关函数
    4. 完成物理内存前256M的映射,与此同时填写了页表目录和二级页表,并赋予页面相应权限
    5. 设置cr3为kern_pgdir的物理地址,并设置cr0的标志
  4. 为所有可能的环境进行初始化(env_init)
    1. 初始化envs数组中的每一项状态为ENV_FREE,并放入未使用的环境env_free_list链表中
    2. 加载GDT表和初始化段描述子
  5. 中断设置和异常处理(trap_init,还没做)
  6. 创建一个新环境(env_create)
    1. 调用env_alloc()初始化一个环境:这个函数不是由我们动手编写的,但调用了我们编写的一些函数
      1. 从env_free_list拿出一个未被使用的Env结构
      2. 利用env_setup_vm()初始化新环境的虚拟地址空间:
        • 为新环境分配一页内存作为页表目录
        • 将其地址填写到新环境的env_pgdir域中
        • 拷贝内核页表目录中UTOP以上部分(内核地址空间映射情况)到新环境的页表目录
        • 将新的页表目录作为页表插入到新环境目录页表项的UVPT处
      3. 为新环境生成一个唯一标识env_id
      4. 初始化新环境的其他域:env_parent_id,env_type,env_status,env_runs
      5. 初始化新环境的段寄存器env_tf.tf_ds/es/ss/cs关联当前GDT表,初始化栈指针env_tf.tf_esp指向USTACKTOP
      6. 修改env_free_list指向下一个未被使用的Env结构
    2. 调用load_icode()为新环境加载可执行二进制文件,这个二进制是进入新环境后执行的程序:
      1. 以elf格式读取二进制的程序执行入口e_entry,并用这个入口设置新环境的env_tf.tf_eip,让新环境能够从二进制的入口开始执行程序
      2. 利用region_alloc()为类型为ELF_PROG_LOAD的节分配内存并映射到p_va指示的位置
      3. 利用memmove()将这些节长度为p_filesz的文件内容移动到p_va指示的位置
      4. 由于p_filesz<=p_memsz,利用memset()填充两者之间的空缺
      5. 注意:此处操作的p_va是新环境的地址空间,有别于kernel的地址空间,因此需要在操作之前先临时把cr3设置为新环境的页表目录地址e->env_pgdir,操作结束后再恢复到内核的页表目录地址
    3. 为新环境分配一页作为其栈区,映射到虚拟地址USTACKTOP-PGSIZE处
      1. 这里我寻思着USTACKTOP-PGSIZE是在UTOP底下,也属于用户内存区,也得在用户地址空间下执行?
  7. 进入一个新环境,执行其程序(env_run)
    1. 若当前有环境在运行,设置该环境的env_status为ENV_RUNNABLE
    2. 设置当前环境指针curenv指向新环境
    3. 修改新环境的运行状态为ENV_RUNNING,运行次数env_runs++
    4. 修改cr3为新环境的页表目录地址
    5. 调用env_pop_tf()进入新环境

        代码完成到这里,编译并启动qemu运行后,系统就应该能够顺利进入用户环境并执行hello这个二进制文件。由于目前还没实现对中断和异常的处理,因此它将在hello进行系统调用int $0x30时出错(这行能在hello.asm中找到)。JOS此时尚未设置硬件允许从用户空间转换到内核态,当CPU发现这个它没办法处理这个中断时,它引发一个异常;当它又发现它没办法处理这个异常时,它又引发了一个异常;但它发现它还是没办法处理这个异常,只好放弃,因此最终引发了“三重异常”,在qemu中得到Triple fault的输出。

处理中断和异常

基础知识

        异常(exceptions)和中断(interrupts)都是受保护的控制转移(protected control transfers),它们将处理器模式从用户态切换到内核态,不给用户模式干扰到其他环境或内核功能的机会。在Intel的术语里,中断一般是指由处理器外部的异步事件引发的受保护的处理器控制权转移,例如外部I/O设备发出的活动信号;异常则是由当前执行的代码同步地引起的控制权转移,例如除零异常或非法存储器访问。
        为了确保这些控制转移被切实地受到了保护,处理器的中断/异常机制被设计为:当中断或异常发生时,当前执行的代码无法选择进入内核的位置或方式。处理器确保只能在严格受控的情况下才能进入内核态。在x86下,两种机制配合工作以提供这种保护:
        1.中断描述符表IDT(中断向量表):处理器保证中断和异常只能引起代码进入到内核的一些特定的、已被明确定义的入口点。这些入口点由内核决定,而非中断或异常引发时正在执行的代码决定。
        x86允许内核有256种不同的中断或异常入口,每个入口的值由整数0~255表示,称为中断向量。一个中断向量的值由引发中断的源决定,不同的设备、错误条件以及应用对内核的请求将引发不同的中断。CPU利用中断向量作为中断描述符表IDT的索引查找中断处理程序的入口地址,IDT表被设置在内核空间。从这个表中的相应条目中,处理器可以读取到:(事实上就是个虚拟地址,修改cs:eip进行跳转)(1)需要加载到寄存器eip中的值:它指出内核中处理该中断的代码的入口地址(2)需要加载到寄存器cs中的值:它指出运行中断处理程序的运行特权级(即这个程序是在用户态还是内核态下运行)
        2.任务状态段TSS:当中断或异常发生,切换到内核态运行中断处理程序之前,处理器需要一个地方保存当前处理器的状态,例如寄存器EIP和CS的值以便在中断处理程序结束后能恢复到中断发生的地方,继续执行原来的代码。这个区域也需要受到保护,避免被用户态的程序访问以破坏内核。
        因此当处理器处理一个中断、从用户态切换到内核态时,也会把它的堆栈切换到内核空间中。数据结构任务状态段(TSS)详细记录这个堆栈所在的段的段描述符和地址。处理器向内核栈中顺序压入SS, ESP, EFLAGS, CS, EIP和error code(可选),然后加载中断处理程序的CS,EIP值,并且设置ESP,SS寄存器指向新的堆栈。
        尽管TSS非常大并且还有很多其他的功能,但JOS仅把它用作定义从用户态切换到内核态时内核堆栈的位置。在JOS的定义中,内核态指特权级为0,因此在TSS数据结构中使用EPS0和SS0来指明这个内核堆栈的位置,大小。

        所有由x86处理器内部同步地产生的异常的中断号都在0~31之间,例如页面错误引起的异常对应的中断向量是14。大于31的中断号都用作软件中断(software interrupts)或硬件中断(hardware interrupts),软件中断由int指令生成,硬件中断由外部设备在需要时异步地生成。在本节中我们扩展JOS以处理x86处理器内部生成的0~31号中断向量,在下一节中我们令JOS能够处理48号中断(用作系统调用)。在lab4中继续扩展JOS使它能够处理外部硬件中断,例如时钟中断。处理器在用户态和内核态都可以引发异常,但若引发异常时处理器已经在内核态,就不需要切换运行状态和堆栈位置,也不需要保存SS, ESP的值只需要将EFLAGS, CS, EIP压栈。通过这种方式,内核可以处理嵌套中断。如果处理器在内核态下接受一个异常,而且由于一些原因,比如堆栈空间不足,不能把当前的状态信息(寄存器的值)压入到内核堆栈中时,那么处理器是无法恢复到原来的状态了,它会自动重启。

实现

        整个操作系统中断控制的流程,主要为以下几步:1、运行trap_init()函数先将所有的中断处理函数的起始地址放到中断向量表IDT中。2、当中断发生时,不管是外部中断还是内部中断,处理器捕捉到该中断然后进入核心态,再根据中断向量去查询中断向量表,找到对应的表项。3、保存被中断的程序的上下文到内核堆栈中,调用这个表项中指明的中断处理函数。4、执行中断处理函数。5、执行完毕之后,恢复被中断得进程的上下文,返回用户态,继续运行这个进程。

        trapentry.S包含每个中断或异常对应的处理程序。其中的两个宏定义TRAPHANDLER和TRAPHANDLER _NOEC为每一种中断设置中断处理程序的入口。
        例如调用TRAPHANDLER_NOEC(t_divide, T_DIVIDE)相当于为中断T_DIVIDE定义了一个全局可见的函数t_divide。
  在中断触发前:(1)从tss找到内核栈的地址,临时保存旧栈的ss, esp,修改当前ss, esp指向内核栈(2)向内核栈压入旧ss, 旧esp, eflag, cs, eip,若中断有错误码,自动压入错误码(3)cs, eip指向中断处理程序入口,准备执行中断处理程序

        从标号t_divide开始执行将发生(宏定义可以展开为程序,执行宏定义的过程中会):(1)入栈中断错误码和中断号num(对于不需要错误码的中断TRAPHANDLER_NOEC入栈0,对于需要错误码的中断,错误码已经由CPU自动入栈,因此TRAPHANDLER只pushl一个num)(2)调用_alltraps:①将各寄存器的值按Trapframe的格式压栈(这里,在后续调用trap()后,会从栈中顺序出栈数据赋值给参数的tf结构体②利用GD_KD设置ds, es,指向内核数据段③压栈esp作为参数
调用trap()处理中断,根据中断处理结果销毁原环境或继续执行
        之后在trap()中再根据中断号(由参数tf中的trapno读出)对不同中断分别处理。
  然后再从trap_init()函数中利用SETGATE宏修改trap_init()为trapentry.S中的入口点初始化中断向量表IDT。

 Part B:页面错误,断点异常和系统调用

        已实现内核基本的异常处理,此部分主要在此基础上利用异常处理进行系统调用。  
        在这一部分首先先完成一个中断分发函数--trap.c中trap_dispatch()。这个函数主要根据中断的类型:缺页中断去执行缺页异常函数
page_fault_handler()断点异常去触发JOS内核监视器monitor()(注:断点异常为3,加断点的基本原理是把要加断点的语句用INT3指令替换,执行到INT3时会触发软中断,在JOS中,我们通常将这个异常转换成一个伪系统调用,这样任何用户环境都可以使用这个伪系统调用来触发JOS内核监视器)。

  之后完成的是系统调用部分,用户程序会要求内核帮助它完成系统调用。当用户程序触发系统调用,系统进入内核态。处理器和操作系统将保存该用户程序当前的上下文状态,然后由内核将执行正确的代码完成系统调用,然后回到用户程序继续执行。用户如何向内核发起系统调用以及某个特定系统调用的具体用途,在不同的操作系统中有很多不同的实现方式。
        在JOS系统中,使用int指令触发一个处理器的中断。特别的,我们使用int $0x30作为系统调用。定义其中断向量为48(0x30),然后需要在中断向量表中初始化它的中断号和入口函数(这个中断不会被硬件触发)。用户程序会通过寄存器向内核传递系统调用号和参数,这样内核就不需要从用户的堆栈或指令流中获取参数了。系统调用号放在%eax,参数(最多五个)分别放在 %edx,  %ecx, %ebx, %edi, %esi中,内核的返回值放在%eax中。
  但是一般触发系统调用的时候会有两种情况,分别是系统已经运行在内核态和系统运行在用户态。如果是运行在内核态此时调用了一个系统调用,不会触发中断而是系统直接定位并运行相应的系统汇编指令集,完成本次系统调用。而如果是在用户态程序中就是exercise 7要完成的内容,从汇编代码中我们可以看出当用户程序要调用系统调用时会先执行一个int $0x30指令,这个指令就是软件中断指令,中断号就是0x30即T_SYSCALL,所以我们先对这个中断号编写一个中断处理函数完成在kern/trapentry.S中,然后在trap.c中声明这个t_syscall()函数,并在trap_init()函数中注册(使用SETGATE宏定义),这样当中断发生时就可以捕获这个中断了,捕获之后进入trap_dispatch()函数,这个函数从不同的寄存器中取出值来执行不同的函数。

        最后需要我们去实现一下因为系统调用给内存保护带来的问题-- 内存保护机制,大部分系统调用接口让用户程序传递一个指针参数给内核。这些指针指向的是用户缓冲区。通过这种方式,系统调用在执行时就可以解引用这些指针。但是这里有两个问题:
   1. 在内核中的page fault要比在用户程序中的page fault更严重。如果内核在操作自己的数据结构时出现 page faults,这是一个内核的bug,而且异常处理程序会中断整个内核。但是当内核在解引用由用户程序传递来的指针时,它需要一种方法去记录此时出现的任何page faults都是由用户程序带来的。
   2. 内核通常比用户程序有着更高的内存访问权限。用户程序很有可能要传递一个指针给系统调用,这个指针指向的内存区域是内核可以进行读写的,但是用户程序不能。此时内核必须小心不要去解析这个指针,否则的话内核的重要信息很有可能被泄露。
   为了实现这个保护机制需要区分当前运行的程序是处于什么状态下,主要通过CS段寄存器的低2位,这两位也叫做CPL位,表示当前运行的代码的访问权限级别,0代表内核态,
3代表是用户态。然后更改page_fault_handler如果内核中发生了缺页错误要打印出用户中的信息,并且终止用户进程。之后还要完成另一个内存保护,检查用户态程序是否对传入内核的地址有访问权限,就是通过查看每一个页表的读写状态,如果没有就返回-E_FAULT。

   Lab3主要进行了如下操作分别是创建用户环境、初始化用户环境的虚拟内存机制、实现用户代码的加载,然后实现了x86中的中断与异常机制,对IDT表进行初始化(有中断发生从IDT表去查询对应的trap然后通过dispatch去往不同的中断处理函数),通过IDT的dispatch来实现页错误,断点异常和系统调用几种功能,最后完善了内存的保护机制。
 

资料来源:

(1条消息) MIT-JOS系列6:用户环境(二)_extern void t_debug();_sssaltyfish的博客-CSDN博客

(1条消息) MIT-JOS系列7:用户环境(三)_sys_getenvid_sssaltyfish的博客-CSDN博客

(1条消息) MIT6.828 Lab3_饮水小思源的博客-CSDN博客

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值