做嵌入式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的作用是让内核自动生成uevent,udev收到后会在/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 0,c表示字符设备,240是实际分配的主设备号。这种方式费劲且容易出错,现在基本都被自动创建取代了。
自动创建的机制依赖sysfs与udev。驱动的class_create会在/sys/class下生成一个类目录,device_create注册具体设备时,内核会向用户空间的udev守护进程发送uevent,udev根据规则在/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_sensor@48 { 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到client
I2C总线是嵌入式设备里最常用的低速总线,挂的传感器、EEPROM、RTC多得数不过来。Linux的I2C子系统分成三块:adapter代表I2C控制器,也就是总线本身;client代表挂在总线上的从设备;driver代表从设备的驱动。总线驱动负责收发时序,设备驱动负责解析数据,两者靠i2c_driver结构体绑定。
I2C设备驱动的注册函数是i2c_add_driver,一般情况下直接调用module_i2c_driver这个宏就行。设备驱动需要实现probe和id_table,id_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, ®, 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_MODVERSIONS=Y并使用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/i2c@xxx下的子节点是否存在,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或类似工具确认总线通信正常,最后才回到驱动代码里打日志验证逻辑。按这个顺序走下去,大部分问题都能很快收口。
最后再分享一个小技巧:平时调试驱动时,我习惯在内核启动参数里加loglevel=8,让printk的信息直接输出到控制台。这样不用每次insmod之后都手动dmesg,能省不少事。等驱动稳定了再把这参数去掉,避免生产环境下刷屏影响性能。驱动开发是一门需要反复踩坑磨出来的手艺,希望这篇分享能帮你少走一段弯路,也算是我这几年在里面摸爬滚打的一点心得。