本系列文章为MIT6.S081的学习笔记,包含了参考手册、课程、实验三部分的内容,前面的系列文章链接如下
操作系统MIT6.S081:[xv6参考手册第1章]->操作系统接口
操作系统MIT6.S081:[xv6参考手册第2章]->操作系统组织结构
操作系统MIT6.S081:[xv6参考手册第3章]->页表
操作系统MIT6.S081:[xv6参考手册第4章]->Trap与系统调用
操作系统MIT6.S081:P1->Introduction and examples
操作系统MIT6.S081:P2->OS organization and system calls
操作系统MIT6.S081:P3->Page tables
操作系统MIT6.S081:P4->RISC-V calling conventions and stack frames
操作系统MIT6.S081:P5->Isolation & system call entry/exit
操作系统MIT6.S081:P6->Page faults
操作系统MIT6.S081:Lab1->Unix utilities
操作系统MIT6.S081:Lab2->System calls
操作系统MIT6.S081:Lab3->Page tables
操作系统MIT6.S081:Lab4->Trap
操作系统MIT6.S081:Lab5->Lazy allocation
驱动程序
驱动程序是操作系统中管理特定设备的代码,它负责做以下等工作:
①配置硬件设备
②指示设备执行操作
③处理产生的中断
④与可能正在等待设备I/O的进程进行交互
----驱动程序可能很棘手,因为驱动程序与它管理的设备是同时运行的。此外,驱动程序必须理解设备的硬件接口,这些接口可能很复杂,而且缺乏文档支持。
----需要操作系统关注的设备通常可以被配置为生成中断(trap的一种)。内核trap处理代码在设备产生中断时进行识别,然后调用驱动程序的中断处理程序。在xv6中,这种调度发生在devintr
中(kernel/trap.c:177)。
许多设备驱动在两种情况下执行代码:
上半部分在进程的内核线程中运行,下半部分在中断时执行。
----上半部分通过系统调用被调用,如希望设备执行I/O操作的read
和write
。这段代码可能会要求硬件执行一个操作(如要求磁盘读取块)。然后代码等待操作完成。最终设备完成操作并引发中断。
----驱动程序的中断处理程序充当下半部分,找出已经完成的操作,如果合适的话唤醒一个等待中的进程,并告诉硬件开始执行下一个正在等待的操作。
控制台输入大致流程
控制台驱动程序(console.c)是驱动程序结构的一个简单说明。控制台驱动程序通过连接到RISC-V的UART串口硬件接收人们输入的字符。控制台驱动程序一次累积一行输入,处理如backspace和Ctrl-u的特殊输入字符。用户进程(如Shell)使用
read
系统调用从控制台获取输入行。当你在QEMU中通过键盘向xv6输入信息时,你的按键将通过QEMU模拟的UART硬件交付给xv6。
UART硬件
驱动程序管理的UART硬件是由QEMU仿真的16550芯片。在真正的计算机上,16550将管理连接到终端或其他计算机的RS232串行链路。运行QEMU时,它连接到你的键盘和显示器。
UART在软件中的视图
-----UART硬件在软件中看起来是一组内存映射的控制寄存器。也就是说,存在一些RISC-V硬件连接到UART的物理地址,以便载入(load)和存储(store)操作与设备硬件而不是RAM交互。
----UART的内存映射地址起始于0x10000000或UART0
(kernel/memlayout.h:21)。有几个宽度为一字节的UART控制寄存器,它们相对于UART0
的偏移量在(kernel/uart.c:22)中定义。例如,LSR
寄存器包含指示输入字符是否正在等待软件读取的位。这些字符(如果有的话)可用于从RHR
寄存器读取。每次读取一个字符,UART硬件都会从等待字符的内部FIFO寄存器中删除它,并在FIFO为空时清除LSR
中的ready位。UART传输硬件在很大程度上独立于接收硬件。如果软件向THR
写入一个字节,则UART传输该字节。
控制台输入具体过程
①xv6的
main
函数调用consoleinit
(kernel/console.c:184)来初始化UART硬件。该代码配置UART:UART对接收到的每个字节的输入生成一个接收中断,对发送完的每个字节的输出生成一个发送完成中断(kernel/uart.c:53)。
②xv6的shell通过init.c
(user/init.c:19)中打开的文件描述符从控制台读取输入。对read
的调用实现了从内核流向consoleread
(kernel/console.c:82)的数据通路。consoleread
等待输入到达(通过中断)并在cons.buf
中缓存,将输入复制到用户空间,然后(在整行到达后)返回给用户进程。如果用户还没有键入整行,任何读取进程都将在sleep系统调用中等待(kernel/console.c:98)(第7章解释了sleep的细节)。
③当用户输入一个字符时,UART硬件要求RISC-V发出一个中断,从而激活xv6的trap处理程序。trap处理程序调用devintr
(kernel/trap.c:177),它查看RISC-V的scause
寄存器以发现中断来自外部设备。然后它要求一个名为PLIC的硬件单元告诉它哪个设备中断了(kernel/trap.c:186)。如果是UART,devintr
调用uartintr
。
④uartintr
(kernel/uart.c:180)从UART硬件读取所有等待输入的字符,并将它们交给consoleintr
(kernel/console.c:138)。它不会等待字符,因为未来的输入将引发一个新的中断。consoleintr
的工作是在cons.buf
中积累输入字符,直到一整行到达。consoleintr
对backspace和其他少量字符进行特殊处理。当换行符到达时,consoleintr
唤醒一个等待的consoleread
(如果有的话)。
⑤一旦被唤醒,consoleread
将监视cons.buf
中的一整行,将其复制到用户空间,并返回(通过系统调用机制)到用户空间。
控制台输出具体流程
①在连接到控制台的文件描述符上执行write系统调用,最终将到达
uartputc
(kernel/uart.c:87) 。设备驱动程序维护一个输出缓冲区(uart_tx_buf
),这样写进程就不必等待UART完成发送。相反,uartputc
将每个字符附加到缓冲区,调用uartstart
来启动设备传输(如果还未启动),然后返回。导致uartputc
等待的唯一情况是缓冲区已满。
②每当UART发送完一个字节,它就会产生一个中断。uartintr
调用uartstart
,检查设备是否真的完成了发送,并将下一个缓冲的输出字符交给设备。因此,如果一个进程向控制台写入多个字节,通常第一个字节将由uartputc
调用uartstart
发送,而剩余的缓冲字节将由uartintr
调用uartstart
发送,直到传输完成中断到来。
注: 需要注意,这里的一般模式是通过缓冲区和中断机制将设备活动与进程活动解耦。即使没有进程等待读取输入,控制台驱动程序仍然可以处理输入,而后续的读取将看到这些输入。类似地,进程无需等待设备就可以发送输出。这种解耦可以通过允许进程与设备I/O并发执行来提高性能,当设备很慢(如UART)或需要立即关注(如回声型字符(echoing typed characters))时,这种解耦尤为重要。这种想法有时被称为I/O并发。
驱动程序的并发性
你或许注意到了在
consoleread
和consoleintr
中对acquire
的调用。这些调用获得了一个保护控制台驱动程序的数据结构不受并发访问的锁。这里有三种并发风险:
①运行在不同CPU上的两个进程可能同时调用consoleread
②硬件或许会在consoleread正在执行时要求CPU传递控制台中断
③硬件可能在当前CPU正在执行consoleread时向其他CPU传递控制台中断
驱动程序中需要注意并发的另一种场景:
一个进程可能正在等待来自设备的输入,但是输入的中断信号可能是在另一个进程(或者根本没有进程)正在运行时到达的。因此中断处理程序不允许考虑他们已经中断的进程或代码。例如,中断处理程序不能安全地使用当前进程的页表调用copyout(注:因为你不知道是否发生了进程切换,当前进程可能并不是原先的进程)。中断处理程序通常做相对较少的工作(例如,只需将输入数据复制到缓冲区),并唤醒上半部分代码来完成其余工作。
xv6的定时器中断
xv6使用定时器中断来维持其时钟,并使其能够在计算密集的进程(compute-bound processes)之间切换。
usertrap
和kerneltrap
中的yield
调用会导致这种切换。定时器中断来自每个RISC-V CPU上的时钟硬件。xv6对该时钟硬件进行编程,以定期中断每个CPU。
定时器中断的运行模式
----RISC-V要求定时器中断在机器模式而不是管理员模式下发生。RISC-V机器模式无需分页即可执行,并且有一组单独的控制寄存器,因此在机器模式下运行普通的xv6内核代码是不实际的。因此,xv6处理定时器中断完全不同于上面列出的trap机制。
----机器模式下执行的代码位于main之前的start.c中,它设置了接收定时器中断(kernel/start.c:57)。工作的一部分是对CLINT(core-local interruptor)硬件编程,以在特定延迟后生成中断。另一部分是设置一个类似于trapframe的scratch区域,以帮助定时器中断处理程序保存寄存器和CLINT寄存器的地址。最后,start
将mtvec
设置为timervec
,并使能定时器中断。
定时器中断发生的时间点
----定时器中断可能发生在用户或内核代码正在执行的任何时间点。内核无法在执行关键操作期间禁用计时器中断。因此,计时器中断处理程序必须保证不干扰被中断的内核代码。基本策略是处理程序要求RISC-V发出“软件中断”并立即返回。RISC-V用普通trap机制将软件中断传递给内核,并允许内核禁用它们。处理由定时器中断产生的软件中断的代码可以在devintr
(kernel/trap.c:204)中看到。
----机器模式定时器中断向量是timervec
(kernel/kernelvec.S:93)。它在start
准备的scratch区域中保存一些寄存器,以告诉CLINT何时生成下一个定时器中断,要求RISC-V引发软件中断,恢复寄存器,并且返回。定时器中断处理程序中没有C代码。
①xv6允许在内核中执行时以及在执行用户程序时能够触发设备和定时器中断。定时器中断迫使定时器中断处理程序进行线程切换(调用
yield
),即使在内核中执行时也是如此。如果内核线程有时花费大量时间计算而不返回用户空间,则在内核线程之间公平地对CPU进行时间分割的能力非常有用。然而,内核代码需要注意它可能被挂起(由于计时器中断),然后在不同的CPU上恢复,这是xv6中一些复杂性的来源。如果设备和计时器中断只在执行用户代码时发生,内核可以变得简单一些。
②在一台典型的计算机上支持所有设备是一项艰巨的工作,因为有许多设备,这些设备有许多特性,设备和驱动程序之间的协议可能很复杂,而且缺乏文档。在许多操作系统中,驱动程序比核心内核占用更多的代码。
③UART驱动程序读取UART控制寄存器,一次检索一字节的数据。因为软件驱动数据移动,所以这种模式被称为程序I/O(Programmed I/O)。程序I/O很简单,但速度太慢,无法在高数据速率下使用。需要高速移动大量数据的设备通常使用直接内存访问(DMA)。DMA设备硬件直接将传入数据写入内存,并从内存中读取传出数据。现代磁盘和网络设备使用DMA。DMA设备的驱动程序将在RAM中准备数据,然后使用对控制寄存器的单次写入来告诉设备处理准备好的数据。
④当一个设备在不可预知的时间点而且不会太频繁地需要注意时,中断是有意义的。但是中断有很高的CPU开销。因此,诸如网络和磁盘控制器这些高速设备使用一些技巧减少中断需求。一个技巧是对整批传入或传出的请求发出单个中断。另一个技巧是驱动程序完全禁用中断,并定期检查设备是否需要注意,这种技术被称为轮询(polling)。如果设备执行操作非常快,轮询是有意义的,但是如果设备大部分空闲,轮询会浪费CPU时间。一些驱动程序根据当前设备负载在轮询和中断之间动态切换。
⑤UART驱动程序首先将传入的数据复制到内核中的缓冲区,然后复制到用户空间。这在低数据速率下是可行的,但是这种双重复制会显著降低快速生成或消耗数据的设备的性能。一些操作系统能够直接在用户空间缓冲区和设备硬件之间移动数据,通常带有DMA。