Linux(30)-热插拔
前言
在嵌入式开发与 Linux 系统运维中,“即插即用”早已是标配功能。当你插入 U 盘或 TF 卡时,系统是如何在几毫秒内感知到硬件变更并自动完成设备节点创建与目录挂载的?
热插拔架构全景图
Linux 热插拔机制遵循经典的 生产者-消费者 模型:
- 1.硬件/中断层:物理接口触发电平变化,控制器产生中断。
- 2.内核 Driver/Subsystem 层:设备驱动感知状态变化,触发 kobject 状态更新。
- 3.内核 Uevent 广播层:内核组装环境变量,通过 Socket(Netlink)或传统 Helper 脚本向用户态发送通知。
- 4.用户态守护进程层:udevd 或 mdev 监听到事件,解析规则并执行设备节点创建、自动挂载等操作。

内核是如何产生并发送 Uevent 的?
核心触发函数:kobject_uevent_env
在内核中,无论通过何种子系统注册设备,最终都会调用 kobject_uevent() 或其核心实现 kobject_uevent_env() 来向外界宣告设备状态的变化(如 KOBJ_ADD、KOBJ_REMOVE、KOBJ_CHANGE)
该函数的主要职责是构建一个包含环境变量的缓冲区(struct kobj_uevent_env),常见的环境变量包括:
1. ACTION(事件动作类型)
含义:标识设备状态发生了何种物理或逻辑变更。常见值:
- change:设备状态或属性发生改变(如电源状态改变、介质装载)。
2. DEVPATH(设备在 sysfs 中的相对路径)
含义:内核中 kobject 在 /sys 目录树下的物理拓扑路径。
- 例:/devices/platform/soc/30800000.usb/usb1/... 记录了设备挂载在哪个总线控制器之下。
- 作用:用户态可通过拼接 /sys + DEVPATH(如 /sys/devices/platform/...)直接访问该设备的 sysfs 属性文件,提取厂商 ID(idVendor)、产品 ID(idProduct)或序列号。
3.SUBSYSTEM(内核子系统分类)
含义:标识管理该设备的内核子系统类型。常见类别:
- block:块设备子系统(如 U 盘、SD/TF 卡、硬盘及其分区)。
- net:网络设备子系统(如网卡 eth0、wlan0)。
- input:输入设备子系统(如键盘、鼠标、触摸屏)。
- 作用:方便用户态匹配规则(如 udev 规则)快速过滤不相关的事件,仅处理特定设备类型。
4. MAJOR 与 MINOR(主/次设备号)
含义:内核分配给该设备的系统级唯一编号(在实际 uevent 数据流中通常分为 MAJOR=x 和 MINOR=y 两行)。
- 具体分工:MAJOR(主设备号):标识设备对应的驱动程序类型。在 Linux 中,主设备号 8 统一代表 SCSI 磁盘 / USB 存储设备(sd 驱动)。
- MAJOR=8, MINOR=0 代表整块 U 盘设备(/dev/sda)。
- MAJOR=8, MINOR=1 代表 U 盘的第 1 个分区(/dev/sda1)。
- MINOR(次设备号):标识该驱动程序管理的特定设备实例或分区。
- 作用:udev/mdev 守护进程在收到 add 事件后,会读取主次设备号,调用系统接口 mknod 在 /dev 目录下创建对应的设备节点文件。
核心控制链条:kset_uevent_ops
当一个 kobject 尝试发送 uevent 时,内核会沿着它的层级向上寻找它所属的 kset。如果该 kset 定义了 uevent_ops 句柄,内核将依次调用这些回调函数:
structkset_uevent_ops {
/* 1. 过滤函数:决定是否向用户态发送该事件 */
int (* const filter)(const struct kobject *kobj);
/* 2. 重命名子系统:覆盖默认的子系统名称 */
constchar *(* const name)(const struct kobject *kobj);
/* 3. 填充环境变量:向 uevent_env 附加自定义键值对 */
int (* const uevent)(const struct kobject *kobj, struct kobj_uevent_env *env);
};
filter() 回调:如果返回 0,内核将直接丢弃该事件,不再向用户态广播。这常用于屏蔽不必要的内部状态更新。
uevent() 回调:子系统在此处向用户态补充专属信息(例如块设备子系统在这里补充 MAJOR 和 MINOR 设备号)。
内核向用户态发送事件的路径
内核提供了两条向用户态传递消息的通道:Netlink 广播机制(现代系统首选)与 uevent_helper 机制(传统方式)。
途径 1:Netlink 广播(高效、异步):
这是当前主流 Linux 系统(如 Ubuntu、Debian、CentOS)以及 udevd 使用的标准路径。
- 原理:内核创建一个专用类型为 NETLINK_KOBJECT_UEVENT 的 Netlink Socket。
- 过程:当 kobject_uevent_env() 组装好环境变量后,调用 netlink_broadcast() 将数据打包发送给所有订阅了该 Multicast Group 的用户态 Socket。
途径 2:uevent_helper 机制(传统、高开销):
在早期的 Linux 内核或极其精简的嵌入式系统中,内核支持直接调用一个用户态可执行程序。
- 配置入口:通过节点 /sys/kernel/uevent_helper 或 /proc/sys/kernel/hotplug 设置。例如:
echo"/sbin/mdev" > /sys/kernel/uevent_helper
原理:每当有热插拔事件产生,内核底层会调用 call_usermodehelper(),直接 Fork 并 Exec 用户态指定的程序,将 ACTION 和 DEVPATH 作为环境变量传入该程序。
缺陷:频繁插拔设备时,内核频繁 Fork 进程会导致巨大的系统开销。现代 Linux 发行版通常将 uevent_helper 设置为空字符串 "" 以禁用该机制。
用户态接收与解析热插拔事件
在用户态,我们可以通过标准的 C 语言代码直接监听 Netlink 广播数据包,从而感知硬件的插拔:
#include<stdio.h>
#include<string.h>
#include<sys/socket.h>
#include<linux/netlink.h>
#include<unistd.h>
#define UEVENT_BUFFER_SIZE 2048
intmain(void){
structsockaddr_nlsa;
char buf[UEVENT_BUFFER_SIZE];
int len;
int fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_KOBJECT_UEVENT);
if (fd < 0) return-1;
memset(&sa, 0, sizeof(sa));
sa.nl_family = AF_NETLINK;
sa.nl_groups = 1; /* 订阅 uevent 组 */
bind(fd, (struct sockaddr *)&sa, sizeof(sa));
while (1) {
len = recv(fd, buf, sizeof(buf) - 1, 0);
if (len > 0) {
buf[len] = '\0';
/* 遍历以 \0 分隔的环境变量字符串 */
char *s = buf;
while (s < buf + len) {
if (strlen(s) > 0)
printf("%s\n", s);
s += strlen(s) + 1;
}
printf("----------------------------------------\n");
}
}
close(fd);
return0;
}
例子
实战演练:U 盘与 TF 卡自动挂载方案
在实际项目中,针对桌面系统、嵌入式 BusyBox 环境和轻量级应用,通常有三种不同的自动化落地方案。
方案 1:基于 udev 实现自动挂载(通用桌面 & 复杂嵌入式)udev 是现代 Linux 标准的设备管理守护进程。它通过配置规则文件实现设备节点生成和脚本触发。
- 1.创建挂载脚本 /usr/local/bin/automount.sh
#!/bin/bash
ACTION=$1
DEVNAME=$2
MOUNT_POINT="/media/${DEVNAME}"
if [ "$ACTION" = "add" ]; then
mkdir -p "${MOUNT_POINT}"
# 自动识别文件系统并挂载
mount -o sync,utf8 /dev/${DEVNAME} "${MOUNT_POINT}"
elif [ "$ACTION" = "remove" ]; then
umount -l "${MOUNT_POINT}"
rmdir "${MOUNT_POINT}"
fi
- 编写 udev 规则文件 /etc/udev/rules.d/11-media-automount.rules
# 匹配 USB U盘与 SD/TF 卡的分区节点 (如 sda1, mmcblk0p1)
KERNEL=="sd[a-z][0-9]*|mmcblk[0-9]p[0-9]*", SUBSYSTEM=="block", ACTION=="add", RUN+="/usr/local/bin/automount.sh add %k"
KERNEL=="sd[a-z][0-9]*|mmcblk[0-9]p[0-9]*", SUBSYSTEM=="block", ACTION=="remove", RUN+="/usr/local/bin/automount.sh remove %k"
udevadm control --reload-rules
方案 2:基于 mdev 实现自动挂载(BusyBox 精简嵌入式)
在资源受限的嵌入式 Linux 系统中,BusyBox 提供的 mdev 是 udev 的精简替代品。
- 1.系统初始化挂载(通常位于 /etc/init.d/rcS):
mount -t sysfs filesystem /sys
mount -t tmpfs mdev /dev
# 注册 mdev 为内核 uevent_helper(或启动 mdev -d 守护进程)
echo /sbin/mdev > /sys/kernel/uevent_helper
mdev -s
# 匹配 SD/TF 卡分区 (如 mmcblk0p1)
mmcblk[0-9]p[0-9]* 0:0660 */etc/mdev/automount.sh
# 匹配 U盘分区 (如 sda1)
sd[a-z][0-9]* 0:0660 */etc/mdev/automount.sh
- 3.编写 /etc/mdev/automount.sh
#!/bin/sh
MNT_BASE="/media"
MNT_DIR="${MNT_BASE}/${MDEV}"
case"${ACTION}" in
add|"")
mkdir -p "${MNT_DIR}"
mount -o sync /dev/${MDEV} "${MNT_DIR}"
;;
remove)
umount -l "${MNT_DIR}"
rm -rf "${MNT_DIR}"
;;
esac
完整例子
一、 用户态实现:监听 Netlink 并在 add / remove 时动态管理节点
该程序通过 Socket 接收 Netlink 广播,解析 ACTION、SUBSYSTEM、MAJOR、MINOR 及 DEVNAME,在收到 add 时调用 mknod() 创建节点,收到 remove 时调用 unlink() 删除节点。
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/socket.h>
#include<sys/stat.h>
#include<sys/sysmacros.h>
#include<sys/types.h>
#include<linux/netlink.h>
#define UEVENT_BUF_SIZE 2048
/* 解析并处理环境变量 */
voidprocess_uevent(char *buf, int len){
char action[32] = {0};
char subsystem[32] = {0};
char devname[64] = {0};
int major = -1, minor = -1;
/* uevent 数据包以 \0 分隔各个环境变量字符串 */
char *s = buf;
while (s < buf + len) {
if (strncmp(s, "ACTION=", 7) == 0)
strncpy(action, s + 7, sizeof(action) - 1);
elseif (strncmp(s, "SUBSYSTEM=", 10) == 0)
strncpy(subsystem, s + 10, sizeof(subsystem) - 1);
elseif (strncmp(s, "DEVNAME=", 8) == 0)
strncpy(devname, s + 8, sizeof(devname) - 1);
elseif (strncmp(s, "MAJOR=", 6) == 0)
major = atoi(s + 6);
elseif (strncmp(s, "MINOR=", 5) == 0)
minor = atoi(s + 5);
s += strlen(s) + 1;
}
/* 仅处理块设备 (SUBSYSTEM=block) 且设备名有效的事件 */
if (strcmp(subsystem, "block") != 0 || strlen(devname) == 0)
return;
char node_path[128];
snprintf(node_path, sizeof(node_path), "/dev/%s", devname);
/* 1. 处理 ADD 事件:根据主次设备号创建设备节点 */
if (strcmp(action, "add") == 0 && major >= 0 && minor >= 0) {
dev_t dev = makedev(major, minor);
// S_IFBLK 代表块设备节点
if (mknod(node_path, S_IFBLK | 0660, dev) == 0) {
printf("[HOTPLUG ADD] 节点创建成功: %s (MAJOR=%d, MINOR=%d)\n", node_path, major, minor);
} else {
perror("[HOTPLUG ADD] mknod 失败");
}
}
/* 2. 处理 REMOVE 事件:删除对应的设备节点 */
elseif (strcmp(action, "remove") == 0) {
if (unlink(node_path) == 0) {
printf("[HOTPLUG REMOVE] 节点移除成功: %s\n", node_path);
} else {
perror("[HOTPLUG REMOVE] unlink 失败");
}
}
}
intmain(void){
int fd = socket(AF_NETLINK, SOCK_RAW, NETLINK_KOBJECT_UEVENT);
if (fd < 0) {
perror("socket");
return1;
}
structsockaddr_nlsa;
memset(&sa, 0, sizeof(sa));
sa.nl_family = AF_NETLINK;
sa.nl_groups = 1; /* 订阅多播组 1 */
if (bind(fd, (struct sockaddr *)&sa, sizeof(sa)) < 0) {
perror("bind");
close(fd);
return1;
}
printf("开始监听 Linux 热插拔事件...\n");
char buf[UEVENT_BUF_SIZE];
while (1) {
int len = recv(fd, buf, sizeof(buf) - 1, 0);
if (len > 0) {
buf[len] = '\0';
process_uevent(buf, len);
}
}
close(fd);
return0;
}
二、 内核态实现:驱动调用 kobject_uevent_env 发送自定义热插拔事件如果在编写内核驱动时需要主动向用户态推送热插拔消息,可以在模块加载与卸载时通过 kobject_uevent_env() 显式构建环境变量并发送:
#include<linux/module.h>
#include<linux/init.h>
#include<linux/kobject.h>
MODULE_LICENSE("GPL");
staticstructkobject *my_kobj;
staticint __init my_dummy_init(void)
{
/* 1. 附加自定义环境变量数组 (以 NULL 结尾) */
char *envp[] = {
"SUBSYSTEM=block",
"DEVNAME=my_dummy_dev1",
"MAJOR=240",
"MINOR=0",
NULL
};
/* 2. 在 /sys/kernel/ 下创建一个 kobject 节点 */
my_kobj = kobject_create_and_add("my_dummy_device", kernel_kobj);
if (!my_kobj)
return -ENOMEM;
/* 3. 发送 KOBJ_ADD 事件,附带自定义环境变量 */
kobject_uevent_env(my_kobj, KOBJ_ADD, envp);
pr_info("my_dummy_driver: Sent KOBJ_ADD uevent\n");
return0;
}
staticvoid __exit my_dummy_exit(void)
{
char *envp[] = {
"SUBSYSTEM=block",
"DEVNAME=my_dummy_dev1",
"MAJOR=240",
"MINOR=0",
NULL
};
/* 4. 发送 KOBJ_REMOVE 事件 */
kobject_uevent_env(my_kobj, KOBJ_REMOVE, envp);
pr_info("my_dummy_driver: Sent KOBJ_REMOVE uevent\n");
kobject_put(my_kobj);
}
module_init(my_dummy_init);
module_exit(my_dummy_exit);
运行效果
1. 启动用户态监听程序
以 root 权限运行编译好的用户态程序(mknod 操作需要 root 权限):
sudo ./uevent_listener
终端 1(监听程序输出):
开始监听 Linux 热插拔事件...
2. 加载内核模块(模拟硬件插入 / add 事件)
打开另一个终端,加载自定义内核模块:
sudo insmod my_dummy_driver.ko
终端 1 立即捕获到 add 事件并输出
[HOTPLUG ADD] 节点创建成功: /dev/my_dummy_dev1 (MAJOR=240, MINOR=0)
在终端 2 验证设备节点生成:
$ ls -l /dev/my_dummy_dev1
brw-rw---- 1 root root 240, 0 8月 19 15:00 /dev/my_dummy_dev1
说明:已在 /dev 目录下成功创建一个主设备号 240、次设备号 0 的块设备文件。
3. 卸载内核模块(模拟硬件拔出 / remove 事件)
在终端 2 卸载内核模块:
sudo rmmod my_dummy_driver
终端 1 立即捕获到 remove 事件并输出:
[HOTPLUG REMOVE] 节点移除成功: /dev/my_dummy_dev1
在终端 2 验证设备节点已被清理:
$ ls -l /dev/my_dummy_dev1
ls: cannot access '/dev/my_dummy_dev1': No such file or directory
实际插入物理 U 盘时的效果
如果保持 uevent_listener 在后台运行,当向电脑插入一个实际的 U 盘(如包含第 1 个分区 sda1)时,终端 1 会捕获真实硬件的广播并输出:
[HOTPLUG ADD] 节点创建成功: /dev/sda1 (MAJOR=8, MINOR=1)
当拔掉 U 盘时,终端 1 会输出:
[HOTPLUG REMOVE] 节点移除成功: /dev/sda1
