操作系统MIT6.S081:P7->Interrupts

本系列文章为MIT6.S081的学习笔记,包含了参考手册、课程、实验三部分的内容,前面的系列文章链接如下
操作系统MIT6.S081:[xv6参考手册第1章]->操作系统接口
操作系统MIT6.S081:[xv6参考手册第2章]->操作系统组织结构
操作系统MIT6.S081:[xv6参考手册第3章]->页表
操作系统MIT6.S081:[xv6参考手册第4章]->Trap与系统调用
操作系统MIT6.S081:P1->Introduction and examples
操作系统MIT6.S081:P2->OS organization and system calls
操作系统MIT6.S081:P3->Page tables
操作系统MIT6.S081:P4->RISC-V calling conventions and stack frames
操作系统MIT6.S081:P5->Isolation & system call entry/exit
操作系统MIT6.S081:P6->Page faults
操作系统MIT6.S081:Lab1->Unix utilities
操作系统MIT6.S081:Lab2->System calls
操作系统MIT6.S081:Lab3->Page tables
操作系统MIT6.S081:Lab4->Trap
操作系统MIT6.S081:Lab5->Lazy allocation


一、操作系统内存使用情况

在真正的操作系统中,内存是如何使用的

登录到Athena计算机(MIT内部共享使用的计算机),执行top指令输出内存的使用情况。
在这里插入图片描述
查看Mem这一行,可以看到:
①计算机中总共有多少内存(33048332)
②大部分内存都被使用了(4214604+26988148),应用程序用得较少,buff/cache用得较多。这在操作系统中很常见,因为我们不想让物理内存就在那闲置着,所以这里大块的内存被用作buff/cache。
③还有一小块内存是空闲的(1845580),但是并不多。
注:
①以上是一个非常常见的场景,大部分操作系统运行时几乎没有任何空闲的内存。这意味着如果应用程序或者内核需要使用新的内存,那么我们需要丢弃一些已有的内容。现在的空闲内存(free)或许足够几个page用,但是在某个时间点如果需要大量内存的话,要么是从应用程序,要么是从buffer/cache中,需要撤回已经使用的一部分内存。所以,当内核在分配内存的时候,通常都不是一个低成本的操作,因为并不总是有足够的可用内存,为了分配内存需要先撤回一些内存。
②如果你查看输出的每一行,VIRT表示的是虚拟内存地址空间的大小,RES是实际使用的内存数量。从这里可以看出,实际使用的内存数量远小于地址空间的大小。所以,我们上节课讨论的基于虚拟内存和page fault提供的非常酷的功能在这都有使用,比如说demand paging。


二、中断的处理流程与硬件设备

中断的使用场景

中断对应的场景很简单,就是硬件想要得到操作系统的关注。例如:
----网卡收到了一个packet,网卡会生成一个中断。
----用户通过键盘按下了一个按键,键盘会产生一个中断。
操作系统需要做的是:保存当前的工作---->处理中断---->恢复之前的工作。这里的保存和恢复工作与之前系统调用过程非常相似。所以系统调用、page fault、中断都使用相同的机制。

中断与系统调用的区别:

asynchronous(异步)。当硬件生成中断时,中断处理程序与CPU上当前运行的进程没有任何关联。但如果是系统调用的话,需要进入内核,然后在调用进程的上下文中运行。
concurrency(并发)。对于中断来说,CPU和生成中断的设备是并行运行。网卡自己独立处理来自网络的网络包,然后在某个时间点产生中断。同时,CPU也在正常运行。所以CPU和设备之间是并行运行的,我们必须管理这里的并行。
program devices(对硬件编程)。我们这节课主要关注外部设备,例如网卡、UART。这些设备需要被编程,每个设备都有一个编程手册,就像RISC-V有一个包含了指令和寄存器的手册一样。设备的编程手册包含了它有什么样的寄存器、它能执行什么样的操作、在读写控制寄存器时设备会如何响应。不幸的是,设备的手册不如RISC-V的手册清晰,这会使得对设备的编程更加复杂。

中断的产生

我们这节课会讨论的内容:
----控制台中的提示符$ 是如何显示出来的。
----如果你在键盘输入ls,这个ls字符是怎么读入并显示在控制台。
----实现上述目标所需要的所有机制。
我们首先要关心的是:中断是从哪里产生的?
因为我们主要关心的是外部设备的中断,而不是定时器中断或软件中断。外设中断来自于主板上的设备,下图是一个SiFive主板(QEMU模拟的那个),可以发现有大量的设备连接在或者可以连接到这个主板上。
在这里插入图片描述
这里连接了许多设备,如以太网卡、MicroUSB、MicroSD、重置按钮等,主板上的各种线路将外设和CPU连接在一起。下面两个引脚一个是用来传输的UART0 Tx,另外一个是用来接收的UART0 Re。
在这里插入图片描述
SiFive文档
下图是SiFive有关处理器的文档,图中的右侧是各种各样的设备,如UART0。我们在之前的课程已经知道UART0这些硬件设备会映射到内核内存地址中低于0x80000000的地方,而左边所有的DRAM都映射在地址空间的0x80000000之上。通过向相应的设备地址执行load/store指令,我们就可以对设备进行编程。
在这里插入图片描述
所有的设备都连接到处理器上,处理器通过Platform Level Interrupt Control(PLIC)来处理设备中断。我们进一步查看PLIC的结构图:
在这里插入图片描述
从左上角可以看出,我们有53个不同的来自于设备的中断。这些中断到达PLIC之后,PLIC会路由这些中断。图的右下角是CPU的核,PLIC会将中断路由到某一个CPU核。如果所有的CPU核都正在处理中断,PLIC会保留中断直到有一个CPU核可以用来处理中断。所以PLIC需要保存一些内部数据来跟踪中断的状态。
这里的流程总结如下:
①PLIC通知当前有一个待处理的中断
②其中一个CPU核会声称接收中断,这样PLIC就不会把中断发给其他的CPU处理
③CPU核处理完中断之后,CPU会通知PLIC处理完毕
④PLIC将不再保存中断的信息

问答

学生提问:PLIC有没有什么执行机制来确保公平?
Frans教授:这里取决于内核以什么样的方式来对PLIC进行编程。PLIC只是分发中断,而内核需要对PLIC进行编程来告诉它中断应该分发到哪。实际上,内核可以对中断优先级进行编程,这里非常的灵活。


三、驱动

驱动的概念

通常来说,管理设备的代码称为驱动,xv6中所有的驱动都在内核中。我们今天要看的是UART设备的驱动,代码在uart.c文件中。

驱动的结构

如果我们查看驱动代码的结构,我们可以发现大部分驱动都分为两个部分:bottomtop
----bottom部分通常是中断处理程序(Interrupt handler)。当一个中断送到了CPU并且CPU接收这个中断,CPU会调用相应的中断处理程序。中断处理程序并不运行在任何特定进程的上下文中,它只是处理中断。
----top部分是用户进程或者内核的其他部分调用的接口。对于UART来说,这里有read/write接口,这些接口可以被更高层级的代码调用。
在这里插入图片描述
通常情况下,驱动中会有一些队列(或者说buffer),top部分的代码会从队列中读写数据,而中断处理程序(bottom部分)同时也会向队列中读写数据。这里的队列可以将顶部和底部解耦,并允许设备和CPU上的其他代码并行运行。
在这里插入图片描述
----通常对于中断处理程序来说存在一些限制,因为它并没有运行在任何进程的上下文中,所以进程的页表并不知道该从哪个地址读写数据,也就无法直接从中断处理程序读写数据。所以驱动的top部分通常与用户的进程交互,进行数据的读写。
----在很多操作系统中,驱动代码加起来可能会比内核还要大,主要是因为对于每个设备都需要一个驱动,而设备又很多。

设备编程

通常来说,对设备编程是通过内存映射I/O(memory mapped I/O)完成的。
----在SiFive的手册中,设备地址出现在物理地址的特定区间内,这个区间由主板制造商决定。
----操作系统需要知道这些设备位于物理地址空间的具体位置,然后再通过普通的load/store指令对这些地址进行编程。
----load/store指令实际上的工作就是读写设备的控制寄存器。例如,对网卡执行store指令时,CPU会修改网卡的某个控制寄存器,进而导致网卡发送一个网络包。所以这里的load/store指令不会读写内存,而是会操作设备。
----你还需要阅读设备的文档来弄清楚设备的寄存器和相应的行为,有的时候文档很清晰,有的时候文档不是那么清晰。
下图中是SiFive主板中的对应设备的物理地址。
在这里插入图片描述
----0x200_0000对应CLINT
----0xC000000对应的是PLIC
----在这个图中UART0对应的是0x1001_0000。但是在QEMU中,我们的UART0的地址略有不同,因为在QEMU中我们并不是完全的模拟SiFive主板,而是模拟与SiFive主板非常类似的东西。
UART文档
下图是UART的文档,16550是QEMU模拟的UART设备,QEMU用这个模拟的设备来与键盘和控制台进行交互。这是一个很简单的芯片,图中表明了芯片拥有的寄存器。
在这里插入图片描述
----对于控制寄存器000,当你执行load指令时,它将持有数据。如果执行store指令,则将数据传输到线路之外。
----UART可以让你能够通过串口发送数据bit。发送是一条线路,接收是一条线路。基本上,你取一个字节,它们在这条线路上是多路复用或串行化的。在线路的另一侧会有另一个UART芯片,能够将数据bit组合成一个个Byte。
----这里还有一些其他可以控制的地方,例如控制寄存器001,可以通过它来控制UART是否产生中断。
----实际上对于一个寄存器,其中的每个bit都有不同的作用。例如对于寄存器001,也就是IER寄存器,bit0-bit3分别控制了不同的中断。
----这个文档还有很多内容,但是对于我们这节课来说,上图就足够了。不过即使是这么简单的一个设备,它的文档也有很多页。

问答

学生提问:如果你写入数据到Transmit Holding Register,然后再次写入,那么前一个数据不会被覆盖掉吗?
Frans教授:我们通过load将数据写入到这个寄存器中,之后UART芯片会通过串口线将这个Byte送出。当完成了发送,UART会生成一个中断给内核,这个时候才能再次写入下一个数据。所以内核和设备之间需要遵守一些协议才能确保一切工作正常。上图中的UART芯片会有一个容量是16的FIFO,但是你还是要小心,因为如果阻塞了16个Byte之后再次写入还是会造成数据覆盖。


四、在xv6中设置中断

控制台显示$的原理

对于$ 来说,实际上就是设备会将字符传输给UART的寄存器,UART之后会在发送完字符之后产生一个中断。在QEMU中,模拟的线路的另一端会有另一个UART芯片,这个UART芯片连接到了虚拟的控制台,它会进一步将$ 显示在控制台上。

键盘输入ls并显示在控制台的原理

对于ls,这是用户输入的字符。键盘连接到了UART的输入线路,当你在键盘上按下一个按键,UART芯片会将按键字符通过串口线发送到另一端的UART芯片。另一端的UART芯片先将数据bit合并成一个Byte,之后再产生一个中断,并告诉处理器这里有一个来自于键盘的字符。之后中断处理程序会处理来自于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寄存器。它会表明当前状态的原因是中断。
STVEC寄存器。它会保存当trap、page fault、中断发生时,CPU运行的用户程序的程序计数器,这样才能在稍后恢复程序的运行。

xv6中设置中断

机器启动时,start.c中的start函数被调用,它以M模式运行,禁用了页表。很快过后,内核又进入Supervisor Mode,可以设置页表。
在这里插入图片描述
这里将所有的中断都设置在Supervisor mode,然后设置SIE寄存器来接收外部、软件、定时器中断,之后初始化定时器。
main函数中是如何处理External中断:
在这里插入图片描述
我们第一个外设是console,这是我们print的输出位置。查看console.c中的consoleinit函数。
在这里插入图片描述
这里首先初始化了锁,然后调用了uart.c中的uartinit函数。这个函数实际上就是配置好UART芯片使其可以被使用。
在这里插入图片描述
这里的流程是先关闭中断,之后设置波特率,设置字符长度为8bit,重置FIFO,最后再重新打开中断。
学生提问:什么是波特率?
Frans教授:这是串口线的传输速率。
以上就是uartinit函数,运行完这个函数之后,原则上UART就可以生成中断了。但是因为我们还没有对PLIC编程,所以中断不能被CPU感知。最终,在main函数中需要调用plicinit函数。
在这里插入图片描述
-----PLIC与外设一样,也占用了一个I/O地址(在Kernel/memlayout.h中可以看到,是0xC000_0000)。
----代码的第一行使能了UART的中断,这里实际上就是设置PLIC会接收哪些中断,进而将中断路由到CPU。
----类似的,代码的第二行设置PLIC接收来自IO磁盘的中断。
main函数中,plicinit之后就是plicinithart函数。plicinit是由0号CPU运行,之后每个CPU的核都需要调用plicinithart函数表明对于哪些外设中断感兴趣。
在这里插入图片描述
----所以在plicinithart函数中,每个CPU的核都表明自己对来自于UART和VIRTIO的中断感兴趣。
----因为我们忽略中断的优先级,所以我们将优先级设置为0。
到目前为止,我们有了生成中断的外部设备,有了PLIC可以传递中断到单个的CPU。但是CPU自己还没有设置好接收中断,因为我们还没有设置好SSTATUS寄存器。在main函数的最后,程序调用了scheduler函数。
在这里插入图片描述
scheduler函数主要是运行进程。但是在实际运行进程之前,会执行intr_on函数来使得CPU能接收中断。
在这里插入图片描述
intr_on函数只完成一件事情,就是设置SSTATUS寄存器,打开中断标志位。在这个时间点,中断被完全打开了。如果PLIC正好有pending的中断,那么这个CPU核会收到中断。以上就是中断的基本设置。
学生提问:哪些核在intr_on之后打开了中断?
Frans教授:任何一个调用了intr_on的CPU核都会接收中断,实际上所有的CPU核都会运行intr_on函数。


五、UART驱动的top部分

输出$ 的流程

我们来看下从Shell程序输出提示符$到Console的流程:
①首先我们看init.c中的main函数,这是系统启动后运行的第一个进程。
在这里插入图片描述
----首先这个进程的main函数通过mknod操作创建了一个代表Console的设备。
----因为这是第一个打开的文件,所以这里返回文件描述符0。
----之后通过dup创建stdout(标准输出)和stderr(标准错误)。这里实际上通过复制文件描述符0,得到了另外两个文件描述符1,2。最终文件描述符0、1、2都用来代表Console。
②Shell程序首先打开文件描述符0、1、2,之后Shell向文件描述符2打印提示符$
在这里插入图片描述
----尽管Console背后是UART设备,但是从应用程序来看,它就像是一个普通的文件。
----Shell程序只通过fprintf向文件描述符2写了数据,它并不知道文件描述符2对应的是什么。
fprintf工作原理
在Unix系统中,设备是由文件表示。我们来看一下这里的fprintf是如何工作的。
①在printf.c文件中,代码只是调用了write系统调用。在我们的例子中,fd对应的就是文件描述符2,c是字符$
在这里插入图片描述
②所以由Shell输出的每一个字符都会触发一个write系统调用。之前我们已经看过了write系统调用最终会走到sysfile.c文件的sys_write函数。
在这里插入图片描述
③这个函数中首先对参数做了检查,然后又调用了file.c中的filewrite函数。
在这里插入图片描述
④在filewrite函数中首先会判断文件描述符的类型。
----mknod生成的文件描述符属于设备(FD_DEVICE),而对于设备类型的文件描述符,我们会为这个特定的设备执行设备相应的write函数。
----因为我们现在的设备是Console,所以我们知道这里会调用console.c中的consolewrite函数。
在这里插入图片描述
⑤这里先通过either_copyin将字符拷入,之后调用uart.c中的uartputc函数。uartputc函数将字符写入给UART设备,并完成实际的打印字符。所以你可以认为consolewrite是一个UART驱动的top部分。
在这里插入图片描述
uartputc
uartputc函数比较有趣。在UART的内部会有一个buffer用来发送数据,buffer的大小是32个字符。同时还有一个为consumer提供的读指针和为producer提供的写指针,来构建一个环形的buffer(可以认为是环形队列)。
在这里插入图片描述
在我们的例子中,Shell是producer,所以需要调用uartputc函数。
----在函数中第一件事情是判断环形buffer是否已经满了。
----如果读写指针相同,那么buffer是空的。如果写指针加1等于读指针,那么buffer满了。
----当buffer是满的时候,向其写入数据是没有意义的,所以这里会sleep一段时间,将CPU出让给其他进程。
----当然,对于我们来说,buffer必然不是满的,因为提示符$是我们送出的第一个字符。所以代码会走到else,字符会被送到buffer中,更新写指针,之后再调用uartstart函数。
在这里插入图片描述
uartstart函数
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函数的地址,具体取决于发生中断时程序运行是在用户空间还是内核空间。

xv6的中断处理流程

在我们的例子中,Shell运行在用户空间,所以STVEC保存的是uservec函数的地址。而从之前的课程我们可以知道uservec函数会调用usertrap函数。所以接下来看一下trap.c文件中的usertrap函数,我们在lec06和lec08分别在这个函数中处理了系统调用和page fault,今天我们将要看一下如何处理中断。
在这里插入图片描述
①在trap.c的devintr函数中,首先会通过SCAUSE寄存器判断当前中断是否是来自于外设的中断。如果是的话,再调用plic_claim函数来获取中断。
在这里插入图片描述
②plic_claim函数位于plic.c文件中。在这个函数中,当前CPU核会告知PLIC自己要处理中断,PLIC_SCLAIM会将中断号返回。对于UART来说,返回的中断号是10。
在这里插入图片描述
③从devintr函数可以看出,如果是UART中断,那么会调用uart.c中的uartintr函数。uartintr会从UART的接收寄存器中读取数据,然后将获取到的数据传递给consoleintr函数。这里讨论的是向UART发送数据,但是我们现在还没有通过键盘输入任何数据,所以UART的接受寄存器现在为空。所以uartgetc()会返回-1,然后break出来。
在这里插入图片描述
④所以uartintr代码会直接运行到uartstart函数,这个函数会将Shell存储在buffer中的任意字符送出。实际上在提示符$之后,Shell还会输出一个空格字符。write系统调用可以在UART发送提示符$的同时,并发的将空格字符写入到buffer中。所以UART的发送中断触发时,可以发现在buffer中还有一个空格字符,之后会将这个空格字符送出。这样,驱动的top部分和bottom部分就解耦开了。
学生提问:UART对于键盘来说很重要,来自于键盘的字符通过UART走到CPU再到我们写的内核代码。但是我不太理解UART对于Shell输出字符究竟有什么作用?因为在这个场景中,并没有键盘的参与。
Frans教授:显示设备与UART也是相连的。所以UART连接了两个设备,一个是键盘,另一个是显示设备,也就是Console。QEMU也是通过模拟的UART与Console进行交互,而Console的作用就是将字符在显示器上画出来。


七、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上运行,这里我们通过锁来管理并行。因为这里有共享的数据,我们想要buffer在一个时间只被一个CPU核所操作。
producer/consumser并发
这里我们主要关注producer/consumser并发,这是驱动中的非常常见的典型现象。正如你们所见,在驱动中会有一个buffer,在我们之前的例子中,buffer是32字节大小。除此之外还有两个指针,分别是读指针和写指针。
在这里插入图片描述
如果两个指针相等,那么buffer是空的。当Shell调用uartputc函数时,会将字符(例如提示符$)写入到写指针的位置,并将写指针加1。这就是producer对于buffer的操作。
在这里插入图片描述
----producer可以一直写入数据,直到写指针+1等于读指针,因为这时buffer已经满了。当buffer满了的时候,producer必须停止运行。我们之前在uartputc函数中看过,如果buffer满了,代码会sleep,暂时搁置Shell并运行其他的进程。
----中断处理程序(也就是uartintr函数)在这个场景下是consumer,每当有一个中断,并且读指针落后于写指针,uartintr函数就会从读指针中读取一个字符再通过UART设备发送,并且将读指针加1。当读指针追上写指针,也就是两个指针相等的时候,buffer为空,这时就不用做任何操作。

问答

学生提问:这里的buffer对于所有的CPU核都是共享的吗?
Frans教授:这里的buffer存在于内存中并且只有一份,所以所有的CPU核都并行的与这一份数据交互,因此我们才需要锁。
学生提问:对于uartputc中的sleep,它怎么知道应该让Shell去sleep,那里写入的只有地址。
在这里插入图片描述
Frans教授: sleep会让当前运行的进程进入睡眠状态,sleep函数传入的是正在等待的东西。在这种情况下,这个地址有一个channel id或者一种与sleep程序交互的方式。在这个例子中传入的是uart_tx_r的地址。在uartstart函数中,一旦buffer中有了空间,会调用与sleep对应的函数wakeup,传入的也是uart_tx_r的地址。
在这里插入图片描述
任何等待在这个地址的进程都会被唤醒。所以sleep和wakeup这两个调用是相互的,有时候这种机制被称为conditional synchronization(条件同步)。以上就是Shell输出提示符$ 的全部内容。如你们所见,过程还挺复杂的,许多代码一起工作才将这两个字符传输到了Console。


八、UART读取键盘输入

在UART的另一侧,会有类似的事情发生:

在某个时候,Shell打印$ ,然后会调用read读取来自键盘的输入。在read系统调用的底层,会调用fileread函数。在这个函数中,如果读取的文件类型是设备,会调用相应设备的read函数。
在这里插入图片描述
在我们的例子中,read函数就是console.c文件中的consoleread函数。
在这里插入图片描述
这里与UART类似,也有一个buffer,包含了128个字符。其他的基本一样,也有producer和consumser。但是在这个场景下Shell变成了consumser,因为Shell是从buffer中读取数据。而键盘是producer,它将数据写入到buffer中。
在这里插入图片描述
从consoleread函数中可以看出,当读指针和写指针一样时,说明buffer为空,进程会sleep。
----所以Shell在打印完$ 之后,如果键盘没有输入,Shell进程会sleep,直到键盘有一个字符输入。
----所以在某个时间点,假设用户通过键盘输入了l,这会导致l被发送到主板上的UART芯片,产生中断之后再被PLIC路由到某个CPU核。
----之后会触发devintr函数,devintr可以发现这是一个UART中断,然后通过uartgetc函数获取到相应的字符,之后再将字符传递给consoleintr函数。
在这里插入图片描述
----默认情况下,字符会通过consputc输出到console上给用户查看。之后,字符被存放在buffer中。在遇到换行符的时候,唤醒之前sleep的进程(也就是Shell),再从buffer中将数据读出。
----所以这里也是通过buffer将consumer和producer之间解耦,这样它们才能按照自己的速度独立地并行运行。如果某一个运行的过快了,那么buffer要么是满的要么是空的,consumer和producer其中一个会sleep并等待另一个追上来。


九、中断的演进

最后我们介绍一下Interrupt在最近几十年的演进。

①当Unix刚被开发出来的时候,Interrupt处理还是很快的,这使得硬件可以很简单。当外设有数据需要处理时,硬件可以中断CPU的执行,并让CPU处理硬件的数据。
②而现在,中断相对处理器来说变慢了。从前面的介绍可以看出来这一点,需要很多步骤才能真正的处理中断数据。如果一个设备在高速的产生中断,处理器将会很难跟上。所以如果查看现在的设备,可以发现现在的设备相比之前做了更多的工作。所以在产生中断之前,设备上会执行大量的操作,这样可以减轻CPU的处理负担。所以现在硬件变得更加复杂。
③如果你有一个高性能的设备,例如你有一个千兆网卡,这个网卡收到了大量的小包,网卡每秒可以生成1.5Mpps。这意味着每一个微秒,CPU都需要处理一个中断,这就超过了CPU的处理能力。那么当网卡收到大量包,并且处理器不能处理这么多中断的时候该怎么办呢?
----这里的解决方法就是使用轮询(polling)。除了依赖Interrupt,CPU可以一直读取外设的控制寄存器来检查是否有数据。对于UART来说,我们可以一直读取RHR寄存器来检查是否有数据。
----这种方法浪费了CPU cycles,当我们在使用CPU不停地检查寄存器的内容时,我们并没有用CPU来运行任何程序。在我们之前的例子中,如果没有数据,内核会让Shell进程sleep,这样可以运行另一个进程。
----所以,对于一个慢设备,你肯定不想一直轮询它来得到数据,我们想要在没有数据的时候切换出来运行一些其他程序。但是如果是一个快设备,那么中断的开销也会很高,那么我们最好还是轮询设备,因为我们很快就能获得数据 。
----所以对于一个高性能的网卡,如果有大量的包要传入,那么应该用polling。对于一些精心设计的驱动,它们会在轮询和中断之间动态切换(也就是网卡的NAPI)。


十、问答

学生提问:uartinit只被调用了一次,所以才导致了所有的CPU核都共用一个buffer吗?
Frans教授:这里只有一个UART设备,一个buffer只针对一个UART设备,而这个buffer会被所有的CPU核共享,这样运行在多个CPU核上的多个程序可以同时向控制台打印输出。
学生提问:所以只有一个队列会使用UART执行任务?
Frans教授:是的,这有点复杂。假设有多个核心正在将一个字符放入缓冲区,所以在uartputc中第一件事就是需要一把锁,让这些核心中的一个获得锁。获得锁的那个CPU可以查看写指针,把字符放入其中,否则就去sleep。当它完成了所有事情之后就可以解锁,然后下一个核心可以进入,获取锁并做一些事情。所以这个锁会序列化对UART的并发访问。
在这里插入图片描述

学生提问:我们之所以需要锁是因为有多个CPU核,但是却只有一个Console,对吧?
Frans教授:是的,如我们之前说的驱动的top和bottom部分可以并行运行。所以一个CPU核可以执行uartputc函数,而另个一CPU核可以执行uartintr函数,我们需要确保它们是串行执行的,而锁确保了这一点。
学生提问:那是不是意味着,某个时间其他所有的CPU核都需要等待某一个CPU核的处理?
Frans教授:这里并不是死锁,其他的CPU核还是可以在等待的时候运行别的进程。这里的大概意思是如果有多个调用uartputc,此时缓冲区已满,之后在某个时刻这个中断会释放锁。这里会调用sleep,而sleep使用那个锁作为参数。在sleep将进程睡眠之前,它会释放那个锁。
学生提问:所以是调用sleep带上锁参数,然后意味着可以在睡眠的时候释放锁,让其他进程去做。然后当从sleep返回后,再次获取锁。
学生提问:当UART触发中断的时候,所有的CPU核都会被中断吗?
Frans教授:取决于你如何对PLIC进行编程。对于XV6来说,所有的CPU都能收到中断,但是只有一个CPU会Claim相应的中断。
学生回答:懂了,PLIC不是软件实现,而是硬件实现。

学生提问:在Prime实验中,我们看到实际上是交错输出,是不是因为锁只是在putc,但是来自多个核心的putc调用可以交错,这意味着单个printf不能保证是原子的。
Frans教授:是的。

学生提问:计时器中断是在机器模式下处理的,我想知道我们在做traps实验时,它是在哪里处理的。比如在traps实验中,在哪里切换到机器模式。
Frans教授:我们查看start代码,可以看到机器启动的开始时间,这个start运行在M模式,它会对计时器检查进行编程。
在这里插入图片描述
timeinit对CLINT编程,这里这个是本地中断,在时钟中断发生时生成中断。
在这里插入图片描述
这个w_mtvec是最重要的函数,它将机器模式的 trap 处理函数设置为这个 timervec函数。
在这里插入图片描述
这个timervec函数是由汇编编写,当计时器中断发生时,这个函数会被调用。所以,在user mode和supervisor mode时,CLINT 生成一个中断到达这一行,它将切换到机器模式并调用timervec。我们看下Kerbelvec.S,看下timervec,它做的就是在这里编写几行代码对CLINT重新编程以生成未来的中断,然后向管理程序发出软件中断。然后在mret的时候,从机器模式返回到管理员模式。如果中断启用了管理者模式,这个时候可能产生管理者软件中断。现在内核也会做同样的事情,转到kernelvec,保存恢复所以寄存器,然后转到内核trap。内核trap会看到那是计时器中断。
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/InnerPeaceHQ/article/details/126420871