type
status
date
slug
summary
tags
category
icon
password
Property
Aug 9, 2023 02:59 PM
思考一下,假设你正在用金山文档或者google docs编辑文字,当你敲下一个字符,屏幕上就会显示一个字符。操作系统做了哪些工作来支持这一过程?为了实现过程,在开机时操作系统又需要做什么工作来准备即将到来的中断?
Basic concepts
driver → 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
- interacts with processes that may be waiting for I/O from the device.
Device drivers execute code in two contexts:
- top half that runs in a process’s kernel thread — 自顶向下
- read:
sys_read()
→fileread()
- write:
sys_write()
→filewrite()
- read:
consoleread()
→either_copyout()
- write:
consolewrite()
→uartputc()
→uartstart()
→WriteReg()
via sstem calls such as read and write that want the device to perform I/O
device ash the hardware to satr an operation(e.g. ask the disk to read a block);
then the driver code waits for operation to complete
the device completes the operayion and raises an interrupt(trap)
kernel trap code handles this trap and calls the driver’s interrupt handler;
- bottom half that executes at interrupt time. — 自下而上
- figure out what operation has completed
- read:
devintr()
→uartintr()
→consoleintr()
- write:
devintr()
→uartintr()
→uartstart()
- wake up the corresponding waiting process;
- tell the hardware to start work on any waiting next operation
- UART(Universal Asynchronous Receiver-Transmitter)
- UART 硬件:在计算机系统中,UART 是一种通用的串行通信设备,它用于将并行数据转换为串行数据以进行传输,或将串行数据转换为并行数据以供计算机处理。UART 通常具有发送(Transmitter)和接收(Receiver)两个部分,用于在计算机和外部设备之间进行双向通信。
- xv6 中的 UART:xv6 操作系统实现了对 UART 的支持,以便在操作系统内部进行串行通信。它使用 UART 进行输入/输出操作,允许用户与操作系统进行交互,或者通过串口与其他设备进行通信。
- UART 在操作系统中的作用:在 xv6 中,UART 可以用于实现终端(Terminal)功能,使用户能够在操作系统运行时与系统交互。用户可以通过终端发送命令、接收输出等。此外,UART 还可以用于调试和监控操作系统的运行状态,例如输出调试信息或错误消息。
- UART 驱动程序:xv6 包含了针对 UART 设备的驱动程序,这些驱动程序负责管理与 UART 的通信。它们提供了操作 UART 的接口,允许操作系统通过读取和写入操作与 UART 进行交互。
可以理解为是一组寄存器
是一种用于串行通信的硬件设备,用于在计算机系统内部或与外部设备之间传输数据。在 xv6 操作系统中,UART 是一个重要的组件,用于实现与外部设备(如终端、串口设备等)的通信。
总之,xv6 操作系统中的 UART 是一个重要的组件,用于实现操作系统与外部设备之间的串行通信。它使操作系统能够与用户进行交互,同时也可以在调试和监控操作系统时发挥作用。
- 一些重要的寄存器:
RHR中保持着UART接收的输入,等待内核将其内容取走;
THR保持着内核的输入,等待UART将其发送。对于内核的read或write指令,将访问对应的RHR或THR控制寄存器。
LSR包含一些位,LSR_RX_READY指示RHR中是否有输入字符,等待内核将其读出;一旦被读出,UART就将其从内部的FIFO缓冲区中移除,直到缓冲区为空时置位LSR_TX_IDLE。
Console input
console driver 是一种driver,负责与uart硬件相沟通
UART与输入输出设备(如键盘屏幕)相连,也就是console driver是从UART中读写相关的数据,console直接交互的对象是UART,UART中的各种寄存器的状态也是由console来管理,包括读写‘初始化等等
- console driver的工作原理:
- The console driver accepts characters typed by a human, via the UART serial-port hardware attached to RISC-V
- The console driver accumulates a line of input at a time, processing special input characters such as backspace and control-u
- User processed, such as the shell, use the read system call to fetch lines of input from the console.
- When you type input to xv6 in QEMU, your key strokes are delivered to xv6 by way of QEMU’s simulated UART hardware.
- The UART hardware that the driver talks to is a 16550 chip emulated by QEMU
- on a real computer, a 16550 would manage an RS232 serial link connecting to a terminal or other computer. When running QEMU, it’s connected to your keyboard and display.
- The UART hardware appears to software as a set of memory-mapped control registers.
- There are some physical addresses that RISC-V hardware connects to the UART device, so that loads and stores interact with the device hardware rather than RAM.
- The memory-mapped addresses for the UART start at 0x10000000, or UART0 (kernel/memlayout.h:21).
- There are a handful of UART control registers, each the width of a byte.
- UART硬件的初始化
- Configures the UART to generate a receive interrupt when the UART receives each byte of input
- A transmit complete interrupt each time the UART finishes sending a byte of output.
调用consoleinit里的uartinit()来初始化UART硬件,写入相关的控制寄存器,配置好传输的波特率,重置FIFO缓冲区,最后开启接收中断receive interrupt和发送完成中断transmit complete interrupt保证能够产生两种interrupts
Xv6’s main calls
consoleinit
(kernel/console.c:182) to initialize the UART hardware. - UART何时产生中断
- 接收到一个字符和发送完成一个字符的时候
- shell如何读取console中的内容?
- Calls to the system call make their way through the kernel to consoleread
consoleread()
waitsfor input to arrive (via interrupts) and (after a whole line has arrived) returns to the user process- if the user hasn’t typed a full line yet, any read processes will wait in the sleep call
- 用户输入字符到console读取到该字符的过程:
- When the user types a character, the UART hardware asks the RISC-V to raise an interrupt, which activate xv6’s trap handler
- The trap handler calls
devintr()
(kernel/trap.c:177), which looks at the RISC-V scause register to discover that the interrupt is from an external device. - Then it asks a hardware unit called the PLIC to tell it which device
interrupted
(kernel/trap.c:86). If it was the UART,devintr()
callsuartintr()
. uarintr()
一个一个读取字符,以便通过consoleintr()
函数缓存到buff中,另一边通过consputc()回显屏幕,直到换行符等特殊符号到达,然后通过wakeup()函数激活已经在consoleread()函数中sleep的进程。uartintr
(kernel/uart.c:180) reads any waiting input characters from the UART hardware and hands them toconsoleintr
(kernel/console.c:136); it doesn’t wait for characters, since future input will raise a new interrupt. The job ofconsoleintr
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 waitingconsoleread
(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.
总结一下: 用户输入字符 → UART中RHR接收字符 → UART发出中断 → 内核接收中断,陷入 → trap handler发现是外部中断,来自UART → 调用uartintr → 发现RHR中有字符可读 → 调用consoleintr → 将输出字符缓冲到cons.buf → 读到’\n’或者’ctrl+D’ → 唤醒consoleread → 读出一整行,拷贝到用户空间
Code
- consoleinit()
xv6在main进行了控制台的初始化,调用
consoleinit()
来完成,然后consoleinit()
又调用uartinit()
初始化UART(kernel/uart.c),如下所示。之后,每当UART收到一个字节的输入时,就会产生receive interrupt;每当UART完成一个字节的发送时,就会产生transmit complete interrupt。- consoleread()
xv6的shell可以主动地从控制台读取输入,shell发出read的系统调用请求后,最终将导向到控制台驱动程序的top half,并执行
consoleread()
(kernel/console.c)。consoleread()
主要从缓冲区cons.buf中读取整行的输入到用户空间中,如果cons.buf中没有字符可读,或者读完了缓冲区中的所有字符但该行仍未结束,shell自己会睡眠,并等待条件满足时才会被唤醒。- consoleintr()
如果有输入字符到达控制台,控制台将会产生中断,因此跳转到控制台的中断处理程序
consoleintr()
,它会将输入字符缓冲到cons.buf中,然后返回。直到cons.buf里累积了一整行的输入,consoleintr
才会唤醒一个用户进程的(比如shell)consoleread
,然后consoleread将缓冲在cons.buf中的一整行输入,拷贝到用户空间,然后返回到用户进程。- devintr()
用户通过键盘输入一个字符,到控制台接收到这个字符,在这之间我们通过UART进行传输。
当用户键下键盘输入一个字符,且UART的RHR接收到时,UART就会向CPU发出中断(receive interrupt),在内核中执行trap handler。发现是设备中断后,trap handler会跳转到
devintr()
,如下所示。在devintr中,通过检查scause中的值,发现该设备中断来自于外部设备,然后由PLIC(管理着所有的外部设备中断)告诉CPU,是哪个设备产生中断。最后发现是UART之后,devintr就会跳转到uartintr()
,即UART的中断处理程序,进行相关处理。- uartintr()
uartintr(kernel/uart.c)如下所示,它首先尝试从其控制寄存器RHR中读出一个字符(我们说过RHR保持着UART接收的输入),如果有,直接将该字符交给控制台驱动程序的bottom half,即consoleintr来处理该字符(实际上在UART的中断处理程序中,又调用了控制台的中断处理程序,但这不是通过中断实现的,并不是中断嵌套),然后consoleintr做的工作正如前面我们所说明的那样。但如果RHR中没有字符可读,uartintr并不会阻塞地等待那些尚未到达RHR的字符,但也不会因此错过它们,因为之后新的字符被用户键入时,UART还会发出后续中断
处理完RHR中保持的字符后,UART接着调用uartstart,在该函数中,检查UART的缓冲区里是否有需要UART发送的数据,如果有,并且THR为空,就将该字符写入到THR中,UART就会发送该字节,在下一小节我们将更仔细地说明发送的部分。
Console output
- 缓冲区
由UART负责维护
- 系统调用write()是如何输出到终端的?
consolewrite()
→ uartputc()
→ uartstart()
unartintr()
→ uartstart()
用户进程需要产生一些输出到控制台上,以便通过屏幕显示给用户。所以,用户进程,例如shell,通过系统调用
write
,往某个发送缓冲区里写入一些字符;我们通过UART来传输这些字符,所以我们应该往UART的发送缓冲区里写入它们,然后用户进程就可以返回;UART会在适当的时候,将一个字符写入控制寄存器THR;最后,UART将字符成功地发送到了控制台,控制台呈现输出给用户。Code
- uartputc()
用户进程请求的write系统调用,最终将导向到UART驱动程序的top half,并执行
uartputc()
(kernel/uart.c),如下所示。对于用户进程来说,只需要通过uartputc向发送缓冲区中写入一个字符,并且调用uartstart()
,而uartstart无论如何都会很快就返回,后面我们将看到这点;如果发送缓冲区已满,驱动程序的决定是暂时将该进程挂起,让其睡眠,什么时候唤醒呢?同样也是在uartstart中。- uartstart()
uartstart()
尝试发送一个位于发送缓冲区中的字符(按FIFO的方式),如果发送缓冲区为空,或者控制寄存器THR还持有着字符(这代表着对于上一个字符,UART的发送已经就绪,只是还没发送出去),那么uartstart将会直接返回。我们可以看到,无论该字符能不能马上被UART发送出去,uartstart都会很快就返回,因此我们可以认为uartstart几乎是无阻塞的。再返回到上一层,在uartputc中,我们要么会调用uartstart,要么让当前进程挂起并睡眠,因此uartputc也是很快就返回的。所以,UART暴露给用户程序或内核的uartputc接口是异步的,用户进程的write可以使用这种接口,因为用户进程可以很快地返回或被挂起,从而很快地进行后续工作或者让出CPU资源。
- uartputc_sync()
uartputc也提供了同步,或者说阻塞的版本,uartputc_sync。该版本的接口,用于满足那些需要马上响应的需求,因此CPU就阻塞在某处,直到THR中的字符被发送,然后就把需要发送的新字符写入THR。你也可以看到,该字符不会写入发送缓冲区中。事实上,内核的printf就使用这个同步的版本,因为内核打印的消息比较重要,我们希望能尽快地显示给用户。
PLIC
- 作用
处理设备中断或者分发中断,PLIC会管理来自外设的中断,比如说对其优先级进行编程等等
- 处理中断的流程
PLIC会通知当前有一个待处理的中断
其中一个CPU核会调用
plic_claim()
Claim接收中断,这样PLIC就不会把中断交给其他CPU处理该CPU处理完中断之后,CPU会通过
plic_complete()
通知PLICPLIC不再保存中断的信息
Device Driver
bottom部分:主要是Interrupt handler。当一个进程送到了CPU,并且CPU设置接收中断,那么CPU会调用Interrupt handler处理中断
top部分:主要是用户进程,或者是内核其他调用的接口。对UART来说,这里有read/wirte接口,可以被更高层代码调用
top:
read → fileread →consoleread → buffer空,就阻塞,否则读取数据
bottom:
kerneltrap/usertrap → deintr → uartintr → 循环uartgetc和consoleintr,满了之后,唤醒consoleread,如果空就调用uartstart
top:
printf → for循环putc每次都是一个字符 → uartintr → write → sys_write → filewrite → consolewrite → for循环uartputc → uartputc将字符输出到output buffer,满了就阻塞,否则 → uartstart → while循环写入THR寄存器,发现空了就返回
bottom:
kerneltrap/usertrap → devintr → uartintr → uartstart write到寄存器中
xv6的中断初始化过程
- start()
函数中对中断相关寄存器的初始化。这里将所有的中断都设置在Supervisor mode,然后设置SIE寄存器来接收External,软件和定时器中断,之后初始化定时器
- main()
consoleinit()
uartinit()
此时xv6还未初始化PLIC,所以CPU不能对中断进行感知
trapinit()
trapinithart()
plicinit()
// set up interrupt controller
设置PLIC可以接受那些设备的中断
第一行使能UART的中断
第二行设置PLIC接受I/O磁盘的中断
plicinithart()
// ask PLIC for device interrupts
需要每个CPU都调用,每个核都表示自己对UART和VIRTIO感兴趣
暂时将中断优先级都设置为0
xv6中shell程序输出$的top部分
init.c:main()
:该函数由main.c:userinit()
创建,也就是xv6的第一个用户进程,该函数首先通过mknod()
创建了一个代表console的设备,type为FD_DEVICE,major为CONSOLE,然后通过open函数打开该文件,这是第一个打开的文件,文件描述符为0。接着通过dup系统调用创建stdout和stderr,分别为1和2,所以文件描述符0,1,2都代表console,之后通过fork+exec创建子进程,即执行sh.c:main()
sh.c:main()
:该函数主要通过getcmd()
函数来读取输入,而getcmd首先会调用fprintf()
来向console写入一个字符:fprintf()
→putc()
→write()
→syswrite()
→filewrite()
file.c:consolewrite()
:该函数会首先判断文件的类型,f→type == FD_DEVICE
,紧接着向对应的设备调用write函数,即consolewirite()
console.c:consolewrite()
:这里先通过either_copyin()
将字符串拷入,之后调用uartputc()
。uartputc()
将字符写入给UART设备。uartputc()
会实际的打印字符
uart.c:uartputc()
:在该函数中,有一个buffer用来发送数据,和其读写指针共同构成一个循环队列。队列满时会让进程sleep
uart.c:uartstart()
:uartstart()
通知设备执行操作。如果buffer没有问题,就将字符写入THR,一旦数据送到设备,系统调用就会返回,shell就会继续执行。从内核返回机制与trap一样
- UART会将数据输出到屏幕,并再产生一个中断,接下来:
xv6中shell程序输出的bottom部分
trap.c:usertrap()
:进入devintr()
,首先判断是否是一个supervisor external interrupt,是的话就调用plic_claim()
获取中断号,对UART来说,中断号是10,进而进入uartintr()
uart.c:uartintr()
:该函数通过uartgetc()
读取寄存器中的字符,这时没有字符,返回-1,直接进入uartstart()
uart.c:uartstart()
:该函数将shell存储在buffer中的任意字符送出,实际上是在还有一个空格要输出。write系统调用可以在UART发送提示符的同时并发地将空格写入buffer,所以在UART的中断触发时,可以发现在buffer中还有一个空格字符,之后会将这个字符送出
- xv6中UART从键盘获取输入的top部分
中断相关内存布局
UART0
: 这个宏定义了一个 UART (Universal Asynchronous Receiver-Transmitter) 外设的物理地址。UART 是用于串行通信的硬件设备,通常用于与外部设备(如终端或串行通信设备)进行通信。
UART0_IRQ
: 定义了 UART0 外设的中断号。
VIRTIO0
: 定义了 virtio 驱动的物理地址,virtio 是一种用于虚拟化环境的设备驱动标准。
VIRTIO0_IRQ
: 定义了 virtio 驱动的中断号。
CLINT
: 定义了 CLINT(Core Local Interruptor)的物理地址,CLINT 包含了计时器等信息。
CLINT_MTIMECMP(hartid)
: 定义了 CLINT 中特定 Hart(硬件线程)的 MTIMECMP 寄存器的地址偏移量,用于设置时钟中断触发时间。
CLINT_MTIME
: 定义了 CLINT 中的 MTIME 寄存器的地址偏移量,表示自系统启动以来的时钟周期数。
PLIC
: 定义了 PLIC(Platform-Level Interrupt Controller)的物理地址,PLIC 用于管理外部中断。
PLIC_PRIORITY
,PLIC_PENDING
: 定义了 PLIC 中优先级和中断挂起寄存器的地址偏移量。
PLIC_MENABLE(hart)
,PLIC_SENABLE(hart)
: 定义了使能特定 Hart 的中断的寄存器地址偏移量。
PLIC_MPRIORITY(hart)
,PLIC_SPRIORITY(hart)
: 定义了特定 Hart 的中断优先级寄存器的地址偏移量。
PLIC_MCLAIM(hart)
,PLIC_SCLAIM(hart)
: 定义了特定 Hart 的中断声明寄存器的地址偏移量。sysfile.c:sys_read()
:read系统调用中,调用了fileread()函数,该函数首先判断文件类型,接着向调用自己的read函数(consoleread)console.c:consoleread()
:console.c:consoleintr()
:默认情况下字符会通过consputc回显到屏幕上。之后字符存放在buffer中,遇到换行符或者ctrlD时唤醒之前sleep的进程,从buffer中读出字符- 通过buffer将top和bottom解耦,可以并发的进行
参考
- xv6book
- 作者:GJJ
- 链接:https://blog.gaojj.cn/article/blog-88
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。