Xv6 手册:中断和设备驱动程序

146 阅读13分钟

Chapter 5: Interrupts and device drivers

驱动程序(device drivers) 是操作系统中用于管理和控制硬件设备的代码。其主要功能包括配置设备、发起操作、处理中断,并与等待I/O操作完成的进程交互。

驱动程序的关键特性:

  1. 并发性:驱动程序与设备硬件并行运行,因此需要妥善管理并发问题。
  2. 硬件接口:驱动程序必须理解设备的具体硬件接口,这可能复杂且文档不全。
  3. 中断机制:设备通过中断通知操作系统事件的发生。操作系统捕获中断后会调用驱动程序的中断处理程序。

驱动程序的工作上下文:

  • 上半部分(Upper Half):运行在进程的内核线程中,通常由系统调用(如 readwrite)触发。这部分代码负责启动设备操作(如磁盘读取),并可能进入等待状态。
  • 下半部分(Lower Half):运行在中断上下文中,当设备完成操作并触发中断时执行。这部分代码处理已完成的操作,唤醒等待的进程,并安排后续任务。

示例流程:

  1. 用户进程调用系统调用(如 read),触发驱动程序的上半部分。
  2. 上半部分指示硬件开始操作(如磁盘读取),然后等待完成。
  3. 硬件完成操作后生成中断。
  4. 操作系统捕获中断并调用驱动程序的中断处理程序(下半部分)。
  5. 下半部分处理结果,唤醒等待的进程,并安排新的操作。

需要操作系统注意的设备通常可以配置为生成中断,这是一种陷阱。内核陷阱处理代码在识别到设备引发中断时会调用驱动程序的中断处理程序;在 xv6 中,这种调度发生在 devintr


5.1 代码:控制台输入

控制台驱动程序 console.c 是驱动程序结构的简单示例。

控制台驱动程序通过连接到 RISC-V 的 UART 串行端口硬件,接受输入的字符。控制台驱动程序一次累积一行输入,处理特殊输入字符,如退格和控制 -u。用户进程(如 shell)使用 read 系统调用来从控制台获取输入行。当你在 QEMU 中向 xv6 输入时,你的按键通过 QEMU 模拟的 UART 硬件传递给 xv6 。

驱动程序与之交互的 UART 硬件是由 QEMU 模拟的 16550 芯片。在真实的计算机上, 16550 将管理连接到终端或其他计算机的 RS232 串行链接。运行 QEMU 时,它连接到你的键盘和显示器。

UART是“通用异步收发器”(Universal Asynchronous Receiver/Transmitter)的缩写。它是一种常见的通信方式,用来在两个电子设备之间传输数据。比如,你的电脑和一个传感器、或者两块电路板之间,都可以通过 UART 进行通信。

简单来说, UART 的作用就是把并行的数据(多个比特同时传输)转换成串行的数据(一个比特接一个比特地传输),然后通过一根数据线发送出去;反过来,它也能把接收到的串行数据还原成并行数据。

UART 硬件通过一组内存映射的控制寄存器与软件交互。这些寄存器从地址 0x10000000 开始,每个寄存器宽度为一个字节,偏移量定义在 uart.c 中。

  • LSR(线路状态寄存器):指示是否有等待读取的输入字符。如果有,可通过 RHR(接收保持寄存器) 读取字符。每次读取后,字符从内部FIFO队列中移除,FIFO为空时清除LSR的“就绪”位。
  • THR(发送保持寄存器):软件向其写入数据后,UART硬件会独立发送该数据。

Xv6 通过 consoleinit 初始化 UART 硬件,配置其在接收输入字节或完成发送时生成中断。以下是数据流和中断处理的关键过程:

  1. 初始化与中断配置
    • consoleinit 配置 UART ,在接收到输入字节或完成发送时触发中断。
  2. 用户读取流程
    • 用户进程通过 read 系统调用读取控制台输入。
    • 内核调用 consoleread ,等待输入到达(通过中断),并将输入缓冲到 cons.buf 中。
    • 如果未输入完整行,调用 sleep 使进程等待。
  3. 中断处理
    • 用户输入字符时,UART 硬件请求 RISC-V 引发中断,激活陷阱处理程序。
    • 陷阱处理程序调用 devintr ,通过 scause 寄存器识别中断来源,并借助 PLIC 确定是 UART 中断。
    • devintr 调用 uartintr ,后者读取 UART 中的等待字符并交给 consoleintr
  4. 字符处理与唤醒
    • consoleintr 累积输入字符到 cons.buf ,处理特殊字符(如退格)。
    • 当换行符到达时, consoleintr 唤醒等待的 consoleread
  5. 返回用户空间
    • 被唤醒后, consoleread 检测到完整行,将其复制到用户空间并通过系统调用返回。

PLIC(Platform-Level Interrupt Controller,平台级中断控制器) 是 RISC-V 中用于管理和处理中断的关键组件。它主要用于 RISC-V 架构下外部中断的控制和处理。 PLIC 独立地处理每个中断目标,并且不考虑包含多个中断目标的组件所使用的任何中断优先级方案。 PLIC 理论上支持 1023 个外部中断源和 15872 个上下文,可以将多个 外部中断源(Source) 仲裁为一个单比特的 中断信号(IRQ) 送入处理器核。

在 RISC-V SiFive U54 内核中, PLIC 最多可支持 132 个具有 7 个优先级的外部中断源。

初始化与中断配置

操作系统启动时,调用 consoleinit ,而 consoleinit 配置 UART。

截屏2025-02-13 20.11.04.png

consoleinit 代码:

截屏2025-02-13 20.11.44.png

consoleinit 把设备表 devsw 中控制台设备的 readwrite 函数指针分别设置为 consolereadconsolewrite 函数。这意味着当系统调用 readwrite 针对控制台设备时,将调用这些函数来处理实际的读写操作。

uartinit 代码:

截屏2025-02-13 20.15.35.png

这段代码是 uartinit 函数的实现,用于初始化 16550a UART(通用异步收发传输器) 。具体功能如下:

  1. 禁用中断。
  2. 进入设置波特率的特殊模式。
  3. 设置波特率为 38.4K(低字节和高字节)。
  4. 退出设置波特率模式,并设置数据位长度为 8 位,无奇偶校验。
  5. 重置并启用 FIFO(先进先出)缓冲区。
  6. 启用发送和接收中断。
  7. 初始化用于 UART 发送缓冲区的自旋锁。

这些步骤确保 UART 设备正确配置,以便进行数据传输和接收。

用户读取流程

用户通过 read 系统调用,代码如下:

截屏2025-02-13 21.01.45.png

f 中存储了文件描述符,其 typeFD_DEVICE

fileread 代码如下:

截屏2025-02-13 21.02.36.png

devsw[f->major].read(1, addr, n) 即调用 consoleread 函数。

consoleread 函数代码:

截屏2025-02-13 21.16.49.png

内核调用 consoleread ,等待输入到达(通过中断),并将输入缓冲到 cons.buf 中。

如果控制台缓冲区为空 cons.r == cons.w,则调用 sleep(&cons.r, &cons.lock) 使当前进程睡眠,直到有输入数据。

中断处理

用户输入字符时,UART 硬件请求 RISC-V 引发中断,激活陷阱处理程序。在 RISC-V 中,scause 寄存器存储了中断或异常的原因。陷阱处理程序首先读取 scause 寄存器,判断中断是否来自外部设备。

devintr 代码,陷阱处理程序由此判断是否为外部设备中断。

截屏2025-02-14 15.04.32.png

devintr 借助 PLIC 确认,是 UART 中断,调用 uartintr

plic_claim 代码:

截屏2025-02-14 15.09.25.png

其功能是从 平台级中断控制器(PLIC) 中获取当前需要处理的中断请求(IRQ)。具体来说,它执行以下操作:

  1. 获取当前处理器的硬件线程ID(hart ID)。
  2. 从PLIC的SCLAIM寄存器中读取当前需要处理的IRQ编号。
  3. 返回读取到的IRQ编号。

这是PLIC中断处理的一部分,用于确定当前需要处理的中断源。

uartgetcuartintr 代码:

截屏2025-02-14 15.13.34.png

uartintr 从 UART 硬件的接收保持寄存器(RHR)中读取等待的字符。每次读取一个字符后,UART 硬件会自动将其从内部 FIFO 队列中移除,其调用 consoleintr

consoleintr 代码:

截屏2025-02-14 15.18.05.png

uartintr 将读取到的字符传递给 consoleintr 函数(位于 kernel/console.c:136)。consoleintr 负责将字符累积到全局缓冲区 cons.buf 中,并处理特殊字符(如退格、换行等)。

如果字符是换行符 \n,表示一行输入完成,consoleintr 会唤醒之前因调用 sleep 而等待输入的进程(例如调用 consoleread 的进程)。

字符处理与唤醒

consoleintr 累积输入字符到 cons.buf ,处理特殊字符(如退格)。如果字符是换行符 \n,表示一行输入完成,consoleintr 会唤醒之前因调用 sleep 而等待输入的进程(例如调用 consoleread 的进程)。

返回用户空间

被唤醒后, consoleread 检测到完整行,将其复制到用户空间并通过系统调用返回。 consoleread:105 往后,即为此功能代码。


5.2 代码:控制台输出

当对连接到控制台的文件描述符执行写系统调用时,最终会到达 uartputc。设备驱动程序维护一个输出缓冲区(uart_tx_buf),这样写进程就不必等待 UART 完成发送;相反, uartputc 会将每个字符追加到缓冲区中,调用 uartstart 来启动设备传输(如果尚未启动),然后返回。

uartputc 唯一会等待的情况是缓冲区已经满了。

uartputc 代码:

截屏2025-02-15 06.37.01.png

输出缓冲区与读指针与写指针的定义:

截屏2025-02-15 06.38.01.png

这里的输出缓冲区是一种环形缓冲区,当 uart_tx_w == uart_tx_r + UART_TX_BUF_SIZE 时,即读指针与写指针指向同一个区域,此时缓存区就已经满了。这个情况下,调用 sleep 等待。

uartstart 代码:

截屏2025-02-15 06.41.24.png

每次 UART 完成一个字节的发送时,它会生成一个中断。 uartintr 会调用 uartstart,后者会检查设备是否确实已经完成发送,并将缓冲区中的下一个输出字符交给设备。

因此,如果一个进程向控制台写入多个字节,通常第一个字节会通过 uartputcuartstart 的调用发送出去,而剩余的缓冲字节则会在传输完成中断到达时,由 uartintruartstart 的调用发送出去。

解耦的意义:

  • 缓冲和中断机制的核心思想是将设备的 I/O 活动与进程的活动分离,使得进程不需要等待设备完成操作,从而实现并发执行。

输入场景:

  • 控制台驱动程序可以在没有进程等待读取的情况下,先将接收到的输入数据缓冲起来。
  • 后续的读取操作可以从缓冲区中获取这些数据,而无需重新等待设备。

输出场景:

  • 进程可以快速将数据写入发送缓冲区,然后继续执行其他任务,而不必等待设备逐字节地发送数据。
  • 实际的发送工作由 UART 硬件在后台逐步完成。

5.3 驱动程序中的并发性

在控制台驱动程序中,acquire 调用用于获取锁,保护数据结构免受并发访问的影响。以下是涉及的三种主要并发危险及驱动程序处理这些危险的方式:

  1. 两个进程在不同 CPU 上同时调用 consoleread

    • 如果没有锁保护,两个进程可能会同时修改或读取共享的数据结构(如 cons.buf),导致数据不一致。
    • 解决方法:通过 acquire 获取锁,确保同一时间只有一个进程可以执行 consoleread 的关键部分。
  2. 硬件中断与 consoleread 并发

    • 当一个 CPU 正在执行 consoleread 时,硬件可能触发中断(例如 UART 接收到新字符),并在同一 CPU 或其他 CPU 上执行中断处理程序 consoleintr
    • 解决方法:consolereadconsoleintr 都使用相同的锁,确保两者不会同时访问共享数据结构。
  3. 硬件中断在不同 CPU 上并发执行

    • 硬件可能在同一时间向多个 CPU 提供中断,导致多个 consoleintr 同时运行。
    • 解决方法:通过锁机制确保只有一个 consoleintr 实例可以操作共享数据结构。

此外,驱动程序还需要处理另一种并发问题:中断与等待进程的解耦

  • 当一个进程正在等待设备输入时,输入到达的中断可能在不同的进程运行时触发,甚至可能完全没有进程运行。
  • 因此,中断处理程序不能依赖当前进程的状态(如页表),也不能直接调用用户空间操作(如 copyout)。
  • 中断处理程序通常只完成少量工作(如将数据复制到缓冲区并唤醒等待的进程),而将复杂任务留给上半身代码(如 consoleread)来完成。

5.4 计时器中断

Xv6 使用定时器中断实现时间管理与进程切换,以下是其关键机制的简述:

  1. 定时器中断的设置
    • 每个 RISC-V CPU 的时钟硬件被配置为定期触发中断。
    • start.c 中,代码允许监督模式访问定时器控制寄存器,并通过设置 stimecmp 寄存器安排首次中断。
    • stimecmp 设置为当前时间值加上一个偏移量(x),以在 x 时间单位后触发中断。在 QEMU 模拟中,1000000 时间单位约为 0.1 秒。

timerinit 代码:

截屏2025-02-15 07.02.10.png

  1. 中断处理流程

    • 定时器中断通过 usertrapkerneltrapdevintr 处理,与其他设备中断类似。
    • 当定时器中断发生时,scause 寄存器的低位设为 5,devintr 检测到此情况并调用 clockintr
    • clockintr 增加全局变量 ticks,用于跟踪时间流逝(仅在一个 CPU 上递增,避免多核系统中时间过快)。
    • clockintr 唤醒在 sleep 系统调用中等待的进程,并重新设置 stimecmp 安排下一次中断。
  2. 进程切换

    • devintr 对定时器中断返回值 2,指示 kerneltrapusertrap 调用 yield,从而在可运行进程之间复用 CPU。
    • 定时器中断可能中断内核代码执行,因此 usertrap 在启用中断前会保存状态(如 sepc),确保上下文切换的安全性。
  3. 多核环境下的注意事项

    • 内核代码需考虑可能的 CPU 迁移问题,确保在多核系统中正确同步和处理上下文切换。

以上内容引用、理解与翻译自《xv6 book》,版权归属 Russ Cox, Frans Kaashoek, 和 Robert Morris,以及 Massachusetts Institute of Technology 所有。