驱动程序
用于管理特定设备,其行为包含:配置、指示操作、处理中断、与进程交互。它与设备是并发
执行的,代码较为复杂;同时还必须理解硬件的设备接口
需要OS关注的硬件设备通常可以生成中断,内核识别 trap 并调用处理程序 devintr
kernel/trap.c:devintr
驱动程序通常分为两部分
,一部分接收系统调用、对设备进行I/O、让进程进行等待,另一部分确定哪个设备操作已经完成、并唤醒等待的进程、然后告诉硬件开始处理等待中的下一个操作
console 控制程序连接到 UART
串行端口硬件接受人类输入的字符,一次接受一行,用户程序通过read读取这些字符
The UART hardware that the driver talks to is a 16550 chip emulated by QEMU. On a real computer, a 16550 would manage an RS232 serial link connecting to a terminal or other computer. When running QEMU, it’s connected to your keyboard and display.
UART的内存映射地址从 0x10000000
开始,或者称为 UART0(kernel/memlayout.h)。与它的交互不通过内存
。
UART 有少量的控制寄存器
,每个寄存器长一个字节,其相对于UART0的偏移量在(kernel/uart.c)中定义
LSR
:line status register,是否有输入字符等待被软件读取RHR
:receive holding register,用于读取字符。读取后UART会从其内部等待字符FIFO中删除它,读取所有等待字符后设置LSR为空THR
:transmit holding register,软件写入字节UART 发送硬件与接收硬件基本独立
Xv6的main函数调用consoleinit(kernel/console.c)来初始化UART硬件。这段代码配置UART,以便在UART接收到每个输入字节时生成接收中断
,并且每次UART完成发送一个输出字节时生成发送完成中断
xv6 shell通过init.c打开的 文件描述符
从控制台读取输入,对 read
的系统调用最终会通过内核传递到 consoleread
consoleread 等待缓存在 cons.buf
中的输入到达(通过中断)后,将输入复制到用户空间,并在整个行到达后返回给用户进程。在输入一行完成前,任何读取的进程会在 sleep
中等待。
当用户输入一个字符时,UART硬件会请求RISC-V触发中断,从而激活xv6的trap处理程序。
trap处理程序调用 devintr
,该函数查看RISC-V的 scause
寄存器,发现中断来自外部设备。然后,它请求名为 PLIC
的硬件单元告知是哪台设备引发了中断。如果是UART,devintr会调用 uartintr
。
从UART硬件读取任何等待的输入字符并将其交给 consoleintr
,它不会等待字符
,因为未来的输入将触发新的中断。
consoleintr 将输入字符累积到 cons.buf
中,直到接收到完整的一行。它会特别处理退格键和其他一些字符。当收到换行符时,consoleintr 会唤醒正在等待的 consoleread
,由它将其复制到用户空间,并返回到用户空间(通过系统调用机制)。
连接到控制台的 文件描述符
上的 write
系统调用最终会到达 uartputc
函数。
驱动程序维护了一个 输出缓冲区
uart_tx_buf,因此写入的进程 不必等待
UART完成发送操作,由 uartputc
将每个字符附加到缓冲区,并调用 uartstart
启动设备传输。uartputc唯一等待的情况就是输出缓冲区满了
每次UART完成发送一个字节时,它会生成一个中断。uartintr
会调用 uartstart
,该函数会检查设备是否确实完成了发送,并将下一个缓冲的输出字符交给设备。
因此,如果一个进程向控制台写入多个字节,通常第一个字节会由uartputc调用uartstart发送
,剩下的缓冲字节则由uartintr在接收到发送完成的中断后,通过调用uartstart发送。
一般模式是通过 缓冲和中断
实现设备活动与进程活动的解耦:
即使没有进程等待读取,控制台驱动程序也可以处理输入;后续的读取操作将获取到这些输入。
同样,进程可以发送输出而不必等待设备完成。这种解耦可以通过允许进程与设备I/O并发执行来提高性能
这在设备速度较慢(如UART)或需要立即响应(如回显输入字符)时尤为重要。这种思想有时被称为I/O并发
。
console 驱动程序的数据结构可能会受并发访问
的影响
这里存在三个并发风险
:两个位于不同CPU上的进程可能会同时调用consoleread;硬件可能会请求某个CPU在执行consoleread时处理控制台(实际上是UART)的中断;硬件还可能在consoleread执行时在不同的CPU上发出控制台中断。
并发需要在驱动程序中小心处理的另一个方面是,一个进程可能在等待设备输入,而表示输入到达的中断信号可能在另一个进程(或者没有进程运行时)到达。因此,中断处理程序无法考虑它们中断的进程或代码
。例如,中断处理程序不能安全地使用当前进程的页表调用copyout。
中断处理程序通常执行相对较少的工作
(例如,仅将输入数据复制到缓冲区),并唤醒上半部分的代码来完成其余的工作。
Xv6 使用定时器中断来维护其时钟
,并进行进程切换;usertrap 和 kerneltrap 中的 yield
调用触发了这种切换。定时器中断来自连接到每个 RISC-V CPU 的时钟硬件
。Xv6 将这些时钟硬件设置为定期中断每个 CPU。
RISC-V 要求定时器中断必须在机器模式下处理,执行时没有分页
,并且使用了一组独立的控制寄存器
。在这种情况下,使用普通的 trap 机制是不现实的,需要 单独处理
在 main 函数之前,start.c 中的机器模式代码设置了接收定时器中断的环境
(kernel/start.c:57)。
这项工作的一部分是编程 CLINT 硬件(核心本地中断器),使其在一定延迟后生成中断。
另一部分是设置一个类似于 trapframe 的临时区域,以帮助定时器中断处理程序保存寄存器和 CLINT 寄存器的地址。最后,start 将 mtvec 设置为 timervec 并启用定时器中断。
可以在用户或内核代码执行的任何时候发生,无法被禁用
。
所以定时器中断处理程序必须以保证不会扰乱原程序,基本策略是触发软件中断,并允许内核禁用这些软件中断
kernel/kernelvec.S,汇编代码
这是定时器中断的中断向量,它在 start 准备的临时区域中保存了一些寄存器
,告诉 CLINT 何时生成下一次定时器中断,请求 RISC-V 触发软件中断,恢复寄存器并返回。
内核代码可能会由于定时器中断被挂起,然后在不同的CPU上恢复,这给Xv6带来了一些复杂性。如果设备和定时器中断只在执行用户代码时发生,内核可以变得稍微简单一些。
支持典型计算机上的所有设备并充分利用其功能是一项繁重的工作,因为有许多设备,这些设备具有许多功能,并且设备与驱动程序之间的协议可能复杂且文档不足。在许多操作系统中,驱动程序的代码量超过了核心内核的代码量。
UART驱动程序通过读取UART控制寄存器逐字节检索数据;这种模式被称为程序化I/O
,因为数据的传输是由软件驱动的。
程序化I/O简单,但在高速数据传输时太慢。需要高速传输大量数据的设备通常使用直接内存访问(DMA
)。DMA设备硬件直接将传入数据写入RAM,并从RAM读取传出数据。现代磁盘和网络设备使用DMA。DMA设备的驱动程序会在RAM中准备数据,然后通过单次写入控制寄存器,告诉设备处理准备好的数据。
当设备在不可预测的时间需要关注且频率不太高时,中断是合理的。但是中断会带来较高的CPU开销。因此,像网络和磁盘控制器这样的高速设备使用一些技巧来减少中断的需求。
一种技巧是为一批传入或传出的请求触发单次中断
。另一种技巧是驱动程序完全禁用中断,定期检查设备是否需要关注。这种技术称为轮询
。如果设备操作非常快,轮询是合理的,但如果设备大部分时间处于空闲状态,它会浪费CPU时间。
一些驱动程序根据当前设备的负载动态地在轮询和中断之间切换。
UART驱动程序首先将传入数据复制到内核中的缓冲区,然后再复制到用户空间。这在低数据速率下是合理的,但这种双重复制会显著降低处理生成或消耗数据非常快的设备的性能。一些操作系统能够直接在用户空间缓冲区和设备硬件之间移动数据,通常使用DMA技术
执行 top
:
——大部分内存都被用掉了
,其中很大一部分被用作 buff/cache
,我们不想让物理内存闲置。这意味着如果需要新内存的话,必须撤回一些已有的内容
RES:实际使用的内存数量,VIRT:虚拟内存地址空间的大小。可以看到RES远小于VIRT
中断相比于其他的trap机制有一些不一样的地方:
asynchronous
:Interrupt handler 不运行在任何特定进程的context中concurrency
:外设和CPU是两个并行的设备program device
:设备需要被编程才能使用。设备的编程手册包含了它有什么样的寄存器,它能执行什么样的操作,在读写控制寄存器的时候,设备会如何响应。
主板上的各种线路将外设和CPU连接在一起,处理器通过Platform Level Interrupt Control,简称 PLIC
来处理设备中断。
从左上角可以看出,我们有53个不同的来自于设备的中断。这些中断到达PLIC之后,PLIC会路由
这些中断到某一个CPU的核。
如果所有的CPU核都正在处理中断,PLIC会保留中断直到有一个CPU核可以用来处理中断。所以PLIC需要保存一些内部数据来跟踪中断的状态
。
PLIC会通知当前有一个待处理的中断
其中一个CPU核会Claim接收中断,这样PLIC就不会把中断发给其他的CPU处理
CPU核处理完中断之后,CPU会通知PLIC
PLIC将不再保存中断的信息
PLIC只是分发中断,而内核需要对PLIC进行编程
来告诉它中断应该分发到哪。实际上,内核可以对中断优先级进行编程,这里非常的灵活。
例如对于xv6,这里的策略被编程为:所有的CPU都能收到中断,但是只有一个CPU会Claim相应的中断。
——管理外设的软件。我们今天要看的是UART设备的驱动
大部分驱动都分为两个部分,bottom/top
:
bottom部分通常是 Interrupt handler
。CPU接收中断后会调用这个处理程序。不运行在任何特定进程的context中
,单纯处理中断。
top部分与用户进程交互
,或者充当内核的其他部分调用的接口
通常来说会有一些队列buffer
,bottom 和 top 都可以往其中读写数据,从而解耦设备与CPU:
由于 Interrupt handler 不运行在任何特定上下文,也就没有适合的页表,无法获取读写的地址
。top 部分与用户交互,并进行数据的读写
通常来说,编程是通过 memory mapped I/O
完成的。即设备出现在特定的物理地址区间,OS只需要向这个地址区间进行读写即可完成对设备的编程。
load/store时实际上就是在读写
设备的控制寄存器
。物理地址区间位置由主板制造商决定。你需要阅读设备的文档来弄清楚设备的寄存器和相应的行为。注意,这里是在直接操作设备,而不是通过RAM
例如,这是一个SiFive主板中的对应设备的物理地址,QEMU并没有完全模仿它:
16550是QEMU模拟的UART设备,QEMU用这个模拟的设备来与键盘和Console进行交互。
实际上对于一个寄存器,其中的每个bit都有不同的作用
。例如对于寄存器001,也就是IER寄存器,bit0-bit3分别控制了不同的中断。
覆盖问题
:需要遵守一些协议,比如发送一个字节后需要等待中断提示完成发送才可以发送下一个字节,比如如果FIFO队列满了不能继续写入等待
当XV6启动时,Shell会输出提示符“$ ”,如果我们在键盘上输入ls,最终可以看到“$ ls”。我们接下来通过研究Console是如何显示出“$ ls”,来看一下设备中断是如何工作的。
每个CPU都有独立的一组控制寄存器
sie
:专门针对特定外设中断的开关。E:例如UART;S:软件中断;T:定时器中断sip
:当前中断类型这里将所有的中断都设置在Supervisor mode
,然后设置SIE
寄存器来接收External,软件和定时器中断,之后初始化定时器。再经过一些操作,跳转到了 main
首先初始化console
,consoleinit 调用 uartinit 初始化了uart
这里关闭中断,设置串口传输速率 baud rate
,设置字长为8bit,重置FIFO,打开中断
运行完这个函数后,uart 就可以生成中断了,但是PLIC尚未被main初始化
,所以这个中断并不能被任何CPU接收。
设置PLIC能接收哪些中断
。这里设置了接收UART与磁盘的中断
PLIC与外设一样,也占用了一个I/O地址(0xC000_0000
)
plicinit之后就是plicinithart,它用于设置CPU对哪些中断感兴趣
。plicinit是由0号CPU运行,每个CPU都需要调用plicinithart
此处我们忽略中断的优先级,所以将优先级设置为0。
实际运行进程前需要设置 sstaus
打开中断:
在这个时间点,如果PLIC有pending的中断,则CPU便会收到它
init.c:这是系统启动后运行的第一个用户进程。
首先通过 mknod
创建了一个代表 console 的设备,其fd为0。然后复制这个文件描述符得到 stdout
和 stderr
。也即,文件描述符012都表示console
然后fork一个子进程执行 shell
shell 中不断获取一行命令:
尽管Console背后是UART设备,但是从应用程序来看,它就像是一个普通的文件。Shell程序只是向文件描述符2写了数据,它并不知道文件描述符2对应的是什么。
write 会走到系统调用 sys_write,sys_write检查参数后调用 filewrite
filewrite 先判断文件类型,对于 FD_DEVICE,会使用设备特定的写函数
。这里是 consolewrite
consolewrite 一个一个复制字符,并调用 uartputc
打印字符
可以认为consolewrite是一个UART驱动的
top
部分。
uartputc 内部维护了一些数据结构:一个发送数据的buf
,一个为consumer提供的读指针
和为producer提供的写指针
。从而构建了一个环形的 buffer
uartputc 首先检查 buf 是否满了,未满则写入一个字符,更新写指针
w - r
表示 buf 中已生产数据的个数,等于SIZE则已满。传统的方式会+1%size,这样操作的次数过多wr 指针不断追逐,到达uint64上限直接溢出即可,仍然成立
最后调用 uartstart
通知设备执行操作
。循环地进行:
首先检查设备是否空闲,是则读取一个数据通过 THR
写给设备。
若LSR满了或缓冲区为空,则返回。循环中写入字符的同时,UART 可以并发地将LSR中的数据送出。
UART 发送完成后会产生一个中断
PLIC将中断路由到一个CPU(sstatus
的SIE开启),硬件会自动进行以下操作:
清除SIE
bit以防止被再次中断保存pc到sepc,记录被中断的mode
变为S-mode,设置pc为stvec的值
,准备跳转到uservec或kernelvec如果被中断的是用户模式,经过 uservec 会跳转到 usertrap,然后会调用 devintr
:
devintr 首先判断中断类型,对于外设中断,调用 plic_claim
获取中断号:
并根据中断类型调用相应的 Interrupt handler
,此处为 uartintr:
uartintr
调用 uartgetc
从 uart 读取数据,若没有任何数据可以读取,则调用 uartstart
将buffer中的任意字符送出
UART 在发送 $ 的同时,CPU可以并发的将空格符写入 buffer,所以在UART发送完成触发中断后,会发现缓冲区还有一个字符可以发送
,便会将这个空格送出
这里的并发包括以下几个方面:
设备与CPU是并行运行的,这里的并行称为 producer-consumer并行
。
我们通过一个循环队列,一个读、一个写指针表示:
中断会停止当前运行的程序,包括内核代码
。有时内核代码需要关闭中断,以防止被打断
驱动的 top 和 bottom 部分是并行运行的,可以并行运行在不同的CPU上
。
这里我们通过 lock
来管理并行。因为这里有共享的数据,我们想要buffer在一个时间只被一个CPU核所操作
。
一个设备只有一个buffer,但这个buffer被所有的CPU核共享
在UART的另一侧,会有类似的事情发生,有时Shell会调用read从键盘中读取字符。
read 底层会调用 fileread
,若为设备,则 fileread 调用相应设备的 read 函数
,此处为 consoleread
consoleread 也有自己的buffer,但此时 shell 变成了 consumer,而键盘是 producer
当r=w时buffer为空,shell进程sleep。当键盘产生输入并发送到UART,UART产生中断被PLIC路由到一个CPU核,然后由trap机制进入devintr,调用 uartintr。
uartintr 通过 uartgetc
获取到字符,然后传递给 consoleintr
对于输入的字符,consoleintr 在默认情形下会通过 consputc
将它输出到 console,之后,字符被存放在buffer中。遇到换行符则唤醒之前挂起的进程,将数据全部读出
所以这里也是通过buffer将consumer和producer之间解耦,这样它们才能按照自己的速度,独立的并行运行。
如果某一个运行的过快了,那么buffer要么是满的要么是空的,consumer和producer其中一个会sleep并等待另一个追上来。
CPU需要很多步骤才能真正的处理中断数据,所以在产生中断之前,设备上会执行大量的操作
,这样可以减轻CPU的处理负担。这使得现在硬件变得更加复杂。
对于一个高速产生中断的设备,比如千兆网卡,CPU难以处理巨量的中断。这里的解决方法就是使用 polling
除了依赖 Interrupt,CPU可以一直读取外设的控制寄存器,来检查是否有数据。
这种方法浪费了CPU cycles,不能执行任何其他的操作。但对于处理高速设备的中断而言,这种轮询是完全值得的,它降低了大量中断产生的代价