Linux字符设备与I2C驱动开发实战:从设备树到中断调试 干过几年驱动开发的人都知道Linux设备驱动开发这活儿表面上看着就是写结构体、注册回调函数真正上手才发现坑全藏在细节里设备树匹配不上、中断申请失败、ioctl传参翻车、并发访问没锁导致随机崩溃。这篇文章把我从字符设备到设备树再到I2C驱动的完整实操过程梳理一遍包含可直接抄的代码框架、配置要点和踩坑记录适合刚入门嵌入式驱动的新手也适合做过一段时间但总感觉某些环节没吃透的同行参考。1. 驱动开发的核心思路与整体设计拆解1.1 先搞清楚驱动到底在解决什么问题很多初学者上来就翻内核源码结果被层层抽象绕晕。换个角度理解设备驱动本质上就是给硬件和应用程序之间搭一座桥。应用程序不会直接去操作物理寄存器内核也不允许用户态乱碰硬件地址驱动的作用就是把往寄存器写0x01这种底层操作封装成read、write、ioctl这类标准接口。换句话说驱动是硬件能力的翻译官。那内核态和用户态为什么要分这么清楚核心是稳定性与安全。用户态程序崩溃了顶多杀掉进程内核态代码出了问题直接整机panic。所以驱动代码运行在内核空间通过系统调用接口被用户态触发内核用权限隔离机制保证任何应用程序都无法直接触碰硬件。理解了这一层你就能明白为什么驱动开发必须关心并发、中断上下文、内存屏障这些东西——因为内核不像用户态那样给你兜底。从工作模式来看Linux驱动大体分成三大类字符设备、块设备、网络设备。字符设备按字节流读写键盘、串口、GPIO都属于这一类也是入门首选块设备以块为单位读写硬盘这类网络设备走socket接口网卡。实际项目中绝大多数业务都落在字符设备上所以这套框架必须吃透。1.2 方案选型为什么从字符设备驱动框架切入我见过不少人一上来就追新框架platform驱动还没搞明白就去看VFIO、IOMMU结果越学越虚。以我的经验字符设备框架是所有Linux设备驱动的基石。你回头看platform总线驱动、I2C驱动、SPI驱动它们的底层最终还是落到file_operations暴露给应用层设备模型再花哨最后也绕不开cdev_add这一步。而且字符设备驱动的调试手段非常直白用echo、cat就能完成交互验证不需要额外搭复杂的测试环境这对学习阶段的反馈闭环特别有利。从项目落地的角度来说很多实际产品——比如血氧仪方案、传感器采集模块、工业控制板卡——本质上都是字符设备逻辑应用程序打开设备节点读写数据偶尔发个ioctl配置参数。把字符设备的流程走通你就能覆盖一大半的嵌入式Linux开发需求。所以后面的实操部分我全部围绕这个主线展开设备树和I2C驱动也都是从字符设备的视野去延伸。1.3 开发环境搭建的几个关键选择驱动开发和普通应用开发的环境差别比较大需要在目标板上跑内核模块所以环境搭得好不好直接影响调试效率。我目前的标配是Ubuntu主机上用交叉编译工具链目标板是ARM架构的板子内核源码版本必须和板子上运行的完全一致包括.config配置。很多初学者栽在版本不对齐上编译出来的ko文件insmod进去直接报invalid module format其实就是版本魔法值和 vermagic 不匹配。内核头文件和编译工具链的安装方式不同平台差异较大但思路是通用的获取与目标系统完全一致的内核源码建议直接解压到工作目录而不是用系统自带的路径方便后续make menuconfig调整配置。安装交叉编译工具链ARM平台常用的是gcc-arm-linux-gnueabihf系列配置环境变量指向工具链路径。板子使用NFS或者TFTP方式挂载根文件系统这样编译出来的ko文件直接拷过去insmod测试不需要反复烧写镜像。代码编辑我习惯用VS Code加remote-ssh插件直接在开发机上编辑、同步、编译一条龙。这套环境搭好之后开发循环就是改代码→交叉编译→拷贝模块到目标板→dmesg看日志→调试。一次配置长期受益。2. 字符设备驱动框架拆解与代码实现2.1 file_operations结构体驱动与应用层交互的契约字符设备驱动最核心的数据结构就是file_operations。它在linux/fs.h中定义本质上是一堆函数指针。应用程序调用open、read、write、ioctl、release这些系统调用时内核VFS虚拟文件系统层会找到对应的驱动函数回调过去。换句话说你的驱动就算硬件逻辑写得再漂亮如果file_operations里的函数指针没填对应用层根本调不到。实际开发中我常用到的成员就几个static struct file_operations fops { .owner THIS_MODULE, .open my_dev_open, .read my_dev_read, .write my_dev_write, .unlocked_ioctl my_dev_ioctl, .release my_dev_release, };owner设置为THIS_MODULE是惯例用来避免模块被卸载时还有进程占用而出现野指针。read和write函数注意内核态不能直接访问应用层传下来的缓冲区指针必须用copy_to_user和copy_from_user做数据拷贝。为什么要多此一举因为用户态指针在内核态直接解引用可能会导致缺页异常加上SMAP等安全机制的限制直接访问会被拒之门外。数据拷贝过程中还需要用access_ok先验证指针合法性。unlocked_ioctl是传递控制命令的通道它的原型是long (*unlocked_ioctl)(struct file *, unsigned int cmd, unsigned long arg);cmd是应用层定义的命令号arg通常是一个指针指向应用层传入的参数结构体或数值。这里有个老生常谈的坑32位和64位兼容问题。如果应用层是32位编译内核是64位结构体传参时成员对齐可能不一致。我一般建议ioctl的参数尽量用unsigned long传递整型值或者使用_IO/_IOR/_IOW宏生成cmd时带上类型和长度信息这样内核和用户态能对参数格式做统一校验。2.2 字符设备的注册流程从设备号到设备节点字符设备驱动的生命周期管理有一个标准流程。首先是设备号的申请有两种方式指定静态设备号或者动态分配。静态设备号需要在include/linux/major.h等头文件中确认没被占用否则注册时直接冲突报错动态分配用alloc_chrdev_region更省心内核帮你挑一个没用的主设备号缺点是设备号不固定不过可以通过udev规则生成固定节点名来解决。接下来是cdev的初始化和添加dev_t dev_num; int major; struct cdev my_cdev; alloc_chrdev_region(dev_num, 0, 1, my_driver); major MAJOR(dev_num); cdev_init(my_cdev, fops); my_cdev.owner THIS_MODULE; cdev_add(my_cdev, dev_num, 1);cdev_add成功后这个设备就已经挂到内核的设备模型里了但应用层还不知道它对应哪个名字。传统做法是手动mknod /dev/my_dev c major 0现在推荐用class和device的组合让udev自动在/dev下创建设备节点static struct class *my_class; my_class class_create(my_class); device_create(my_class, NULL, dev_num, NULL, my_dev);class_create在较新的内核版本中参数有变化部分版本需要传入owner参数具体以你使用内核版本的接口为准。module_init的时候执行上面的注册module_exit的时候反向销毁——device_destroy、class_destroy、cdev_del、unregister_chrdev_region一个都不能少顺序也不能乱。我见过有同事卸载模块时忘了注销设备号结果重新加载时alloc_chrdev_region分配到同一个主设备号和残留的/dev节点对应上了虽然能跑但隔离性很差换一台机器就会出现诡异问题。2.3 完整的cdev模板代码把代码串起来一个最小可用的字符设备驱动长这样#include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #include linux/slab.h #define DEV_NAME my_demo #define BUF_LEN 128 static int major; static struct cdev my_cdev; static struct class *my_class; static char *kernel_buf; static int my_open(struct inode *inode, struct file *filp) { printk(KERN_INFO %s: opened\n, DEV_NAME); return 0; } static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *pos) { int ret; if (count BUF_LEN) count BUF_LEN; ret copy_to_user(buf, kernel_buf, count); if (ret) return -EFAULT; return count; } static ssize_t my_write(struct file *filp, const char __user *buf, size_t count, loff_t *pos) { int ret; if (count BUF_LEN) count BUF_LEN; ret copy_from_user(kernel_buf, buf, count); if (ret) return -EFAULT; return count; } static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { switch (cmd) { case 0x01: printk(KERN_INFO %s: ioctl cmd 0x01\n, DEV_NAME); break; default: return -EINVAL; } return 0; } static int my_release(struct inode *inode, struct file *filp) { printk(KERN_INFO %s: released\n, DEV_NAME); return 0; } static struct file_operations fops { .owner THIS_MODULE, .open my_open, .read my_read, .write my_write, .unlocked_ioctl my_ioctl, .release my_release, }; static int __init my_init(void) { dev_t dev_num; int ret; kernel_buf kzalloc(BUF_LEN, GFP_KERNEL); if (!kernel_buf) return -ENOMEM; ret alloc_chrdev_region(dev_num, 0, 1, DEV_NAME); if (ret 0) goto fail_alloc; major MAJOR(dev_num); cdev_init(my_cdev, fops); ret cdev_add(my_cdev, dev_num, 1); if (ret 0) goto fail_cdev; my_class class_create(DEV_NAME); if (IS_ERR(my_class)) { ret PTR_ERR(my_class); goto fail_class; } device_create(my_class, NULL, dev_num, NULL, DEV_NAME); printk(KERN_INFO %s: init done, major%d\n, DEV_NAME, major); return 0; fail_class: cdev_del(my_cdev); fail_cdev: unregister_chrdev_region(dev_num, 1); fail_alloc: kfree(kernel_buf); return ret; } static void __exit my_exit(void) { dev_t dev_num MKDEV(major, 0); device_destroy(my_class, dev_num); class_destroy(my_class); cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); kfree(kernel_buf); printk(KERN_INFO %s: exit done\n, DEV_NAME); } module_init(my_init); module_exit(my_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Driver Engineer); MODULE_DESCRIPTION(A simple char device driver);这个模板直接编译就能运行insmod之后查看/proc/devices能看到主设备号和模块名/dev/my_demo节点也应该自动生成。应用层用open、read、write就能和它交互。注意ioctl的cmd在这里只是演示实际项目中建议用_IO/_IOW等宏组合生成规范cmd避免和别的驱动冲突。3. 设备树配置与platform驱动实操3.1 设备树到底在做什么设备树Device Tree的出现解决了ARM平台的一个老大难问题硬件描述和内核代码耦合太深。在早期内核里每换一块板子就要改arch/arm/mach-xxx下的源代码平台多了之后维护成本高到离谱。设备树的思路是把硬件信息用树状结构描述出来比如CPU型号、内存基地址、外设挂在哪条总线上、中断号是多少、GPIO用的是哪个引脚全部写进dts文件。内核启动时解析这个文件然后动态生成platform_device挂到总线上驱动只需要声明自己支持哪个设备就可以match上。对于驱动开发者来说设备树配置好坏直接影响你驱动能不能正常工作。匹配不上驱动probe函数压根不会执行。最常见的匹配机制是靠compatible属性它是一串字符串格式通常为厂商,型号。驱动端用of_match_table声明自己支持的compatible列表内核把设备树里的compatible和驱动的匹配表做比对相同就执行probe。3.2 dts文件编写与匹配规则假设我们要给某块开发板添加一个基于GPIO的LED外设设备树里可以这样写/ { my_led { compatible mycompany,myled; reg 0x01c20800 0x04; interrupts 0 23 4; gpios pio 7 2 GPIO_ACTIVE_LOW; status okay; }; };compatible是匹配的关键reg描述寄存器地址和长度interrupts第0个参数是中断号第1个参数是触发方式这里对应设备树标准格式gpios引用了pio控制器下的引脚。status默认是okay如果写成disabled内核会直接忽略这个节点不用删除节点就能屏蔽设备。驱动端的匹配表这样写static const struct of_device_id my_led_of_match[] { { .compatible mycompany,myled }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_led_of_match); static struct platform_driver my_led_driver { .probe my_led_probe, .remove my_led_remove, .driver { .name my_led, .of_match_table my_led_of_match, }, }; module_platform_driver(my_led_driver);module_platform_driver是一个封装宏展开后就是module_init和module_exit注册platform_driver。probe函数在设备树匹配成功后执行通常在这里完成寄存器映射、GPIO申请、中断注册等硬件初始化工作。3.3 probe函数中的常用操作probe函数里最核心的就是获取设备树里描述的硬件资源。我列几个最常用的APIof_property_read_u32读取u32类型的属性值比如reg中的寄存器地址解析。of_iomap根据reg属性做ioremap把物理地址映射成内核虚拟地址之后就可以用readl/writel访问寄存器。platform_get_resource更通用的资源获取接口可以拿到IORESOURCE_MEM类型的内存资源。platform_get_irq获取中断号返回的是irq number可以直接传给request_irq。devm_gpiod_get获取GPIO描述符这是基于GPIO descriptor的现代API比老的gpio_request更推荐。用数组风格做一下示意GPIO LED的例子可以写成这样static int my_led_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; int irq; struct device *dev pdev-dev; res platform_get_resource(pdev, IORESOURCE_MEM, 0); base devm_ioremap(dev, res-start, resource_size(res)); if (IS_ERR(base)) return PTR_ERR(base); irq platform_get_irq(pdev, 0); if (irq 0) return irq; dev_info(dev, probe ok, irq%d, base%p\n, irq, base); return 0; }注意devm_系列API的好处是资源自动释放。你用devm_ioremap映射的地址、devm_gpiod_get获取的GPIO在驱动卸载或者probe失败时内核会自动清理不需要手动释放。这能省掉大量错误路径处理代码大幅降低内存泄漏和资源泄漏的概率。我现在的驱动基本全用devm版本只有极少数场景才手动管理。3.4 中断申请与使用要点设备树里配了interrupts属性驱动拿到irq号之后就可以申请中断ret request_irq(irq, my_irq_handler, IRQF_TRIGGER_FALLING, my_led, priv_data); if (ret) { dev_err(dev, request_irq failed: %d\n, ret); return ret; }IRQF_TRIGGER_FALLING是下降沿触发具体用哪个触发方式要看硬件设计。如果不确定设备树里不要重复指定触发类型否则可能和应用层设置的触发方式冲突导致中断注册失败。中断处理函数分两种硬中断handler和底半部机制。handler里不能做耗时操作不能调用可能睡眠的函数比如kmalloc带GFP_KERNEL只能处理最快的事情——清中断标志、读取硬件状态、唤醒等待队列或者调度workqueue/tasklet。我习惯用workqueue处理耗时逻辑static irqreturn_t my_irq_handler(int irq, void *data) { struct my_priv *priv data; schedule_work(priv-work); return IRQ_HANDLED; }然后work函数里做真正的数据处理。这样做的好处是handler执行时间极短不会阻塞其他中断work函数运行在进程上下文可以放心使用信号量、kmalloc这些睡眠函数。这个模式在实际项目中用得非常多。4. I2C设备驱动框架与实现细节4.1 I2C子系统分层架构I2C是嵌入式设备里最常见的低速总线一个总线控制器上可以挂多个设备每个设备有唯一地址。Linux的I2C子系统分成三层I2C核心层、I2C总线驱动层adapter、I2C设备驱动层client。adapter就是I2C控制器驱动负责收发时序的底层实现通常是SoC厂商已经写好的我们作为设备驱动开发者主要写的是client这一层即某个具体传感器、EEPROM、触摸屏芯片的驱动。设备端的注册方式有两种传统的方式是在板级文件里通过i2c_board_info注册现在推荐的是在设备树里声明设备节点。比如在某个I2C总线下挂了一个温度传感器i2c1 { status okay; clock-frequency 100000; tmp10148 { compatible ti,tmp101; reg 0x48; }; };reg的值就是设备地址0x48内核会把该节点绑定到i2c1这个adapter下生成一个i2c_client。驱动的probe函数只有当设备树中的compatible匹配到驱动的of_match_table时才会被调用。有一个容易犯的错误地址写错或者和总线上其他设备冲突导致probe不执行。这种问题用i2cdetect -y 1命令扫一下就知道了。4.2 i2c_driver驱动的注册代码I2C设备驱动的框架长这样static int tmp101_probe(struct i2c_client *client) { struct tmp101_data *data; int ret; data devm_kzalloc(client-dev, sizeof(*data), GFP_KERNEL); if (!data) return -ENOMEM; i2c_set_clientdata(client, data); >static int tmp101_read_temp(struct i2c_client *client) { int ret i2c_smbus_read_byte_data(client, TMP101_TEMP_REG); if (ret 0) return ret; return ret; }对于一次要读写多个寄存器的场景可以用i2c_transfer组合读写。比如读一个16位的寄存器高低字节分开存放可以构造两个msgu8 addr SOME_REG_H; u8 buf[2]; struct i2c_msg msgs[] { { .addr client-addr, .flags 0, .len 1, .buf addr, }, { .addr client-addr, .flags I2C_M_RD, .len 2, .buf buf, }, }; ret i2c_transfer(client-adapter, msgs, 2);第一个msg是写寄存器地址flags为0表示写第二个msg是读数据flags设为I2C_M_RD。i2c_transfer返回值是成功传输的消息数量不是字节数。如果返回的msg数小于2说明传输有问题不能直接认为buf里的数据有效。4.4 I2C驱动和字符设备框架的结合实际项目中I2C传感器驱动往往不是只在内核里跑完就结束应用层需要拿到数据。所以I2C驱动经常会同时创建设备节点把读取到的传感器数据通过read或ioctl暴露给用户空间。这也是我前面强调字符设备框架是基础的另一个原因。思路是在i2c_driver的probe里带上cdev注册逻辑或者用miscdevice更轻量static struct miscdevice tmp101_miscdev { .minor MISC_DYNAMIC_MINOR, .name tmp101_sensor, .fops tmp101_fops, };miscdevice的好处是自动分配主设备号统一用misc主设备号10省去alloc_chrdev_region这些步骤只需要在probe里misc_register在remove里misc_deregister就行。应用层打开/dev/tmp101_sensorread就能读到温度值write可以配置报警阈值。这样一套走下来一个真实可用的I2C传感器驱动就完整了。5. 编译、调试与性能调优实录5.1 Makefile的编写与编译参数内核模块的编译和应用层完全不同不能直接用gcc。需要内核的Kbuild系统来处理Makefile写起来也有一套固定套路。我一般这样写obj-m my_demo.o my_demo-objs : my_demo_main.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) clean如果是交叉编译加ARCH和CROSS_COMPILE参数ARCH ? arm CROSS_COMPILE ? arm-linux-gnueabihf- KERNEL_DIR ? /home/user/linux-kernel注意obj-m后面如果有多个源文件要用模块名-objs这种写法把源文件罗列出来否则Kbuild分不清哪个是模块主文件。编译完成后生成.ko文件insmod到目标板测试。5.2 模块加载卸载与日志定位模块的加载卸载命令要区分清楚insmod my_demo.ko # 加载模块不处理依赖 modprobe my_demo # 加载模块自动处理依赖 rmmod my_demo # 卸载模块 lsmod # 查看已加载模块模块加载后第一件事就是看dmesg输出模块里的printk信息默认会打到内核日志缓冲区。KERN_INFO的级别不一定立刻显示在控制台上但dmesg一定能看到。我习惯在probe函数里多打几条关键信息匹配到的compatible、申请到的资源地址、中断号、初始化状态。这些日志在后期排障时非常有用。如果模块加载失败常见报错是insmod: ERROR: could not insert module my_demo.ko: Invalid module format。这时候用modinfo查看模块版本信息对比内核版本和vermagic是否一致。还有一个常见坑内核开启了CONFIG_MODULE_SIG强制签名验证未签名的模块直接加载失败。解决办法是编译内核时关闭该选项或者在内核配置里开启CONFIG_MODULE_SIG_FORCEn。5.3 应用层测试与验证方法测试驱动最直接的方式就是用命令行工具。open失败多半是设备节点权限问题加sudo可能就过了读写数据不对用hexdump或者od查看设备节点的输出echo hello /dev/my_demo cat /dev/my_demo对于I2C设备可以用i2cdetect扫描总线上的设备地址i2cget和i2cset直接读写寄存器这两个命令排障效率远高于自己写测试程序。比如某传感器读不到数据先用i2cdetect确认设备地址是否正确再用i2cget读寄存器看看硬件是否在响应最后才怀疑驱动代码的问题。这个顺序能帮你快速把问题定位到硬件层、总线层还是驱动层。5.4 性能优化与并发处理技巧驱动性能的瓶颈通常不在CPU而在数据拷贝频率和锁的竞争上。read/write路径上每次copy_to_user/copy_from_user都有开销高频采集场景建议用mmap把内核缓冲区映射到用户态省掉拷贝。但mmap引入的问题是需要自己管理同步用内核提供的DMA_BUF机制或者简单的原子变量加环形缓冲区都能处理。并发访问是驱动里的老大难问题。字符设备可能被多进程同时打开如果共享全局缓冲区就必须加锁。我建议按场景选锁临界区极短只是修改一个标志或计数器用atomic_t原子变量。临界区较长且允许睡眠比如I2C传输、数据拷贝用mutex互斥锁。中断上下文不能睡眠用spinlock自旋锁。读多写少用读写锁或RCU。用自旋锁要尤其小心持有自旋锁期间不能调用任何可能睡眠的函数否则在多核系统上其他CPU自旋等待单核系统直接死锁。我踩过这个坑在持有spinlock的情况下调用i2c_transferI2C传输底层需要等待总线完成调度器想切换进程却拿不到锁结果内核挂死。排查到最后只能靠看内核栈回溯定位异常痛苦。5.5 系统裁剪对驱动的影响最近不少项目要求做系统裁剪优化省内存、省Flash。裁剪过度也会影响驱动。比如内核把某个I2C控制器的驱动编译成模块而不是编进内核设备树里引用的adapter在启动阶段就不存在I2C设备的probe自然会延迟到模块加载后才触发。这时候需要在模块加载顺序上做规划可以在initramfs阶段就加载对应模块或者干脆把关键驱动编进内核。另一个常见问题是内核开启了CONFIG_DEVTMPFS但根文件系统没有挂载/dev设备节点不会自动生成。裁剪系统的过程中要确保devtmpfs正常挂载或者用mdev/udev配合。我在某个裁剪项目里遇到过insmod成功但/dev下面找不到节点查到最后是udev服务没起来设备节点全靠手动mknod非常被动。6. 常见问题与排查技巧实录6.1 模块加载失败类问题insmod报Invalid module format原因基本是版本不匹配。先跑uname -r看目标板内核版本再确认编译时用的KERNEL_DIR指向的内核源码是不是同一版本。交叉编译环境下最容易踩这个坑因为主机内核版本和板子完全不同。解决方法是重新用板子同版本的内核源码编译并且不要修改内核源码目录下的.localversion文件。还有一个隐蔽原因内核配置了CONFIG_MODVERSIONS模块和内核的符号版本校验失败。报错信息里能看到no symbol version for symbol xxx。这种情况需要把模块和内核的配置对齐或者关闭MODVERSIONS重新编译内核。insmod成功但probe没有执行模块能加载说明init函数运行了但probe没触发说明设备和驱动没有匹配。先确认设备树节点是否存在如果是platform设备看compatible有没有对上I2C设备要看i2c_driver的of_match_table和id_table有没有覆盖设备树里的compatible。可以打开内核的deferred probe日志看看是否有设备在等待驱动匹配echo 1 /sys/kernel/debug/device_component/deferred_probe cat /sys/kernel/debug/device_component/deferred_probe如果设备一直挂在deferred probe列表里说明设备树里引用的控制器或时钟资源还没准备好pinctrl等子系统没有完成初始化。6.2 中断不触发或触发异常中断一直不触发先用cat /proc/interrupts确认驱动是否成功申请了中断。如果没有申请到回看platform_get_irq的返回值可能是设备树interrupts属性里的中断号不对也可能是引用的中断控制器节点写错了。如果中断申请成功了但就是不进handler大概率是硬件中断源没打开或者GPIO复用配置不对需要用devmem直接操作寄存器确认硬件状态。中断触发非常频繁CPU占用100%这类问题通常是中断没有清除标志导致中断风暴。CPU认为中断没处理完反复进入handler。检查中断处理函数里有没有正确地清除硬件中断状态寄存器。另一个可能原因是没有使用IRQF_TRIGGER_XX指定触发方式或者设备树和控制器的默认触发方式不一致导致电平触发模式下中断线一直被拉低。6.3 I2C读写失败相关问题i2cdetect找不到设备总线扫描不到设备地址先确认硬件接线正常SDA、SCL有没有接上拉电阻地址引脚有没有正确配置设备供电是否正常。如果硬件没问题检查i2c控制器的时钟频率某些传感器对总线速率有上限要求100kHz读不到数据不代表400kHz能读到。用示波器看波形是最直接的排查手段。i2c_transfer返回EIO或者ENXIOEIO说明底层的adapter发送消息时收到了NACK。设备地址对不对、芯片是否处于正常模式、总线上是否有多地址冲突按这个顺序排查。ENXIO大多数情况是msg里的addr根本不是当前adapter管理范围内的设备。另外注意i2c_transfer的返回值只有返回值和传入的num_msgs相等才是全部成功。6.4 数据错乱问题read/write数据反复异常大概率是copy_to_user和copy_from_user返回值没有检查。返回值非0说明有数据没拷贝成功如果直接返回count应用层会认为数据已经到了实际缓冲区内容是半新的。正确做法是检查拷贝函数的返回值不为0返回-EFAULT。多进程同时访问出现数据错乱共享数据没加锁。char设备驱动中如果多个进程同时open同一个设备节点各自的file结构体指向同一个驱动私有数据多个进程并发读写全局缓冲区就会相互覆盖。建议每个open分配一份私有数据结构或者在read/write路径加mutex保护。6.5 快速排查速查表现象可能原因排查命令/工具insmod Invalid module format内核版本不匹配uname -r, modinfoprobe未执行compatible不匹配、设备树status错误查看dmesg, /sys/devices设备节点不存在udev/mdev未运行ls /dev, dmesg读写返回-EFAULTcopy_to/from_user失败检查用户态指针合法性I2C传输NACK地址错误、硬件未就绪i2cdetect, 示波器中断风暴中断标志未清cat /proc/interrupts系统挂死自旋锁内睡眠内核栈回溯, /proc/lockdep6.6 独家调试技巧最后分享几个书本上不怎么讲的技巧。第一个是用tracepoint。内核的trace_event在驱动调试里非常好用比如查看i2c_transfer的收发流程可以直接trace i2c/i2c_read和i2c/i2c_write事件echo 1 /sys/kernel/debug/tracing/events/i2c/enable cat /sys/kernel/debug/tracing/trace第二个是用kprobe动态插桩不需要改驱动代码就能在指定函数上挂点打印信息对于排查底层函数有没有被调用、参数是什么这类问题非常高效。第三个是devmem工具。驱动开发经常需要确认硬件寄存器状态devmem可以直接读写物理地址。比如查看某个GPIO控制器的寄存器值判断中断挂起标志位是否被正确清除。这在硬件初始化阶段和中断问题排查中能节省大量时间。我在实际项目中还用过crash工具分析vmcore不过那个门槛偏高普通问题用不到。建议手头至少掌握三种调试手段dmesg日志分析、tracepoint观察行为、devmem操作硬件寄存器。这三个配合起来绝大多数驱动问题都能在半小时内定位到根因。驱动开发的调试说到底是一个逐步缩小范围的过程先确认硬件有没有问题再确认驱动有没有跑起来最后确认数据路径有没有错。顺序对了排查效率自然高。