关注 Linux 驱动开发的朋友,应该都绕不过“字符设备”这个坎。字符设备驱动是驱动开发里最基础也最高频的类型,从按键、触摸屏到传感器,底层基本都是这套框架。而我写这篇文章的原因很直接:不少刚入门的开发者,写完 hello world 之后,一到写正经驱动就卡住了——设备号怎么申请?cdev 是个什么东西?为什么 /dev 目录下的节点有时候会自动出现、有时候又要手动 mknod?网上教程有的用 register_chrdev,有的用 cdev_add,到底该用哪个?
这篇文章就围绕“字符设备注册和设备号”这一整套机制展开,把设备号分配、cdev 注册、设备节点创建、常见坑位一次讲清楚。不管你是在校学生、往驱动方向转行的嵌入式工程师,还是做内核模块开发的初学者,看完应该都能上手写一个完整的字符设备驱动,并且知道自己每一步在做什么、为什么这样做。
1. 先搞懂设备号:数字背后的三段命名规则
1.1 dev_t 到底是什么
设备号在 Linux 内核里本质就是一个数字,类型是 dev_t。这个东西可以理解为设备的“身份证号”,用户在应用层操作设备、内核定位驱动程序,靠的就是这个号码。
dev_t 实际上是一个 32 位的无符号整数,内核在include/linux/kdev_t.h中把它拆成了两段:高 12 位是主设备号,范围是 0~4095;低 20 位是次设备号,范围是 0~1048575。这个划分比例是内核定死的,写驱动的时候不需要自己拆分,直接调用内核提供的宏就行:
MAJOR(dev_t dev) // 得到主设备号 MINOR(dev_t dev) // 得到次设备号 MKDEV(int major, int minor) // 用主次设备号拼出一个 dev_t还有一个细节很多人会忽略:dev_t 不一定非得是 32 位,但因为内核目前默认的设备号编码就用 12/20 划分,所以 99% 的场景下你只需要按这个规则来理解就够用了。
1.2 主设备号和次设备号怎么分工
用一个生活例子来理解:主设备号就像一个小区地址,次设备号就是楼栋和单元号。所有同类型的设备共享同一个主设备号,内核根据主设备号找到对应的驱动程序,再根据次设备号区分同类型下的不同实例。
举个例子,一块开发板上可能有多个同型号的串口外设,你给它们分配同一个主设备号,但次设备号分别用 0、1、2。应用层打开/dev/serial0和/dev/serial1时,内核会拿着设备号去查 cdev 列表,找到对应的 file_operations,然后调用 open、read、write 等函数。cdev 就像一个登记簿,把设备号和操作函数绑定在一起,内核用它来做“按号找人”。
1.3 动态申请还是静态指定
设备号不是你想用哪个就用哪个,得向内核申请才能使用。内核提供两个接口:
int alloc_chrdev_region(dev_t *dev, unsigned int firstminor, unsigned int count, const char *name); int register_chrdev_region(dev_t from, unsigned int count, const char *name);alloc_chrdev_region 让内核从空闲的主设备号里挑一个给你,好处是永远不会冲突,缺点是每次加载模块可能拿到不同的主设备号。register_chrdev_region 则是你自己指定从哪个设备号开始,适合设备号已经固定好的场景。
我个人在实际项目里更推荐动态申请。为什么?因为主设备号本身没有固定标准,没人会保证你指定的号码没被别的驱动占用。动态分配虽然不固定,但通过cat /proc/devices随时能查到分配结果,配合自动创建设备节点的机制,应用层根本不需要硬编码设备号。当然也有例外:如果你的驱动要写在设备树里、要在系统启动早期使用,或者有多个内核模块共享同一个主设备号,那静态指定就更有必要。
2. 注册字符设备的两种姿势
2.1 老式接口 register_chrdev 的成与败
早期 Linux 内核注册字符设备用的是 register_chrdev 接口。这个接口很原始:
int register_chrdev(unsigned int major, const char *name, const struct file_operations *fops);如果传入的 major 是 0,内核会动态分配一个主设备号并返回。听起来挺方便,但问题也出在这:register_chrdev 一次性帮你把主设备号对应的 256 个次设备号全占用了,也就是一个主设备号下最多支持 256 个设备实例。对于多数简单驱动这没问题,但如果你需要更多次设备号、或者想更精细地控制设备号分配范围,这条路就走窄了。
更重要的是,register_chrdev 注册完了之后,/dev 下并不会有任何设备节点。你得手动执行mknod /dev/xxx c major 0去创建节点,而且卸载驱动时节点不会被自动清理。这在嵌入式开发里非常反人类,尤其是系统移植和测试阶段,手动管理节点很容易出错。
2.2 现代标准姿势:cdev + cdev_add
现代内核驱动开发的标准流程是:先申请设备号,再初始化 cdev 结构体,然后把 cdev 加进内核。cdev 结构体拿大白话说就是一个“设备登记卡”,里面存了 file_operations 指针、owner 等关键信息。
cdev_init(struct cdev *cdev, const struct file_operations *fops); int cdev_add(struct cdev *cdev, dev_t dev, unsigned int count);cdev_init 负责把结构体填上操作函数,cdev_add 负责把设备号和操作函数注册到内核全局链表里。cdev_add 成功之后,这个设备就“接入”了内核,应用层打开对应设备节点时,VFS 能顺着设备号找到你的操作函数。
注意 cdev_init 和 cdev_add 是两个动作,不能合并。cdev_init 只是初始化内存里的结构体,内核还没感知到这个设备;cdev_add 才把设备真正连接进内核。模块加载时顺序不能乱,卸载时也别忘了 cdev_del 清理。
2.3 两条路线怎么选
可以肯定地说,新代码里基本不要再碰 register_chrdev 了。除了太古老的驱动或者子系统,绝大多数场景下现代驱动开发标准流程更灵活、更可控。我整理了一个对比表帮助理解:
| 维度 | register_chrdev | cdev_init + cdev_add |
|---|---|---|
| 次设备号控制 | 直接占用全部 256 个 | 可精确控制数量 |
| 与 class/device 配合 | 不好配合 | 天然支持自动节点 |
| 卸载清理 | 只注销设备,节点残留 | 配合 device_destroy 完整清理 |
| 推荐程度 | 仅理解历史代码用 | 新驱动首选 |
选择的关键不是“哪个简单用哪个”,而是看你的设备要不要支持多个实例、要不要自动创建设备节点、要不要动态管理资源。现代标准流程把设备号数量、设备节点生命周期都控制得明明白白,这也是内核社区一直推动的方向。
3. 手把手实现一个完整的字符设备驱动
3.1 需求与设计
下面我写一个完整可用的字符设备驱动,目标是:加载模块后自动在 /dev 下生成一个设备节点,应用层通过 write 写入数据、通过 read 读出内核里的固定字符串。这个需求覆盖了设备号申请、cdev 注册、class/device 创建设备节点、卸载清理的全部流程,虽然简单但五脏俱全。
完整代码放在一起太长容易看晕,我分段注释讲解。先看驱动主体:
#include <linux/module.h> #include <linux/kernel.h> #include <linux/cdev.h> #include <linux/fs.h> #include <linux/device.h> #include <linux/uaccess.h> #include <linux/slab.h> #define DEVICE_NAME "demo_dev" #define CLASS_NAME "demo_class" static dev_t dev_num; static struct cdev demo_cdev; static struct class *demo_class; static struct device *demo_device;头文件是驱动开发的“工具箱”,其中linux/cdev.h提供 cdev 相关接口,linux/device.h提供 class 和 device 的创建销毁函数,linux/uaccess.h提供 copy_to_user / copy_from_user 用于内核态和用户态的数据拷贝。
3.2 设备号申请与 cdev 注册代码解析
接着是 file_operations 操作集和模块加载卸载函数。我在其中写了 open、release、read、write 四个回调,read 返回一段固定字符串,write 只打印接收到的数据:
static int demo_open(struct inode *inode, struct file *filp) { pr_info("demo: open called\n"); return 0; } static int demo_release(struct inode *inode, struct file *filp) { pr_info("demo: release called\n"); return 0; } static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { char *msg = "hello from kernel!\n"; size_t len = strlen(msg); if (*offset >= len) return 0; if (count > len - *offset) count = len - *offset; if (copy_to_user(buf, msg + *offset, count)) return -EFAULT; *offset += count; return count; } static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset) { char *kbuf; kbuf = kzalloc(count + 1, GFP_KERNEL); if (!kbuf) return -ENOMEM; if (copy_from_user(kbuf, buf, count)) { kfree(kbuf); return -EFAULT; } kbuf[count] = '\0'; pr_info("demo: write %zu bytes: %s\n", count, kbuf); kfree(kbuf); return count; } static struct file_operations demo_fops = { .owner = THIS_MODULE, .open = demo_open, .release = demo_release, .read = demo_read, .write = demo_write, };read 回调里用了一个offset偏移量参数,这是 character 设备实现“读到 EOF”的惯例:当 offset 超过字符串长度就返回 0,表示读完了。不要在这里一直返回相同的字符串长度,否则应用层cat会无限循环读数据(亲身踩过,终端疯狂输出,只能 Ctrl+C)。copy_to_user 的返回值也要检查,失败要返回 -EFAULT,这是内核向用户空间拷贝数据的标准做法。
然后是模块加载函数。这个函数是整套流程的核心,我每一行封装一个步骤:
static int __init demo_init(void) { int ret; // 第一步:动态申请一个设备号 ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME); if (ret < 0) { pr_err("alloc_chrdev_region failed: %d\n", ret); return ret; } pr_info("device number: major=%d minor=%d\n", MAJOR(dev_num), MINOR(dev_num)); // 第二步:初始化 cdev 结构体 cdev_init(&demo_cdev, &demo_fops); demo_cdev.owner = THIS_MODULE; // 第三步:将 cdev 注册到内核 ret = cdev_add(&demo_cdev, dev_num, 1); if (ret < 0) { pr_err("cdev_add failed: %d\n", ret); goto fail_cdev_add; } // 第四步:创建设备类 demo_class = class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(demo_class)) { ret = PTR_ERR(demo_class); pr_err("class_create failed: %d\n", ret); goto fail_class; } // 第五步:创建设备节点 demo_device = device_create(demo_class, NULL, dev_num, NULL, DEVICE_NAME); if (IS_ERR(demo_device)) { ret = PTR_ERR(demo_device); pr_err("device_create failed: %d\n", ret); goto fail_device; } pr_info("demo driver loaded successfully\n"); return 0; fail_device: class_destroy(demo_class); fail_class: cdev_del(&demo_cdev); fail_cdev_add: unregister_chrdev_region(dev_num, 1); return ret; }这个加载函数里每一步都做了错误判断,一旦失败就跳转清理。这种错误处理模式在驱动开发里极其重要——你不会希望一个注册了一半的驱动残留在内核里。有一点我想特别提醒:goto不是坏味道,而是内核代码里最常见的错误清理模式,顺序从后往前逐个释放资源,保证没有内存泄漏。
卸载函数是加载函数的“镜像操作”:
static void __exit demo_exit(void) { device_destroy(demo_class, dev_num); class_destroy(demo_class); cdev_del(&demo_cdev); unregister_chrdev_region(dev_num, 1); pr_info("demo driver removed\n"); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Demo Author"); MODULE_DESCRIPTION("A simple character device registration example"); MODULE_VERSION("1.0");卸载顺序有讲究:先销毁设备节点,再销毁类,再删除 cdev,最后释放设备号。顺序反了可能在中间状态留下僵尸设备节点。cdev_del 是删除设备注册,但释放设备号则是另一个动作,必须配合 unregister_chrdev_region。
3.3 自动创建设备节点的机制说明
很多人第一次看到 class_create 和 device_create 会懵:这两个函数和设备号有什么关系?为什么调用完 /dev 下就自动出现了设备节点?
实际上内核根本不直接管 /dev 目录。device_create 做的事情是在 /sys/class/demo_class 下创建一个设备目录,并通过内核的 uevent 机制向用户空间发送一个“新设备加入”的事件。用户空间里跑着 udev(systemd 环境)或 mdev(busybox 环境),它们监听到事件后,会根据 sysfs 里的信息在 /dev 下创建设备节点。
这就要求你的内核配置开了 CONFIG_DEVTMPFS 和 CONFIG_DEVTMPFS_MOUNT,并且文件系统里有 devtmpfs 挂载在 /dev。绝大多数桌面版和嵌入式板卡系统都默认配置好了,但某些裁剪特别狠的内核可能关掉了,导致设备节点迟迟不出现。碰到这种情况,先检查系统配置再怀疑代码。
项目起初不想依赖 udev 的话,也可以手动创建节点:
sudo mknod /dev/demo_dev c 240 0 sudo chmod 666 /dev/demo_devmknod 后面的 240 是主设备号,0 是次设备号。手动创建的节点在系统重启后不保留,这就是为什么自动化创建节点还是更省心。
3.4 Makefile 与测试验证
驱动模块编译需要一个 Makefile,核心逻辑是把当前目录注册进内核的模块构建系统:
obj-m := demo_drv.o KERNELDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean需要提前安装内核头文件包(Ubuntu/Debian 上是 linux-headers-$(uname -r)),然后在源码目录执行:
make sudo insmod demo_drv.ko模块加载后,用 dmesg 查看日志确认设备号,然后检查设备节点:
dmesg | tail -5 ls -l /dev/demo_dev cat /proc/devices | grep demo再写一个简单的用户态测试程序验证读写:
#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <string.h> int main(void) { int fd; char buf[128]; fd = open("/dev/demo_dev", O_RDWR); if (fd < 0) { perror("open"); return -1; } read(fd, buf, sizeof(buf)); printf("read from device: %s", buf); write(fd, "hello kernel", strlen("hello kernel")); close(fd); return 0; }编译运行:
gcc test.c -o test ./test如果一切正常,控制台会先打印 read 到的字符串,dmesg 里能看到 write 打印的内容。最后卸载模块,再确认 /dev/demo_dev 被正确清掉:
sudo rmmod demo_drv ls -l /dev/demo_dev # 预期是 No such file or directory4. 常见问题与排查技巧
4.1 加载/卸载阶段的问题
先说一个最常见的:insmod提示Invalid argument(-EINVAL)。大多数人第一反应是代码写错了,但如果你用的设备号已经被其他驱动占用,或者 cdev_add 的参数有问题,也会得到这个错误。排查方向有两个:先用cat /proc/devices看看申请到的主设备号是否真的空闲,再用dmesg看内核打印的具体错误码。
我实际碰到过一次:调试的板子上有个遗留驱动占用了动态设备号,结果我的模块加载时申请到的主设备号跟它冲突,insmod 直接失败。当时排查了很久,最后发现是之前测试时残留了一个旧模块没卸载干净。这种情况lsmod一眼就能看出来,但rmmod提示Device or resource busy,说明有进程打开着设备节点。最土的办法是找到那个进程杀掉,或者等它退出。这也提醒我们,开发调试阶段要养成写测试程序时 fcntl 打开后及时 close 的习惯,别在代码里把 fd 当全局变量一直留着。
4.2 设备节点不生成的排查
模块加载成功、dmesg 也打印了加载日志,但 /dev 下就是没有 demo_dev。这个问题的排查路径基本是三层:
第一,确认内核有没有 devtmpfs 支持。mount | grep devtmpfs能挂上才算正常。第二,确认用户空间有没有动态设备管理程序。桌面系统查 udevadm,嵌入式环境查 mdev。第三,确认 /sys/class/demo_class 下有没有对应的设备目录。如果 sysfs 里目录存在但 /dev 里没有节点,问题大概率在 udev 规则或 devtmpfs 挂载上。
还有一个容易被忽略的细节:device_create 传入的设备名不能和已有设备节点重名,否则创建会静默失败。设备名里最好直接体现设备功能,不要用太通用的名字,否则很容易触发这种“看起来一切正常”的坑。
4.3 应用层操作失败的坑
测试程序 open 设备节点时返回No such device or address(ENXIO),通常意味着设备节点存在,但 cdev_add 没有被正确执行。常见原因是设备注册的 dev_t 和创建设备节点的 dev_t 不一致,比如申请设备号用了 0 号次设备,但 device_create 用了另一个次设备号拼出来的 dev_t,结果目录里看似有设备,实际内核 cdev 链表里找不到对应的号码。
应用层写入时报Operation not permitted,99% 是权限问题。设备节点默认归 root 所有,如果不想每次测试都 sudo,可以先执行sudo chmod 666 /dev/demo_dev。但更内核的做法是在 udev 规则里加一个规则,让节点创建时自动调整权限。比如在/etc/udev/rules.d/99-demo.rules里写入:
KERNEL=="demo_dev", MODE="0666"这个 udev 规则会在设备节点创建时把它设为 666 权限,开发调试阶段省掉很多环境问题。
另外一个很隐蔽的坑是 open 返回 0。cdev_add 成功但 file_operations 里没写 open 回调,内核会用默认行为处理,此时 open 依然能成功返回,但后续 read/write 会返回错误码。所以测试程序不要太早下结论“驱动正常”,至少要把 read/write 两条路径都真实验证一遍。
4.4 主设备号不固定带来的适配问题
动态申请的好处上面说了,但也有代价:每次 insmod 同一个模块,主设备号都可能变化。如果你有应用程序要把 /dev/demo_dev 对应的设备号写死在配置文件里,就会踩坑。正确的做法是让应用层通过设备节点路径直接操作,不要关心主设备号到底是多少;如果确实需要知道设备号,可以用 ioctl 或者通过 sysfs 接口读。
还有一种场景需要指定设备号:嵌入式系统中设备树节点里绑定了设备号,驱动加载时必须用 register_chrdev_region 申请固定的号码。这时候建议先用cat /proc/devices确认没有冲突,再选一个空闲的号。
最后留一句实际操作层面的经验:驱动模块开发的调试周期里,不要总靠重启系统恢复状态。insmod 失败、rmmod 失败、设备节点残留、设备号混乱,这些都是可以用命令现场排查的。把 /proc/devices、/sys/class、/dev 这三位一体的状态理解了,字符设备和设备号的机制基本就掌握了一大半。写驱动时心里要想着“我到底在内核里登记了什么,设备节点又是从哪冒出来的”,带着这条主线去学,后续再接触平台设备、设备树、中断子系统时也会有底气得多。