Chapter5

Interrupts and device drivers

驱动程序driver是操作系统中管理特定设备的代码。它配置设备硬件,指示设备执行操作,处理产生的中断,并与可能正在等待设备I/O的进程交互。

需要操作系统关注的设备通常被配置为可以生成中断,这是trap的一种。kernel trap处理代码会识别设备何时引发了中断,并调用驱动程序的中断处理程序。xv6中,这种调度发生在devintr()函数。

在设备驱动程序中,代码通常分为两个部分执行:一个是在进程的内核线程中运行的Top Half,另一个是在中断时执行的Bottom Half。顶部部分通过系统调用来调用,这些系统调用希望设备执行I/O操作,然后等待操作完成并引发中断。底部部分为中断处理程序,确定已经完成的操作,如果合适则唤醒等待的进程,并告诉硬件开始处理等待着的下一个操作。


Code:Console input

代码:控制台输入

控制台驱动程序console.c是驱动程序结构的一个示例。它通过连接到 RISC-V 的 UART 串口硬件接受输入的字符。控制台驱动程序一次累积一行输入,用consoleintr处理特殊输入字符。用户进程使用read系统调用从控制台获取输入行,驱动程序通过 QEMU 模拟的 16550芯片与 UART 硬件通信。

UART

UART 硬件对软件来说表现为一组内存映射的控制寄存器。RISC-V 将一些物理地址映射到 UART 设备,因此 load 和 store 会直接与设备硬件交互,而非 RAM 。

// qemu puts UART registers here in physical memory.
#define UART0 0x10000000L
#define UART0_IRQ 10

UART 的内存映射地址从0x10000000开始。其中有一些控制寄存器,内存映射地址计算为#define Reg(reg) ((volatile unsigned char *)(UART0 + (reg)))

// 寄存器定义 uart.c
#define RHR 0    // 接收保持寄存器(读取输入字节)
#define THR 0    // 发送保持寄存器(写入输出字节)
#define IER 1    // 中断使能寄存器
#define IER_RX_ENABLE (1<<0)  // 接收中断使能位
#define IER_TX_ENABLE (1<<1)  // 发送中断使能位
#define FCR 2    // FIFO控制寄存器
#define FCR_FIFO_ENABLE (1<<0)  // 启用FIFO缓冲
#define FCR_FIFO_CLEAR (3<<1)  // 清除FIFO内容
#define LCR 3    // 线路控制寄存器
#define LCR_EIGHT_BITS (3<<0)  // 8位数据位配置
#define LCR_BAUD_LATCH (1<<7)  // 波特率设置模式位
#define LSR 5    // 线路状态寄存器
#define LSR_TX_IDLE (1<<5)  // 发送空闲标志(THR可接受新字符)

举例来说,LSR 寄存器包含指示是否有输入字符等待读取的位,这些字符从 RHR 寄存器读取,每读取一个字符,硬件会将其从等待字符的内部 FIFO 中删除,并在 FIFO 为空时清除 LSR 中的这个位。

int
uartgetc(void)
{
  if(ReadReg(LSR) & 0x01){
    // input data is ready.
    return ReadReg(RHR);
  } else {
    return -1;
  }
}

xv6main调用kernel/console.c/consoleinit()来初始化 UART 硬件。UART 在接收到每个输入字节时生成接收中断,在完成发送每个输出字节时生成发送中断。

void
uartinit(void)
{
  // 1. 禁用中断
  WriteReg(IER, 0x00);

  // 2. 设置波特率
  WriteReg(LCR, LCR_BAUD_LATCH);     // 进入波特率设置模式
  WriteReg(0, 0x03);                // LSB 38.4K baud
  WriteReg(1, 0x00);                // MSB

  // 3. 设置数据格式
  WriteReg(LCR, LCR_EIGHT_BITS);     // 8位数据位,无校验

  // 4. 初始化FIFO
  WriteReg(FCR, FCR_FIFO_ENABLE | FCR_FIFO_CLEAR); // 启用并清空FIFO缓冲

  // 5. 启用中断
  WriteReg(IER, IER_TX_ENABLE | IER_RX_ENABLE); // 允许收发中断

  // 6. 初始化发送缓冲区锁
  initlock(&uart_tx_lock, "uart");
}

控制台输入

xv6 shell通过文件描述符从控制台读取数据,read系统调用通过内核会到达consoleread。它等待输入通过中断到达并从缓冲区cons.buf读取字符,将输入复制到用户空间,并在整行到达后返回用户进程。

int
consoleread(int user_dst, uint64 dst, int n)
{
  uint target;
  int c;
  char cbuf;

  target = n;                 // 保存原始请求读取长度
  acquire(&cons.lock);        // 获取控制台锁

  // 主循环:处理每个字符读取
  while(n > 0){
    // 等待输入缓冲区有数据(中断处理填充)
    while(cons.r == cons.w){
      if(killed(myproc())){   // 检查进程是否被终止
        release(&cons.lock);
        return -1;           // 返回错误
      }
      sleep(&cons.r, &cons.lock); // 睡眠等待数据到达
    }

    // 从循环缓冲区读取字符
    c = cons.buf[cons.r++ % INPUT_BUF_SIZE]; 

    // 处理Ctrl+D(EOF)
    if(c == C('D')){ 
      if(n < target){         // 保留EOF标记供下次读取
        cons.r--;             // 回退读指针
      }
      break;                  // 结束读取
    }

    // 复制字符到用户空间
    cbuf = c;
    if(either_copyout(user_dst, dst, &cbuf, 1) == -1)
      break;                  // 用户地址无效时终止

    dst++;  // 移动目标指针
    --n;     // 递减剩余读取计数

    // 遇到换行符提前返回
    if(c == '\n')
      break;
  }

  release(&cons.lock); // 释放控制台锁
  return target - n;    // 返回实际读取字节数
}

当用户输入一个字符时,UART 硬件请求 RISC-V 引发一个中断,激活trap handler程序,它调用devintr,查看 scause 寄存器,发现中断来自外部设备,然后请求 PLIC[1] 硬件单元得知哪个设备引发中断。如果是 UART,decintr调用uartintr

void
uartintr(void)
{
  // 输入处理循环
  while(1){
    int c = uartgetc();      // 从UART读取字符
    if(c == -1)              // 无更多输入时退出
      break;
    consoleintr(c);          // 将字符交给控制台处理
  }

  // 输出处理部分
  acquire(&uart_tx_lock);    // 获取发送缓冲区锁
  uartstart();               // 启动发送流程
  release(&uart_tx_lock);    // 释放锁
}

这个函数读取正在等待的输入字符并传递给consoleintr

consoleintr的任务是将输入字符累积到cons.buf中,直到一整行到达。这个函数里面有一下退格键和其他字符的特别处理,当换行符到达,它会唤醒等待的consoleread

consoleread在检验cons.buf有一整行数据后,会将其复制到用户空间,并通过系统调用机制返回用户空间。


Code:Console output

代码:控制台输出

对连接到控制台的文件描述符进行write系统调用最终会到达uartputc

设备驱动程序维护一个输出缓冲区uart_tx_buf,使得write进程不必等待 UART 完成发送。

// the transmit output buffer.
struct spinlock uart_tx_lock;
#define UART_TX_BUF_SIZE 32
char uart_tx_buf[UART_TX_BUF_SIZE];
uint64 uart_tx_w; // write next to uart_tx_buf[uart_tx_w % UART_TX_BUF_SIZE]
uint64 uart_tx_r; // read next from uart_tx_buf[uart_tx_r % UART_TX_BUF_SIZE]

uartputc将每个字符加到缓冲区,然后调用uartstart启动设备传输。

void
uartputc(int c)
{
  acquire(&uart_tx_lock);  // 获取发送缓冲区锁

  // 系统panic时进入死循环
  if(panicked){
    for(;;)
      ;
  }

  // 等待发送缓冲区空间(32字节循环缓冲)
  while(uart_tx_w == uart_tx_r + UART_TX_BUF_SIZE){
    sleep(&uart_tx_r, &uart_tx_lock); // 阻塞等待空间释放
  }

  // 写入缓冲区并更新写指针
  uart_tx_buf[uart_tx_w % UART_TX_BUF_SIZE] = c;
  uart_tx_w += 1;

  // 尝试立即发送缓冲内容
  uartstart();

  release(&uart_tx_lock);  // 释放锁
}

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

void
uartstart()
{
  while(1){
    if(uart_tx_w == uart_tx_r){        // 检查发送缓冲区是否为空
      ReadReg(ISR);                    // 读取中断状态寄存器(清除中断标志)
      return;                          // 退出函数
    }

    if((ReadReg(LSR) & LSR_TX_IDLE) == 0){ // 检查发送保持寄存器是否空闲
      return;                          // 等待下次中断触发
    }

    int c = uart_tx_buf[uart_tx_r % UART_TX_BUF_SIZE]; // 从环形缓冲区取字符
    uart_tx_r += 1;                    // 移动读指针

    wakeup(&uart_tx_r);                // 唤醒可能阻塞的uartputc()进程

    WriteReg(THR, c);                  // 将字符写入发送保持寄存器
  }
}

如果进程向控制台写入多个字节,通常第一个字节由uartputc调用uartstart发送,其余的缓冲区字节在传输完成,触发中断时由uartintr调用uartstart发送。

通过缓冲区和中断,实现了设备活动和进程活动的解耦。二者可以并发来提高性能,这种思想也被称为I/O concurrency


Concurrency in drivers

驱动程序中的并发性

concolereadconsoleintr中都有acquire语句,这些调用获取了一个锁,用于保护控制台驱动程序的数据结构免受并发访问。这里有三种并发危险:不同 CPU 上的两个进 程可能同时调用consoleread;硬件可能在一个 CPU 已经在执行consoleread时要求该 CPU 传递一个控制台(实际上是 UART)中断;以及硬件可能在consoleread执行时在另一个 CPU 上传递控制台中断。

并发性在驱动程序中需要注意的另一个例子是,一个进程正在等待设备输入,但输入到达的中断信号可能在不同的进程运行时到达。因此,中断处理程序不允许考虑当前进程的状态,必须在独立于当前进程的上下文运行。因此中断处理程序通常只做较少的工作,并唤醒top half来完成其他工作。


Timer interrupts

定时器中断

xv6使用定时器中断来维护其时钟,并使其能在计算密集型进程间切换。切换在usertrapkerneltrap中用yield实现。xv6对连接到RISC-V CPU 的时钟硬件编程,使其定期中断每个 CPU。

定时器中断必须在machine mode下处理。这种模式在没有分页的情况下执行,使用一组独立的控制寄存器。因此定时器中断的处理机制与其他的trap处理机制完全独立。

不是太懂,代码中是直接把所有异常和中断都委托给了 supervisor mode。

// delegate all interrupts and exceptions to supervisor mode.
w_medeleg(0xffff);
w_mideleg(0xffff);

start.c/timerinit()中配置时钟中断。部分配置是对 CLINT (Core Local Interruptor) 硬件进行编程。

void timerinit() {
  // 启用监督模式定时器中断
  w_mie(r_mie() | MIE_STIE); // 设置 mie.STIE 位

  // 启用 stimecmp 扩展
  w_menvcfg(r_menvcfg() | (1L << 63)); 

  w_mcounteren(r_mcounteren() | 2);

  // 设置首次中断触发时间(当前时间 + 1000000 ticks)
  w_stimecmp(r_time() + 1000000); 
}

CLINT 通过比较stimecmptime寄存器来生成中断,当time >= stimecmp时触发。

另外部分是保存寄存器,在kernelvec.S中。

定时器中断可以在任何时候发送,内核无法禁用。因此,定时器中断处理程序必须保证不会干扰被中断的内核代码。解决方案是让处理程序请求 RISC-V 引发一个 software interrupt并立即返回,RISC-V通过正常的trap机制将software传递给内核,并允许内核禁用它们。

void clockintr() {
  // 更新时间计数器
  if(cpuid() == 0){
    acquire(&tickslock);
    ticks++;
    wakeup(&ticks);  // 唤醒等待进程
    release(&tickslock);
  }

  // 设置下次中断(10ms 后)
  w_stimecmp(r_time() + 1000000);
}

Real world

xv6允许在执行内核代码和用户程序时发送设备和定时器中断。定时器中断强制从处理程序中进行线程切换。

UART 驱动程序通过读取控制寄存器一次检索一字节的数据,这种模式称为programmed I/O,但这种模式速度太慢。需要高速传输大量数据的设备通常使用直接内存访问(DMA),DMA 设备硬件直接将传入数据写入 RAM,并从中读取传出数据。其驱动程序会写入控制寄存器告诉设备处理准备好的数据。

中断的 CPU 开销很高,因此高速设备会用一些技巧来减少中断的需求。一个技巧是为整个批量的传入或传出请求引发单个中断。另一个技巧是驱动程序完全禁用中断,并定期检查设备以查看是否需要关注。这种技术称为polling。有一些驱动会在 polling 和 interrupts 之间动态切换。

Last updated