资讯动态

新版Linux LED驱动实验:设备号分配与cdev结构体全流程解析

发布时间:2026/10/18 16:31:50 来源:尧图企业网站定制
做 ARM Linux 驱动绕不开一个经典实验LED。这些年在网上能翻到的教程一大半还在用老版register_chrdevmknod那套写法放到 Ubuntu20.04 的新内核上要么编译报错要么加载后行为不对劲。标题里提到的“新版 LED 驱动实验原理”和“设备号分配 cdev 结构体全流程”其实点出了新旧两套框架最本质的差异新版把一个黑盒拆成了几个显式步骤每一步都让你自己控制、自己释放。这篇文章就照着 Ubuntu20.04 环境把从设备号申请到 cdev 注册、再到/dev/led节点自动出现的完整链路走一遍顺带把我在调试中踩过的坑填给你适合刚从 2.6/3.x 老代码切过来、或者准备正经写字符设备驱动的开发者参考。1. 从“一键打包”到“全流程拆解”新版 LED 驱动实验的框架差异1.1 老版 register_chrdev 到底封装了什么早期驱动里最常见的一段代码是这样的ret register_chrdev(major, led, led_fops);如果major传 0内核会替你动态分配一个主设备号如果传一个固定值那就把该主设备号占下来。看着省事但这个函数内部做的事情远比表面多它会申请一段设备号区间、初始化一个cdev结构体、把file_operations挂上去并且一下就占满 256 个次设备号。问题也随之而来。第一你无法精细管理次设备号比如一个驱动要控制 8 个 LED你想让每个 LED 各占一个次设备号老接口根本表达不出这种意图它默认把所有次设备号都包圆了。第二主设备号冲突极难排查内核只在申请失败时返回一个错误码不会告诉你是谁占用了这个号你得自己去翻/proc/devices。第三也是最让新手难受的一点设备节点不会自己出现。老教程会教你在加载后用mknod /dev/led c major 0手动建节点而现代发行版的/dev由 devtmpfs/udev 管理手动mknod出来的节点在系统重启后就会消失权限也经常不对。这并不是说register_chrdev不能用了它在 5.x 内核里依然作为兼容接口存在但新代码再走这条路等于放弃了内核提供的大量资源管理能力。新版驱动把流程拆成我们下面要讲的“设备号分配 → cdev 注册 → 设备节点创建”三段让每一步都可控、可回滚、可排查。1.2 新版全流程每个环节各管一摊新版 LED 驱动在module_init里要按顺序做四件事分配设备号alloc_chrdev_region或register_chrdev_region初始化并添加cdevcdev_initcdev_add创建 classclass_create创建设备节点device_create。对应在module_exit里清理顺序严格反着来先销毁设备节点再销毁 class然后移除 cdev最后释放设备号。我经常跟新人讲这段顺序不是随便定的它对应的是资源依赖关系device_create依赖 class 存在cdev 依赖设备号存在所以你销毁的时候必须从最上层开始一层层拆先断掉用户可见的入口再回收内核资源。我用一个生活类比来理解这套模型设备号是房子的门牌号cdev是房子里摆好的办公桌file_operations是桌上贴着的工作流程说明而/dev/led这个节点则是门口挂的招牌。没有门牌号办公桌不知道挂靠在哪没有办公桌客人来了没人接待没有招牌用户根本不知道有这个房间。新版驱动把这三件事一步步做每一步失败都能立刻定位到是哪块出了问题。2. 设备号分配alloc_chrdev_region 和 register_chrdev_region 怎么选2.1 dev_t 与 MKDEV/MAJOR/MINOR 的编码规则设备号在内核里是一个dev_t类型本质是个 32 位无符号整数。高 12 位是主设备号低 20 位是次设备号。主设备号用来区分设备类型和对应的驱动次设备号用来区分同一驱动下的多个实例。直接位运算容易出错内核提供了现成宏#include linux/kdev_t.h dev_t devno MKDEV(240, 0); // 主设备号 240次设备号 0 int major MAJOR(devno); // 取出主设备号 int minor MINOR(devno); // 取出次设备号主设备号在 5.x 内核里可用于注册的范围大约到 511实际分配时建议别手写固定值除非你有明确理由。次设备号的 20 位空间足够让一个主设备挂几百万个同类型设备所以驱动里通常一个主设备号配合多个次设备号使用。我在调试时候的习惯是先把MAJOR(devno)和MINOR(devno)打出来看确认设备号确实拿到了再进行下一步。这一步出错后面所有工作都白做。2.2 两种分配函数的适用场景设备号分配有两个 API很多新手搞不清区别其实看名字就够直白函数传入参数特点适用场景风险register_chrdev_region(dev_t first, unsigned count, const char *name)需要手动构造first即指定起始设备号调试时固定设备号、产品有明确主设备号约定可能与其他驱动冲突加载失败alloc_chrdev_region(dev_t *dev, unsigned firstminor, unsigned count, const char *name)内核自动分配结果写入指针产品代码、避免手工管理设备号每次加载主设备号可能不同如果你想固定主设备号代码这么写static int major 240; dev_t devno; devno MKDEV(major, 0); ret register_chrdev_region(devno, 1, led); if (ret 0) { pr_err(failed to register chrdev region\n); return ret; }如果让内核动态分配dev_t devno; ret alloc_chrdev_region(devno, 0, 1, led); if (ret 0) { pr_err(failed to alloc chrdev region\n); return ret; } major MAJOR(devno); pr_info(led major %d, minor %d\n, major, MINOR(devno));我在实际项目里强烈推荐第二种原因很现实你永远不知道目标板子上哪些主设备号已经被其他驱动占了。动态分配把“找空号”这件事交给内核冲突概率降到最低。调试阶段为了复现问题可以加一个模块参数允许手动指定主设备号0 表示动态分配。static int major 0; module_param(major, int, 0644); if (major) { devno MKDEV(major, 0); ret register_chrdev_region(devno, 1, led); } else { ret alloc_chrdev_region(devno, 0, 1, led); }这样insmod led.ko默认动态分配insmod led.ko major241则固定为 241调试和产品两不误。2.3 设备号释放与常见冲突排查设备号属于内核全局资源用完了必须还回去。释放函数只有配套的unregister_chrdev_region(devno, count)一个放在module_exit的最后一步执行。我在代码审查时见过不少新手把unregister_chrdev_region放在cdev_del之前当时看不出问题但设备文件还开着的时候内核按设备号找不到对应的cdev系统日志里就会冒出一些奇怪的错误。排查设备号冲突最直接的办法是加载前先看一眼cat /proc/devices这个文件列出当前已经注册的字符设备和块设备的设备号与名称。如果发现目标主设备号已经在列表里那就别硬抢。还有一招是用grep过滤cat /proc/devices | grep led如果日志出现register_chrdev_region: ... already in use之类的提示多半就是主设备号撞车了。3. cdev 结构体如何把 file_operations 挂到设备号上3.1 struct cdev 的关键成员与初始化设备号只是给设备发了一张“身份证”真正让这张身份证生效的是cdev。内核源码里的定义大致如下struct cdev { struct kobject kobj; struct module *owner; const struct file_operations *ops; dev_t dev; unsigned int count; };四个关键成员各有用处kobj让cdev成为内核对象模型的一员可以被引用计数管理owner通常写成THIS_MODULE保证设备正被使用时模块不能被卸载ops挂载文件操作函数集是业务核心dev记录它对应的设备号count表示占用了多少个次设备号。初始化有两种方式。一种是在文件作用域直接放一个struct cdevstatic struct cdev led_cdev; cdev_init(led_cdev, led_fops);另一种先用cdev_alloc()动态分配再cdev_init()。对于 LED 这种单实例驱动我建议用静态方式少一次动态内存管理就少一类内存泄漏隐患多实例场景再用动态方式按需创建。初始化完成后led_cdev.owner THIS_MODULE这行也别忘了否则模块卸载时会出问题。3.2 cdev_add 的时机与错误处理cdev_add是真正把cdev和设备号绑进内核的设备表里的动作ret cdev_add(led_cdev, devno, 1); if (ret 0) { pr_err(failed to add cdev\n); goto err_unregister; }第三个参数是设备数量一般写 1如果申请了多个次设备号就写对应的数量。这里特别提醒一个最容易忽视的点cdev_add一旦成功用户空间立刻就能打开这个设备了所以在这之前必须保证后续所有资源都已经准备好。顺序反了比如先cdev_add再class_create那么成功添加后到 class 创建前这个短暂窗口内已经可以 open 设备但节点还不存在容易让上层应用拿到一个半成品设备。cdev_add失败时要记得把前面分配的设备号释放掉。我最常看到的问题就是失败分支里只return -EINVAL设备号还挂在系统里第二次加载直接撞车。正确的回滚顺序是cdev_del→unregister_chrdev_region一层层退回去。3.3 file_operations 里怎么设计 LED 的操作入口file_operations是驱动与用户空间交互的接口表。LED 实验不需要复杂功能核心就三个open、write、release。以下是新内核推荐的空 open 写法static int led_open(struct inode *inode, struct file *filp) { return 0; }如果有多个 LED可以在open里通过iminor(inode)取出次设备号确定当前操作的是哪一路 LED。write里要接收用户传进来的“开/关”指令static ssize_t led_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { char value; if (copy_from_user(value, buf, 1)) return -EFAULT; if (value 1) led_set_state(1); else if (value 0) led_set_state(0); else return -EINVAL; return 1; }注意这里不能直接用*buf那是用户空间地址内核线程不能直接解引用。copy_from_user返回值为 0 表示拷贝成功返回值非 0 表示还有多少字节没拷完应当返回-EFAULT。这个细节是新手最容易踩的不拷贝直接解引用轻则读到垃圾值重则触发内核 oops。file_operations定义如下static const struct file_operations led_fops { .owner THIS_MODULE, .open led_open, .write led_write, .release led_release, };写const是必须的因为内核只允许注册只读的file_operations表。4. 设备节点从 mknod 到自动出现class_create 与 device_create 的配合4.1 /dev/led 这个节点到底是谁创建的老框架需要mknod手动创建节点但新版驱动在module_init里调用device_create后/dev/led会自动出现。这个“自动”的背后是 udev 在干活device_create会向内核的 sysfs 写入设备信息并触发 ueventudev 守护进程收到事件后根据设备所属的 class 和名字在/dev下创建对应节点。对于嵌入式环境里不用 udev、而是用 busybox 的场景对应的机制是 mdev需要在启动脚本里配置 mdev 的规则。Ubuntu20.04 桌面版和服务器版默认都有 udev所以我们在 PC 上跑模块时基本上不需要额外配置。class_create的作用是给设备分组。设备创建后你会在/sys/class/led/下面看到这个设备这也是用户态程序发现硬件设备的入口之一。可以理解为class是设备在 sysfs 里的“目录名”决定它归到哪个类目下面。4.2 device_create 参数逐项拆解device_create这个函数参数比较多我一个个解释static struct class *led_class; static struct device *led_device; led_class class_create(THIS_MODULE, led); if (IS_ERR(led_class)) { pr_err(failed to create class\n); goto err_cdev_del; } led_device device_create(led_class, NULL, devno, NULL, led); if (IS_ERR(led_device)) { pr_err(failed to create device\n); goto err_class_destroy; }第一个参数led_class是设备归属的 class第二个参数parent通常传 NULL第三个参数devno是这个设备对应的设备号它是设备和cdev挂钩的锚点第四个参数drvdata可以传驱动的私有数据指针LED 实验里不需要最后一个变参是设备节点名字的格式化字符串比如想要led0、led1这种名字就写成device_create(led_class, NULL, devno, NULL, led%d, 0)。要注意class_create和device_create的返回值都是错误指针。判断时不能直接和 NULL 比较要用IS_ERR()和PTR_ERR()if (IS_ERR(led_device)) { ret PTR_ERR(led_device); goto err_class_destroy; }4.3 完整可编译的 LED 驱动代码把前三章的思路串起来就是一个完整的新版 LED 字符设备驱动。我贴一个能直接编译的版本实际硬件控制部分我用一个接口函数代替你换成自己的 GPIO 或寄存器操作即可#include linux/module.h #include linux/kernel.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #define LED_DEV_COUNT 1 static int major 0; module_param(major, int, 0644); MODULE_PARM_DESC(major, led major number, 0 for dynamic alloc); static dev_t led_devno; static struct cdev led_cdev; static struct class *led_class; static struct device *led_device; static void led_set_state(int state) { /* 实际项目中替换为 ioremap 寄存器操作或 gpiod_set_value */ pr_info(led state - %d\n, state); } static ssize_t led_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { char value; if (count 1) return -EINVAL; if (copy_from_user(value, buf, 1)) return -EFAULT; if (value 1) led_set_state(1); else if (value 0) led_set_state(0); else return -EINVAL; return 1; } static int led_open(struct inode *inode, struct file *filp) { return 0; } static int led_release(struct inode *inode, struct file *filp) { return 0; } static const struct file_operations led_fops { .owner THIS_MODULE, .open led_open, .write led_write, .release led_release, }; static int __init led_init(void) { int ret; if (major) { led_devno MKDEV(major, 0); ret register_chrdev_region(led_devno, LED_DEV_COUNT, led); } else { ret alloc_chrdev_region(led_devno, 0, LED_DEV_COUNT, led); } if (ret 0) { pr_err(failed to alloc/register chrdev region\n); return ret; } major MAJOR(led_devno); pr_info(led major %d, minor %d\n, major, MINOR(led_devno)); cdev_init(led_cdev, led_fops); led_cdev.owner THIS_MODULE; ret cdev_add(led_cdev, led_devno, LED_DEV_COUNT); if (ret 0) { pr_err(failed to add cdev\n); goto err_unregister_region; } led_class class_create(THIS_MODULE, led); if (IS_ERR(led_class)) { ret PTR_ERR(led_class); pr_err(failed to create class\n); goto err_cdev_del; } led_device device_create(led_class, NULL, led_devno, NULL, led); if (IS_ERR(led_device)) { ret PTR_ERR(led_device); pr_err(failed to create device\n); goto err_class_destroy; } return 0; err_class_destroy: class_destroy(led_class); err_cdev_del: cdev_del(led_cdev); err_unregister_region: unregister_chrdev_region(led_devno, LED_DEV_COUNT); return ret; } static void __exit led_exit(void) { device_destroy(led_class, led_devno); class_destroy(led_class); cdev_del(led_cdev); unregister_chrdev_region(led_devno, LED_DEV_COUNT); pr_info(led driver removed\n); } module_init(led_init); module_exit(led_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(A Developer); MODULE_DESCRIPTION(New style LED char device driver); MODULE_VERSION(1.0);这个文件保存为led.c和后面的 Makefile 放同一目录。加载后write一个1或0进去日志里会打印led state - 1或led state - 0。换成真实硬件时只要把led_set_state里的占位代码替换成实际的点灯/灭灯操作即可字符设备那套框架完全不用动。5. Ubuntu20.04 上编译模块KDIR 与 Makefile 是关键5.1 本机编译与交叉编译的取舍Ubuntu20.04 在驱动开发里通常有两个角色。如果你只是想在 x86 主机上验证字符设备框架直接用本机内核头文件编译模块insmod到当前系统就能测。如果你手上有 ARM 开发板需要把 Ubuntu 当作宿主机给目标板交叉编译.ko文件。本机编译需要安装内核头文件最简单的方式sudo apt update sudo apt install linux-headers-generic装完后一般指向/lib/modules/$(uname -r)/build目录。uname -r查看当前内核版本如果头文件版本和当前运行内核不一致编译时 vermagic 对不上insmod时会拒绝加载。这一点在升级内核后特别容易踩旧头文件还在但当前内核已经换新版本了。交叉编译则要准备目标板同版本的内核源码并且该源码目录已经完成配置。这个源码目录就是 KDIR编译出的模块和开发板内核的 vermagic 必须完全一致否则加载时报version magic不匹配。所以内核源码版本要和开发板烧录的内核版本严格对应最好直接用制作内核镜像时的那份源码。5.2 Makefile 模板外部模块的 Makefile 是一个固定套路核心是调用内核构建系统obj-m : led.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-C告诉 make 先切换到内核构建目录M$(PWD)指回模块源码目录modules是构建目标。这个 Makefile 里的缩进必须是 Tab不能用空格否则 make 直接报错我见过太多次因为这个原因卡住的情况。交叉编译时改成ARCH ? arm CROSS_COMPILE ? arm-linux-gnueabihf- KERNELDIR ? /path/to/kernel/source all: $(MAKE) -C $(KERNELDIR) M$(PWD) ARCH$(ARCH) CROSS_COMPILE$(CROSS_COMPILE) modules clean: $(MAKE) -C $(KERNELDIR) M$(PWD) ARCH$(ARCH) CROSS_COMPILE$(CROSS_COMPILE) cleanCROSS_COMPILE是工具链前缀Ubuntu20.04 上安装 32 位 ARM 工具链sudo apt install gcc-arm-linux-gnueabihf安装完确认一下arm-linux-gnueabihf-gcc -v工具链能跑起来再继续否则后面所有交叉编译命令都会以command not found告终。5.3 编译报错与模块加载校验编译阶段最常出现的问题之一是头文件找不到报错类似linux/module.h: No such file or directory。这通常意味着KERNELDIR指向的目录缺少include/generated或include/linux文件简单说就是内核头文件没准备好。解决方法是要么用发行版提供的linux-headers-generic要么在内核源码根目录先执行make prepare等配置步骤不能拿一个刚解压的裸源码目录直接当 KDIR 用。另一个问题是架构混乱。如果你在一台 x86 主机上编译出了 ARM 的.ko可以先用file命令确认file led.ko输出里能看到 ELF 架构信息比如ARM或x86-64。如果架构不对多半是 Makefile 里的ARCH和CROSS_COMPILE没起作用或者上一次编译的中间文件残留。换架构之前先make clean清掉旧文件再编避免残留的.o被复用。模块编译出来之后用modinfo看 vermagicmodinfo led.ko | grep vermagic这份信息和开发板内核要一致。Ubuntu 桌面版默认开启了安全启动直接insmod可能报Required key not available那是模块签名校验没通过。临时测试可以进入 BIOS 关闭 Secure Boot或者给模块做签名这个环节不是驱动逻辑问题但很容易卡住新手。6. 加载、测试、排错实际跑一次新版 LED 驱动6.1 insmod 之后应该看到什么先把 Ubuntu20.04 上本机测试的完整流程走一遍。编译成功得到led.ko然后用sudo加载sudo insmod led.ko紧接着观察内核日志dmesg | tail正常情况下应该能看到led major 240, minor 0数字取决于动态分配结果。然后确认设备号确实进入内核cat /proc/devices | grep led再查看设备节点ls -l /dev/led如果有crw-rw---- 1 root root 240, 0 ... /dev/led说明整套链路已经通了。此时用一个普通用户访问可能没有权限测试时直接换成 root 或用sudoecho 1 /dev/led echo 0 /dev/led每执行一次dmesg里应该多一行led state - 1或led state - 0。如果你在板子上接了真实 LED这时候灯就会跟着亮灭。6.2 失败链路排查清单我在带人做实验时总结过一套排查顺序按现象定位最快现象可能原因排查与解决insmod 成功但 /dev/led 不存在device_create失败或者 udev 没有收到事件dmesg看是否有failed to create device检查 class_create 是否成功/dev/led 存在但 open 失败设备节点权限不足或 cdev_add 顺序有问题sudo chmod 666 /dev/led临时测试写 udev 规则做永久处理open 成功但 echo 报错copy_from_user失败或 write 逻辑返回负数回到驱动代码检查 fops 里 write 是否注册成功rmmod 报仍在占用有进程打开着 /dev/ledfuser -v /dev/led查看占用进程关闭后再卸载加载时设备号冲突register_chrdev_region 指定的 major 被占用cat /proc/devices查询改用动态分配或换号特别说一下/dev/led权限问题。device_create创建的节点默认属主是 root权限是 0600所以普通用户echo进去必然被拒。临时测试用chmod 666没问题产品上建议写一个 udev 规则按 class 和 subsystem 给设备节点设置权限组这样每次开机自动生效不依赖手动改权限。6.3 动态主设备号对应用透明吗这是许多新手最后会纠结的问题如果每次加载主设备号都变那我应用程序里写死的mknod或者open路径怎么办答案是不用担心。/dev/led这个节点名是device_create最后一个参数决定的和主设备号无关。应用层打开设备用的是路径名真正打开时 VFS 会根据节点记录的dev_t找到对应的cdev所以你完全不感知主设备号是多少。这也正是新版框架的价值设备号是内核内部资源应用只看到设备节点名。多 LED 场景就更好理解了。申请两个次设备号device_create分别创建led0和led1应用行为完全一致驱动内部通过iminor(inode)区分控制哪一路。LED 实验跑通后建议你再补一个read方法和ioctl把整个字符设备驱动的骨架填满后面写按键、写传感器驱动时这套框架能直接复刻。最后提醒一句经验调试阶段固定major241方便看现象但产品代码里一定要默认走动态分配。我见过不止一次同事把测试时的固定设备号带进量产程序结果目标板上那个号早被别的驱动占走驱动加载直接失败排查大半天才反应过来。新版这套流程看着比老版多一些代码但每一步都可控、可回滚、可定位等你能不看模板把这个链路默写出来字符设备驱动基本上就入门了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑