1.CH-3文档学习笔记
第五章 中断和设备驱动
驱动程序:
- 作用:配置硬件设备,告诉设备要执行的操作,处理设备产生的中断,与等待设备I/O的进程进行交互。
- 难点:驱动程序需与设备并行运行,驱动程序必须理解设备的硬件接口,可能没有文档。
设备中断是trap的一种,内核通过处理代码
识别设备,然后调用相应的驱动程序进行处理,这种调度发生在devintr(kernel/trap.c:177)
中
设备驱动程序分为两部分:
-
top
运行在内核,bottom
在中断时执行。 -
top
通过系统调用(如read和write)
执行,具体操作有:-
请求设备执行I/O。
-
请求硬件执行一些操作(例如,读取一个磁盘数据块)。
-
-
设备完成操作后发出中断信号。驱动程序的
中断处理程序
作为下半部分(bottom
)继续执行:确定哪些操作已完成,唤醒等待中的进程。
5.1 代码:控制台输入
控制台驱动程序通过RISC-V的UART串口硬件UART(Universal Asynchronous Receiver/Transmitter):通用异步收/发器
接收字符。
控制台驱动程序一次累积一行输入、处理特殊字符,如backspace
和Ctrl-u
。用户进程(如Shell)使用read
系统调用获取输入的行。
当通过键盘输入时,即通过QEMU模拟的UART硬件传递字符到xv6。
与驱动程序通信的UART硬件是QEMU模拟的16550芯片。在真正的计算机上,16550管理连接到终端的RS232串行链路。当运行QEMU时,它连接到键盘和显示器。
[!NOTE]
16550:是一款为实现串行通信接口而设计的芯片,管理连接到PC的设备
RS232:是一种串口接口标准

在软件看来,UART硬件是一组内存映射的控制寄存器。
-
UART被映射到一些地址上,对这些地址进行读取就是与设备进行交互。
-
UART的内存映射起始地址为
0x10000000(UART0(kernel/memlayout.h:21))
,有一组UART控制寄存器,大小都为1字节,它们与UART0之间的偏移量定义在(kernel/uart.c:22)
中。例如,
LSR寄存器
表示输入的字符是否正在等待软件读取。这些字符可从RHR寄存器
读出。每次读取一个字符,UART硬件都会按照FIFO算法将其从等待字符寄存器中删除,并在FIFO为空时清除LSR
中的"ready"位。 -
UART发送硬件在很大程度上独立于接收硬件;软件向
THR
写入一个字节,UART就会将该字节发送出去。
初始化UART
xv6在main.c\main()
中调用consoleinit()-->uartinit()
初始化UART硬件。使其每接收到一个字节生成一个接收中断、每发送完一个字节生成一个发送完成中断。
shell读取用户输入
当有数据到达时,会发生中断,函数走向usertrap()-->devintr()-->uartintr()
void uartintr(void){
// read and process incoming characters.
while(1){
int c = uartgetc();
if(c == -1)
break;
consoleintr(c);
}
。。。
}
通过uartgetc
读取RHR寄存器,获取输入的字符,将字符通过consoleintr
写入缓冲区
用户程序调用read
时,会依次到达sys_read()->fileread()->consoleread()
consoleread
每次处理一行,然后返回
int consoleread(int user_dst, uint64 dst, int n){
uint target;
int c;
char cbuf;
target = n;
acquire(&cons.lock);
while(n > 0){
// 若uart缓冲区为空,就sleep在cons.r上
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];
// 如果c为EOF字符
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;
}
// 复制字符到用户空间
cbuf = c;
if(either_copyout(user_dst, dst, &cbuf, 1) == -1)
break;
dst++;
--n;
// 如果c是换行符,说明一行输入完成,停止读出
if(c == '\n'){
// a whole line has arrived, return to
// the user-level read().
break;
}
}
release(&cons.lock);
return target - n;
}
接收总结
- 当有数据来时,会发生中断,xv6会将输入字符读出,写入缓冲区
- 程序调用read时,会将字符从缓冲区读出,每次读出一行
5.2 代码:控制台输出
xv6初始化时,consoleinit()
将consoleread
和consolewrite
写入devsw数组中,
void consoleinit(void){
initlock(&cons.lock, "cons");
uartinit();
// connect read and write system calls
// to consoleread and consolewrite.
devsw[CONSOLE].read = consoleread;
devsw[CONSOLE].write = consolewrite;
}
当调用write
时,会依次走向sys_write
--> filewrite
--> ret = devsw[f->major].write(1, addr, n);
-->consolewrite
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;
}
consolewrite
每次复制一个字符到内核,然后调用uartputc
void uartputc(int c){
acquire(&uart_tx_lock);
if(panicked){
for(;;)
;
}
while(uart_tx_w == uart_tx_r + UART_TX_BUF_SIZE){
// buffer is full.
// wait for uartstart() to open up space in the buffer.
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_tx_buf
,是环结构
如果缓冲区已满,则等待
若未满,将字符写入到缓冲区中,调用uartstart
启动设备传输(如果还未启动),然后返回。
void uartstart(){
while(1){
if(uart_tx_w == uart_tx_r){
// 无待发送数据,返回
return;
}
if((ReadReg(LSR) & LSR_TX_IDLE) == 0){
// UART非空闲状态,无法发送,返回
return;
}
int c = uart_tx_buf[uart_tx_r % UART_TX_BUF_SIZE];
uart_tx_r += 1;
wakeup(&uart_tx_r);
WriteReg(THR, c);
}
}
-
LSR: LINE STATUS REGISTER
LSR BIT 5:
0 = transmit holding register is full. 16550 will not accept any data for transmission.
1 = transmitter hold register (or FIFO) is empty. CPU can load the next character. -
THR:TRANSMIT HOLDING REGISTER
需将待发送字符写入此寄存器
每当UART
发送完一个字节,就会产生一个中断。依次到达usertrap()-->devintr()-->uartintr()
void uartintr(void){
。。。
// send buffered characters.
acquire(&uart_tx_lock);
uartstart();
release(&uart_tx_lock);
}
uartintr
会调用uartstart
,uartstart
会检查是否有待发送数据,若有就发送。
发送总结
调用write时,会将字符写入缓冲区,第一个字节将由uartputc
调用uartstart
发送,剩余字节将通过中断调用uartstart
发送,直到传输完成。
5.3 驱动中的并发
您可能已经注意到 consoleread
和 consoleintr
中存在 acquire
调用。这些调用获取一个锁,该锁保护console驱动程序
的数据结构免受并发访问的影响。这里存在三个并发危险,这些危险可能导致竞争或死锁。
- 不同 CPU 上的两个进程可能同时调用 consoleread;
- 硬件可能在某个CPU正在执行 consoleread 时要求该 CPU 发送控制台(实际上是 UART)中断;
- 硬件可能在 consoleread 执行时在不同的 CPU 上交付控制台中断。
在驱动程序中需要注意并发的另一种场景是,一个进程可能正在等待来自设备的输入,但是输入的中断信号在另一个进程(或者根本没有进程)正在运行时到达。因此中断处理程序不允许考虑已经中断的进程或代码。例如,中断处理程序不能安全地使用当前进程的页表调用copyout
(注:因为你不知道是否发生了进程切换,当前进程可能并不是原先的进程)。中断处理程序通常做的工作相对较少(例如,只将输入数据复制到缓冲区),其余的工作需要唤醒上半部分代码完成。
5.4 定时器中断
Xv6使用定时器中断来维持其时钟,以使其能够在受计算量限制的进程(compute-bound processes)之间切换;usertrap
和kerneltrap
中的yield
调用会产生这种切换。定时器中断来自于连接到每个RISC-V CPU的时钟硬件。Xv6对该时钟硬件进行编程,周期性地中断每个CPU。
RISC-V要求定时器中断在机器模式而不是管理模式下进行。RISC-V在机器模式下运行时不分页,并且有一组单独的控制寄存器,因此在机器模式下运行普通的xv6内核代码是不切实际的。因此,xv6处理定时器中断与上文介绍的trap机制是完全分开的。
在机器模式下执行的代码位于main
之前的start.c
中,它设置了接收定时器中断(kernel/start.c:57)
。工作的一部分是对CLINT硬件(core-local interruptor)
进行编程,使其在一定的延迟后产生中断。另一部分是设置一个scratch区域,类似于trapframe,以帮助定时器中断处理程序保存寄存器和CLINT寄存器的地址。最后,start
将mtvec
设置为timervec
,并启用定时器中断。
计时器中断可能发生在用户或内核代码执行的任何时候;内核无法在临界区操作期间禁用计时器中断。因此,计时器中断处理程序必须保证不干扰被中断的内核代码。基本策略是处理程序要求RISC-V发出一个“软件中断”就立即返回。RISC-V用普通trap机制将软件中断传递给内核,并允许内核禁用这些中断。处理由定时器中断产生的软件中断的代码可以在devintr(kernel/trap.c:204)
中看到。
机器模式定时器中断向量(中断处理程序)是timervec(kernel/kernelvec.S:93)
。它在start
准备的scratch区域中保存一些寄存器,以告诉CLINT何时生成下一个定时器中断,要求RISC-V引发软件中断,恢复寄存器并返回。定时器中断处理程序中没有C代码。
5.5 真实世界
Xv6允许在内核中执行时以及在执行用户程序时触发设备和定时器中断。定时器中断迫使定时器中断处理程序进行线程切换(调用yield
),即使在内核中执行时也是如此。如果内核线程有时花费大量时间计算而不返回用户空间,那么在内核线程之间公平地对CPU进行时间分割的能力非常有用。但在xv6中,内核代码需要注意内核可能会挂起(由于定时器中断),然后在另一个CPU上恢复,这是xv6中一些复杂性的来源。如果设备和计时器中断只在执行用户代码时发生,内核可以变得简单一些。
在一台典型的计算机上支持所有设备是一项艰巨的工作,因为设备太多,这些设备有许多特性,设备和驱动程序之间的协议可能很复杂,而且缺乏文档。在许多操作系统中,驱动程序比核心内核占用更多的代码。
UART驱动程序读取UART控制寄存器,一次一字节地检索数据;因为是软件驱动数据移动,因此这种模式被称为编程I/O(Programmed I/O)。编程I/O很简单,但速度太慢,无法在高数据速率下使用。需要高速移动大量数据的设备通常使用直接内存访问(DMA)。DMA设备硬件直接将输入数据写入内存,直接从内存读取输出数据。现代磁盘和网络设备都使用DMA。DMA设备的驱动程序在RAM中准备数据,然后使用控制寄存器告诉设备去处理准备好的数据。
当一个设备在不可预知的时间需要注意时,中断是有意义的。但是中断有很高的CPU开销。因此,一些高速设备,如网络和磁盘控制器,使用一些技巧来减少对中断的需求。一个技巧是对整批传入或传出请求使用单个中断。另一个技巧是驱动程序完全禁用中断,定期检查设备是否需要处理。这种技术被称为轮询(polling)。如果设备执行的非常快,轮询是有意义的,但如果设备大部分都处于空闲状态,轮询就会浪费CPU时间。一些驱动程序会根据当前设备负载在轮询和中断之间动态切换。
UART驱动程序首先将传入的数据复制到内核中的缓冲区,然后复制到用户空间。这在低数据速率下是可行的,但对于生成或消费数据非常快的设备来说,这种双重拷贝会显著降低性能。一些操作系统会使用DMA直接在用户空间缓冲区和设备硬件之间移动数据。
如第1章所述,控制台对应用程序来说就像一个普通文件,应用程序使用read和write系统调用处理输入输出。应用程序可能希望控制无法通过标准文件系统调用表示的设备(例如,在控制台驱动程序中启用/禁用行缓冲)。Unix通过ioctl系统调用来处理这种情况。
计算机的某些使用要求系统必须在有限的时间内作出响应。例如,在安全关键系统中,错过最后期限可能会导致灾难。Xv6不适合硬实时设置。支持硬实时的操作系统往往是与应用程序链接的库,通过这种方式可以分析确定最坏情况下的响应时间。Xv6也不适用于软实时应用程序,因为偶尔错过截止日期是可以接受的,因为Xv6的调度器过于简单,而且它的内核代码会停用很长一段时间的中断。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了