Linux内核与驱动:4.字符设备驱动基础

张开发
2026/5/17 12:05:40 15 分钟阅读
Linux内核与驱动:4.字符设备驱动基础
在嵌入式 Linux 的世界里有一句至理名言万物皆文件。无论是一个 LED 灯、一个传感器还是一个复杂的加密模块在 Linux 眼里它们大多都是“字符设备”。在 Linux 驱动开发里字符设备驱动几乎是入门必经的一关。字符设备的核心特征是用户空间通过 /dev/xxx 这样的设备文件以 open / read / write / ioctl / close 这类“文件操作”的方式和驱动交互而 VFS 在打开设备节点后会把这些操作转交给驱动提供的 file_operation。这篇文章把字符设备驱动最常见、最基础的几个概念串起来讲清楚设备号dev_t、主设备号、次设备号字符设备对象struct cdev注册字符设备alloc_chrdev_region、cdev_init、cdev_add文件操作集合struct file_operations设备节点dev/xxx 是怎么来的一个最小可运行的字符设备驱动骨架1.什么是字符设备Linux 把设备大体分成字符设备和块设备。字符设备通常按字节流或顺序方式访问典型操作是 read()、write()、ioctl()块设备则更偏向面向块、带缓存和调度的存储访问。对字符设备来说用户看到的是 /dev 下面的一个特殊文件而驱动开发者要做的就是把这个“文件”背后的操作接到自己的驱动代码上。从 VFS 的角度看打开一个设备节点时文件系统原本的文件操作会被替换为设备驱动对应的文件操作然后再调用驱动的 open。这也是为什么我们总说“设备驱动看起来像是在实现一组文件操作”。2.字符设备的核心组件2.1设备号 (Device Number)内核通过设备号来识别驱动。它由两部分组成主设备号 (Major)标识驱动类型你是哪类驱动。次设备号 (Minor)标识同类驱动下的具体实例你是第几个传感器。设备号的申请分为静态申请和动态申请在现代内核开发中推荐使用alloc_chrdev_region()动态申请设备号避免硬编码导致冲突。静态注册int register_chrdev_region(dev_t from, unsigned count, const char *name);动态分配int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);对应的释放函数为void unregister_chrdev_region(dev_t from, unsigned count);常用宏MAJOR(dev)从设备号 dev_t 提取出主设备号32位中的高12位MINOR(dev)从设备号 dev_t 提取出次设备号32位中的低20位MKDEV(major, minor)将主次设备号组装成一个设备号2.2字符设备对象拿到设备号还不够你还得告诉内核这个设备号背后是我的这个字符设备对象。struct cdev字符设备注册的步骤1.分配设备号2.初始化 cdev关联file_operations。3.添加cdev正式向内核注册设备生效。2.3 file_operations驱动的“灵魂”字符设备驱动最核心的结构之一是struct file_operations他是连接用户空间和驱动程序的桥梁。这是一个函数指针结构体。它定义了当用户在应用层调用 open()、read()、write()ioctl()release() 时内核应该去执行驱动里的哪个函数。static struct file_operations my_fops { .owner THIS_MODULE, .open my_open, // 应用层 open() 时触发 .read my_read, // 应用层 read() 时触发 .write my_write, // 应用层 write() 时触发 .release my_close, // 应用层 close() 时触发 };其中最常用的几个成员含义如下open打开设备节点时调用read用户执行read()时调用write用户执行write()时调用unlocked_ioctl用户执行ioctl()时调用release最后一个引用关闭时调用2.4 设备节点连接应用与内核的桥梁file_operation是连接应用程序与驱动程序“操作”的桥梁设备节点是应用程序与驱动程序“目标/视角”的桥梁在驱动程序中一个设备被表示为设备号与设备对象cdev在用户空间的表现形式就是 /dev/xxx 设备节点。创建设备节点有两种方式手动创建设备节点自动创建设备节点。手动创建在insmod驱动程序之后使用 mknod /dev/my_device c[主设备号] [次设备号]。自动创建设备节点在 Linux 系统中自动创建设备节点的核心机制由udev嵌入式中一般是mdev完成的。内核空间通过调用 class_create()和 device_create()函数分配一块 struct class/device 结构体的空间然后通过sysfs 投影映射到用户空间生成 /sys/class/xxx这个目录只是内核对象在用户空间的一个“影子”。udev是在用户空间中的应用程序udev的机制为当检测到在 /sys/class/下出现新的文件时自动调用mknod()接口创建 /dev/my_device_node这就是设备节点。所以自动创建设备节点在驱动程序中表示为class_create() device_create()用完后通过 device_destroy()、class_destroy() 清理。2.5流程总结一个最常见的初始化流程是申请设备号alloc_chrdev_region()初始化字符设备对象cdev_init()注册字符设备cdev_add()创建设备类class_create()创建设备device_create()退出时按反向顺序释放device_destroy()class_destroy()cdev_del()unregister_chrdev_region()这个顺序非常重要。尤其要记住先申请设备号再注册 cdev再创建设备节点退出时反过来。3.最小字符设备驱动示例#include linux/init.h #include linux/module.h #include linux/fs.h #include linux/kdev_t.h #include linux/cdev.h static dev_t dev_num; //设备号 static struct cdev cdev_test; //字符设备结构体 struct class* class_test; struct device* device_test; static int cdev_test_open(struct inode* inode,struct file* filp) { printk(cdev_test_open\n); return 0; } static int cdev_test_read(struct file* filp,char __user* buf,size_t count,loff_t* offset) { printk(cdev_test_read\n); return 0; } static int cdev_test_write(struct file* filp,const char __user* buf,size_t count,loff_t* offset) { printk(cdev_test_write\n); return count; } static int cdev_test_release(struct inode* inode,struct file* flip) { printk(cdev_test_release\n); return 0; } static struct file_operations fops { .owner THIS_MODULE, .open cdev_test_open, .read cdev_test_read, .write cdev_test_write, .release cdev_test_release, }; //文件操作结构体 static int _init module_cdev_init(void) { int ret; int major,minor; //1.申请设备号 ret alloc_cdev_region(dev_num,0,1,cdev_test); if(ret 0) { printk(alloc_cdev_region failed\n); return ret; } major MAJOR(dev_num); minor MINOR(dev_num); printk(major %d,minor %d\n,major,minor); //2.初始化字符设备结构体 cdev_init(cdev_test,fops); cdev_test-owner THIS_MODULE; //3.注册字符设备 ret cdev_add(cdev_test,dev_num,1); if(ret 0) { printk(cdev_add failed\n); unregister_chrdev_region(dev_num,1); return ret; }else { printk(cdev_add success\n); } //4.自动创建设备节点(创建类和设备) class_test class_create(THIS_MODULE,cdev_test_class); device_test device_create(class_test,NULL,dev_num,NULL,cdev_test_device); return 0; } static void _exit module_cdev_exit(void) { //1.删除字符设备 cdev_del(cdev_test); //2.释放设备号 unregister_chrdev_region(dev_num,1); printk(cdev_test exit\n); } module_init(module_cdev_init); module_exit(module_cdev_exit); MODULE_LICENSE(GPL);4.文件私有数据“高内聚低耦合”是软件开发的终极思想我们可以通过某些方法来靠近达到这种思想使用私有文件数据filp-private_data就是其中一种方法。4.1为什么需要私有文件数据作用消除全局变量支持多设备实例。场景假设你的驱动要控制 4 个相同的硬件串口UART0~UART3。糟糕的做法定义 4 个全局变量分别存储每个串口的状态。这样代码会变得臃肿内聚性极差。私有数据做法你定义一个结构体 struct uart_dev 来描述串口。在 open 函数中根据次设备号找到对应的结构体并把它赋值给 filp-private_data。结果后续的 read ,write 函数只需要从 private_data 里把这个结构体取出来即可。无论你有多少个设备驱动代码只有一套。4.2私有文件数据实验struct cdev_test_dev{ static dec_t dev_num; int major; int minor; struct cdev cdev_test; struct class* class_test; struct device* device_test; char kbuf[100]; } struct cdev_test_dev dev1; struct cdev_test_dev dev2; static int cdev_test_open(struct inode* inode,struct file* filp) { dev1.minor 0; dev2.minor 1; printk(cdev_test_open\n); //判断是哪个设备被打开了 struct cdev_test_dev* dev container_of(inode-i_cdev,struct cdev_test_dev,cdev_test); filp-private_data dev; //将设备结构体指针保存在文件私有数据中 printk(major %d,minor %d\n,dev-major,dev-minor); return 0; }我们为了实现一个驱动兼容多个设备的情况多个次设备在之前代码的基础上增加了cdev_test_dev结构体用来存储每个设备的信息在cdev_test_open函数中我们通过struct cdev_test_dev* dev container_of(inode-i_cdev,struct cdev_test_dev,cdev_test);来判断用户空间打开的是哪个设备。container_of函数已知“结构体某个成员的指针”反推出“包含这个成员的整个结构体指针”。ptr指向某个成员的指针type外层容器结构体类型member这个成员在外层结构体里的字段名我们在例子中用到container_of函数的作用是inode中的i_cdev成员就是用户open 的设备节点的内核态表达也就是结构体dev1/2中的cdev_test。意思是我们通过cdev_test的首地址得到整个结构体的首地址也就是dev1/2的首地址这样我们就知道打开的是哪个设备节点了。然后我们把dev1/2的首地址赋值给filp-private_data后续直接操作filp-private_data即可。举例在驱动write函数中的表达static int cdev_test_write(struct file* filp,const char __user* buf,size_t count,loff_t* offset) { struct cdev_test_dev* dev (struct cdev_test_dev*)filp-private_data; if(dev-minor 0) { copy_from_user(dev-kbuf,buf,count); //用于将用户空间的数据复制到内核空间 printk(cdev_test_write to dev1: %s\n,dev-kbuf); } if(dev-minor 1) { copy_from_user(dev-kbuf,buf,count); //用于将用户空间的数据复制到内核空间 printk(cdev_test_write to dev2: %s\n,dev-kbuf); } return count; }5.杂项设备驱动杂项设备是 Linux 内核中非常常见的一种字符设备类型适用于各种简单设备或工具类驱动。它让驱动开发更简单、繁琐的字符设备号管理都交给内核处理。杂项设备本质上也是字符设备但它通过高度封装让你只需要一个结构体、一个函数就能完成所有注册工作。可以通过cat /proc/misc查看系统中的杂项设备。5.1杂项设备核心特征主设备号固定所有的杂项设备主设备号都是10。次设备号唯一不同的杂项设备通过不同的次设备号来区分。自动创建节点只要调用注册函数内核会自动在/dev/下生成设备文件无需手动调用 class_create 和 device_create。节省资源对于那些功能简单、数量不多的驱动如 LED、蜂鸣器、按键用杂项设备可以节省主设备号资源。核心结构体struct miscdevicestruct miscdevice { int minor; // 次设备号通常设为 MISC_DYNAMIC_MINOR 自动分配 const char *name; // 设备名称会在 /dev/ 下生成的节点名 const struct file_operations *fops; // 你的文件操作接口 struct list_head list; struct device *parent; struct device *this_device; const char *nodename; umode_t mode; };5.2杂项设备vs标准字符设备维度标准字符设备 (Cdev)杂项设备 (Misc)主设备号需动态申请或手动指定固定为 10次设备号需要管理0~255自动分配或指定注册复杂度繁琐5个步骤起步极简只需 1 个函数设备节点需手动创建类和设备自动创建/dev/节点适用场景复杂的、多实例的设备简单的、单一实例的设备5.3杂项设备实验#include linux/module.h #include linux/miscdevice.h #include linux/fs.h // 1. 实现文件操作函数 static int led_open(struct inode *inode, struct file *file) { printk(LED device opened!\n); return 0; } static struct file_operations led_fops { .owner THIS_MODULE, .open led_open, }; // 2. 定义杂项设备结构体 static struct miscdevice my_led_misc { .minor MISC_DYNAMIC_MINOR, // 动态分配次设备号 .name my_led, // 注册后生成 /dev/my_led .fops led_fops, // 绑定操作接口 }; // 3. 入口函数一键注册 static int __init led_init(void) { return misc_register(my_led_misc); } // 4. 出口函数一键注销 static void __exit led_exit(void) { misc_deregister(my_led_misc); } module_init(led_init); module_exit(led_exit); MODULE_LICENSE(GPL);

更多文章