Linux驱动开发模板:从字符设备到平台驱动的完整套路

发布时间:2026/10/17 13:27:32
Linux驱动开发模板:从字符设备到平台驱动的完整套路
写Linux驱动说来说去一半以上都是模板活。我刚接手第一个驱动项目时被file_operations、cdev_add、platform_driver这些名字砸得晕头转向后来翻了几份参考实现的源码发现大家套路其实差不多先注册一个字符设备或平台驱动把回调函数填好再处理数据通路。这篇文章把我这些年一直在用的那套驱动模板从头到尾拆开讲一遍适合刚入门的内核开发者也适合那些已经能编译过但总觉得结构缺了点什么的驱动工程师。1. 驱动模板的核心价值同一套骨架应对八成场景很多人觉得套模板是不够专业的表现写驱动应该每一行都从原理推出来。真不是这样。内核本身就希望开发者按模板来写它把设备模型抽象好了把资源生命周期管理好了你只是在规定的位置填上硬件相关的逻辑。模板不是偷懒是行走在内核设计者铺好的路上。1.1 内核设备模型给驱动留下的模板口子Linux驱动开发中内核并不是为每个设备准备一套完全独立的接口而是把设备分为几类。字符设备对外表现为文件使用open/read/write/ioctl这类熟悉的操作块设备走block_layer网络设备走net_device。无论硬件连接方式多特殊驱动最终都要向内核注册一个结构体结构体里塞满回调函数。比如字符设备核心就是struct file_operations。你在里面填.read my_read用户层调用read时内核就会通过VFS把请求分发到你写的my_read。这个分发机制由内核完成驱动侧只需要保证函数签名正确。所以模板的骨架本身就是设备模型设计出来的一部分你沿用它是应该的。1.2 哪些场景最值得保留模板常见的驱动模板大致可以归成下面几类驱动类型典型用途模板核心字符设备传感器、LED、串口扩展、虚拟设备cdev_add file_operations平台驱动SoC内置外设、GPIO控制器、片上外设platform_driver probeI2C/SPI驱动挂在外设总线上的芯片i2c_driver/spi_driver probe块设备存储介质block_device_operations request过程网络设备有线/无线网卡net_device_ops open/xmit实际项目里字符设备和平台驱动这两类模板复用率最高。很多外设驱动最终都会落到既是一个平台驱动也创建一个字符设备的混合结构。先把这两套骨架写熟后面看I2C、SPI驱动时会发现思路完全一致只是注册的bus接口换成i2c_driver或spi_driver。2. 字符设备驱动的万能骨架从module_init到fops一步步拆解字符设备模板的江湖地位最高因为Linux哲学是一切皆文件。设备一旦变成了/dev下的节点应用层就能用最熟悉的open/read/write去操作。下面这个模板可以直接用我按平时新同事入职时给的版本稍微精简了一下。2.1 最小的file_operations和全局结构#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 MY_DEVICE_NAME mydrv #define MY_CLASS_NAME mydrv_class static dev_t my_devno; static struct class *my_class; static struct cdev my_cdev; static int my_open(struct inode *inode, struct file *filp) { return 0; } static ssize_t my_read(struct file *filp, char __user *buf, size_t size, loff_t *offset) { return 0; } static ssize_t my_write(struct file *filp, const char __user *buf, size_t size, loff_t *offset) { return size; } static int my_release(struct inode *inode, struct file *filp) { return 0; } static const struct file_operations my_fops { .owner THIS_MODULE, .open my_open, .read my_read, .write my_write, .release my_release, };这个fops就是模板最核心的部分。注意.owner THIS_MODULE不能丢它告诉内核这个模块在使用中时不能被强制卸载不然用户持着fd时你rmmod后续调用会踩空指针。open/release最少要有哪怕里面return 0也能保证用户程序正常打开关闭。2.2 设备号、cdev和设备节点的初始化字符设备必须有一个设备号。推荐动态分配因为主设备号是有限的你不知道目标系统上哪个号空闲static int __init my_init(void) { int ret; ret alloc_chrdev_region(my_devno, 0, 1, MY_DEVICE_NAME); if (ret) return ret; cdev_init(my_cdev, my_fops); my_cdev.owner THIS_MODULE; ret cdev_add(my_cdev, my_devno, 1); if (ret) goto err_unregister; my_class class_create(THIS_MODULE, MY_CLASS_NAME); if (IS_ERR(my_class)) { ret PTR_ERR(my_class); goto err_cdev_del; } device_create(my_class, NULL, my_devno, NULL, MY_DEVICE_NAME); pr_info(mydrv: registered, major%d\n, MAJOR(my_devno)); return 0; err_cdev_del: cdev_del(my_cdev); err_unregister: unregister_chrdev_region(my_devno, 1); return ret; } static void __exit my_exit(void) { device_destroy(my_class, my_devno); class_destroy(my_class); cdev_del(my_cdev); unregister_chrdev_region(my_devno, 1); pr_info(mydrv: unloaded\n); } module_init(my_init); module_exit(my_exit); MODULE_LICENSE(GPL); MODULE_DESCRIPTION(A simple character device template);alloc_chrdev_region自动挑一个空闲的主设备号返回的dev_t是主设备号次设备号的组合。后面的cdev_init和cdev_add是把fops和设备号绑定。再往后class_create和device_create是锦上添花但实际几乎必需只要系统里有udev或者mdev插入模块后/dev/mydrv会自动出现不需要你手写mknod。2.3 旧接口和新接口的取舍网上很多老模板用register_chrdev一步到位内部同时完成设备号注册和cdev_add。这个接口在内核里仍然存在但它有两个麻烦一是分配到的设备号范围比较死板二是它把一个主设备号绑定到固定fops不方便管理同一类设备的多个次设备号。用alloc_chrdev_region cdev_init cdev_add是更现代、更灵活的写法新代码建议直接照这个套路写。还要提醒一个细节device_create的返回值在完整实现里也必须检查IS_ERR我这里为了模板简洁没展开但真实代码别省。设备节点创建失败如果不管后面应用层访问会得到No such file or directory排查起来很困惑。3. 模板的活都藏在数据结构和回调节奏里骨架是死的一个驱动真正的性格在数据结构和回调处理上。你定义一个私有数据结构把所有状态、锁、缓冲区都放进去让open到release之间所有回调都能访问到它这是字符设备驱动最常见的做法。3.1 private_data怎么用看下面这个增强版结构struct my_dev_data { struct mutex lock; char buf[128]; size_t len; }; static struct my_dev_data mydata { .lock __MUTEX_INITIALIZER(mydata.lock), };在open里把它挂到file指针上static int my_open(struct inode *inode, struct file *filp) { filp-private_data mydata; return 0; }之后read/write/ioctl/release都能通过filp-private_data拿回这份数据。用container_of配合inode-i_cdev去反查结构体也是常见写法但单实例驱动直接filp-private_data mydata最直观。以后扩展成多实例时再改为container_of也不迟。3.2 read/write里最常见的逻辑read的通用套路是从内核缓冲区拷贝数据到用户空间返回拷贝长度返回0表示EOF。write的套路是从用户空间拷贝数据到内核缓冲区返回成功接受的字节数。一个可用的模板如下static ssize_t my_read(struct file *filp, char __user *buf, size_t size, loff_t *offset) { struct my_dev_data *data filp-private_data; ssize_t ret; mutex_lock(data-lock); if (data-len 0) { ret 0; goto out; } size min(size,>#define MYDRV_IOC_MAGIC M #define MYDRV_GET_LEN _IOR(MYDRV_IOC_MAGIC, 1, int) #define MYDRV_CLR_BUF _IO(MYDRV_IOC_MAGIC, 2)在fops里使用.unlocked_ioctl字段新内核已经把老式.ioctl移除了static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct my_dev_data *data filp-private_data; switch (cmd) { case MYDRV_GET_LEN: { int len >#include linux/platform_device.h #include linux/of.h #include linux/of_device.h #include linux/interrupt.h static int my_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; int irq; res platform_get_resource(pdev, IORESOURCE_MEM, 0); base devm_ioremap_resource(pdev-dev, res); if (IS_ERR(base)) return PTR_ERR(base); irq platform_get_irq(pdev, 0); if (irq 0) return irq; dev_info(pdev-dev, probe success, irq%d\n, irq); return 0; } static int my_remove(struct platform_device *pdev) { dev_info(pdev-dev, remove\n); return 0; } static const struct of_device_id my_of_match[] { { .compatible myvendor,my-device, }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_of_match); static struct platform_driver my_pdrv { .probe my_probe, .remove my_remove, .driver { .name my-device, .of_match_table my_of_match, }, }; module_platform_driver(my_pdrv); MODULE_LICENSE(GPL); MODULE_DESCRIPTION(A minimal platform driver template);这里直接用module_platform_driver宏它会自动生成module_init和module_exit内部调用platform_driver_register和platform_driver_unregister不需要你再手动写init/exit。很多人看到这个宏会觉得像魔法但它恰恰是模板的精髓把注册卸载的样板代码收起来只留下probe和remove两个业务点。4.2 probe就是驱动的构造函数probe被调用的时机是内核在总线上发现一个设备并且这个设备的compatible信息和驱动里的of_match_table匹配上。设备树里的节点不需要你初始化内核解析完device_node后会把资源信息映射成platform_deviceprobe函数只需要从struct platform_device *pdev里取资源。对应的设备树节点长这样my_device: my-device1e00000 { compatible myvendor,my-device; reg 0x1e00000 0x1000; interrupts 0 42 4; };注意compatible字符串必须和my_of_match[]里写的一模一样包含厂商前缀和型号反了或者拼错一个字符都不匹配。devm_ioremap_resource是devres机制里的函数它完成两件事把reg里的物理地址映射成内核虚拟地址并且在probe失败或者module卸载时自动释放映射。所以my_remove里不需要手动iounmap这是现代驱动模板要求的写法。老式模板常在remove里写一堆iounmap、kfree、release_mem_region在devm系列函数面前全都可以省掉。4.3 platform驱动和字符设备的组合拳实际项目里platform_driver的probe成功之后常常还要创建设备号、注册cdev、建class和device节点。也就是把第2章的字符设备模板搬进probe里资源释放搬进remove里。这是个非常经典的组合probe解析硬件资源初始化私有结构体注册cdev创建/dev节点remove删除/dev节点注销cdev释放资源fops回调真正操作硬件寄存器、处理中断、收发数据这种结构下struct my_dev_data可以多放几个硬件相关字段比如void __iomem *base、int irq、struct cdev cdev然后open里用container_of(inode-i_cdev, struct my_dev_data, cdev)拿到整个结构体。设备模型帮你管理生命周期你自己只关心业务。5. 模板里最容易写错的半套路部分并发、中断与休眠模板代码看着简单一加上并发和中断就会开始崩。原因在于驱动代码跑在进程上下文和中断上下文两套完全不同的环境里能用的锁和函数都不一样。这里是我看到新人犯错最多的区域。5.1 锁的选取mutex、spinlock还是atomic锁/同步手段是否允许睡眠使用场景注意事项mutex允许进程上下文临界区可能较长不可在中断上下文使用spinlock不允许持有期间睡眠中断/底半部临界区很短临界区里不能copy_to_user、不能kmalloc(GFP_KERNEL)、不能msleepatomic_t / atomic_long_t否简单计数标志位性能最好但只适合加减和交换wait_queue允许事件等待配合wait_event_interruptible使用检查返回值默认情况下read/write/ioctl里用mutex是最稳的选择。例如第3章的buffer读写模板如果换成spinlockcopy_to_user在锁内发生缺页睡眠整个系统都会卡住等待一个永远不能释放的锁最后触发内核故障。真正需要spinlock的地方是中断处理函数和tasklet里。顶半部发生硬件中断时CPU在处理中断这时候一旦睡眠系统直接panic。所以中断上下文只能用自旋锁、原子操作或者raw_spinlock。若中断处理函数要做的活儿太长应该用request_threaded_irq申请一个中断线程或者把heavy work丢给workqueue。5.2 wait_event_interruptible不能忽略返回状态驱动经常在read里等待设备准备好数据static int my_read(...) { struct my_dev_data *data filp-private_data; if (wait_event_interruptible(data-wq,>KERNELDIR ? /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) obj-m : mydrv.o all: $(MAKE) -C $(KERNELDIR) M$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M$(PWD) clean把mydrv.c和这个Makefile放在一个目录执行make。obj-m : mydrv.o表示内核构建系统会把mydrv.c编译成mydrv.ko。注意Makefile里$(MAKE)那两行的缩进必须是Tab字符不能用空格这是Makefile的硬性规定。KERNELDIR指向/lib/modules/$(uname -r)/build这个目录必须和正在运行的内核版本完全匹配。如果你在一个发行版上直接apt装了个linux-headers或者自己编了内核但没装headers编译时会报找不到include/generated/autoconf.h这类错误。6.2 加载、查看节点、测试读写编译成功后按顺序执行sudo insmod ./mydrv.ko dmesg | tail -20 ls -l /dev/mydrv cat /proc/devices | grep mydrv第2章的模板用alloc_chrdev_region动态分配主设备号所以dmesg会打印出major号。如果/dev/mydrv没有自动出现大概率是用户空间没有运行udev相关服务可以手动建节点major$(awk $2mydrv {print $1} /proc/devices) sudo mknod /dev/mydrv c $major 0记得首设备和次设备号传0。如果用了device_create通常是udev自动创建的路径是你在device_create第五个参数里写的名字。测试读写要看你的模板实现。用第3章的缓冲模板直接echo hello driver /dev/mydrv cat /dev/mydrvwrite通过copy_from_user把数据存进内核缓冲区read通过copy_to_user把它取出来。这样一通操作下来open/write/read/release全都被验证到了至少证明模板链路是通的。卸载模块sudo rmmod mydrv dmesg | tail -20卸载后/dev/mydrv节点应该消失。如果rmmod提示Device or resource busy说明还有进程持有fd先找到并关闭那个进程再卸载。6.3 insmod失败时怎么快速定位最常见错误基本就三类。第一类module verification failed: signature and/or required key。如果内核开启了强制模块签名未签名的模块会被拒绝。开发板上一般禁用签名校验或者你直接换到允许加载未签名模块的内核。第二类Invalid module format。模块是按某个内核版本的具体构建配置编出来的和当前运行内核不一致就会报错。别慌确认uname -r和KERNELDIR对应的版本一致重新make一次。第三类加载成功但设备节点不出现。这时先看dmesg有没有mydrv的打印如果init函数执行到device_create之前失败了后面节点不会出现。模板里我已经把错误路径展开了哪里ret非零就跟着那个goto走排查起来很清楚。调试阶段可以把pr_info换成pr_debug然后开启动态调试。以第6章的命令触发echo file mydrv.c p /sys/kernel/debug/dynamic_debug/control开了动态调试后dmesg里能看到pr_debug打出来的日志但正常运行时不刷屏。这个习惯非常好日志代码从一开始就埋好调试完不用删发布后也靠它远程定位。驱动模板这套东西越基础越值得多看几遍。我现在的习惯是先建一个空的字符设备驱动确认设备节点、读写、卸载都顺畅然后再往里面堆业务逻辑。如果一开始就试图把寄存器操作、中断、工作队列全部写在一起出问题根本不知道是模板问题还是逻辑问题。另一个心得是所有回调入口先补一个pr_debug后面定位驱动有没有被probe这种问题时能帮你滤掉一半的迷惑。