 学习笔记:I/O 设备原理、万物皆文件与驱动的黑魔法)
写在前面这是本系列的第二十篇。虚拟化和并发两个主题为我们展示了操作系统为应用程序提供的各类 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一根可读写的数据线ledLED(2)led.on();time.sleep(0.03);led.off()真正的核弹发射器底层也是类似的原理……真实的输入/输出设备案例#1 串口UART“COM1” (Communication 1)。早期没有花里胡哨的 USB 协议直接通过几个 I/O 端口就能读写字节流。#defineCOM10x3f8staticintuart_init(){outb(COM12,0);// 控制器相关细节如关闭 FIFOoutb(COM13,0x80);// 开启 DLAB准备设置波特率outb(COM10,115200/9600);// 设置波特率...}staticvoiduart_tx(AM_UART_TX_T*send){outb(COM1,send-data);// 发送数据就是往端口写一个字节}staticvoiduart_rx(AM_UART_RX_T*recv){// 读数据就是从端口读一个字节需要先检查状态位是否有数据recv-data(inb(COM15)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(读命令)然后死等硬盘把数据送到内存里voidreadsect(void*dst,intsect){waitdisk();out_byte(0x1f2,1);// 读几个扇区1out_byte(0x1f3,sect);// 扇区号 LBA (low)out_byte(0x1f4,sect8);// 柱面 (mid)out_byte(0x1f5,sect16);// 柱面 (high)out_byte(0x1f6,(sect24)|0xe0);// 驱动器与模式out_byte(0x1f7,0x20);// 核心指令执行读取操作!waitdisk();for(inti0;iSECTSIZE/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 DPIESC*r1A// Start raster graphicsESC*b100W// Set width of raster data (100 bytes)ESC*b0M// Set compression mode (0 uncompressed)ESC*b100V// Send 100 bytes of raster databinary 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。structfile_operations{structmodule*owner;loff_t(*llseek)(structfile*,loff_t,int);ssize_t(*read)(structfile*,char__user*,size_t,loff_t*);ssize_t(*write)(structfile*,constchar__user*,size_t,loff_t*);int(*mmap)(structfile*,structvm_area_struct*);int(*open)(structinode*,structfile*);int(*release)(structinode*,structfile*);// ... 还有一大堆其他的钩子};什么是设备驱动程序驱动程序就是实现了这套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 健康状况读取、缓存策略控制……两种实现配置的方法把控制指令混在数据流里发过去比如之前讲过的终端 ANSI Escape Code\033[...。提供一个新的请求-响应接口。缝合怪 APIioctl“Theioctl()system call manipulates the underlying device parameters of special files.”ioctl()系统调用负责操作特殊文件的底层设备参数。参数fd必须是一个打开的文件描述符。intioctl(intfd,unsignedlongrequest,.../* 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接口的特殊文件。掌握了这一切你不仅不再对黑框框里的系统感到畏惧甚至已经具备了“手搓”属于自己的软硬件桥梁驱动的能力。