操作系统内功 · 第 55 篇|设备模型与 udev:从总线、驱动到 /dev 节点的生成

为什么需要统一的设备模型

第 24 篇讲 VFS 时,我们把视线停在「文件系统之上的统一接口」;这一篇往下走一层:设备本身如何被内核组织、/dev 下的节点由谁在什么时机创建出来。这两个问题合起来,就是 Linux 设备模型与用户态守护进程 udev 的完整故事线。

一块普通服务器主板,同时挂着 NVMe 盘、万兆网卡、USB 控制器、I2C 温度传感器、GPU——设备种类成千上万,型号迭代以月为单位。如果没有统一模型,每个驱动都要各自发明设备描述、电源管理与热插拔协议,用户态也要为每类设备写一套发现逻辑。设备模型要回答三件事:设备长什么样(统一描述)、设备之间怎么连(拓扑树)、设备出事谁负责(驱动绑定与事件分发)。

热插拔是当年催生这套模型的最直接动力。U 盘插入时,内核必须「主动」通知用户态创建节点;笔记本合盖时,电源管理要按正确顺序让所有设备挂起——先挂起叶子设备,再挂起总线控制器,顺序错了轻则唤醒失败、重则数据损坏。这些跨驱动的共性需求,只能放进统一的框架里做,散在各驱动里各写各的是没有出路的。

sysfs 的诞生也源于同一个动机:把内核的对象树暴露成文件系统。运维想知道「这台机器有几块盘、插在哪个 PCIe 槽位」,ps 和 top 都答不了,sysfs 用目录结构直接回答拓扑。ls /sys/bus/pci/devices/ 下列出的 0000:00:1f.2 这样的名字,正是「域:总线:设备.功能」的 PCI 拓扑坐标,一行命令就能对上机房里的物理槽位。

kobject:设备模型的原子积木

kobject 是设备模型的最小单元,本身没有任何业务语义,而是嵌在 struct device、struct device_driver、struct bus_type 这些大结构里的「对象头」。它提供四件公共设施:引用计数(决定对象何时释放)、名字(sysfs 目录名)、父子指针(拓扑关系)、以及与 sysfs 目录的映射。每个 kobject 对应 sysfs 里的一个目录,目录下的属性文件由它的 ktype 描述——这就是「内核对象即文件」的实现根基。kset 则是同类 kobject 的集合,承担 sysfs 顶层聚合与事件分组发送的角色。

要注意 /sys 与 /dev 的分工完全不同:/sys 是内核视角的「说明书」,树状、以只读为主,展示设备挂在哪、用哪个驱动、有哪些可调参数;/dev 是给应用程序 open 的设备文件,只有节点名和主次设备号。前者回答「是什么、在哪」,后者回答「怎么用」。两者的生成机制也不同:sysfs 目录由设备模型自动维护,/dev 节点则由 devtmpfs 与 udev 合作生成——这正是本文的主线。

举几个日常例子把这套抽象落地:读 /sys/class/net/eth0/address 拿网卡 MAC,改 /sys/block/sda/queue/scheduler 切换 IO 调度器,看 /sys/devices/system/cpu/cpu0/cpufreq/ 检查调频策略。sysfs 的设计原则是「一个文件一个值」,与 procfs 里大段格式化文本的风格形成对照,脚本化处理因此容易得多——grep 一个文件就是 grep 一个值。

总线-设备-驱动三角:match 与 probe

内核用三类对象描述硬件世界:bus_type(总线,如 pci、usb、platform、i2c、spi)、device(设备实例)、device_driver(驱动)。总线是三角的枢纽:它各持一条设备链表和一条驱动链表,核心动作只有一个——match。设备枚举(PCI 总线扫描、USB 插入、设备树解析、ACPI 表读取)时创建 device 挂上总线,然后逐个与驱动比对身份。

匹配规则因总线而异:PCI 靠驱动的 vendor/device ID 表;USB 靠 idVendor、idProduct 或接口类;platform 总线靠设备树的 compatible 字符串。匹配成功后,驱动的 probe 函数被调用:初始化硬件、申请中断、注册子设备;probe 返回 0 即绑定成立,sysfs 里设备目录与驱动目录之间出现符号链接。卸载时走 remove,解绑后的 device 依然留在总线上,可以被另一个驱动重新接管——热插拔场景下「同一插槽先后插两只同型号盘」就是这么被干净处理的。

这套机制还支撑了「按需加载」:内核枚举到一个设备却找不到驱动时,会把设备的 modalias(形如 pci:v00008086d00001002sv00...)塞进 uevent,用户态收到后调 modprobe 按别名查找模块并插入。这就是现代发行版插上任何常见硬件都能直接可用的原因——不是预装了所有驱动,而是有一套「设备身份到模块名」的自动解析链,配套的 coldplug 机制还会在开机时重放全部存量设备事件,把该加载的驱动补齐。

platform 总线值得一提:它是「不在可枚举总线上的设备」的收容所。SoC 内部的 UART、GPIO、看门狗控制器没有 PCI 配置空间可扫,ARM 世界用设备树描述它们的寄存器地址与中断号,内核解析后逐一挂成 platform device,再与同样声明了 compatible 的驱动撮合;x86 上则多由 ACPI 表承担同样的角色。驱动工程师的日常因此被模型定型:写一个驱动,等于定义支持的设备 ID 表、实现 probe 与 remove、可选地补 suspend/resume 回调,剩下的一切——枚举、匹配、生命周期与电源事件的分发——由总线层代劳。

sysfs 的目录组织有三个视图值得一并认识:/sys/devices/ 是真实的拓扑树(按物理连接层级),/sys/bus 顶层按总线类型分目录、其下 devices 是按总线聚集的快捷视图(多为符号链接),/sys/class 则是按功能聚集的视图——/sys/class/net 把所有网卡摆在一起,不管它们挂在 PCI 还是虚拟总线上。三个视图是同一批对象的三个索引,不是三份数据,理解这一点就不会被符号链接绕晕。运维脚本里最常走的是 class 视图,排查拓扑问题时才深入 devices 树。

uevent 与 udevd:/dev 节点的生成之旅

现在看热插拔事件怎么变成 /dev 里的文件。内核侧的起点是 kobject_uevent:设备注册、移除、状态变化时,内核拼一条消息,带上环境变量(ACTION=add、SUBSYSTEM=block、DEVNAME=sdb、MODALIAS 等),通过 netlink 套接字向 UEVENT 组播组广播。用户态的 udevd 订阅这个组播组,成为所有设备事件的统一消费者——这是内核与用户态之间极少数「内核主动推消息」的通道之一,设计上刻意保持简单:内核只报事实(什么设备、什么动作),策略全部留给用户态。

udev 收到事件后按序做三件事:先跑规则匹配(下一节细讲),然后决定节点的名字、权限与符号链接,最后向 systemd 发出对应的 device unit 事件——桌面环境据此弹出「可移动介质」提示,存储服务据此触发挂载。一台机器开机可能产生几百上千个 uevent,udev 用内部队列逐个消化,udevadm monitor --kernel --udev 能实时看到这条流水线:内核事件行在前,udev 处理行在后,一目了然。

事件里携带的环境变量值得单独看一眼:ACTION 说明动作(add、remove、change、offline 等),SUBSYSTEM 是子系统类型,DEVNAME 是内核为设备起的节点名,DEVPATH 指向 sysfs 路径,MODALIAS 用于驱动加载,驱动还可以附带私有变量。规则匹配本质上就是对这些变量的模式匹配,所以排查规则问题时,把事件的原始变量集 dump 出来对照即可,不需要猜。

历史上有三个时代值得记住:静态 /dev 时代,节点由脚本预先生成,插多少设备全靠猜;纯 udev 时代,节点由 udevd 现场创建,udev 挂了新设备就没有名字;devtmpfs 时代(现在),内核在驱动注册设备时自动创建节点,udev 退居二线,只负责改名、加符号链接、设权限。所以今天即使 udevd 出问题,/dev 大体仍可用,只是「名字策略」失效——这个分层是理解 udev 类故障的关键:节点不存在是 devtmpfs 层的问题,节点名字不对是规则层的问题。

udevadm 三板斧

排查设备问题的命令值得背下来三只:udevadm info -a -n /dev/sdb 查询设备及其全部父设备的属性(写规则就靠这里的 ATTRS,一个属性都不用猜);udevadm monitor 看事件流实时滚动,确认内核到底发了什么、udev 处理成了什么;udevadm trigger 重放现有设备的 add 事件,配 udevadm control --reload-rules 热加载规则,改完规则不用插拔硬件就能验证。

udev 规则与可预测接口名

规则文件放 /etc/udev/rules.d/,文件名按前缀数字排序依次应用。匹配键与赋值键两类:ACTION、SUBSYSTEM、KERNEL、ATTR/ATTRS、ENV 用于匹配;NAME、SYMLINK、MODE、OWNER、RUN 用于动作。操作符 == 比较、= 赋值、+= 追加。最容易踩的坑是 ATTR 与 ATTRS 的区别:ATTR 只看当前设备,ATTRS 会沿父设备链向上查找——USB 转串口的序列号挂在 usb_device 层而不是 tty 层,写成 ATTR 永远匹配不上,这是新手规则「不生效」的头号原因。

第一个真实案例:工控机上两个 USB 转串口设备,插拔顺序一变 ttyUSB0 与 ttyUSB1 就对调,采集程序接错传感器。用 udevadm 查到两只芯片各自的序列号后写规则:

ACTION=="add", SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", ATTRS{serial}=="A50285BI", SYMLINK+="sensor-front"

应用程序从此固定打开 /dev/sensor-front,物理插哪个口都无所谓。注意 SYMLINK 可以追加多个别名,而 NAME 改名只能有一个——多数场景用符号链接就够了,保留内核原名还能从名字读出 ttyUSB 序号方便对照日志。

规则的第三类常用动作是权限与执行:MODE、OWNER、GROUP 给节点设属主与权限(把某串口组设成 dialout 组,普通用户就能直开设备);RUN 一类动作键可以在事件处理时执行脚本——比如某块盘到达时触发备份同步,或按厂商属性打上自定义 ENV 标记供后续规则引用。规则是按文件顺序执行的,前一条用 ENV 打的标记后一条能看见,复杂的设备策略可以拆成一组互相衔接的规则链,这也是大型发行版 udev 规则的真实组织形态。

第二个案例是网卡命名。上古时代内核按枚举顺序起名 eth0、eth1,多网卡服务器重启后可能对调——交换机口配置、防火墙规则、监控基线全线错乱,这是运维圈流传多年的经典事故。udev 兴起后的老办法是按 MAC 固定名字:

SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="aa:bb:cc:dd:ee:ff", NAME="wan0"

(MAC 里的十六进制字母必须小写。)而 systemd 时代的主流方案是「可预测网络接口名」:udev 在开机早期读取固件与 PCI 拓扑,把网卡命名为 enp3s0(Ethernet 加 PCI 总线 3 槽位 0)这类拓扑坐标,无线网卡以 wl 起头。名字丑但稳定:它不再依赖驱动加载顺序,换内核版本、调整启动顺序都不会漂移。想退回 eth0 老命名,需要内核参数 net.ifnames=0 或放一个空规则文件屏蔽,多数生产环境如今选择接受新名字,把拓扑写进自动化基线。

容器里的 /dev:裁剪版的由来

进入容器执行 ls /dev,你会发现只有 null、zero、urandom、tty、pts 等少量节点,没有宿主的 sda、没有 nvme、也没有完整的 input 设备。这不是 devtmpfs 的过滤视图,而是运行时手工搭的舞台:容器启动时,/dev 位置被挂上一个全新的 tmpfs,再由运行时逐个创建或绑定挂载少量白名单节点——容器里的 /dev 与宿主 devtmpfs 在数据结构上毫无关系。

裁剪的原因首先是安全。设备文件是内核攻击面的直接入口:容器里拿到块设备节点,配合 root 权限可以读写宿主整块磁盘;拿到某些底层设备节点更是直接摸到硬件。所以默认策略是「一个设备都不给,白名单放行」。其次是访问控制的闭环:Linux 对设备访问的管控由 cgroup 的 devices 控制器(底层实现是设备过滤规则)完成,运行时把 docker run --device /dev/video0 这类参数翻译成两步——把宿主节点绑定挂进容器,同时往 devices 控制器里加一条放行规则,两层一起兜住。

容器里一般不跑 udevd:容器看不到宿主的总线拓扑,也没有热插拔事件可消费;而且 udevd 若真跑起来,它读到的是宿主 sysfs,行为反而危险。这也是为什么容器里的设备必须由运行时静态规划,而不能指望「插上就有」的动态发现。

顺带看一层现代延伸:容器平台的设备插件体系、GPU 容器的设备传递,本质上都是这套「绑定挂载加放行规则」机制的编排化——调度器决定哪块 GPU 给哪个容器,运行时把宿主上的设备节点与设备过滤规则一起下发。理解了本节的机制,容器平台的设备管理文档就不再是魔法清单,而是三件事的排列组合:节点在不在、挂载过不过、过滤放没放。

第 8 篇讲容器底座时我们看过 namespace 与 overlayfs,这一篇补上的正是另一半拼图:设备的来源与裁剪逻辑。

小结

设备模型把「硬件」抽象成内核对象树:kobject 提供对象基建,sysfs 把拓扑暴露成目录,总线在设备与驱动之间做 match,probe 完成绑定。理解这条链之后,lspci、lsusb、udevadm 的输出都不再是黑盒,而是同一棵对象树在不同角度上的投影。

热插拔的本质是事件流:内核 kobject_uevent 经 netlink 广播,udevd 按规则消费,devtmpfs 兜底节点创建,udev 专注名字、权限与符号链接策略。这套分工解释了为什么 U 盘插入即用、为什么 udevd 出问题时设备大体还能用、为什么规则写错只会影响「名字」而不是「存在」。

工程现场记三件事:写规则前先用 udevadm info 查清属性挂在父设备链的哪一层,ATTR 与 ATTRS 一字之差天壤之别;多网卡命名交给可预测接口名,别再手工 eth0 打补丁;容器的 /dev 是运行时搭的舞台,设备给不给、给哪个,要在运行时配置里显式声明。

下一篇回到中断语境:内核为什么要把「中断来了」切成上下两半处理,softirq、tasklet、workqueue 与线程化中断四代机制各自解决什么问题。

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?