XV6学习(8)中断和设备驱动

驱动是操作系统中用于管理特定设备的代码:驱动控制设备硬件,通知硬件执行操作,处理中断,与等待该设备IO的进程进行交互。

当设备需要与操作系统进行交互时,就会产生中断(陷阱的一种),之后内核的陷阱处理代码就会识别中断设备并调用对应的驱动处理程序。在XV6这一步发生在trap.cdevintr中。

大部分设备驱动在两个上下文中执行代码:顶层部分运行在进程的内核线程中,底层部分在中断处理时执行。顶层部分通过系统调用如readwrite来调用,这一部分代码会请求硬件开始一个操作的执行(如请求硬盘读取块);之后就会进入等待状态等待操作的完成。当设备完成操作后,就会触发一个中断,驱动的中断处理程序,即底层部分就会判断完成的操作,唤醒对应的正在等待的进程,之后通知硬件执行下一个操作。

代码:控制台输入

控制台的驱动程序console.c是一个驱动结构的简单抽象。控制台驱动通过UART(Universal asynchronous receiver-transmitter,通用异步收发传输器)串口读取用户输入的字符。驱动程序一次会累积一行的输入,并处理特定的字符如退格和ctrl-u。用户进程通过read系统调用来获取一行输入。

驱动调用的UART硬件是由QEMU模拟的16550芯片,一个16550芯片可以管理一条连接到终端或其他电脑的RS232串行链路。在QEMU中,其连接到键盘和显示器。

UART硬件可以看作一组映射到内存中的控制寄存器,对硬件的控制可以直接通过loadstore特定内存来完成。UART内存映射地址开始于0x10000000UART0(定义于memlayout.h)。每个控制寄存器的大小为1byte,偏移量定义于uart.c

XV6main函数中的consoleinit对UART设备进行初始化,设置UART设备每接收一个字节的输入就产生一个接收中断,每当完成一个字节输出的发送时就产生一个传输完成中断。

XV6 shell通过init.c中打开的文件描述符对控制台进行读取。read系统调用将会调用consoleread函数,该函数等待输入的到达(通过中断)并保持在cons.buf中,拷贝其到用户空间,当一整行接收完成后返回到用户进程中。如果没有一整行输入到达,read进程就会在sleep调用中等待。

当用户输入一个字符,UART设备就会产生一个中断,激活XV6的陷阱处理程序。陷阱处理程序将会调用devintr,读取scause判断是否为外部设备产生的中断。之后通过PLIC(平台级中断控制器)判断中断设备,如果是UART设备,就会调用uartintr函数。

uartinit从UART设备中读取所有输入字符,并将其交给consoleintr处理,此函数不会等待字符的输入,因为未来的输入会产生新的中断。consoleintr将输入保持在buffer中直到一整行到达,同时对一些特殊符号进行处理。当一整行到达后,就会唤醒一个正在等待的consoleread

consoleread被唤醒时,buffer中就保存了完整的一行输入,此时就可以将其拷贝到用户空间并返回。

代码:控制台输出

write系统调用对控制台的写入最终会调用uartputc函数,设备会维护输出缓冲uart_tx_buf,因此写进程不需要等待UART完成发送。uartputc将字符加入缓冲区后,调用uartstart函数开始传输之后返回,该函数唯一的等待情况是缓冲区已满。

每当UART发送一个字节后,就会产生一次中断。uartintr函数会调用uartstart函数判断传输是否完成,未完成就开始传输下一个缓冲的字符。因此,当进程写入多个字符时,第一个字节会通过uartputc调用uartstart进行传输,之后的字节将会被uartintr调用的uartstart进行传输。

对于设备活动和进程活动,常用的解耦方式是通过缓冲和中断。控制台驱动可以处理输入即使没有进程在等待读取,一个随后到来的read会读取到输入。类似的,进程可以进行输出而不需要等待设备响应。解耦可以允许进程并行执行设备IO从而提高性能,尤其是当设备速度很慢或需要立即进行响应(如输入一个字符)。这种思想也被称作I/O并行。

驱动中的并行

consolereadconsoleintr中会调用acquire函数。这些调用会申请一个锁,用于在并行访问中保护驱动的数据结构。在这里有三种并行风险:两个不同CPU上的进程同时调用consoleread;当CPU在执行consoleread函数时硬件触发了一个中断;当consoleread在执行时,硬件在其他CPU上触发了一个中断。

在并行中需要关注的另一个点是一个进程可能会等待设备的输入,但是中断信号在另一个进程运行时产生,因此中断处理程序是必须上下文无关的(不允许考虑中断时的进程或代码)。例如中断处理程序不能安全地在当前进程地页表上调用copyout函数。中断处理程序应该仅执行上下文无关的工作(如拷贝输入到缓冲区),之后唤醒顶层部分来处理剩余工作。

定时器中断

XV6通过定时器中断来维护时钟以及进行进程切换;在usertrapkerneltrap中的yield函数会执行进程切换。定时器中断会由RISC-V CPU内部的时钟硬件产生。XV6对此时钟硬件进行编程以定期中断每个CPU。

RISC-V要求定时器中断必须在机器模式下执行,而不是在监管模式下执行。RISC-V的机器模式在无分页环境下执行,并且具有一系列单独的控制寄存器,因此在机器模式下运行普通的 xv6 内核代码是不切实际的。所有XV6的定时器中断处理程序是和陷阱机制完全分开的。

start.c中的代码执行于机器模式中,main函数之前,在timerinit函数中对定时器中断进行了设置:对CLINT硬件编程使其在一定时间后产生一次中断;设置scratch区域(类似于trapframe),帮助定时器中断处理程序保存寄存器和CLINT寄存器的地址。最后函数会设置mtvectimervec函数地址并开启定时器中断。

定时器中断会在任何时候发生,内核在执行关键操作时也无法禁用定时器中断。因此定时器中断处理程序必须保证不会干扰被中断的内核代码执行。处理程序最基本的策略就是产生一个软件中断之后立即返回。产生的软件中断就可以通过通用的陷阱机制进行处理,并且可以进行关闭。软件中断的处理程序在devintr函数中。

机器模式的时钟中断向量为timervec,该函数保存了三个寄存器在start函数准备的scratch区域中,通知CLINT下一个中断的时刻,通过csrw sip, a1a1为2)指令触发一个软件中断,最后恢复寄存器并返回。

真实操作系统

XV6运行设备和时钟中断在内核执行时产生。定时器中断在中断处理程序中强制线程切换,即使是在内核态执行中。这个功能可以使得内核线程公平地获取CPU时间片,尤其是当内核线程耗费大量时间进行计算而不返回用户态。但是,这使得内核代码需要考虑到其可能会被暂停并在一段时间后再另一个CPU上恢复,而这给XV6带来了一定的复杂性。如果设备中断和定时器中断只在用户代码执行时运行触发,内核可以变得更加简单。

在许多操作系统中,驱动程序的代码量远远大于内核本身。要支持所有设备在计算机上运行是十分繁杂的工作:有大量设备需要支持,设备有很多特性,设备间的协议十分复杂并且缺少文档。

UART设备通过读取控制寄存器一次接收一个字节数据,这种模式称为程序I/O(programmed I/O),因为数据移动由软件驱动。这种方式十分简单但是在高速设备上是十分缓慢的。高速设备通常通过DMA方式来进行数据传输。DMA设备硬件可以直接对内存进行读写,现代硬盘和网络设备就是通过这种方式进行的。DMA设备驱动会在内存中准备数据,之后通过一次控制寄存器的写入告诉设备对准备好的数据进行处理。

当设备需要在无法预知但不太频繁的时间上需要进行处理时,中断是有效的。但是中断有很大的CPU开销。因此高速设备会使用一些技巧来减少中断次数。一个技巧就是对一整批的输入或输出请求发起一次中断。另一个是驱动完全禁用中断,转为定时查询设备是否需要处理,这种技术被称为轮询(polling)。如果设备操作执行非常频繁,那么轮询是有意义的,反之如果设备大部分时间都是空闲的,那么轮询会浪费CPU时间。一些驱动会根据设备负载自动切换轮询和中断。

UART驱动先拷贝输入数据到内核缓冲区,之后再拷贝到用户空间。这在低数据传输率的情况下是有效的,但是对于高速设备,两次拷贝会显著地降低性能。一些操作系统可以直接将数据在用户态缓冲区和设备硬件之间移动,通常是通过DMA。

posted @ 2021-01-30 11:00  星見遥  阅读(1495)  评论(0编辑  收藏  举报