写在前面:这是本系列的第二十篇。

虚拟化和并发两个主题,为我们展示了操作系统为应用程序提供的各类 API:我们可以通过系统调用创建进程、使用多个处理器,也可以通过文件描述符访问操作系统对象。

今天,是时候回到 UNIX 最极致的浪漫 “Everything is a file” 中的这个 “Everything” 了。

本讲内容:计算机系统的最后一块拼图——I/O 设备原理、构造与实现。从键盘、鼠标到打印机、显卡……学完这节课,你会感到“手搓一个计算机系统”真的是可以做到的。

在这里插入图片描述

课前反思:Testkit 与工程实现

关于之前的 Testkit,其实我还不太会用……
多个 .c 文件的编译顺序让人摸不着头脑,快照(Snapshot)机制到底是怎么起作用的,老师讲得也有点快。

不过没关系,这也是 AI 时代赋予我们的特权。找个机会把源码丢给豆包或者 Kimi,让 AI 手把手教我们怎么用 Test 框架,攻克这种历史包袱沉重的 C 语言工程结构。


输入/输出设备 (I/O Devices)

Everything is a file (复习)

  • 文件:有“名字”的数据对象
    • 字节流(终端,/dev/random
    • 字节序列(普通文件)
  • 文件描述符 (File Descriptor)
    • 指向操作系统对象的“指针”
    • Everything is a file,通过这根“指针”可以访问“一切”。
    • 所有的对象访问都必须通过这根指针:open, close, read/write (解引用), lseek (指针偏移运算), dup (指针复制)。

没那么简单:水面下的冰山

让我们插个 U 盘试试……
U 盘的文件系统会自动“出现”在桌面上,但作为专业的系统程序员,你应该去看看 /dev/ 目录下是不是发生了一些微妙的变化。

/dev/ 下的对象不会凭空创建!

  • 底层有 udev 守护进程监控着硬件的热插拔事件(规则定义在 /lib/udev/rules.d)。
  • 发现硬件后,再由 udisks2 这样的程序去真正执行 mount 挂载操作。

今天的主角:输入输出设备

你实际上“看到”的计算机主板,上面密密麻麻全是对外的物理接口:

“计算”和“物理世界”的桥梁

I/O 设备 = 一个能与 CPU 交换数据的接口/控制器

  • 它本质上就是“几组约定好功能的排线”(对应着硬件上的寄存器)。
  • 硬件控制器通过握手信号,从这些线上读出或写入数据。
  • 操作系统通过地址解码器(Address Decoder),给这些硬件寄存器“赋予”一个特殊的内存地址。
  • 于是,CPU 就可以直接使用指令(in/out 或者 MMIO 内存映射 I/O)来和设备交换数据。
  • 是的,就这么简单。它就是一个读写协议。

实现输入/输出:发射核弹的例子

I/O 使得无情的计算机器能够:

  • 感知外部状态 (作为眼睛、耳朵)
  • 对外实施动作 (作为手,哪怕是按下一个核弹发射按钮)

答案:一根线、一条指令 (GPIO)

GPIO (General Purpose Input/Output,通用输入输出)

  • 最极简的模型:通过 Memory-mapped I/O (内存映射 I/O),直接向某个特定内存地址写入一个数字,就能控制外部硬件引脚的电平高低(0 或 1)。

GPIO:一根可读写的数据线

led = LED(2)
led.on(); time.sleep(0.03); led.off()
  • 真正的核弹发射器,底层也是类似的原理……

真实的输入/输出设备案例

#1 串口(UART)

“COM1” (Communication 1)。早期没有花里胡哨的 USB 协议,直接通过几个 I/O 端口就能读写字节流。

#define COM1 0x3f8

static int uart_init() {
  outb(COM1 + 2, 0);   // 控制器相关细节,如关闭 FIFO
  outb(COM1 + 3, 0x80); // 开启 DLAB,准备设置波特率
  outb(COM1 + 0, 115200 / 9600); // 设置波特率
  ...
}

static void uart_tx(AM_UART_TX_T *send) {
  outb(COM1, send->data); // 发送数据就是往端口写一个字节
}

static void uart_rx(AM_UART_RX_T *recv) {
  // 读数据就是从端口读一个字节(需要先检查状态位是否有数据)
  recv->data = (inb(COM1 + 5) & 0x1) ? inb(COM1) : -1;
}

#2:键盘控制器

IBM PC/AT 8042 PS/2 (Keyboard) Controller

  • 端口: 0x60 (data), 0x64 (status/command)
  • 向命令端口写入 0xED $ \rightarrow $ 控制键盘上的 LED 灯(大小写锁定灯等)。
  • 向命令端口写入 0xF3 $ \rightarrow $ 设置按键重复速度和延迟。

#3:磁盘控制器

ATA (Advanced Technology Attachment)。古老的 IDE 接口磁盘(那种又宽又扁的 40 针数据线 + 4 针电源)。

  • 主通道端口: 0x1f0 - 0x1f7

读一个扇区,本质上就是依次向控制器端口写入扇区号、柱面号、驱动器号,最后发送一个 0x20 (读命令),然后死等硬盘把数据送到内存里:

void readsect(void *dst, int sect) {
  waitdisk();
  out_byte(0x1f2, 1);                   // 读几个扇区:1
  out_byte(0x1f3, sect);                // 扇区号 LBA (low)
  out_byte(0x1f4, sect >> 8);           // 柱面 (mid)
  out_byte(0x1f5, sect >> 16);          // 柱面 (high)
  out_byte(0x1f6, (sect >> 24) | 0xe0); // 驱动器与模式
  out_byte(0x1f7, 0x20);                // 核心指令:执行读取操作!
  waitdisk();
  for (int i = 0; i < SECTSIZE / 4; i ++)
    ((uint32_t *)dst)[i] = in_long(0x1f0); // 从数据端口把数据掏出来
}

#4:打印机

打印机是怎样的设备?

  • 打印机负责将字节流描述的文字/图形打印到纸张上。

PostScript 和打印机:

  • PostScript: 一种描述页面布局的领域特定语言 (DSL)。
  • 类似于汇编语言(通常由排版编译器,如 LaTeX 生成)。
  • PDF 其实就是 PostScript 的超集(Superset)。目前最常用的 PDF 实际上是包裹了对象和压缩数据的二进制格式,而 ps 是纯文本描述的文件。
  • 打印机设备: 本质上就是一个将汇编语言(指令)翻译成机械部件动作的机器。
// 向打印机发送 PCL/PostScript 指令示例:
<ESC>*t300R          // Set resolution to 300 DPI
<ESC>*r1A            // Start raster graphics
<ESC>*b100W          // Set width of raster data (100 bytes)
<ESC>*b0M            // Set compression mode (0 = uncompressed)
<ESC>*b100V          // Send 100 bytes of raster data
<binary raster data> // 真正的图像像素二进制数据
<ESC>*rB             // End raster graphics

如果想要“无穷无尽”的 I/O 设备:总线的诞生

早期的硬件生态极其封闭。

  • 想卖大价钱的“大型机”:IBM, DEC…
  • 车库里造出来的“微型机”:造就了名垂青史的梦想家。
  • IBM PC/AT: 确立了 ISA (Industry Standard Architecture) 总线标准,让任何第三方厂商都能开发兼容的主板扩展卡。
  • Apple II: 50-pin 扩展槽 (Apple II Bus)。

#5:总线 (Bus)

  • 总线提供了设备的“虚拟化”:注册和转发。
  • 它把 CPU 发出的地址(总线地址)和数据,智能地转发到插在槽位上的相应设备。
  • 例子: CPU 的 port I/O 端口就是总线上的地址。在 IBM PC 的 CPU 看来,外部世界其实只有“总线”这一个庞大的 I/O 设备,其他设备全靠总线路由。

巅峰之作:PCIe 总线

今天获得“CPU 直连”待遇的终极标准设备。

  • 接口供电: 槽位提供 75W 供电(显卡功耗太高,所以我们需要 6-pin, 8-pin 的额外供电线)。
  • 数据传输:
  • PCIe 6.0 x16 带宽达到极其恐怖的 128GB/s!
  • 于是我们才有了 800Gbps 的超高速网卡。
  • 总线自带 DMA (Direct Memory Access,专门负责在内存和设备之间狂搬数据的“小 CPU”,从而解放主 CPU)。
  • 中断管理: 将传统电信号中断升级为消息信号中断 (Message-signaled Interrupts, MSI),大幅减少延迟和布线。

PCIe 支撑了现代 I/O 设备的完整体系:

  • 所有高速设备都是直插 PCIe 的(FPGA, 显卡, 万兆网卡……)。
  • 连 USB 控制器和 NVMe 固态硬盘,本质上也是挂载在 PCIe 总线下的节点!

设备驱动程序 (Device Drivers)

用程序访问设备

应用程序绝对不能直接访问硬件寄存器! (会导致严重的安全和资源冲突问题)。

  • 硬件设备是可以在多程序之间共享的。
  • 仔细想:CPU 和内存本质上也是“设备”,操作系统通过多进程和分页实现了它们的虚拟化
  • 同理,我们通过驱动程序,实现 I/O 设备的虚拟化就行了!

Everything is a file (万物归宗)

File = 实现了文件操作的 “Anything” (任何东西)

在 Linux 内核里,一个文件在底层其实就是一张塞满了函数指针的表:struct file_operations

struct file_operations {
 struct module *owner;
 loff_t (*llseek) (struct file *, loff_t, int);
 ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
 ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
 int (*mmap) (struct file *, struct vm_area_struct *);
 int (*open) (struct inode *, struct file *);
 int (*release) (struct inode *, struct file *);
 // ... 还有一大堆其他的钩子
};

什么是设备驱动程序?

  • 驱动程序,就是实现了这套 struct file_operations 指针的内核代码。
  • 它负责把上层的系统调用 (read/write),翻译成与硬件设备能听懂的底层寄存器控制数据
  • 这就是一段普普通通的 C 语言内核代码。

例子:

  • devfs 中的“虚拟”文件:/dev/pts/0 (伪终端)。
  • /dev/zero, /dev/null, /dev/random……它们连物理硬件都没有,完全是驱动程序在内存里“凭空捏造”出来的数据黑洞或生成器。
  • procfs 也是一样,只要驱动程序实现了 read 钩子,当你 cat /proc/stat 时,驱动就会实时收集系统的 CPU 状态并返回字符串给你。

极客玩法:驱动 Nuclear Launch (核弹发射)

  • 我们完全可以自己写一个内核模块。
  • 注册一个 /dev/nuke0 的虚拟文件,把它的 file_operations 路由到我们的代码。
  • 当用户态程序执行 write(fd, "LAUNCH", 6) 时,驱动程序里的 write 钩子被触发,向 GPIO 的 Memory-mapped address 写入高电平,点燃发射药!(当然,在实验里我们只是亮起一个红灯模拟一下)。
  • 核心:设备和驱动,本质上就是抽象成文件的一组 API 接口。

避无可避的深渊:配置设备与 ioctl

硬件设备不仅仅只是按流发送或接收数据(read / write),它还有极其复杂的配置

  • 打印机的卡纸重试、喷头清洁、自动双面装订……一台几十万的工业打印机可没那么简单。
  • 键盘的 RGB 跑马灯、按键重复速度调节、硬件宏编程……
  • 磁盘的 S.M.A.R.T 健康状况读取、缓存策略控制……

两种实现配置的方法:

  1. 把控制指令混在数据流里发过去(比如之前讲过的终端 ANSI Escape Code \033[...)。
  2. 提供一个新的请求-响应接口。

缝合怪 API:ioctl

“The ioctl() system call manipulates the underlying device parameters of special files.”
ioctl() 系统调用负责操作特殊文件的底层设备参数。参数 fd 必须是一个打开的文件描述符。)

int ioctl(int fd, unsigned long request, ... /* void *arg */);

这是一个名副其实的“缝合怪”系统调用。你只需传入 fd 和一个魔法指针 ptr

  • “非数据”的设备功能几乎全部依赖 ioctl
  • 官方手册原话:“ioctl() 的参数、返回值和具体语义,完全取决于对应的设备驱动程序”。(换句话说,OS 内核不管了,驱动程序员爱怎么写怎么写)。

ioctl:堆叠的 💩 山

  • 设备的复杂性是无法在软件层面降低的。 硬件就是有那么多奇葩的专有功能。
  • 于是 ioctl 成了垃圾桶,里面塞满了各种无法分类的功能。
  • 这是 UNIX “万物皆文件”抽象模型下背负的沉重十字架:形成了大量复杂的、隐藏在文档深处的 “hidden specifications”。
  • (另一个饱受诟病的负担是 procfs,里面也塞满了一堆乱七八糟的非标准接口)。

例子:

  • 终端:为什么 libc 能“智能”实现缓冲区模式?底层靠 ioctl 读取终端宽度和模式。
  • 网卡的混杂模式、GPU 的显存分配、虚拟化底层 KVM Device 的创建,无一不是 ioctl 的杰作。

总结

Take-away messages:

输入/输出设备可以说是五花八门,我们也看到越来越多的设备上甚至开始“自带电脑”(比如带有微型操作系统的高级网卡或硬盘控制器)。

但万变不离其宗。无论底层硬件多么狂野、离谱,伟大的操作系统依然成功地在内核中把它们统一抽象成了一个可以读写、可以控制的、实现了 struct file_operations 接口的特殊文件。

掌握了这一切,你不仅不再对黑框框里的系统感到畏惧,甚至已经具备了“手搓”属于自己的软硬件桥梁(驱动)的能力。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐