Linux 基础体系 · 第 32/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux 设备模型与 udev:设备号、sysfs、规则、热插拔和权限

在 Linux 中,“设备”不是只有一个 /dev/sda/dev/ttyUSB0 文件。一个硬件设备从内核发现、驱动匹配,到 sysfs 表示、设备号分配、/dev 节点创建、权限设置,再到拔出后的清理,经过的是一条由多个组件共同完成的链路。

可以先把相关对象分成四层:

  1. 内核设备模型:描述设备、总线、驱动和设备之间的关系。
  2. 设备号与设备节点:让用户空间通过 /dev 文件访问字符设备或块设备。
  3. sysfs 与 uevent:暴露设备拓扑、属性和状态,并把设备变化通知用户空间。
  4. udev:接收内核事件,根据规则创建设备节点、设置权限、建立稳定名称和触发辅助动作。

udev 不是驱动,也不是文件系统;它通常不负责“发现硬件”,而是处理内核已经发现并发布出来的设备事件。


一、从硬件到 /dev:完整数据流

现代 Linux 系统中,一次 USB 存储设备插入大致经过以下路径:

sequenceDiagram
    participant H as 硬件
    participant K as 内核驱动/设备模型
    participant S as sysfs
    participant U as systemd-udevd
    participant D as devtmpfs
    participant V as /dev
    participant A as 应用程序

    H->>K: USB 插入、中断或总线枚举
    K->>K: 驱动匹配并创建 block/char device
    K->>S: 创建 /sys/devices 下的设备对象
    K->>K: 分配或公布 dev_t
    K->>D: 创建基础设备节点
    K->>U: 发送 add uevent
    U->>S: 读取属性、父设备和 modalias
    U->>V: 设置权限、所有者、组
    U->>V: 创建 /dev/disk/by-* 等符号链接
    A->>V: 打开 /dev/sdb 或稳定链接

这里有几个容易混淆的事实:

  • 内核驱动负责设备功能和设备模型对象。
  • sysfs 由内核提供,用于暴露设备对象及其属性。
  • devtmpfs 可以在内核侧自动创建基础设备节点。
  • udev 根据规则补充节点属性、符号链接、ACL 和其他用户空间动作。
  • 应用程序通常打开 /dev 下的节点,而不是直接访问 sysfs 属性。

在不同发行版和内核配置中,devtmpfs 是否启用、由谁设置节点权限可能有所差异。现代主流发行版通常同时使用 devtmpfs 和 systemd-udevd:前者保证基础节点尽快出现,后者完成规则处理和权限管理。


二、设备号:dev_t、主设备号和次设备号

2.1 设备号的含义

Linux 的设备节点通过一个设备号标识内核中的设备对象。设备号通常称为 dev_t,逻辑上分为:

  • 主设备号(major number):通常表示负责处理该设备的驱动类别。
  • 次设备号(minor number):通常表示该驱动管理的具体实例、分区或功能单元。

形式上可以写成:

devt=encode(major,minor)dev_t = \operatorname{encode}(major, minor)

其中:

  • major 是主设备号;
  • minor 是次设备号;
  • encode 是内核规定的编码方式。

用户空间不应自行假设 dev_t 的位布局。Linux 内核当前使用的 dev_t 是 32 位表示,但主、次设备号的具体编码应通过内核宏或 stat(2) 接口获得,而不是手工移位。命令行中的 ls -l 会把它们显示为:

$ ls -l /dev/null /dev/zero /dev/sda
crw-rw-rw- 1 root root 1, 3  ... /dev/null
crw-rw-rw- 1 root root 1, 5  ... /dev/zero
brw-rw---- 1 root disk 8, 0  ... /dev/sda

例如:

  • /dev/null 是字符设备,主设备号为 1,次设备号为 3
  • /dev/sda 是块设备,主设备号为 8,次设备号为 0

这里的 1, 38, 0 是设备号,不是文件大小。

2.2 字符设备与块设备

设备节点的第一个字符区分设备类型:

c  字符设备(character device)
b  块设备(block device)

字符设备通常按字节流访问,例如:

  • 终端;
  • 串口;
  • /dev/null
  • GPIO、输入设备和许多自定义驱动接口。

块设备提供按块访问的存储接口,例如:

  • 硬盘;
  • NVMe namespace;
  • 虚拟磁盘;
  • 分区设备。

类型并不等于“是否能随机访问”。关键区别是内核使用的设备接口和缓存模型不同。

2.3 驱动如何获得设备号

字符驱动常见的注册流程包括:

  1. 分配一段设备号;
  2. 初始化 struct cdev
  3. 将文件操作表 struct file_operations 绑定到 cdev
  4. 将设备添加到内核;
  5. 创建 class 和 device,使其出现在 sysfs 并产生 uevent。

简化的内核代码形态如下:

dev_t devno;
int ret;

ret = alloc_chrdev_region(&devno, 0, 1, "example");
if (ret)
    return ret;

cdev_init(&example_cdev, &example_fops);
example_cdev.owner = THIS_MODULE;

ret = cdev_add(&example_cdev, devno, 1);
if (ret) {
    unregister_chrdev_region(devno, 1);
    return ret;
}

这段代码只注册了字符设备接口。要让用户空间看到可用的 /dev/example0,驱动还需要将设备对象接入设备模型,例如通过 class_create()device_create()。之后内核会发布设备事件,udev 才能依据事件创建或管理相应节点。

现代内核驱动通常优先使用动态设备号:

alloc_chrdev_region()

而不是在源码中固定占用某个主设备号。固定主设备号可能与其他驱动冲突,也会降低模块移植性。

2.4 设备号不是永久名称

设备号主要用于内核路由访问,不适合作为业务层的永久身份。设备重连、驱动重载、系统启动顺序变化后,某些动态主次设备号可能发生变化。

因此,下面两类名称需要区分:

/dev/ttyUSB0          可能因插入顺序改变
/dev/serial/by-id/... 通常依据设备身份建立,稳定性更好

稳定符号链接本质上仍然指向一个带设备号的设备节点,但应用程序通过符号链接访问,从而避免依赖枚举顺序。


三、设备节点:/dev 文件究竟做了什么

设备节点是特殊文件。对普通文件调用 open() 是打开文件内容;对设备节点调用 open(),内核会根据设备号找到对应的设备驱动入口。

可以简化为:

open(path)inodedevtdriver operationsopen(path) \rightarrow inode \rightarrow dev_t \rightarrow driver\ operations

具体过程是:

  1. 进程解析 /dev/ttyUSB0
  2. VFS 找到该路径对应的 inode;
  3. inode 中记录设备类型和 dev_t
  4. 内核根据主设备号找到对应的驱动操作;
  5. 驱动执行自己的 open()read()write()ioctl() 等逻辑。

因此,设备节点不是设备本身,也不包含驱动代码。删除节点通常不会卸载驱动,也不会让硬件消失:

sudo rm -f /dev/example0

这只会删除用户空间路径。若设备仍在,重新触发 udev 或重新创建节点后,路径可能再次出现。

反过来,手工创建一个节点也不代表设备一定可用:

sudo mknod /dev/test-null c 1 3

这个命令创建一个主次设备号为 1,3 的字符设备节点。只要内核中存在对应的 /dev/null 驱动,它可能具有相同的行为;但它绕过了 udev 的命名、权限和生命周期管理,生产环境中不应使用这种方式替代正常设备管理。

mknod 的风险包括:

  • 设备号写错,得到“存在但不能工作”的节点;
  • 权限过宽,暴露底层设备;
  • 设备拔出后节点仍然残留;
  • 绕过发行版对设备权限和 ACL 的控制。

四、内核设备模型:设备、总线、驱动和类

4.1 设备模型解决什么问题

Linux 设备模型把硬件和驱动组织成对象关系,而不是让每个驱动各自维护一套不可见的全局表。核心对象包括:

  • device:具体设备实例;
  • driver:驱动程序;
  • bus:设备与驱动匹配的总线类型;
  • class:按用户可见功能分类的设备集合;
  • kobject:引用计数、名称、父子层级和 sysfs 表示的基础对象。

典型关系如下:

物理 USB 设备
└── USB 接口
    └── 串口驱动实例
        └── tty 设备

总线层级回答“设备挂在哪里”;class 层级回答“用户从功能角度如何看到它”。

例如,一个 USB 串口设备可能实际位于:

/sys/devices/pci0000:00/
└── 0000:00:14.0/
    └── usb1/
        └── 1-2/
            └── 1-2:1.0/
                └── ttyUSB0/

而在 class 视图中可以通过:

/sys/class/tty/ttyUSB0

访问。/sys/class/tty/ttyUSB0 通常是指向真实设备层级的符号链接,而不是另一份独立设备对象。

4.2 sysfs 的两个视图

sysfs 通常挂载在 /sys

mount | grep ' on /sys '

常见目录包括:

/sys/devices/  真实设备拓扑
/sys/class/    按功能分类的视图
/sys/bus/      按总线查看设备和驱动
/sys/block/    块设备视图
/sys/module/   已加载内核模块

例如:

readlink -f /sys/class/block/sda

可能得到类似:

/sys/devices/pci0000:00/0000:00:17.0/ata1/host0/target0:0:0/0:0:0:0/block/sda

这说明 /sys/class/block/sda 是分类视图,而真实拓扑位于 /sys/devices 下。

sysfs 属性文件不是普通配置文件。读取属性通常是向内核请求当前状态:

cat /sys/class/block/sda/size
cat /sys/class/net/eth0/address
cat /sys/class/tty/ttyUSB0/dev

其中 /sys/class/tty/ttyUSB0/dev 可能输出:

188:0

这表示该设备的主设备号是 188,次设备号是 0。它与下面命令得到的信息应当一致:

stat -c 'type=%F major=%t minor=%T' /dev/ttyUSB0

stat 输出中的 %t%T 是十六进制表示;如果需要十进制值,可使用 Python 或 stat(2) 的宏进行转换。

4.3 sysfs 属性的边界

sysfs 不是通用数据库,也不是稳定的应用配置接口。需要注意:

  • 属性名称和目录结构由内核子系统及驱动定义;
  • 某些属性只读,某些属性可写;
  • 可写属性可能直接改变硬件或驱动状态;
  • 属性可能因内核版本、驱动配置和硬件不同而不存在;
  • 不能假设所有设备都有相同属性。

例如:

echo 1 | sudo tee /sys/bus/pci/devices/0000:03:00.0/remove

可能会请求内核移除一个 PCI 设备。这不是普通文本写入,而是有实际设备生命周期影响的操作。生产环境执行前必须确认设备路径、驱动状态和恢复方式。


五、uevent:内核如何通知用户空间

当设备被添加、移除或状态改变时,内核会发送 uevent。它是内核向用户空间传递设备状态变化的事件,通常包含:

  • ACTIONaddremovechangebindunbind 等;
  • SUBSYSTEMblockttynetusb 等;
  • DEVPATH:设备在 sysfs 中的路径;
  • DEVNAME:设备节点名称,若该事件对应设备节点;
  • MAJORMINOR:设备号;
  • MODALIAS:用于驱动模块匹配的别名。

可以查看一个现有设备的属性:

udevadm info --query=property --name=/dev/ttyUSB0

可能看到:

DEVPATH=/devices/.../ttyUSB0
DEVNAME=/dev/ttyUSB0
DEVTYPE=usb_device
MAJOR=188
MINOR=0
SUBSYSTEM=tty
ID_VENDOR=FTDI
ID_SERIAL_SHORT=...

实际输出取决于硬件、驱动和 udev 规则,不能把某一台机器的全部属性当成规范保证。

查看 sysfs 设备路径对应的 uevent 文件:

cat /sys/class/block/sda/uevent

输出可能包含:

MAJOR=8
MINOR=0
DEVNAME=sda
DEVTYPE=disk
DISKSEQ=...

uevent 文件是设备对象提供的状态接口。向某些设备的 uevent 文件写入 add,可以请求重新发送事件:

echo add | sudo tee /sys/class/block/sda/uevent

这类操作属于管理动作,可能重新触发规则和外部程序,不应在不了解规则的生产系统上随意执行。


六、udev 的职责与生命周期

现代主流发行版通常由 systemd-udevd 进程处理 udev 事件。可以检查服务状态:

systemctl status systemd-udevd

udev 的典型处理流程如下:

  1. 内核创建或删除设备对象;
  2. 内核更新 sysfs;
  3. 内核发送 uevent;
  4. udev 接收事件;
  5. udev 读取设备属性和父设备属性;
  6. udev 按规则匹配;
  7. udev 设置节点权限、所有者和组;
  8. udev 创建符号链接或标签;
  9. udev 运行有限的辅助动作;
  10. 设备节点和 udev 数据库状态达到稳定状态。

设备移除时,流程通常是:

  1. 内核发布 remove
  2. udev 找到对应设备状态;
  3. 删除或清理由 udev 管理的节点和符号链接;
  4. 设备对象从 sysfs 消失。

6.1 冷插拔与热插拔

热插拔(hotplug) 是设备在系统运行期间加入或离开。USB 插入、网卡接口出现、磁盘拔出都属于热插拔场景。

系统启动时,设备可能早已由固件或内核发现。为了让 udev 重新处理这些设备,启动流程通常会执行 coldplug:遍历 sysfs,重新发送或处理已有设备的 add 事件。

因此:

热插拔:设备变化 → 内核发送事件 → udev 处理
冷插拔:系统启动后扫描已有设备 → 模拟或重新处理 add

规则不能只在“物理插入瞬间”有效;正确的 udev 规则也应能在系统启动时对已有设备生效。

6.2 事件并发与顺序

不同设备的 uevent 可能并发到达。一个物理设备通常还会产生多个层次的事件,例如:

USB 设备
USB 接口
块设备
分区
文件系统识别结果

不要假设“收到某一个事件时,所有后续属性和节点都已经存在”。例如:

  • USB 父设备的事件不一定等于串口子设备已经可用;
  • 磁盘节点出现不等于分区或文件系统识别已完成;
  • change 事件可能晚于 add 事件;
  • 设备拔出时,后台程序可能仍然持有文件描述符。

udev 本身会处理事件队列和规则执行,但应用程序仍然必须正确处理设备暂时不可用、节点消失和 I/O 返回错误等情况。


七、规则文件:匹配设备,改变设备呈现方式

udev 规则通常位于:

/usr/lib/udev/rules.d/   发行版或软件包提供
/usr/local/lib/udev/rules.d/ 本地软件可能使用
/run/udev/rules.d/       运行时生成
/etc/udev/rules.d/       管理员自定义

不同发行版的目录优先级和覆盖细节由其 udev 实现及打包方式决定。管理员规则通常放在 /etc/udev/rules.d/,避免直接修改 /usr/lib 下的软件包文件。

文件名通常使用数字前缀,例如:

10-*.rules
60-*.rules
70-*.rules
99-*.rules

规则文件按词法顺序处理。数字前缀是常见的排序手段,但“数字越大就一定完全覆盖前面所有行为”并不准确:不同操作符有不同的赋值语义,某些属性只能首次赋值,某些属性可以追加或覆盖。

7.1 常见匹配键

常见匹配键包括:

ACTION=="add"
SUBSYSTEM=="tty"
KERNEL=="ttyUSB[0-9]*"
ATTR{属性}=="值"
ATTRS{父级或祖先属性}=="值"
ENV{变量}=="值"
DEVPATH=="..."

它们的含义不同:

  • KERNEL 匹配内核设备名,例如 sdattyUSB0
  • SUBSYSTEM 匹配子系统,例如 blocktty
  • ATTR{} 匹配当前设备目录下的 sysfs 属性;
  • ATTRS{} 向父设备链查找属性,常用于用 USB 设备的厂商和序列号匹配子设备;
  • ENV{} 匹配 udev 事件环境或前面规则生成的环境变量。

ATTRS{} 特别容易误用。它可能沿父设备链查找,匹配到的是父设备信息;而 ATTR{} 只针对当前设备。规则的主体通常是子设备,例如 ttyUSB0,但 USB 厂商 ID 可能位于其父设备上,因此需要使用 ATTRS{}

7.2 常见赋值键

常用动作包括:

NAME="..."
SYMLINK+="..."
OWNER="..."
GROUP="..."
MODE="..."
TAG+="..."
ENV{变量}="..."
RUN+="..."

现代系统中,NAME= 的使用受到限制,通常推荐创建稳定符号链接:

SYMLINK+="serial/my-device"

不要试图通过规则把多个真实设备都重命名成同一个名字。并发插入时会发生冲突,且某些内核子系统不允许随意修改内核设备名。


八、一个完整的 udev 规则示例:为 USB 串口创建稳定名称

假设有一只 USB 转串口设备,希望得到:

/dev/serial/my-gps

8.1 先收集真实属性

设备插入后查找节点:

ls -l /dev/ttyUSB* /dev/ttyACM* 2>/dev/null

假设节点是 /dev/ttyUSB0,查看属性:

udevadm info --attribute-walk --name=/dev/ttyUSB0

也可以查看已经由 udev 规则整理出的属性:

udevadm info --query=property --name=/dev/ttyUSB0

需要确认的内容包括:

  • 当前设备是 tty 子系统;
  • 真实内核名称是 ttyUSB0
  • idVendoridProduct 来自哪个父设备;
  • 是否有稳定的 serial
  • 是否存在多个相同型号设备。

不能直接复制网上示例中的 idVendoridProduct,因为设备型号和属性值必须以本机实际输出为准。

8.2 规则文件

创建:

sudoedit /etc/udev/rules.d/70-my-gps.rules

内容示例:

ACTION=="add", SUBSYSTEM=="tty", KERNEL=="ttyUSB[0-9]*", \
  ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6015", \
  ATTRS{serial}=="A1B2C3D4", \
  SYMLINK+="serial/my-gps", GROUP="dialout", MODE="0660"

逐项解释:

  • ACTION=="add":只处理设备加入事件;
  • SUBSYSTEM=="tty":只处理终端设备;
  • KERNEL=="ttyUSB[0-9]*":限制为 USB 串口节点;
  • ATTRS{idVendor}ATTRS{idProduct}:匹配 USB 父设备;
  • ATTRS{serial}:进一步匹配具体物理设备;
  • SYMLINK+=:创建稳定链接;
  • GROUP="dialout":将节点组设置为串口用户组;
  • MODE="0660":所有者和组可读写,其他用户无权限。

如果设备没有序列号,仅按厂商和产品 ID 匹配,那么同型号设备会同时匹配该规则,可能都被创建成同一个符号链接。此时最后到达的设备可能覆盖链接指向,不能把这种规则当成设备身份识别。

8.3 重新加载和测试

重新加载规则:

sudo udevadm control --reload-rules

这通常只让 udev 重新读取规则,不会自动重新处理已经存在的设备。可以针对已有设备重新触发:

sudo udevadm trigger --subsystem-match=tty --action=add

也可以拔出再插入设备。

查看结果:

ls -l /dev/serial/my-gps
readlink -f /dev/serial/my-gps

预期类似:

lrwxrwxrwx 1 root root ... /dev/serial/my-gps -> ../../ttyUSB0

检查权限:

stat -c '%A %U %G %n' /dev/ttyUSB0 /dev/serial/my-gps

符号链接本身的权限没有实际访问意义。应用程序最终访问的是链接指向的 /dev/ttyUSB0,因此要检查真实节点的组和模式。

8.4 不执行动作的规则测试

在规则生效前,可以运行:

udevadm test /sys/class/tty/ttyUSB0

它会模拟 udev 对该设备的处理,并输出规则匹配和动作信息。重要边界是:

  • udevadm test 主要用于诊断;
  • 它不会完全等同于真实事件处理;
  • 不应把测试输出当成设备已被实际重新配置的证明;
  • RUN+= 等动作可能被跳过或以测试模式处理。

对于事件监控,可以使用:

udevadm monitor --kernel --udev --property

插拔设备时可以看到内核事件和 udev 处理后的事件。对比二者能够判断问题发生在:

内核是否发事件
→ udev 是否接收到
→ 规则是否匹配
→ 节点或链接是否创建

九、规则匹配中的常见误区

9.1 用 KERNEL=="sd*" 识别某一块磁盘

下面的规则过于宽泛:

KERNEL=="sd*", SYMLINK+="my-disk"

它可能匹配系统盘、USB 盘、虚拟盘以及不同时间出现的多个设备。正确做法应使用更具体的身份,例如序列号、WWN 或经过验证的父设备属性:

SUBSYSTEM=="block", ENV{ID_SERIAL}=="ATA_...", \
  SYMLINK+="disk/my-disk"

ID_SERIAL 往往是 udev 内置规则生成的环境属性,不是所有设备都保证存在。应先用以下命令确认:

udevadm info --query=property --name=/dev/sdb | grep -E 'ID_SERIAL|ID_WWN|ID_MODEL'

9.2 用 /dev/sda 作为永久磁盘身份

/dev/sda 只是当前枚举结果。下列情况都可能改变它:

  • 增加或移除其他磁盘;
  • 控制器初始化顺序改变;
  • 虚拟机设备顺序变化;
  • 驱动重载或总线重新扫描。

对于挂载、备份和自动化任务,应优先使用:

/dev/disk/by-id/
/dev/disk/by-uuid/
/dev/disk/by-partuuid/

它们适用范围不同:

  • by-id 通常对应硬件或虚拟设备身份;
  • by-uuid 对应文件系统 UUID;
  • by-partuuid 对应分区表中的分区 UUID。

文件系统被重新格式化后,by-uuid 通常会变化,而硬件 ID 可能不变。

9.3 用 ATTR{} 匹配父设备属性

下面的规则可能匹配不到:

SUBSYSTEM=="tty", ATTR{idVendor}=="0403"

因为 idVendor 常在 USB 父设备目录,而当前对象是 ttyUSB0。通常应使用:

SUBSYSTEM=="tty", ATTRS{idVendor}=="0403"

但不能机械地把所有 ATTR 改成 ATTRS。如果属性确实属于当前设备,使用 ATTR{} 更准确,也能避免祖先链上同名属性造成意外匹配。


十、驱动模块加载与 udev 的关系

设备出现时,内核可能发布 MODALIAS,例如描述某个 USB、PCI 或其他总线设备所需的驱动别名。用户空间的模块加载机制会根据该别名查找模块,并请求加载匹配模块。

这条链路可以表示为:

设备枚举
→ 内核生成 MODALIAS
→ 用户空间模块加载器查询别名
→ modprobe 加载模块
→ 驱动注册并绑定设备
→ 设备对象和 /dev 节点出现

这里要区分两个角色:

  • 驱动模块:实现硬件操作和内核接口;
  • udev 规则:处理设备事件和用户空间呈现。

udev 不是驱动加载器本身,但现代系统的设备事件处理和模块自动加载经常通过同一套用户空间基础设施协同完成。若设备完全没有驱动绑定,通常不会出现预期的 ttyblock 或其他功能设备节点,此时只修改 udev 规则无效。

诊断驱动绑定关系:

udevadm info --query=path --name=/dev/sda
readlink -f /sys/class/block/sda/device/driver

如果设备是 PCI 设备,也可以检查:

lspci -k

模块是否加载:

lsmod
modinfo <模块名>

若模块涉及签名、DKMS、内核版本不匹配或依赖缺失,应该先解决模块加载和驱动绑定问题,再诊断 udev。没有内核设备事件,udev 无法凭空创建一个功能正确的设备。


十一、权限:模式、所有者、组、ACL 和设备访问控制

11.1 设备节点权限

设备节点和普通文件一样有:

  • 所有者;
  • 所属组;
  • 模式位;
  • 可能存在的 POSIX ACL。

例如:

crw-rw---- 1 root dialout 188, 0 /dev/ttyUSB0

这表示:

  • 类型为字符设备;
  • 所有者 root 可读写;
  • dialout 组成员可读写;
  • 其他用户无权限。

如果当前用户不在 dialout 组中,访问可能失败:

groups
id

加入组后,现有登录会话通常不会立即获得新组,需要重新登录,或启动一个带新组的会话。发行版使用的组名并不完全统一,串口设备可能归属于 dialoutuucp 或其他组,应以本机节点实际结果为准。

11.2 MODEGROUPTAG+="uaccess"

一个简单的服务器规则可能使用:

SUBSYSTEM=="tty", ATTRS{serial}=="A1B2C3D4", \
  GROUP="dialout", MODE="0660"

桌面系统常见另一种机制:

SUBSYSTEM=="tty", ATTRS{serial}=="A1B2C3D4", TAG+="uaccess"

uaccess 通常由 logind 等桌面会话组件根据当前 seat 和活动本地用户动态授予 ACL。它的优点是设备可以跟随活动桌面用户变化;局限是:

  • 依赖桌面会话和 logind;
  • 不适合简单的无头服务器权限模型;
  • SSH 登录用户、容器用户或后台服务不一定获得该 ACL;
  • 设备切换活动会话时权限可能变化。

因此,后台服务通常更适合使用明确的服务组、专用组或 systemd 服务配置,而不是假设 uaccess 对所有会话都有效。

11.3 不要用 0666 解决权限问题

以下规则会让所有本地用户都能读写设备:

MODE="0666"

对串口尚且可能造成误操作,对块设备、原始存储、加密设备、调试接口和控制设备则可能形成严重安全风险。设备节点权限是访问控制的一部分,不应把“程序打不开设备”简单处理成全局开放。

还要注意,udev 设置的只是节点文件访问权限。应用能否真正使用设备,还可能受到以下因素限制:

  • SELinux 或 AppArmor;
  • systemd 服务的 DeviceAllow=
  • 容器的 device cgroup;
  • 用户命名空间和挂载命名空间;
  • 宿主机 /dev 是否传入容器;
  • 设备驱动自身的权限和能力检查。

符号链接不会绕过这些控制。访问 /dev/serial/my-gps 最终仍受目标设备节点及内核访问控制约束。


十二、规则中的 RUN+=:为什么不适合承载业务服务

规则可以使用:

RUN+="/usr/local/bin/device-hook"

RUN+= 适合短小、快速、无交互的辅助动作,不适合启动长期运行的服务或执行复杂脚本。原因包括:

  • udev 事件处理不应被长时间阻塞;
  • 多个设备事件可能并发执行;
  • 设备节点创建和权限处理不应依赖一个脆弱的业务脚本;
  • 热插拔发生在系统启动早期时,网络、用户会话和挂载点可能尚未就绪;
  • 拔出事件发生时,脚本访问的设备节点可能已经消失;
  • udev 会限制某些程序的执行环境,不能假设完整的交互式 shell 环境。

更可靠的模式通常是:

udev 负责识别设备和建立稳定链接
→ udev 设置一个 systemd device unit 或触发受控服务
→ systemd 负责长期进程、重启、日志和依赖

如果确实使用 RUN+=,脚本应:

  • 使用绝对路径;
  • 不依赖当前工作目录和交互式环境变量;
  • 快速退出;
  • 正确处理 addremove 和重复事件;
  • 记录失败原因;
  • 不把设备节点路径永久写死为动态名称。

十三、调试方法:从内核事件逐层定位

13.1 先确认内核是否发现设备

以 USB 设备为例:

dmesg --follow

插入设备后,可能看到:

usb 1-2: new full-speed USB device number 7 using xhci_hcd
usb 1-2: New USB device found, idVendor=0403, idProduct=6015
ftdi_sio 1-2:1.0: FTDI USB Serial Device converter detected
usb 1-2: FTDI USB Serial Device converter now attached to ttyUSB0

如果连 USB 枚举信息都没有,问题通常在:

  • 物理连接;
  • 供电;
  • USB 控制器;
  • 固件或 BIOS;
  • 内核日志和硬件错误。

这不是 udev 规则问题。

13.2 再确认 sysfs 和设备节点

ls -l /sys/class/tty/ttyUSB0
ls -l /dev/ttyUSB0
udevadm info --query=all --name=/dev/ttyUSB0

如果 sysfs 中存在 ttyUSB0,但 /dev/ttyUSB0 不存在,重点检查:

  • devtmpfs 是否挂载;
  • systemd-udevd 是否运行;
  • /dev 是否被正确挂载;
  • 容器或 chroot 是否有独立的 /dev
  • 节点是否被其他管理程序删除。

检查挂载:

mount | grep -E ' on /dev | on /sys '

13.3 观察事件

sudo udevadm monitor --kernel --udev --property

然后重新插拔设备。重点对比:

  • 是否收到 KERNEL[...] 事件;
  • 是否收到 UDEV[...] 事件;
  • ACTION 是否为预期值;
  • SUBSYSTEM 是否正确;
  • DEVNAMEMAJORMINOR 是否存在;
  • 是否出现规则生成的 SYMLINK 或环境变量。

13.4 测试规则匹配

udevadm test --action=add /sys/class/tty/ttyUSB0

在某些发行版中,命令行选项和输出格式可能略有差异,可以先查看:

udevadm test --help

测试时检查:

  • 规则文件是否被读取;
  • ATTRATTRS 是否能匹配;
  • 条件是否被前面的规则改变;
  • 符号链接目标是否冲突;
  • 最终的 GROUPMODETAG 是否符合预期。

13.5 检查规则语法

udevadm verify /etc/udev/rules.d/70-my-gps.rules

该命令在支持的 udev 版本中可用于验证规则文件。由于发行版版本差异,若子命令不可用,应使用:

udevadm --help

并通过 udevadm test 和实际事件验证。


十四、热拔出、设备重连和竞态

设备热拔出时,/dev 节点可能很快消失,但已经打开的文件描述符不一定立即失效。应用程序仍然可能持有一个文件描述符,只是后续读写返回错误,或者设备进入不可用状态。

一个健壮的程序不能只在启动时执行:

open("/dev/ttyUSB0")

然后假设设备永远存在。它需要考虑:

  • open() 失败;
  • read()write() 返回 ENODEVEIO 等错误;
  • 设备重连后节点名称发生变化;
  • 原来的文件描述符不能自动指向新插入的同型号设备;
  • 稳定符号链接可能短暂不存在;
  • 同一硬件的 ttyUSB 编号可能变化。

应用应在设备断开后关闭旧文件描述符,重新解析稳定路径并重新打开,而不是继续使用旧句柄。

对于自动挂载、备份、烧录等任务,还要防止以下竞态:

看到 /dev/sdb
→ 立即开始操作
→ 实际分区或文件系统尚未出现

块设备、分区和文件系统识别可能产生多个异步事件。程序需要确认目标设备的身份和状态,而不能仅以路径存在作为“设备准备完成”的证明。


十五、权限和规则变更的风险控制

修改规则后,常见操作顺序是:

sudo install -m 0644 70-my-gps.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm trigger --subsystem-match=tty

但在生产系统中需要注意以下风险:

  1. udevadm trigger 可能重新处理大量设备;
  2. 某些规则含有 RUN+=,重新触发可能再次启动动作;
  3. 存储设备规则错误可能造成错误符号链接或自动化任务误选磁盘;
  4. 权限规则过宽可能让非特权用户访问高风险设备;
  5. 规则文件名冲突可能导致发行版升级后行为改变;
  6. 依赖某个内核属性的规则可能在换驱动或换内核后失效。

更安全的验证方式是先针对单个 sysfs 路径运行测试,再在维护窗口中拔插或触发目标子系统。规则中应尽量使用稳定且经过核实的属性,并避免在通配范围过大的设备上执行破坏性动作。


十六、常见故障表现与因果判断

16.1 /dev 节点不存在,但设备在 sysfs 中

可能原因:

  • 驱动没有创建字符或块设备;
  • devtmpfs 未挂载;
  • udevd 未运行;
  • udev 规则删除或覆盖了节点;
  • 当前环境是容器,宿主机节点未映射;
  • 设备事件在启动时未被正确处理。

诊断顺序应是:

readlink -f /sys/class/<subsystem>/<device>
udevadm info --query=all --path=/sys/class/<subsystem>/<device>
systemctl status systemd-udevd
mount | grep /dev

16.2 节点存在,但应用提示 Permission denied

先看真实节点:

ls -l /dev/ttyUSB0
getfacl /dev/ttyUSB0
id

之后区分:

  • Unix 模式位是否允许;
  • 用户是否已经加入正确的组;
  • ACL 是否授予或拒绝访问;
  • SELinux/AppArmor 是否阻止;
  • systemd 服务是否设置了设备访问限制;
  • 容器设备 cgroup 是否拒绝。

不要只检查符号链接权限。

16.3 规则不生效

常见原因:

  • SUBSYSTEM 写错;
  • 当前设备名不是预期的 ttyUSB*
  • 应使用 ATTRS{} 却写成了 ATTR{}
  • 序列号属性为空或位于另一层父设备;
  • 规则文件权限或语法错误;
  • 修改规则后只 reload,没有重新触发已有设备;
  • 另一个规则创建了不同权限或链接;
  • 规则匹配了多个同类设备。

应使用:

udevadm info --attribute-walk --name=/dev/ttyUSB0
udevadm test --action=add /sys/class/tty/ttyUSB0

而不是凭猜测修改规则。

16.4 设备插入时模块没有加载

先看:

dmesg | tail -n 50
lsusb
lsmod

如果事件中存在 MODALIAS,但对应模块未加载,检查:

modprobe <模块名>
modinfo <模块名>

还要考虑:

  • 模块是否安装;
  • 当前内核版本是否匹配;
  • DKMS 是否编译失败;
  • Secure Boot 是否拒绝未签名模块;
  • 模块依赖是否完整;
  • 内核配置是否包含该驱动。

这类问题发生在驱动绑定之前,单纯添加 udev SYMLINK 规则不能修复。


十七、内核保证、实现细节和经验选择

需要明确区分三类结论。

17.1 规范和接口层面的保证

Linux 提供并长期使用的接口包括:

  • dev_t 表示设备号;
  • /dev 中的设备节点通过字符或块设备接口访问;
  • sysfs 表示内核设备模型;
  • uevent 用于通知设备状态变化;
  • udev 规则根据事件和设备属性管理用户空间设备呈现。

这些是系统设计的基础接口。

17.2 常见实现

现代主流发行版通常采用:

内核设备模型
+ sysfs
+ devtmpfs
+ systemd-udevd
+ systemd/logind 权限和设备管理

但这不是所有 Linux 环境的唯一组合。嵌入式系统可能使用 BusyBox mdev,容器中可能没有独立的 udevd,定制系统也可能不启用 devtmpfs。

17.3 工程上的经验选择

以下属于工程取舍,而不是内核强制要求:

  • /dev/disk/by-id 替代 /dev/sdX
  • 用 USB 序列号区分同型号设备;
  • 用组权限替代 0666
  • 用 systemd 服务替代复杂 RUN+=
  • 在应用中处理拔出、重连和节点变化;
  • 将管理员规则放在 /etc/udev/rules.d/

这些选择的正确性取决于部署环境、设备身份质量、权限模型和故障恢复要求。


十八、最小可复用检查流程

面对一个“设备插入后不能使用”的问题,可以按以下因果顺序检查:

1. 硬件是否被总线发现?
2. 内核是否加载了正确驱动?
3. 驱动是否创建了设备对象?
4. sysfs 中是否存在目标设备?
5. 内核是否发布 add uevent?
6. devtmpfs 或 udev 是否创建 /dev 节点?
7. udev 规则是否匹配?
8. 符号链接是否指向正确节点?
9. 节点权限、ACL 和安全策略是否允许访问?
10. 应用是否能处理设备重连和动态路径?

对应的工具分别是:

dmesg --follow
lsusb / lspci -k
lsmod / modinfo
find /sys/class -maxdepth 2
udevadm monitor --kernel --udev --property
ls -l /dev
udevadm test
udevadm info --attribute-walk
ls -l /dev/... 
ls -l /dev/... && getfacl /dev/...

这条顺序很重要:如果硬件没有进入内核设备模型,继续修改 udev 规则没有意义;如果设备节点已正确创建但访问被拒绝,继续研究设备号也不会解决权限问题。

最终应把几个层次分别看待:

设备号:内核定位驱动接口的标识
sysfs:内核设备模型和属性的可见表示
uevent:内核向用户空间报告状态变化的机制
udev 规则:根据设备属性管理用户空间名称、权限和动作
/dev 节点:应用访问设备的文件系统入口

只有把这些对象放回完整生命周期中,才能正确解释为什么设备可以出现在 sysfs 却不在 /dev,为什么 /dev/ttyUSB0 会变化,为什么符号链接不能绕过权限控制,以及为什么一个看似简单的 udev 规则可能在热插拔和并发场景下产生生产事故。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。