MIT 6.828 book_xv6:Chapter 3

运行进程时,CPU执行正常的处理器循环:读取指令,增加程序计数器,执行指令,重复。但有些事件,必须从对用户程序的控制转移回内核,而不是执行下一条指令。这些事件包括希望得到注意的设备指令、执行非法操作的用户程序(例如,引用没有PTE的虚拟地址),或用户程序要求内核提供具有系统调用的服务。在处理这些事件时,有三个主要挑战:
1)内核必须安排处理器从用户模式切换到内核模式(和返回);
2)必须协调内核和设备之间的并行活动;
3)内核必须很好地理解设备的接口;
解决这三个难题需要对硬件有详细的了解并小心的编写代码,并且可能会产生不透明的内核代码。本章解释xv6如何解决这些问题。


MIT 6.828 Operating System Engineering

Systems calls,exceptions,and interrupts

有了系统调用,用户程序就可以像我们在最后一章结束时看到的那样,使用操作系统提供的服务(operating system service),。术语exception是指生成中断的非法程序操作。非法程序操作的示例包括除零、尝试访问不存在的PTE的内存等。interrupt一词是指硬件设备生成的信号,表明它需要操作系统的注意。例如,时钟芯片可能每100毫秒生成一次中断,以允许内核实现时间共享。另一个示例是,当磁盘从磁盘读取块时,它会生成一个中断,以提醒操作系统该块已准备好进行检索。

内核处理所有中断,而不是进程处理中断,因为在大多数情况下,只有内核具有所需的特权和状态。例如,为了在响应时钟中断的进程之间进行时间划分,内核必须参与其中,即使只是为了强制不合作进程(uncooperative processes)放弃(yield)处理器。

在所有三种示例中,操作系统设计都必须为以下情况做好安排。系统必须保存处理器的寄存器,以便将来透明恢复。必须将系统设置为在内核中执行。系统必须选择一个位置,让内核开始执行。内核必须能够检索有关事件的信息,例如,系统调用参数。这一切都必须安全地完成;系统必须保持用户进程和内核的隔离。

要实现此目标,操作系统必须了解硬件如何处理系统调用、异常和中断的详细信息。在大多数处理器中,这三个事件由一个硬件机制处理。例如,在x86上,程序通过使用int指令生成中断来调用系统调用。同样,异常也会生成中断。因此,如果操作系统有中断处理程序,那么操作系统也可以处理系统调用和异常。

基本计划如下。中断停止正常的处理器循环,并开始执行称为中断处理程序的新序列。在启动中断处理程序之前,处理器将保存其寄存器,以便操作系统可以在从中断返回时还原它们。在转换到中断处理程序和从中断处理程序返回的过程中的一个挑战是,处理器应该从用户模式切换到内核模式,然后再切换到中断处理程序。

虽然官方的x86术语是interrupt,但xv6将所有这些词都称为traps,主要是因为它是PDP 11/40中使用的术语,因此是传统的Unix术语。本章使用的术语trap和interrupt是可互换的,但重要的是要记住,陷阱是由处理器上运行的当前进程引起的(例如,进程进行系统调用,并因此生成陷阱),中断是由设备,并且可能与当前正在运行的进程无关。例如,磁盘在检索完一个进程的块后可能会生成中断,但在中断时,其他进程可能正在运行。中断的这一属性使思考中断比思考陷阱更困难,因为中断与其他活动同时发生。但是,两者都依赖于相同的硬件机制来安全地在用户和内核模式之间传输控制。

X86 protection

X86有4个保护级别,编号为0(最高特权)到3(最低特权)。实际上,大多数操作系统只使用2个级别:0和3,分别称为内核模式和用户模式。X86执行指令的当前权限级别存储在%cs寄存器中的字段CPL中。

在x86上,中断处理程序在中断描述符表(IDT,interrupt descriptor table)中定义。IDT有256个条目,每个条目都给出了在处理相应中断时要使用的%cs和%eip。

要在x86上进行系统调用,程序将调用int n指令,其中n指明了在IDT中的索引。Int指令执行以下步骤:

  • 从IDT中提取第n个描述符,其中n是int的参数
  • 检查%cs中CPL<=DPL,其中DPL是描述符中的权限级别
  • 在CPU内部寄存器中保存%esp和%ss,但前提是目标段选择器的PL<CPL
  • 从任务段描述符加载%ss和%esp
  • Push %ss
  • Push %esp
  • Push %eflags
  • Push %cs
  • Push %eip
  • Clear some bits of %eflags
  • Set %cs and %eip to the values in the descriptor.

Int指令是一个复杂的指令,人们可能会怀疑这些操作是否都是必要的。检查CPL<=DPL允许内核禁止系统使用某些权限级别。例如,要使用户程序成功地执行int指令,DPL必须为3。如果用户程序没有适当的权限,则 int 指令将导致int 13,这是一个一般的保护故障。另一个示例是,int指令不能使用用户堆栈来保存值,因为用户可能没有设置适当的堆栈,以便硬件使用任务段中指定的堆栈,这是在内核模式下设置的。

图3-1显示int指令完成后的堆栈,并且需要特权级别的更改(描述符中的权限级别低于CPL)。如果int指令不需要特权级别的更改,则x86不会保存%ss和%esp。在这两种情况下,%eip指向描述符表中指定的地址,该地址上的指令是要执行的下一个指令和int n处理程序的第一条指令。实现这些处理程序是操作系统的工作,下面我们将看到xv6的作用。

操作系统可以使用iret指令从int指令返回。它从堆栈中弹出int指令期间保存的值,并在保存的%eip处继续执行。

Code: The first system call

第1章结尾的initcode.S就调用了系统调用,让我们再来看看(8213)。该进程在进程的堆栈上推入了exec调用的参数,并将系统调用号放在%eax中。系统调用号与syscalls数组中的条目匹配,syscalls数组是函数指针(3600)的表。我们需要安排int指令将处理器从用户模式切换到内核模式,内核调用正确的内核函数(即sys_exec),内核可以检索sys_exec的参数。接下来的几个小节介绍了xv6如何安排此系统调用,然后我们将发现,我们可以重用相同的代码来处理中断和异常。

Code: Assembly trap handlers

Xv6必须设置x86硬件,以便在遇到int指令时执行一些合理的操作,这将导致处理器生成陷阱。X86允许256个不同的中断。对于软件异常(如除法错误或尝试访问无效内存地址)定义了中断0-31。Xv6将32个硬件中断映射到32-63范围,并使用中断64作为系统调用中断。

从main调用的Tvinit(3317)设置了表idt中的256个条目。中断i由vectors[i]对应地址处的代码处理。每个入口点都是不同的,因为x86不向中断处理程序提供陷阱号。使用256种不同的处理程序是区分256种情况的唯一方法。

Tvinit处理T_SYSCALL,用户系统调用陷阱,特别是:它通过传递值1作为第二个参数来指定门的类型为”陷阱”。陷阱门(trap gate)不清除FL标志,允许在系统调用处理程序期间进行其他中断。

内核还将系统调用门权限设置为DPL_USER,它允许用户程序使用显式int指令生成陷阱。xv6不允许进程用int引发其他中断(例如,设备中断);如果他们尝试,他们将遇到一个一般的保护异常,这指向了异常向量13。

当将保护级别从用户模式更改为内核模式时,内核不应使用用户进程的堆栈,因为它可能无效。用户进程可能是恶意的,或包含导致用户%esp指向不属于该进程的用户内存的地址的错误。Xv6 通过设置任务段描述符,通过该描述符加载堆栈段选择器和%esp的新值,对x86硬件进行编程,以便在陷阱上执行堆栈开关。函数switchuvm(1873)将用户进程内核堆栈顶部的地址存储到任务段描述符中。

当陷阱发生时,处理器硬件执行以下操作。如果处理器在用户模式下执行,它将从任务段描述符加载%esp和%ss,将旧用户%ss和%esp push到新堆栈上。如果处理器在内核模式下执行,则上述情况都不会发生。然后,处理器将push %eflags、%cs和%eip寄存器。对于一些陷阱,处理器也会推入错误字。处理器然后从相关的IDT条目中加载%eip和%cs。

xv6使用Perl脚本(3200)生成IDT条目指向的入口点。如果处理器没有,则每个条目都会push错误代码,push中断号,然后跳转到alltraps。

Alltraps(3254)继续保存处理器寄存器:它push %ds、%es、%fs、%gs和通用寄存器(3255-3260)。这种努力的结果是,内核堆栈现在包含一个strict trapframe(0602),其中包含发生陷阱时的处理器寄存器(参见图3-2)。处理器先push%ss、%esp、%eflags、%cs和%eip。处理器或陷阱向量push一个错误号,alltraps push其余的。陷阱帧包含在内核返回到当前进程时还原用户模式处理器寄存器所需的所有信息,以便处理器可以完全像陷阱启动时那样继续。回顾第2章,userinit手动构建一个陷阱来实现此目标(参见图1-4)。

在第一个系统调用的情况下,保存的%eip是在int指令之后的指令地址。%cs是用户代码段选择器。%eflags是在执行int指令时的标志寄存器(eflags register)的内容。作为保存通用寄存器的一部分,alltraps还保存%eax,其中包含供内核稍后检查的系统调用号。

现在,用户模式时的处理器寄存器已保存,alltraps已经完成设置处理器去运行内核C代码。处理器在进入处理程序之前设置选择器%cs和%ss;alltraps设置%ds和%es(3263-3265)。它将%fs和%gs设置为指向per-CPU数据段(3266-3268)的SEG_KCPU。

正确设置段后,alltraps都可以调用C陷阱处理程序trap。它将%esp(指向它刚刚构造的陷阱帧)推送到堆栈上,作为trap(3271)的参数。然后调用trap(3272)。trap返回后,alltraps通过增加堆栈指针(3273)来弹出所有参数,然后开始在标签trapret执行代码。当第一个用户进程运行此代码以退出到用户空间时,我们在第2章中跟踪了此代码。同样的顺序也发生在这里:弹出陷阱帧恢复用户模式寄存器,然后iret跳回用户空间。

到目前为止,讨论已经讨论了在用户模式下发生的陷阱,但在内核执行过程中也可能发生陷阱。在这种情况下,硬件不会切换堆栈或保存堆栈指针或堆栈段选择器;否则,会出现与用户模式陷阱相同的步骤,并执行相同的xv6陷阱处理代码。当iret稍后还原内核模式%cs时,处理器将继续在内核模式下执行。

Code: C trap handler

我们在上一节中看到,每个处理程序都设置了一个陷阱框架(trap frame),然后调用C函数trap。Trap(3351)查看硬件陷阱编号tf-trapnp,以决定为什么调用它以及需要做什么。如果陷阱是T_SYSCALL,Trap调用系统调用处理程序 syscall。我们将在第5章中回头看两次proc->killed检查。

检查系统调用后,trap将查找硬件中断(我们将在下面讨论)。除了预期的硬件设备外,陷阱还可能是由虚假中断、不需要的硬件中断引起的。

如果陷阱不是系统调用,也不是寻求注意的硬件设备,则trap假定它是由不正确的行为(例如,除以零)引起的,这部分代码作为陷阱之前执行的代码的一部分。如果导致陷阱的代码是用户程序,则xv6打印详细信息,然后设置cp->killed以记住清理用户进程。我们将在第5章中了解xv6是如何进行此清理的。

如果是内核在运行,那就必须有一个内核错误:trap打印有关意外的细节,然后调用panic。

Code: System calls

对于系统调用,trap调用syscall(3625)。Syscall从陷阱帧(trap frame)加载系统调用号码,其保存在%eax,并将索引加载到系统调用表中。对于第一个系统调用,%eax包含值SYS_exec(3457),syscall将调用系统调用表的SYS_exec'th条目,这与调用sys_exec相对应。

Syscall将系统调用函数的返回值记录在%eax。当陷阱返回到用户空间时,它将从cp->tf中的值加载到机器寄存器中。因此,当exec返回时,它将返回系统调用处理程序返回的值(3631)。系统调用通常返回负数以指示错误,并表示成功的正数。如果系统调用号无效,syscall将打印错误并返回–1。

后面的章节将研究特定系统调用的实现。本章涉及系统调用的机制。还有一点机制:找到系统调用参数。辅助函数argintargptrargstr检索第n个系统调用参数,参数可以是整数、指针或字符串。argint使用用户空间%esp寄存器来定位第n个参数:%esp指向系统调用存根(system call stub)的返回地址处。参数就在它的正上方,即在%esp+4。然后第n个参数是%esp+4+4*n

argint调用fetchint从用户内存中读取该地址的值,并将其写入*ip。fetchint可以简单地将地址强制转换为指针,因为用户和内核共享相同的页面表,但内核必须验证用户的指针确实是地址空间的用户部分中的指针。内核设置了页面表硬件,以确保进程无法访问其本地专用内存之外的内存:如果用户程序尝试在p->sz或更高的地址读取或写入内存,处理器将导致分段陷阱,陷阱会杀死这个过程,正如我们上面看到的。现在,内核正在运行,它可以取消用户可能已传递的任何地址,因此它必须明确检查该地址是否低于p->sz。

argptr的目的与argint相似:它解释第n个系统调用参数。argptr调用argint将参数作为整数获取,然后检查整数作为用户指针是否确实在地址空间的用户部分。请注意,在调用代码argptr时会发生两次检查。首先,在获取参数的过程中检查用户堆栈指针。然后检查参数,它本身就是一个用户指针。

argstr是系统调用参数三人组的最后一个成员。它将第n个参数解释为指针。它确保指针指向NUL-terminated的字符串,并且完整的字符串位于用户地址空间部分的末尾以下。

系统调用实现(例如,sysprocr.c和sysfile.c)通常是包装器:它们使用argint、argptr和argstr对参数进行解码,然后调用实际实现。在第2章中,sys_exec使用这些函数来获取其参数。

Code: Interrupts

主板上的设备可以生成中断,xv6必须设置硬件来处理这些中断。如果没有设备支持,xv6将无法使用。用户无法在键盘上键入,文件系统无法将数据存储在磁盘上等。幸运的是,添加中断和对简单设备的支持不需要太多的复杂性。正如我们将看到的,中断可以使用与系统调用和异常相同的代码。

中断类似于系统调用,但设备随时生成中断。当设备需要得到注意时,主板上有硬件可向CPU发出信号(例如,用户在键盘上键入了字符)。我们必须对设备进行编程以生成中断,并安排CPU接收中断。

让我们来看看计时器设备和计时器中断。我们希望计时器硬件生成中断,例如,每秒100次,这样内核就可以跟踪时间的推移,并且内核就可以在多个正在运行的进程之间进行时间划分。选择每秒100次,可以实现体面的交互性能,同时不干扰处理器处理中断。

与x86处理器本身一样,PC主板也在不断发展,中断的提供方式也在演变。早期的主板有一个简单的可编程中断控制器(称为PIC, programmable interrupt controler),您可以在picirq.c中找到管理它的代码。

随着多处理器PC主板的出现,需要一种新的处理中断的方法,因为每个CPU都需要一个中断控制器来处理发送给它的中断,并且必须有一种将中断路由到处理器的方法。这种方式由两部分组成:一部分在I/O系统(the IO APIC,ioapic.c),以及连接到每个处理器的部分(the local APIC,lapic.c)。Xv6是为具有多个处理器的主板而设计的,每个处理器都必须编程以接收中断。

为了在单处理器上也正常工作,Xv6对可编程控制器(PIC)(7432)进行编程。每个PIC最多可以处理8个中断(即设备),并在处理器的中断引脚上对其进行复用。为了允许8台以上的设备,要连接PICs,通常主板至少有2个。使用inb和outb指令Xv6对master编程生成IRQ 0到7和对slave生成IRQ 8到16。最初xv6对PIC编程,以掩盖所有中断。Timer.c中的代码设置计时器为1,并在PIC(8074)上启用计时器中断。此说明忽略了编程PIC方案的一些细节。PIC(以及 IOAPIC和LAPIC)的这些详细信息对本文并不重要,但感兴趣的读者可以查阅源文件中引用的每个设备的手册。

在多处理器上,xv6必须对每个处理器上的IOAPIC和LAPIC进行编程。IO APIC有一个表,处理器可以通过内存映射的I/O对表中的条目进行编程,而不是使用inb和outb指令。在初始化过程中,xv6程序将中断0映射到IRQ 0,依此类推,但禁用它们。特定设备启用特定中断,并说明中断应路由到哪个处理器。例如,xv6 将键盘中断路由到处理器0(8016)。Xv6将磁盘中断路由到系统上编号最高的处理器,我们将在下面看到。

定时器芯片位于LAPIC内部,因此每个处理器都可以独立接收计时器中断。Xv6将其设置在lapicinit(7151)中。关键行是对计时器(7164)进行编程的行。此行告诉LAPIC定期在IRQ_TIMER(即IRQ 0)生成中断。第7193行启用CPU LAPIC上的中断,这将导致它向本地处理器传递中断。

处理器可以通过eflags寄存器中的IF标志控制是否希望接收中断。指令cli通过清除IF来禁用处理器上的中断,sti在处理器上启用中断。Xv6在引导(booting)主CPU(8912)和其他处理器(1126)时禁用中断。每个处理器上的调度程序启用中断(2714)。为了控制某些代码片段不会中断,xv6在这些代码片段期间禁用中断(例如,请参见switchuvm(1873))。

计时器通过向量32中断(其中xv6选择处理IRQ 0),其中xv6设置在idtinit(1265)。向量32和向量64(系统调用)之间的唯一区别是,向量32是中断门而不是陷阱门。中断门清除IF,以便中断的处理器在处理当前中断时不会接收中断。从这里开始,直到trap,中断遵循与系统调用和异常相同的代码路径,建立陷阱框架。

当它被调用为时间中断时,trap只做两件事:增加刻度变量(ticks variable,3367)和调用wakeup。正如我们将在第5章中看到的那样,后者可能会导致中断在不同的过程中返回。

Drivers

驱动程序是操作系统中管理特定设备的一段代码:它为设备提供中断处理程序,导致设备执行操作,导致设备生成中断等。编写驱动程序代码可能会很棘手,因为驱动程序与其管理的设备同时执行。此外,驱动程序必须了解设备的接口(例如,哪个I/O执行什么操作),并且该接口可能很复杂,文档记录不足。

磁盘驱动程序在xv6中提供了一个很好的示例。磁盘驱动程序从磁盘复制数据并返回到磁盘。磁盘硬件传统上将磁盘上的数据显示为512字节块(也称为扇区)的编号序列:扇区0是前512字节,扇区1是下一个字节,依此类推。为了表示磁盘扇区,操作系统具有与一个扇区相对应的结构。存储在此结构中的数据通常与磁盘不同步:它可能尚未从磁盘中读取(磁盘正在处理,但尚未返回扇区的内容),或者它可能已更新但尚未写出。驱动程序必须确保在结构与磁盘不同步时,xv6的其余部分不会感到困惑。

Code: Disk driver

IDE设备提供对连接到PC标准IDE控制器的磁盘的访问。IDE现在已经过时,取而代之的是SCSI和SATA,但接口很简单,让我们专注于驱动程序的整体结构,而不是特定硬件的细节。

磁盘驱动程序使用称为buffer的数据结构来表示磁盘扇区,struct buf(3750)。每个缓冲区表示特定磁盘设备上的一个扇区的内容。dev和sector字段提供设备和扇区编号,data字段是磁盘扇区的内存中副本。

flags跟踪内存和磁盘之间的关系:B_VALID标志表示已读取数据,B_DIRTY标志表示需要写出数据。B_BUS标志是一个锁位;它表示某些进程正在使用缓冲区,而其他进程不能使用。当缓冲区设置了B_BUSY标志时,我们说缓冲区是锁定的。

内核在启动时初始化磁盘驱动程序,方法是从main(1234)调用ideinit(4151)。Ideinit调用picenable和ioapicenable启用IDE_IRQ中断(4156-4157)。对picenable的调用启用了单处理器上的中断;ioapicenable在多处理器上启用中断,但仅在最后一个CPU(ncpu-1)上启用:在双处理器系统上,CPU 1处理磁盘中断。

接下来,ideinit探测磁盘硬件。它首先调用idewait(4158),以等待磁盘能够接受命令。PC主板显示I/O端口0x1f7上磁盘硬件的状态位。Idewait(4133)轮询状态位,直到忙位(IDE_BSY)清除并设置就绪位(IDE_DRDY)。

现在磁盘控制器已准备就绪,ideinit可以检查存在多少磁盘。它假定磁盘0存在,因为引导加载程序和内核都是从磁盘0加载的,但它必须检查磁盘1。它将写入I/O端口0x1f6以选择磁盘1,然后等待一段时间,以等待状态位显示磁盘已准备就绪(4160-4167)。如果没有,则认为磁盘不存在。

在ideinit之后,在缓冲区缓存调用iderw之前,不会再次使用磁盘,该缓冲区将按照标志的指示更新锁定的缓冲区。如果设置了B_DIRTY,则将缓冲区写入磁盘;如果未设置B_VALID,则从磁盘读取缓冲区。

磁盘访问通常需要毫秒,对于处理器来说是很长一段时间。引导加载程序发出磁盘读取命令,并重复读取状态位,直到数据准备就绪。此轮询或繁忙等待在引导加载程序中很好,没有更好的操作。但是,在操作系统中,让另一个进程在CPU上运行并在磁盘操作完成时安排接收中断会更有效。Iderw采用后一种方法,将挂起的磁盘请求列表保留在队列中,并使用中断来确定每个请求何时完成。尽管iderw维护一个请求队列,但简单的IDE磁盘控制器一次只能处理一个操作。磁盘驱动程序保持不变,即它已将队列前面的缓冲区发送到磁盘硬件;其他人只是在等着轮到他们。

Iderw(4254)将缓冲区b添加到队列的末尾(4267-4271)。如果缓冲区位于队列的最前面,则必须通过调用idestart(4224-4226)将其发送到磁盘硬件;否则,只有缓冲区前面的缓冲区得到处理,才会启动缓冲区。

根据标志,idestart(4175)会发出缓冲区设备和扇区的读取或写入问题。如果操作是写的,则必须立即提供数据(4189),中断将发出数据已写入磁盘的信号。如果操作是读取操作,中断将发出数据已准备就绪的信号,处理程序将读取该数据。请注意,idestart对IDE设备有详细的了解,并在正确的端口写入正确的值。如果这些输出语句中的任何一个是错误的,IDE将执行与我们所需的不同的操作。正确掌握这些细节是编写设备驱动程序具有挑战性的原因之一。

将请求添加到队列并在必要时启动它后,idew必须等待结果。如上所述,轮询不能有效地利用CPU。相反,idew休眠,等待中断处理程序在缓冲区的标志中记录操作已完成(4278-4279)。当此过程处于睡眠状态时,xv6将安排其他进程以保持CPU繁忙。

最终,磁盘将完成其操作并触发中断。trap会调用ideintr来处理(3374)。Ideintr(4202)会咨询队列中的第一个缓冲区,以找出正在执行的操作。如果正在读取缓冲区,并且磁盘控制器有数据等待,则ideintr会使用insl(4215-4217)将数据读取到缓冲区中。现在缓冲区已准备就绪:ideintr设置 B_VALID,清除B_DIRTY,并唤醒休眠缓冲区(4219-4222)上的任何进程。最后,ideintr必须将下一个等待缓冲区传递到磁盘(4224-4226)。

Real world

支持PC主板上的所有设备的全部荣誉是大量的工作,因为有许多设备,设备有许多功能,并且设备和驱动程序之间的协议可能很复杂。在许多操作系统中,驱动程序共同在操作系统中考虑的代码比核心内核更多。

实际设备驱动程序比本章中的磁盘驱动程序复杂得多,但基本思想是相同的:通常设备比CPU慢,因此硬件使用中断来通知操作系统状态更改。现代磁盘控制器通常一次接受一批磁盘请求,甚至对其进行重新排序,以便最有效地利用磁盘臂。当磁盘更简单时,操作系统通常会对请求队列本身进行重新排序。

许多操作系统都有固态磁盘的驱动程序,因为它们提供了更快的数据访问。尽管固态与传统的机械磁盘的工作原理非常不同,但这两种设备都提供了基于块的接口,并且固态磁盘上的读/写入块仍然比读写RAM更昂贵。

其他硬件与磁盘惊人地相似:网络设备缓冲区保存数据包,音频设备缓冲区保存声音样本,图形卡缓冲区保存视频数据和命令序列。高带宽设备(磁盘、图形卡和网卡)通常使用直接内存访问(DMA, direct memory access),而不是此驱动程序中的显式I/O(insl,outsl)。DMA允许磁盘或其他控制器直接访问物理内存。驱动程序向设备提供缓冲区数据字段的物理地址,设备直接复制到主内存或从主内存复制,一旦复制完成,就会中断。使用DMA意味着CPU根本不参与传输,这可以更高效,并且对 CPU的内存缓存的负担更小。

本章中的大多数设备都使用I/O指令对其进行编程,这反映了这些设备的旧特性。所有现代设备都使用内存映射I/O(memory-mapped I/O)进行编程。

某些驱动程序在轮询和中断之间动态切换,因为使用中断可能会很昂贵,但使用轮询可能会导致延迟,直到驱动程序处理事件。例如,对于接收到大量数据包的网络驱动程序,可以从中断切换到轮询,因为它知道必须处理更多的数据包,并且使用轮询处理这些数据包的成本更低。一旦不再需要处理数据包,驱动程序就可以切换回中断,以便在新数据包到达时立即向其发出警报。

IDE驱动程序静态地将中断路由到特定的处理器。一些驱动程序有一个复杂的算法,将中断路由到处理器,使处理数据包的负载很好地平衡,但也实现了良好的局部性。例如,网络驱动程序可能会安排将一个网络连接的数据包的中断传递到管理该连接的处理器,而另一个连接的数据包的中断则传递到另一个处理器。此路由可以变得相当复杂;例如,如果某些网络连接的寿命较短,而另一些网络连接寿命较长,并且操作系统希望使所有处理器都忙得不可开交,以实现高吞吐量。

如果用户进程读取文件,则会复制该文件的数据两次。首先,驱动程序将其从磁盘复制到内核内存,然后通过读取系统调用将其从内核空间复制到用户空间。如果用户进程然后在网络上发送数据,这将再次复制数据两次:一次从用户空间到内核空间,从内核空间到网络设备。为了支持低延迟的应用程序是很重要(例如,为静态网页提供服务的Web),操作系统使用特殊的代码路径来避免这些副本。例如,在实际操作系统中,缓冲区通常与硬件页面大小匹配,因此只读副本可以使用分页硬件映射到进程的地址空间,而无需进行任何复制。

Exercises

  • Set a breakpoint at the first instruction of syscall() to catch the very first system call (e.g.,br syscall). What values are on the stack at this point? Explain the output of x/37x $esp at that breakpoint with each value labeled as to what it is (e.g.,saved %ebp for trap,trapframe.eip,scratch space,etc.).
  • Add a new system call
  • Add a network driver