当前位置: 代码迷 >> 综合 >> MIT 6.S081 lec8、9总结 —— Page faults Interrupts
  详细解决方案

MIT 6.S081 lec8、9总结 —— Page faults Interrupts

热度:10   发布时间:2023-11-26 10:03:20.0

MIT 6.S081 课程摘要/总结
不含 xv6 的具体代码 分析

Lec08 Page faults

  • 虚拟内存的有两个优点

    • 隔离性:虚拟内存使得操作系统可以为每个应用程序提供属于它们自己的地址空间。
    • 提供了一层抽象。处理器和所有的指令都可以使用虚拟地址,而内核会定义从虚拟地址到物理地址的映射关系,实现一些有趣的功能。
  • page fault

    • page fault可以让这里的地址映射关系变得动态起来。通过page fault,内核可以更新page table,这是一个非常强大的功能。因为现在可以动态的更新虚拟地址这一层抽象,结合page table和page fault,内核将会有巨大的灵活性。
    • 内核需要什么信息响应page fault
      • 引起page fault的内存地址
      • 引起page fault的原因类型
      • 引起page fault时的程序计数器值(修复page table后,重新执行指令)
  • Lazy page allocation (sbrk的实现)

    • sbrk是XV6提供的系统调用,它使得用户应用程序能扩大自己的heap。当一个应用程序启动的时候,sbrk指向的是heap的最底端,同时也是stack的最顶端。
    • 你可以设想自己写了一个应用程序,读取了一些输入然后通过一个矩阵进行一些运算。你需要为最坏的情况做准备,比如说为最大可能的矩阵分配内存,但是应用程序可能永远也用不上这些内存,通常情况下,应用程序会在一个小得多的矩阵上进行运算。所以,程序员过多的申请内存但是过少的使用内存,这种情况还挺常见的。
    • lazy allocation的核心思想非常简单,sbrk系统调基本上不做任何事情,唯一需要做的事情就是提升p->sz,将p->sz增加n,其中n是需要新分配的内存page数量。但是内核在这个时间点并不会分配任何物理内存。之后在某个时间点,应用程序使用到了新申请的那部分内存,这时会触发page fault,因为我们还没有将新的内存映射到page table。所以,如果我们解析一个大于旧的p->sz,但是又小于新的p->sz(注,也就是旧的p->sz + n)的虚拟地址,我们希望内核能够分配一个内存page,并且重新执行指令。
  • Zero Fill On Demand

    • 当你查看一个用户程序的地址空间时,存在text区域,data区域,同时还有一个BSS区域(注,BSS区域包含了未被初始化或者初始化为0的全局或者静态变量)。
    • 通常可以调优的地方是,我有如此多的内容全是0的page,在物理内存中,我只需要分配一个page,这个page的内容全是0。然后将所有虚拟地址空间的全0的page都map到这一个物理page上。(类似 COW)
    • 我们不能允许对于这个page执行写操作,因为所有的虚拟地址空间page都期望page的内容是全0,所以这里的PTE都是只读的。之后在某个时间点,应用程序尝试写BSS中的一个page时,比如说需要更改一两个变量的值,我们会得到page fault。
    • 在物理内存中申请一个新的内存page,将其内容设置为0,因为我们预期这个内存的内容为0。之后我们需要更新这个page的mapping关系,首先PTE要设置成可读可写,然后将其指向新的物理page。这里相当于更新了PTE,之后我们可以重新执行指令。
  • Copy On Write Fork

    • 有一个非常有效的优化:当我们创建子进程时,与其创建,分配并拷贝内容到新的物理内存,其实我们可以直接共享父进程的物理内存page。所以这里,我们可以设置子进程的PTE指向父进程对应的物理内存page。
    • 一旦子进程想要修改这些内存的内容,相应的更新应该对父进程不可见,因为我们希望在父进程和子进程之间有强隔离性。而为了确保进程间的隔离性,我们可以将这里的父进程和子进程的PTE的标志位都设置成只读的。
    • 在某个时间点,当我们需要更改内存的内容时,我们会得到page fault。
    • 在得到page fault之后,我们需要拷贝相应的物理page。假设现在是子进程在执行store指令,那么我们会分配一个新的物理内存page,然后将page fault相关的物理内存page拷贝到新分配的物理内存page中,并将新分配的物理内存page映射到子进程。这时,新分配的物理内存page只对子进程的地址空间可见,所以我们可以将相应的PTE设置成可读写,并且我们可以重新执行store指令。实际上,对于触发刚刚page fault的物理page,因为现在只对父进程可见,相应的PTE对于父进程也变成可读写的了。
    • 但是对于这里的物理内存page,现在有多个用户进程或者说多个地址空间都指向了相同的物理内存page,举个例子,当父进程退出时我们需要更加的小心,因为我们要判断是否能立即释放相应的物理page。如果有子进程还在使用这些物理page,而内核又释放了这些物理page,我们将会出问题。那么现在释放内存page的依据是什么呢?
    • 我们需要对于每一个物理内存page的引用进行计数,当我们释放虚拟page时,我们将物理内存page的引用数减1,如果引用数等于0,那么我们就能释放物理内存page。所以在copy-on-write lab中,你们需要引入一些额外的数据结构或者元数据信息来完成引用计数。
  • Demand Paging

    • 在虚拟地址空间中,我们为text和data分配好地址段,但是相应的PTE并不对应任何物理内存page。对于这些PTE,我们只需要将valid bit位设置为0即可。
    • 那么该如何处理这里的page fault呢?首先我们可以发现,这些page是on-demand page。我们需要在某个地方记录了这些page对应的程序文件,我们在page fault handler中需要从程序文件中读取page数据,加载到内存中;之后将内存page映射到page table;最后再重新执行指令。
    • 如果内存耗尽了,一个选择是撤回page(evict page)。比如说将部分内存page中的内容写回到文件系统再撤回page。一旦你撤回并释放了page,那么你就有了一个新的空闲的page,你可以使用这个刚刚空闲出来的page,分配给刚刚的page fault handler,再重新执行指令。
  • Memory Mapped Files

    • 这里的核心思想是,将完整或者部分文件加载到内存中,这样就可以通过内存地址相关的load或者store指令来操纵文件。为了支持这个功能,一个现代的操作系统会提供一个叫做mmap的系统调用。这个系统调用会接收一个虚拟内存地址(VA),长度(len),protection,一些标志位,一个打开文件的文件描述符,和偏移量(offset)。
    • 你不会立即将文件内容拷贝到内存中,而是先记录一下这个PTE属于这个文件描述符。相应的信息通常在VMA结构体中保存,VMA全称是Virtual Memory Area。例如对于这里的文件f,会有一个VMA,在VMA中我们会记录文件描述符,偏移量等等,这些信息用来表示对应的内存虚拟地址的实际内容在哪,这样当我们得到一个位于VMA地址范围的page fault时,内核可以从磁盘中读数据,并加载到内存中。所以这里回答之前一个问题,dirty bit是很重要的,因为在unmap中,你需要向文件回写dirty block。

Lec09 Interrupts

  • 中断

    • 中断对应的场景很简单,就是硬件想要得到操作系统的关注。例如网卡收到了一个packet,网卡会生成一个中断;用户通过键盘按下了一个按键,键盘会产生一个中断。操作系统需要做的是,保存当前的工作,处理中断,处理完成之后再恢复之前的工作。
    • 中断与系统调用主要有3个小的差别:
      • asynchronous (异步)。当硬件生成中断时,Interrupt handler与当前运行的进程在CPU上没有任何关联。
      • concurrency。CPU和设备之间是真正的并行的,我们必须管理这里的并行。
      • program device。外部设备,例如网卡,UART,而这些设备需要被编程。每个设备都有一个编程手册。设备的编程手册包含了它有什么样的寄存器,它能执行什么样的操作,在读写控制寄存器的时候,设备会如何响应。
  • 外设中断处理流程

    • 外设中断来自于主板上的设备。所有的设备都连接到处理器上,处理器上是通过Platform Level Interrupt Control,简称PLIC来处理设备中断。
      • PLIC会通知当前有一个待处理的中断

      • 其中一个CPU核会Claim接收中断,这样PLIC就不会把中断发给其他的CPU处理

      • CPU核处理完中断之后,CPU会通知PLIC

      • PLIC将不再保存中断的信息

  • 设备驱动概述

    • 大部分驱动都分为两个部分,bottom/top。
      • bottom部分通常是Interrupt handler。当一个中断送到了CPU,并且CPU设置接收这个中断,CPU会调用相应的Interrupt handler。Interrupt handler并不运行在任何特定进程的context中,它只是处理中断。
      • top部分,是用户进程,或者内核的其他部分调用的接口。对于UART来说,这里有read/write接口,这些接口可以被更高层级的代码调用。
      • 驱动中会有一些队列(或者说buffer),top部分的代码会从队列中读写数据,而Interrupt handler(bottom部分)同时也会向队列中读写数据。这里的队列可以将并行运行的设备和CPU解耦开来。
  • 如何对设备进行编程

    • 设备地址出现在物理地址的特定区间内,这个区间由主板制造商决定。操作系统需要知道这些设备位于物理地址空间的具体位置,然后再通过普通的load/store指令对这些地址进行编程。load/store指令实际上的工作就是读写设备的控制寄存器。例如,对网卡执行store指令时,CPU会修改网卡的某个控制寄存器,进而导致网卡发送一个packet。所以这里的load/store指令不会读写内存,而是会操作设备。
  • 在XV6中设置中断

    • 当XV6启动时,Shell会输出提示符“$ ”,如果我们在键盘上输入ls,最终可以看到“$ ls”。我们接下来通过研究Console是如何显示出“$ ls”,来看一下设备中断是如何工作的。实际上“$ ”和“ls”还不太一样,“$ ”是Shell程序的输出,而“ls”是用户通过键盘输入之后再显示出来的。

      对于“$ ”来说,实际上就是设备会将字符传输给UART的寄存器,UART之后会在发送完字符之后产生一个中断。

    • 另一方面,对于“ls”,这是用户输入的字符。键盘连接到了UART的输入线路,当你在键盘上按下一个按键,UART芯片会将按键字符通过串口线发送到另一端的UART芯片。另一端的UART芯片先将数据bit合并成一个Byte,之后再产生一个中断,并告诉处理器说这里有一个来自于键盘的字符。之后Interrupt handler会处理来自于UART的字符。

  • Interrupt相关的并发

    • 设备与CPU是并行运行的。例如当UART向Console发送字符的时候,CPU会返回执行Shell,而Shell可能会再执行一次系统调用,向buffer中写入另一个字符,这些都是在并行的执行。这里的并行称为producer-consumer并行。
    • 中断会停止当前运行的程序。例如,Shell正在运行第212个指令,突然来了个中断,Shell的执行会立即停止。对于用户空间代码,这并不是一个大的问题,因为当我们从中断中返回时,我们会恢复用户空间代码,并继续执行执行停止的指令。我们已经在trap和page fault中看过了这部分内容。但是当内核被中断打断时,事情就不一样了。所以,代码运行在kernel mode也会被中断,这意味着即使是内核代码,也不是直接串行运行的。在两个内核指令之间,取决于中断是否打开,可能会被中断打断执行。对于一些代码来说,如果不能在执行期间被中断,这时内核需要临时关闭中断,来确保这段代码的原子性。
    • **驱动的top和bottom部分是并行运行的。**例如,Shell会在传输完提示符“$”之后再调用write系统调用传输空格字符,代码会走到UART驱动的top部分(注,uartputc函数),将空格写入到buffer中。但是同时在另一个CPU核,可能会收到来自于UART的中断,进而执行UART驱动的bottom部分,查看相同的buffer。所以一个驱动的top和bottom部分可以并行的在不同的CPU上运行。这里我们通过lock来管理并行。因为这里有共享的数据,我们想要buffer在一个时间只被一个CPU核所操作。
  • Interrupt的演进

    • 现在,中断相对处理器来说变慢了。 (CPU的中断处理速度 相对 设备中断产生速度 相对来说变慢了,跟不上) 从前面的介绍可以看出来这一点,需要很多步骤才能真正的处理中断数据。如果一个设备在高速的产生中断,处理器将会很难跟上。所以如果查看现在的设备,可以发现,现在的设备相比之前做了更多的工作。所以在产生中断之前,设备上会执行大量的操作,这样可以减轻CPU的处理负担。所以现在硬件变得更加复杂。
    • 如果你有一个高性能的设备,例如你有一个千兆网卡,这个网卡收到了大量的小包,网卡每秒可以生成1.5Mbps,这意味着每一个微秒,CPU都需要处理一个中断,这就超过了CPU的处理能力。那么当网卡收到大量包,并且处理器不能处理这么多中断的时候该怎么办呢?
    • 这里的解决方法就是使用polling。除了依赖Interrupt,CPU可以一直读取外设的控制寄存器,来检查是否有数据。对于UART来说,我们可以一直读取RHR寄存器,来检查是否有数据。现在,CPU不停的在轮询设备,直到设备有了数据。
    • 这种方法浪费了CPU cycles,当我们在使用CPU不停的检查寄存器的内容时,我们并没有用CPU来运行任何程序。在我们之前的例子中,如果没有数据,内核会让Shell进程sleep,这样可以运行另一个进程。
    • 所以,对于一个慢设备,你肯定不想一直轮询它来得到数据。我们想要在没有数据的时候切换出来运行一些其他程序。但是如果是一个快设备,那么Interrupt的overhead也会很高,那么我们在polling设备的时候,是经常能拿到数据的,这样可以节省进出中断的代价。
    • 所以对于一个高性能的网卡,如果有大量的包要传入,那么应该用polling。对于一些精心设计的驱动,它们会在polling和Interrupt之间动态切换。
  相关解决方案