做 ARM Linux 驱动,绕不开一个经典实验:LED。这些年在网上能翻到的教程,一大半还在用老版register_chrdev+mknod那套写法,放到 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); - 初始化并添加
cdev(cdev_init+cdev_add); - 创建 class(
class_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 major=241则固定为 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 cdev:
static 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 写入设备信息,并触发 uevent,udev 守护进程收到事件后,根据设备所属的 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看 vermagic:
modinfo 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 或用sudo:
echo 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/led | fuser -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,把整个字符设备驱动的骨架填满,后面写按键、写传感器驱动时,这套框架能直接复刻。
最后提醒一句经验:调试阶段固定major=241方便看现象,但产品代码里一定要默认走动态分配。我见过不止一次,同事把测试时的固定设备号带进量产程序,结果目标板上那个号早被别的驱动占走,驱动加载直接失败,排查大半天才反应过来。新版这套流程看着比老版多一些代码,但每一步都可控、可回滚、可定位,等你能不看模板把这个链路默写出来,字符设备驱动基本上就入门了。