MIT6.S081 xv6book chapter5
第五章主要讲述的是外部设备的中断,不同于软件中断,外部设备中断可以与CPU处理并行。
这里要特别理解外设的驱动,驱动的top部分一般是驱动提供给用户的接口服务,驱动的bottom部分则是interrupt handler。top部分和bottom部分通过buffer解藕,top部分往设备的缓冲区读写完事儿,待设备处理完成发送一个中断,bottom部分则处理中断,bottom亦能读写缓冲区。
值得注意的是:一个中断是如何产生,又如何被CPU处理的(这里会有多个CPU);设备与CPU的并行。
这节融合了lec09的内容,通过追踪以下两个场景来分析中断过程:
console中的提示符“$ ” 是如何显示出来的;
如果你在键盘输入“ls”,这些字符是怎么最终在console中显示出来的。
前言
中断对应的场景很简单,就是硬件想要得到操作系统的关注。例如网卡收到了一个packet,网卡会生成一个中断;用户通过键盘按下了一个按键,键盘会产生一个中断。操作系统需要做的是,保存当前的工作,处理中断,处理完成之后再恢复之前的工作。系统调用,page fault,中断,都使用相同的机制。
中断与系统调用主要有3个小的差别:
- asynchronous。异步,当硬件生成中断时,Interrupt handler与当前运行的进程在CPU上没有任何关联。
- concurrency。并行,对于中断来说,CPU和生成中断的设备是并行的在运行。比如,网卡自己独立的处理来自网络的packet,然后在某个时间点产生中断,但是同时,CPU也在运行。
- program device。设备编程,每个设备都有一个编程手册,设备的编程手册包含了它有什么样的寄存器,它能执行什么样的操作,在读写控制寄存器的时候,设备会如何响应。根据这些手册对设备进行编程。
通常来说,编程是通过memory mapped I/O完成的。设备地址出现在物理地址的特定区间内,这个区间由主板制造商决定。操作系统需要知道这些设备位于物理地址空间的具体位置,然后再通过普通的load/store指令对这些地址进行编程。load/store指令实际上的工作就是读写设备的控制寄存器。
![image-20240111091130275](/Users/mac/Library/Application Support/typora-user-images/image-20240111091130275.png)
中断是从哪里产生的?外设中断来自于主板上的设备(我们主要关心的是外部设备的中断,而不是定时器中断或者软件中断)
中断是怎么被CPU处理的?处理器上是通过PLIC(Platform Level Interrupt Control)来处理设备中断。PLIC会管理来自于外设的中断。
从左上角可以看出,我们有53个不同的来自于设备的中断。这些中断到达PLIC之后,PLIC会路由这些中断。图的右下角是CPU的核,PLIC会将中断路由到某一个CPU的核。如果所有的CPU核都正在处理中断,PLIC会保留中断直到有一个CPU核可以用来处理中断。所以PLIC需要保存一些内部数据来跟踪中断的状态。
uart会产生什么样的中断?
- 接收中断(比如键盘按下一个按键,那么这个字符会存入到uart的RHR寄存器,uart产生一个接收中断)
- 发送完成中断(往uart的THR寄存器存入字符,当uart发送THR寄存器中的一个字符到console完成时,产生一个中断)
驱动driver
A driver is the code in an operating system that manages a particular device: it configures the device hardware, tells the device to perform operations, handles the resulting interrupts, and interacts with processes that may be waiting for I/O from the device.
驱动大部分都分为两个部分,bottom和top。
bottom部分通常是Interrupt handler。当一个中断送到了CPU,并且CPU设置接收这个中断,CPU会调用相应的Interrupt handler。Interrupt handler并不运行在任何特定进程的context中,它只是处理中断。
top部分,是用户进程或者内核的其他部分调用的接口。对于UART来说,这里有read/write接口,这些接口可以被更高层级的代码调用。
通常情况下,驱动中会有一些队列(或者说buffer),top部分的代码会从队列中读写数据,而Interrupt handler(bottom部分)同时也会向队列中读写数据。这里的队列可以将并行运行的设备和CPU解耦开来。
设置中断(中断初始化)
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)寄存器。当发生中断时,处理器可以通过查看这个寄存器知道当前是什么类型的中断。
xv6启动之初,首先设置uartinit(),使得uart设备能够产生中断。
然后设置plic设备,使得plic能够路由中断。
最后是打开cpu的中断开关(设置sstatus寄存器),使得cpu能够处理中断。
top部分(“$”的输出)
sh.c 中调用fprintf(2, "$ ")
user/printf.c文件中,fprintf代码只是调用了write系统调用,最终走到sys_write函数。
sysfile.c文件中的sys_write函数fetch参数,然后调用file.c文件的filewrite函数。
file.c文件的filewrite函数首先会判断文件描述符的类型,然后调用console.c中的consolewrite函数。
1 | //file.c |
console.c文件中的consolewrite函数先通过either_copyin将字符拷入,之后调用uart.c文件中的uartputc函数。
1 | // console.c |
uart.c文件中的uartputc函数,主要逻辑就是将字符写入uart的环形缓冲区,然后调用uartstart()。uartstart函数主要逻辑是不断从环形缓冲读数据,然后发送到console,WriteReg(THR, c);
。
1 | // uart.c |
至此,一个$就打印在屏幕上了。一旦WriteReg完成,系统调用会返回,用户应用程序Shell就可以继续执行。
1 |
这里来理解top部分,主要的作用就是写到uart设备的缓冲区,然后再写到uart的寄存器,通知uart开始发送数据。
后续uart数据发送完成,uart就会产生一个发送完成中断,处理中断的代码就是bottom部分。
bottom部分
bottom部分就是cpu处理中断的代码,我们来看cpu是怎么响应一个中断的。
假设键盘生成了一个中断并且发向了PLIC,PLIC会将中断路由给一个特定的CPU核,并且如果这个CPU核设置了SIE寄存器的E bit(针对外部中断的bit位),那么会发生以下事情:
- 清除SIE寄存器相应的bit,这样可以阻止CPU核被其他中断打扰;
- 设置SEPC寄存器为当前的PC(保存PC);
- 保存当前的mode
- 设置为supervior mode
- 设置PC指向STVEC(指向中断向量地址,要么uservec要么kernelvec,uservec在Trampoline页面中)
- 执行指令
我们知道uservec最终会走向trap.c文件usertrap函数,usertrap会根据中断类型(系统调用or外部中断or计时器中断)作出相应处理。
1 | which_dev = devintr() |
在trap.c的devintr函数中,首先会通过SCAUSE寄存器判断当前中断是否是来自于外设的中断。如果是的话,再调用plic_claim函数来获取中断。
1 | // check if it's an external interrupt or software interrupt, |
在uartintr函数中,处理uart产生的中断(这个中断可能是发送完成中断,也可能是接收中断)。
1 | // handle a uart interrupt, raised because input has arrived, |
我们可以看到uartintr其实是完成两件事情的,读和写。读自己的接收寄存器(uartgetc)和写发送寄存器(userstart)。其中,读完寄存器后,调用consoleintr向console输出键盘。
简要的时钟中断
计时器中断发生在机器模式下,在start.c中对CLINT计时器硬件进行编程,然后设置一个scratch区域,类似于trapframe,来存储信息。
The machine-mode timer interrupt handler is timervec (kernel/kernelvec.S:93). It saves a few registers in the scratch area prepared by start, tells the CLINT when to generate the next timer interrupt, asks the RISC-V to raise a software interrupt, restores registers, and returns. There’s no C code in the timer interrupt handler.
总结
“$ “传送到屏幕的过程
“$ “传送到屏幕的过程其实就是drive驱动的top部分,不过当$发送完成后,uart还会产生一个发送完成中断。此时,恰好有一个并行时序,使得$后的空格被写进uart设备缓冲区,同时cpu处理中断调用uartintr发送缓冲区里数据。此时驱动的top和bottom就解耦了(这里意思是top和bottom不再是串行时序,两者可以并行进行)。
在发送完$后的空格后其实也会产生一个发送完成中断,由于这个中断既无键盘输入,uart的缓冲区又无字符,所以并不会做什么。
刚刚执行shell的core,此时也返回了进程空间,并且继续执行shell。shell又执行gets,最终到sys_read,consoleread,consoleread会一直阻塞自己等待键盘中断传进来字符,所以我们看到启动xv6之后,输出完$ 之后便一直阻塞。
ls\n的输出过程
当我们敲击键盘ls,每一个字符会产生一个接收中断,这里触发中断traps,调用uartintr,调用uartgetc将 l 从寄存器中读出,然后调用consoleintr。
1 | default: |
The job of consoleintr is to accumulate input characters in cons.buf until a whole line arrives. consoleintr treats backspace and a few other characters specially. When a newline arrives, consoleintr wakes up a waiting consoleread (if there is one).
Once woken, consoleread will observe a full line in cons.buf, copy it to user space, and return (via the system call machinery) to user space.
cosoleintr默认会将每个字符回显到console,同时也会存储这个字符到cons的buf中,这是为了一旦读到换行时能唤醒consoleread线程(如果有的话),这样consoleread便能返回。于是shell便能解析命令,然后执行。
关于解耦的问题
解耦:谁也不会影响谁
1.进程与设备解耦 2.生产者和消费者解耦
核心就是通过缓冲区和中断机制实现
1.进程不必等待设备输入,干自己的事情就好,设备输入会中断进来。设备也不必等着进程的输出,他要是想输出了把字符放在缓冲区就好。
2.生产者和消费者亦是如此。 谁想干什么事找缓冲区去~
过buffer将consumer和producer之间解耦,这样它们才能按照自己的速度,独立的并行运行。如果某一个运行的过快了,那么buffer要么是满的要么是空的,consumer和producer其中一个会sleep并等待另一个追上来。
学生提问:这里的buffer对于所有的CPU核都是共享的吗?
Frans教授:这里的buffer存在于内存中,并且只有一份,所以,所有的CPU核都并行的与这一份数据交互。所以我们才需要lock。
MIT6.S081 xv6book chapter5
https://xyz.desirer233.fun/2024/01/12/MIT6.S081/book/chapter5/