MIT 6.S081 教材第五章内容 -- 中断与设备驱动--上
- 引言
- 真实操作系统内存使用情况(上一节回顾)
- 中断和设备驱动
- Interrupt硬件部分
- 设备驱动概述
- 在XV6中设置中断
- UART驱动的top部分
- UART驱动的bottom部分
- Interrupt相关的并发
- UART读取键盘输入
- Interrupt的演进
- 小结
- 代码:控制台输入
- 代码:控制台输出
- 驱动中的并发
引言
MIT 6.S081 2020 操作系统
本文为MIT 6.S081课程第五章教材内容翻译加整理。
本课程前置知识主要涉及:
- C语言(建议阅读C程序语言设计—第二版)
- RISC-V汇编
- 推荐阅读: 程序员的自我修养-装载,链接与库
真实操作系统内存使用情况(上一节回顾)
下图是一台Athena计算机(注,MIT内部共享使用的计算机)的top指令输出。如果你查看Mem这一行:
首先是计算机中总共有多少内存(33048332),如果你再往后看的话,你会发现大部分内存都被使用了(4214604 + 26988148)。但是大部分内存并没有被应用程序所使用,而是被buff/cache用掉了。这在一个操作系统中还挺常见的,因为我们不想让物理内存就在那闲置着,我们想让物理内存被用起来,所以这里大块的内存被用作buff/cache。可以看到还有一小块内存是空闲的(1845580),但是并不多。
以上是一个非常常见的场景,大部分操作系统运行时几乎没有任何空闲的内存。这意味着,如果应用程序或者内核需要使用新的内存,那么我们需要丢弃一些已有的内容。现在的空闲内存(free)或许足够几个page用,但是在某个时间点如果需要大量内存的话,要么是从应用程序,要么是从buffer/cache中,需要撤回已经使用的一部分内存。所以,当内核在分配内存的时候,通常都不是一个低成本的操作,因为并不总是有足够的可用内存,为了分配内存需要先撤回一些内存。
另外,我这里将top的输出按照RES进行了排序。如果你查看输出的每一行,VIRT表示的是虚拟内存地址空间的大小,RES是实际使用的内存数量。从这里可以看出,实际使用的内存数量远小于地址空间的大小。所以,我们上节课讨论的基于虚拟内存和page fault提供的非常酷的功能在这都有使用,比如说demand paging。
有关这台机器的其它信息还有:
- 即使它有103个用户登录进来了,它的负载还是很低
- 它有许多许多的进程
- 这台机器已经运行了249天,我们的XV6系统或许运行不了这么久
这里想传达的信息:大部分内存都被使用了,并且RES内存远小于VIRT内存。
中断和设备驱动
驱动程序是操作系统中管理特定设备的代码:
- 它配置硬件设备,告诉设备执行操作,处理由此产生的中断,并与可能正在等待设备输入/输出的进程进行交互。
- 编写驱动可能很棘手,因为驱动程序与它管理的设备同时运行。
- 此外,驱动程序必须理解设备的硬件接口,这可能很复杂,而且缺乏文档。
需要操作系统关注的设备通常可以被配置为生成中断,这是陷阱的一种。内核陷阱处理代码识别设备何时引发中断,并调用驱动程序的中断处理程序;在xv6中,这种调度发生在devintr
中(kernel/trap.c:177)。
许多设备驱动程序在两种环境中执行代码:
- 上半部分在进程的内核线程中运行,下半部分在中断时执行。
- 上半部分通过系统调用进行调用,如希望设备执行I/O操作的
read
和write
。 - 这段代码可能会要求硬件执行操作(例如,要求磁盘读取块);然后代码等待操作完成。最终设备完成操作并引发中断。
- 驱动程序的中断处理程序充当下半部分,计算出已经完成的操作,如果合适的话唤醒等待中的进程,并告诉硬件开始执行下一个正在等待的操作。
Interrupt硬件部分
户通过键盘按下了一个按键,键盘会产生一个中断。操作系统需要做的是,保存当前的工作,处理中断,处理完成之后再恢复之前的工作。这里的保存和恢复工作,与我们之前看到的系统调用过程非常相似。所以系统调用,page fault,中断,都使用相同的机制。
但是中断又有一些不一样的地方,中断与系统调用主要有3个小的差别:
- asynchronous: 当硬件生成中断时,Interrupt handler与当前运行的进程在CPU上没有任何关联。但如果是系统调用的话,系统调用发生在运行进程的context下。
- concurrency: 对于中断来说,CPU和生成中断的设备是并行的在运行。网卡自己独立的处理来自网络的packet,然后在某个时间点产生中断,但是同时,CPU也在运行。所以我们在CPU和设备之间是真正的并行的,我们必须管理这里的并行。
- program device: 我们这节课主要关注外部设备,例如网卡,UART,而这些设备需要被编程。每个设备都有一个编程手册,就像RISC-V有一个包含了指令和寄存器的手册一样。设备的编程手册包含了它有什么样的寄存器,它能执行什么样的操作,在读写控制寄存器的时候,设备会如何响应。不过通常来说,设备的手册不如RISC-V的手册清晰,这会使得对于设备的编程会更加复杂。
我们首先要关心的是,中断是从哪里产生的?
- 因为我们主要关心的是外部设备的中断,而不是定时器中断或者软件中断。外设中断来自于主板上的设备,下图是一个SiFive主板,如果你查看这个主板,你可以发现有大量的设备连接在或者可以连接到这个主板上。
主板可以连接以太网卡,MicroUSB,MicroSD等,主板上的各种线路将外设和CPU连接在一起。这节课的大部分内容都会介绍当设备产生中断时CPU会发生什么,以及如何从设备读写数据。
下图是来自于SiFive有关处理器的文档,图中的右侧是各种各样的设备,例如UART0。我们在之前的课程已经知道UART0会映射到内核内存地址的某处,而所有的物理内存都映射在地址空间的0x80000000之上。类似于读写内存,通过向相应的设备地址执行load/store指令,我们就可以对例如UART的设备进行编程。
所有的设备都连接到处理器上,处理器上是通过Platform Level Interrupt Control,简称PLIC来处理设备中断。PLIC会管理来自于外设的中断。如果我们再进一步深入的查看PLIC的结构图,
从左上角可以看出,我们有53个不同的来自于设备的中断。这些中断到达PLIC之后,PLIC会路由这些中断。图的右下角是CPU的核,PLIC会将中断路由到某一个CPU的核。如果所有的CPU核都正在处理中断,PLIC会保留中断直到有一个CPU核可以用来处理中断。所以PLIC需要保存一些内部数据来跟踪中断的状态。
如果你看过了文档,这里的具体流程是:
- PLIC会通知当前有一个待处理的中断
- 其中一个CPU核会Claim接收中断,这样PLIC就不会把中断发给其他的CPU处理
- CPU核处理完中断之后,CPU会通知PLIC
- PLIC将不再保存中断的信息
- PLIC只是分发中断,而内核需要对PLIC进行编程来告诉它中断应该分发到哪。实际上,内核可以对中断优先级进行编程,这里非常的灵活。
- 对于XV6来说,所有的CPU都能收到中断,但是只有一个CPU会Claim相应的中断。
设备驱动概述
通常来说,管理设备的代码称为驱动,所有的驱动都在内核中。我们今天要看的是UART设备的驱动,代码在uart.c文件中。如果我们查看代码的结构,我们可以发现大部分驱动都分为两个部分: bottom/top。
- bottom部分通常是Interrupt handler。当一个中断送到了CPU,并且CPU设置接收这个中断,CPU会调用相应的Interrupt handler。Interrupt handler并不运行在任何特定进程的context中,它只是处理中断。
- top部分,是用户进程,或者内核的其他部分调用的接口。对于UART来说,这里有read/write接口,这些接口可以被更高层级的代码调用。
通常情况下,驱动中会有一些队列(或者说buffer),top部分的代码会从队列中读写数据,而Interrupt handler(bottom部分)同时也会向队列中读写数据。这里的队列可以将并行运行的设备和CPU解耦开来。
对应的就是应用层开发常说的生产者消费者模式,可以看做是一个消息队列
通常对于Interrupt handler来说存在一些限制,因为它并没有运行在任何进程的context中,所以进程的page table并不知道该从哪个地址读写数据,也就无法直接从Interrupt handler读写数据。驱动的top部分通常与用户的进程交互,并进行数据的读写。我们后面会看更多的细节,这里是一个驱动的典型架构。
在很多操作系统中,驱动代码加起来可能会比内核还要大,主要是因为,对于每个设备,你都需要一个驱动,而设备又很多。
接下来我们看一下如何对设备进行编程。通常来说,编程是通过memory mapped I/O完成的:
- 在SiFive的手册中,设备地址出现在物理地址的特定区间内,这个区间由主板制造商决定。
- 操作系统需要知道这些设备位于物理地址空间的具体位置,然后再通过普通的load/store指令对这些地址进行编程。
- load/store指令实际上的工作就是读写设备的控制寄存器。
- 例如,对网卡执行store指令时,CPU会修改网卡的某个控制寄存器,进而导致网卡发送一个packet。
- 所以这里的load/store指令不会读写内存,而是会操作设备。并且你需要阅读设备的文档来弄清楚设备的寄存器和相应的行为,有的时候文档很清晰,有的时候文档不是那么清晰。
下图中是SiFive主板中的对应设备的物理地址:
例如: 0x200_0000对应CLINT,0xC000000对应的是PLIC。在这个图中UART0对应的是0x1001_0000,但是在QEMU中,我们的UART0的地址略有不同,因为在QEMU中我们并不是完全的模拟SiFive主板,而是模拟与SiFive主板非常类似的东西。
以上就是Memory-mapped IO。
下图是UART的文档 --> 16550是QEMU模拟的UART设备,QEMU用这个模拟的设备来与键盘和Console进行交互:
这是一个很简单的芯片,图中表明了芯片拥有的寄存器:
- 例如: 对于控制寄存器000,如果写它会将数据写入到寄存器中并被传输到其他地方,如果读它可以读出存储在寄存器中的内容。
UART可以让你能够通过串口发送数据bit,在线路的另一侧会有另一个UART芯片,能够将数据bit组合成一个个Byte。
这里还有一些其他可以控制的地方:
- 例如: 控制寄存器001,可以通过它来控制UART是否产生中断。
实际上对于一个寄存器,其中的每个bit都有不同的作用。
- 例如: 对于寄存器001,也就是IER寄存器,bit0-bit3分别控制了不同的中断。这个文档还有很多内容,但是对于我们这节课来说,上图就足够了。不过即使是这么简单的一个设备,它的文档也有很多页。
如果你写入数据到Transmit Holding Register,然后再次写入,那么前一个数据不会被覆盖掉吗?
- 我们通过load将数据写入到这个寄存器中,之后UART芯片会通过串口线将这个Byte送出。当完成了发送,UART会生成一个中断给内核,这个时候才能再次写入下一个数据。所以内核和设备之间需要遵守一些协议才能确保一切工作正常。上图中的UART芯片会有一个容量是16的FIFO,但是你还是要小心,因为如果阻塞了16个Byte之后再次写入还是会造成数据覆盖。
在XV6中设置中断
当XV6启动时,Shell会输出提示符“$ ”,如果我们在键盘上输入ls,最终可以看到“$ ls”。我们接下来通过研究Console是如何显示出“$ ls”,来看一下设备中断是如何工作的。
实际上“$ ”和“ls”还不太一样,“$ ”是Shell程序的输出,而“ls”是用户通过键盘输入之后再显示出来的。
对于“$ ”来说,实际上就是设备会将字符传输给UART的寄存器,UART之后会在发送完字符之后产生一个中断。在QEMU中,模拟的线路的另一端会有另一个UART芯片(模拟的),这个UART芯片连接到了虚拟的Console,它会进一步将“$ ”显示在console上。
UART在点对点配置中运行,其中两个设备直接连接使用两条数据线:一条用于发送数据(TX),一条用于接收数据(RX)。一个设备的TX线连接到另一个设备的RX线,反之亦然。这允许设备之间的双向通信。
另一方面,对于“ls”,这是用户输入的字符。键盘连接到了UART的输入线路,当你在键盘上按下一个按键,UART芯片会将按键字符通过串口线发送到另一端的UART芯片。另一端的UART芯片先将数据bit合并成一个Byte,之后再产生一个中断,并告诉处理器说这里有一个来自于键盘的字符。之后Interrupt handler会处理来自于UART的字符。我们接下来会深入通过这两部分来弄清楚这里是如何工作的。
RISC-V有许多与中断相关的寄存器:
- SIE(Supervisor Interrupt Enable)寄存器。这个寄存器中有一个bit(E)专门针对例如UART的外部设备的中断;有一个bit(S)专门针对软件中断,软件中断可能由一个CPU核触发给另一个CPU核;还有一个bit(T)专门针对定时器中断。我们这节课只关注外部设备的中断。
- SSTATUS(Supervisor Status)寄存器。这个寄存器中有一个bit来打开或者关闭中断。每一个CPU核都有独立的SIE和SSTATUS寄存器,除了通过SIE寄存器来单独控制特定的中断,还可以通过SSTATUS寄存器中的一个bit来控制所有的中断。
- SIP(Supervisor Interrupt Pending)寄存器。当发生中断时,处理器可以通过查看这个寄存器知道当前是什么类型的中断。
- SCAUSE寄存器,这个寄存器我们之前看过很多次。它会表明当前状态的原因是中断。
- SEPC寄存器,它会保存当trap,page fault或者中断发生时,CPU运行的用户程序的程序计数器,这样才能在稍后恢复程序的运行。
接下来看看代码,首先是位于start.c的start函数:
// 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);
// 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");
}
这里将所有的中断都设置在Supervisor mode,然后设置SIE寄存器来接收External,软件和定时器中断,之后初始化定时器。
接下来我们看一下main函数中是如何处理External中断:
我们第一个外设是console,这是我们print的输出位置。查看位于console.c的consoleinit函数:
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;
}
这里首先初始化了锁,我们现在还不关心这个锁。然后调用了uartinit,uartinit函数位于uart.c文件。这个函数实际上就是配置好UART芯片使其可以被使用。
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");
}
这里的流程是先关闭中断,之后设置波特率,设置字符长度为8bit,重置FIFO,最后再重新打开中断。
以上就是uartinit函数,运行完这个函数之后,原则上UART就可以生成中断了。但是因为我们还没有对PLIC编程,所以中断不能被CPU感知。最终,在main函数中,需要调用plicinit函数。下图是plicinit函数。
void
plicinit(void)
{
// set desired IRQ priorities non-zero (otherwise disabled).
*(uint32*)(PLIC + UART0_IRQ*4) = 1;
*(uint32*)(PLIC + VIRTIO0_IRQ*4) = 1;
}
PLIC与外设一样,也占用了一个I/O地址(0xC000_0000)。代码的第一行使能了UART的中断,这里实际上就是设置PLIC会接收哪些中断,进而将中断路由到CPU。类似的,代码的第二行设置PLIC接收来自IO磁盘的中断,我们这节课不会介绍这部分内容。
main函数中,plicinit之后就是plicinithart函数。plicinit是由0号CPU运行,之后,每个CPU的核都需要调用plicinithart函数表明对于哪些外设中断感兴趣。
void
plicinithart(void)
{
int hart = cpuid();
// set uart's enable bit for this hart's S-mode.
*(uint32*)PLIC_SENABLE(hart)= (1 << UART0_IRQ) | (1 << VIRTIO0_IRQ);
// set this hart's S-mode priority threshold to 0.
*(uint32*)PLIC_SPRIORITY(hart) = 0;
}
所以在plicinithart函数中,每个CPU的核都表明自己对来自于UART和VIRTIO的中断感兴趣。因为我们忽略中断的优先级,所以我们将优先级设置为0。
- Threshold寄存器的值决定了可以被接受和处理的中断优先级范围。当PLIC接收到一个中断请求时,它会将请求的中断优先级与Threshold寄存器中的值进行比较。如果中断的优先级高于或等于Threshold寄存器的值,那么该中断将被发送给处理器核心进行处理。
- 通过调整Threshold寄存器的值,可以控制系统中不同中断的优先级。较低的Threshold值会导致更高优先级的中断被接受和处理,而较高的Threshold值则会限制只有更高优先级的中断才能被接受和处理。
到目前为止,我们有了生成中断的外部设备,我们有了PLIC可以传递中断到单个的CPU。但是CPU自己还没有设置好接收中断,因为我们还没有设置好SSTATUS寄存器。在main函数的最后,程序调用了scheduler函数,
scheduler函数主要是运行进程。但是在实际运行进程之前,会执行intr_on函数来使得CPU能接收中断。
// enable device interrupts
static inline void
intr_on()
{
w_sstatus(r_sstatus() | SSTATUS_SIE);
}
intr_on函数只完成一件事情,就是设置SSTATUS寄存器,打开中断标志位。
在这个时间点,中断被完全打开了。如果PLIC正好有pending的中断,那么这个CPU核会收到中断。
以上就是中断的基本设置。
UART驱动的top部分
接下来看一下如何从Shell程序输出提示符“$ ”到Console。首先我们看init.c中的main函数,这是系统启动后运行的第一个进程。
首先这个进程的main函数创建了一个代表Console的设备。这里通过mknod操作创建了console设备。因为这是第一个打开的文件,所以这里的文件描述符0。之后通过dup创建stdout和stderr。这里实际上通过复制文件描述符0,得到了另外两个文件描述符1,2。最终文件描述符0,1,2都用来代表Console。
Shell程序首先打开文件描述符0,1,2。之后Shell向文件描述符2打印提示符“$ ”。
//sh.c
int
getcmd(char *buf, int nbuf)
{
fprintf(2, "$ ");
memset(buf, 0, nbuf);
gets(buf, nbuf);
if(buf[0] == 0) // EOF
return -1;
return 0;
}
尽管Console背后是UART设备,但是从应用程序来看,它就像是一个普通的文件。Shell程序只是向文件描述符2写了数据,它并不知道文件描述符2对应的是什么。在Unix系统中,设备是由文件表示。我们来看一下这里的fprintf是如何工作的。
在printf.c文件中,代码只是调用了write系统调用,在我们的例子中,fd对应的就是文件描述符2,c是字符“$” :
static void
putc(int fd, char c)
{
write(fd, &c, 1);
}
所以由Shell输出的每一个字符都会触发一个write系统调用。之前我们已经看过了write系统调用最终会走到sysfile.c文件的sys_write函数。
uint64
sys_write(void)
{
struct file *f;
int n;
uint64 p;
// argfd: 通过fd从当前进程打开的文件列表中获取对应的file实例
if(argfd(0, 0, &f) < 0 || argint(2, &n) < 0 || argaddr(1, &p) < 0)
return -1;
// fd,字符地址,写入长度
return filewrite(f, p, n);
}
这个函数中首先对参数做了检查,然后又调用了filewrite函数。filewrite函数位于file.c文件中。
// Write to file f.
// addr is a user virtual address.
int
filewrite(struct file *f, uint64 addr, int n)
{
int r, ret = 0;
if(f->writable == 0)
return -1;
if(f->type == FD_PIPE){
ret = pipewrite(f->pipe, addr, n);
} else if(f->type == FD_DEVICE){
//对设备写入的处理---major是主设备号
if(f->major < 0 || f->major >= NDEV || !devsw[f->major].write)
return -1;
// 设备数组,每个设备将自己注册到该设备数组中---其实就是驱动的注册
// 这里可以参考上面的consoleinit
ret = devsw[f->major].write(1, addr, n);
} else if(f->type == FD_INODE){
//后面文件系统章节再看
...
} else {
panic("filewrite");
}
return ret;
}
在filewrite函数中首先会判断文件描述符的类型。mknod生成的文件描述符属于设备(FD_DEVICE),而对于设备类型的文件描述符,我们会为这个特定的设备执行设备相应的write函数。因为我们现在的设备是Console,所以我们知道这里会调用console.c中的consolewrite函数。
//
// user write()s to the console go here.
//
int
//参数: src地址是否来源于用户地址空间,数据源地址,数据长度
consolewrite(int user_src, uint64 src, int n)
{
int i;
acquire(&cons.lock);
// 依次拷贝每个字符
for(i = 0; i < n; i++){
char c;
// 依次将每个字符从src源地址处拷贝到c变量地址中
if(either_copyin(&c, user_src, src+i, 1) == -1)
break;
// 写入到uart进行字符输出
uartputc(c);
}
release(&cons.lock);
return i;
}
// Copy from either a user address, or kernel address,
// depending on usr_src.
// Returns 0 on success, -1 on error.
int
either_copyin(void *dst, int user_src, uint64 src, uint64 len)
{
struct proc *p = myproc();
// 如果源地址来源于用户态地址空间,那么使用当前进程的用户态页表进行地址翻译
if(user_src){
return copyin(p->pagetable, dst, src, len);
} else {
memmove(dst, (char*)src, len);
return 0;
}
}
这里先通过either_copyin将字符拷入,之后调用uartputc函数。uartputc函数将字符写入给UART设备,所以你可以认为consolewrite是一个UART驱动的top部分。uart.c文件中的uartputc函数会实际的打印字符。
// add a character to the output buffer and tell the
// UART to start sending if it isn't already.
// blocks if the output buffer is full.
// because it may block, it can't be called
// from interrupts; it's only suitable for use
// by write().
void
uartputc(int c)
{
acquire(&uart_tx_lock);
if(panicked){
for(;;)
;
}
while(1){
// uart_tx_w: 写指针 , uart_tx_r: 读指针
// 如果写指针+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.
// 当前进程阻塞等待在uart_tx_r条件变量上
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;
//让uart开始工作
uartstart();
release(&uart_tx_lock);
return;
}
}
}
uartputc函数会稍微有趣一些。在UART的内部会有一个buffer用来发送数据,buffer的大小是32个字符。同时还有一个为consumer提供的读指针和为producer提供的写指针,来构建一个环形的buffer(注,或者可以认为是环形队列)。
// the transmit output buffer.
struct spinlock uart_tx_lock;
#define UART_TX_BUF_SIZE 32
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++]
在我们的例子中,Shell是producer,所以需要调用uartputc函数。
- 在函数中第一件事情是判断环形buffer是否已经满了。
- 如果读写指针相同,那么buffer是空的,如果写指针加1等于读指针,那么buffer满了。
- 当buffer是满的时候,向其写入数据是没有意义的,所以这里会sleep一段时间,将CPU出让给其他进程。
- 当然,对于我们来说,buffer必然不是满的,因为提示符“$”是我们送出的第一个字符。所以代码会走到else,字符会被送到buffer中,更新写指针,之后再调用uartstart函数。
// if the UART is idle, and a character is waiting
// in the transmit buffer, send it.
// caller must hold uart_tx_lock.
// called from both the top- and bottom-half.
void
uartstart()
{
while(1){
//判断缓冲区是否为空
if(uart_tx_w == uart_tx_r){
// transmit buffer is empty.
return;
}
//读取LSR寄存器,获取其第五位,判断是否空闲
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.
// 唤醒阻塞在uart_tx_r条件变量上的进程
wakeup(&uart_tx_r);
// 向THR寄存器写入一个字符
WriteReg(THR, c);
}
}
uartstart就是通知设备执行操作:
- 首先是检查当前设备是否空闲,如果空闲的话,我们会从buffer中读出数据,然后将数据写入到THR(Transmission Holding Register)发送寄存器。
- 这里相当于告诉设备,我这里有一个字节需要你来发送。
- 一旦数据送到了设备,系统调用会返回,用户应用程序Shell就可以继续执行。
- 这里从内核返回到用户空间的机制与lec06的trap机制是一样的。
- 与此同时,UART设备会将数据送出。在某个时间点,我们会收到中断,因为我们之前设置了要处理UART设备中断。
接下来我们看一下,当发生中断时,实际会发生什么。
UART驱动的bottom部分
在我们向Console输出字符时,如果发生了中断,RISC-V会做什么操作?
- 我们之前已经在SSTATUS寄存器中打开了中断,所以处理器会被中断。
假设键盘生成了一个中断并且发向了PLIC,PLIC会将中断路由给一个特定的CPU核,并且如果这个CPU核设置了SIE寄存器的E bit(注,针对外部中断的bit位),那么会发生以下事情:
- 首先,会清除SIE寄存器相应的bit,这样可以阻止CPU核被其他中断打扰,该CPU核可以专心处理当前中断。处理完成之后,可以再次恢复SIE寄存器相应的bit。
- 之后,会设置SEPC寄存器为当前的程序计数器。我们假设Shell正在用户空间运行,突然来了一个中断,那么当前Shell的程序计数器会被保存。
- 之后,要保存当前的mode。在我们的例子里面,因为当前运行的是Shell程序,所以会记录user mode。
- 再将mode设置为Supervisor mode。
- 最后将程序计数器的值设置成STVEC的值。
注:
- STVEC用来保存trap处理程序的地址
- 在XV6中,STVEC保存的要么是uservec或者kernelvec函数的地址,具体取决于发生中断时程序运行是在用户空间还是内核空间。在我们的例子中,Shell运行在用户空间,所以STVEC保存的是uservec函数的地址。而从之前的课程我们可以知道uservec函数会调用usertrap函数。所以最终,我们在usertrap函数中。我们这节课不会介绍trap过程中的拷贝,恢复过程,因为在之前的课程中已经详细的介绍过了。
接下来看一下trap.c文件中的usertrap函数,我们在lec06和lec08分别在这个函数中处理了系统调用和page fault。今天我们将要看一下如何处理中断。
在trap.c的devintr函数中,首先会通过SCAUSE寄存器判断当前中断是否是来自于外设的中断。如果是的话,再调用plic_claim函数来获取中断。
// check if it's an external interrupt or software interrupt,
// and handle it.
// returns 2 if timer interrupt,
// 1 if other device,
// 0 if not recognized.
int
devintr()
{
uint64 scause = r_scause();
//外部中断
if((scause & 0x8000000000000000L) &&
(scause & 0xff) == 9){
// this is a supervisor external interrupt, via PLIC.
// irq indicates which device interrupted.
// 读取claim寄存器,以获取待处理的中断源
int irq = plic_claim();
// 判断是否是uart外部中断源
if(irq == UART0_IRQ){
uartintr();
} else if(irq == VIRTIO0_IRQ){
// 为磁盘外部中断源
virtio_disk_intr();
} else if(irq){
// 暂不支持的中断源
printf("unexpected interrupt irq=%d\n", irq);
}
// the PLIC allows each device to raise at most one
// interrupt at a time; tell the PLIC the device is
// now allowed to interrupt again.
// 更新中断不再是待处理状态,而是已经处理完毕
if(irq)
plic_complete(irq);
return 1;
} else if(scause == 0x8000000000000001L){
// software interrupt from a machine-mode timer interrupt,
// forwarded by timervec in kernelvec.S.
// m态下的时钟中断
if(cpuid() == 0){
clockintr();
}
// acknowledge the software interrupt by clearing
// the SSIP bit in sip.
w_sip(r_sip() & ~2);
return 2;
} else {
return 0;
}
}
plic_claim函数位于plic.c文件中。在这个函数中,当前CPU核会告知PLIC,自己要处理中断,PLIC_SCLAIM会将中断号返回,对于UART来说,返回的中断号是10。
// ask the PLIC what interrupt we should serve.
int
plic_claim(void)
{
int hart = cpuid();
int irq = *(uint32*)PLIC_SCLAIM(hart);
return irq;
}
从devintr函数可以看出,如果是UART中断,那么会调用uartintr函数。位于uart.c文件的uartintr函数,会从UART的接受寄存器中读取数据,之后将获取到的数据传递给consoleintr函数。哦,不好意思,我搞错了。我们现在讨论的是向UART发送数据。因为我们现在还没有通过键盘输入任何数据,所以UART的接受寄存器现在为空。
// handle a uart interrupt, raised because input has
// arrived, or the uart is ready for more output, or
// both. called from trap.c.
void
// 当发生uart中断的时候,有两种可能: 键盘中断发生,或者数据传输完成,可以进行下一次传输了
uartintr(void)
{
// read and process incoming characters.
// 1. 检查键盘中断是否发生
while(1){
// uart的RHR寄存器中是否有可读数据,如果没有返回-1
int c = uartgetc();
if(c == -1)
break;
// 如果存在可读数据,传输给consoleintr进行输出
consoleintr(c);
}
// send buffered characters.
// 2. 检查是否是数据传输完成,可以进行下一次数据传输了
acquire(&uart_tx_lock);
uartstart();
release(&uart_tx_lock);
}
所以代码会直接运行到uartstart函数,这个函数会将Shell存储在buffer中的任意字符送出。实际上在提示符“$”
之后,Shell还会输出一个空格字符,write系统调用可以在UART发送提示符“$”
的同时,并发的将空格字符写入到buffer中。所以UART的发送中断触发时,可以发现在buffer中还有一个空格字符,之后会将这个空格字符送出。
这样,驱动的top部分和bottom部分就解耦开了。
UART对于键盘来说很重要,来自于键盘的字符通过UART走到CPU再到我们写的代码。但是我不太理解UART对于Shell输出字符究竟有什么作用?因为在这个场景中,并没有键盘的参与。
- 显示设备与UART也是相连的。所以UART连接了两个设备,一个是键盘,另一个是显示设备,也就是Console。QEMU也是通过模拟的UART与Console进行交互,而Console的作用就是将字符在显示器上画出来。
uartinit只被调用了一次,所以才导致了所有的CPU核都共用一个buffer吗?
- 因为只有一个UART设备,一个buffer只针对一个UART设备,而这个buffer会被所有的CPU核共享,这样运行在多个CPU核上的多个程序可以同时向Console打印输出,而驱动中是通过锁来确保多个CPU核上的程序串行的向Console打印输出。
我们之所以需要锁是因为有多个CPU核,但是却只有一个Console,对吧?
- 是的,如我们之前说的驱动的top和bottom部分可以并行的运行。所以一个CPU核可以执行uartputc函数,而另个一CPU核可以执行uartintr函数,我们需要确保它们是串行执行的,而锁确保了这一点。
那是不是意味着,某个时间,其他所有的CPU核都需要等待某一个CPU核的处理?
- 这里并不是死锁。其他的CPU核还是可以在等待的时候运行别的进程。
还要注意一点: 串口uart的写线是连接到屏幕,读线是连接到键盘,所以对RHR寄存器的读是读取键盘输入,对THR寄存器的写是向屏幕输出
Interrupt相关的并发
接下来我们讨论一下与中断相关的并发,并发加大了中断编程的难度。这里的并发包括以下几个方面:
- 设备与CPU是并行运行的。
- 例如:当UART向Console发送字符的时候,CPU会返回执行Shell,而Shell可能会再执行一次系统调用,向buffer中写入另一个字符,这些都是在并行的执行。这里的并行称为producer-consumer并行。
- 中断会停止当前运行的程序。
- 例如,Shell正在运行第212个指令,突然来了个中断,Shell的执行会立即停止。
- 对于用户空间代码,这并不是一个大的问题,因为当我们从中断中返回时,我们会恢复用户空间代码,并继续执行执行停止的指令。
- 我们已经在trap和page fault中看过了这部分内容。但是当内核被中断打断时,事情就不一样了。所以,代码运行在kernel mode也会被中断,这意味着即使是内核代码,也不是直接串行运行的。在两个内核指令之间,取决于中断是否打开,可能会被中断打断执行。对于一些代码来说,如果不能在执行期间被中断,这时内核需要临时关闭中断,来确保这段代码的原子性。
- 驱动的top和bottom部分是并行运行的。
- 例如,Shell会在传输完提示符“$”之后再调用write系统调用传输空格字符,代码会走到UART驱动的top部分(注,uartputc函数),将空格写入到buffer中。
- 但是同时在另一个CPU核,可能会收到来自于UART的中断,进而执行UART驱动的bottom部分,查看相同的buffer。所以一个驱动的top和bottom部分可以并行的在不同的CPU上运行。这里我们通过lock来管理并行。因为这里有共享的数据,我们想要buffer在一个时间只被一个CPU核所操作。
这里我将会关注在第一点,也就是producer/consumser并发。这是驱动中的非常常见的典型现象。如你们所见的,在驱动中会有一个buffer,在我们之前的例子中,buffer是32字节大小。并且有两个指针,分别是读指针和写指针。
如果两个指针相等,那么buffer是空的。当Shell调用uartputc函数时,会将字符,例如提示符“$”,写入到写指针的位置,并将写指针加1。这就是producer对于buffer的操作。
producer可以一直写入数据,直到写指针 + 1等于读指针,因为这时,buffer已经满了。当buffer满了的时候,producer必须停止运行。我们之前在uartputc函数中看过,如果buffer满了,代码会sleep,暂时搁置Shell并运行其他的进程。
Interrupt handler,也就是uartintr函数,在这个场景下是consumer,每当有一个中断,并且读指针落后于写指针,uartintr函数就会从读指针中读取一个字符再通过UART设备发送,并且将读指针加1。当读指针追上写指针,也就是两个指针相等的时候,buffer为空,这时就不用做任何操作。
这里的buffer对于所有的CPU核都是共享的吗?
- 这里的buffer存在于内存中,并且只有一份,所以,所有的CPU核都并行的与这一份数据交互。所以我们才需要lock。
对于uartputc中的sleep,它怎么知道应该让Shell去sleep?
- sleep会将当前在运行的进程存放于sleep数据中。它传入的参数是需要等待的信号,在这个例子中传入的是uart_tx_r的地址。在uartstart函数中,一旦buffer中有了空间,会调用与sleep对应的函数wakeup,传入的也是uart_tx_r的地址。任何等待在这个地址的进程都会被唤醒。有时候这种机制被称为conditional synchronization。
以上就是Shell输出提示符“$ ”的全部内容。如你们所见,过程还挺复杂的,许多代码一起工作才将这两个字符传输到了Console。
UART读取键盘输入
在UART的另一侧,会有类似的事情发生,有时Shell会调用read从键盘中读取字符。 在read系统调用的底层,会调用fileread函数。在这个函数中,如果读取的文件类型是设备,会调用相应设备的read函数。
// Read from file f.
// addr is a user virtual address.
int
fileread(struct file *f, uint64 addr, int n)
{
int r = 0;
if(f->readable == 0)
return -1;
if(f->type == FD_PIPE){
r = piperead(f->pipe, addr, n);
} else if(f->type == FD_DEVICE){
//与file_write一样,如果当前文件类型是设备,那么通过主设备号,从设备数组中定位的设备驱动
//调用驱动的read方法,完成数据的读取
if(f->major < 0 || f->major >= NDEV || !devsw[f->major].read)
return -1;
// 第一个参数传入1表示addr是来自用户态的虚拟地址
r = devsw[f->major].read(1, addr, n);
} else if(f->type == FD_INODE){
ilock(f->ip);
if((r = readi(f->ip, 1, addr, f->off, n)) > 0)
f->off += r;
iunlock(f->ip);
} else {
panic("fileread");
}
return r;
}
在我们的例子中,read函数就是console.c文件中的consoleread函数:
//
// 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.
// 当缓存区为空时,就阻塞在cons.r条件变量上,直到被唤醒
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];
//这里的宏定义C是'D'-'@'字符=EOT传输结束字符
//键盘输入方式为Ctrl+D
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;
}
// Copy to either a user address, or kernel address,
// depending on usr_dst.
// Returns 0 on success, -1 on error.
int
either_copyout(int user_dst, uint64 dst, void *src, uint64 len)
{
struct proc *p = myproc();
//如果目的地址在用户空间,那么需要借助当前进程的用户态页表进行地址翻译
if(user_dst){
return copyout(p->pagetable, dst, src, len);
} else {
memmove((char *)dst, src, len);
return 0;
}
}
这里与UART类似,也有一个buffer,包含了128个字符。其他的基本一样,也有producer和consumser。但是在这个场景下Shell变成了consumser,因为Shell是从buffer中读取数据。而键盘是producer,它将数据写入到buffer中。
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;
从consoleread函数中可以看出,当读指针和写指针一样时,说明buffer为空,进程会sleep。所以Shell在打印完“$ ”之后,如果键盘没有输入,Shell进程会sleep,直到键盘有一个字符输入。所以在某个时间点,假设用户通过键盘输入了“l”,这会导致“l”被发送到主板上的UART芯片,产生中断之后再被PLIC路由到某个CPU核,之后会触发devintr函数,devintr可以发现这是一个UART中断,然后通过uartgetc函数获取到相应的字符,之后再将字符传递给consoleintr函数。
//
// 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;
// 唤醒阻塞在cons.r条件变量上的进程
wakeup(&cons.r);
}
}
break;
}
release(&cons.lock);
}
//
// 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.
//这意味着用户按下了退格键。在这种情况下,函数会通过向 UART 发送三个字符来覆盖退格键字符:
//先发送一个退格字符 ('\b') 将光标移回,然后发送一个空格字符以擦除前一个字符,最后再发送一个退格字符将光标再次移回。
uartputc_sync('\b'); uartputc_sync(' '); uartputc_sync('\b');
} else {
uartputc_sync(c);
}
}
// alternate version of uartputc() that doesn't
// use interrupts, for use by kernel printf() and
// to echo characters. it spins waiting for the uart's
// output register to be empty.
void
uartputc_sync(int c)
{
push_off();
if(panicked){
for(;;)
;
}
// wait for Transmit Holding Empty to be set in LSR.
// 不断轮询直到THR寄存器空闲,则进行字符写入操作
while((ReadReg(LSR) & LSR_TX_IDLE) == 0)
;
WriteReg(THR, c);
pop_off();
}
默认情况下,字符会通过consputc,输出到console上给用户查看。之后,字符被存放在buffer中。在遇到换行符的时候,唤醒之前sleep的进程,也就是Shell,再从buffer中将数据读出。
所以这里也是通过buffer将consumer和producer之间解耦,这样它们才能按照自己的速度,独立的并行运行。如果某一个运行的过快了,那么buffer要么是满的要么是空的,consumer和producer其中一个会sleep并等待另一个追上来
Interrupt的演进
最后我想介绍一下Interrupt在最近几十年的演进。当Unix刚被开发出来的时候,Interrupt处理还是很快的。这使得硬件可以很简单,当外设有数据需要处理时,硬件可以中断CPU的执行,并让CPU处理硬件的数据。
而现在,中断相对处理器来说变慢了。从前面的介绍可以看出来这一点,需要很多步骤才能真正的处理中断数据。如果一个设备在高速的产生中断,处理器将会很难跟上。所以如果查看现在的设备,可以发现,现在的设备相比之前做了更多的工作。所以在产生中断之前,设备上会执行大量的操作,这样可以减轻CPU的处理负担。所以现在硬件变得更加复杂。
如果你有一个高性能的设备,例如你有一个千兆网卡,这个网卡收到了大量的小包,网卡每秒可以生成1.5Mpps,这意味着每一个微秒,CPU都需要处理一个中断,这就超过了CPU的处理能力。那么当网卡收到大量包,并且处理器不能处理这么多中断的时候该怎么办呢?
这里的解决方法就是使用polling。除了依赖Interrupt,CPU可以一直读取外设的控制寄存器,来检查是否有数据。对于UART来说,我们可以一直读取RHR寄存器,来检查是否有数据。现在,CPU不停的在轮询设备,直到设备有了数据。
这种方法浪费了CPU cycles,当我们在使用CPU不停的检查寄存器的内容时,我们并没有用CPU来运行任何程序。在我们之前的例子中,如果没有数据,内核会让Shell进程sleep,这样可以运行另一个进程。
所以,对于一个慢设备,你肯定不想一直轮询它来得到数据。我们想要在没有数据的时候切换出来运行一些其他程序。但是如果是一个快设备,那么Interrupt的overhead也会很高,那么我们在polling设备的时候,是经常能拿到数据的,这样可以节省进出中断的代价。
所以对于一个高性能的网卡,如果有大量的包要传入,那么应该用polling。对于一些精心设计的驱动,它们会在polling和Interrupt之间动态切换(注,也就是网卡的NAPI)。
小结
下面是xv6对应教材的总结,大家阅读完上面的内容后,可以再通过书本内容进行回顾:
代码:控制台输入
控制台驱动程序(console.c)是驱动程序结构的简单说明。控制台驱动程序通过连接到RISC-V的UART串口硬件接受人们键入的字符。控制台驱动程序一次累积一行输入,处理如backspace
和Ctrl-u
的特殊输入字符。用户进程,如Shell,使用read
系统调用从控制台获取输入行。当您在QEMU中通过键盘输入到xv6时,您的按键将通过QEMU模拟的UART硬件传递到xv6。
驱动程序管理的UART硬件是由QEMU仿真的16550芯片。在真正的计算机上,16550将管理连接到终端或其他计算机的RS232串行链路。运行QEMU时,它连接到键盘和显示器。
UART硬件在软件中看起来是一组内存映射的控制寄存器。也就是说,存在一些RISC-V硬件连接到UART的物理地址,以便载入(load)和存储(store)操作与设备硬件而不是内存交互:
- UART的内存映射地址起始于
0x10000000
或UART0
(kernel/memlayout.h:21)。 - 有几个宽度为一字节的UART控制寄存器,它们关于UART0的偏移量在(kernel/uart.c:22)中定义。
- 例如,LSR寄存器包含指示输入字符是否正在等待软件读取的位。这些字符(如果有的话)可用于从RHR寄存器读取。每次读取一个字符,UART硬件都会从等待字符的内部FIFO寄存器中删除它,并在FIFO为空时清除LSR中的“就绪”位。UART传输硬件在很大程度上独立于接收硬件;如果软件向THR写入一个字节,则UART传输该字节。
Xv6的main
函数调用consoleinit
(kernel/console.c:184)来初始化UART硬件:
- 该代码配置UART:UART对接收到的每个字节的输入生成一个接收中断,对发送完的每个字节的输出生成一个发送完成中断(kernel/uart.c:53)。
xv6的shell通过init.c (user/init.c:19)中打开的文件描述符从控制台读取输入:
- 对
read
的调用实现了从内核流向consoleread
(kernel/console.c:82)的数据通路。 consoleread
等待输入到达(通过中断)并在cons.buf
中缓冲,将输入复制到用户空间,然后(在整行到达后)返回给用户进程。- 如果用户还没有键入整行,任何读取进程都将在
sleep
系统调用中等待(kernel/console.c:98)(第7章解释了sleep
的细节)。
控制台输入的整个流程如下:
- 当用户输入一个字符时,UART硬件要求RISC-V发出一个中断,从而激活xv6的陷阱处理程序。
- 陷阱处理程序调用
devintr
(kernel/trap.c:177),它查看RISC-V的scause
寄存器,发现中断来自外部设备。 - 然后它要求一个称为PLIC的硬件单元告诉它哪个设备中断了(kernel/trap.c:186)。如果是UART,
devintr
调用uartintr
。 uartintr
(kernel/uart.c:180)从UART硬件读取所有等待输入的字符,并将它们交给consoleintr
(kernel/console.c:138);- 它不会等待字符,因为未来的输入将引发一个新的中断。
consoleintr
的工作是在cons.buf中积累输入字符,直到一整行到达。consoleintr
对backspace
和其他少量字符进行特殊处理。- 当换行符到达时,
consoleintr
唤醒一个等待的consoleread
(如果有的话)。 - 一旦被唤醒,
consoleread
将监视cons.buf中的一整行,将其复制到用户空间,并返回(通过系统调用机制)到用户空间。
代码:控制台输出
在连接到控制台的文件描述符上执行write
系统调用,最终将到达uartputc
(kernel/uart.c:87) 。设备驱动程序维护一个输出缓冲区(uart_tx_buf
),这样写进程就不必等待UART完成发送;相反,uartputc
将每个字符附加到缓冲区,调用uartstart
来启动设备传输(如果还未启动),然后返回。导致uartputc
等待的唯一情况是缓冲区已满。
每当UART发送完一个字节,它就会产生一个中断。uartintr
调用uartstart
,检查设备是否真的完成了发送,并将下一个缓冲的输出字符交给设备。因此,如果一个进程向控制台写入多个字节,通常第一个字节将由uartputc
调用uartstart
发送,而剩余的缓冲字节将由uartintr
调用uartstart
发送,直到传输完成中断到来。
需要注意,这里的一般模式是通过缓冲区和中断机制将设备活动与进程活动解耦:
- 即使没有进程等待读取输入,控制台驱动程序仍然可以处理输入,而后续的读取将看到这些输入。
- 类似地,进程无需等待设备就可以发送输出。
这种解耦可以通过允许进程与设备I/O并发执行来提高性能,当设备很慢(如UART)或需要立即关注(如回声型字符(echoing typed characters))时,这种解耦尤为重要。这种想法有时被称为I/O并发。
驱动中的并发
你或许注意到了在consoleread
和consoleintr
中对acquire
的调用。这些调用获得了一个保护控制台驱动程序的数据结构不受并发访问的锁。这里有三种并发风险:
- 运行在不同CPU上的两个进程可能同时调用
consoleread
; - 硬件或许会在
consoleread
正在执行时要求CPU传递控制台中断; - 并且硬件可能在当前CPU正在执行
consoleread
时向其他CPU传递控制台中断。第6章探讨了锁在这些场景中的作用。
在驱动程序中需要注意并发的另一种场景是,一个进程可能正在等待来自设备的输入,但是输入的中断信号可能是在另一个进程(或者根本没有进程)正在运行时到达的。因此中断处理程序不允许考虑他们已经中断的进程或代码。
- 例如,中断处理程序不能安全地使用当前进程的页表调用
copyout
(注:因为你不知道是否发生了进程切换,当前进程可能并不是原先的进程)。中断处理程序通常做相对较少的工作(例如,只需将输入数据复制到缓冲区),并唤醒上半部分代码来完成其余工作。