Interrupts and device driver(中断和设备驱动)
- [一、console input](#一、console input)
-
- [1、控制台驱动 console driver](#1、控制台驱动 console driver)
- [2、UART 硬件如何暴露给软件](#2、UART 硬件如何暴露给软件)
- [3、xv6 初始化 UART](#3、xv6 初始化 UART)
- [4、shell 如何读输入](#4、shell 如何读输入)
- 5、用户敲键后发生什么
- 6、一整行输入完成后
- [二、 Console output](#二、 Console output)
- [三、Concurrency in drivers](#三、Concurrency in drivers)
- [四、Timer interrupts](#四、Timer interrupts)
一、console input
xv6 的控制台驱动程序是怎样通过 UART 硬件完成输入输出的,尤其是用户输入一行命令后,这些字符如何一路传到 shell。
1、控制台驱动 console driver
kernel/console.c 里的 console driver 是一个简单的设备驱动例子。它负责处理控制台输入输出。
c
//
// Console input and output, to the uart.
// Reads are line at a time.
// Implements special input characters:
// newline -- end of line
// control-h -- backspace
// control-u -- kill line
// control-d -- end of file
// control-p -- print process list
//
#include <stdarg.h>
#include "types.h"
#include "param.h"
#include "spinlock.h"
#include "sleeplock.h"
#include "fs.h"
#include "file.h"
#include "memlayout.h"
#include "riscv.h"
#include "defs.h"
#include "proc.h"
#define BACKSPACE 0x100
#define C(x) ((x)-'@') // Control-x
//
// send one character to the uart.
// called by printf, and to echo input characters,
// but not from write().
//
void
consputc(int c)
{
if(c == BACKSPACE){
// if the user typed backspace, overwrite with a space.
uartputc_sync('\b'); uartputc_sync(' '); uartputc_sync('\b');
} else {
uartputc_sync(c);
}
}
struct {
struct spinlock lock;
// input
#define INPUT_BUF 128
char buf[INPUT_BUF];
uint r; // Read index
uint w; // Write index
uint e; // Edit index
} cons;
//
// user write()s to the console go here.
//
int
consolewrite(int user_src, uint64 src, int n)
{
int i;
for(i = 0; i < n; i++){
char c;
if(either_copyin(&c, user_src, src+i, 1) == -1)
break;
uartputc(c);
}
return i;
}
//
// user read()s from the console go here.
// copy (up to) a whole input line to dst.
// user_dist indicates whether dst is a user
// or kernel address.
//
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);
}
c = cons.buf[cons.r++ % INPUT_BUF];
if(c == C('D')){ // end-of-file
if(n < target){
// Save ^D for next time, to make sure
// caller gets a 0-byte result.
cons.r--;
}
break;
}
// copy the input byte to the user-space buffer.
cbuf = c;
if(either_copyout(user_dst, dst, &cbuf, 1) == -1)
break;
dst++;
--n;
if(c == '\n'){
// a whole line has arrived, return to
// the user-level read().
break;
}
}
release(&cons.lock);
return target - n;
}
//
// the console input interrupt handler.
// uartintr() calls this for input character.
// do erase/kill processing, append to cons.buf,
// wake up consoleread() if a whole line has arrived.
//
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;
}
release(&cons.lock);
}
void
consoleinit(void)
{
initlock(&cons.lock, "cons"); // 控制台是一个全局设备,可能被多个cpu访问所以需要锁来保护
uartinit(); // 初始化uart设备
// connect read and write system calls
// to consoleread and consolewrite.
devsw[CONSOLE].read = consoleread; // 把控制台设备的读写函数注册到设备表 devsw 中。
devsw[CONSOLE].write = consolewrite;
}
在 xv6/QEMU 中,你在键盘上输入的字符,并不是直接交给 shell,而是先通过 QEMU 模拟的 UART 硬件进入 xv6。

2、UART 硬件如何暴露给软件
UART 在软件看来是一组 内存映射寄存器。
也就是说,某些物理地址不是普通内存,而是连接到 UART 硬件。xv6 对这些地址执行读写操作,实际上就是在读写 UART 设备。
比如:
UART0 = 0x10000000
LSR:状态寄存器,告诉软件有没有字符可读
RHR:接收寄存器,软件从这里读输入字符
THR:发送寄存器,软件往这里写输出字符
LSR:有没有数据?
RHR:读取收到的数据
THR:写入要发送的数据
3、xv6 初始化 UART
xv6 启动时,main 会调用 consoleinit()。
consoleinit() 又会调用 uartinit(),完成 UART 初始化,比如:
设置波特率
设置字符格式
启用 FIFO
开启接收中断
开启发送完成中断
注册 console 的 read / write 函数
所以之后用户程序调用 read() 读控制台时,最终会进入:consoleread()
c
void
uartinit(void)
{
// disable interrupts.
WriteReg(IER, 0x00);
// special mode to set baud rate.
WriteReg(LCR, LCR_BAUD_LATCH);
// LSB for baud rate of 38.4K.
WriteReg(0, 0x03);
// MSB for baud rate of 38.4K.
WriteReg(1, 0x00);
// leave set-baud mode,
// and set word length to 8 bits, no parity.
WriteReg(LCR, LCR_EIGHT_BITS);
// reset and enable FIFOs.
WriteReg(FCR, FCR_FIFO_ENABLE | FCR_FIFO_CLEAR);
// enable transmit and receive interrupts.
WriteReg(IER, IER_TX_ENABLE | IER_RX_ENABLE);
initlock(&uart_tx_lock, "uart");
}
4、shell 如何读输入
shell 是用户程序,它通过文件描述符读控制台:
c
read(fd, buf, n);
这个 read 系统调用最终会进入内核的 consoleread()。
但是 consoleread() 不是一有一个字符就返回,而是通常等待用户输入完整一行,也就是直到遇到换行符 \n。
如果用户还没按回车,consoleread() 会调用 sleep() 睡眠等待。
5、用户敲键后发生什么
当用户输入一个字符时:

6、一整行输入完成后
当用户按下回车,consoleintr() 发现一整行已经完成,于是唤醒正在等待的 consoleread()。

二、 Console output
xv6 的 console/UART 驱动通过 缓冲区 + 中断,把进程的 read/write 和 UART 硬件的慢速收发解耦,实现 I/O 并发。
text
console.c:控制台层
- 处理 read/write 的控制台语义
- 输入按行缓冲
- 处理退格、Ctrl-U、换行
- 使用 cons.buf
uart.c:UART 驱动层
- 直接操作 UART 寄存器
- 从 UART 读字符
- 向 UART 写字符
- 使用 uart_tx_buf
UART 硬件:
- RHR:读收到的字符
- THR:写要发送的字符
- LSR:查看状态

uartputc 不等 UART 发完,只把字符放进缓冲区
uartstart 负责真正把下一个字符交给 UART
发送完成中断推动后续字符继续发送
核心的设计思想:
text
buffering + interrupts = decoupling
缓冲区:让数据可以先存起来
中断:让设备有事时通知 CPU
解耦:进程不用一直等设备
xv6 的 console driver 建立在 UART driver 之上。输入时,UART 中断触发 uartintr 读取字符,交给 consoleintr 放入 cons.buf,直到一整行完成后唤醒 consoleread,再复制到用户空间。输出时,write 最终到达 uartputc,字符先进入 uart_tx_buf,uartstart 启动发送,之后每次 UART 发送完成中断都会继续发送下一个字符。这体现了通过缓冲和中断实现设备活动与进程活动解耦,也就是 I/O concurrency。
三、Concurrency in drivers
consoleread 和 consoleintr 中都有对 acquire 的调用。这些调用会获取一把锁,用来保护控制台驱动的数据结构,防止它们被并发访问。
- 两个进程可能在不同 CPU 上同时调用 consoleread;
- 当某个 CPU 已经在执行 consoleread 时,硬件可能要求这个 CPU 处理一个控制台中断,实际上是 UART 中断;
- 当 consoleread 正在某个 CPU 上执行时,硬件可能在另一个 CPU 上触发控制台中断。
驱动程序中还有另一种需要小心处理并发的情况:某个进程可能正在等待来自设备的输入,但表示输入到达的中断,可能发生在另一个进程正在运行时,甚至在没有任何进程运行时。因此,中断处理程序不能假设自己打断的是哪个进程或哪段代码。例如,中断处理程序不能安全地使用当前进程的页表来调用 copyout。中断处理程序通常只做很少的工作,例如只是把输入数据复制到缓冲区,然后唤醒 top-half 代码去完成剩下的工作。
text
锁:保护驱动共享数据,防止并发访问
中断处理程序:不能依赖当前进程
正确模式:中断放入内核缓冲区 + wakeup,进程醒来后再 copyout
四、Timer interrupts
xv6 使用定时器中断来维护系统时钟,并使系统能够在多个计算密集型进程之间切换;usertrap 和 kerneltrap 中的 yield 调用会导致这种切换。定时器中断来自连接到每个 RISC-V CPU 的时钟硬件。xv6 会配置这个时钟硬件,使它周期性地中断每个 CPU。
RISC-V 要求定时器中断必须在 machine mode 下处理,而不是在 supervisor mode 下处理。RISC-V 的 machine mode 执行时没有分页机制,并且使用一套单独的控制寄存器,所以在 machine mode 中运行普通的 xv6 内核代码并不实际。因此,xv6 对定时器中断的处理,与前面介绍的普通 trap 机制是分开的。
在 main 运行之前,start.c 中以 machine mode 执行的代码会设置定时器中断(kernel/start.c:62)。其中一部分工作是配置 CLINT 硬件,也就是 core-local interruptor,让它在一定延迟之后产生中断。另一部分工作是设置一个 scratch area,它类似于 trapframe,用来帮助定时器中断处理程序保存寄存器,以及保存 CLINT 寄存器的地址。最后,start 会把 mtvec 设置为 timervec,并启用定时器中断。
c
// entry.S jumps here in machine mode on stack0.
void
start()
{
// set M Previous Privilege mode to Supervisor, for mret.
unsigned long x = r_mstatus();
x &= ~MSTATUS_MPP_MASK;
x |= MSTATUS_MPP_S;
w_mstatus(x);
// set M Exception Program Counter to main, for mret.
// requires gcc -mcmodel=medany
w_mepc((uint64)main);
// disable paging for now.
w_satp(0);
// delegate all interrupts and exceptions to supervisor mode.
w_medeleg(0xffff);
w_mideleg(0xffff);
w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE);
// configure Physical Memory Protection to give supervisor mode
// access to all of physical memory.
w_pmpaddr0(0x3fffffffffffffull);
w_pmpcfg0(0xf);
// ask for clock interrupts.
timerinit();
// keep each CPU's hartid in its tp register, for cpuid().
int id = r_mhartid();
w_tp(id);
// switch to supervisor mode and jump to main().
asm volatile("mret");
}
// set up to receive timer interrupts in machine mode,
// which arrive at timervec in kernelvec.S,
// which turns them into software interrupts for
// devintr() in trap.c.
void
timerinit()
{
// each CPU has a separate source of timer interrupts.
int id = r_mhartid();
// ask the CLINT for a timer interrupt.
int interval = 1000000; // cycles; about 1/10th second in qemu.
*(uint64*)CLINT_MTIMECMP(id) = *(uint64*)CLINT_MTIME + interval;
// prepare information in scratch[] for timervec.
// scratch[0..2] : space for timervec to save registers.
// scratch[3] : address of CLINT MTIMECMP register.
// scratch[4] : desired interval (in cycles) between timer interrupts.
uint64 *scratch = &timer_scratch[id][0];
scratch[3] = CLINT_MTIMECMP(id);
scratch[4] = interval;
w_mscratch((uint64)scratch);
// set the machine-mode trap handler.
w_mtvec((uint64)timervec);
// enable machine-mode interrupts.
w_mstatus(r_mstatus() | MSTATUS_MIE);
// enable machine-mode timer interrupts.
w_mie(r_mie() | MIE_MTIE);
}
定时器中断可能在用户代码或内核代码执行的任意时刻发生;内核没有办法在关键操作期间禁止定时器中断。因此,定时器中断处理程序必须以一种保证不会干扰被打断的内核代码的方式完成自己的工作。
基本策略是:定时器中断处理程序让 RISC-V 产生一个"软件中断",然后立刻返回。RISC-V 会用普通的 trap 机制把软件中断交给内核处理,并且允许内核关闭这种软件中断。处理由定时器中断产生的软件中断的代码可以在 devintr 中看到(kernel/trap.c:204)。
machine mode 下的定时器中断处理程序是 timervec(kernel/kernelvec.S:93)。它会把几个寄存器保存到 start 准备好的 scratch area 中,告诉 CLINT 下一次什么时候产生定时器中断,请求 RISC-V 产生一个软件中断,恢复寄存器,然后返回。定时器中断处理程序中没有 C 代码。
asm
timervec:
# start.c has set up the memory that mscratch points to:
# scratch[0,8,16] : register save area.
# scratch[24] : address of CLINT's MTIMECMP register.
# scratch[32] : desired interval between interrupts.
csrrw a0, mscratch, a0
sd a1, 0(a0)
sd a2, 8(a0)
sd a3, 16(a0)
# schedule the next timer interrupt
# by adding interval to mtimecmp.
ld a1, 24(a0) # CLINT_MTIMECMP(hart)
ld a2, 32(a0) # interval
ld a3, 0(a1)
add a3, a3, a2
sd a3, 0(a1)
# raise a supervisor software interrupt.
li a1, 2
csrw sip, a1
ld a3, 16(a0)
ld a2, 8(a0)
ld a1, 0(a0)
csrrw a0, mscratch, a0
mret
定时器中断的作用有两个:
- 维护系统时间
- 定期打断当前进程,让 xv6 有机会切换到别的进程
比如一个进程一直在死循环计算,如果没有定时器中断,它可能长期占着 CPU。定时器中断周期性发生后,xv6 可以在 usertrap 或 kerneltrap 中调用 yield(),让出 CPU,调度其他进程运行。
RISC-V 的定时器中断必须先进入 machine mode。
但 xv6 内核主要运行在 supervisor mode。普通内核代码依赖分页、内核页表、普通 trap 机制等,而 machine mode 环境不适合直接运行这些 C 代码。
所以 xv6 采用两步走:
为什么 timervec 要尽量简单?
因为定时器中断可能在任何时刻发生,甚至可能发生在内核正在修改关键数据结构的时候。如果 machine mode 的中断处理程序做太复杂的事情,就很容易破坏内核当前状态。
所以它只做几件最低限度的事:
- 保存少量寄存器
- 设置下一次定时器中断时间
- 请求产生软件中断
- 恢复寄存器
- 返回
为什么需要把machine mode的中断转换成trap中的中断
text
1. machine mode 不适合跑普通内核代码
xv6 内核主要运行在 supervisor mode。普通内核代码依赖:
内核页表
内核栈
进程结构
锁
调度器
trap 机制
但 machine mode 更底层:
没有按 xv6 普通方式使用分页
有自己的一套控制寄存器
执行环境和 supervisor mode 不同
如果在 machine mode 的 timervec 里直接做复杂事情,比如更新 ticks、唤醒进程、调用 yield(),会很麻烦,也容易出错。
所以 timervec 被设计得很小,只做最低限度的工作。
2. 定时器中断可能打断任意内核代码
定时器中断可能发生在任何时候:
用户程序运行时
内核代码运行时
内核正在持锁时
内核正在修改关键数据结构时
而原文说,xv6 没法在关键操作期间禁止 machine timer interrupt。
所以如果 machine-mode timer handler 做很多复杂内核操作,就可能破坏被打断代码的状态,导致竞态或死锁。
因此 xv6 的策略是:
machine timer interrupt 到来
↓
timervec 只保存寄存器、设置下一次 timer、触发 software interrupt
↓
马上返回
尽量不干扰当前代码。
3. software interrupt 可以交给普通 trap 机制处理
timervec 触发 software interrupt 后,RISC-V 会把它交给 xv6 的普通 trap 流程。
普通 trap 流程里,xv6 已经有成熟逻辑可以判断:
现在是从用户态进入内核?
还是本来就在内核态?
是否可以 yield?
如何保存/恢复上下文?
而且 supervisor software interrupt 可以被内核控制和屏蔽,比 machine timer interrupt 更适合和 xv6 的锁、调度机制配合。
所以最终设计是:
硬件 timer interrupt:
只负责提醒"时间到了"
machine-mode timervec:
只负责转发
supervisor trap/devintr:
负责真正的 xv6 内核逻辑
一句话总结:
xv6 这样设计,是为了把 RISC-V 强制在 machine mode 到来的硬件定时器中断,转换成 xv6 更容易、安全、统一处理的 supervisor software interrupt,从而避免在 machine mode 中运行复杂内核代码。