Interrupts and device drivers
A driver is the code in an operating system that manages a particular device:
- configures the device hardware
- tells the device to perform operations
- handles the resulting interrupts
- interacts with processes that may be waiting for I/O from the device
Driver code can be tricky because a driver executes concurrently with the device that it manages.
In addition, the driver must understand the device’s hardware interface, which can be complex and poorly documented.
如果devices需要让操作系统对某些事情做出响应,就要采取中断的方法。在kerneltrap
中,内核响应中断,并且根据设备类型来决定中断处理函数。
这段对设备中断的概述总结得非常到位
也就是说,一个device driver可以分为两部分实现,一部分是接收请求,然后开启read/write;另一部分是接收中断,这个中断有可能是设备完成IO,也可能是设备需要IO,它会通知设备具体怎么做,它也会唤醒恰当的进程。
Code: Console input
console driver是driver structure的一个实现案例。
上层逻辑
shell获取用户输入console的信息是通过系统调用read()
实现的。read通过文件描述符,最终转向consoleread()
来实现具体的逻辑。
1 | // in file.c fileread() |
对console的读写事实上是对cons结构体里buf的读写。这个buf则是由底层逻辑管理的。consoleread()
每次读取buf中的一行,当未读满一行且无字符输入时会阻塞,直到底层逻辑将字符放入buf。读满了一行后,consoleread
将该行copy进用户空间,随后返回read
。
1 | // in kernel/console.c |
底层逻辑
底层逻辑维护了与上层逻辑交互的buf。
console接收数据对buf的读,是通过中断来实现的。
当用户输入字符,UART硬件检测到读,会向操作系统发送中断。中断在kerneltrap()
中被接收处理,然后通过devintr()
对该中断分门别类地进行转发。console的转发路径为devintr->uartintr->consoleintr。
UART
UART的全称是Universal Asynchronous Receiver and Transmitter,即异步发送和接收。它的软件上的表示形式是a set of memory-mapped control registers。CPU通过物理地址与这些寄存器交互,也即它们跟RAM是同一个地址空间。在xv6中,UART的地址空间从UART0
(0x1000 0000)开始。这些寄存器地址关于UART0
的偏移量定义如下:
1 | // the UART control registers. |
例如,LSR寄存器包含指示输入字符是否正在等待软件读取的位。这些字符(如果有的话)可用于从RHR寄存器读取。每次读取一个字符,UART硬件都会从等待字符的内部FIFO寄存器中删除它,并在FIFO为空时清除LSR中的“就绪”位。UART传输硬件在很大程度上独立于接收硬件;如果软件向THR写入一个字节,则UART传输该字节。
kerneltrap
1 | // in kerneltrap() |
devintr
devintr处在trap.c
中,作用是对中断归类,然后分门别类地转发到下一层级的handler。
注:
外中断和内中断
根据中断源的不同,可以把中断分为硬件中断和软件中断两大类,而硬件中断又可以分为外部中断和内部中断两类。
外部中断一般是指由计算机外设发出的中断请求,如:键盘中断、打印机中断、定时器中断等。外部中断是可以屏蔽的中断,也就是说,利用中断控制器可以屏蔽这些外部设备的中断请求。
内部中断是指因硬件出错(如突然掉电、奇偶校验错等)或运算出错(除数为零、运算溢出、单步中断等)所引起的中断。内部中断是不可屏蔽的中断。
软件中断其实并不是真正的中断,它们只是可被调用执行的一般程序。例如:ROM BIOS中的各种外部设备管理中断服务程序(键盘管理中断、显示器管理中断、打印机管理 中断等,)以及DOS的系统功能调用(INT 21H)等都是软件中断。【比如说系统调用之类的】
1 | // check if it's an external interrupt or software interrupt, |
uartintr
这代码其实乍一看是看不懂的,这是因为uartintr不止负责读中断。它还负责另一个中断(发送区空余中断),下面会细说。
1 | // handle a uart interrupt, raised because input has |
consoleintr
向buf中放入字符c
1 | void |
Code: Console output
外部通过write这个系统调用来对console写。
uartputc
最先到达这里。
uart内置了一个缓冲区。
1 | char uart_tx_buf[UART_TX_BUF_SIZE]; |
用户仅需通过uartputc对buf进行写入即可,具体的buf数据向UART转移由uartputc通过调用uartstart实现。
1 | // add a character to the output buffer and tell the |
uartstart
uartstart的作用是从缓冲区取数据向UART硬件发送。不阻塞。
1 | // if the UART is idle, and a character is waiting |
当传输过程非常流畅,UART硬件没有阻塞时,以上的代码就能完美阐述发送的过程。但是当UART硬件的transmit阻塞时,过程就会有许多改动。
transmit complete interrupt
在uartstart
中,当UART硬件的transmit满,uartstart
就直接return了。
当UART硬件的transmit空,就会发送transmit complete中断。中断在kerneltrap被接收,经过devintr转发,最终来到了uartintr:
1 | // handle a uart interrupt, raised because input has |
此时,第一个while循环会直接退出,因为压根没有get到字符。所以,这时候,就会去执行uartstart,然后继续读未完成读取的缓冲区。
等到所有都读完了,最后一次发送transmit complete中断时,会在uartstart进入该分支:
1 | if(uart_tx_w == uart_tx_r){ |
然后就不会再发送transmit中断了。
感觉这点是真的牛逼。uartintr这个函数完美兼顾了两种情况【这也归功于uartstart做得很健壮】:1. 外部输入数据到console,2. 接收数据未结束,继续接收
Concurrency in drivers
用户进程与设备之间的读写交流,比如说上面的console,重点依靠于uart_tx_buf
和cons.buf
这两个的正确性。因而,就需要保障它们的并发安全。在上面的代码中,使用到这两个的地方都被锁保护着。
在kernel中还需要格外注意的一点并发是,一个进程A在等待来自设备的中断,但此时另一个进程B在运行。这时候设备发出中断信号,CPU转入中断处理程序处理中断。此时,中断处理程序的执行不应该涉及到当前被中断进程的代码。例如,中断处理程序不能安全地使用当前进程的页表调用copyout
(页表正是跟当前进程息息相关的)。中断处理程序通常做相对较少的工作(例如,只需将输入数据复制到缓冲区),并唤醒上半部分代码来完成其余工作。
Timer interrupts
Xv6 uses timer interrupts to maintain its clock and to enable it to switch among compute-bound processes; the yield calls in usertrap and kerneltrap cause this switching.
1 | // in kernel/trap.c usertrap() |
1 | // in kernel/trap.c kerneltrap() |
RISC-V requires that timer interrupts be taken in machine mode, not supervisor mode. As a result, xv6 handles timer interrupts completely separately from the trap mechanism laid out above.
xv6启动时调用过start.c
。start.c
处于机器态,并准备向内核态过渡。start.c
中就对时钟进行了初始化timeinit()
。要做的有以下几件事:
- program the CLINT hardware (core-local interruptor) to generate an interrupt after a certain delay.
- set up a scratch area to help the timer interrupt handler save registers and the address of the CLINT registers
- start sets mtvec to timervec and enables timer interrupts.
1 | // arrange to receive timer interrupts. |
计时器中断处理程序必须保证不干扰中断的内核代码。基本策略是处理程序要求RISC-V发出“软件中断”并立即返回。RISC-V用普通陷阱机制将软件中断传递给内核,并允许内核禁用它们。处理由定时器中断产生的软件中断的代码可以在
devintr
(kernel/trap.c:204)中看到:
1 | // in kernel/trap.c devintr() |
注意,w_sip(r_sip() & ~2);
就对应着“RISC-V用普通陷阱机制将软件中断传递给内核”。【应该吧个人理解】
来源:rCore 手册(rCore tutorial doc)
riscv 中的中断寄存器
S 态的中断寄存器主要有 sie(Supervisor Interrupt Enable,监管中断使能), sip (Supervisor Interrupt Pending,监管中断待处理)两个,其中 s 表示 S 态,i 表示中断, e/p 表示 enable (使能)/ pending (提交申请)。 处理的中断分为三种:
- SI(Software Interrupt),软件中断
- TI(Timer Interrupt),时钟中断
- EI(External Interrupt),外部中断
比如
sie
有一个STIE
位, 对应sip
有一个STIP
位,与时钟中断 TI 有关。当硬件决定触发时钟中断时,会将STIP
设置为 1,当一条指令执行完毕后,如果发现STIP
为 1,此时如果时钟中断使能,即sie
的STIE
位也为 1 ,就会进入 S 态时钟中断的处理程序。可能SSIP跟这里的STIP差不多吧,都是时钟中断的标志。如果把SSIP clear掉,那么则说明不是时钟中断了,而是软中断了。
Real world
UART驱动程序读取UART控制寄存器,一次检索一字节的数据;因为软件驱动数据移动,这种模式被称为程序I/O(Programmed I/O)。程序I/O很简单,但速度太慢,无法在高数据速率下使用。需要高速移动大量数据的设备通常使用直接内存访问(DMA)。DMA设备硬件直接将传入数据写入内存,并从内存中读取传出数据。现代磁盘和网络设备使用DMA。DMA设备的驱动程序将在RAM中准备数据,然后使用对控制寄存器的单次写入来告诉设备处理准备好的数据。
当一个设备在不可预知的时间需要注意时,中断是有意义的,而且不是太频繁。但是中断有很高的CPU开销。因此,如网络和磁盘控制器的高速设备,使用一些技巧减少中断需求。一个技巧是对整批传入或传出的请求发出单个中断。另一个技巧是驱动程序完全禁用中断,并定期检查设备是否需要注意。这种技术被称为轮询(polling)。如果设备执行操作非常快,轮询是有意义的,但是如果设备大部分空闲,轮询会浪费CPU时间。一些驱动程序根据当前设备负载在轮询和中断之间动态切换。
UART驱动程序首先将传入的数据复制到内核中的缓冲区,然后复制到用户空间。这在低数据速率下是可行的,但是这种双重复制会显著降低快速生成或消耗数据的设备的性能。一些操作系统能够直接在用户空间缓冲区和设备硬件之间移动数据,通常带有DMA。
Lab: networking
In this lab you will write an xv6 device driver for a network interface card (NIC).
这个概述光是听起来就让人觉得热血沸腾。网络的本质其实就是IO设备,这一点我一直觉得很牛逼,而现在我居然要亲手实现网络……That’s very cool.
On this emulated LAN, xv6 (the “guest”) has an IP address of 10.0.2.15.
Qemu also arranges for the computer running qemu to appear on the LAN with IP address 10.0.2.2.
When xv6 uses the E1000 to send a packet to 10.0.2.2, qemu delivers the packet to the appropriate application on the (real) computer on which you’re running qemu (the “host”).
We’ve added some files to the xv6 repository for this lab.
The file
kernel/e1000.c
contains initialization code for the E1000 as well as empty functions for transmitting and receiving packets, which you’ll fill in.
kernel/e1000_dev.h
contains definitions for registers and flag bits defined by the E1000 and described in the Intel E1000 Software Developer’s Manual.
kernel/net.c
andkernel/net.h
contain a simple network stack that implements the IP, UDP, and ARP protocols.These files also contain code for a flexible data structure to hold packets, called an
mbuf
.Finally,
kernel/pci.c
contains code that searches for an E1000 card on the PCI bus when xv6 boots.
Your job:
Your job is to complete
e1000_transmit()
ande1000_recv()
, both inkernel/e1000.c
, so that the driver can transmit and receive packets. You are done whenmake grade
says your solution passes all the tests.
感想
说实话,一开始看题的时候真是感觉非常地哈人……但其实文档看着看着,心中也逐渐有了个大概,最后再结合下指导书的提示【当然不是后面那些保姆级的Hints】,最后写的也就八九不离十了。总体上来说,我觉得这次实验的代码还是很简单的,它主要难在探究过程,也就是从一开始什么也不懂,然后去阅读硬件设备的文档,结合代码尝试去理解,最后一步步写出来的过程。本次实验耗时六小时,我觉得肯定有不少于一半,甚至可能达到2/3的时间都耗费在理解上。这种从零开始探究的过程给了我很大的收获,同时也稍微提高了我面对挫折的能力。
这个实验确实设计得很有教育意义。除了我上面说的它锻炼了我的能力以外,它其实还具有比较深刻的工业意义。在看书的时候,书中这么写道:
In addition, the driver must understand the device’s hardware interface, which can be complex and poorly documented.
本次实验正是上述描述的简化版:E1000的文档很详细,并且我们只用掌握一部分它的功能就行了。但虽然简化了,其探究过程的内在逻辑还是不会改变的。
总之,我很喜欢这次实验的设计。我的评价是牛逼。
思路
正确思路
Hints写得很详细,不做赘述了。主要就是明确一下数据结构的问题:
rx_ring和tx_ring是两个分开的队列
它们只是结构一模一样,都是阴影部分表示software持有,白色部分表示硬件持有。
因而,对于rx来说,白色部分表示需要传给协议栈的包,因而我们需要把白色部分转化为阴影部分;对于tx来说,白色部分表示网卡将要发送的包,因而我们需要把阴影部分转化为白色部分。
rx_mbufs和tx_mbufs
一开始不知道这俩是啥,后来才意识到,这俩和第1点的那俩其实是下标一一对应的关系。也就是说rx_ring[i]这个descriptor接收到的数据存在rx_mbufs[i],tx_ring[i]要发送的数据存在tx_mbufs[i]。知道了这个之后,代码就简单了。
忏悔:我一开始真没反应过来。计网我记得是有一模一样的结构的,看来算是白做了2333
个人的推理过程
一开始就先懵懵懂懂地看指导书,直到看到这句话:
Browse the E1000 Software Developer’s Manual.
然后我这时连自己要干什么都迷迷糊糊,但姑且还是按他下面说的,准备先浏览第二章了。然而,我发现要我看我也还是看不懂啊,所以我就直接放弃了。【经验1:看不懂就算了,别死磕了】
我放弃了第二章后,就再次从头开始细细看了一遍这句话之前的指导书,也结合了一下它给的代码。这次总算是差不多弄懂这次要做什么了:
实现driver的两个函数,从而实现对网卡进行数据的取出和送入。数据是eth frame。数据取出后要通过net_rx
传递给上层协议栈。数据是mbuf
类型的。
所以我们只需实现协议栈最底下的部分,也即从网卡读写数据,其他一些别的东西比如协议栈什么的都已经写好了。
但是那些什么rx_ring
,还有各种奇奇怪怪的寄存器,我都看不懂,所以我就去看第三章了。初次略过一遍感觉还是一脸懵逼不知道干什么,但我带着“我们要做的是driver”这样的想法,在第二遍细看的时候有意区分开什么是网卡硬件帮我们做的,什么是我们的driver软件需要做的(经验2:明确要做什么。我们需要做的是软件部分,它的文档一般会说Software should XXX,密切关注这部分就行),就差不多有了点实现的雏形:
1 | for recv: |
可以看到,跟正确思路虽然很多细节理解上有点问题,但是大体框架还是大差不差。然后再阅读指导书:
When the E1000 receives each packet from the ethernet, it first DMAs the packet to the mbuf pointed to by the next RX (receive) ring descriptor, and then generates an interrupt. 【这句话可得知,
descriptor
们存放在代码中的rx_ring
中。】Your
e1000_recv()
code must scan the RX ring and deliver each new packet’s mbuf to the network stack (innet.c
) by callingnet_rx()
. You will then need to allocate a new mbuf and place it into the descriptor, so that when the E1000 reaches that point in the RX ring again it finds a fresh buffer into which to DMA a new packet.
就差不多是正确思路了。transmit
的实现也是同理
代码
以下代码不知道为什么过不了test,我跟别人的逻辑一模一样也还是不行emmm
它的问题是,不会接收到外界的返ping,导致进程一直等待网卡IO,所以kerneltrap一直触发不了,无法正常网卡读写,从而导致
fileread
会一直处于sleep等待状态,整个系统就沉睡了【】我感觉应该是transmit
没发成功。等以后有精力再来看看吧。
1 | int |