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)
当 SIE 为 1 ,也就是开中断状态下,用户进程(如 shell)在用户态(U-Mode)运行,键盘产生一个字符输入时
硬件自动执行的 5 个关键步骤:
- 立即关闭 S 模式中断(SIE ← 0)
- 防止当前中断处理过程中被其他 S 模式中断嵌套打断(避免栈溢出或状态混乱)
- 这仅屏蔽 新的 S 模式中断,M 模式中断仍可抢占
- 这是硬件 自动完成 的
- 保存"被中断的位置"到 SEPC
- 将来通过
sret返回时,硬件会用SEPC恢复PC
- 将来通过
- 保存"被中断时的特权级"到
SPPsret返回时,硬件根据SPP决定恢复到用户态还是内核态
- 切换到 Supervisor 模式 + 更新
SPIE - 跳转到中断入口
stvec
Interrupts and concurrency
Qwen3.6-Plus
- 设备和 CPU 是并行运行的
- 现象:CPU 和 UART(串口)等外设是独立硬件,它们同时运行。例如 CPU 命令 UART 发送一个字符后,CPU 不会干等,而是继续执行其他代码(比如回到 Shell 提示符)
- 生产者-消费者模式:CPU 向 UART 的发送缓冲区写数据(生产者),UART 硬件按自己的时钟节奏把数据发出去(消费者)。反之,接收数据时 UART 是生产者,CPU 是消费者。这种模式在内核驱动中极其常见
- 中断会“强行打断”当前执行流
- 用户态被打断:影响不大。中断返回时,内核会恢复寄存器、程序计数器(PC),用户程序从被中断的指令继续执行,行为可预测
- 内核态被打断:这才是难点。如果 CPU 正在执行内核代码,突然被中断打断,意味着内核代码也不是严格顺序执行的。两条相邻的内核指令之间,可能插入了一个中断处理程序
- 由于中断可能随时插入,内核在执行某些关键代码段(如更新全局状态、修改设备寄存器)时,必须保证“原子性”(不可被分割),解决办法是在关键代码段前关闭中断(
disable interrupts),执行完后再开启中断(enable interrupts)。这是最基础的并发控制手段,但代价是会丢失或延迟中断。
- 设备驱动的“上半部”与“下半部”并发
- 上半部(Top Half / 同步路径):用户进程通过系统调用进入内核,执行驱动的写/读逻辑(例如 Shell 调用
write打印空格) - 下半部(Bottom Half / 异步路径):硬件完成操作或收到数据时触发中断,CPU 跳转到中断处理程序执行
- 并发风险:在多核 CPU 上,一个核正在执行驱动的同步代码(操作发送队列),另一个核可能同时收到 UART 中断并执行中断处理程序(也操作同一个队列)。两者访问同一块共享内存/缓冲区,若不保护就会发生数据覆盖、指针错乱等 race condition ,解决办法是引入锁,但是锁是后面内容,因此不会细说
- 上半部(Top Half / 同步路径):用户进程通过系统调用进入内核,执行驱动的写/读逻辑(例如 Shell 调用
由于设备和多核 CPU 是在硬件层面是并行的,内核必须通过生产者/消费者模型和锁来保证共享数据的一致性和操作的原子性
Producer/consumer
驱动中的队列,有两个指针,一个写指针作为生产者,一个读指针作为消费者,所有的核心都与他们交互
// 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 ,让生产者进程停止
// 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 将生产者唤醒
// 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 打印完 $ 后,会进入睡眠。(如果是其他进程在接收用户输入,则是其他程序进入睡眠)
// 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);
}如果键盘键入了一个字符,例如 l , l 会被发送到 UART 芯片,通过 PLIC 到达某个核心,核心接受中断,然后调用 uratintr() 取到字符,再调用 consoleintr() 传递字符 ,其中 consoleintr() 如果接收到 \n 会唤醒 shell
// 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
早期计算机 中断开销 << 数据间隔 → 中断模式高效
现代高性能设备时代 中断开销 >> 数据间隔 -> 中断模式低效
假设:
- 最小以太网包: 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 主动认领” 的混合机制
- PLIC 主动向目标 CPU 发送外部中断请求
- CPU 进入中断服务程序后,显示的读取中断信号
- 中断处理完毕后,CPU 必须将刚才认领的中断号写回同一个寄存器中,否则 PLIC 会认为该中断仍在处理中,不仅不会调度其他同优先级中断,还可能阻塞整个中断流。