Lecture 9 - Interrupts

介绍中断

在进入今天关于中断的课程之前,我们回顾一下上周关于内存管理的内容。上周的课程主要讨论了内存的使用和管理,这引发了许多与内存相关的问题。我们今天先讨论一下内存在真实操作系统中是如何使用的,而不仅仅是像 xv6 这样的教学操作系统。

真实操作系统中的内存使用

下图显示了 Athena 计算机(MIT 内部共享使用的计算机)上 top 命令的输出:

image-20240821112350254

Mem 一行中,我们可以看到以下信息:

  • 总内存:计算机中的总内存为 33048332 KB
  • 已用内存:显示为 4214520 KB(被应用程序使用)和 26986864 KB(被 buff/cache 使用)。
  • 空闲内存:显示为 1846948 KB,即只有一小部分内存是空闲的。

这种内存分配情况在操作系统中非常常见。我们不希望物理内存闲置,而是希望它被充分利用,因此大部分内存被用作缓存(buff/cache)。这意味着,虽然看起来大部分内存已经被使用,但它实际上并没有被应用程序直接使用,而是被用作缓存,以提高系统性能。

内存管理的挑战

当内核或应用程序需要分配新的内存时,通常不会有足够的空闲内存。这时,操作系统需要撤回一些已经分配的内存,这可能来自应用程序或缓存。这意味着内存分配并不是一个低成本的操作,因为在大多数情况下,系统内存是紧张的,为了腾出空间,操作系统可能需要执行一些复杂的内存管理操作。

虚拟内存与实际内存的差异

通过 top 命令的输出,我们可以看到每个进程的虚拟内存地址空间(VIRT)和实际使用的物理内存(RES)。在许多情况下,实际使用的内存量远小于分配的虚拟内存地址空间。这种差异表明,操作系统正在利用虚拟内存管理技术,例如需求分页(demand paging),从而高效地管理内存。

系统的总体表现

top 命令的输出中,我们还可以了解到其他一些关于系统的信息:

  • 尽管有 103 个用户同时登录,系统的负载仍然很低,这表明系统的资源管理非常高效。
  • 系统运行了 249 天,这显示了系统的稳定性和可靠性。

这与 xv6 系统的简单性和短暂运行时间形成了对比,显示了现代操作系统在内存管理和系统稳定性方面的复杂性和优势。

中断:操作系统与硬件的交互

中断的基本概念

今天的课程主题是中断。中断是操作系统与硬件设备之间的一种重要交互机制。它们通常发生在硬件设备需要操作系统的关注时,例如:

  • 网卡收到数据包:当网卡接收到一个数据包时,它会生成一个中断,通知操作系统处理这个数据包。
  • 键盘输入:当用户按下键盘上的一个键时,键盘会生成一个中断,通知操作系统进行处理。

操作系统的任务是在接收到中断时保存当前的工作状态,处理中断,然后恢复并继续之前的工作。这一过程与之前讨论的系统调用过程非常相似,因为系统调用、page fault(页错误)、中断都依赖于相同的底层机制。

中断与系统调用的区别

尽管中断和系统调用在机制上有相似之处,但它们之间仍然存在一些关键的差别:

  1. 异步性 (Asynchronous)
    • 中断:中断是异步的,当硬件生成中断时,它与当前运行在 CPU 上的进程没有直接的关联。换句话说,中断可以在任何时候发生,无论 CPU 当前在处理什么任务。
    • 系统调用:系统调用则是同步的,它发生在正在运行的进程的上下文中,并且是由该进程主动发起的。
  2. 并发性 (Concurrency)
    • 中断:中断处理涉及到并发,因为硬件设备(例如网卡)和 CPU 是并行运行的。设备可能在处理某些任务的同时生成中断,而 CPU 也在执行其他指令。因此,操作系统必须管理这种并行性,以确保中断的处理不影响系统的正常运行。
    • 系统调用:虽然系统调用也涉及到上下文切换,但通常不会涉及到这种层次的并发。
  3. 设备编程 (Programming the Device)
    • 中断:中断通常由外部设备(如网卡或 UART)生成,而这些设备需要通过特定的编程进行控制。每个设备都有自己的编程手册,详细描述了设备的寄存器、操作方式,以及在读取或写入控制寄存器时设备如何响应。这些手册可能不像 CPU 指令集手册那样清晰明了,增加了设备编程的复杂性。
    • 系统调用:系统调用不涉及直接与硬件设备打交道,更多的是在操作系统内部完成处理。

本节课的讨论内容

本节课的内容集中在一个非常基础且实际的案例上,通过这个案例可以深入理解中断的机制:

  1. 控制台提示符的显示
    • 我们将探讨控制台中的提示符 “$” 是如何显示出来的。这涉及到操作系统如何处理硬件中断来更新显示内容。
  2. 处理键盘输入
    • 当用户在键盘上输入“ls”时,这些字符如何通过中断处理机制最终在控制台中显示出来。

这些案例将帮助我们理解操作系统如何通过中断处理与硬件设备进行交互,同时管理并发性和异步性,确保系统的正常运行。

中断的来源与处理流程

外部设备的中断来源

在这部分内容中,我们首先要探讨外部设备的中断是从哪里产生的,以及当中断发生时,CPU 会如何响应。我们关注的主要是外部设备的中断,而不是定时器中断或软件中断。

外设中断的源头是主板上的设备。下图展示了一个 SiFive 主板,它上面可以连接许多设备,如以太网卡、MicroUSB、MicroSD 等。

image-20240821121636066

主板上的各种线路将外设与 CPU 连接在一起。当设备产生中断时,CPU 需要识别并处理这些中断信号。

设备与 CPU 的连接

下图来自 SiFive 处理器的文档,展示了设备和 CPU 之间的连接方式。图中的右侧显示了多种设备,例如 UART0。我们之前已经了解到,UART0 映射到内核内存地址的某处,所有物理内存都映射在地址空间 0x80000000 之上(见虚拟地址空间与物理地址空间的映射关系)。通过向这些设备的内存地址执行 load/store 指令,CPU 可以对设备进行编程和控制。

20240816125238362

平台级中断控制器(PLIC)

所有设备的中断信号通过 Platform Level Interrupt Control (PLIC) 传递到 CPU。PLIC 的作用是管理和分发来自外设的中断信号。

下图是 PLIC 的结构图,展示了它如何处理和路由中断信号。

image-20240821122509813

  • 左上角显示了 53 个来自不同设备的中断信号。
  • 右侧是 CPU 核心,PLIC 会将中断路由到某个 CPU 核心进行处理。

如果所有 CPU 核心都正在处理中断,PLIC 会保留这些中断信号,直到某个 CPU 核心可以处理它们。因此,PLIC 需要保存一些内部数据来跟踪中断的状态。

PLIC 的中断处理流程

根据文档,PLIC 的处理流程如下:

  1. 中断通知:PLIC 首先通知 CPU 当前有一个待处理的中断信号。
  2. 中断认领(Claim):某个 CPU 核心会“认领”这个中断,从而避免其他 CPU 核心重复处理同一个中断。
  3. 处理中断:认领中断的 CPU 核心会处理这个中断。
  4. 中断完成通知:处理中断之后,CPU 核心会通知 PLIC 该中断已处理完毕,PLIC 将不再保存该中断的信息。

学生提问:PLIC 有没有什么机制能确保中断一定被处理?

这取决于内核如何对 PLIC 进行编程。PLIC 负责分发中断,内核则通过编程告诉 PLIC 中断应该如何分发。实际上,内核可以为中断设定优先级,因此这里具有很大的灵活性。

学生提问:当 UART 触发中断时,所有的 CPU 核心都会收到这个中断吗?

这取决于 PLIC 是如何被编程的。对于 XV6 来说,所有的 CPU 都可以收到中断,但最终只有一个 CPU 核心会认领并处理这个中断。

UARTUniversal Asynchronous Receiver/Transmitter,通用异步收发传输器)是一种用于串行通信的硬件模块或芯片。它是实现计算机与外部设备(如调制解调器、串口终端、传感器等)之间数据通信的重要组件。UART 在嵌入式系统、微控制器以及通信设备中广泛应用,常见的通信接口包括 RS-232、RS-485 和 TTL 串口。

UART 的基本工作原理

UART 的主要功能是将并行数据转换为串行数据进行传输(在发送端),以及将接收到的串行数据转换为并行数据(在接收端)。具体来说:

  1. 发送数据
    • 当主机需要发送数据时,数据通常以字节(8 位)的形式进入 UART。
    • UART 将这些字节的数据逐位(bit by bit)地串行输出。
    • 在发送时,UART 会按照特定的格式(起始位、数据位、奇偶校验位、停止位)逐位传输数据。
  2. 接收数据
    • UART 接收到串行数据后,按照相同的格式解析数据。
    • 它会将接收到的串行数据重新组合成字节形式,并将这些数据提供给主机进行处理。

UART 数据帧结构

UART 传输的数据通常由以下几部分组成:

  • 起始位(Start Bit):用于标识数据帧的开始。它通常是一个低电平(逻辑 0)。
  • 数据位(Data Bits):实际传输的数据,通常为 5 到 9 位(通常为 8 位)。
  • 奇偶校验位(Parity Bit)(可选):用于错误检测,通过计算传输的数据位是否符合奇偶性规则来检测传输过程中是否出现错误。
  • 停止位(Stop Bit):用于标识数据帧的结束。它通常是一个高电平(逻辑 1),可以是 1 位或 2 位。

UART 的异步特性

UART 是一种异步通信协议,这意味着发送端和接收端的时钟并不需要同步。为了确保数据的正确传输,双方需要预先约定通信速度(波特率),如 9600、115200 波特等。这种约定使得 UART 能够在没有共享时钟信号的情况下可靠地进行数据传输。

UART 设备

UART 设备是实现 UART 通信功能的硬件模块,通常集成在微控制器、嵌入式系统或其他硬件平台中。UART 设备负责将并行数据(如字节)转换为串行数据流,通过 Tx 线传输,并将接收到的串行数据转换为并行数据通过 Rx 线输出。

UART 设备可以独立存在,也可以作为嵌入式系统的一部分,集成在主板上,如在电脑的主板、嵌入式设备、微控制器等系统中。

UART 驱动程序

UART 驱动程序是操作系统中一段用于控制 UART 设备的软件代码。它负责在软件层面与 UART 设备进行交互,实现数据的发送和接收,以及处理相关的中断。

UART 驱动程序通常包含以下功能:

  1. 配置 UART:初始化 UART 设备,包括设置波特率、数据位长度、奇偶校验、停止位等参数。
  2. 数据发送:通过 UART 的 Tx 线将数据发送到另一端的设备。
  3. 数据接收:通过 UART 的 Rx 线接收数据,并将其传递给操作系统或应用程序。
  4. 中断处理:处理 UART 设备产生的中断,例如发送完成中断、接收数据中断等,确保数据正确地收发。
  5. 缓冲区管理:管理发送和接收缓冲区,确保数据传输的稳定性和效率。

UART 驱动程序的作用

UART 驱动程序在操作系统和硬件设备之间扮演桥梁的角色:

  • 用户应用程序可以通过 UART 驱动程序与 UART 设备进行通信,发送或接收数据。
  • 操作系统内核通过驱动程序来管理 UART 设备,处理硬件中断,确保数据传输的可靠性。

总的来说,UART 是一种基础的通信技术,广泛应用于计算机与外部设备或系统之间的串行通信。UART 设备提供硬件支持,而 UART 驱动程序则实现了从操作系统到硬件设备的控制和管理,使得操作系统能够可靠地通过 UART 进行数据通信。

驱动程序的结构与工作原理

在操作系统中,管理设备的代码通常称为驱动程序,这些驱动程序位于内核中,用于处理各种外设的中断并与设备进行交互。今天我们要重点讨论的是 UART 设备的驱动程序,其代码位于 uart.c 文件中。驱动程序通常分为两个主要部分:Bottom 半部Top 半部

Bottom 半部:中断处理程序(Interrupt Handler)

  • 角色:当设备发出中断信号并送达 CPU 时,CPU 会调用与该设备相关的中断处理程序,即 Bottom 半部。
  • 特性:中断处理程序通常不在特定进程的上下文中运行。它的任务是快速响应和处理中断,完成必要的工作后尽快返回,确保系统能够继续正常运行。

Top 半部:用户接口和数据处理

  • 角色:Top 半部提供了驱动程序的用户接口,是用户进程或内核的其他部分与设备交互的桥梁。对于 UART 驱动程序,Top 半部包括读写接口(read/write),允许更高层级的代码通过这些接口与 UART 设备通信。
  • 特性:Top 半部通常运行在用户进程的上下文中,处理来自用户或系统的请求,并将结果返回给调用者。

驱动程序中的队列与并行处理

驱动程序的设计中,队列(或缓冲区)起着关键作用:

  • 队列的作用:队列用于存储中断处理程序(Bottom 半部)和用户接口(Top 半部)之间的数据。Top 半部的代码可以从队列中读取或写入数据,而 Bottom 半部则负责在中断发生时,将数据放入或取出队列。这种设计使得设备和 CPU 之间的并行处理得以顺利进行,避免了数据传输中的冲突和延迟。
  • 并行与解耦:通过队列的设计,驱动程序有效地解耦了并行运行的设备与 CPU,确保了系统的稳定性和效率。

驱动程序中的限制与操作

由于中断处理程序(Bottom 半部)并不在特定进程的上下文中运行,因此它在进行内存读写时存在一定限制:

  • 内存访问的局限性:中断处理程序无法直接访问用户进程的地址空间,因为它不知道当前使用的页表(page table)。因此,底部半部通常只执行与设备相关的基本操作,如将数据移动到缓冲区或从缓冲区读取数据。
  • Top 半部的作用:驱动程序的 Top 半部负责与用户进程交互,并根据需要调整内存地址和数据的处理。这部分代码能够在特定进程的上下文中运行,因此可以正确处理与用户地址空间相关的操作。

驱动程序的重要性

在许多操作系统中,驱动程序代码的总量可能会超过内核本身。这是因为:

  • 多样性:每种设备都需要一个特定的驱动程序来与操作系统交互,而现代计算机中可能有大量不同类型的设备。
  • 复杂性:不同设备的操作方式、通信协议和中断处理机制可能各不相同,因此需要专门的代码来处理每种设备的特殊需求。

设备编程与 Memory-mapped I/O

在操作系统中,与设备进行交互通常通过Memory-mapped I/O(内存映射 I/O)来实现。这种方式使得设备的控制寄存器映射到系统的物理地址空间,从而能够使用常规的 load/store 指令对设备进行编程和操作。以下是对这一机制的详细解析。

Memory-mapped I/O 的工作原理

Memory-mapped I/O 是指设备的控制寄存器被映射到系统的物理地址空间的特定区域。主板制造商通常会决定这些设备在物理地址空间中的具体位置。操作系统通过以下内容来与设备交互:

  1. 设备地址映射:设备的控制寄存器映射到物理地址空间的特定区域。这些地址在主板的设计中是固定的,操作系统需要知道这些地址的具体位置才能与设备交互。

  2. 普通的 load/store 指令:操作系统通过普通的 load/store 指令来读写这些映射到物理地址空间的控制寄存器。例如,执行 store 指令将数据写入网卡的控制寄存器,可能会触发网卡发送一个数据包;执行 load 指令可能会读取某个设备的状态寄存器。

  3. 设备文档:要正确操作设备,操作系统必须依赖设备文档。文档详细描述了设备寄存器的地址、每个寄存器的功能,以及不同位(bit)对应的行为。有时文档清晰易懂,但也可能存在不够详细或难以理解的情况。

SiFive 主板设备的物理地址

下面列出了几个SiFive 主板中不同设备对应的物理地址,展示了设备如何映射到物理地址空间中。

  • 0x200_0000 对应 CLINT(Core Local Interruptor),用于管理处理器本地的定时器和中断。
  • 0xC000_0000 对应 PLIC(Platform-Level Interrupt Controller),用于处理外部设备的中断。
  • 0x1001_0000 对应 UART0,用于串口通信。

在 QEMU(一个开源的硬件虚拟化平台)中,UART0 的地址略有不同,因为 QEMU 并不是完全模拟 SiFive 主板,而是模拟了一个与其类似的系统。

UART 驱动的工作原理

我们以 QEMU 模拟的 UART 设备(16550 芯片)为例,深入了解驱动程序如何通过 Memory-mapped I/O 与设备交互。UART 驱动程序用于实现串口通信,键盘输入和控制台输出都通过这个设备来实现。

image-20240821133801722

UART 寄存器的结构

UART 芯片包含多个寄存器,每个寄存器都用于不同的目的。例如:

  • 控制寄存器 000:用于传输和接收数据。写入数据到这个寄存器会触发 UART 发送数据,通过串口线传输到接收端的设备。
  • 控制寄存器 001(IER - Interrupt Enable Register):用于控制 UART 的中断生成。这个寄存器的不同位(bit)控制不同类型的中断,比如接收数据中断、发送完成中断等。

中断处理与数据传输

在串口通信中,为了确保数据不会被覆盖,内核和设备之间需要遵循一些协议:

  • 数据发送协议:当内核写入数据到 UART 的发送寄存器后,UART 会将数据通过串口发送出去。发送完成后,UART 会生成一个中断通知内核可以写入下一个数据。如果内核在发送完成之前再次写入数据,可能会导致数据丢失或覆盖。

  • FIFO(First In, First Out)队列:UART 通常会有一个容量为 16 字节的 FIFO 队列,用于暂存要发送的数据。但即使有这个队列,内核仍然需要确保不会写入超出队列容量的数据,否则会导致数据被覆盖。

通过 Memory-mapped I/O,操作系统能够有效地与硬件设备进行交互,驱动程序通过 load/store 指令来操作设备的控制寄存器,从而实现对设备的控制和数据传输。在 UART 设备的例子中,我们看到这种机制如何在串口通信中发挥作用,并了解到内核在处理硬件中断和数据传输时需要注意的细节。

中断处理流程概述

通过 Console 显示 “$ ls” 了解设备中断

在 xv6 启动时,Shell 会输出提示符 “$ ”,等待用户输入命令。当用户在键盘上输入 ls 并按下回车,最终在 Console 上会看到 “$ ls”。这是一个典型的设备中断工作流程。

Shell 输出 “$ ”

当 Shell 需要显示提示符 “\$ ” 时,操作系统会通过 UART(通用异步收发器)设备将字符传输到 UART 的寄存器中。UART 设备会在成功发送完字符后产生一个中断信号,这个中断会通知处理器,字符已经成功发送。随后,在 QEMU 模拟环境中,这个字符会通过模拟的 UART 芯片传输到虚拟 Console 上,显示出 “$ ” 字符。

用户输入 “ls”

另一方面,当用户通过键盘输入 ls 时,输入的每个字符都会通过键盘连接的 UART 输入线路传输。UART 设备接收到这些字符后,会将它们通过串口传输到另一端的 UART 芯片。这个 UART 芯片会将接收到的比特流重新组合成字节(Byte),并生成一个中断信号,通知处理器有新的输入。然后,处理器会通过中断处理程序(Interrupt handler)来处理这个来自 UART 的字符,最终在 Console 上显示用户输入的 ls 字符。

中断相关的寄存器

在 xv6 操作系统中,处理设备中断的过程涉及多个步骤和相关的寄存器配置。为了更好地理解中断的工作原理,我们需要详细研究这些寄存器及其在中断处理中的角色。

1. SIE(Supervisor Interrupt Enable)寄存器

  • 功能:SIE 寄存器控制中断的启用或禁用。该寄存器包含多个位(bits),其中每个位对应一种特定类型的中断:
    • E 位:用于外部设备的中断,例如 UART 设备的中断。
    • S 位:用于软件中断,这种中断可能由一个 CPU 核向另一个 CPU 核发送。
    • T 位:用于定时器中断。
  • 应用场景:SIE 寄存器使操作系统能够选择性地启用或禁用某些类型的中断。例如,操作系统可能只启用与 UART 相关的外部中断,而禁用其他中断。

2. SSTATUS(Supervisor Status)寄存器

  • 功能:SSTATUS 寄存器包含一个全局中断使能位,控制所有中断的启用或禁用。这使得操作系统可以通过 SSTATUS 寄存器一次性启用或禁用所有类型的中断,而无需逐个配置。

  • 应用场景:SSTATUS 寄存器通常用于在操作系统中执行关键代码段时,暂时关闭所有中断,以避免干扰。处理完关键代码段后,再重新启用中断。

3. SIP(Supervisor Interrupt Pending)寄存器

  • 功能:SIP 寄存器用于指示当前等待处理的中断类型。当中断发生时,相应的位会被置位,以通知处理器有中断待处理。

  • 应用场景:当操作系统需要判断当前系统中断的来源时,可以读取 SIP 寄存器,了解是哪个中断源(例如,外部设备、软件或定时器)触发了中断,从而采取相应的处理措施。

4. SCAUSE(Supervisor Cause)寄存器

  • 功能:SCAUSE 寄存器保存了引发当前 trap 或中断的原因。对于中断处理,这个寄存器可以表明是哪种中断(如外部设备中断)导致了当前的处理流程。

  • 应用场景:操作系统的中断处理程序通常会检查 SCAUSE 寄存器,以确定中断类型,并根据中断的类型选择相应的处理例程。

5. STVEC(Supervisor Trap Vector)寄存器

  • 功能:STVEC 寄存器存储了当发生中断、异常或 trap 时,CPU 应该跳转执行的地址。这是中断处理程序的入口地址。

  • 应用场景:STVEC 寄存器的配置决定了 CPU 在处理中断时从何处开始执行代码。操作系统在初始化过程中会设置 STVEC 寄存器,以指向适当的中断处理例程。

xv6 中的中断处理流程简述

在 xv6 中,设置这些寄存器使得 CPU 能够处理来自外部设备(如 UART)的中断。当 UART 设备生成一个中断时,CPU 会根据 SIE 和 SSTATUS 寄存器的配置决定是否处理该中断。一旦决定处理,CPU 会根据 STVEC 寄存器的值跳转到相应的中断处理程序,并利用 SCAUSE 和 SIP 寄存器来判断中断的类型和来源。中断处理程序会完成必要的任务,如读取输入或发送输出,然后恢复之前被中断的程序的执行状态。

通过以上的寄存器配置,操作系统可以有效地管理和处理设备中断,确保系统的稳定运行和响应能力。这些寄存器之间的协作,以及操作系统对它们的使用,是实现中断处理的关键。

XV6 中断处理流程和 UART 初始化到中断使能

中断处理的初始化

在系统启动时,操作系统需要配置处理器和外部设备,以便能够正确处理中断。这个配置过程包括设置处理器的各种状态寄存器,以及初始化外部设备,例如 UART 设备。

1. 处理器的初始化(start() 函数)

start() 函数是在系统启动时首先执行的,负责初始化处理器的各个寄存器,并将控制权转移到操作系统的主循环中。以下是 start() 函数中的关键步骤:

void start() {
  // 设置 M Previous Privilege mode 为 Supervisor mode,以便 mret 指令返回到 Supervisor mode。
  unsigned long x = r_mstatus();
  x &= ~MSTATUS_MPP_MASK;
  x |= MSTATUS_MPP_S;
  w_mstatus(x);

  // 设置 M Exception Program Counter 为 main 函数的地址,以便 mret 返回时执行 main。
  w_mepc((uint64)main);

  // 暂时禁用分页。
  w_satp(0);

  // 将所有中断和异常委托给 Supervisor mode。
  w_medeleg(0xffff);
  w_mideleg(0xffff);
  w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE);

  // 配置物理内存保护,以便 Supervisor mode 可以访问所有物理内存。
  w_pmpaddr0(0x3fffffffffffffull);
  w_pmpcfg0(0xf);

  // 请求时钟中断。
  timerinit();

  // 将每个 CPU 的 hartid 存储在其 tp 寄存器中,以便 cpuid() 可以使用。
  int id = r_mhartid();
  w_tp(id);

  // 切换到 Supervisor mode 并跳转到 main 函数。
  asm volatile("mret");
}
  • Mstatus 寄存器配置:处理器的状态寄存器 MSTATUS 被配置为 Supervisor 模式,确保中断处理在适当的权限级别下进行。
  • Mepc 寄存器设置:设置 MEPC 寄存器的值为 main 函数的地址,这样当处理器执行 mret 指令时,会跳转到 main 函数执行。
  • 中断和异常委托:通过设置 MEDELEGMIDELEG 寄存器,将中断和异常的处理委托给 Supervisor 模式。SIE 寄存器的相应位被设置,以启用外部设备、软件和定时器中断。

2. 操作系统主函数(main() 函数)

start() 函数完成基本配置后,控制权会转移到 main() 函数。main() 函数是操作系统的主循环,负责初始化各个子系统并启动第一个用户进程。

// start() 函数跳转到这里,所有的 CPU 都以 supervisor 模式启动。
void main() {
  if(cpuid() == 0){  // 如果当前是第一个 CPU 核心(hart 0)
    consoleinit();    // 初始化控制台(Console)
    printfinit();     // 初始化 printf 函数的相关设置
    printf("\n");     
    printf("xv6 kernel is booting\n");  // 输出启动信息
    printf("\n");
    kinit();          // 初始化物理页分配器
    kvminit();        // 创建内核页表
    kvminithart();    // 启用分页(Paging)
    procinit();       // 初始化进程表
    trapinit();       // 初始化 trap 向量
    trapinithart();   // 安装内核 trap 向量
    plicinit();       // 设置中断控制器(PLIC)
    plicinithart();   // 使 PLIC 开始接收设备中断
    binit();          // 初始化缓冲区缓存
    iinit();          // 初始化 inode 表
    fileinit();       // 初始化文件表
    virtio_disk_init(); // 初始化虚拟硬盘
    userinit();       // 初始化第一个用户进程
    __sync_synchronize();  // 同步,确保所有内存操作已完成
    started = 1;      // 标记启动完成
  } else {  // 如果当前不是第一个 CPU 核心
    while(started == 0)  // 等待第一个 CPU 核心完成启动
      ;
    __sync_synchronize(); // 再次同步
    printf("hart %d starting\n", cpuid());  // 打印当前 CPU 核心的启动信息
    kvminithart();    // 启用分页(Paging)
    trapinithart();   // 安装内核 trap 向量
    plicinithart();   // 使 PLIC 开始接收设备中断
  }

  scheduler();  // 启动进程调度器
}

main() 函数的初始化过程中,consoleinit() 是第一个初始化的外设,负责控制台(Console)的设置。控制台是操作系统和用户交互的重要接口,它处理用户输入(如键盘输入)和系统输出(如在屏幕上显示文本)。

3. 第一个初始化的外设 (consoleinit() 函数)

consoleinit() 函数位于 console.c 文件中,它的作用是初始化控制台设备,并设置读写接口。具体分为以下几个步骤:

void consoleinit(void)
{
  initlock(&cons.lock, "cons");  // 初始化控制台的锁,以保护控制台资源的并发访问

  uartinit();  // 初始化 UART 设备,配置其寄存器和中断

  // 将系统调用中的读写操作连接到控制台读写函数
  devsw[CONSOLE].read = consoleread;
  devsw[CONSOLE].write = consolewrite;
}

初始化锁 (initlock(&cons.lock, "cons"))

initlock 函数用于初始化一个锁,这里初始化的是 cons.lock。锁的作用是在多核处理器上保护共享资源的并发访问。对于控制台设备而言,锁可以确保在同一时间内,只有一个进程能够访问控制台,以防止输出混乱或者其他数据冲突。尽管在这个初始化过程中,锁的具体机制还不需要深入理解,但它是并发编程中确保数据一致性和安全性的重要工具。

初始化 UART 设备 (uartinit())

uartinit() 是一个重要的函数,位于 uart.c 文件中。这个函数的作用是配置 UART(通用异步收发器)设备,使其可以被系统使用。

连接系统调用的读写操作到控制台 (devsw[CONSOLE].read = consoleread; devsw[CONSOLE].write = consolewrite;)

在初始化控制台和 UART 设备之后,consoleinit() 函数通过设置 devsw 表的 readwrite 字段,将控制台的读写操作与具体的实现函数 consolereadconsolewrite 连接起来。devsw 是一个设备开关表,它将设备号映射到对应的读写函数上。

  • consoleread 函数处理从控制台读取输入的操作(例如键盘输入)。
  • consolewrite 函数处理向控制台写入输出的操作(例如在屏幕上显示文本)。

4. UART 设备的初始化(uartinit() 函数)

UART(通用异步收发传输器)设备的初始化是操作系统设置串行通信的关键步骤。在 uartinit() 函数中,UART 被配置为适当的波特率和字符长度,并且 FIFO 被重置以确保数据的正确传输。

void uartinit(void) {
  // 禁用中断。
  WriteReg(IER, 0x00);

  // 设置波特率(传输速率)。
  WriteReg(LCR, LCR_BAUD_LATCH);
  WriteReg(0, 0x03);  // 38.4K 波特率的 LSB。
  WriteReg(1, 0x00);  // 38.4K 波特率的 MSB。

  // 设置字符长度为 8 bit,无奇偶校验。
  WriteReg(LCR, LCR_EIGHT_BITS);

  // 重置并启用 FIFO。
  WriteReg(FCR, FCR_FIFO_ENABLE | FCR_FIFO_CLEAR);

  // 启用发送和接收中断。
  WriteReg(IER, IER_TX_ENABLE | IER_RX_ENABLE);

  initlock(&uart_tx_lock, "uart");
}
  • 波特率配置:波特率决定了串口的传输速率。通过 WriteReg(LCR, LCR_BAUD_LATCH) 和接下来的配置,UART 被设置为 38.4Kbps 的传输速率。
  • 字符长度和校验:字符长度被设置为 8 位,无奇偶校验,这确保了数据在传输过程中不会发生不必要的错误检测。
  • FIFO 设置:FIFO(先进先出队列)被重置并启用,确保数据能够有序且无丢失地传输。
  • 中断启用:UART 的发送和接收中断被启用,这意味着 UART 设备在完成传输或者接收到数据时,会产生中断,通知处理器进行处理。

5. PLIC 初始化

在 UART 初始化完成后,还需要配置中断控制器(PLIC),以便将 UART 的中断正确路由到处理器。这一步骤在 plicinit()plicinithart() 函数中完成。

plicinit() 函数

plicinit() 函数负责为 PLIC 设置中断优先级,并启用来自 UART 和 VIRTIO 设备的中断。

void plicinit(void) {
  // 设置 UART 和 VIRTIO 设备的中断优先级。
  *(uint32*)(PLIC + UART0_IRQ*4) = 1;
  *(uint32*)(PLIC + VIRTIO0_IRQ*4) = 1;
}

plicinithart() 函数

每个 CPU 核都需要调用 plicinithart(),以便在硬件上注册自己对特定中断的兴趣。这保证了多个 CPU 核可以并行处理来自不同设备的中断。

void plicinithart(void) {
  int hart = cpuid();

  // 为当前 CPU 核启用 UART 和 VIRTIO 设备的中断。
  *(uint32*)PLIC_SENABLE(hart) = (1 << UART0_IRQ) | (1 << VIRTIO0_IRQ);

  // 将当前 CPU 核的中断优先级阈值设置为 0。
  *(uint32*)PLIC_SPRIORITY(hart) = 0;
}

6. 中断使能:intr_on() 函数

main() 函数中的 scheduler() 函数里,处理器会最终使能中断。intr_on() 函数通过设置 SSTATUS 寄存器,开启处理器对中断的响应。

static inline void intr_on() {
  w_sstatus(r_sstatus() | SSTATUS_SIE);
}

在执行 intr_on() 函数后,处理器就准备好接收并处理来自外部设备的中断。所有调用了 intr_on() 的 CPU 核都可以处理中断。

总结

通过对 XV6 操作系统中断处理流程的深入分析,我们可以看到,在系统启动过程中,各个关键组件如控制台、UART 设备、中断控制器(PLIC)和处理器寄存器之间是如何紧密协作的。每个步骤的初始化都确保了系统能够正确响应和处理来自外部设备的中断请求,从而实现用户与操作系统的交互。

首先,通过对 consoleinit()uartinit() 函数的分析,我们了解到如何初始化控制台设备,并配置 UART 以支持数据的传输和中断生成。随后,我们看到 PLIC 的初始化是如何启用特定设备的中断,并将其路由到 CPU 进行处理。

最终,在系统进入主循环并启用中断后,处理器可以接收和处理各种外部设备的中断请求,从而保障系统的稳定运行和实时响应。这一过程不仅展示了操作系统的底层机制,更揭示了中断处理在操作系统架构中的重要性。

为了帮助你更好地理解 XV6 操作系统中的中断处理流程,下面展示一个简化的全流程图,该图涵盖了系统启动、控制台初始化、UART 设备配置、中断控制器初始化以及最终的中断使能。

+------------------------------------+
|            系统启动 (start)         |
+------------------------------------+
     |
     v
+------------------------------------+
|  处理器初始化:设置寄存器和中断委托      |
| - MSTATUS寄存器 (设置权限模式)        |
| - MEPC寄存器 (设置返回地址)           |
| - 中断寄存器 (SIE, SSTATUS 等)       |
+------------------------------------+
     |
     v
+------------------------------------+
|         进入 main 函数              |
| - 初始化控制台 (consoleinit)         |
| - 初始化物理页分配器,页表等           |
| - 初始化进程表 (procinit)            |
| - 设置中断控制器 (PLIC)              |
| - 启动第一个用户进程 (userinit)       |
+------------------------------------+
        |
        v
+------------------------------------+
|   控制台初始化 (consoleinit)         |
| - 初始化控制台锁 (initlock)          |
| - 初始化 UART 设备 (uartinit)        |
+------------------------------------+
     |
     v
+---------------------------------------+
|   UART 设备初始化 (uartinit)           |
| - 关闭 UART 中断 (WriteReg(IER, 0x00)) |
| - 设置波特率和字符长度                   |
| - 重置并启用 FIFO                      |
| - 启用发送和接收中断                     |
+---------------------------------------+
     |
     v
+--------------------------------------+
|     初始化中断控制器 (PLIC)             |
| - 设置 UART 和 VIRTIO 设备的中断优先级   |
| - 各个 CPU 核启用中断接收 (plicinithart) |
+---------------------------------------+
     |
     v
+--------------------------------------+
|   启用处理器的中断响应 (intr_on)        |
| - 设置 SSTATUS 寄存器,打开中断标志位    |
+--------------------------------------+
     |
     v
+-------------------------------------+
|     处理用户输入和输出的中断处理         |
| - UART 设备产生中断                   |
| - PLIC 路由中断至 CPU                 |
| - CPU 处理中断,更新 Console 显示      |
+-------------------------------------+

流程解释:

  1. 系统启动:系统在启动时通过 start() 函数初始化处理器寄存器,并设置中断委托。
  2. 进入 main() 函数:处理器将控制权移交给 main() 函数,执行系统初始化任务,包括控制台和中断控制器的设置。
  3. 控制台初始化:通过 consoleinit() 函数,初始化控制台设备并设置 UART 设备的读写接口。
  4. UART 设备初始化uartinit() 函数配置 UART 设备的波特率、字符长度,并启用发送和接收中断。
  5. 中断控制器初始化plicinit()plicinithart() 函数配置 PLIC,使其能够接收并处理来自 UART 和 VIRTIO 设备的中断。
  6. 启用处理器中断响应:通过 intr_on() 函数,处理器启用对中断的响应,使得系统可以处理来自外部设备的中断请求。
  7. 处理中断:当 UART 设备产生中断时,PLIC 将中断路由到 CPU,CPU 随后执行中断处理程序,更新控制台显示(如用户输入的 ls 命令)。

通过这个全流程图,你可以更清晰地看到从系统启动到处理用户输入和输出的中断的整个过程。

Shell 程序初始化 输出提示符“$”到 Console

接下来,我们将详细分析 Shell 程序是如何通过内核和硬件层逐步输出提示符“$”到 Console 上的。这一过程涉及多个层次的系统调用和中断处理,最终实现用户在 Console 上看到的交互界面。

系统启动后的第一个进程(init.c)

系统启动后,init 进程是第一个运行的进程。它的 main 函数负责创建 console 设备,并启动 Shell 程序。

int main(void) {
  int pid, wpid;

  if(open("console", O_RDWR) < 0) {
    mknod("console", CONSOLE, 0);
    open("console", O_RDWR);
  }
  dup(0);  // stdout
  dup(0);  // stderr

  for(;;) {
    printf("init: starting sh\n");
    pid = fork();
    if(pid < 0) {
      printf("init: fork failed\n");
      exit(1);
    }
    if(pid == 0) {
      exec("sh", argv);
      printf("init: exec sh failed\n");
      exit(1);
    }

    for(;;) {
      wpid = wait((int *) 0);
      if(wpid == pid) {
        break;  // restart shell if it exits
      } else if(wpid < 0) {
        printf("init: wait returned an error\n");
        exit(1);
      }
    }
  }
}
  1. 创建 console 设备:系统启动时,init 进程会通过 mknod 系统调用创建一个名为 console 的设备文件。console 设备代表的是一个字符设备,它对应的实际上是 UART 硬件。
  2. 文件描述符配置:接着,open("console", O_RDWR) 打开 console 设备,并分配文件描述符 0(标准输入)。dup(0) 复制文件描述符 0,分别赋给 1(标准输出)和 2(标准错误)。此时,所有的标准 I/O 操作都被重定向到 console 设备。
  3. 启动 Shellinit 进程通过 fork() 创建一个子进程,然后在子进程中通过 exec("sh", argv) 启动 Shell 程序(sh)。如果 Shell 进程终止,init 会检测到,并重新启动一个新的 Shell 进程。

1. for(;;) 循环

for(;;) 是一个无限循环,用于在系统启动时持续运行。在操作系统的初始化过程中,init 进程通常会一直运行,因此它需要保持活跃,以确保系统的基础服务不断重启(如 sh)。

2. printf("init: starting sh\n");

这行代码输出一条消息到控制台,表示系统正在启动 sh(Shell)。

3. pid = fork();

fork() 系统调用创建一个子进程。如果 fork() 成功,则它会返回两次:

  • 在父进程中,它返回新创建子进程的 PID。
  • 在子进程中,它返回 0。

4. if(pid < 0)

如果 fork() 返回的 pid 小于 0,表示创建子进程失败。此时输出一条错误信息,并调用 exit(1) 终止 init 进程。

5. if(pid == 0)

这部分代码在子进程中运行。子进程通过 exec("sh", argv); 替换自己的内存空间并执行 sh 程序(即 Shell)。

  • exec("sh", argv);: exec 系列函数会将当前进程替换为新的进程映像(这里是 sh)。这个操作成功的话,后续代码不会再执行,因为 sh 程序将占据这个进程的执行流程。
  • 如果 exec 调用失败,程序会输出 "init: exec sh failed\n",并调用 exit(1) 终止子进程。

6. 内层 for(;;) 循环

init 进程(父进程)成功启动 sh(子进程)后,它进入这个内层无限循环,通过调用 wait() 等待子进程(sh)的终止。

  • wpid = wait((int *) 0);: wait() 系统调用会挂起当前进程,直到一个子进程终止。它返回终止的子进程的 PID。
  • if(wpid == pid): 如果终止的子进程是刚启动的 shinit 进程就会跳出内层循环,重新启动 sh
  • else if(wpid < 0): 如果 wait() 返回值小于 0,表示出现错误,init 进程会输出错误信息并退出。

sh 是在哪儿启动的

  • 启动位置: sh 程序是在子进程中通过 exec("sh", argv); 启动的。exec 系列函数会将当前子进程替换为 sh 程序的执行映像。因此,当 sh 被执行时,原来的子进程代码不再存在,整个进程内存空间已经被 sh 的代码、数据和堆栈替换。

Shell 程序的初始化(sh.c)

Shell 程序是用户与系统交互的命令行接口。当 Shell 启动后,它会检查文件描述符 0, 1, 2 是否已正确打开(对应 console 设备)。然后,它进入一个循环等待用户输入命令,并执行这些命令。

int main(void) {
  static char buf[100];
  int fd;

  while((fd = open("console", O_RDWR)) >= 0) {
    if(fd >= 3) {
      close(fd);
      break;
    }
  }

  while(getcmd(buf, sizeof(buf)) >= 0) {
    if(buf[0] == 'c' && buf[1] == 'd' && buf[2] == ' ') {
      buf[strlen(buf)-1] = 0;
      if(chdir(buf+3) < 0)
        fprintf(2, "cannot cd %s\n", buf+3);
      continue;
    }
    if(fork1() == 0)
      runcmd(parsecmd(buf));
    wait(0);
  }
  exit(0);
}
  1. 确保文件描述符打开:Shell 在启动时首先检查并确保 console 设备的文件描述符 0, 1, 2 已经正确打开。如果超过 3 个文件描述符打开,Shell 会关闭多余的文件描述符。
  2. 命令处理循环:Shell 进入一个无限循环,通过 getcmd 函数从用户获取命令。如果用户输入了命令,如 cd 或其他命令,Shell 会通过相应的系统调用或函数处理这些命令。

在 Unix 和类 Unix 操作系统(包括 xv6)中,文件描述符 0、1、2 是标准输入(stdin)、标准输出(stdout)和标准错误(stderr)的约定。这些文件描述符用于管理和操作程序的输入和输出流,具体如下:

文件描述符简介

  • 文件描述符 0(标准输入,stdin):
    • 对应标准输入流,通常连接到键盘输入。程序从文件描述符 0 读取数据。
    • 例如,用户在命令行输入的字符通常会通过标准输入传递给程序。
  • 文件描述符 1(标准输出,stdout):
    • 对应标准输出流,通常连接到控制台。程序将数据写入文件描述符 1,这些数据会显示在用户的屏幕上。
    • 例如,程序的正常输出信息(如 printf 语句的输出)会通过标准输出显示在控制台上。
  • 文件描述符 2(标准错误,stderr):
    • 对应标准错误流,也通常连接到控制台。程序将错误信息写入文件描述符 2,这些错误信息会显示在用户的屏幕上。
    • 例如,程序运行中的错误信息(如 fprintf(stderr, "error\n"))会通过标准错误显示在控制台上。

输出提示符“$”到 Console

Shell 程序每次等待用户输入命令时,都会先输出提示符“$ ”。

int getcmd(char *buf, int nbuf) {
  write(2, "$ ", 2);  // 输出 "$ "
  memset(buf, 0, nbuf);
  gets(buf, nbuf);
  if(buf[0] == 0) // EOF
    return -1;
  return 0;
}
  1. 输出提示符getcmd 函数首先调用 write 系统调用,向文件描述符 2(标准错误,即 console)写入字符 "$ ",将提示符输出到 Console。

  2. 等待用户输入gets(buf, nbuf) 函数阻塞等待用户输入命令并将其存储在 buf 中。

fprintfwrite 系统调用

在 Shell 程序中,fprintf 函数用于格式化输出到 consolefprintf 函数内部调用了 vprintf,而 vprintf 最终调用了 putc 函数,putc 调用了 write 系统调用。

static void putc(int fd, char c) {
  write(fd, &c, 1);  // 向文件描述符 fd 写入一个字符 c
}
  • putc 函数putc 函数将字符 c 写入到指定的文件描述符 fd,在我们的例子中,fd 为 2,即 console

  • write 系统调用write 系统调用会触发内核操作,将字符通过 UART 设备发送到 console 上显示出来。

sys_write 函数

write 系统调用最终会调用到内核中的 sys_write 函数,负责将数据从用户空间传递到内核空间,并写入到实际设备。

uint64 sys_write(void) {
  struct file *f;
  int n;
  uint64 p;
  
  argaddr(1, &p);
  argint(2, &n);
  if(argfd(0, 0, &f) < 0)
    return -1;

  return filewrite(f, p, n);
}
  1. 参数解析sys_write 函数解析 write 系统调用传递的参数,包括文件描述符、缓冲区地址、写入长度等。

  2. 调用 filewrite 函数sys_write 函数将解析后的数据传递给 filewrite 函数,filewrite 函数负责实际将数据写入到文件或设备中。在我们的例子中,文件描述符对应的是 console 设备。

filewrite 函数详解

filewrite 函数负责将数据写入到指定的文件或设备。它首先检查文件是否可写,并根据文件类型选择相应的写入方法。

// 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){
    if(f->major < 0 || f->major >= NDEV || !devsw[f->major].write)
      return -1;
    ret = devsw[f->major].write(1, addr, n);
  } else if(f->type == FD_INODE){
    // write logic for regular files
    ...
  }
  return ret;
}
  1. 可写性检查:首先检查文件是否可写。如果不可写,立即返回错误。

  2. 文件类型处理

    • FD_PIPE:如果文件是管道(pipe),调用 pipewrite 函数处理。
    • FD_DEVICE:如果文件是设备(如 console),查找设备表(devsw)中对应的写函数,并调用它来处理写操作。
    • FD_INODE:如果是普通文件(inode),调用文件系统相关的写操作。

在我们的例子中,console 设备属于 FD_DEVICE 类型,因此调用 devsw[CONSOLE].write,实际指向 consolewrite 函数。

consolewrite 函数详解

consolewrite 函数负责将数据从用户空间传输到 console 设备。它会逐个字符处理,并将每个字符传输到 UART 设备。

// user write()s to the console go here.
int consolewrite(int user_src, uint64 src, int n)
{
  int i;

  for(i = 0; i < n; i++){
    char c;
    if(either_copyin(&c, user_src, src+i, 1) == -1)
      break;
    uartputc(c);
  }

  return i;
}
  1. 数据拷贝either_copyin 函数将每个字符从用户空间缓冲区拷贝到内核空间。

  2. 字符输出:拷贝完成后,consolewrite 调用 uartputc 函数,将字符发送给 UART 设备。

uartputc 函数详解

uartputc 函数在 xv6 操作系统中负责将字符发送到 UART(通用异步收发传输器)设备,用于串行通信。这个函数的实现有些特别,它涉及到一个环形缓冲区(circular buffer)和一些同步机制,以确保字符能够安全且有序地被传输到 UART 设备。

...
 // the transmit output buffer.
struct spinlock uart_tx_lock;
#define UART_TX_BUF_SIZE 32
char uart_tx_buf[UART_TX_BUF_SIZE];
uint64 uart_tx_w; // write next to uart_tx_buf[uart_tx_w % UART_TX_BUF_SIZE]
uint64 uart_tx_r; // read next from uart_tx_buf[uart_tx_r % UART_TX_BUF_SIZE]
...
// add a character to the output buffer and tell the
// UART to start sending if it isn't already.
void uartputc(int c)
{
  acquire(&uart_tx_lock);

  if(panicked){
    for(;;);
  }

  while(uart_tx_w == uart_tx_r + UART_TX_BUF_SIZE){
    // buffer is full.
    // wait for uartstart() to open up space in the buffer.
    sleep(&uart_tx_r, &uart_tx_lock);
  }

  uart_tx_buf[uart_tx_w % UART_TX_BUF_SIZE] = c;
  uart_tx_w += 1;
  uartstart();
  release(&uart_tx_lock);
}
  • uart_tx_buf: 这是一个大小为 32 个字符的缓冲区,用来存储等待传输的字符。这个缓冲区是环形的,即当写入操作到达缓冲区的末尾时,它会绕回到缓冲区的开头继续写入。

  • uart_tx_wuart_tx_r: 这些是写指针(write pointer)和读指针(read pointer),分别用于标识下一个要写入或读取字符的位置。通过这些指针,函数能够追踪哪些字符已经被发送,哪些字符还在等待发送。

  • uart_tx_lock: 这是一个自旋锁,用于确保多个进程或线程在访问 uart_tx_buf 时不会发生数据竞争(race condition)。它保证了对缓冲区的访问是原子性的,即在一个进程写入缓冲区时,其他进程不能同时修改缓冲区的内容。

函数流程

  1. 获取锁 (acquire(&uart_tx_lock)):
    • 在函数开始时,通过 acquire 获取自旋锁 uart_tx_lock。这确保了在接下来的操作中,缓冲区不会被其他进程修改。
  2. 检查系统状态:
    • 如果系统处于 panicked 状态(系统发生了严重错误),函数进入一个无限循环,以防止任何进一步的操作。这是为了避免在系统已经崩溃时发送字符导致更多的错误。
  3. 检查缓冲区是否满 (while 循环):
    • 函数接着会检查环形缓冲区是否已满。如果写指针 uart_tx_w 等于读指针 uart_tx_r 加上缓冲区大小(32),说明缓冲区已满,无法再写入数据。
    • 当缓冲区满了时,函数会调用 sleep(&uart_tx_r, &uart_tx_lock),让当前进程进入睡眠状态,等待缓冲区有空间时再继续执行。这时,CPU 资源会被释放给其他进程。
  4. 写入数据到缓冲区:
    • 如果缓冲区未满,函数会将字符 c 写入到缓冲区的当前位置。写入位置通过 uart_tx_w % UART_TX_BUF_SIZE 计算得到,这是环形缓冲区的典型用法,保证写指针不会超出缓冲区的范围。
    • 写入数据后,写指针 uart_tx_w 向前移动一位。
  5. 启动 UART 发送 (uartstart):
    • 接着,函数会调用 uartstart 来启动 UART 设备发送数据。这个函数会检查 UART 设备是否空闲,并将缓冲区中的字符发送出去。
  6. 释放锁 (release(&uart_tx_lock)):
    • 最后,函数释放之前获取的自旋锁 uart_tx_lock,允许其他进程访问缓冲区。

环形缓冲区的工作机制

  • 环形缓冲区: uart_tx_buf 是一个环形缓冲区,它的优点是可以高效地管理生产者(producer)和消费者(consumer)之间的数据流动。在这里,生产者是 Shell,它向缓冲区写入数据;消费者是 uartstart 函数,它从缓冲区读取数据并将其发送到 UART 设备。

  • 指针管理: 读写指针的管理非常关键。如果写指针和读指针相同,说明缓冲区为空;如果写指针追上了读指针,说明缓冲区已满。通过 % 运算符,指针可以在缓冲区中循环移动,而不会超出其边界。

uartstart 函数详解

uartstart 函数负责实际将字符从缓冲区传输到 UART 设备,并触发发送操作。

// if the UART is idle, and a character is waiting
// in the transmit buffer, send it.
void uartstart()
{
  while(1){
    if(uart_tx_w == uart_tx_r){
      // transmit buffer is empty.
      ReadReg(ISR);
      return;
    }

    if((ReadReg(LSR) & LSR_TX_IDLE) == 0){
      // the UART transmit holding register is full,
      // so we cannot give it another byte.
      return;
    }

    int c = uart_tx_buf[uart_tx_r % UART_TX_BUF_SIZE];
    uart_tx_r += 1;

    // maybe uartputc() is waiting for space in the buffer.
    wakeup(&uart_tx_r);

    WriteReg(THR, c);
  }
}
  1. 检查设备空闲状态:首先检查 UART 设备是否空闲(LSR_TX_IDLE 标志位),只有在设备空闲时才可以发送数据。

  2. 发送字符:从缓冲区中读取字符,并写入 UART 的传输寄存器 THR,触发数据发送。

  3. 中断处理准备:如果缓冲区中的字符全部发送完毕,或 UART 设备正在忙碌,函数返回。否则继续发送下一字符。

UART 设备中断处理

当 UART 设备完成字符传输时,会产生一个中断,通知 CPU 数据已成功发送,可以继续处理下一个字符。

中断处理程序会清理缓冲区,并调用 uartstart 函数继续传输剩余的数据。

在这一系列操作中,从 Shell 程序输出提示符“$”到 console,涉及了多个系统调用、内核函数和设备驱动的配合。sys_write 系统调用通过 filewrite 函数将数据传递到 console,然后 consolewrite 将字符逐个发送到 UART 设备。UART 设备负责实际的字符传输,并在完成后产生中断,确保数据传输的连续性和正确性。这一流程展示了操作系统如何通过系统调用与硬件设备进行交互,为用户提供了一个统一、抽象的接口来操作底层硬件。

在 xv6 操作系统中,Shell 程序输出提示符“$”到 Console 的过程涉及一系列系统调用和硬件中断的协同工作。以下是这个过程的主要步骤:

  1. 系统启动与进程初始化
    • 系统启动后,第一个进程 init 会创建一个代表 console 设备的文件,并启动 Shell 程序。Shell 负责与用户交互,并在每次等待用户输入命令时输出提示符“$”。
  2. 文件描述符的配置
    • init 进程打开 console 设备,并将文件描述符 0、1、2 分配给 console。这三个文件描述符分别代表标准输入、标准输出和标准错误,确保所有的标准 I/O 操作都重定向到 console 设备。
  3. Shell 程序启动
    • Shell 程序启动后,会检查文件描述符 0、1、2 是否已正确打开。然后进入命令处理循环,通过 getcmd 函数获取用户输入的命令。
  4. 输出提示符“$”
    • 每次等待用户输入时,Shell 调用 write 系统调用,通过文件描述符 2 向 console 输出提示符“$”。这会触发内核中的一系列操作,将字符传输到硬件设备。
  5. 系统调用与内核处理
    • write 系统调用最终调用 sys_write 函数,将数据从用户空间传递到内核空间。然后通过 filewrite 函数,将数据写入到实际的 console 设备。
  6. UART 设备驱动与中断处理
    • consolewrite 函数将字符逐个传输到 UART 设备。UART 设备负责将字符从缓冲区发送到硬件,并在传输完成后产生中断,通知 CPU 可以继续处理下一个字符。
  7. 缓冲区与同步机制
    • UART 设备内部有一个环形缓冲区,用于暂存等待发送的字符。通过读写指针和自旋锁,系统确保字符按序被发送,同时避免数据竞争和缓冲区溢出。
  8. UART 设备的实际发送
    • uartstart 函数检查 UART 设备的状态,将缓冲区中的字符发送到传输寄存器,触发实际的数据传输操作。UART 设备在完成传输后会生成中断,继续处理剩余的数据。

从 Shell 程序输出提示符“$”到 Console 的过程展示了操作系统中从用户空间到内核空间再到硬件设备的全链路操作。通过系统调用、设备驱动和中断处理,操作系统提供了统一、抽象的接口,让用户和程序可以方便地与底层硬件设备进行交互。这一过程不仅展示了操作系统的基本功能,还体现了系统的同步机制和硬件资源的高效管理。

Console 输出字符:中断处理与字符输出

在 xv6 中,当 Shell 向 Console 输出字符时,如果发生了中断,系统将通过一系列复杂的硬件和软件层面的操作来处理该中断。以下是对这个流程的详细解析:

RISC-V 处理器的中断处理机制

当 Shell 正在用户空间运行,并且系统接收到一个外部设备(例如键盘)触发的中断时,RISC-V 处理器会执行以下操作:

  1. 清除 SIE 寄存器的外部中断位
    • 当一个中断发生时,处理器会首先清除 SIE(Supervisor Interrupt Enable)寄存器中的相应位(E 位)。这一步骤的目的是阻止其他中断打扰当前正在处理的中断,确保 CPU 专注于处理当前的中断请求。处理中断完成后,可以恢复该位以重新启用中断。
  2. 保存当前程序计数器(PC)到 SEPC 寄存器
    • 处理器将当前正在执行的程序计数器(即 Shell 进程中的指令地址)保存到 SEPC 寄存器中。这一步骤是为了在中断处理完成后能够恢复 Shell 进程的执行。
  3. 保存并切换处理器模式
    • 处理器记录当前的执行模式(在这种情况下,模式为用户模式),并将其切换到 Supervisor 模式。这是因为中断处理必须在更高权限的 Supervisor 模式下进行,以便访问处理器的所有功能和资源。
  4. 跳转到中断处理程序
    • 处理器将程序计数器设置为 STVEC 寄存器的值,这个寄存器存储了中断处理程序的入口地址。在 xv6 中,STVEC 寄存器通常保存 userveckernelvec 函数的地址,具体取决于中断发生时程序是否在用户空间执行。在 Shell 正在运行时,STVEC 指向的是 uservec 函数,uservec 会进一步调用 usertrap 函数来处理中断。(L06)

usertrap 函数中的中断处理

usertrap 函数位于 trap.c 文件中,它是处理从用户空间进入的 trap 的核心部分。在之前的课程中,这个函数已经处理了系统调用和页面错误(page fault)。现在我们来看它如何处理中断。

  } else if((which_dev = devintr()) != 0){
    // ok
  } else {
    printf("usertrap(): unexpected scause 0x%lx pid=%d\n", r_scause(), p->pid);
    printf("            sepc=0x%lx stval=0x%lx\n", r_sepc(), r_stval());
    setkilled(p);
  }
  • 中断处理检查usertrap 函数首先检查中断的来源,并调用 devintr 函数进行处理。如果 devintr 函数返回非零值,则表示成功处理了中断;否则,可能是一个未预期的中断,会输出错误信息并终止相应的进程。

devintr 函数处理外部设备中断

devintr 函数是处理来自外部设备中断的关键部分。这个函数首先读取 SCAUSE 寄存器,以确定中断的类型和来源。

int devintr()
{
  uint64 scause = r_scause();

  if(scause == 0x8000000000000009L){
    // 这是一个来自 PLIC 的 Supervisor 外部中断

    // irq 表示哪个设备引发了中断。
    int irq = plic_claim();

    if(irq == UART0_IRQ){
      uartintr();
    } else if(irq == VIRTIO0_IRQ){
      virtio_disk_intr();
    } else if(irq){
      printf("unexpected interrupt irq=%d\n", irq);
    }

    // PLIC 每次只能允许一个设备引发中断;
    // 处理完成后需要通知 PLIC 设备已经处理完毕。
    if(irq)
      plic_complete(irq);

    return 1;
  } else if(scause == 0x8000000000000005L){
    // 定时器中断
    clockintr();
    return 2;
  } else {
    return 0;
  }
}
  • 判断中断类型:通过 SCAUSE 寄存器,devintr 函数判断当前中断是否是来自 PLIC 的外部设备中断。如果是,则调用 plic_claim 函数获取中断号。

  • 处理中断:如果中断号对应 UART 设备,函数会调用 uartintr 函数处理 UART 中断。如果是 VIRTIO 硬盘中断,则调用 virtio_disk_intr 函数处理。

  • 中断处理完成通知:处理完中断后,plic_complete 函数通知 PLIC 当前中断已处理完毕,允许同一设备再次引发中断。

plic_claimplic_complete 函数

plic_claim 函数负责从 PLIC 中获取当前中断的设备号。plic_complete 函数则是在中断处理完成后,向 PLIC 通知该中断已被处理,以允许同一设备再次引发中断。

// ask the PLIC what interrupt we should serve.
int
plic_claim(void)
{
  int hart = cpuid();
  int irq = *(uint32*)PLIC_SCLAIM(hart);
  return irq;
}

// tell the PLIC we've served this IRQ.
void
plic_complete(int irq)
{
  int hart = cpuid();
  *(uint32*)PLIC_SCLAIM(hart) = irq;
}
  • plic_claim:当前 CPU 核通过读取 PLIC_SCLAIM 寄存器,获取当前中断的设备号。

  • plic_complete:当前 CPU 核在中断处理完成后,通过写入 PLIC_SCLAIM 寄存器,通知 PLIC 该中断已处理完毕。

uartintr 函数处理 UART 中断

当 UART 设备生成中断时,处理器会调用 uartintr 函数来处理该中断。uartintr 函数的主要职责是读取 UART 的接收寄存器中的数据,并将数据传递给 consoleintr 函数。同时,如果有字符需要发送,它会将缓冲区中的字符通过 UART 发送出去。

// kernel/uart.c

// 处理 UART 中断,当有输入到达或 UART 准备好发送更多数据时触发。
// 由 devintr() 调用。
void
uartintr(void)
{
  // 读取并处理接收的字符。
  while(1){
    int c = uartgetc(); // 从 UART 读取一个字符
    if(c == -1)
      break; // 如果没有字符可读,跳出循环
    consoleintr(c); // 将读取到的字符传递给控制台处理
  }

  // 发送缓冲区中的字符。
  acquire(&uart_tx_lock); // 获取发送缓冲区的锁
  uartstart(); // 启动 UART 发送
  release(&uart_tx_lock); // 释放发送缓冲区的锁
}

uartintr 函数中,首先会尝试从 UART 接收寄存器中读取数据,并调用 consoleintr 函数处理。如果接收寄存器中没有数据,则代码会跳转到处理发送缓冲区中的数据。

uartstart()处理 UART 发送缓冲区中的数据

当 Shell 输出提示符“$”时,字符会被放入 UART 的发送缓冲区。uartstart 函数负责将这些字符从缓冲区中取出,并通过 UART 发送出去。

// 如果 UART 处于空闲状态,并且发送缓冲区中有等待发送的字符,
// 则将字符发送到 UART。调用者必须持有 uart_tx_lock。
// 该函数可以由 top-half 和 bottom-half 调用。
void
uartstart()
{
  while(1){
    if(uart_tx_w == uart_tx_r){
      // 如果发送缓冲区为空,读取 ISR 寄存器并返回。
      ReadReg(ISR);
      return;
    }
    
    if((ReadReg(LSR) & LSR_TX_IDLE) == 0){
      // 如果 UART 的发送寄存器已满,无法发送下一个字节,
      // 则等待下一次中断。
      return;
    }
    
    int c = uart_tx_buf[uart_tx_r % UART_TX_BUF_SIZE]; // 从缓冲区读取字符
    uart_tx_r += 1; // 更新读指针
    
    // 如果有进程在等待缓冲区有空间,将其唤醒。
    wakeup(&uart_tx_r);
    
    WriteReg(THR, c); // 将字符写入 UART 的发送寄存器
  }
}

uartstart 函数中,首先会检查发送缓冲区是否为空。如果缓冲区不为空且 UART 设备空闲,它将读取缓冲区中的字符并将其写入 UART 的传输寄存器(THR)。当 UART 设备完成一个字符的发送后,它会触发一次中断,通知处理器当前字符已经发送完毕,准备好发送下一个字符。

锁机制与并发控制

为了保证在多核 CPU 环境下的并发安全,xv6 使用了锁机制。由于多个 CPU 核心可能同时访问 UART 的发送缓冲区,因此在 uartputcuartstart 函数中都使用了 uart_tx_lock 自旋锁。

// 前面提到过的内容
// kernel/uart.c

// 发送缓冲区。
struct spinlock uart_tx_lock; // 发送缓冲区的自旋锁
#define UART_TX_BUF_SIZE 32
char uart_tx_buf[UART_TX_BUF_SIZE]; // 发送缓冲区
uint64 uart_tx_w; // 指向下一个要写入 uart_tx_buf 的位置
uint64 uart_tx_r; // 指向下一个要从 uart_tx_buf 读取的字符位置
...
// 向输出缓冲区添加一个字符,并通知 UART 开始发送(如果尚未发送)。
// 如果输出缓冲区已满,则阻塞等待缓冲区腾出空间。
// 因为可能会阻塞,所以不能在中断处理程序中调用;
// 只能在 write() 中使用。
void
uartputc(int c)
{
  acquire(&uart_tx_lock); // 获取自旋锁

  if(panicked){
    for(;;) // 如果系统已崩溃,进入死循环
      ;
  }

  while(uart_tx_w == uart_tx_r + UART_TX_BUF_SIZE){
    // 如果缓冲区已满,等待缓冲区腾出空间。
    sleep(&uart_tx_r, &uart_tx_lock);
  }

  uart_tx_buf[uart_tx_w % UART_TX_BUF_SIZE] = c; // 将字符写入缓冲区
  uart_tx_w += 1; // 更新写指针
  uartstart(); // 启动 UART 发送
  release(&uart_tx_lock); // 释放自旋锁
}
  • uart_tx_lockuartputc 函数中首先获取 uart_tx_lock 自旋锁,确保在对缓冲区进行写操作时不会发生竞态条件。
  • 缓冲区管理:如果缓冲区已满,uartputc 函数会将当前进程置于睡眠状态,等待缓冲区有空闲位置。当缓冲区不满时,字符会被写入缓冲区,并调用 uartstart 函数尝试发送字符。

UART 对 Shell 输出的重要性

在 Shell 输出字符的过程中,UART 起到了关键作用:

  • 字符传输:UART 负责将字符从系统发送到 Console 或其他输出设备。
  • 双向通信:虽然当前场景没有键盘输入,但 UART 设备不仅处理输出,还可以处理输入,使其成为双向通信的关键组件。

锁与并发控制的重要性

由于只有一个 UART 设备,但可能有多个 CPU 核心需要访问该设备,因此需要使用锁来确保对 UART 设备的访问是串行化的。这样可以避免多个进程或 CPU 核心同时访问 UART 设备,导致数据冲突或丢失。

  • 全局锁控制:由于多个核心共享一个 UART 设备,因此所有 CPU 核心都必须通过锁来串行化访问,确保字符的有序传输。

通过对 UART 中断处理和字符传输流程的深入分析,我们可以看到,在操作系统的底层实现中,设备驱动程序如何通过中断和锁机制,协调多个硬件和软件层次的工作,以实现 Shell 程序与用户交互的功能。UART 设备不仅是字符输出的关键路径,同时也负责处理用户输入,这使得它在整个系统的输入输出流程中扮演了至关重要的角色。

1. usertrap 函数

  • usertrap 函数是处理用户空间中断的入口点。当用户空间的程序(如 Shell)运行时发生中断,控制权会转移到 usertrap 函数。
  • usertrap 首先检查中断的类型,如果中断来自外部设备(如 UART),则调用 devintr 函数进行处理。

2. devintr 函数

  • devintr 函数负责处理外部设备中断。它通过检查 SCAUSE 寄存器来确定中断的来源。
  • 如果中断来自 UART,devintr 会调用 plic_claim 函数获取中断号,并调用 uartintr 函数处理 UART 中断。

3. plic_claim 函数

  • plic_claim 函数从 PLIC(Platform-Level Interrupt Controller)获取当前待处理的中断号。对于 UART 中断,返回的中断号是 10。
  • plic_claim 使处理器能够识别是哪一个设备触发了中断,从而进行有针对性的处理。

4. uartintr 函数

  • uartintr 函数是 UART 中断的核心处理函数。
  • 它首先尝试从 UART 的接收寄存器读取数据。如果有数据到达,uartintr 会将其传递给 consoleintr 函数进行进一步处理。
  • 如果接收寄存器中没有数据,则 uartintr 会调用 uartstart 函数,检查并发送缓冲区中的下一个字符。

5. uartstart 函数

  • uartstart 函数负责从发送缓冲区中读取字符并通过 UART 发送出去。
  • 该函数首先检查 UART 是否处于空闲状态。如果 UART 设备准备好发送数据,则从缓冲区中读取字符并将其写入 UART 的传输寄存器(THR),触发字符传输。

6. plic_complete 函数

  • 在 UART 中断处理完成后,plic_complete 函数通知 PLIC 中断已被处理。
  • 这一步骤允许相同的设备在之后继续引发新的中断,确保系统能够连续处理来自 UART 的输入输出操作。

并发与中断

在讨论中断处理时,必须考虑并发问题。并发操作显著增加了中断编程的复杂性,特别是在处理设备与CPU之间的交互时。这里涉及到几个关键方面的并发情况:

  1. 设备与CPU的并行运行
    • 例如,当UART设备向Console发送字符时,CPU可以同时执行Shell的其他操作,如执行系统调用并向缓冲区写入另一个字符。这种并行操作被称为生产者-消费者并发
  2. 中断对程序执行的影响
    • 中断会暂停当前正在运行的程序。例如,当Shell正在执行某个指令时,如果发生中断,Shell的执行会立即暂停。对于用户空间代码,这个问题并不大,因为在中断处理完成后,程序会恢复执行。而在内核模式下,中断则可能会影响代码的串行执行,导致更复杂的情况。因此,对于一些关键代码片段,内核可能需要暂时关闭中断,以确保这些代码的原子性。
  3. 驱动程序的Top-Half与Bottom-Half的并行
    • Shell在输出提示符“$”后可能会通过系统调用写入另一个字符到缓冲区。这是通过UART驱动程序的Top-Half处理的。同时,另一个CPU核可能会接收到UART中断,触发驱动程序的Bottom-Half处理,操作相同的缓冲区。这种并行操作需要通过锁机制来管理,以确保在同一时间只有一个CPU核能够操作缓冲区。

Producer-Consumer 并发模型

在驱动程序中,常见的并发模式是生产者-消费者模型。这个模型通常围绕一个缓冲区展开,在我们的例子中,缓冲区大小为32字节,并通过两个指针来管理:读指针写指针

缓冲区状态与指针操作

  1. 空缓冲区:当读指针和写指针相等时,缓冲区为空。

     读指针  
      v 
     [ ][ ][ ][ ][ ][ ][ ][ ]  // 缓冲区为空
    0 ^                     31
     写指针            
    
  2. 写入操作(Producer)

    • Shell作为生产者会调用uartputc函数,将字符(如提示符“$”)写入写指针的位置,并将写指针递增。

    • 生产者可以持续写入数据,直到写指针加1等于读指针。此时,缓冲区已满,生产者必须停止运行。如果缓冲区满了,uartputc函数会使Shell进程进入睡眠状态,等待缓冲区腾出空间。

    读指针  
     v 
    [$][ ][ ][ ][ ][ ][ ][ ]  // 写入一个字符
        ^
       写指针
    
  3. 读取操作(Consumer)

    • 中断处理程序uartintr作为消费者会读取缓冲区中的数据。如果读指针落后于写指针,它会从读指针处读取一个字符,并通过UART发送出去,然后递增读指针。

    • 当读指针追上写指针,表示缓冲区为空,消费者就不需要再做任何操作。

       读指针  
        v 
    [ ][ ][ ][ ][ ][ ][ ][ ]  // 读取一个字符
        ^ 
       写指针
    

环形缓冲区

环形缓冲区是一种特殊的数据结构,常用于处理生产者和消费者并发问题。在UART驱动中,环形缓冲区用来暂存等待发送的字符。环形缓冲区的特点是,它的末尾连接到开头,形成一个闭合的环形结构,这样可以高效地利用固定大小的缓冲区来管理数据的读写操作。

指针与缓冲区的关系

  • 写指针:指向下一个要写入数据的位置。
  • 读指针:指向下一个要读取数据的位置。
  • 当缓冲区为空时,读指针和写指针相等。
  • 当缓冲区满时,写指针加1等于读指针,这意味着没有多余的空间写入新数据。

环形缓冲区的实际操作通过对缓冲区大小的取模运算来实现,这保证了指针在达到缓冲区末尾时会自动回绕到缓冲区的开头。例如,如果缓冲区的大小是32字节,则写指针的更新操作可以表示为:

write_ptr = (write_ptr + 1) % 32;

这个取模操作确保了指针不会超出缓冲区的边界,形成环形结构。

锁机制与并发控制

由于缓冲区是所有CPU核共享的,因此需要使用锁机制来管理对缓冲区的访问,防止多个CPU核同时操作缓冲区导致数据竞争。锁机制确保在任意时间内,只有一个CPU核能够对缓冲区进行操作,从而维护数据的一致性和系统的稳定性。

条件同步与进程睡眠

当缓冲区满时,生产者(如Shell进程)会进入睡眠状态,等待缓冲区腾出空间。这个过程通过条件同步机制实现:

  • 进程睡眠(Sleep)
    • 当缓冲区满时,uartputc函数会使当前Shell进程进入睡眠状态,并等待缓冲区有空间可写。
  • 唤醒进程(Wakeup)
    • 当缓冲区中有空闲空间时,uartstart函数会调用wakeup函数,将等待在缓冲区的进程(如Shell)唤醒,以便继续执行写操作。

键盘输入的处理与Shell读取字符

在操作系统中,当用户通过键盘输入字符时,这些字符会通过UART设备传输到系统,并最终由Shell程序读取和处理。以下是整个流程的详细解析,从字符输入到数据被Shell读取的全过程。

设备读取的系统调用流程

当Shell调用read系统调用从键盘读取字符时,系统会通过一系列的函数调用来处理这一请求。

  1. fileread 函数

    • read系统调用的底层实现通过fileread函数来完成。如果读取的文件类型是设备(如console),则会调用相应设备的read函数。
    // 从文件 f 中读取数据,addr 是用户虚拟地址。
    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){
            if(f->major < 0 || f->major >= NDEV || !devsw[f->major].read)
                return -1;
            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;
    }
    
  2. 调用 consoleread 函数

    • 在我们的例子中,read函数最终调用的是console.c文件中的consoleread函数,该函数负责从console读取用户输入的数据。
    // 用户从控制台读取数据。
    int consoleread(int user_dst, uint64 dst, int n) {
        uint target;
        int c;
        char cbuf;
       
        target = n;
        acquire(&cons.lock);
        while(n > 0){
            // 等待中断处理程序将输入放入 cons.buffer。
            while(cons.r == cons.w){
                if(killed(myproc())){
                    release(&cons.lock);
                    return -1;
                }
                sleep(&cons.r, &cons.lock);
            }
       
            c = cons.buf[cons.r++ % INPUT_BUF_SIZE];
       
            if(c == C('D')){  // 文件结束符
                if(n < target){
                    cons.r--;
                }
                break;
            }
       
            // 将输入字节复制到用户空间缓冲区。
            cbuf = c;
            if(either_copyout(user_dst, dst, &cbuf, 1) == -1)
                break;
       
            dst++;
            --n;
       
            if(c == '\n'){
                // 一整行数据已到达,返回用户级的 read()。
                break;
            }
        }
        release(&cons.lock);
       
        return target - n;
    }
    

环形缓冲区与指针操作

类似于前面的解释,consoleread函数也使用了一个环形缓冲区来管理从键盘接收到的数据。在这个场景下,键盘作为生产者将数据写入缓冲区,而Shell作为消费者从缓冲区读取数据。

  1. 环形缓冲区结构

    • 缓冲区包含128个字符,有两个指针:读指针r和写指针w。当两个指针相等时,缓冲区为空。
    struct {
        struct spinlock lock;
        #define INPUT_BUF_SIZE 128
        char buf[INPUT_BUF_SIZE];
        uint r;  // 读指针
        uint w;  // 写指针
        uint e;  // 编辑指针
    } cons;
    
  2. 缓冲区操作

    • 当读指针和写指针相等时,表示缓冲区为空,此时Shell会进入睡眠状态,等待键盘输入新字符。当键盘输入新字符时,字符被写入缓冲区,触发中断,唤醒等待的Shell进程。

中断处理与数据传输

当用户通过键盘输入字符时,字符被发送到主板上的UART芯片,生成中断,并最终由devintr函数处理。

  1. 处理UART中断

    • devintr函数检测到UART中断后,通过uartgetc函数获取字符,并将其传递给consoleintr函数进行处理。
  2. consoleintr 函数处理字符

    • consoleintr函数负责处理从UART接收到的字符,并将其存放在缓冲区中。如果输入的是特殊字符如换行符,还会唤醒等待的Shell进程。
    // 控制台输入中断处理程序。
    void consoleintr(int c) {
        acquire(&cons.lock);
       
        switch(c){
        case C('P'):  // 打印进程列表。
            procdump();
            break;
        case C('U'):  // 删除整行。
            while(cons.e != cons.w &&
                  cons.buf[(cons.e-1) % INPUT_BUF_SIZE] != '\n'){
                cons.e--;
                consputc(BACKSPACE);
            }
            break;
        case C('H'): // 退格键
        case '\x7f': // 删除键
            if(cons.e != cons.w){
                cons.e--;
                consputc(BACKSPACE);
            }
            break;
        default:
            if(c != 0 && cons.e-cons.r < INPUT_BUF_SIZE){
                c = (c == '\r') ? '\n' : c;
       
                // 将字符回显给用户。
                consputc(c);
       
                // 存储供 consoleread() 消费。
                cons.buf[cons.e++ % INPUT_BUF_SIZE] = c;
       
                if(c == '\n' || c == C('D') || cons.e-cons.r == INPUT_BUF_SIZE){
                    // 如果一整行(或文件结束符)已到达,唤醒 consoleread()。
                    cons.w = cons.e;
                    wakeup(&cons.r);
                }
            }
            break;
        }
           
        release(&cons.lock);
    }
    

键盘与Shell的解耦

通过使用环形缓冲区和中断机制,操作系统将键盘输入与Shell的处理解耦。这意味着键盘输入和Shell读取操作可以并行运行,互不阻塞。如果某一方处理速度较慢,缓冲区机制确保数据不会丢失,同时可以让进程在适当的时候进入睡眠状态,等待另一方完成操作。

  • 生产者-消费者模式:在这个场景中,键盘是生产者,向缓冲区写入数据;Shell是消费者,从缓冲区读取数据。
  • 同步与并发控制:使用缓冲区和锁机制,可以确保生产者和消费者在多核环境下的并发安全,避免数据竞争和死锁问题。

通过这些机制,系统实现了用户输入的高效处理,并确保Shell能够正确响应用户的命令输入。

Interrupt 的演进与 Polling 机制

中断(Interrupt)机制在计算机系统的发展过程中,经历了显著的演变。早期的 Unix 系统中,Interrupt 处理非常快速,硬件的设计也相对简单。当外设需要处理数据时,它可以立即中断 CPU 的执行,直接让 CPU 处理硬件的数据。然而,随着硬件和软件复杂度的增加,尤其是在高性能设备中,处理器面对大量中断请求时,处理负荷显著增加,这催生了新的处理机制,如 Polling(轮询)。

早期的 Interrupt 处理

在早期的计算机系统中,中断处理的速度非常快。设备一旦有数据需要处理,便会立即中断 CPU 的当前任务,CPU 立即转而处理该设备的数据。这种机制确保了外设数据能够被及时处理,并使得系统能够高效地管理多个任务。

然而,随着计算机技术的发展,处理器的速度和复杂性不断提高,中断处理相对处理器来说变得缓慢了。这是因为中断处理过程需要经过多个步骤,如保存当前状态、切换处理模式、调用中断处理程序等,这些步骤增加了中断处理的时间开销。

现代设备中的 Interrupt 处理

在现代计算机系统中,特别是高性能设备中,中断处理的效率问题变得更加突出。例如,一个千兆网卡每秒可以接收1.5百万个数据包(Mpps),这意味着每微秒 CPU 都需要处理一个中断。如果网卡每接收一个数据包都产生一次中断,处理器将会被大量的中断请求淹没,无法有效地处理其他任务。

为了应对这种情况,现代设备通常会在产生中断之前进行大量的预处理操作,以减少中断请求的频率。这种方式使得设备硬件变得更加复杂,但同时也减轻了 CPU 的负担,使得系统能够处理更多的并发任务。

Polling 机制的引入

当设备以高速率产生中断,而 CPU 无法及时处理这些中断时,系统可能会采用 Polling(轮询)机制来取代或补充中断处理。在 Polling 机制下,CPU 不再依赖中断通知,而是主动、持续地检查设备的状态寄存器,以确定是否有新数据需要处理。

Polling 的优缺点

  • 优点:对于高性能设备,Polling 机制可以减少中断处理的开销。当设备频繁产生数据时,CPU 在轮询时很可能总能拿到新数据,这使得轮询比中断更加高效,因为它避免了频繁的上下文切换。

  • 缺点:Polling 会消耗大量的 CPU 时间,特别是在设备没有数据可处理时,CPU 的计算资源被浪费在无效的轮询操作中。因此,Pollng 机制适合于高数据传输速率的设备,但对于低速设备则不合适。

Polling 与 Interrupt 的动态切换

在一些高级设备驱动程序中,会实现 Polling 与 Interrupt 之间的动态切换。这种机制通常应用于网络设备的驱动程序中,如网络适配器的 NAPI(New API)模式。在数据流量较低时,驱动程序使用中断机制;而当数据流量增加,超过一定阈值时,驱动程序切换到 Polling 机制,以减少中断处理的开销,提高系统的整体效率。

通过在 Polling 和 Interrupt 之间动态切换,系统可以在处理性能和资源消耗之间找到最佳平衡点,确保在高负载情况下也能高效运行。

中断处理与 Polling 机制的并行工作

通过结合中断和 Polling 机制,现代系统能够灵活应对不同负载下的处理需求。这种结合使得系统能够在低负载时节省 CPU 资源,而在高负载时仍能保持高效的数据处理能力,从而提升整体系统性能。