输入输出设备

Everything is a File

在 Unix-like 系统中,与外部设备交互的核心思想是 Everything is a File

  • 文件描述符 (File Descriptor):操作系统为上层软件提供了一个统一的抽象,即文件描述符,它是一个指向内核中任何 I/O 对象的“指针”或句柄

  • 统一接口:无论是普通文件、硬件设备(如终端、磁盘)、还是网络连接,都可以通过 open 获得一个文件描述符,然后使用相同的 read/write 等系统调用来进行操作,这极大地简化了应用程序的编写

设备控制器与 MMIO

“文件”这个美好的抽象背后,是具体的硬件工作原理

  • 设备控制器 (Device Controller):每个 I/O 设备都有一个控制器,它是一个包含 CPU、内存和寄存器的微型计算机,作为 CPU 和物理设备之间的桥梁

  • 设备寄存器:控制器通过一组寄存器与 CPU 通信,通常包括:

    • 状态寄存器:用于表示设备当前是否繁忙、是否准备好等
    • 指令寄存器:CPU 写入指令,告诉设备要做什么
    • 数据寄存器:用于在 CPU 和设备之间传输数据
  • 内存映射 I/O (MMIO):为了让 CPU 能访问这些寄存器,现代系统普遍采用 MMIO (Memory-Mapped I/O),操作系统会将设备的寄存器映射到物理内存地址空间中的特定区域,这样一来,CPU 就可以像访问普通内存一样,使用标准的 load/store 指令来读写设备寄存器,从而实现对设备的控制

GPIO

GPIO (General-Purpose Input/Output) 是理解 I/O 设备原理最直观的例子,GPIO 就是一个物理引脚,可以通过编程设置为输入或输出模式

通过 MMIO,一个 GPIO 引脚的电平状态被映射到一个特定的内存地址,当 CPU 向这个地址写入 1 时,引脚就变为高电平;写入 0 时,则变为低电平,这个过程将一条内存写指令直接转化为了一个物理世界的动作(比如点亮一个 LED)

输入输出设备案例

串口与键盘

经典的 I/O 设备,展示了最基础的设备交互方式

端口 I/O:它们通常使用端口 I/O (Port I/O) 与 CPU 通信,设备寄存器被映射到专用的 I/O 端口地址(而非内存地址),CPU 需要使用特殊的 in/out 指令来读写这些端口

向指定端口写入不同的数值,相当于向设备发送不同的指令(如设置波特率、控制键盘 LED 灯),而从指定端口读取数据则是接收设备的状态或输入(如串口收到的字符、键盘按键的扫描码)

磁盘控制器与 PIO

早期的磁盘控制器(如 ATA/IDE)展示了一种更复杂但效率较低的交互模式:PIO 协议:全称为Programmed I/O,在这种模式下,数据的传输完全由 CPU 控制

工作流程

  1. CPU 向磁盘控制器的指令寄存器写入命令(如“读取第 N 个扇区”)
  2. CPU 进入轮询 (Polling) 状态,反复读取状态寄存器,直到设备报告“数据准备就绪”
  3. CPU 在一个循环中,逐个字节或字地将数据从磁盘的数据寄存器读入 CPU 寄存器,再写入内存

缺点:在轮询和数据传输期间,CPU 被完全占用,无法执行其他任务,效率极其低下

打印机与 DSL

打印机这类设备,将交互模型提升到了一个新的高度

领域专用语言 (DSL):打印机不是简单地接收像素数据,而是作为一个独立的计算机,接收并解释用页面描述语言(如 PostScript 或 PCL)编写的“程序”

CPU(驱动程序)的角色更像是一个编译器,将应用程序的打印请求(如一个 Word 文档)编译成一串 PostScript 指令流,然后发送给打印机

打印机内部的处理器负责执行这些指令,将抽象的描述(如“在这里画一条线”、“使用这个字体显示文本”)翻译成打印头的物理动作

总线与可扩展性

单个计算机系统需要连接多种多样的设备,这就需要一个标准化的扩展机制——总线 (Bus)

总线是一组共享的电子线路,它定义了一套协议,允许 CPU、内存和多个 I/O 设备之间进行通信,它提供了一种“设备虚拟化”,CPU 只需与总线控制器通信,由总线负责将请求转发到正确的设备

  • 可扩展性:通过标准的扩展插槽(如早期的 ISA、现代的 PCIe),用户可以向系统中添加无穷无尽的新设备,而无需修改主板或 CPU 的设计

PCIe 总线与 DMA

PCIe (PCI Express) 是目前主流的高速总线标准,它引入了一项革命性的技术来解决 PIO 的效率问题:DMA,全称为直接内存访问 (Direct Memory Access),它允许设备控制器在没有 CPU 干预的情况下,直接与主内存进行数据传输

工作流程

  1. CPU 设置 DMA 控制器,告诉它源地址、目标地址和传输大小
  2. CPU 向设备发出“开始传输”的指令后,就可以去执行其他任务
  3. DMA 控制器全权负责数据的搬运
  4. 传输完成后,DMA 控制器通过中断通知 CPU

优势:DMA 极大地解放了 CPU,使其不需要负责搬运数据,从而显著提升了整个系统的 I/O 吞吐量和效率,是所有现代高性能设备(显卡、NVMe 硬盘、高速网卡)的基础

设备驱动程序

file_operations 结构体

操作系统内核通过名为 file_operations 的结构体落实“Everything is a File”的思想

核心机制:这个结构体本质上是一个函数指针列表,定义了一系列标准的文件操作,如 read, write, open, llseek, ioctl

驱动的本质:一个设备驱动程序 (Device Driver) 的核心,就是为特定的硬件或虚拟设备,提供一套具体的 file_operations 实现,当一个设备被注册到系统中时,内核就会将这个设备的“文件”与这套操作函数关联起来

驱动的翻译职责

设备驱动程序的核心职责,就是翻译应用程序和物理硬件之间的交互

翻译过程:当一个用户程序对文件描述符执行系统调用时(例如 read(fd, buf, size)),内核会:

  1. 通过 fd 找到对应的内核文件对象
  2. 从文件对象中找到关联的 file_operations 结构体
  3. 调用其中的 .read 函数指针,并将系统调用的参数传递过去

驱动的实现:驱动程序中的 .read 函数则负责执行设备相关的底层操作,比如通过 MMIOPIO 向设备控制器发送指令,等待数据就绪,然后将数据从设备寄存器中读出,最后复制到用户空间的 buf

虚拟设备:这个模型同样适用于虚拟设备,例如对 /dev/nullwrite 操作,其驱动实现仅仅是直接返回写入的字节数,而什么也不做,对 /proc/statread 操作,则是读取内核中的统计数据并格式化成字符串返回

ioctl 万能接口

对于读写数据流之外的设备控制和配置需求(如设置键盘重复率、获取磁盘健康信息、配置网络参数等),read/write 模型显然不能满足,为此,Unix 系统提供了一个通用的 ioctl (I/O Control) 系统调用

ioctl 是一个高度灵活的接口,它的具体行为完全由设备驱动程序定义,应用程序通过传递一个设备专属的命令码和参数,来执行特定的控制功能

虽然强大,但 ioctl 也带来了巨大的复杂性,因为每个设备的命令集都不同,形成了一系列隐藏的、非标准的协议,应用程序需要知道这些细节才能与设备深度交互(是巨大的屎山💩)

实际案例

  • libc 缓冲:libc 库通过对文件描述符 1 (stdout) 执行一个 tty 设备专属的 ioctl 命令(如 TCGETS),来判断输出目标是否为一个交互式终端,从而决定是采用行缓冲还是全缓冲

  • KVM 虚拟化:KVM 就是一个通过 ioctl 暴露全部功能的复杂设备,用户程序打开 /dev/kvm 后,通过一系列 ioctl 命令(如 KVM_CREATE_VM, KVM_SET_REGS, KVM_RUN)来创建虚拟机、设定 CPU 状态并运行虚拟机,直到发生 VM Exit 事件返回到用户态