/dev 下一个设备节点背后是一整套对象体系:kobject,kset,bus,device,driver,它们把内核里成千上万个设备组织成一棵树,顺便解决了热插拔通知,引用计数,设备驱动配对这些脏活.这篇写一个完整设备从代码里"生"出来,看着它在 sysfs,devtmpfs,uevent 里留下全套痕迹,再解剖板上一个真实串口.
这篇回答几件事:
- class,cdev,device_create 这些调用各干了什么,谁负责建 /dev 节点
- uevent 是什么,udev 怎么知道设备来了
- sysfs 属性文件怎么写出来
- 设备和驱动怎么配对,总线在中间干什么
- DT 里的 compatible 怎么变成一次成功的绑定
先造设备.核心代码十几行,一个字符设备从无到有:
alloc_chrdev_region(&devt, 0, 1, "kblog"); /* 要一个主设备号 */
cdev_init(&kblog_cdev, &kblog_fops);
cdev_add(&kblog_cdev, devt, 1); /* 注册字符设备操作集 */
kblog_cls = class_create("kblog"); /* 建类 /sys/class/kblog */
device_create(kblog_cls, NULL, devt, NULL, "kblog0"); /* 造设备 */
四步各有分工.alloc_chrdev_region 申请设备号,这里没写死 major,内核动态分配,实测给了 242.cdev_add 把 file_operations 挂进内核的字符设备表,从此 open("/dev/kblog0") 能找到你的函数.class_create 建的是"类别",纯组织用,/sys/class/kblog 这个目录.真正的暴击是最后一步 device_create,它一口气干了三件事:在 sysfs 里建设备目录,让 devtmpfs 建出 /dev/kblog0 节点,向全系统广播 uevent.实测 insmod 之后:
crw------- 1 root root 242, 0 /dev/kblog0
节点已经在了.这里有个常见误解要掰正:很多人以为是 udev 建的 /dev 节点 — 现在不是了.devtmpfs 在内核里,device_register 时节点就出来,udev 存在的意义是改权限,建符号链接,跑规则,而不是从零造节点.所以哪怕板子上没有任何用户态管理器,/dev/kblog0 照样出现,这在 initramfs 阶段调试驱动时特别有用.
uevent 那一路用 udevadm monitor --kernel 实时抓,一次 insmod 三条:
KERNEL[593.485402] add /class/kblog (class)
KERNEL[593.485533] add /devices/virtual/kblog/kblog0 (kblog)
KERNEL[593.485925] add /module/devmod (module)
类出现,设备出现,模块出现,各报一次到.uevent 是内核到用户态的广播,本质是个 netlink 消息,带着 DEVNAME,MAJOR,MINOR 这些键值对,udev 收到后按规则行事.热插拔世界的"内核说,用户态听",说的就是它.
sysfs 这边,设备的目录里天生带着 dev,uevent,power,subsystem,想加自己的就用 DEVICE_ATTR 宏加一个属性:
static ssize_t level_show(...) { return sprintf(buf, "%dn", opens); }
static ssize_t level_store(...) { ... }
static DEVICE_ATTR_RW(level);
device_create_file(kblog_dev, &dev_attr_level);
cat 一下走 show,echo 一下走 store,实测 echo 7 > level,dmesg 里 store 函数的打印原样出现.注意这个宏变出的是 struct device_attribute,6.18 里 device_create_file 收的就是它,别手滑传裸 attribute 指针,类型直接拒你(我就被拒了一次).属性文件是驱动和用户态最便宜的交互通道,比 ioctl 省 binder...不对,省一个文件描述符和一堆命令号设计,简单状态查询首选它.
引用计数的长相也实地看了.file_operations 里一句 .owner = THIS_MODULE,open 这个设备时内核自动 try_module_get.实测:没人的时候 /sys/module/devmod/refcnt 是 0,一个程序 open 住不放,变 1;此时 rmmod:
rmmod: ERROR: Module devmod is in use
上一篇说引用计数是"用着就不能拔",这里的"用着"就是用户手里那个 open 着的 fd.程序一死,release 里 module_put,计数归零,rmmod 畅通,同时 /sys/class/kblog 和 /dev/kblog0 一起消失 — 卸载路径的 device_destroy 同样会广播 remove uevent,正反两个方向都是对称的.这一套保护看着烦,但它防的是最恶性的那类事故:驱动代码还在被调用,内存已经还给系统.
人造设备看完了,解剖一个真的.板上控制台的串口,设备树里:
serial@ff1a0000 {
compatible = "rockchip,rk3399-uart", "snps,dw-apb-uart";
...
};
sysfs 里它活了: /sys/devices/platform/ff1a0000.serial,这就是设备模型一侧的实体 — platform 总线把 DT 节点展开成 platform device(启动篇 initcall 榜单里那个 of_platform_default_populate_init 干的活).它和驱动的配对关系在三个地方都能看,组成一个三角:
/sys/devices/platform/ff1a0000.serial/driver -> ../../../bus/platform/drivers/dw-apb-uart
/sys/bus/platform/devices/ff1a0000.serial -> ../../../devices/platform/ff1a0000.serial
/sys/bus/platform/drivers/dw-apb-uart/ 里列着 ff180000.serial ff1a0000.serial ...
设备目录下有 driver 软链,总线目录下 devices 和 drivers 两边各挂链接互相可达.顺带注意驱动目录里有 bind 和 unbind 两个写文件 — 手动绑定旋钮,排试驱动或者玩设备重新绑定时会用到,echo 设备名进 unbind 能当场解绑一个活设备.
配对的规则看 compatible.DT 节点给了两个字符串,这是一份按优先级排列的候选清单:先试 "rockchip,rk3399-uart",没有驱动认;再试 "snps,dw-apb-uart",8250_dw 驱动的 of_match_table 里正好有这一条(drivers/tty/serial/8250/8250_dw.c),命中.这种"厂商串在前,通用串兜底"的写法是 DT 的惯用姿势:专用驱动优先接管,没有也能退回通用实现.RK3399 的串口就这么被通用 dw-apb-uart 驱动收编的,一个驱动服务一整族 SoC.
最后留几个容易忽略的细节:
- device_create 的第四个参数是传给驱动的私有数据 dev_get_drvdata 能取回,probe 里拿资源指针全靠它,别自己另开全局变量存.
- class_create 在 6.4 之后签名瘦成只收名字,老教程里两个参数的写法编译不过,抄代码先看内核版本.
- uevent 环境变量里 MODALIAS 很关键,模块自动加载(kmod)靠它反查该 insmod 谁,冷插 USB 设备自动出驱动就是这条链.
- sysfs 属性一个文件一个值,想一次导出结构化数据就 device_create_bin_attribute,二进制属性文件.
- bus 也是一种对象,自己注册新总线用 bus_register,协议栈之外很多子系统(比如一些工业总线)真这么干.
- unbind 一个正被 console 占着的串口,系统会立刻哑掉,这是能做但别在远程机器上做的事.
- /sys/class 是按功能分类的视图,/sys/devices 才是设备的真身所在,前者的文件几乎都是软链 — 写工具遍历 sysfs 时认准 devices 树,别被视图骗.
- platform 总线是"没有更好总线"的兜底,DT 里大多数外设都落这;PCI,USB,MMC 各有自己的 bus 实例,三角结构一模一样,学会一个等于学会全部.