ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

嵌入式Linux字符设备驱动入门:从内核模块到读写并发

嵌入式Linux字符设备驱动入门:从内核模块到读写并发 做嵌入式Linux驱动开发很多人第一次打开内核源码树就懵了——drivers目录下成千上万个文件从哪儿下手我的建议非常直接从字符设备驱动开始。字符设备驱动是嵌入式Linux驱动开发里结构最清晰、反馈最直接的一类它不需要你立刻搞懂复杂的块层调度也不用先啃网络协议栈只要理解几个核心结构体就能让用户空间的read、write真正落到自己写的内核函数上。这种“看得见摸得着”的反馈对刚入门的同学来说特别重要。这篇文章面向的是有一点C语言和Linux命令行基础、想正式跨进内核态门槛的朋友。我会把字符设备驱动的设计思路、核心组件、完整代码、编译加载、用户态测试以及我在实际调试中踩过的坑一次性摊开讲清楚。学完你能独立写出一个可读写、带设备节点自动创建、能处理并发的字符设备驱动模块并且知道下一步该往设备树和平台驱动方向怎么走。1. 为什么字符设备驱动是入门第一站1.1 从内核态与用户态的分界线说起Linux把整个运行空间切成两块用户态和内核态。你在应用层写的printf、open、read其实只是系统调用的一层封装真正的硬件操作、内存管理、进程调度都发生在内核态。驱动程序的本质就是站在内核态这一侧替用户态程序去“摸”硬件。而系统调用进入内核之后怎么知道你调用的read该执行哪段代码答案就是通过设备号找到对应的驱动再通过file_operations结构体里的函数指针跳转到你写的函数。理解这条链路是入门的关键。字符设备驱动之所以适合作为第一个练手对象是因为它的链路最短用户态open(/dev/xxx)→ 内核VFS根据设备号找到cdev→ 调用fops-open。中间没有文件系统缓存、没有块层排队、没有网络协议封装。你写的函数被调用时参数是什么、返回什么都能立刻通过printk打印出来验证。这种即时反馈是学习内核编程最需要的东西。1.2 字符设备、块设备、网络设备三分法的由来内核把所有设备抽象成三类。字符设备以字节流为单位访问读写不经过缓存典型代表是串口、按键、LED、I2C从设备、各种传感器块设备以固定大小的数据块为单位访问有缓存层和请求队列典型代表是eMMC、SD卡、NAND网络设备不走/dev节点而是通过套接字接口访问走的是另一套net_device结构。初学者容易混淆的是一个硬件到底算字符设备还是块设备不取决于硬件本身而取决于你希望它对外呈现什么样的访问方式。比如SPI Flash如果你把它当作裸存储按块读写可以做成块设备mtdblock如果你只是读它的ID、状态寄存器那走字符设备接口就够了。我个人的经验是嵌入式项目里八成以上的自定义驱动都是字符设备因为传感器、执行器这类外设的访问模式天然就是“发命令、读寄存器”的字节流操作。1.3 一个最小可行的学习目标应该长什么样很多人学驱动容易贪多上来就想搞懂中断、DMA、设备树、电源管理结果两周过去连模块都没编译成功。我的建议是先立一个具体到能验收的小目标写一个名为mychardev的字符设备驱动加载后能在/dev下自动生成设备节点用户态程序往里面写字符串再读出来能得到刚才写进去的内容。就这一个目标涵盖了设备号申请、cdev注册、file_operations实现、内核与用户空间数据拷贝、设备节点自动创建这几个最核心的知识点。把这个目标跑通之后你再往上加功能就有底气了。加ioctl做命令控制、加互斥锁处理并发、加设备树匹配做多实例这些都是在这个骨架上做增量。反过来如果骨架都没搭稳直接去啃平台驱动很容易陷入“每个函数都认识连起来就不知道在干嘛”的状态。2. 驱动骨架的核心组件拆解2.1 设备号主次设备号的分配逻辑内核用dev_t类型来表示设备号它是个32位整数高12位是主设备号低20位是次设备号。主设备号标识“这个设备由哪个驱动负责”次设备号标识“这是该驱动管理的第几个具体设备”。比如你有三个LED可以共用一个主设备号用不同的次设备号区分。#define MAJOR(dev) ((unsigned int)((dev) 20)) #define MINOR(dev) ((unsigned int)((dev) 0xFFFFF)) #define MKDEV(ma, mi) (((ma) 20) | (mi))设备号有两种申请方式我强烈建议新手用动态分配静态指定register_chrdev_region(dev_t from, unsigned count, const char *name)需要你提前知道某个主设备号没被占用。内核里已经有一份官方的设备号分配表你自己随便挑一个主设备号很容易和现有驱动冲突加载时报Device or resource busy。动态分配alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name)由内核帮你找一个空闲的主设备号。缺点是每次加载主设备号可能不同但这正好可以配合自动创建设备节点解决。注意申请的设备号一定要在模块退出函数里用unregister_chrdev_region释放否则反复加载卸载会把设备号耗尽。2.2 file_operations用户空间系统调用与内核函数的映射表file_operations是字符设备驱动最核心的结构体你可以把它理解成一张“函数跳转表”。用户在应用层调用read(fd, buf, len)VFS 最终就是找到这个文件对应的fops-read去执行。常用的成员有这几个成员对应系统调用作用openopen()打开设备时初始化releaseclose()关闭设备时清理readread()从设备读取数据到用户空间writewrite()从用户空间写入数据到设备unlocked_ioctlioctl()执行设备专有命令llseeklseek()定位读写位置owner—填THIS_MODULE管理模块引用计数这里有个关键细节新手常忽略owner字段必须填THIS_MODULE。它的作用是当设备正在被某个进程打开时内核会通过这个字段增加模块引用计数防止你在设备使用期间rmmod卸载模块导致内核崩溃。忘了填卸载时就会遇到Module is in use或者更糟的段错误。2.3 cdev 与模块生命周期cdev结构体是内核对字符设备的抽象它把设备号和file_operations绑定在一起。申请完设备号之后需要三步把它注册进内核struct cdev my_cdev; cdev_init(my_cdev, my_fops); /* 绑定 fops初始化 cdev */ my_cdev.owner THIS_MODULE; /* 设置所属模块 */ cdev_add(my_cdev, devno, 1); /* 注册到内核count1 表示一个设备 */模块的入口和出口用module_init和module_exit宏声明。入口函数负责申请设备号、初始化并添加cdev、创建设备节点出口函数则是逆操作销毁设备节点、删除cdev、释放设备号。这两条路径必须严格对称任何一个资源申请在出口没释放都会造成内核资源泄漏而且这种泄漏不会立刻报错往往是反复加载卸载几十次之后系统才出问题。2.4 自动创建设备节点class 与 device早期的驱动需要用户手动mknod /dev/mychardev c 主设备号 0来创建设备节点非常麻烦。现在标准做法是用class_create和device_create让内核自动在/dev下生成节点这背后是 udev/mdev 在配合工作。static struct class *my_class; my_class class_create(mychardev_class); device_create(my_class, NULL, devno, NULL, mychardev);注意class_create的接口在 Linux 6.4 之后发生变化老版本是class_create(owner, name)两个参数新版本去掉了 owner 参数变成class_create(name)。编译报参数个数错误时先查一下你的内核版本。对应的清理函数是device_destroy和class_destroy。顺序是先销毁 device再销毁 class反了会有警告。这套机制的好处是你不再需要关心主设备号是多少用户始终用/dev/mychardev这个固定名字访问。3. 从零写一个可读写的字符设备驱动3.1 模块框架与头文件先把需要的头文件列清楚这些是字符设备驱动的标配#include linux/module.h #include linux/kernel.h #include linux/fs.h #include linux/cdev.h #include linux/uaccess.h #include linux/slab.h #include linux/mutex.h #include linux/device.h #define DEV_NAME mychardev #define BUF_SIZE 1024 static dev_t devno; static struct cdev my_cdev; static struct class *my_class; static char *kbuf; static int kbuf_len; static DEFINE_MUTEX(my_lock); MODULE_LICENSE(GPL); MODULE_AUTHOR(yourname); MODULE_DESCRIPTION(A simple char device demo);MODULE_LICENSE(GPL)不是可选项是必须项。不写的话内核会标记为“污染内核”很多内核符号无法使用调试时也会收到一堆警告。kbuf我们用一个全局缓冲区来暂存用户写入的数据DEFINE_MUTEX定义一个互斥锁后面处理并发时要用。3.2 open 与 release 的实现要点open和release通常不需要做太多事但有些细节值得留意static int my_open(struct inode *inode, struct file *file) { pr_info(mychardev: opened by pid %d\n, current-pid); return 0; } static int my_release(struct inode *inode, struct file *file) { pr_info(mychardev: released by pid %d\n, current-pid); return 0; }current是内核提供的一个宏指向当前进程的task_struct用它可以打印出是哪个进程打开了设备。这个技巧在调试多进程访问场景时非常好用。open返回0表示成功返回负数则用户态open会失败并设置errno。返回-EBUSY通常表示设备已被独占打开如果你希望设备同一时刻只允许一个进程访问可以在这个函数里做独占判断。心得不要小看 release 的调用时机。用户态close()、进程异常退出、甚至fork之后子进程退出都会触发 release。如果你在 release 里做资源释放要考虑清楚是“最后一个引用释放”还是“每次 close 都执行”。3.3 read/write 与内核空间数据拷贝这是整个驱动最核心也最容易出错的部分。关键点在于内核空间和用户空间的指针不能直接互相解引用必须用copy_to_user和copy_from_user这两个函数来搬运数据。static ssize_t my_read(struct file *file, char __user *buf, size_t count, loff_t *ppos) { int ret; if (mutex_lock_interruptible(my_lock)) return -ERESTARTSYS; if (*ppos kbuf_len) { mutex_unlock(my_lock); return 0; /* 已读到末尾返回0表示EOF */ } if (count kbuf_len - *ppos) count kbuf_len - *ppos; ret copy_to_user(buf, kbuf *ppos, count); if (ret) { mutex_unlock(my_lock); return -EFAULT; } *ppos count; mutex_unlock(my_lock); return count; } static ssize_t my_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos) { int ret; if (count BUF_SIZE) count BUF_SIZE; if (mutex_lock_interruptible(my_lock)) return -ERESTARTSYS; ret copy_from_user(kbuf, buf, count); if (ret) { mutex_unlock(my_lock); return -EFAULT; } kbuf_len count; *ppos 0; mutex_unlock(my_lock); return count; }这里有三个必须讲透的点。第一copy_to_user返回值是“未能拷贝的字节数”成功时返回0失败时返回非0。很多人习惯把它当普通返回值判断“非0即失败”这个逻辑是对的但要注意它返回的不是错误码所以你要手动返回-EFAULT。第二*ppos是读写位置偏移read要推进它否则用户程序循环读会一直读到同一段数据死循环。第三write里我故意把*ppos重置为0让每次写入都从缓冲区头部开始这符合“覆盖式写入”的语义具体怎么用要看你设备的实际需求。3.4 ioctl 的扩展给设备加命令通道read/write适合传数据但传控制命令就有点别扭。ioctl专为命令而生比如让设备复位、设置采样率、查询状态都适合走ioctl。#define MY_IOC_RESET _IO(m, 0) #define MY_IOC_GETLEN _IOR(m, 1, int) static long my_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { int len; switch (cmd) { case MY_IOC_RESET: mutex_lock(my_lock); kbuf_len 0; memset(kbuf, 0, BUF_SIZE); mutex_unlock(my_lock); break; case MY_IOC_GETLEN: mutex_lock(my_lock); len kbuf_len; mutex_unlock(my_lock); if (copy_to_user((int __user *)arg, len, sizeof(len))) return -EFAULT; break; default: return -ENOTTY; } return 0; }_IO、_IOR、_IOW这几组宏不是随便定义的它们把方向、类型、序号、数据大小编码进一个32位整数防止命令号冲突。m是自定义的类型标识字符你要保证在自己的驱动集合里不重复。默认分支返回-ENOTTY不是-EINVAL这是ioctl未实现命令的标准返回码。3.5 完整模块初始化、Makefile 与编译验证把上面所有零件组装进入口和出口函数static const struct file_operations my_fops { .owner THIS_MODULE, .open my_open, .release my_release, .read my_read, .write my_write, .unlocked_ioctl my_ioctl, }; static int __init mychardev_init(void) { int ret; kbuf kmalloc(BUF_SIZE, GFP_KERNEL); if (!kbuf) return -ENOMEM; ret alloc_chrdev_region(devno, 0, 1, DEV_NAME); if (ret 0) { kfree(kbuf); return ret; } cdev_init(my_cdev, my_fops); my_cdev.owner THIS_MODULE; ret cdev_add(my_cdev, devno, 1); if (ret 0) { unregister_chrdev_region(devno, 1); kfree(kbuf); return ret; } my_class class_create(DEV_NAME); if (IS_ERR(my_class)) { cdev_del(my_cdev); unregister_chrdev_region(devno, 1); kfree(kbuf); return PTR_ERR(my_class); } device_create(my_class, NULL, devno, NULL, DEV_NAME); pr_info(mychardev: init ok, major%d\n, MAJOR(devno)); return 0; } static void __exit mychardev_exit(void) { device_destroy(my_class, devno); class_destroy(my_class); cdev_del(my_cdev); unregister_chrdev_region(devno, 1); kfree(kbuf); pr_info(mychardev: exit done\n); } module_init(mychardev_init); module_exit(mychardev_exit);注意初始化里每一个失败分支都做了回滚。这个顺序性思维是内核编程的必备素养——内核不像应用层出错时没有垃圾回收你申请过的资源必须自己按相反顺序释放干净。编译用一份简单的 Makefileobj-m mychardev.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean嵌入式交叉编译环境下把KDIR换成你的交叉工具链对应的内核源码路径即可比如KDIR : /home/xxx/linux-kernel并指定ARCH和CROSS_COMPILE。4. 实测验证加载、测试与并发保护4.1 加载模块与 dmesg 观察编译成功后执行sudo insmod mychardev.ko然后用dmesg | tail看内核日志你应该能看到mychardev: init ok, majorX。接着ls -l /dev/mychardev确认设备节点自动生成了权限通常是crw-------。如果设备节点没出现八成是class_create或device_create那一步失败也可能你的系统用的是早期内核class_create要传THIS_MODULE。cat /proc/devices可以查看所有已注册的字符设备主设备号确认你的驱动确实注册进去了。4.2 用户态测试程序写一个简单的测试程序验证读写#include stdio.h #include fcntl.h #include unistd.h #include string.h #include sys/ioctl.h #define MY_IOC_RESET _IO(m, 0) #define MY_IOC_GETLEN _IOR(m, 1, int) int main(void) { int fd, len; char buf[64] hello chardev; fd open(/dev/mychardev, O_RDWR); if (fd 0) { perror(open); return -1; } write(fd, buf, strlen(buf)); ioctl(fd, MY_IOC_GETLEN, len); printf(kernel buffer len %d\n, len); lseek(fd, 0, SEEK_SET); /* 重置读位置 */ memset(buf, 0, sizeof(buf)); read(fd, buf, sizeof(buf) - 1); printf(read back: %s\n, buf); ioctl(fd, MY_IOC_RESET); close(fd); return 0; }编译成可执行文件运行正常输出应该是写入长度12、读回的字符串和写入一致。这里lseek那一步很关键因为前面write之后*ppos在我们实现里被重置了但如果你没重置read前一定要lseek回来否则读到的是空数据。4.3 并发访问下的互斥保护上面代码里的mutex_lock_interruptible不是装饰。考虑两个进程同时读写这个设备一个在write里往kbuf拷数据另一个在read里正往外拷如果不加锁kbuf_len和kbuf内容可能错乱用户读到半新半旧的数据。为什么用mutex而不是spinlock因为copy_to_user/copy_from_user可能触发缺页处理导致睡眠而spinlock保护的临界区里不允许睡眠。这一点是新手特别容易犯的错——在内核里用错锁系统可能直接卡死而且不报错。锁的选型原则是临界区里会睡眠就用mutex纯变量的极短操作才用spinlock。实测经验mutex_lock_interruptible相比mutex_lock多了可被信号打断的能力返回非0时要返回-ERESTARTSYS。对于可能长时间持有的锁用可中断版本更友好避免用户进程被 D 状态卡死无法 kill。5. 常见问题与排查技巧实录5.1 高频问题速查表我把这些年带新人时遇到最多的问题整理成表对照排查能省下大量时间现象可能原因排查与解决insmod报Invalid module format内核版本不匹配modinfo看 vermagic确认用当前运行内核的头文件编译加载后/dev无节点class/device 创建失败查 dmesg确认内核版本的class_create参数用户open报No such device设备号未注册成功cat /proc/devices确认主设备号存在read返回-EFAULT拷贝函数用错或指针非法确认用copy_to_user别直接解引用用户指针rmmod报Module is in usefops.owner没填或进程没关设备检查THIS_MODULE用lsof /dev/mychardev找占用进程系统卡死无响应临界区用了 spinlock 又睡眠改用 mutex检查是否有copy_*_user在自旋锁里反复加载卸载后失败设备号或内存泄漏确保退出函数释放所有资源顺序与申请相反5.2 几个文档里不会写的避坑心得第一个坑是printk的日志级别。默认级别会直接刷到控制台调试时如果驱动在中断里疯狂打印控制台会被刷爆。建议调试信息用pr_debug配合dynamic_debug或者至少加上KERN_DEBUG前缀把日志留在dmesg里而不是污染终端。第二个坑是缓冲区大小。我见过有人把BUF_SIZE定义成几MB测试没问题真实场景里kmalloc大块内存可能失败。驱动里的全局缓冲区一般保持小而够用超过一页的内存考虑用vmalloc或者干脆用read/write分段处理。第三个坑是错误码。返回-1在内核里是禁忌内核期望你返回标准的负错误码比如-ENOMEM、-EINVAL、-EFAULT。返回-1用户态拿到的是errnoEPERM完全误导排查方向。养成查errno-base.h的习惯用对错误码能让你自己的调试效率翻倍。独家技巧调试读写边界问题时在read/write入口打印count、*ppos、kbuf_len三个值一眼就能看出是越界还是偏移计算错误。这个习惯帮我定位过至少一半的拷贝类 bug。6. 往深了走设备树与平台驱动的衔接6.1 平台设备与字符设备的关系你现在写的驱动是“直接挂设备号”的裸字符设备。在真实嵌入式项目里更常见的做法是先写一个平台驱动通过设备树匹配硬件资源寄存器地址、中断号、GPIO再在probe回调里注册字符设备。这样同一个驱动可以匹配多个设备树节点实现多实例。为什么要这样分层因为裸字符设备写死了设备信息换一块板子就得改代码重新编译。平台驱动把“硬件描述”从代码里剥离出来交给设备树驱动本身只关心逻辑。这是嵌入式Linux驱动的标准范式也是你掌握字符设备之后最该迈出的下一步。6.2 设备树节点的基本写法一个最简单的设备树节点大概长这样my_device: my-device10000000 { compatible vendor,my-device; reg 0x10000000 0x1000; interrupts 0 32 4; status okay; };驱动里通过compatible字符串和of_device_id表匹配匹配成功后probe函数被调用参数带进来platform_device再用platform_get_resource、devm_ioremap_resource拿到寄存器基址platform_get_irq拿到中断号。整套流程把硬件资源和驱动逻辑彻底解耦。6.3 一条相对靠谱的嵌入式Linux驱动学习路线梳理一下我建议的推进顺序避免东一榔头西一棒子第一层字符设备驱动裸写、模块编译加载、/proc/devices观察——也就是本文内容。第二层ioctl命令完善、并发锁、poll/select支持、中断处理的下半部机制。第三层平台驱动与设备树匹配、probe/remove生命周期、devm_*资源管理。第四层sysfs属性节点、debugfs调试、regmap 框架、子系统接入如 IIO、input。第五层结合具体硬件做完整项目比如基于 I2C 的传感器驱动从设备树描述到用户态读取端到端打通。这条路线的好处是每一层都能独立跑通验证不会出现“学了三周还看不到效果”的挫败感。字符设备驱动是这条路的起点也是后面所有复杂驱动的基础骨架。我在实际带项目时最大的体会是字符设备驱动的价值不在于它本身多复杂而在于它把内核驱动编程的所有基本概念——模块生命周期、设备号、文件操作、内核与用户空间交互、并发保护——用最小的规模全展示了一遍。你把这套骨架吃透再去看 input 子系统、IIO 子系统会发现它们只是在这套骨架外面加了很多层封装内核结构体不同、注册流程不同但底层的“用户调用进内核、内核找到驱动函数”这条主线始终没变。所以别急着堆功能先把这一个最小的字符设备驱动反复写几遍闭着眼睛都能默出初始化回滚顺序你的内核感就真正建立起来了。
返回列表