资讯动态

Linux驱动开发实战:从字符设备到设备树与I2C驱动

发布时间:2026/9/14 19:40:56 来源:尧图企业网站定制
做嵌入式Linux开发的人估计都经历过这种时刻板子上的某个外设死活不工作屏幕不亮、传感器读不到数据、网卡认不出来查了一圈硬件没有问题最后发现是驱动没加载或者驱动压根没写对。Linux设备驱动开发听着像一座大山——内核、系统调用、设备模型、中断、DMA随便一个词都能把人绕晕。但实际动手写过一两个模块之后你会发现它的核心思想非常朴素让内核认识你的硬件让应用程序能用你的硬件。这篇分享我不讲教科书式的长篇大论而是会从一个字符设备驱动入手把框架、设备树、I2C驱动、性能调优和常见坑都梳理一遍。内容更适合刚接触驱动开发的同学也适合想系统整理驱动知识、正在准备Linux相关岗位面试的工程师参考。1. 从零理解Linux设备驱动它到底解决什么问题1.1 驱动的本质内核与硬件之间的“翻译官”很多人初学驱动时会把驱动想得很神秘其实它的作用就是一句话在内核和具体硬件之间做翻译。硬件厂商不会给Linux内核写一套统一接口每个芯片的寄存器布局、控制逻辑都不同而内核又不能把这些“方言”直接暴露给应用程序所以需要驱动这个中间层把千奇百怪的硬件操作统一成read、write、ioctl这类标准接口。打个比方你买了一个国际品牌的插座转换头墙上的插孔是统一的但每个国家电器的插脚形状不一样。转换头就是驱动它负责把不同形状的插脚转换成墙上统一的插孔标准。应用程序就是“电器”它只管拿着标准的插头去插不需要关心对面是USB设备、I2C传感器还是PCIe网卡。在Linux里驱动运行在内核态有最高的硬件访问权限。用户态程序通过系统调用陷入内核内核根据打开的设备文件找到对应的驱动再调用驱动里注册好的函数去操作硬件最后把结果返回给用户态。整个流程就是这样一层一层下来的。1.2 驱动开发的实际应用场景与适合人群驱动开发绝不是内核维护者的专利。这几年国产化替代趋势明显ARM架构的嵌入式设备遍地都是从工业控制、车载系统到智能家居底层跑的几乎都是Linux。硬件要跑起来第一关就是驱动。用I2C接个温湿度传感器、用SPI接个LCD屏、用GPIO接个按键这些常见的嵌入式需求都需要写驱动。除了嵌入式X86平台上也有不少场景。比如某些特殊采集卡、自定义PCIe设备、非标USB设备厂商只给Windows驱动Linux下的驱动要么自己写要么找开源替代这时候懂驱动的价值就体现出来了。另外一些做性能优化的团队也会通过内核模块去监控IO行为、跟踪系统调用这也需要驱动开发的基本功。适合学这个方向的人首先是嵌入式软件工程师驱动几乎是必修课其次是搞Linux系统运维和底层开发的理解了驱动对内核日志、设备节点、硬件故障排查会有完全不同的视角最后是准备面试的开发者驱动相关的问题字符设备、设备树、platform驱动在大厂Linux岗位面试中出现的频率相当高。2. 字符设备驱动框架驱动开发的第一课2.1 三类设备的本质区别Linux把设备分成三大类这个分类不是按硬件形态分的而是按数据交互方式分的。字符设备是一个字节一个字节读写比如鼠标、键盘、串口块设备是以块为单位读写比如硬盘、SD卡数据可以随机访问网络设备则走socket接口数据是包转发的方式不通过设备节点暴露给用户。驱动开发新手一定从字符设备入手。为什么因为它最简单不需要考虑缓存管理和随机访问打开、关闭、读、写四个函数就能撑起一个完整驱动。而且字符设备的概念最直接设备节点在/dev下面应用层open它就是打开一个文件read/write它就是读写数据逻辑非常直观。块设备驱动要处理请求队列、bio结构、调度算法网络驱动要处理NAPI、SKB、协议栈交互这些对新手来说都是劝退级别的复杂度。相比之下字符设备框架清楚、代码量小、出问题好定位把字符设备打通了再来理解其他类型的设备会轻松很多。2.2 设备号主设备号与次设备号背后的设计逻辑应用程序是通过设备节点访问设备的而设备节点的核心是设备号。设备号是一个dev_t类型的数据高12位是主设备号低20位是次设备号。主设备号用来标识驱动内核通过它找到对应的file_operations次设备号用来标识同一驱动管理下的不同设备实例。主设备号的分配有两种方式静态指定和动态分配。静态指定就是自己挑一个没被占用的号码直接register_chrdev_region好处是设备节点可以固定预测坏处是有可能跟已有驱动冲突而且一个驱动占一个主设备号对系统资源是种浪费。动态分配用alloc_chrdev_region内核自动给你分配一个空闲的主设备号完全避免冲突代价是设备号不确定需要靠udev或mdev自动创建节点。我在实际项目中几乎都选择动态分配。原因很简单现代Linux系统里设备节点的创建由udev负责它会根据内核上报的uevent自动在/dev下建节点完全不需要人为知道主设备号。自己在代码里通过打印或者/sys目录查看实际分配的设备号就行。这里要补充一个新手容易忽略的点次设备号在驱动里怎么用。如果你的驱动要管理多个同类硬件比如四个串口那就申请四个次设备号然后在open函数里通过iminor(inode)拿到次设备号区分当前打开的是哪个串口。如果只有一个设备次设备号固定填0就行但框架上要留好扩展空间。2.3 file_operations驱动与应用之间的接口契约file_operations是驱动开发里最重要的结构体它定义了应用层调用read、write、open、close时内核底层实际执行的两个函数。应用程序对设备文件的一切操作最终都会落到这个结构体的函数指针上。结构体里的成员非常多但新手不需要全部实现。最核心的是这几项owner、open、read、write、release再加上一个unlocked_ioctl应付大部分场景足够了。owner必须设为THIS_MODULE目的是告诉内核这个结构体属于哪个模块防止设备正在使用时模块被卸载掉导致函数指针悬空。open和release一个负责初始化、一个负责清理很像面向对象里的构造函数和析构函数。read和write则是数据交换的核心特别要注意的是这两个函数的参数里有一个loff_t *offset表示当前的读写位置。你需要在每次读写后手动更新它否则应用层反复read会一直从头读到相同的数据这个细节对写虚拟设备、数据缓存类驱动特别重要。还有copy_to_user和copy_from_user这两个函数。内核空间不能直接用memcpy去拷贝用户态传入的指针因为用户态的地址在内核态不一定有效直接用会引发段错误甚至崩溃。必须用这两个专用函数做安全拷贝拷贝失败时返回-EFAULT应用层会收到一个经典的Bad address错误。3. 手写一个完整的字符设备驱动3.1 从模块加载到卸载的整体骨架Linux驱动一般以模块形式存在可以动态加载不用每次改代码都重编内核。入口函数用module_init声明模块被insmod时执行出口函数用module_exit声明rmmod卸载时执行。模块加载后驱动代码在内核态运行拥有完全权限所以写驱动时一个NULL指针、一个越界都可能直接让系统panic而不像用户态程序只是崩掉自己的进程。下面这个例子是一个最简单的字符设备驱动。它维护了一块64字节的内核缓冲区用户写进去什么读出来就是什么。虽然功能很弱但驱动开发需要接触的关键环节都覆盖了#include linux/init.h #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 DEVICE_NAME mydemo #define CLASS_NAME mydemo_class static dev_t dev_num; static struct cdev my_cdev; static struct class *my_class; static struct device *my_device; static char kernel_buffer[64] hello from kernel; static int my_open(struct inode *inode, struct file *filp) { printk(KERN_INFO mydemo: open called\n); return 0; } static ssize_t my_read(struct file *filp, char __user *buf, size_t len, loff_t *offset) { size_t size strlen(kernel_buffer); if (*offset size) return 0; if (len size - *offset) len size - *offset; if (copy_to_user(buf, kernel_buffer *offset, len)) return -EFAULT; *offset len; return len; } static ssize_t my_write(struct file *filp, const char __user *buf, size_t len, loff_t *offset) { if (len sizeof(kernel_buffer)) len sizeof(kernel_buffer) - 1; if (copy_from_user(kernel_buffer, buf, len)) return -EFAULT; kernel_buffer[len] \0; return len; } static int my_release(struct inode *inode, struct file *filp) { printk(KERN_INFO mydemo: release called\n); return 0; } static struct file_operations fops { .owner THIS_MODULE, .open my_open, .read my_read, .write my_write, .release my_release, }; static int __init my_init(void) { if (alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME) 0) { pr_err(mydemo: alloc region failed\n); return -1; } cdev_init(my_cdev, fops); my_cdev.owner THIS_MODULE; if (cdev_add(my_cdev, dev_num, 1) 0) { unregister_chrdev_region(dev_num, 1); return -1; } my_class class_create(THIS_MODULE, CLASS_NAME); my_device device_create(my_class, NULL, dev_num, NULL, DEVICE_NAME); pr_info(mydemo: init done, major%d minor%d\n, MAJOR(dev_num), MINOR(dev_num)); return 0; } static void __exit my_exit(void) { device_destroy(my_class, dev_num); class_destroy(my_class); cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); pr_info(mydemo: exit done\n); } module_init(my_init); module_exit(my_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple char device demo);初始化流程是固定的套路先申请设备号再注册cdev然后创建class和设备节点。cdev_add之后这个驱动就跟设备号绑定了可以响应系统调用。class和device_create的作用是让内核自动生成ueventudev收到后会在/dev下自动创建mydemo节点省去手动mknod的麻烦。这里要提一个内核版本差异的坑class_create的接口在不同内核版本之间变过。老版本6.3及以前调用方式通常是class_create(THIS_MODULE, CLASS_NAME)新版本里第一个owner参数被丢弃直接class_create(CLASS_NAME)就行。如果你把老教程的代码直接拿到新内核上编译大概率编译不过改成新接口即可。3.2 设备节点的创建与应用程序交互设备节点的创建在旧时代是手动用mknod比如mknod /dev/mydemo c 240 0c表示字符设备240是实际分配的主设备号。这种方式费劲且容易出错现在基本都被自动创建取代了。自动创建的机制依赖sysfs与udev。驱动的class_create会在/sys/class下生成一个类目录device_create注册具体设备时内核会向用户空间的udev守护进程发送ueventudev根据规则在/dev下创建节点。如果你用的是busybox的精简系统可能需要mdev或直接自己解析uevent那时候手动mknod反而更可控。驱动加载成功之后应用层操作就变得很简单了。写一个测试程序open设备节点write一段数据再read出来#include stdio.h #include fcntl.h #include unistd.h #include string.h int main(void) { char buf[64] {0}; int fd open(/dev/mydemo, O_RDWR); if (fd 0) { perror(open); return -1; } write(fd, hello from user, 15); read(fd, buf, sizeof(buf)); printf(read: %s\n, buf); close(fd); return 0; }编译运行后会看到read返回的是hello from user。如果不更新offset这个程序会出问题第一次read可能返回空或者反复读取到相同内容。这也是为什么我前面反复强调offset必须自己维护的原因很多功能看起来正常的驱动在这种连续读写场景下就会暴露缺陷。3.3 Makefile与编译加载全流程编译驱动模块不像编译普通C程序那么简单它不是直接调用gcc而是要借助内核的Kbuild构建系统。Kbuild会进入内核源码目录读取当前内核的配置把模块和内核正确地链接起来。最简Makefile就三行核心内容obj-m : mydemo.o KERNEL_DIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERNEL_DIR) M$(PWD) cleanobj-m表示这个目标文件要编译成外部模块。-C指定进入内核源码目录M指定模块源码所在目录。如果目标板是ARM架构需要额外指定交叉编译工具链和ARCH参数比如ARCH ? arm CROSS_COMPILE ? arm-linux-gnueabihf-模块编译好之后生成的是.ko文件。insmod mydemo.ko加载驱动lsmod查看是否加载rmmod mydemo卸载。modprobe也可以加载但它会处理依赖关系需要把模块放到标准路径或者用depmod更新依赖数据库。注意加载驱动前建议先用dmesg观察启动日志insmod之后立刻dmesg | tail查看有没有报错。内核模块一旦崩溃是直接panic的不会给你任何挽救机会所以每次加载前都要确认代码里没有明显的指针问题。4. 设备树与平台驱动模型总线时代的驱动方式4.1 设备树让内核知道硬件长什么样早期的ARM Linux板级硬件信息全都写在C文件里改一个GPIO要重新编译内核维护起来简直是灾难。设备树Device Tree的出现改变了这一切。设备树用dts文件描述硬件的拓扑结构、寄存器地址、中断号等信息编译成dtb后由bootloader传给内核内核解析设备树后就知道当前机器有哪些设备、各自用什么驱动。设备树的基本结构是一个树形节点。一个根节点下面是各种总线节点总线下面挂具体外设。以I2C设备为例i2c1 { status okay; temp_sensor48 { compatible lm75; reg 0x48; }; };compatible属性是驱动匹配的关键。内核中的驱动通过of_match_table告诉内核“我支持哪些设备”设备树里的compatible告诉内核“我是什么设备”。两者字符串一致驱动就会匹配上。reg表示设备在总线上的地址I2C从设备就是7位地址。写设备树最容易犯的错误是compatible字符串不一致。你在设备树里写lm75驱动里匹配表写的却是lm75a那驱动的probe永远不会被调用。排查这类问题查看/sys/firmware/devicetree/base下面的目录能直观看到实际解析出来的设备树内容。4.2 平台驱动从probe函数开始的驱动平台驱动platform driver是Linux设备模型中核心的一种驱动类型。它的特点是驱动和设备分离驱动只负责描述“我能处理什么设备”和“设备被找到后怎么初始化”至于设备本身在哪里、用什么方式注册由设备树或ACPI表决定。匹配成功后内核调用驱动的probe函数probe是驱动真正开始工作的起点。一个平台驱动的基本框架static int my_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; res platform_get_resource(pdev, IORESOURCE_MEM, 0); base devm_ioremap_resource(pdev-dev, res); pr_info(my_platform: probe success\n); return 0; } static int my_remove(struct platform_device *pdev) { pr_info(my_platform: remove\n); return 0; } static const struct of_device_id my_of_match[] { { .compatible vendor,mydevice }, { } }; MODULE_DEVICE_TABLE(of, my_of_match); static struct platform_driver my_platform_driver { .probe my_probe, .remove my_remove, .driver { .name my_platform, .of_match_table my_of_match, }, }; module_platform_driver(my_platform_driver); MODULE_LICENSE(GPL);probe函数里通常做这些事情从platform_device的resource里获取寄存器物理地址用ioremap映射到内核虚拟地址注册中断处理函数初始化硬件比如配置GPIO、复位芯片最后创建字符设备或注册其他子系统接口。为什么强调probe是驱动起点而不是init因为init只代表模块被加载了不代表硬件一定存在。在设备树机制下即使没有对应硬件模块也能insmod成功但probe不会被调用。很多新手在内核日志里看不到probe信息就一脸懵实际上是compatible不匹配或者设备树没有使能这个节点。4.3 I2C设备驱动详解从adapter到clientI2C总线是嵌入式设备里最常用的低速总线挂的传感器、EEPROM、RTC多得数不过来。Linux的I2C子系统分成三块adapter代表I2C控制器也就是总线本身client代表挂在总线上的从设备driver代表从设备的驱动。总线驱动负责收发时序设备驱动负责解析数据两者靠i2c_driver结构体绑定。I2C设备驱动的注册函数是i2c_add_driver一般情况下直接调用module_i2c_driver这个宏就行。设备驱动需要实现probe和id_tableid_table用于匹配从设备既支持设备树匹配也支持传统的i2c_device_id匹配static const struct i2c_device_id lm75_id[] { { lm75, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, lm75_id); static const struct of_device_id lm75_of_match[] { { .compatible lm75 }, { } }; MODULE_DEVICE_TABLE(of, lm75_of_match); static int lm75_probe(struct i2c_client *client, const struct i2c_device_id *id) { u8 reg 0x00; s16 val; int ret; ret i2c_master_send(client, reg, 1); if (ret 0) return ret; ret i2c_master_recv(client, (u8 *)val, 2); if (ret 0) return ret; pr_info(lm75: temperature %d.%d C\n, val / 256, (val % 256) * 100 / 256); return 0; } static struct i2c_driver lm75_driver { .driver { .name lm75, .of_match_table lm75_of_match, }, .probe lm75_probe, .id_table lm75_id, }; module_i2c_driver(lm75_driver); MODULE_LICENSE(GPL);i2c_master_send和i2c_master_recv是I2C设备驱动里最常见的收发函数。它们由I2C核心层根据client绑定的adapter调用具体控制器的传输函数对驱动作者来说屏蔽了总线时序细节。有些场景需要连续操作比如先发送寄存器地址再读数据i2c_transfer加i2c_msg数组会更高效。调试I2C驱动时i2cdetect是神器。它会扫描总线上的所有地址并显示哪些从设备有ACK响应第一时间确认硬件连接和从设备地址是否正确。很多I2C驱动出问题根本不是代码问题而是设备地址被7位和8位搞混了i2cdetect -y 1扫一遍直接定位。5. 进阶技巧动态管理file_operations与性能调优思路5.1 为什么要挂钩file_operations怎么挂钩正常情况下file_operations在驱动编译时通过cdev_init注册之后不再变化。但实际开发中会碰到一些特殊需求比如想临时监控某个设备文件的读写行为、想在现有驱动上叠加日志、不想重新编译驱动就做审计过滤这些场景都可能涉及动态修改file_operations。实现思路不复杂先拿到目标设备的struct file把里面的f_op指针备份一份然后替换成我们定义的file_operations替换后的结构里read、write先调用原来的函数再插入监控逻辑static struct file_operations orig_fops; static ssize_t hook_read(struct file *filp, char __user *buf, size_t len, loff_t *offset) { ssize_t ret; pr_info(hook: read called, len%zu\n, len); ret orig_fops.read(filp, buf, len, offset); return ret; } static int __init hook_init(void) { struct file *filp filp_open(/dev/mydemo, O_RDONLY, 0); if (IS_ERR(filp)) return PTR_ERR(filp); memcpy(orig_fops, filp-f_op, sizeof(struct file_operations)); ((struct file_operations *)filp-f_op)-read hook_read; filp_close(filp, NULL); return 0; }但这个做法有两个很大的坑。第一struct file_operations所在的内存可能被内核标记为只读直接写会触发缺页异常。第二多核系统中其他CPU可能正在并发读取f_op的函数指针无锁修改会造成看不到一致的函数指针。所以生产环境下真要挂钩更稳妥的方式是使用kprobes或者内核自带的安全钩子框架。kprobes可以在不修改原函数的情况下在函数入口插入探测回调非常安全。直接把f_op替换成钩子函数的手法我建议只用于调试和教学场景不要把它当成生产方案。另外要特别说明动态挂钩file_operations这个技术本身是双刃剑。合法的使用场景包括驱动调试、IO行为统计、沙箱文件过滤。但如果用于隐藏系统行为、绕过安全机制那就是rootkit的行为这也是我为什么强调要优先用kprobes这类内核提供的正规机制尽量不要碰直接改写结构体指针这种危险动作。5.2 中断、延时与性能调优的实战思路驱动性能调优和用户态完全不是一个世界级。在用户态性能瓶颈往往在磁盘IO、网络延迟你可以用perf去采样分析。在内核态一个睡眠函数、一次忙等待、一个不合理的锁都可能让整个系统的实时性崩塌。中断处理是第一个要注意的分水岭。中断上半部处理紧急的硬件事件要求短平快里面绝对不能调用可能睡眠的函数比如kmalloc(GFP_KERNEL)、mutex_lock、msleep这些函数放在中断上下文里要么死锁要么直接报错。需要复杂处理的场景要把工作推迟到下半部常用的有tasklet、workqueue和threaded irq三种机制。workqueue因为能在进程上下文运行、睡眠不受限制是处理复杂中断业务的优先选择。延时也是讲究细节的。忙等待udelay在短延时微秒级时很常用但它会让CPU空转时间一长就是灾难。毫秒级延时优先用usleep_range或msleep它们会让出CPU让其他任务得到调度机会。我踩过的一个典型坑是在probe里用udelay(100000)延长了100毫秒的等待整机启动慢了不说CPU负载还异常高改成msleep(100)之后一切正常。算法部署和数据处理方面高频数据采集场景下驱动经常成为瓶颈。copy_to_user每次从内核拷贝到用户态的性能开销不小如果数据量大可以考虑三种优化路径第一是启用mmap把内核缓冲区直接映射到用户空间零拷贝读取代价是缓存一致性和同步要自己管第二是用DMA传输让硬件直接把数据搬到内存绕过CPU干预第三是合并小包语义一次中断处理批量上报数据减少系统调用次数。具体选哪种取决于数据量级、实时性要求和你愿意承担的复杂度。6. 常见问题与排查技巧实录6.1 模块加载失败的典型原因insmod失败是我看到的最密集的新手问题这里整理几个高频原因和排查思路。第一个是Unknown symbol。内核模块之间调用导出函数时如果符号找不到就会报这个错。常见原因是依赖的模块没有先加载或者新模块用到的符号没有EXPORT_SYMBOL导出。可以用nm查看.ko文件里未解析的符号确认它属于哪个模块。第二个是version magic不匹配。模块编译时的内核版本和当前运行内核版本不一致加载时会直接拒绝。这种情况基本发生在自己编译内核之后没有重新编译外部模块。解决方法是重新编译模块或者给内核加CONFIG_MODVERSIONSY并使用modversion机制兼容。第三个是加载顺序问题。你的模块依赖另一个模块提供的函数但依赖模块还没加载。用insmod逐个加载时很容易出这问题换成modprobe会自动处理依赖。如果是自研模块建议用depmod -a更新依赖数据库再通过modprobe加载。第四个是权限问题模块文件权限不对或者SELinux限制insmod会直接报Operation not permitted。排查时先确认文件权限再通过dmesg查看有没有AVC拒绝日志。6.2 设备节点不生效的排查模块加载正常lsmod也能看到但/dev下没有设备节点或者open返回No such device这类问题也不少。首先要确认udev有没有收到uevent。可以在加载模块前先运行udevadm monitor然后insmod如果设备节点创建流程正常终端会实时刷出add事件。如果没有任何事件输出则说明class_create或者device_create没有走通查代码里这两步的返回值。其次是设备号不匹配。如果设备树或系统里之前有同名设备残留的旧节点指向了错误的主设备号open就会失败。用ls -l /dev/mydemo看看节点的主设备号再用cat /proc/devices查内核实际分配的主设备号二者不一致就是管理残留问题rmmod后清理节点再重新加载。最后是权限问题。如果节点存在但open返回Permission denied那是udev规则里没有给这个设备足够的权限。临时测试可以直接chmod 666 /dev/mydemo正式方案写一条udev规则根据KERNEL或者SUBSYSTEM匹配设备并设置权限。6.3 I2C通信异常的排查I2C是低速总线起了波形问题往往出在地址、速率和上拉电阻这些基础环节。最推荐的排查顺序是先用i2cdetect -y bus号扫描总线。命令输出里所有显示UU的地址代表已经被驱动占用显示数字的地址代表有从设备在ACK响应显示--的地址则是没有设备。如果i2cdetect什么都扫不到先量总线上的上拉电阻和电平I2C总线的SDA和SCL必须有上拉否则通信起来就是波形不完整。如果i2cdetect能扫到设备但你的驱动probe不触发重点排查compatible是否匹配。从设备树解析路径入手查看/proc/device-tree/i2cxxx下的子节点是否存在compatible字符串跟驱动of_match_table是否一字不差。注意有些传感器芯片有多种型号变体比如LM75家族很多芯片都兼容LM75地址但compatible名称却是tmp75之类的需要根据实际芯片手册填写。如果probe触发但读写失败最常见的是设备地址7位、8位混淆。I2C从设备的7位地址比如0x48在Linux中i2c_client的addr用的就是7位值但很多芯片手册里写的是8位地址0x90因为它在7位地址后面拼了一个读写位。看到0x90这种地址需要右移一位再填进设备树reg。6.4 驱动稳定性并发、内存与竞态驱动跑着跑着整个系统卡死或者崩溃这类稳定性问题排查起来最头疼。很多情况下不是逻辑写错了而是并发访问没有保护。多核系统里同一个设备可能被多个进程同时打开中断也可能在任意时刻触发。如果驱动里有一个全局缓冲区两个进程同时write就会产生数据覆盖中断处理函数和数据采集线程同时操作同一个链表就会崩溃。最基础的防护是对共享数据加锁进程上下文用mutex中断上下文用spin_lock不能混用。mutex睡在中断里会死锁spin_lock在用户态长时间持有则会浪费CPU二者都要注意临界区要短。内存方面最常见的坑是kmalloc之后没有配对kfree。驱动一旦加载kmalloc分配的内存在模块卸载前不会自动回收泄漏一次不明显长时间运行后可用内存越来越少最终导致系统OOM。排查时可以用slabinfo或kmemleak工具扫描。另一个坑是分配时用了GFP_KERNEL标志却放在了中断上下文里这种场景必须用GFP_ATOMIC。GFP_KERNEL可能睡眠在中断处理函数里调用就是潜在的崩溃源。还有一个容易被忽略的稳定性问题是copy_from_user和copy_to_user的判断。这两个函数在传入非法用户地址时依赖page fault机制决定是否返回错误。如果用户态传入一个野指针返回值是负数驱动把返回值忽略掉继续使用未初始化的缓冲区数据就会出现数据随机错乱的诡异Bug。每次拷贝之后强制检查返回值这应该是驱动代码的铁律。结合我自己做的设备来看驱动开发在嵌入式项目里往往是最后一块硬骨头。硬件设计有问题、设备树写错了、中断号冲突、电源时序不对这些问题全都会堆在驱动阶段暴露出来。一个驱动调试几天的原因经常不是代码不行而是没有用系统的排查方法按层定位。先从设备树解析确认硬件被内核看到了再用i2cdetect或类似工具确认总线通信正常最后才回到驱动代码里打日志验证逻辑。按这个顺序走下去大部分问题都能很快收口。最后再分享一个小技巧平时调试驱动时我习惯在内核启动参数里加loglevel8让printk的信息直接输出到控制台。这样不用每次insmod之后都手动dmesg能省不少事。等驱动稳定了再把这参数去掉避免生产环境下刷屏影响性能。驱动开发是一门需要反复踩坑磨出来的手艺希望这篇分享能帮你少走一段弯路也算是我这几年在里面摸爬滚打的一点心得。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价