一、硬件部分
网卡收到packpage,网卡就会产生中断,按下键盘也会产生中断。
如果受到一个中断,sw save its work, process interupt, resume its work。它和page fault,syscall都使用了相同的机制。
中断和系统调用的三大差别:
- 异步性(Asynchronous) :中断由硬件生成,与当前运行的进程无关;而系统调用发生在当前进程的上下文中。
- 并发性(Concurrency) :中断和设备处理是并行的,设备(如网卡)独立工作,生成中断,且与CPU并行执行。
- 外设编程(Program Device) :中断通常涉及与外部设备(如网卡、UART)的交互,这些设备需要编程和管理,而系统调用通常是在操作系统内核中处理。
(一) PLIC
所有的设备都连接到处理器上,处理器上是通过Platform Level Interrupt Control ( 简称PLIC )来处理设备中断。
中断到达PLIC后,PLIC会路由这些分发这些中断,路由到某个CPU核(PLIC仅仅负责分发中断,需要内核对PLIC编程告诉他中断分发到哪里,PLIC本身并没有确保中断处理的机制)。
如果所有的CPU核都在处理中断,PLIC会将中断等待CPU核空闲。 大致流程如下:
- PLIC会通知当前有一个待处理的中断
- 其中一个CPU核会Claim接收中断,这样PLIC就不会把中断发给其他的CPU处理
- CPU核处理完中断之后,CPU会通知PLIC
- PLIC将不再保存中断的信息
二、驱动程序与中断
当用户输入一个字符,UART设备就会产生一个中断,激活XV6的 Trap 处理程序。
Trap 处理程序将会调用devintr,读取scause判断是否为外部设备产生的中断。
之后通过PLIC(平台级中断控制器)判断中断设备,如果是UART设备,就会调用uartintr函数。
驱动程序:负责管理和控制硬件设备的代码,处理硬件与操作系统之间的交互。包括配置设备硬件、指示设备执行操作、处理硬件中断等。
所有的驱动都在内核中。我们今天要看的是UART设备的驱动。
(一) bottom部分和top部分
驱动程序负责管理硬件设备与操作系统之间的交互,通常分为bottom部分和top部分:
bottom部分(中断处理程序) :
- 运行在内核空间,响应硬件设备(如 UART)发出的中断信号。主要任务是快速处理中断事件,如更新状态、处理缓冲区数据等,不涉及复杂操作。
- 由于它不依赖进程上下文,因此不能直接访问进程的虚拟内存。
top部分(用户接口) :
- 提供用户空间与设备交互的接口(如
read()、write()系统调用)。应用程序通过这些接口与设备进行数据交换,例如读取或发送数据。
而 Top 和 Bottom 通过队列(缓冲区) 来进行数据通信,这样做有几个好处:
- 用于存储数据,避免设备与 CPU 之间的直接冲突。
- 实现设备的读写操作与 CPU 操作的解耦,提高效率和并发性。
(二) Memory Mapped I/O
内存映射 I/O (Memory-mapped I/O) 是一种将文件或设备内容直接映射到程序的内存空间的技术,它通过将文件或设备的内容直接映射到程序的内存空间,使得程序可以像操作普通内存一样操作文件 。 它的工作原理如下:
- 操作系统将文件内容映射到程序的虚拟内存区域,使得程序可以通过内存地址直接访问文件数据。
- 程序读写这些内存地址时,操作系统会自动处理数据的加载和保存,确保访问的是文件的最新内容。
通常情况下,当程序读取或写入文件时,它会通过操作系统提供的API(比如 read() 和 write())来进行数据的传输。操作系统负责将磁盘上的数据加载到内存中,程序再处理这些数据。在频繁读取或修改大型文件时,会造成多次磁盘 I/O 操作。
(三) UART硬件接口
1. UART 相关概念
异步串行通信:仅需两根信号线(TX发送、RX接收),无时钟同步,依靠波特率(如115200bps)协调收发时序。
这里有三个关键寄存器:
- THR(发送保持寄存器) :写入数据后自动启动串行发送****。
- RHR(接收保持寄存器) :存储接收到的字节,触发中断后由CPU读取****。
- IER(中断使能寄存器) :配置中断类型(如接收完成、发送缓冲区空)。
CPU通过执行load/store指令与UART的寄存器进行交互(内存映射)。
实际上,CPU并不直接操作内存,而是与设备的寄存器交换数据。比如,当你向Transmit Holding Register写入数据时,CPU会将数据送入UART芯片,这样数据就通过串口被发送出去。
我们来看看硬件层面的操作流程:
consoleinit初始化UART硬件,配置中断(通过IER开启中断,并在PLIC(平台级中断控制器)中分配优先级)。- 设备触发中断(例如,用户输入时触发接收中断)。
-
- 接收中断:UART将串行数据合并为字节存入RHR → 触发中断 → CPU读取数据并传递给进程(如Shell)
- 发送中断:THR空时触发 → CPU填充新数据继续发送
输入输出示例(xv6的 $ ls )
- 输出流程:
-
- Shell调用
write将字符$写入标准输出(文件描述符1)。 - 内核将数据写入THR → UART发送至虚拟终端(如QEMU模拟的UART)→ 触发发送完成中断****。
- Shell调用
- 输入流程:
-
- 用户输入
ls→ 键盘信号经UART接收线路转换为字节 → 存入RHR并触发接收中断。 - 中断处理程序读取数据 → 通过控制台驱动传递给Shell执行命令****。
- 用户输入
2. UART驱动的top部分
我们要知道将 Shell 程序的提示符 $ 通过 Console(控制台)输出到终端上这个过程,背后其实是通过 UART 设备来完成的。本质上是“从应用层的 Shell 程序写数据,经过内核处理,最终通过硬件设备输出到终端”。
Shell 是用户与操作系统内核之间的媒介,负责解析用户输入的命令,将其转化为内核能理解的指令,并管理输入/输出流程。
那么我们在来看一下,如何将 Shell 程序的提示符 $ 通过 Console(控制台)输出到终端上。
其实我们脑海中现有个大致的概念,先将整个过程可以类比为:
用户通过“键盘”(Shell)输入指令 → 系统将指令打包交给“快递员”(内核) → 快递员将包裹(字符 $)暂存到“中转站”(环形缓冲区) → “卡车”(UART 硬件)按顺序将包裹运送到目的地(终端) → 卡车卸货后通知快递员继续送货。
1️⃣ Console 设备初始化:
系统启动时,首先会创建 /dev/console 设备文件,作为用户与系统交互的入口。这个设备文件通过文件描述符 0(标准输入) 绑定到控制台设备。接着,系统通过复制操作(如 dup)将 标准输入(0) 的配置信息传递给 标准输出(1) 和 标准错误(2) ,使得三者都指向同一个 Console 设备。
2️⃣ 数据流从 Shell 到 UART
用户在 Shell 中输入命令(例如打印字符 $)时,系统会通过 write 调用将数据写入标准错误输出(文件描述符 2)。这里选择标准错误输出可能是为了调试或特殊标识需求。
内核收到写请求后,会检查参数合法性,并调用文件写入函数。由于文件描述符 2 对应的是设备类型(Console),内核会进一步调用 Console 设备的专用写函数。
3️⃣ Console 与 UART 的协作
Console 设备的写函数将字符 $ 传递给 UART 驱动。UART 驱动内部有一个 环形缓冲区(类似一个“临时存储区”,容量为 32 字符),用于暂存待发送的数据。
缓冲区规则:读指针和写指针重合时,表示缓冲区为空;写指针追上读指针时,表示缓冲区已满,需等待发送完成。
4️⃣ UART 发送数据到硬件
当字符 $ 被放入缓冲区后,UART 驱动会检查硬件状态:
- 如果 UART 空闲(例如之前的字符已发送完毕),则从缓冲区取出字符
$,直接写入发送寄存器(THR); - 发送寄存器将字符传输到物理线路上(如串口线或虚拟终端)
5️⃣ 硬件完成数据发送
UART 硬件将字符$发送到终端,并在发送完成后发出中断信号,通知 CPU 数据:已发送成功,UART 处于空闲状态,可以处理下一个字符。
其关键点在于:
- 设备初始化:通过文件描述符的绑定和复制,统一管理输入、输出和错误信息****。
- 数据流动:从用户输入到硬件发送,依赖内核的驱动和缓冲区管理,保证数据不丢失且有序传输。
- 硬件协作:UART 的环形缓冲区和中断机制,解决了低速硬件与高速 CPU 之间的速度不匹配问题。
3. UART驱动的bottom部分
当 Shell 程序正在运行时(比如打印提示符“$ ”),如果键盘或其他外设(比如 UART)触发了中断 ,RISC-V CPU 会暂停当前的用户程序,将运行权限交给内核,并跳转到预先定义的中断处理函数去处理中断。 具体流程:
- 清除与当前中断相关的 SIE 寄存器中的 bit 位,防止被其他中断打扰。
- 保存当前程序计数器(PC)到 SEPC 寄存器,并保存运行模式。
- 切换到 Supervisor mode(内核模式),准备执行中断处理程序。
- 将程序计数器设置为 STVEC 寄存器的值,跳转到中断处理函数。
(四) Interrupt 并发
我们接下来看看,他们是怎么保证并发时候的线程安全的。
- 生产者:Shell通过
uartputc将字符(如$)写入循环缓冲区(buffer),写指针移动。若buffer满,Shell调用sleep等待。 - 消费者:UART通过
uartintr从buffer读取字符并发送到Console,读指针移动。若buffer空,UART等待中断。 - 同步机制:
-
- 锁:保护buffer,防止多核同时访问导致数据混乱。
- Sleep/Wakeup:当buffer满时,Shell休眠;UART清空buffer后唤醒Shell。
每次UART发送完字符后触发中断,通知CPU继续处理。
最终,字符$通过以下步骤输出到Console:
- Shell写入
$到buffer。 - UART读取
$并发送到Console。 - 用户看到
$
三、附录
(一) XV6 代码解读
首先追踪 trap.c ,我们可以看到 devintr()函数。我们事实上可以根据devintr()函数中去判断,SCAUSE 寄存器判断当前中断是否来自于外设的中断。如果是的话,再调用plic_claim函数来获取中断。
plic_claim函数位于plic.c文件中。在这个函数中,当前CPU核会告知PLIC,自己要处理中断,PLIC_SCLAIM会将中断号返回,对于UART来说,返回的中断号是10。
从devintr函数可以看出,如果是UART中断,那么会调用uartintr函数。我们现在讨论的是向UART发送数据。因为我们现在还没有通过键盘输入任何数据,所以UART的接受寄存器现在为空。会直接跳到运行 uartstart函数
这个函数会将Shell存储在buffer中的任意字符送出。实际上在提示符“”的同时,并发的将空格字符写入到buffer中。所以UART的发送中断触发时,可以发现在buffer中还有一个空格字符,之后会将这个空格字符送出。
在多核 CPU 系统中,多个核可能同时访问 UART 缓冲区(比如一个核写数据,另一个核发送数据)。为了保证数据的一致性,驱动程序会通过锁机制来串行化这些操作:
- 当一个核执行
uartputc写数据时,会加锁,防止其他核同时写入。 - 当另一个核处理 UART 发送中断时,会加锁,确保只有一个核访问缓冲区。
锁的作用:避免并发访问导致数据冲突,同时确保多个核可以正确地共享一个 UART 缓冲区。