
操作系统里“设备管理”这一章我印象最深的就是设备分配。前面讲中断、IO通道、缓冲技术时总觉得设备只是个“外设”真到了设备分配才明白设备、控制器、通道三层结构加上进程请求、排队、死锁检测这套机制比想象中复杂得多。写这篇OS笔记时我花了大量时间把教材上的抽象流程转成可验证的模拟代码发现只要把“设备分配”这四个字拆成三个问题——给谁、何时给、给完怎么收——整个知识框架就清楚了。这篇笔记适合正在复习操作系统课程、准备考研或面试以及想做一个小型IO调度实验的朋友。我会从顺序分配流程讲到Linux内核里设备模型的对应关系最后整理几个我实际排查过的设备分配问题尽量不绕弯子。1. 设备分配到底在解决什么问题很多初学者容易把“设备分配”想成“把一个打印机分给一个进程”好像进程拿到设备就能用了。但实际上进程真正要用的资源链是设备——控制器——通道。打印机只是最末端打印机靠控制器解析指令控制器要靠通道连接内存和CPU。任何一环不可用整个IO请求都完不成。所以设备分配不是单点分配而是一条资源链的三级分配。1.1 一次打印请求背后的完整链路进程A调用打印接口表面上是“请求一台打印机”底层却要走这样一条路检索系统设备表找到类型匹配、尚未分配的一台打印机。把打印机标记为“已分配”记录使用它的进程标识。继续检索这台打印机对应的控制器检查控制器是否空闲。控制器空闲则分配再接着检查该控制器能通达的通道。通道空闲则分配进程才可以真正发起IO操作。只要中间任何一步失败进程就不能直接使用设备。常见的处理方式是如果设备已分配进程挂到该设备的等待队列如果设备拿到了但控制器忙此时不能“攥着设备等控制器”而要把已经分配到的设备释放掉再挂到控制器等待队列。因为持有低层资源等待高层资源是最经典的资源死锁诱因。这一点我在实验里踩过坑后面会单独写。从这就能看出设备管理为什么要设计“设备独立性”进程不直接指认物理设备而是按设备类型提出请求。系统再从同类型空闲设备中选一台从而避免进程和某个物理设备强绑定。否则一旦设备被拔掉或故障所有进程都要出错。1.2 静态分配与动态分配安全换不来效率设备分配有两种基本时机。静态分配在进程创建或作业调度时把运行期间需要的设备一次性全部分配直到进程结束才释放。这种方式实现简单也天然避免死锁——资源全部到手才会运行不存在“占着一个等另一个”的循环等待。但它的致命缺点是设备利用率太低。想象一台打印机同时只服务一个进程进程中间不打印时打印机也在那干等着其他进程只能排队。动态分配则在进程运行过程中按需提出请求用到才申请用完即释放。这是现代通用操作系统的主流方式因为设备是稀缺资源动态分配能显著提高利用率和系统吞吐量。代价就是必须处理安全性问题要防止多个进程各占一部分资源形成死锁。操作系统课程里讲银行家算法很多时候就是套在设备分配这个场景下的。这里要记住设备分配的顺序口诀先设备再控制器最后通道。这个顺序不是随便拍的因为一个设备对应一个控制器典型结构一个控制器又可能对应多个设备按设备到通道的方向分配资源范围是从大到小收敛的更容易判断“当前剩余资源是否够用”。反过来从通道往设备分配很容易出现通道拿到了、控制器和设备却不匹配的浪费。2. 设备分配的数据结构、队列与调度策略2.1 几张核心表的分工与关系教材用了几张表管理设备资源我第一次看时被缩写绕晕了SDT、DCT、COCT、CHCT。理顺之后其实就一套逻辑资源分几层每一层一张表。表名全称记录内容作用SDT系统设备表每个已接入系统的物理设备包含设备名、类型、DCT指针等系统全局入口按设备类型检索候选设备DCT设备控制表单个设备的状态、等待队列指针、设备忙闲、重复执行次数等记录设备自身分配情况COCT控制器控制表控制器状态、通道指针、等待队列记录控制器分配情况CHCT通道控制表通道状态、等待队列记录通道分配情况值得注意的是DCT、COCT、CHCT在结构上长得几乎一样都是“状态 等待队列指针 指向下一层资源的指针”。用C语言表示大概是struct device_ctrl { int dev_id; int status; // 0空闲 1忙碌 struct wait_queue *wait; struct controller_ctrl *ctrl; int repeat_count; // 出错重试次数 };设备分配真正做的操作就是从SDT找到合适的DCT判断DCT状态并尝试分配然后顺藤摸瓜去操作COCT和CHCT。等待队列本质上是把“暂时分不到资源”的进程排队等对应资源释放时由释放方或者IO完成中断去唤醒。2.2 设备分配算法不只是FCFS当多个进程同时都要打印机到底先给谁两种经典策略先来先服务FCFS谁先发出请求谁先分配公平且实现简单。问题是一个大打印任务占住打印机时后来小任务也只能等。优先级高者优先让关键进程优先拿到设备。问题也很明显低优先级进程可能长期饥饿。所以实际系统往往采用“基于优先级的FCFS”——同一优先级内按到达顺序排队高优先级只是排到更前并不是无限插队。设备分配算法和I/O调度算法还不太一样。“设备分配”关心的是谁获得使用资格“I/O调度”关心的是获得设备后多个请求在设备内部怎么安排执行顺序。磁盘这类共享设备设备层的共享请求队列用的是电梯算法、最短寻道优先等核心是减少寻道时间打印机这类独占设备则更多考虑队列公平性。学习时最好把这两层分开否则容易混。2.3 设备分配流程成功、失败、等待与释放把前面的流程写成伪代码便于直接对照实验function allocate_device(process, device_type): dev find_free_device_by_type(device_type) // 从SDT找DCT if dev is NULL: enqueue(device_wait_queue, process) return WAIT dev.status BUSY dev.owner process ctrl dev.controller if ctrl is BUSY: dev.status IDLE // 关键释放已占设备 dev.owner NULL enqueue(controller_wait_queue, process) return WAIT ctrl.status BUSY ctrl.owner process ch ctrl.channel if ch is BUSY: ctrl.status IDLE // 同理逆向释放 dev.status IDLE dev.owner NULL enqueue(channel_wait_queue, process) return WAIT ch.status BUSY ch.owner process return OK注意代码里那两次“逆向释放”。刚开始学的时候我对这个处理特别不理解既然控制器忙挂到控制器队列不就行了为什么要先把打印机释放后来想通了这是破坏“持有并等待”条件。如果进程占了打印机再去等控制器另一个进程占了控制器再去等打印机两个进程就互相卡死了。宁可当前请求先让出设备让整体系统处于更安全的状态。3. 从教材设备分配到Linux内核的对应关系教材里的“设备表”“设备队列”听起来抽象其实Linux内核里有非常直观的对应物。搞懂这层映射再回头看“设备分配”就像看一张地图不再只是文字。3.1 设备号、驱动绑定与请求队列用户态程序执行open(/dev/sda)时VFS会根据设备文件里的主设备号、次设备号定位到对应的块设备。主设备号用来找驱动次设备号用来区分同一驱动管理下的不同设备。/proc/devices可以查看当前系统注册的主设备号列表这个文件就是一张“系统设备表的精简版”。驱动侧则沿用设备模型。设备有struct device驱动有struct device_driver总线和核心层负责匹配。当设备与驱动匹配成功就会调用probe完成绑定。这个过程就是教材中“设备分配”在内核里的雏形设备被分配到某个驱动管理之下而不是某个进程。块设备真正处理请求时核心对象是request_queue。每个块设备都有一个请求队列IO调度器把来自多个进程的IO请求排序、合并再交给设备驱动。这套机制相当于把教材里“设备的共享访问”落到了实处磁盘是共享设备不能分配独占给某个进程所有进程的请求统一进入队列由调度器按策略选下一个。我用一张表总结这种映射面试复习时特别好用教材概念Linux内核对应物系统设备表SDT设备模型/总线上的设备列表/proc/devices设备控制表DCTstruct device、块设备的gendisk设备等待队列请求队列request_queue、等待队列机制设备分配策略IO调度器deadline、mq-deadline、bfq等设备/控制器/通道结构设备硬件拓扑总线-控制器-磁盘/网卡等3.2 用一段Python模拟分配流程光看内核太深我建议初学者先用脚本模拟设备分配。下面这段是我当时照着教材伪代码改的逻辑很接近真实内核的“失败逆序释放”。class Device: def __init__(self, name, controller): self.name name self.owner None self.controller controller class Controller: def __init__(self, name, channel): self.name name self.owner None self.channel channel class Channel: def __init__(self, name): self.name name self.owner None def allocate(device, process): if device.owner is not None: print(f{process} 等待设备 {device.name}) return False device.owner process ctrl device.controller if ctrl.owner is not None: print(f{process} 等待控制器 {ctrl.name}先释放设备 {device.name}) device.owner None return False ctrl.owner process ch ctrl.channel if ch.owner is not None: print(f{process} 等待通道 {ch.name}释放设备和控制器) ctrl.owner None device.owner None return False ch.owner process print(f{process} 成功分配 {device.name} - {ctrl.name} - {ch.name}) return True运行后会看到凡是没走到“成功分配”的路径都严格保证“已经分配到的资源全部释放”。这个习惯放进真实项目里也一样重要资源申请不是要全部拿到才叫成功半路失败时要把临时占用的资源清理干净。3.3 查看设备分配状态的两个高频命令实际排查设备问题时我经常先用dmesg | tail看内核有没有报资源申请失败再用lsblk、lspci -v看硬件拓扑和驱动绑定情况。如果要确认一个设备文件是否正被进程占用可以用fuser -v /dev/sda或lsof /dev/sda。这些命令给出的设备层次信息正是“分配链路”在真实系统里的投影。比如lspci -tv能清楚显示通道、控制器、挂载设备的树形关系和教材上的三级结构几乎一模一样。4. 设备分配场景里的死锁、安全检测与饥饿4.1 两个进程互相卡死的经典场景设备分配一旦管理不好就会触发操作系统里最经典的死锁场景。假设系统里有一台打印机P和一台绘图仪D进程A占用了打印机P还想要绘图仪D进程B占用了绘图仪D还想要打印机P。此时双方都在持有一种资源等待对方释放另一种资源谁也不会先释放。这个场景虽然简单但非常容易发生在真实的打印、绘图或数据采集系统中。所以设备分配算法必须能识别这种风险不能无条件满足所有请求。可以从四个死锁必要条件逐一思考互斥、持有并等待、不可抢占、循环等待。设备分配默认满足互斥和不可抢占。要防止死锁最容易下手的就是破坏“持有并等待”和“循环等待”。“一次申请所有设备”静态分配就是破坏持有并等待“所有进程按设备编号递增顺序申请资源”就是破坏循环等待而“银行家算法”则是一种动态的安全判断方法允许请求但不允许系统进入不安全状态。4.2 银行家算法在设备分配中的三步判断银行家算法的核心是每次分配前先“试探性”分配然后判断系统是否仍然处于安全状态。如果找不到一个让所有进程都能完成的执行顺序就拒绝这次分配让请求进程等待。用设备分配来举例会更直观。假设系统还剩余1台打印机、0台绘图仪进程状态如下进程已分配(打印机,绘图仪)最大需求(打印机,绘图仪)A(1,0)(1,1)B(0,1)(1,1)这个时候A还需要绘图仪B还需要打印机系统剩余资源却只有1台打印机。如果擅自把剩余打印机分给BA永远拿不到绘图仪B拿到打印机后还需要绘图仪双方卡死。银行家算法会判断出系统处于不安全状态于是拒绝把最后的打印机分配给B。实际分配中写判断逻辑时遵循这个顺序判断请求是否超过进程的最大需求若超过直接报错判断请求是否小于等于系统当前剩余资源若不足让进程等待执行“试探分配”修改Available、Allocation、Need三个矩阵运行安全性检测算法初始时Work等于当前Available不断寻找“Need小于等于Work”且尚未完成的进程假定其运行完毕并释放资源直到找不到可完成进程为止若所有进程都能完成本次分配安全否则回滚试探结果拒绝分配。安全性检测本质上是反复执行“找到一个可以完成的进程”。对应到设备管理上就是看剩余资源是否够某个等待进程完成全部设备需求够就让它跑完并释放资源释放后再盘活下一个进程。这个思想写成代码并不复杂难在每次分配前都要认真执行不能偷懒只看到“剩余资源能满足当前请求”就放行。4.3 等待队列里的饥饿问题设备分配还有一个容易被忽略的问题饥饿。如果系统总是优先满足高优先级任务打印机队列里低优先级的后台任务可能一直排不上号。这种问题比死锁更隐蔽因为系统没有卡死只是某些进程永远得不到设备。缓解饥饿的一个常见策略是“优先级老化”进程等待时间越长优先级逐步提高直到被服务。另一种做法是在设备队列里采用FCFS让所有进程按提交请求的先后顺序排队。真实打印服务通常把作业按提交时间排好管理员还能查看队列状态、取消或提前作业。做设备分配实验时我也建议在模拟队列里加一个“等待时间字段”每经过一个时间片就累加当等待时间超过阈值时提升该请求的优先等级。这样既能照顾高优先级突发请求又不至于饿死长尾任务。5. 常见问题与排查技巧实录5.1 打开设备报“设备忙”或EBUSY在Linux下如果open(/dev/ttyUSB0)返回EBUSY多半是设备已经被另一个进程占用或者驱动正处于独占模式。排查顺序先看进程是否占用了文件lsof /dev/ttyUSB0或fuser -v /dev/ttyUSB0再看驱动的分配状态有些串口设备驱动默认不允许多进程同时打开这相当于教材里的“独占设备动态分配失败”。找到占用进程后正常释放如果是驱动没释放干净可以尝试重新插拔设备或重启相关服务。5.2 打印队列卡住但设备看起来空闲这属于设备分配中“已分配状态不回收”的典型表现。进程可能因为崩溃或信号中断没有走正常的释放流程导致打印机在系统记录中仍处于忙碌状态。实际处理时先清掉打印服务残留的作业查看cups或lpstat -p -d的队列状态如果确认没有进程真正使用打印机可以用管理员权限重置打印服务。这个问题也提醒一个设计原则设备分配的释放操作最好放在finally或内核的异常处理路径上不要依赖进程正常退出才能释放。5.3 SPOOLing实验里出现数据错乱用假脱机技术把打印机改造成虚拟设备时最容易出的问题是多个进程同时往输出井写数据没有做互斥结果打印内容交叉混在一起。SPOOLing的“虚拟设备”本质是把独占设备变成一个带缓冲的共享服务输入井、输出井用来临时存放数据调度程序负责把输出井中的数据逐个发给打印机。设计时一定要给“写输出井”这个操作加锁否则进程A的数据和进程B的数据会交错在一起看起来像设备分配成功了实际上数据完全不可用。我在模拟SPOOLing时一开始只关注“能否让多个进程同时提交打印任务”忽略了伪设备内部队列的互斥打印出来全是乱码。后来在输出井写入函数入口加了一个信号量问题立刻解决。5.4 设备分配顺序写反导致实验死锁自己写模拟器时最容易犯的错误是“先申请通道再申请控制器和设备”。这个顺序一旦反过来就会在代码里埋下循环等待的隐患。比如进程A先占了通道1再去等打印机进程B先占了打印机再去等通道1。A持有通道等设备B持有设备等通道循环等待形成而模拟器又没有银行家算法兜底整个程序就卡住了。解决办法很简单严格按设备、控制器、通道的顺序申请任何一步失败就逆向释放。这也再次印证了教材上分配顺序本质上是“死锁预防”手段不只是流程规定。5.5 排查设备分配问题的“五步走”按照我个人的排查习惯遇到设备分配相关异常通常按这个顺序看系统日志dmesg、journalctl -k先定位有没有硬件报错、中断冲突、资源申请失败看设备树lspci -tv、lsusb -t确认设备、控制器、通道的拓扑关系是否正常看设备占用lsof、fuser、/proc/devices判断设备是否被错误占用看服务状态打印、存储、串口等服务是否有残留任务或僵尸进程看分配算法如果自己写的模拟器有问题回头检查代码里的释放路径和队列唤醒条件。这套流程不需要背多踩几次坑就自然记住了。尤其是“先看dmesg”这个习惯能帮你过滤掉一半的“假故障”。6. 最后再分享一点个人体会设备分配这一节我前后啃了三遍才真正理顺。第一遍看教材只记住了“设备、控制器、通道”三个词第二遍画图把分配流程的每个分支都画成状态图才意识到失败路径才最考验设计第三遍写模拟代码用Python模拟了FCFS队列、SPOOLing输出井和银行家算法才真正把“静态/动态分配”“死锁避免”“等待队列”串成一条线。如果让我给正在学OS的朋友一个建议那就是别光背概念拿一台Linux机器跑几个命令再写一段几十行的模拟器比看书有用得多。后面我计划把这个模拟器扩展成支持优先级老化的版本再给它加一个“请求超时”统计模块看看不同分配策略下的平均等待时间对比。等做完了我再单独写一篇笔记出来。