Skip to content

Interrupts

中断、系统调用、陷阱(如页错误)共享同一套底层机制:

1. 保存当前执行上下文
    • 程序计数器(PC)
    • 寄存器状态
    • 特权级/标志位
2. 切换到内核态执行处理程序
3. 处理完成后恢复上下文返回

Where do interrupts come frome?

外部中断来自于电路板上的设备,电路板的大部多数设备都会跟它连接。

在内存的 0x8000 以下的是各种设备,设备里会触发中断,但是对于 CPU 来说,真正触发中断是 平台级中断间控制器 Platform Level Inter-interrupt Controller (PLIC) ,它是对外部设备中断的管理者。

PLIC 直接连接到 CPU 内部的核心,同时 PLIC 是可编程的,当外部设备的中断进入 PLIC 后,由 PLIC 路由进入核心,如果此时没有任何一个核心可以处理中断,那么 PLIC 会持有该中断,直到有核心可以处理它。

Driver managers device

大多数传统驱动代码确实运行在操作系统内核空间,但是现在也有很多驱动会放在内核之外

驱动通常分为上半部分和下半部分,这不是按地址空间来划分,而是按照职责划分
一般下半部分是中断处理程序,如果启用了中断, CPU 访问中断时运行代码就是底部的代码,它也没有自己的上下文
上半部分是用户进程或者内核调用的,提供 read/write 接口
通常还会有一些队列用来存储数据,根据需要,上半部分和下半部分都有向这个队列写入或取出数据,这样会实现解耦

上半部分通常是软件主动调用的,下半部分是硬件触发中断, CPU 响应执行的

驱动代码比内核代码量大也是比较常见的

Programming device

UART(Universal Asynchronous Receiver/Transmitter,通用异步收发器) 是计算机内部的一个硬件模块,负责在 CPU(并行数据) 和 外部设备(串行数据) 之间进行 “并串转换”与“异步通信”。

Case study (Qwen3.6-Plus)

屏幕上出现 $ ls 这中间发生了什么?

  • Shell 打印提示符 $ (输出路径)

      [Shell 进程] 启动,进入主循环
         │
         ▼ 调用 printf("$ ")  → 底层调用 write(1, "$ ", 2)
         │
         ▼ 系统调用陷入内核 → sys_write() → 遍历字符 '$' 和 ' '
         │
         ▼ 内核控制台输出路径:
         │   1. 调用 consputc('$') → uartputc('$')
         │   2. '$' 被塞入 uart_tx_buf(输出缓冲区)
         │   3. uartstart() 将 '$' 写入 UART THR 寄存器 → 立即返回 ✅
         │   4. 同上处理空格 ' '
         │
         📡 UART 硬件开始异步发送 "$ "(约 200μs)
         │
         🖥️ 宿主终端接收到 ASCII 码,渲染出 "$ "
         │
         ▼ Shell 继续执行 → 调用 read(0, buf, 128) 等待用户输入
         │
         🔒 read() 发现缓冲区无完整行 → 调用 sleep(&cons) → Shell 挂起,让出 CPU
    

    此时屏幕状态:$ (光标在空格后闪烁)
    Shell 状态:正在 sleep,不占用 CPU

  • 用户敲击 l(输入 + 回显路径)

      [用户键盘] 按下 'l'
         │
         ▼ QEMU 捕获按键 → 写入仿真 UART0 的 RX FIFO
         │
         ▼ UART 硬件触发“接收完成”中断 → PLIC → CPU
         │
         ▼ 陷入内核 → devintr() → uartintr()
         │   读取 UART RHR 寄存器 → 拿到字符 'l'
         │   调用 consoleintr('l')
         │
         ▼ consoleintr('l') 核心逻辑:
         │   1. 将 'l' 存入 cons.buf(供后续 read() 使用)
         │   2. ⭐ 调用 consputc('l') → 回显到屏幕!
         │      → uartputc('l') → 塞入 uart_tx_buf → uartstart() 发走
         │   3. 中断返回,CPU 恢复执行(可能切到其他进程)
         │
         📡 UART 异步发送 'l'
         🖥️ 终端渲染 'l'
    

    ✅ 此时屏幕状态:$ l ✅ 内核缓冲区:cons.buf 中有 l,但 cons.nread == 0(未回车) ✅ Shell:仍在 sleep,完全不知道用户已经按了键

  • 用户敲击 s(同上)

      [用户键盘] 按下 's'
         │
         ▼ UART RX 中断 → uartintr() → consoleintr('s')
         │   1. 存入 cons.buf
         │   2. consputc('s') → uartputc('s') → 发送
         │
         🖥️ 终端渲染 's'
    

    ✅ 此时屏幕状态:$ ls

Interrupts(HW)

SIE1 ,也就是开中断状态下,用户进程(如 shell)在用户态(U-Mode)运行,键盘产生一个字符输入时

硬件自动执行的 5 个关键步骤:

  1. 立即关闭 S 模式中断(SIE ← 0)
    • 防止当前中断处理过程中被其他 S 模式中断嵌套打断(避免栈溢出或状态混乱)
    • 这仅屏蔽 新的 S 模式中断,M 模式中断仍可抢占
    • 这是硬件 自动完成
  2. 保存"被中断的位置"到 SEPC
    • 将来通过 sret 返回时,硬件会用 SEPC 恢复 PC
  3. 保存"被中断时的特权级"到 SPP
    • sret 返回时,硬件根据 SPP 决定恢复到用户态还是内核态
  4. 切换到 Supervisor 模式 + 更新 SPIE
  5. 跳转到中断入口 stvec

Interrupts and concurrency

Qwen3.6-Plus

  1. 设备和 CPU 是并行运行的
    • 现象:CPU 和 UART(串口)等外设是独立硬件,它们同时运行。例如 CPU 命令 UART 发送一个字符后,CPU 不会干等,而是继续执行其他代码(比如回到 Shell 提示符)
    • 生产者-消费者模式:CPU 向 UART 的发送缓冲区写数据(生产者),UART 硬件按自己的时钟节奏把数据发出去(消费者)。反之,接收数据时 UART 是生产者,CPU 是消费者。这种模式在内核驱动中极其常见
  2. 中断会“强行打断”当前执行流
    • 用户态被打断:影响不大。中断返回时,内核会恢复寄存器、程序计数器(PC),用户程序从被中断的指令继续执行,行为可预测
    • 内核态被打断:这才是难点。如果 CPU 正在执行内核代码,突然被中断打断,意味着内核代码也不是严格顺序执行的。两条相邻的内核指令之间,可能插入了一个中断处理程序
    • 由于中断可能随时插入,内核在执行某些关键代码段(如更新全局状态、修改设备寄存器)时,必须保证“原子性”(不可被分割),解决办法是在关键代码段前关闭中断(disable interrupts,执行完后再开启中断(enable interrupts。这是最基础的并发控制手段,但代价是会丢失或延迟中断。
  3. 设备驱动的“上半部”与“下半部”并发
    • 上半部(Top Half / 同步路径):用户进程通过系统调用进入内核,执行驱动的写/读逻辑(例如 Shell 调用 write 打印空格)
    • 下半部(Bottom Half / 异步路径):硬件完成操作或收到数据时触发中断,CPU 跳转到中断处理程序执行
    • 并发风险:在多核 CPU 上,一个核正在执行驱动的同步代码(操作发送队列),另一个核可能同时收到 UART 中断并执行中断处理程序(也操作同一个队列)。两者访问同一块共享内存/缓冲区,若不保护就会发生数据覆盖、指针错乱等 race condition ,解决办法是引入锁,但是锁是后面内容,因此不会细说

由于设备和多核 CPU 是在硬件层面是并行的,内核必须通过生产者/消费者模型和锁来保证共享数据的一致性和操作的原子性

Producer/consumer

驱动中的队列,有两个指针,一个写指针作为生产者,一个读指针作为消费者,所有的核心都与他们交互

c
// kernel/uart.c
char uart_tx_buf[UART_TX_BUF_SIZE];
int uart_tx_w; // write next to uart_tx_buf[uart_tx_w++]
int uart_tx_r; // read next from uart_tx_buf[uar_tx_r++]

如果它们指向位置是一样的,则说明缓冲区是空的

如果写指针的下一个指针是读指针指向的位置,则说明缓冲区已经满了,当缓冲区满了的时候,就会调用 sleep ,让生产者进程停止

c
// kernel/uart.c
void
uartputc(int c)
{
  acquire(&uart_tx_lock);

  if(panicked){
    for(;;)
      ;
  }

  while(1){
    if(((uart_tx_w + 1) % UART_TX_BUF_SIZE) == uart_tx_r){
      // buffer is full.
      // wait for uartstart() to open up space in the buffer.
      sleep(&uart_tx_r, &uart_tx_lock);
    } else {
      uart_tx_buf[uart_tx_w] = c;
      uart_tx_w = (uart_tx_w + 1) % UART_TX_BUF_SIZE;
      uartstart();
      release(&uart_tx_lock);
      return;
    }
  }
}

当消费者消费掉一个数据后,会立即调用 wakeup 将生产者唤醒

c
// kernel/uart.c
void
uartstart()
{
  while(1){
    if(uart_tx_w == uart_tx_r){
      // transmit buffer is empty.
      return;
    }
    
    if((ReadReg(LSR) & LSR_TX_IDLE) == 0){
      // the UART transmit holding register is full,
      // so we cannot give it another byte.
      // it will interrupt when it's ready for a new byte.
      return;
    }
    
    int c = uart_tx_buf[uart_tx_r];
    uart_tx_r = (uart_tx_r + 1) % UART_TX_BUF_SIZE;
    
    // maybe uartputc() is waiting for space in the buffer.
    wakeup(&uart_tx_r);
    
    WriteReg(THR, c);
  }
}

当 shell 打印完 $ 后,会进入睡眠。(如果是其他进程在接收用户输入,则是其他程序进入睡眠)

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

  target = n;
  acquire(&cons.lock);
  while(n > 0){
    // wait until interrupt handler has put some
    // input into cons.buffer.
    while(cons.r == cons.w){
      if(myproc()->killed){
        release(&cons.lock);
        return -1;
      }
      sleep(&cons.r, &cons.lock);
    }

如果键盘键入了一个字符,例如 ll 会被发送到 UART 芯片,通过 PLIC 到达某个核心,核心接受中断,然后调用 uratintr() 取到字符,再调用 consoleintr() 传递字符 ,其中 consoleintr() 如果接收到 \n 会唤醒 shell

c
// kernel/uart.c
void
uartintr(void)
{
  // read and process incoming characters.
  while(1){
    int c = uartgetc();
    if(c == -1)
      break;
    consoleintr(c);
  }

// kernel/console.c
void
consoleintr(int c)
{
  acquire(&cons.lock);

  switch(c){
  case C('P'):  // Print process list.
    procdump();
    break;
  case C('U'):  // Kill line.
    while(cons.e != cons.w &&
          cons.buf[(cons.e-1) % INPUT_BUF] != '\n'){
      cons.e--;
      consputc(BACKSPACE);
    }
    break;
  case C('H'): // Backspace
  case '\x7f':
    if(cons.e != cons.w){
      cons.e--;
      consputc(BACKSPACE);
    }
    break;
  default:
    if(c != 0 && cons.e-cons.r < INPUT_BUF){
      c = (c == '\r') ? '\n' : c;

      // echo back to the user.
      consputc(c);

      // store for consumption by consoleread().
      cons.buf[cons.e++ % INPUT_BUF] = c;

      if(c == '\n' || c == C('D') || cons.e == cons.r+INPUT_BUF){
        // wake up consoleread() if a whole line (or end-of-file)
        // has arrived.
        cons.w = cons.e;
        wakeup(&cons.r);
      }
    }
    break;
  }

Interrupt evolution

中断不是永远最优解。

早期中断快、设备慢 → 中断是高效方案  
现代中断开销大、设备极快 → 每包一中断会压垮 CPU  

早期计算机 中断开销 << 数据间隔 → 中断模式高效

现代高性能设备时代 中断开销 >> 数据间隔 -> 中断模式低效

Qwen3.6-Plus
假设:
- 最小以太网包: 64 字节
- 线速: 1 Gbps = 125 MB/s
- 包到达率: 125 MB/s ÷ 64 B ≈ **1.95 百万包/秒**
- 即: 每包间隔 ≈ **0.5 μs**

中断处理开销(保守估计):
- 保存/恢复寄存器: ~50 指令
- 缓存失效 + 流水线刷新: ~100 指令
- 中断处理程序本身: ~200 指令
- 总计: ~350 指令 @ 3GHz ≈ **0.12 μs**

问题:
- 每 0.5 μs 来一个包,中断开销 0.12 μs
- CPU 24% 时间花在「进/出中断」,而非处理数据!
- 如果多核 + 多队列 + 小包,开销进一步放大

Polling

轮询(Polling)

CPU 只读取寄存器,检查是否有数据,跳过中断做的准备,自旋检查设备是否有数据输入,这样缺点也很明显,会一直占用 CPU ,不能运行其他进程

对于设备快的情况,中断的开销非常大
相对于设备(生产者)慢的情况,轮询会导致 CPU 空转的时间多,非常浪费

在现代操作系统中,通常会选择根据负载动态切换

中断的架构

PLIC 采用的是 “硬件主动通知 + CPU 主动认领” 的混合机制

  1. PLIC 主动向目标 CPU 发送外部中断请求
  2. CPU 进入中断服务程序后,显示的读取中断信号
  3. 中断处理完毕后,CPU 必须将刚才认领的中断号写回同一个寄存器中,否则 PLIC 会认为该中断仍在处理中,不仅不会调度其他同优先级中断,还可能阻塞整个中断流。