Interrupts and device drivers
驱动程序是操作系统中管理特定设备的代码:它配置设备硬件,告诉设备执行操作,处理由此产生的中断,并与可能等待设备I/O的进程进行交互。驱动程序需要与它所管理的设备并发执行并且必须理解设备的硬件接口,编写代码可能很棘手。
设备通常可以产生中断,内核陷阱处理代码识别设备何时引发中断并调用驱动程序的中断处理程序;在xv6中,这个分派发生在devintr中。
许多设备驱动程序在两种上下文中执行代码:上半部分在进程的内核线程中运行,下半部分在中断时执行。上半部分是通过系统调用来调用的,比如读和写,它们需要设备执行I/O。这段代码可以要求硬件启动一个操作(例如,要求磁盘读取一个块);然后,代码等待操作完成。最终,设备完成操作并引发中断。驱动程序的中断处理程序,作为底层,计算出哪个操作已经完成,如果合适的话,唤醒一个等待的进程,并告诉硬件开始任何等待的下一个操作。
console 输入
以console为例,展示了驱动程序如何工作。
console驱动程序是一种简单典型的驱动程序。控制台驱动程序通过连接到RISC-V的UART串行端口硬件接受键入的字符,一次累积一行输入,处理特殊的输入字符,如退格和。用户进程(如shell)使用read系统调用从控制台获取输入行。在QEMU中向xv6输入时,将通过QEMU的模拟UART硬件传递给xv6
UART硬件对软件来说是一组内存映射的控制寄存器,RISC-V硬件连接到UART设备的一些物理地址,以便加载和存储与设备硬件而不是RAM交互。
UART的内存映射地址从0x10000000(UART0)开始。包含了UART控制寄存器,每个寄存器的宽度为一个字节。例如,LSR寄存器包含指示输入字符是否等待软件读取的位。而实际字符可以从RHR寄存器中读取。每次读取一个字符时,UART硬件将其从等待字符的内部队列中删除,并在队列为空时清除LSR中的就绪位。UART的发送硬件在很大程度上独立于接收硬件;如果软件向THR寄存器写入一个字节,则UART传输该字节。
xv6的主程序调用consoleinit来初始化UART硬件,并将CONSOLE设备的read和write设置为consoleread和consolewrite。
当用户输入一个字符时,UART硬件要求RISC-V引发一个中断,从而激活xv6的trap陷阱处理程序。trap处理程序调用devintr,查看RISC-V cause寄存器以发现中断来自外部设备。如果是UART, devintr会调用uartintr,读取任何等待的输入字符,并将它们交给consoleintr。consoleintr的工作是处理特殊字符,回显,然后将输入字符写入buffer中。换行符到达时,consoleinter唤醒一个等待的consoleread,将字符复制到用户空间,然后退出返回到用户空间。
console 输出
console文件描述符的write调用最终到达uartputc。设备驱动程序维护一个输出缓冲区(uart_tx_buf,输入缓冲区在console),这样写进程不必等待UART完成发送,uartputc将每个字符追加到缓冲区,调用uartstart启动设备传输,然后返回。唯一需要等待的情况是缓冲区已经满了。
每次UART发送完一个字节,它就产生一个中断。uartintr调用uartstart,检查设备是否真的完成了发送,并将下一个缓冲的输出字符交给设备。因此,如果一个进程向console输出多个字节,通常第一个字节将由uartputc调用uartstart发送,而剩余的缓冲字节将在传输完成中断到达时从uartintr中调用uartstart发送。
通过缓冲和中断将设备活动与进程活动解耦。控制台驱动程序可以处理输入,即使没有进程等待读取它,随后进程可以读取输入。类似地,进程可以发送输出而不必等待设备。这种解耦可以通过允许进程与设备I/O并发执行来提高性能,并且在设备速度较慢(如UART)或需要立即注意(如回显输入字符)时尤为重要。
并发保护
consoleread和consoleinter中调用了acquire,获得一个锁,保护控制台驱动程序的数据结构不受并发访问。
这里存在三个并发性危险:
- 不同cpu上的两个进程可能同时调用consoleread;
- 当CPU已经在consoleread内部执行时,硬件可能会要求CPU发送console(实际上是UART)中断;
- 当consoleread执行时,硬件可能会在不同的CPU上发送console中断。
锁的保护可以让避免数据竞争,但xv6不实现进程的完整输入(或许可以说一次输入在结果上表现的是原子的)。从结果上来看,如果同时有两个进程读取console,那么两个进程都会读取到输入的字符,而不是一个进程完整读取所需字节数或者读取一行使读取操作完成。
对于以下代码
int main()
{
int pid = fork();
char buf[100];
if(pid == 0)
{
read(0, buf, 3);
buf[3] = '\0';
printf("child: %s\n", buf);
}
else
{
read(0, buf, 3);
buf[3] = '\0';
printf("parent: %s\n", buf);
}
return 0;
}
和输入helo
xv6下输出
ubuntu 20.04/22.04下输出
解释
lab
rx_ring
Software adds receive descriptors by writing the tail pointer with the index of the entry beyond the
last valid descriptor. As packets arrive, they are stored in memory and the head pointer is
incremented by hardware. When the head pointer is equal to the tail pointer, the ring is empty.
Hardware stops storing packets in system memory until software advances the tail pointer, making
more receive buffers available.
The receive descriptor head and tail pointers reference 16-byte blocks of memory. Shaded boxes in
the figure represent descriptors that have stored incoming packets but have not yet been recognized
by software. Software can determine if a receive buffer is valid by reading descriptors in memory
rather than by I/O reads. Any descriptor with a non-zero status byte has been processed by the
hardware, and is ready to be handled by the software.
tail -> header之间的部分存储接受到但还未交由上层软件处理的数据包的descriptor,即tail指向最早的未处理的descriptor,header指向最新的未处理的descriptor的下一个空闲位置。
而header -> tail之间的部分则是剩余的空闲的descriptor。
tx_ring
New descriptors are added to the ring by writing descriptors into the
circular buffer memory region and moving the ring’s tail pointer. The tail pointer points one entry
beyond the last hardware owned descriptor (but at a point still within the descriptor ring).
Shaded boxes in Figure 3-4 represent descriptors that have been transmitted but not yet reclaimed
by software. Reclaiming involves freeing up buffers associated with the descriptors.
Transmit Descriptor Tail register (TDT)
This register holds a value which is an offset from the base, and indicates the location beyond
the last descriptor hardware can process. This is the location where software writes the first
new descriptor.
即head -> tail之间的部分是软件写入但还未传输的数据包的descriptor
可以看出,rx_ring和tx_ring的结构是一样的,都是一个循环队列,唯一区别是接收数据放在tail -> header之间,发送数据放在header -> tail之间,
知道这一点区别结合hints还是很好写的。
注意e1000_recv调用net_rx时必须未获得锁,因为存在net_rx -> net_rx_arp -> net_tx_arp -> net_tx_eth -> e1000_transmit, 可能会导致dead lock