Linux 字符设备驱动从入门到精通:从 register_chrdev 到 cdev 的演进实践
目录
- 前言
- 核心概念梳理
- 2.1 设备号:主设备号与次设备号
- 2.2 关键数据结构:
struct file与struct file_operations - 2.3
struct cdev:字符设备的化身 - 2.4 设备类与设备节点自动创建
- 旧接口
register_chrdev的工作方式与缺陷 - 现代 cdev 接口详解
- 4.1 分步注册流程
- 4.2 各函数参数精讲
- 4.3 错误处理与资源回滚
- 实战:编写一个支持读写的小驱动
- 5.1 驱动程序完整代码(cdev 版本)
- 5.2 关键代码逐行剖析
- 5.3 用户空间测试程序
- 编译、加载与测试全流程
- 6.1 Makefile 编写
- 6.2 编译与解决警告
- 6.3 模块的加载与卸载
- 6.4 功能测试与现象解释
- 常见问题与调试技巧
- 7.1
insmod: File exists错误排查 - 7.2 设备号查看方法
- 7.3 时间戳异常分析
- 7.4 如何确认当前运行的驱动版本
- 7.1
- 总结与扩展
- 自测题(附答案)
- 完整代码下载
前言
在 Linux 系统中,设备驱动是连接硬件与用户程序的桥梁。字符设备驱动是最常见的一类驱动,它把硬件抽象为一个文件(/dev/xxx),应用程序通过标准的open、read、write、close等系统调用即可操作硬件。
Linux 内核在发展过程中,字符设备驱动的注册接口经历了从register_chrdev到基于cdev的现代化接口的演进。本文将以一个最简单的“hello 驱动”为例,带你从零开始掌握字符设备驱动的编写,并深入理解两种接口的差异与适用场景。
本文内容基于 Linux 4.9.88 内核,测试平台为 NXP i.MX6ULL 开发板,但原理适用于所有 Linux 版本。
核心概念梳理
2.1 设备号:主设备号与次设备号
在 Linux 中,每个字符设备都由一个设备号唯一标识。设备号是一个 32 位无符号整数(dev_t类型),其中高 12 位代表主设备号,低 20 位代表次设备号。
- 主设备号:标识设备对应的驱动程序。同一个驱动程序管理的所有设备通常共享同一个主设备号。
- 次设备号:由驱动程序内部使用,用于区分同一驱动管理的不同具体设备。例如,串口驱动中
/dev/ttyS0和/dev/ttyS1主设备号相同,次设备号不同。
可以通过以下宏操作设备号:
c
dev_t dev = MKDEV(major, minor); // 组合成设备号 int major = MAJOR(dev); // 提取主设备号 int minor = MINOR(dev); // 提取次设备号2.2 关键数据结构:struct file与struct file_operations
当应用程序调用open("/dev/hello", ...)时,内核会为该次打开操作创建一个struct file对象,它记录了本次打开的状态信息:
c
struct file { fmode_t f_mode; // 读写权限 loff_t f_pos; // 当前文件偏移量 unsigned int f_flags; // 打开标志(如 O_RDWR) const struct file_operations *f_op; // 指向操作函数集 void *private_data; // 驱动私有数据指针 // ... 其他成员 };而struct file_operations则是驱动开发者需要填充的核心结构体,它将系统调用与驱动函数进行绑定:
c
struct file_operations { struct module *owner; loff_t (*llseek) (struct file *, loff_t, int); ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *); int (*open) (struct inode *, struct file *); int (*release) (struct inode *, struct file *); // ... 还有很多其他操作 };在我们的驱动中,只实现了最基础的open、read、write、release。
2.3struct cdev:字符设备的化身
struct cdev是内核用来描述一个字符设备的结构体:
c
struct cdev { struct kobject kobj; // 内核对象基础结构 struct module *owner; // 所属模块 const struct file_operations *ops; // 操作函数集 struct list_head list; // 链接到内核的 cdev 链表 dev_t dev; // 起始设备号 unsigned int count; // 管理的次设备号数量 };当我们向内核注册一个cdev后,内核就会在内部散列表中记录一条映射:“从某设备号开始的连续 N 个设备号,均由该 cdev 处理”。此后,任何打开这些设备号的请求,都会调用该cdev关联的file_operations中的函数。
2.4 设备类与设备节点自动创建
早期的 Linux 系统中,设备节点需要手动使用mknod命令创建,十分繁琐。现代内核通过设备模型提供了自动创建设备节点的机制,涉及两个关键函数:
class_create(owner, name):在/sys/class/下创建一个设备类。device_create(class, parent, devt, drvdata, fmt, ...):在/dev/下自动创建一个设备节点,并触发udev或mdev设置权限。
这种方式使得模块加载后,设备节点自动出现;模块卸载后,节点自动消失,极大方便了开发和部署。
旧接口register_chrdev的工作方式与缺陷
函数原型
c
int register_chrdev(unsigned int major, const char *name, const struct file_operations *fops);- 若
major为0,则由内核动态分配一个主设备号,并返回该主设备号。 - 该函数一次性完成三件事:分配设备号、注册 cdev、绑定 fops。
- 问题:无论你实际需要多少个次设备号,该函数会固定占用 256 个次设备号(0~255)。对于只需要一个设备节点的简单驱动,这是巨大的资源浪费。
卸载接口
c
void unregister_chrdev(unsigned int major, const char *name);释放之前占用的主设备号和次设备号范围。
示例代码(旧方式)
c
static int major; static int __init hello_init(void) { major = register_chrdev(0, "100ask_hello", &hello_drv); // ... 创建 class 和 device return 0; } static void __exit hello_exit(void) { // ... 销毁 device 和 class unregister_chrdev(major, "100ask_hello"); }虽然这种方式简单直观,但由于其资源浪费且不符合现代内核的设计哲学,Linux 2.6 之后推荐使用更精细的cdev接口。
现代 cdev 接口详解
4.1 分步注册流程
现代接口将设备号申请、cdev 初始化、cdev 注册分成独立的步骤:
- 申请设备号:
alloc_chrdev_region()或register_chrdev_region() - 初始化 cdev:
cdev_init() - 向内核添加 cdev:
cdev_add()
同时,错误处理中必须按照“后申请的先释放”的原则进行回滚。
4.2 各函数参数精讲
4.2.1alloc_chrdev_region
c
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);dev:输出参数,内核将分配到的起始设备号存入其中。baseminor:申请的起始次设备号,通常填0。count:申请多少个连续的次设备号。按需申请,例如只需要一个设备就填1。name:设备名称,会显示在/proc/devices中。- 返回值:成功返回
0,失败返回负的错误码。
4.2.2cdev_init
c
void cdev_init(struct cdev *cdev, const struct file_operations *fops);- 将
fops绑定到cdev->ops,并初始化cdev的其他字段。
4.2.3cdev_add
c
int cdev_add(struct cdev *cdev, dev_t dev, unsigned count);cdev:已初始化的 cdev 对象。dev:该 cdev 管理的起始设备号。count:该 cdev 管理的连续次设备号数量(必须 ≤alloc_chrdev_region中申请的数量)。- 函数执行后,内核将记录映射关系。
4.2.4 注销顺序
卸载时,必须严格按相反顺序释放资源:
c
cdev_del(&hello_cdev); // 1. 移除 cdev unregister_chrdev_region(dev, count); // 2. 释放设备号注意:cdev_del必须在unregister_chrdev_region之前调用,以避免设备号被新驱动抢占后仍可访问到旧的 cdev。
4.3 错误处理与资源回滚
由于资源是分步申请的,如果中间某一步失败,必须将之前已申请的资源释放掉,否则会造成资源泄漏。
初始化函数中的典型回滚逻辑:
c
ret = alloc_chrdev_region(&dev, 0, 2, "hello"); if (ret) return ret; cdev_init(&hello_cdev, &fops); ret = cdev_add(&hello_cdev, dev, 2); if (ret) { unregister_chrdev_region(dev, 2); // 回滚第一步 return ret; } hello_class = class_create(THIS_MODULE, "hello_class"); if (IS_ERR(hello_class)) { cdev_del(&hello_cdev); // 回滚第二步 unregister_chrdev_region(dev, 2); // 回滚第一步 return PTR_ERR(hello_class); }教学代码中有时会省略回滚以保持简洁,但在生产级驱动中,完整的错误处理是必须的。
实战:编写一个支持读写的小驱动
5.1 驱动程序完整代码(cdev 版本)
文件名:hello_drv.c
c
#include <linux/cdev.h> #include <linux/device.h> #include <linux/fs.h> #include <linux/init.h> #include <linux/module.h> #include <linux/uaccess.h> static struct class *hello_class; static struct cdev hello_cdev; static dev_t dev; static unsigned char hello_buf[100]; static int hello_open(struct inode *node, struct file *filp) { printk(KERN_INFO "%s %s %d\n", __FILE__, __func__, __LINE__); return 0; } static ssize_t hello_read(struct file *filp, char __user *buf, size_t size, loff_t *offset) { unsigned long len = size > 100 ? 100 : size; printk(KERN_INFO "%s %s %d\n", __FILE__, __func__, __LINE__); if (copy_to_user(buf, hello_buf, len)) return -EFAULT; return len; } static ssize_t hello_write(struct file *filp, const char __user *buf, size_t size, loff_t *offset) { unsigned long len = size > 100 ? 100 : size; printk(KERN_INFO "%s %s %d\n", __FILE__, __func__, __LINE__); if (copy_from_user(hello_buf, buf, len)) return -EFAULT; return len; } static int hello_release(struct inode *node, struct file *filp) { printk(KERN_INFO "%s %s %d\n", __FILE__, __func__, __LINE__); return 0; } static const struct file_operations hello_drv = { .owner = THIS_MODULE, .read = hello_read, .write = hello_write, .open = hello_open, .release = hello_release, }; static int __init hello_init(void) { int ret; /* 1. 动态申请设备号,次设备号从0开始,申请2个 */ ret = alloc_chrdev_region(&dev, 0, 2, "hello"); if (ret < 0) { printk(KERN_ERR "alloc_chrdev_region failed\n"); return ret; } /* 2. 初始化 cdev 并绑定 file_operations */ cdev_init(&hello_cdev, &hello_drv); /* 3. 向内核注册 cdev */ ret = cdev_add(&hello_cdev, dev, 2); if (ret) { printk(KERN_ERR "cdev_add failed\n"); unregister_chrdev_region(dev, 2); return ret; } /* 4. 创建设备类 */ hello_class = class_create(THIS_MODULE, "hello_class"); if (IS_ERR(hello_class)) { printk(KERN_ERR "class_create failed\n"); cdev_del(&hello_cdev); unregister_chrdev_region(dev, 2); return PTR_ERR(hello_class); } /* 5. 自动创建设备节点 /dev/hello */ device_create(hello_class, NULL, dev, NULL, "hello"); printk(KERN_INFO "hello driver initialized.\n"); return 0; } static void __exit hello_exit(void) { device_destroy(hello_class, dev); class_destroy(hello_class); cdev_del(&hello_cdev); unregister_chrdev_region(dev, 2); printk(KERN_INFO "hello driver removed.\n"); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A simple cdev-based hello driver");5.2 关键代码逐行剖析
(1)头文件
c
#include <linux/cdev.h> // cdev 相关函数 #include <linux/device.h> // class_create, device_create #include <linux/fs.h> // alloc_chrdev_region, file_operations #include <linux/uaccess.h> // copy_to/from_user(2)全局变量
c
static dev_t dev; // 设备号 static struct cdev hello_cdev; // cdev 对象 static struct class *hello_class;// 设备类指针 static unsigned char hello_buf[100]; // 数据缓冲区(3)copy_to_user与copy_from_user
用户空间与内核空间不能直接通过指针访问对方的内存,必须使用专用函数:
copy_to_user(to, from, n):从内核拷贝到用户空间。copy_from_user(to, from, n):从用户空间拷贝到内核。
两个函数均返回未成功拷贝的字节数,成功返回0。示例中我们增加了错误判断,若拷贝失败则返回-EFAULT。
(4)申请数量为何是2而不是1?
本文申请了 2 个次设备号(minor 0 和 minor 1),但只创建了/dev/hello(使用 minor 0)。这样做是为了演示cdev_add可以一次性管理多个次设备号,同时为后续扩展预留空间。实际产品中应根据需要精确申请。
5.3 用户空间测试程序
文件名:hello_test.c
c
#include <stdio.h> #include <string.h> #include <fcntl.h> #include <unistd.h> /* * 用法: * 写: ./hello_test /dev/hello <string> * 读: ./hello_test /dev/hello */ int main(int argc, char **argv) { int fd; char buf[100]; if (argc < 2) { printf("Usage:\n"); printf(" %s <dev> [string]\n", argv[0]); return -1; } fd = open(argv[1], O_RDWR); if (fd < 0) { printf("can not open file %s\n", argv[1]); return -1; } if (argc == 3) { int len = write(fd, argv[2], strlen(argv[2]) + 1); printf("write ret = %d\n", len); } else { int len = read(fd, buf, sizeof(buf) - 1); buf[len] = '\0'; printf("read str: %s\n", buf); } close(fd); return 0; }编译、加载与测试全流程
6.1 Makefile 编写
makefile
# 指定内核源码树路径(根据你的环境修改) KERN_DIR = /home/book/100ask_imx6ull-sdk/Linux-4.9.88 # 交叉编译工具链前缀(若为本地编译则无需设置) CROSS_COMPILE = arm-buildroot-linux-gnueabihf- all: make -C $(KERN_DIR) M=$(PWD) modules $(CROSS_COMPILE)gcc -o hello_test hello_test.c clean: make -C $(KERN_DIR) M=$(PWD) modules clean rm -rf modules.order Module.symvers rm -f hello_test # 目标模块文件名 obj-m += hello_drv.o6.2 编译与解决警告
执行bear make后,可能会看到如下警告:
text
warning: ignoring return value of ‘copy_from_user’, declared with attribute warn_unused_result这是因为未检查copy_*_user的返回值。一定要处理返回值,否则可能因用户传入非法地址导致内核崩溃。我们在代码中已加入错误判断,编译警告消失。
编译成功后,当前目录下会生成hello_drv.ko(内核模块)和hello_test(测试程序)。
6.3 模块的加载与卸载
将文件推送到开发板:
bash
adb push hello_drv.ko hello_test /root/在开发板上执行:
bash
# 加载模块 insmod /root/hello_drv.ko # 查看内核日志 dmesg | tail -5 # 检查设备节点 ls -l /dev/hello # 查看设备号分配情况 cat /proc/devices | grep hello # 卸载模块 rmmod hello_drv6.4 功能测试与现象解释
写入数据
bash
./hello_test /dev/hello 100ask预期输出:write ret = 7(包括字符串结尾的\0)
内核日志中可见hello_open、hello_write、hello_release的打印信息。
读出数据
bash
./hello_test /dev/hello预期输出:read str: 100ask
内核日志中可见hello_open、hello_read、hello_release。
验证多设备管理
由于申请了两个次设备号,我们可以手动创建次设备号为 1 的节点并测试:
bash
mknod /dev/abc c 244 1 # 主设备号请根据实际值修改 ./hello_test /dev/abc 1234 ./hello_test /dev/abc你会看到读写/dev/abc同样成功,且数据与/dev/hello共享同一缓冲区(因为驱动内部未区分次设备号)。这也展示了如何利用次设备号区分不同设备实例。
常见问题与调试技巧
7.1insmod: File exists错误排查
现象:加载模块时提示insmod: ERROR: could not insert module hello_drv.ko: File exists
可能原因与解决方案:
模块已加载
bash
lsmod | grep hello rmmod hello_drv设备节点残留(模块已卸载但
/dev/hello未删除)bash
rm -f /dev/hello设备号在
/proc/devices中仍有记录(极少见,通常由于卸载函数不完善导致)
重启系统是最简单的清理方法。
7.2 设备号查看方法
- 查看主设备号:
cat /proc/devices | grep hello - 查看完整设备号:
ls -l /dev/hello输出中244, 0即为主设备号 244,次设备号 0。 - 通过 sysfs 查看:
cat /sys/class/hello_class/hello/dev输出格式244:0。
7.3 时间戳异常分析
在测试中,ls -l /dev/hello显示的时间戳可能是Jan 2 20:27,而当前系统时间为Jan 2 20:32 1970。这是因为:
- 开发板没有 RTC 电池,每次上电系统时间重置为 1970-01-01 00:00:00(Unix 纪元)。
- 设备节点在模块加载时被创建,因此时间戳为当时的系统时间。
- 如果系统时间从未被同步,这个时间戳就会显示为 1970 年 1 月 1 日之后的某个时刻。
解决方法:使用ntpdate同步网络时间,或手动date -s "2026-04-16 15:30:00"设置。
7.4 如何确认当前运行的驱动版本
如果你频繁修改驱动代码,可能会担心加载的是旧版本。可以通过以下方法确认:
在驱动初始化函数中加入版本打印:
c
printk(KERN_INFO "hello_drv version: 2026-04-16-v2\n");然后查看
dmesg。查看模块文件的修改时间:
bash
ls -l /root/hello_drv.ko检查
/sys/module/hello_drv/目录(如果定义了模块参数或版本信息)。
总结与扩展
通过本文,你已掌握了:
- 字符设备驱动的核心概念:设备号、
cdev、file_operations、设备模型。 - 从
register_chrdev到cdev接口的演进及其背后的设计思想。 - 完整编写、编译、测试一个基于 cdev 的字符设备驱动。
- 调试常见问题的方法。
扩展思考
- 如何实现多个设备各自独立的缓冲区?
可以在open函数中根据iminor(inode)分配不同的缓冲区,并将其地址保存在filp->private_data中,后续的read/write即可操作对应缓冲区。 - 如何支持更复杂的 ioctl 命令?
在file_operations中添加.unlocked_ioctl成员,实现命令解析。 - 如何在驱动中使用并发控制(信号量、自旋锁)?
当多个进程同时访问驱动时,需要保护共享资源(如hello_buf),防止数据混乱。
字符设备驱动是 Linux 驱动开发的基石,扎实掌握这部分知识,将为你后续学习平台总线驱动、I2C/SPI 等子系统驱动打下坚实基础。
自测题(附答案)
1. 请解释struct cdev中的ops成员的作用。
答案:
ops是一个指向struct file_operations的指针,它将设备号与具体的操作函数(如open、read、write)关联起来。当用户程序打开设备时,内核根据设备号找到对应的cdev,然后通过cdev->ops调用驱动实现的函数。
2.alloc_chrdev_region(&dev, 0, 2, "hello")中,参数0和2分别代表什么?
答案:
0表示起始次设备号(baseminor),2表示申请 2 个连续的次设备号(即 minor 0 和 minor 1)。
3. 下面的卸载代码有何问题?
c
unregister_chrdev_region(dev, 2); cdev_del(&hello_cdev);答案:应该先调用
cdev_del,再释放设备号。如果先释放设备号,其他驱动可能立刻占用同一设备号,而此时cdev尚未从内核移除,会导致设备号冲突和内核状态不一致。
4. 用户程序调用read(fd, buf, 100)时,内核如何一步步调用到驱动的hello_read函数?
答案:
- 系统调用
read进入 VFS(虚拟文件系统)。- VFS 从
fd对应的struct file中获取f_op(即hello_drv)。- 调用
f_op->read,即驱动的hello_read函数。- 驱动执行
copy_to_user将数据返回用户空间。
5. 为什么现代驱动推荐使用 cdev 接口而非register_chrdev?
答案:
- 资源利用:
register_chrdev固定占用 256 个次设备号,而 cdev 接口按需申请,节省资源。- 灵活性:cdev 接口将设备号申请和 fops 绑定分离,一个驱动可注册多个 cdev 管理不同设备。
- 可维护性:符合 Linux 设备模型的分层思想,代码更清晰。
6. 如何让本驱动支持两个独立的设备节点/dev/hello0和/dev/hello1,且拥有各自的缓冲区?
答案:
- 将全局
hello_buf改为数组char hello_buf[2][100]。- 在
open函数中通过iminor(inode)获得次设备号,保存到filp->private_data。- 在
read/write中从filp->private_data取出次设备号,操作对应的缓冲区。- 在
hello_init中分别用device_create创建两个设备节点:hello0(minor 0)和hello1(minor 1)。
7. 加载模块时出现insmod: ERROR: could not insert module hello_drv.ko: File exists,可能的原因有哪些?如何解决?
答案:可能原因:
- 模块已经在内存中(
lsmod检查,rmmod卸载)。- 设备节点
/dev/hello已存在(手动删除)。- 设备号在
/proc/devices中残留(重启或完善卸载函数)。
解决方法:依次执行rmmod、rm -f /dev/hello、检查/proc/devices,必要时重启。