这篇《OS笔记43》要聊的是操作系统设备管理里的设备分配。我在带操作系统课程实验那几年,每次讲到设备分配,都会先拿办公室打印机举个例子:五个人几乎同时按了打印,系统怎么决定谁先用、谁等着、谁的任务标成异常?这个问题比表面看起来复杂得多,因为设备分配不是一条“谁排队谁先上”的规则,而是整条硬件链路的状态仲裁。
设备管理是操作系统五大功能里最贴近硬件的一块,设备分配则是其中用来回答“一个I/O请求到底该交给哪一台物理设备、什么时候能拿到使用权、拿不到要等在哪”的核心机制。搞懂它,你才能真正看懂驱动、中断、DMA、SPOOLing、Linux的sysfs和cgroup这些后续知识。这篇文章适合正在学操作系统的学生、准备考研408的人、以及被设备驱动和设备树折磨的开发人员。
1. 设备管理里的设备分配,到底在解决什么问题
1.1 从一台打印机的日常冲突说起
想象一下办公室只有一台打印机,五个人同时点了“打印”。最直观的做法是排一个先来后到的队列,谁先按谁先出。但真实的操作系统要考虑的事情比这个多:打印机正忙着打印一个超大PDF时,新的打印任务应该挂到打印机的等待队列;如果打印机不是直接连在主机上,而是经过一个打印控制器,那控制器可能在忙别的任务,这又涉及第二层排队;再往下,如果数据要走DMA通道传输,通道繁忙时还要继续排队。
所以“设备分配”这个短语里的“设备”,在操作系统教材里往往不只是指物理设备本身,而是指一条“设备-控制器-通道”的使用链路。一个I/O请求要真正执行成功,三个环节都得拿到手。这就是为什么单独谈设备分配很有必要:CPU调度只需要决定一个CPU核心给谁用,设备分配却要同时协调多种硬件资源的占用关系。
1.2 设备-控制器-通道:分配不是只给设备
计算机的I/O系统通常是三层结构:最外层是设备本身,比如磁盘、打印机、网卡;中间是设备控制器,它负责把CPU的抽象指令翻译成设备能理解的具体动作;最内层是通道,相当于一个专门负责I/O的小型处理器,可以独立完成数据传输而不需要CPU全程盯着。
拿NVMe固态硬盘举例,你打开一个文件,数据从磁盘到内存,要经过NVMe控制器,最终通过PCIe DMA通道搬运。操作系统分配设备时,如果只把硬盘分配给你,却没确认控制器当前是否空闲、DMA通道是否被别的设备占用,数据依然过不去。所以设备分配本质上是在一条硬件链路上“三级占坑”,任一级别被占用,整个请求就得等待。
这跟我们出差租车是同一个道理:租到车只是第一步,司机和高速通行资格也得同步确认,任何一环掉了,行程都卡住。
1.3 设备按属性分三类,分配方式天差地别
操作系统的教材里,设备通常按使用方式分成三大类,我在实验课上让学生记住一句话:先看能不能多个进程同时用,再看能不能伪装成别的设备。
第一类叫独占设备,典型代表是打印机、磁带机。这类设备一次只能给一个进程使用,你正打印着,别人插进来就会导致内容交错。独占设备的分配最简单,空闲就给,忙就排队,但最大的问题是利用率低,全局就一台打印机,一个人占着,其他人全等着。
第二类叫共享设备,典型代表是磁盘。多个进程可以同时交替使用同一个磁盘,你读文件的同一秒,别人可以写另一个扇区。对共享设备来说,分配的关键不再是“独占锁”,而是把多个I/O请求合理地合并、排队,提高整体吞吐量。
第三类叫虚拟设备,这是最巧妙的一类。虚拟设备是利用SPOOLing技术,把原本必须独占的物理设备,改造成看起来像共享设备的东西。后面的第4节我会专门拆这个,因为它真正改变了“分配”的对象。
2. 设备分配背后的“档案系统”:四张核心数据结构表
2.1 SDT系统设备表:全局设备花名册
操作系统要管理设备,第一步得知道系统里到底有哪些设备。系统设备表(System Device Table,简称SDT)就是一张全局的花名册,整个系统只有一张,每个表项对应一台物理设备。
表项里记录的内容包括设备名称、设备类型,以及一个指向该设备控制表(DCT)的指针。不要小看这个指针,它把“从逻辑到物理”的桥梁搭起来了。后面讲设备独立性时会提到,用户程序通常只按逻辑设备名请求I/O,系统拿到逻辑名之后,就是靠SDT找到对应的物理设备,再顺着指针去查这台设备的详细状态。
在实际实验课上,我经常让学生写一段简单的设备管理模拟程序,最容易被忽略的就是SDT。很多人一上来就给每个设备建数据结构,却不建全局索引。结果程序里只有“设备状态下标”,一旦涉及逻辑名和物理名的转换,代码就乱成一团。正确的做法是先把SDT写好,它是所有设备查询的入口。
2.2 DCT设备控制表:每台设备的“状态卡”
每一台物理设备都对应一张设备控制表(Device Control Table,简称DCT),它像是这台设备的专属状态卡。DCT里至少包含这几项:
- 设备类型和设备标识,用来区分“这是打印机还是磁盘,是哪一台打印机”
- 设备状态,通常标记为空闲、忙碌或故障
- 设备等待队列指针,哪些进程排着队等这台设备,就挂在这里
- 与设备相连的控制器表指针,用来找到下一层资源
- 重复执行次数,这是很多初学者忽略的一个字段
重复执行次数特别值得展开说。I/O操作偶尔会出现瞬时的错误,比如磁盘上有坏道导致读取失败,但重试一次就成功了。DCT里记录重复执行次数,就是让操作系统在遇到这种瞬时故障时,不是立刻把失败信息丢给进程,而是自动重试若干次,达到次数上限才判定真失败。这个机制在日常使用中很常见:你拷文件偶尔遇到“读取错误”,拔了再插又好了,背后就有这类重试逻辑在做支撑。
DCT的另一个关键点是等待队列指针。设备忙时,请求进程不是简单地“阻塞一下”,而是具体挂到DCT等待队列里。操作系统唤醒进程时,也要精确地从这个队列中取出最前面的进程,而不是盲扫全部进程。
2.3 COCT与CHCT:把分配链路串完整
设备控制表只能说明设备本身的状态,但设备要执行I/O,还得经过控制器和通道。所以操作系统又设计了控制器控制表(Controller Control Table,简称COCT)和通道控制表(Channel Control Table,简称CHCT)。
COCT的结构和DCT很像,记录控制器标识、控制器状态(忙/闲)、控制器等待队列指针,以及指向上一级设备DCT和下一级通道CHCT的指针。CHCT则记录通道标识、通道状态、通道等待队列指针,以及指向控制器的指针。
这几张表通过指针互相串联,形成了一条完整的查找链:进程按逻辑设备名查SDT,SDT指向DCT,DCT指向COCT,COCT指向CHCT。任何一个环节出现等待队列,进程就要在该环节排队。这其实是一个非常直观的设计,每一步都带着“我还能连到谁”的信息前进,不需要额外维护大而全的全局资源图。
2.4 多通路结构下,表的指针成了关键
有些教科书会把设备图画成“设备-控制器-通道”的多对多关系,叫多通路结构。比如一台磁盘同时连着两个控制器,每个控制器又分别连着两条通道。这时候DCT里就不能只放一个COCT指针,而应该能索引到多个控制器;COCT同样可能索引到多个通道。
多通路的好处是提高了可靠性:某条路径上的控制器或通道忙,系统可以换另一条路径继续分配。但代价是分配算法变复杂了,因为你要在多条路径里选一条;而且一旦中途开路,还要把已经分配到的资源释放掉再换路。这一块在408考研里经常出成“画设备分配流程图”的题,后面第3节我详细说流程。
3. 分配策略、分配算法与完整执行流程
3.1 静态分配与动态分配怎么选
设备分配的时机有两种主流策略。
静态分配是在进程创建时,一次性把进程运行期间需要的所有设备都分配好,直到进程运行结束才释放。这种策略的优点是实现简单,而且因为进程从开始到结束都持有设备,不存在中途多个进程争抢资源的问题,所以可以彻底避免死锁。但缺点也极其明显:资源利用率太低。一个进程可能只在最后几秒才用打印机,却从创建那一刻就把打印机占住了,其他进程哪怕再急也用不上。
动态分配是进程在运行过程中,按需申请设备,用完后立刻释放。这是现代操作系统的普遍做法。动态分配的资源利用率高,但多资源同时申请时可能产生死锁,后面第5节会专门谈。两种策略的取舍本质上是一个“安全 vs 效率”的权衡,绝大多数真实系统宁可承担风险也要选动态分配,然后用银行家算法等工具去控制风险。
3.2 先来先服务与高优先级优先
设备等待队列应该按什么顺序排?教材里给出了两个最经典的算法。
先来先服务(FCFS)按请求到达设备的先后顺序排队,先来的进程先分配设备。它简单公平,不会饿死任何进程,但问题是不够灵活,一个紧急任务可能被前面一堆打印任务拖很久。
高优先级优先则让设备等待队列按照进程优先级排列,高优先级进程可以插到队列前面。这个算法能保证关键任务优先完成,但要小心优先级倒置和饥饿问题:如果系统里一直有高优先级进程插队,低优先级进程可能永远等不到设备。常规解决办法是老化机制,也就是进程等待时间超过某个阈值后,动态提升它的优先级,保证最终还是能被服务到。
3.3 单通路到多通路:逐级分配与回退
先理清两个概念。单通路结构里,一个设备只连接一个控制器,一个控制器只连接一个通道。分配没什么可选的,一路查到底,哪个环节忙就在哪个环节排队。多通路结构则复杂一些,设备连了多个控制器,控制器连了多个通道,系统可以挑选一条当前全空闲的通路来使用。
动态分配的完整过程,我建议按下面这个顺序理解:
- 进程发出I/O请求,往往只带一个逻辑设备名,比如“打印机1”。
- 系统查SDT,找到目标物理设备对应的DCT。
- 检查DCT中的设备状态。设备忙,把进程挂到DCT等待队列;设备空闲,先把设备分配出去,把DCT状态改成忙。
- 顺着DCT找到控制器,查COCT。控制器忙就排到COCT等待队列,空闲则分配控制器。
- 再顺着COCT找到通道,查CHCT。通道忙就排到CHCT等待队列,空闲则分配通道。
- 三级资源全部到手,系统启动I/O设备执行传输。
- I/O完成后,进程退出时依次释放通道、控制器、设备。
- 每释放一个资源,都要检查对应等待队列里有没有被阻塞的进程,有就唤醒队列头部的进程去尝试分配。
这里有个细节很多人会踩坑:如果设备忙时,进程挂到了DCT等待队列,那这个进程是只在DCT这一层等,还是要继续往下检查控制器和通道?答案是只在DCT这一层等就行。因为设备是独占资源,设备都拿不到,控制器和通道考虑得再多也没有意义。同理,如果设备空闲、控制器忙,进程就只需要挂在控制器等待队列,不需要再去占设备,因为设备一旦分配给一个进程,其他进程就算能使用这个控制器,也无法绕过已占用的设备来传输数据。
3.4 逻辑回退:别把已分配的资源白白锁住
在多通路分配时,有一种容易忽略的细节:设备、控制器都分配成功了,通道却在忙,而且没有备用通道。此时进程当然可以继续等待,但前面已经占着的设备和控制器怎么办?
正确做法是把已经分配到的设备和控制器先释放掉,让它们可以被其他进程继续使用。因为当前进程既然拿不到最关键的通道,整个I/O就进行不下去,占着控制器和设备只会白白浪费资源,甚至可能诱发连锁等待和死锁。释放之后,当前进程再整体重新排队,等待下一次完整分配。
这部分是最像做项目的一环。很多模拟实验代码里,逐级分配只写了“成功”的路径,漏掉了“部分成功但后续失败要回退”的分支。结果测试一跑,两个进程互相占着资源和等待对方的资源,程序就死锁了。所以写分配逻辑时,条件分支一定不能省。
4. 设备独立性与SPOOLing:如何把独占设备“虚拟化”
4.1 逻辑设备名与物理设备名的映射
用户程序一般不应该指定“用3号打印机”,而是说“打印一份文档”。操作系统通过一张逻辑设备表把用户的逻辑名映射为物理设备名,再通过SDT找到物理设备。这个中间层的存在就是设备独立性,也叫设备无关性。
设备独立性的价值在于:应用程序不需要关心底层硬件细节,打印机坏了就把任务重定向到另一台同型号打印机,用户程序完全无感。换一台新设备时,甚至可以实现二进制兼容,只要驱动对外接口一致。这一点在真实世界里极其重要,你在Windows里打印文档不会去管具体端口是LPT还是USB,系统内部早就把逻辑名到物理设备的绑定关系处理好了。
4.2 SPOOLING技术拆解:假脱机到底“假”在哪
SPOOLING是Simultaneous Peripheral Operations On-Line的缩写,翻译成假脱机。假脱机技术把独占设备改造成“看起来可以共享”的虚拟设备,核心思路是:不让进程直接接触物理设备,而是先跟磁盘上的缓冲区打交道。
以打印为例,SPOOLING系统包含三个关键组成部分:磁盘上的输入井和输出井、内存中的输入缓冲区和输出缓冲区、以及一组SPOOLing进程。当用户进程要打印时,输出进程先把打印任务写进磁盘的输出井,并排进打印队列。用户进程的任务到这里就算“完成”了,不用管打印机此刻忙不忙。
真正的打印机由另一个输出进程控制,它负责在打印机空闲时,从输出井队列里取出任务一一打印。这样一来,用户进程看到的是一台“随时都能用”的虚拟打印机,而物理打印机仍然是独占用途,只是独占权被收归到了系统内部的一个服务进程。
我上课时打过一个比方:SPOOLing就像把公司前台的“共用签字笔”换成了“一个笔筒+一个管理员”,你需要用笔时直接跟管理员说,管理员等上一支笔还回来再给你。你不需要一直攥着笔,别人也不用干等。
4.3 虚拟设备时代的“分配”对象变了
SPOOLing技术带来的变化是,进程申请的不再是物理打印机,而是输出井里的一个打印任务槽,或者说是井区中一段可用空间。分配动作发生在内存和磁盘的软件队列里,比直接操作硬件快得多,也不会因为打印机忙就阻塞进程。
这就解释了为什么现代操作系统里,几十个用户可以同时提交打印任务,而对每个用户来说,打印机看起来“永远在线”。设备分配的核心矛盾已经从“谁独占打印机”变成了“如何管理输出井空间、如何调度打印队列顺序”。反过来看,这种思想也延续到了很多软件系统里:消息队列、任务队列、线程池,本质都是把共享的消费资源包装成一个用户无感知的缓冲层。
5. 常见问题、死锁避免与实验避坑
5.1 设备分配死锁:条件和银行家算法
设备分配最臭名昭著的问题是死锁。经典场景是:进程P1占着打印机,同时申请磁盘;进程P2占着磁盘,同时申请打印机。两者都在等对方释放资源,谁也别想继续。
为什么静态分配能防死锁?因为资源在使用前一次性分配完毕,进程不可能在持有资源的同时还要申请新资源,也就破坏了死锁的“持有并等待”条件。但静态分配太浪费资源,动态分配又无法天然避免死锁。教材里的解决方案之一就是银行家算法,它在分配前做安全性检查,只有能找到一个安全执行序列时才允许分配。
银行家算法的核心并不神秘,就是模拟“试探分配+回滚验证”。先假装把资源分给当前进程,然后检查是否还剩一个进程,能在剩余资源下完成任务并归还资源;如果能,继续模拟下一个,直到所有进程都能完成。如果找不到这样的顺序,说明分配后系统进入不安全状态,那这次分配就不能执行。
下面给一段我在实验课里常用的Python示意代码,用来演示安全性检查的过程,不是完整系统代码,但核心逻辑都在:
def is_safe(available, allocation, need, processes): work = available[:] finish = [False] * len(processes) safe_seq = [] while len(safe_seq) < len(processes): found = False for i in range(len(processes)): if not finish[i] and all(need[i][j] <= work[j] for j in range(len(work))): # 模拟该进程执行完,归还资源 work = [work[j] + allocation[i][j] for j in range(len(work))] finish[i] = True safe_seq.append(processes[i]) found = True break if not found: # 一轮扫描后没有任何进程可执行,说明不安全 return False, [] return True, safe_seq看起来很简单,但放到设备分配场景里特别实用。每次一个进程请求设备资源时,先拿这个函数判断一下,返回“安全”才真正分配。代价是要遍历一遍进程表,但在设备分配这种低频操作上,开销完全可以接受。
5.2 分配失败的常见原因速查表
我整理了一份日常排查设备分配问题的速查表,实验和工作中都能参考:
| 现象 | 可能原因 | 处理思路 |
|---|---|---|
| 设备显示空闲但分配失败 | 逻辑设备名映射错误,DCT指针为空 | 检查SDT与逻辑设备映射表 |
| 设备一直忙,长时间得不到 | 占用进程没及时释放,或有进程阻塞在后续环节 | 检查DCT等待队列,确认释放逻辑有唤醒操作 |
| 控制器忙,设备分配不出去 | 多设备共用同一控制器,控制器成瓶颈 | 考虑换用多通路结构或调整分配顺序 |
| 通道总是忙 | 大量DMA传输竞争通道 | 检查中断处理是否过长,考虑合并I/O请求 |
| 分配后进程仍无法读写 | 设备实际故障但DCT状态未更新 | 完善DCT状态字段,故障时及时置为故障态 |
这里面最隐蔽的就是第一行:逻辑设备名映射错误。你真去找打印机时会发现,明明SDT里列着三台打印机,进程却总是分配到同一台。原因往往是映射表里三个逻辑名指向了同一个物理DCT。这种问题靠读设备树或打印日志比较容易定位。
5.3 模拟实验里最常见的四个坑
我做实验指导时,批过太多份设备分配模拟程序,踩坑点高度集中,列出来可以省不少时间:
第一,释放资源时忘了唤醒等待队列。很多代码里,释放函数只做“资源状态改成空闲”,却没有去取等待队列头部的进程来唤醒,结果后续进程阻塞到天荒地老。
第二,多通路分配时没有“尝试其他路径”的代码。设备明明连着两条通路,程序只会查第一条,通道忙就直接返回失败,白白牺牲了多通路的可靠性。
第三,分配成功的顺序不对。正确顺序应该是设备、控制器、通道逐级申请,但有些同学倒过来先查通道,再查控制器和设备,逻辑上其实也说得通,但代码会复杂很多。建议还是按照标准流程写,方便对照教材验证。
第四,测试用例太单一。只测独占设备、不测共享设备,或者只测单通路、不测多通路。设备分配里最容易出问题的恰恰是资源回退和多路径切换,这些分支必须设计专门的测试场景去触发。
6. 现代操作系统里的设备管理:从DCT到Linux设备模型
6.1 Linux设备模型与sysfs/udev
教科书里的四张表是很好用的抽象,但现代Linux并没有真的拿一张SDT全局表去管理所有设备。Linux把设备管理做成了面向对象模型:每个设备是一个struct device对象,设备挂在bus_type总线上,由device_driver驱动对象来匹配管理。
你打开/sys/bus/pci/devices/目录,能看到系统里每个PCI设备都有一个子目录,里面存放着vendor、device、irq等属性。这就是设备模型把设备暴露给用户态的方式。当设备热插拔时,内核会产生uevent事件,udev守护进程监听事件,动态创建/dev下的设备节点并设置权限。整个机制依然是“根据设备状态决定谁能访问”,只是那张“设备控制表”变成了内核对象模型加sysfs文件系统。
6.2 用户态如何影响实际的设备分配
设备分配不只是内核内部的事情,用户也可以通过各种参数参与。
cgroup的blkio子系统可以限制块设备读写速率;ionice命令可以设置进程的I/O调度优先级;磁盘调度器决定了多个I/O请求到底先处理谁。你查看/sys/block/sda/queue/scheduler,能看到当前磁盘用的是mq-deadline还是kyber,这就是设备分配算法在真实世界中的一个直接体现。
再比如,你跑数据库时觉得磁盘I/O不均衡,可以用iotop看进程的I/O占用,用iostat看设备队列长度。这些工具本质上都在帮你观察“谁在设备队列里排队、排多久、谁一直插队”。理解了设备分配,看这些输出时思路会清晰很多。
6.3 下一步延伸:中断、DMA与io_uring
把设备分配弄清楚之后,我建议顺着三条线继续深入。
第一条是中断与DMA。设备完成I/O后怎么通知CPU,数据要不要经过CPU搬运,这决定了设备分配结束之后的“收尾动作”。
第二条是驱动模型。Linux里一个驱动如何注册到总线,如何匹配设备,probe函数何时被调用,这些跟教材里的“分配设备后启动I/O”有直接呼应。
第三条是io_uring。它把传统的“提交请求-等待完成”变成了“提交队列-完成队列”的异步模型,本质上是在更高层面改良了设备请求的分配和调度方式。理解设备分配后再看io_uring的SQ和CQ,会觉得主线特别顺。
想从实验角度加深理解,可以去看rcore、ucore这类教学操作系统里的驱动和设备管理代码,或者自己在Linux内核模块里写一个简单的字符设备驱动,亲手走一遍“设备注册-驱动匹配-用户打开-执行I/O-释放”的完整链路。比单纯背数据结构表管用得多。
这篇笔记写到这里,我最后分享一个个人经验:排查设备相关问题时,不要一上来就怀疑硬件,先按“设备状态→控制器状态→通道状态”的顺序,把每一级的占用情况理清楚。我遇到过一台机器打印特别慢,折腾半天驱动,最后发现是输出井里堆了几百个过期任务,打印队列的等待时间全耗在处理垃圾任务上了。设备分配的道理放在真实环境里就是这样,看着是硬件问题,其实是排队和调度的问题。