【OS笔记38】设备管理 - I/O 设备原理写这篇笔记的起因是前两天帮人看一台老机器一块 GT710 显卡在设备管理器里直接给你来一个代码 43Windows 说设备已停用网上搜一圈全是让重装驱动、换卡、刷 BIOS 的。但你要真把 I/O 那套原理吃透了就会明白代码 43 很多时候根本不是显卡坏了而是设备与 CPU 之间的通信链路出了问题——要么是中断资源冲突要么是驱动层的 I/O 请求没被正确响应甚至是一根供电线接触不良导致的设备掉线。这事儿特别典型属于典型的你以为在修硬件其实是在跟 I/O 子系统搏斗。所以我把操作系统里设备管理这块的 I/O 原理单独拎出来结合这些实际踩坑场景写一篇笔记。这篇内容适合正在复习操作系统的学生、刚入门做嵌入式或驱动开发的朋友以及那些整天被设备管理器报错系统 I/O 高数据库慢查询折磨的运维和测试同学。关注的是从硬件引脚到软件中断、从设备控制器到系统调用这一整条链路上操作系统到底在管什么、怎么管、为什么这么管。期间我会穿插一些真实排障案例比如 Oracle 慢查询怎么查 I/O、C 流式读写和系统调用什么关系、AUTOSAR OS 对 I/O 实时性有多变态把这些零散经验串起来保证看完不是只会背概念而是真能拿来用。1. I/O设备到底在管理什么从设备控制器到驱动的关系拆解1.1 设备、控制器与CPU之间的分工咱们先把概念捋清楚。操作系统的 I/O 设备管理管的核心不是那个物理硬件本身而是CPU 怎么跟硬件说话。你以为 CPU 是直接控制硬盘、网卡、显卡的吗不是。中间永远隔着一个设备控制器。硬盘数据进内存其实是硬盘控制器比如 NVMe 控制器、SATA 控制器在中间干苦力活CPU 只负责发起指令和收结果。举个例子你就明白了。你在 C 里写std::ofstream out(data.txt); out hello;你觉得这是直接往硬盘写字节根本不是。这行代码经过 C 标准库的流缓冲区掉进操作系统提供的write()系统调用系统调用再按设备驱动提供的接口把请求塞给设备控制器控制器才真正去操作磁头或者闪存颗粒。所以 C 流 I/O 只是最上层的马甲真正干活的是驱动和控制器。这里有个初学者容易混乱的点设备管理到底管理的是设备还是控制器管理的是逻辑设备到物理设备的映射过程。每个设备都有一个控制器控制器里有一组寄存器CPU 通过这些寄存器与设备交互。操作系统要做的就是屏蔽不同控制器的差异给上层提供统一的接口。这就是为什么你在 Linux 下open(/dev/sda)也是用 open打开一个串口/dev/ttyS0也是用 open——底层完全不一样但对外接口都一样。这就是设备独立性的体现。1.2 设备控制器的核心寄存器与地址映射控制器里通常有三类寄存器状态寄存器、控制寄存器、数据寄存器。状态寄存器告诉 CPU 我忙不忙、上次操作成功没控制寄存器是 CPU 下发指令用的比如开始读、寻道到哪个扇区数据寄存器则是传输数据的中转站。关键是这些寄存器怎么访问。有两种方式一种是独立编址Port I/Ox86 上用 in/out 指令寄存器有独立的 I/O 地址空间另一种是内存映射 I/OMMIO直接把寄存器映射到内存地址空间里CPU 用普通的 load/store 指令就能操作。现在大多数 PCIe 设备走 MMIO因为你没法用 in/out 去操作显卡的几十 MB 显存和一堆状态信息还是统一映射到内存里方便。但是 MMIO 有个坑如果 BIOS 或者系统固件没给设备分配正确的地址范围驱动读到的一堆 0xFF 或者乱七八糟的值就会导致设备无法识别。你们应该遇到过某些老显卡插在主板上Windows 直接报代码 43但 Linux 下用lspci -vvv看又能发现设备只是驱动加载失败。很多时候就是这个地址映射出了问题或者设备固件没有正确响应 MMIO 访问。这时候你换个 PCIe 插槽问题可能就消失了——因为重新枚举后地址分配变了。注意排查设备问题时第一步永远不是重装驱动而是先看系统有没有正确识别到设备、设备有没有正常响应控制器的寄存器访问。Windows 的设备管理器错码、Linux 的dmesg | tail这类信息才是定位问题的第一现场。2. 四种I/O控制方式轮询、中断、DMA与通道怎么选2.1 轮询与中断的取舍以及实际中的异常表现CPU 跟设备交互最早的办法就是轮询。CPU 傻傻地循环读状态寄存器直到设备说我准备好了。代码写起来最简单但 CPU 被占死了——你等一个硬盘寻道 5 毫秒的时间CPU 就在那空转几百万个时钟周期。在早年间设备慢、任务简单的时候无所谓但现在一个 NVMe SSD 动不动几 GB/s你要轮询等它写完一大块数据CPU 就别干别的了。中断的出现是为了解决等待时 CPU 闲着的问题。设备准备好了就主动发一个中断信号给 CPUCPU 暂停当前任务去执行中断处理程序。这个逻辑听着很美好但中断也不是免费的。每次中断都有上下文切换的开销保存现场、跳转处理、恢复现场还有 TLB 和缓存可能失效一次中断的开销至少几百个时钟周期。要是设备每秒产生几百万个中断CPU 光处理中断就用掉大半性能这比轮询还惨。我在实际测试 OS 性能时最怕的就是网卡被配置成每收一个小包就发一次中断。小包高并发场景下CPU 直接被打爆softirq 占满整个核。解决办法要么是在驱动里打开中断合并coalesce攒一批包再中断一次要么直接切换到轮询模式比如 Linux 的 NAPI在高负载下主动禁用中断用轮询顶着。这告诉我们轮询和中断没有绝对好坏只有适合不适合。操作系统设备管理里讲到的权衡到了真实世界里就是网卡驱动那几十行配置参数。2.2 DMA为什么能解放CPU以及缓冲区溢出隐患比中断更进一步的是 DMA直接内存访问。有了 DMA数据从设备到内存的搬运工作不再经过 CPU 的寄存器中转而是由 DMA 控制器直接完成。CPU 只需要告诉 DMA 控制器源地址在哪、目标地址在哪、传多少字节然后就可以去干别的事等 DMA 干完发个中断通知一下就行。用磁盘读取举例。非 DMA 时代CPU 先从磁盘控制器数据寄存器读一个字节存到 CPU 寄存器再写进内存循环几百万次。DMA 时代CPU 设置好 DMA 控制器的参数然后整个扇区数据就哗啦啦地流进内存了。SSD 的高性能很大程度上就依赖 DMA 和 NVMe 队列机制让 CPU 从数据搬运工变成了包工头。但 DMA 也有隐患最经典的就是缓冲区溢出。如果 DMA 需要往内存写数据而操作系统给驱动分配的缓冲区比设备实际传输的数据量小设备就会一股脑写过去把内存里别的东西打烂。还有一类更隐蔽的问题DMA 操作的是物理内存地址如果操作系统开启了大页、开启了对齐限制驱动没处理好边界对齐DMA 就会出错。很多随机蓝屏、随机内存损坏的诡异问题最后查下来都是某个驱动 DMA 越界。实操提示排查 DMA 相关问题重点看两个地方。第一设备驱动申请缓冲区时有没有用内存一致性标志比如 DMA 属性里的 coherent 或 streaming这影响缓存一致性第二有没有开启 IOMMUx86 叫 VT-dIOMMU 可以限制设备只能访问被允许的内存范围相当于给 DMA 上了锁。如果你的系统整天出诡异问题先别急着怪内存条看看 BIOS 里 VT-d 到底开没开。2.3 通道机制与I/O处理机针对大型系统DMA 还能再进化那就是 I/O 通道也叫 I/O 处理机。和 DMA 不同DMA 每次只能执行一个传输任务而通道可以理解为一个专门处理 I/O 的小型处理器它能自己执行一套 I/O 指令程序控制多台设备。这套设计在大型机和小型机时代非常流行比如 IBM 主机的通道子系统可以同时管理磁带、磁盘、打印机等一堆设备CPU 只需要发起一条启动 I/O指令剩下的事全部交给通道程序。现代操作系统课程里讲通道更多是让你理解I/O 操作也可以是分层的。早期 PC 没有这么高级的东西但现代服务器上的 RAID 卡、智能网卡SmartNIC本质上就是通道思想的延续——网卡上的嵌入式处理器卸载了 TCP/IP 卸载引擎、RDMA 协议栈CPU 根本不参与数据包处理这不就是 I/O 处理机吗。包括现在火热的 DPU也是把网络、存储的 I/O 管理从 CPU 上剥离开来。你学通道机制时如果只盯着考试重点背通道是独立的 I/O 处理机那这概念就是死的联想到 DPU、智能网卡这个概念就活了。3. 从原理到实战I/O软件层次与常见故障排查3.1 I/O软件的四个层次剥离用户层、设备独立性、驱动、中断处理操作系统把所有 I/O 分成四层从这个分层能看出设计者的强迫症到底有多严重。最顶层是用户层 I/O 软件就是库函数和系统调用接口比如printf、read()、C 的iostream。往下是设备独立性软件负责设备名映射、分配、缓冲管理给上层提供统一的接口再往下是设备驱动程序每个设备对应一个驱动负责和控制器寄存器打交道最底层是中断处理程序负责响应设备发出的中断。学习的时候有个很经典的例子你在 Linux 下执行cat /dev/sda从用户层read()开始依次经过虚拟文件系统层的设备无关逻辑、块设备驱动层的sd驱动最后到 AHCI 或 NVMe 控制器产生中断中断处理程序再唤醒等待的进程。每一层都只干自己的事绝不越权。这种分层设计最大的好处是换一个硬盘型号只需要换驱动层上面的所有东西都不动。但分层也会带来性能损耗。每多一层就多一层函数调用和上下文切换。所以在高性能场景里大家又拼命做绕过内核的事情。比如用户态驱动UIO/DPDK、SPDK 这种直接让用户态进程控制 NVMe 设备的方案就是打破经典分层来换取性能。我建议你把标准四层结构背熟同时理解分层是设计哲学而非不可违背的律法。3.2 一次read()系统调用背后发生了什么结合C流I/O我把整个链路拆一个实例这是理解 I/O 原理最好的方式。假如你在 C 里写std::ifstream in(data.bin); in.read(buffer, 1024);。第一步std::ifstream内部维护了一个 stream bufferstd::filebuf它在你第一次读取时通过open()打开文件这个open()对操作系统而言就是openat()系统调用返回一个文件描述符 fd。第二步in.read()尝试先从进程内的流缓冲区拿数据如果缓冲区没有足够字节就会触发底层的read()系统调用。第三步read()进入内核VFS 根据 fd 找到对应的文件对象调用文件系统层比如 ext4文件系统经过页缓存page cache发现要读的页不在缓存里于是发起真正的块设备 I/O。第四步块设备驱动构造一个 bio块 I/O 请求提交给 NVMe 驱动驱动往设备队列里塞一条命令然后进程睡眠。等到 SSD 完成读取通过 MSI-X 中断通知 CPU中断处理程序把完成队列里的信息取出来再唤醒那个睡眠中的进程。最后数据从内核页缓存拷贝到用户态缓冲区read()返回 1024。这整个过程就是一次最简单的磁盘读。你问我为什么不用 C 的fread而用 C 流其实底层都走同样的read()系统调用区别只在用户层的缓冲策略不同。C 流默认有缓冲fread也有缓冲写入std::cout有缓冲但std::cerr无缓冲——直接进系统调用。理解了系统调用和设备层次再去看自己程序里的 I/O 瓶颈心里就有谱了。3.3 用排错案例讲清楚设备状态NVIDIA代码43、OS error 5、拒绝访问前面铺垫了这么多理论现在来看几个真实的错误现象。先说 Windows 里最常见的显卡代码 43设备管理器显示 Windows 已停止该设备因为它报告了问题。这种情况的本质是设备在枚举过程中没能完成正常的初始化握手。初始化握手是什么就是操作系统加载驱动之后驱动会向设备控制器写一些初始化和配置命令然后等待设备状态寄存器变为已就绪。如果设备没有正确应答比如硬件损坏、供电不足、BIOS 里 PCIe 链路协商失败、访问冲突驱动就会向内核报告设备无响应Windows 就给你贴上代码 43 的标签。所以你修代码 43思路不是盲目换驱动。先检查 PCIe 插槽是否插紧、供电线有没有接、在 BIOS 里把 PCIe 链路速度调低一档试试然后看 Windows 事件查看器里有没有 WHEA 记录、用 GPU-Z 看设备能不能被识别到。我修过一台代码 43 的机器最后发现是显卡金手指氧化了橡皮擦一擦就好。这种问题你让驱动背锅能背一百年。再说错误以一种访问权限不允许的方式做了一个访问套接字的尝试。OS error 5。这个常见于你写网络服务时去 bind 一个端口却收到这个错。OS error 5 在 Windows 上对应的是 ERROR_ACCESS_DENIED和 POSIX 的 EACCES 一个意思。这类问题从 I/O 原理来看就是设备这里是网络协议栈创建的套接字设备拒绝了你的操作权限。常见原因是端口被保留比如 443 端口被 HTTP.sys 占用、没有管理员权限绑定低端口、防火墙拦截。解决办法很简单先netstat -ano查端口占用再用管理员身份启动进程。如果你理解了套接字在操作系统里本身就是一种 I/O 设备你就明白这类错不是编程问题而是操作系统资源访问控制的问题。还有一波人喜欢在 Linux 下挂载光驱或者访问外接硬盘时遇到 Transport endpoint is not connected 或者 I/O error——这往往就是 USB 存储设备发送了中断但没有得到响应或者驱动层因为超时而把设备标记为离线。查这类问题dmesg是永远的第一现场它比任何图形界面的报错都诚实。3.4 怎么查看系统的I/O瓶颈Windows、Linux、Oracle数据库场景基本原理懂了还要会看指标。I/O 设备管理里最重要的三个数字利用率utilization、队列长度queue length、等待时间wait time。在 Windows 上打开资源监视器的磁盘页看总活动时间百分比、磁盘队列长度任务管理器不够细看不了单块盘的情况我一般直接上 PowerShell 的Get-Counter或typeperf。Linux 下最经典的就是iostat -x 1重点看%util和await。但%util有点误导性——NVMe 盘能并行处理很多请求%util到 100% 不代表它不行了SSD 有很好的并行度真正要关注的是avgqu-sz平均队列长度和svctm附近的值。数据库场景更典型。Oracle 数据库慢你打开 AWR 报告第一眼就是Read I/O和Write I/O的等待事件。如果你看到db file sequential read排在前面说明索引扫描在做大量小文件离散读如果db file scattered read排在前面那就是全表扫描对应连续读。查看 Oracle 的 I/O 最简单的方式是v$filestat或者v$iostat_file按AVG_READ_TIME排序找出哪个数据文件的读延迟最高。很多时候问题不在数据库而在底层磁盘阵列的 IOPS 不够或者快照磁盘损坏导致读延迟飙到一两百毫秒。心得查 I/O 瓶颈不要只看平均延迟一定要看 99 百分位延迟。平均延迟 10ms 的盘可能 99% 的请求是 1ms但有 1% 的请求卡了 900ms。这种抖动对外部服务而言是致命的。我见过不少案例监控图看着不吓人但业务说经常卡死后来一查就是某个磁盘在跑自检周期性掉链子。4. 缓冲技术与设备分配性能优化的关键细节4.1 缓冲区的作用与双缓冲、环形缓冲有了基础原理再看性能优化。I/O 管理绕不开缓冲技术。缓冲区的基本作用是匹配 CPU 与设备速度差异。CPU 处理速度是纳秒级硬盘是微秒到毫秒级网卡数据到来是突发的。如果没有缓冲区CPU 得一直等待慢设备或者有突发数据时直接丢包。操作系统最常见的缓冲策略有三种单缓冲、双缓冲、环形缓冲。单缓冲就是一块缓冲区设备写入后 CPU 读取但读和写不能同时进行双缓冲把缓冲区分为两块设备往 A 块写的时候CPU 从 B 块读交替用读和写可以重叠环形缓冲则是一圈固定的缓冲区生产者和消费者通过指针追赶常用于串口、网卡接收队列。在 Linux 下网卡的 ring buffer环形缓冲区就是一个经典实现。ethtool -g eth0可以看到当前环形队列大小默认只有 256 或 512 个描述符要是你发现大量丢包ethtool -S eth0里 rx_dropped 不小可以尝试调大ring parameter。这就是缓冲技术最直接的实操点。4.2 SPOOLing技术以及设备分配中的死锁预防SPOOLing假脱机是个有点复古但思想依旧先进的技术。最典型的场景是打印机。多进程都要打印但打印机只有一个总不能让进程直接占着打印机互斥吧。于是操作系统搞了个磁盘上的打印缓冲区每个进程要打印时先把数据写到缓冲区然后由一个专门的守护进程统一排队往打印机送。从进程角度看每个进程都感觉自己独占了一台打印机。这个思想在云原生时代得到了重生。你想想消息队列生产者进程把日志/任务发给 MQ消费者进程慢慢消费。MQ 就是 SPOOLing 里的磁盘缓冲消费者进程就是打印守护进程。包括对象存储的分片上传也是这思路——客户端先把数据分片传到缓冲区桶里后台再把分片合并成对象。所以学 SPOOLing 不要只背概念往现在的架构上靠一靠会特别有意思。再说设备分配里的死锁问题。打印机、磁带机这种独占设备系统要设计好分配策略否则会出现死锁进程 A 占了打印机等扫描仪进程 B 占了扫描仪等打印机。操作系统处理方式无非就是死锁的四个条件破坏其中一个就行。比如破坏持有并等待——要么一次性分配所有设备要么分配不到就释放已有资源再比如破坏循环等待——给所有设备编号进程只能按编号递增顺序申请。这块考试爱考实际设计多路传感器系统时也是同样的套路控制好资源分配顺序就能大概率避免死锁。4.3 性能指标吞吐量、响应时间与I/O占空比最后说点硬核指标。I/O 子系统最终要衡量的是吞吐量Throughput和响应时间Response Time。吞吐量是单位时间内完成的 I/O 请求数IOPS或者字节数带宽响应时间是单个请求从发出到完成的时间。这两个指标通常是矛盾的系统负载越高吞吐量越大但响应时间会变长因为请求排队了。这就是 Littles Law 的直观体现L λW队列中的平均请求数 请求到达率 × 平均响应时间。你看到一个磁盘队列长度 L 很大说明要么请求来得太密要么响应太慢。还有一个容易被忽略的概念I/O 占空比。对某个设备来说占空比 设备忙的时间 / 总时间。如果占空比接近 100%说明设备已经饱和再怎么优化驱动也没用只能加设备或者换更快的设备。但注意饱和不一定发生在你直觉认为忙的地方。比如内存带宽占满、总线带宽占满、中断处理 CPU 核占满都会让 I/O 变慢。我遇到过一次 NVMe 速度只有预期一半的怪事排查到最后是 PCIe 链路因为插槽带宽不够降到了 x2而不是 x4。这种占空比问题用lspci -vv看LnkSta就能一眼发现。5. 学习I/O设备原理时容易出现的理解误区实操心得5.1 中断不是越快越好上下文切换开销要算很多初学操作系统的人有一种错觉用中断肯定比轮询强。实际在生产环境中断频率过高会导致中断风暴系统大部分时间都在做恢复现场的无用功而不是真正处理业务。我在压测一个低速串口设备时就发现了这个问题设备每收到一个字节就发一次中断中断处理程序只是把字节放缓冲区结果 CPU 的一个核被打满 100%业务响应照样烂。后来我把驱动改成收到一定阈值比如 32 字节才触发一次中断或者直接用定时器聚集中断CPU 占用立刻降到 20%。这个经验在嵌入式开发里特别重要尤其是做 AUTOSAR OS 那种实时操作系统时中断响应时间Interrupt Latency是硬指标但不能让中断频率失控。你要在 及时性 和 开销 之间找平衡点而不是无脑追求每个事件都立刻中断。5.2 设备驱动不是万能药用户态驱动与内核态驱动的争论很多人遇到设备问题第一反应是驱动没装好这种思维太简化了。驱动只是内核里让设备能工作的协议翻译层很多情况下设备不工作是因为硬件资源中断号、I/O 地址、DMA 通道分配冲突或者设备本身固件状态不对。驱动不是万能灵药。另外现在的趋势是用户态驱动越来越主流。内核态驱动虽然有更好的稳定性和权限但一旦驱动有 bug整个系统直接崩给你看。而用户态驱动比如 DPDK、SPDK把设备访问通过 UIO/VFIO 暴露给用户态应用自己去 poll 设备完成队列虽然绕过了内核的调度保护但换来了极致的性能和灵活性。做高性能存储的人经常说内核 I/O 路径太长了所以自己写用户态 NVMe 驱动一条命令从应用到设备只需要几微秒。你说他违反操作系统教科书吗确实违反了驱动在内核态的经典设定但现实世界的需求是多元的。我个人的原则是能求助内核态驱动的场景普通网卡、普通磁盘老老实实让系统管但如果是高性能极客场景比如跑存储引擎、跑核心网络转发那用户态驱动是真香。别被教科书限制死。5.3 兼容性问题的根源往往在中断冲突与地址映射最后说说最恼人的兼容性问题。Windows 下旧显卡代码 43、Linux 下某个 PCIe 设备不能识别很多时候都是中断路由和地址映射的锅。传统 PC 里中断控制器PIC/APIC资源有限多个设备可能共享一条中断线。当驱动为设备注册中断处理函数时如果设备实际发出的中断信号被另一个设备占用了那驱动永远等不到自己的中断设备就表现为工作异常。现代系统基本都是 MSI/MSI-X 中断每个设备可以独享中断向量很多兼容性问题解决了。但碰上那些老设备或者某些喜欢乱写 ACPI 表的笔记本主板中断冲突依然能让你一个头两个大。排查这类问题的一个有效办法在 Linux 下看/proc/interrupts如果某个设备的中断号被频繁共享而且设备错误计数在涨基本就是中断问题了。这时候可以试试给内核传参数比如pcinomsi强制禁用 MSI退回传统中断方式往往就能让设备稳定工作。还有地址映射问题。上面提到 MMIO如果 PCIe 设备的 ROM 地址区域和系统保留内存冲突设备根本无法完成初始化。你会在dmesg里看到类似 BAR 0: cant reserve 或 address space collision 的报错。解决办法是调整 BIOS 里的Above 4G Decoding选项或者更换插槽。我见过有人为此刷主板 BIOS其实大概率只是 PCIe IO 空洞配置问题。把这些问题串起来你会发现学 I/O 设备原理不是说非要会编写一个网卡驱动才算学会而是当你面对设备报错的时候你能从 CPU、中断、DMA、地址映射、缓冲这些底层机制出发快速定位问题的大致范围。那种重启一下又好了过几天又开始抽风的疑难杂症十有八九都藏在 I/O 子系统某个你看不见的细节里。最后再分享一个我这些年看设备和性能问题养成的小习惯手里备一个桌面级 Linux 发行版的 U 盘。Windows 机器出设备问题、系统蓝屏我第一件事是插 U 盘启动到 Linux用lspci -vvv、dmesg看设备枚举和中断分配情况。这样做的好处是绕开了 Windows 驱动层的干扰直接看硬件自身是否正常。如果 Linux 下设备能正常枚举、正常读写那问题基本就锁死在 Windows 驱动或配置上如果 Linux 下也报错那就老老实实从硬件层面开始查吧。这招帮我解决了很多代码 43和设备不识别的疑难杂症也算是我从 I/O 原理里带出来的一个很实用的操作习惯。