ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux设备驱动开发实战:字符设备框架、设备树配置与I2C驱动调试指南

Linux设备驱动开发实战:字符设备框架、设备树配置与I2C驱动调试指南 Linux设备驱动开发说穿了就是让内核认识你的板子上的外设。从最简单的LED、按键到复杂的触摸屏、传感器、网卡只要硬件想和应用程序对话中间就少不了驱动的身影。这篇文章不绕弯子直接说清楚字符设备驱动框架、设备树配置、I2C驱动注册这些绕不开的核心点再把我实际调试时踩过的坑一并列出来。适合刚接触嵌入式Linux、看过几本驱动书但还没动手写过完整驱动的读者也适合想快速捡起Linux驱动开发的老手当作清单参考。我做驱动开发这些年最大的体会是驱动本身不难难的是搞清楚内核的脾气。内核有自己的节奏、自己的内存管理、自己的并发模型你写应用时那套“拿来就用”的思路放到驱动里大概率会踩到各种意想不到的问题。所以这篇东西不会只贴代码每一段我都会解释为什么要这么写内核为什么要这么设计。这是驱动开发区别于普通编程的关键。1. 开始之前驱动开发到底在解决什么问题1.1 驱动在内核里的位置很多初学者会把驱动理解成“控制硬件的代码”这个说法没错但不完整。一个完整的Linux系统里应用跑在用户态硬件跑在物理世界中间隔着内核。内核提供系统调用接口驱动则是内核和硬件之间的翻译官。应用程序说“我要读温度”通过open/read这些标准接口进入内核驱动再根据硬件手册去操作寄存器、等待转换完成、把数据返回给上层。这意味着驱动要同时面对两边的约束。对上层你必须遵循Linux的文件抽象——设备也是一个文件要支持open、read、write、ioctl这些标准操作对下层你必须按照硬件的时序、寄存器、中断信号去操作物理设备。两边任何一端出问题驱动都会表现出“应用调不通”或者“内核直接崩掉”的症状。还有一个容易忽略的点驱动不是普通的C程序。它没有main函数不能随便调用标准库内存分配要小心还要处理并发访问。因为内核可能同时被多个进程使用你的驱动如果没加锁两个进程同时读同一个设备数据就乱了。这些应用开发里不太敏感的细节在驱动里都是实打实的问题。1.2 开发环境准备写驱动之前先把环境搞对能省掉后面一大半的折腾。我建议用一个独立的Linux开发机装好交叉编译工具链再把目标板对应的内核源码准备好。这里有个关键概念驱动模块不是随便拿gcc编一下就能用的。它依赖内核的编译配置、头文件、甚至编译选项所以必须用目标板同一版本的内核源码来编译最好连.config都用板子上的那份。很多新手拿Ubuntu自带的generic内核 headers 编出来的 .ko一放到开发板上就提示版本不匹配就是这个原因。一个比较稳妥的做法是把内核源码放到开发机执行目标板厂商推荐的配置然后先编一遍完整的内核确认环境和工具链没问题。之后再编模块就顺了。实际项目中我习惯在源码根目录建一个build脚本内容大致是#!/bin/bash export ARCHarm export CROSS_COMPILEarm-linux-gnueabihf- make modules_prepare make Mdrivers/misc/my_driver modulesmodules_prepare这一步很重要它会生成模块编译需要的头文件和符号文件。如果你的驱动放到了内核源码树里就用M指向驱动目录如果驱动是独立维护的也可以用内核源码目录配合KDIR指向源码make -C /path/to/kernel M$(pwd) modules依赖关系提前确认好剩下的就是反复改代码、编模块、拷到板子、insmod测试。这套循环跑顺了效率才有保障。1.3 明确你要写哪类驱动Linux驱动按设备类型大致可以分三类字符设备、块设备、网络设备。字符设备是最常见、也最适合入门的比如串口、GPIO、I2C传感器多数属于字符设备。块设备以数据块为单位读写典型的是硬盘、SD卡、eMMC。网络设备走的是另一套接口重点是net_device结构体和收发包函数。这三类驱动对上层暴露的接口完全不同。字符设备走文件系统接口对应read/write块设备走block层的请求队列网络设备用sk_buff传递数据包。实际工作中你可能不需要从头写一个块设备驱动很多时候是基于现成的子系统扩展。比如你要驱动一个温度传感器最合理的做法是写一个I2C客户端驱动注册进I2C子系统然后在应用层通过设备节点或者工业IO如果你用的是IIO子系统来读取数据。所以动手之前先问自己我的设备属于哪类内核有没有现成的框架如果是I2C设备就找I2C子系统如果是GPIO按键就看看input子系统。用对框架驱动开发一半的坑就已经绕开了。2. 字符设备驱动框架拆解与最小实现2.1 主设备号与次设备号字符设备驱动最核心的两个概念是主设备号和次设备号。主设备号标识驱动类型次设备号标识同一个驱动管理的多个设备。应用层通过设备节点比如 /dev/mydev访问设备VFS根据设备节点里存的设备号找到对应的驱动再调用你注册的read/write函数。设备号的分配有两种方式静态指定和动态分配。静态指定就是你自己定一个主设备号比如使用0到255之间的值然后调用register_chrdev_region。这种方式适合设备号固定的场景但容易和别的驱动冲突。动态分配更推荐用alloc_chrdev_region让内核给你分配一个没被占用的主设备号完事儿再用cat /proc/devices查看。有一件事要记住注册字符设备不等于创建了设备节点。早期驱动里经常看到手动 mknod /dev/mydev c 240 0这是手工创建设备节点。现在更规范的做法是配合device_create和class_create自动创建下面代码里我会一起演示。2.2 file_operations回调集合字符设备驱动的工作核心是file_operations结构体。这个结构体里是一堆函数指针对应应用程序对设备节点发起的操作。你不需要全部实现但open、release、read、write、ioctl这几个是最常用的。应用程序执行open(/dev/mydev)时内核会找到你的open回调执行read时会调用你的read回调。这里有个关键点回调函数的参数由内核传入其中file指针代表当前打开的动态文件实例而另一个参数——根据函数不同——可能是struct inode指针或者struct vm_area_struct指针。比如read的完整签名是ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos);buf是用户空间的缓冲区指针你不能在内核里直接访问它必须用copy_to_user把内核的数据拷贝过去。为什么要绕这一步因为内核有独立的地址空间而且从安全角度内核也不能随便解引用用户态指针否则很容易被恶意程序利用。所有用户态数据交互标准做法就是copy_to_user / copy_from_user或者更高效的get_user / put_user。2.3 注册与注销流程一个典型字符设备驱动的生命周期大概是四步分配设备号初始化cdev结构体关联file_operations调用cdev_add注册进内核创建类和设备节点让应用层能看到/dev下的文件卸载时反向操作销毁设备节点、cdev_del、释放设备号代码骨架大概是这样的我把每一步的意图也写在注释里#include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #define DEVICE_NAME mydemo static int major; static struct class *demo_class; static struct device *demo_device; static struct cdev demo_cdev; static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) { char data[] hello from driver\n; size_t len strlen(data); if (count len) return -EINVAL; if (copy_to_user(buf, data, len)) return -EFAULT; return len; } static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) { char kbuf[128]; if (count sizeof(kbuf) - 1) return -EINVAL; if (copy_from_user(kbuf, buf, count)) return -EFAULT; kbuf[count] \0; pr_info(received: %s\n, kbuf); return count; } static int demo_open(struct inode *inode, struct file *filp) { pr_info(device opened\n); return 0; } static int demo_release(struct inode *inode, struct file *filp) { pr_info(device released\n); return 0; } static const struct file_operations demo_fops { .owner THIS_MODULE, .open demo_open, .read demo_read, .write demo_write, .release demo_release, }; static int __init demo_init(void) { dev_t devno; // 动态分配设备号避免静态指定造成的主设备号冲突 if (alloc_chrdev_region(devno, 0, 1, DEVICE_NAME) 0) return -EIO; major MAJOR(devno); // cdev初始化并添加到内核 cdev_init(demo_cdev, demo_fops); demo_cdev.owner THIS_MODULE; if (cdev_add(demo_cdev, devno, 1) 0) { unregister_chrdev_region(devno, 1); return -EIO; } // 创建设备类然后在/dev下生成节点 demo_class class_create(THIS_MODULE, demo_class); if (IS_ERR(demo_class)) { cdev_del(demo_cdev); unregister_chrdev_region(devno, 1); return PTR_ERR(demo_class); } demo_device device_create(demo_class, NULL, devno, NULL, DEVICE_NAME); if (IS_ERR(demo_device)) { class_destroy(demo_class); cdev_del(demo_cdev); unregister_chrdev_region(devno, 1); return PTR_ERR(demo_device); } pr_info(mydemo driver loaded, major%d\n, major); return 0; } static void __exit demo_exit(void) { device_destroy(demo_class, MKDEV(major, 0)); class_destroy(demo_class); cdev_del(demo_cdev); unregister_chrdev_region(MKDEV(major, 0), 1); pr_info(mydemo driver unloaded\n); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(Minimal char device driver demo);这段代码对应一个非常基本的测试模块。编译后insmod你在dmesg里能看到加载日志然后可以和/dev/mydemo交互。它没有处理并发也没有处理多节点但作为理解框架的起点已经完全够了。2.4 编译与装载注意事项编译模块的Makefile也有讲究不要用Makefile惯用的target链接逻辑而是用内核模块构建系统的特殊变量obj-m : mydemo.o KDIR : /path/to/kernel PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean装载模块后先看dmesg有没有错误再确认设备节点是否存在。如果设备节点没有自动生成多半是class/device创建的某个环节出了问题最常见的错误是device_create里的设备名与class_create重名冲突。卸载时如果模块busy多半是有进程还在占用设备节点用fuser -v /dev/mydemo查一下谁在用或者直接检查是否有shell的历史命令把它打开了。3. 设备树配置与platform驱动结合3.1 为什么需要设备树早期的Linux内核里板级信息是通过一堆C代码和宏写死的要支持新的开发板就得改内核源码非常痛苦。设备树出现之后硬件描述从内核里分离出来用一棵树状的节点来描述CPU、内存、I2C控制器、GPIO控制器以及外设挂在哪里。内核起来的时候解析这棵树就知道当前平台有哪些设备。这个设计带来的直接好处是同一个内核镜像可以支持多个板子只需要更换不同的设备树文件。对驱动开发者来说设备树的意义在于“设备是什么”和“驱动怎么控制”分开了。设备树只负责描述硬件拓扑和配置参数不负责具体行为。行为是你驱动代码里写的。比如你在设备树里声明了一个温度传感器位于I2C0总线上地址0x48驱动代码里就写如何匹配这个设备、怎么初始化、怎么读数。3.2 设备树基本语法与节点设备树的语法并不复杂。每一个硬件设备对应一个节点节点有compatible属性用来匹配驱动有reg属性描述地址和寄存器长度还可以有自定义属性给驱动传递参数。一个I2C传感器节点的写法大概是这样i2c0 { status okay; temperature_sensor: temp48 { compatible vendor,tmp117; reg 0x48; vdd-supply reg_3v3; interrupt-parent gpio1; interrupts 13 IRQ_TYPE_EDGE_FALLING; }; };child节点的reg就是I2C从设备地址。compatible里的“vendor,tmp117”只是一个约定厂商名加设备名驱动匹配时会用到。properties这里有个容易踩的坑地址一定要核对硬件手册很多传感器的引脚配置不同地址位最低位可能被改0x48和0x49经常搞混。设备树的修改和编译也很重要。源码是dts文件编译后变成dtbU-Boot引导内核时会把dtb地址传给内核。开发时如果想临时改设备树不需要重编整个内核只改dts再编dtb就可以。用dtc工具反编译也能快速检查当前板子上的设备树是不是你想要的那份dtc -I dtb -O dts -o current.dts board.dtb这个命令经常用来确认板子上的设备树里节点到底有没有被正确添加。我调试时就遇到过以为设备树改了实际U-Boot还是加载了旧的dtb导致驱动一直probe不了的情况。3.3 platform_driver匹配机制设备树节点写好了驱动怎么找到它内核里和“挂在总线上”的设备打交道有两种常见方式一种是I2C、SPI这样有独立总线的用对应子系统的机制另一种是platform设备——它不是挂在真正的物理总线上而是内核虚拟出来的一条platform总线。很多SoC内部控制器比如UART、GPIO、DMA以及设备树里描述的“片上设备”都会被注册为platform设备。platform_driver要做的事情就是声明自己支持哪些设备然后在probe回调里初始化硬件。匹配的核心还是compatible属性static const struct of_device_id temp_of_match[] { { .compatible vendor,tmp117 }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, temp_of_match); static struct platform_driver temp_platform_driver { .probe temp_probe, .remove temp_remove, .driver { .name tmp117, .of_match_table temp_of_match, }, }; module_platform_driver(temp_platform_driver);内核在设备树里找compatible为“vendor,tmp117”的节点找到之后就会调用你的probe。如果设备树里没这个节点或者compatible拼错了probe就不会执行。这是我遇到最多的“驱动编译成功但就是不工作”的原因之一。3.4 在probe里获取设备树资源设备树里定义的属性在probe函数里可以通过设备指针获取。比如reg里的地址对应平台资源interrupts对应中断号自定义属性则需要用of_property_read_系列函数读取。一个带GPIO和中断的probe函数大概是这样的static int temp_probe(struct platform_device *pdev) { struct device *dev pdev-dev; struct resource *res; struct temp_dev *priv; int ret; priv devm_kzalloc(dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; // 获取设备树中reg定义的寄存器基地址 res platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) return -ENODEV; priv-base devm_ioremap(dev, res-start, resource_size(res)); if (!priv-base) return -ENOMEM; // 通过GPIO descriptor API请求GPIO priv-reset_gpio devm_gpiod_get_optional(dev, reset, GPIOD_OUT_LOW); if (IS_ERR(priv-reset_gpio)) return PTR_ERR(priv-reset_gpio); // 申请中断中断号由设备树interrupts属性解析得到 ret platform_get_irq(pdev, 0); if (ret 0) return ret; priv-irq ret; ret devm_request_irq(dev, priv-irq, temp_irq_handler, IRQF_TRIGGER_FALLING, tmp117, priv); if (ret) return ret; platform_set_drvdata(pdev, priv); return 0; }这里有个经验之谈能用devm_开头的managed API就不要手动释放资源。devm_kzalloc分配的内存会在设备解绑时自动释放devm_ioremap映射的地址也会自动释放devm_request_irq申请的中断一样。手动管理这些资源一旦probe中途出错遗漏任何一个释放都会造成内存泄漏或者资源泄漏。新手写驱动特别喜欢在probe里手动ioremap、kzalloc到了remove里忘了释放最后modprobe/rmmod循环几次就开始内核报错这就是没用好managed API。设备树res获取还有一个细节如果节点描述的是寄存器块要用IORESOURCE_MEM如果节点有多个reg区域第2个参数0、1、2分别对应第几个。一些SoC的IP包含多个寄存器段千万要数清楚。4. I2C设备驱动开发一套可复用的流程4.1 I2C子系统分层I2C设备驱动开发是嵌入式里很常见的工作。Linux的I2C子系统分了清晰的层次底层是I2C控制器适配器负责物理总线的时序中间是核心层负责总线仲裁和设备管理上层是客户端驱动也就是你写的传感器、EEPROM、触摸屏驱动。客户端驱动不直接操作寄存器地址而是通过I2C核心提供的接口构造消息并发送到适配器适配器再去和硬件握手。这样带来的好处是驱动代码可以跨平台复用——适配器是芯片厂商已经写好的你只要关注怎么和你的传感器通信就行。I2C设备驱动的注册核心还是i2c_driver结构体里面最关键的是id_table和probe回调。id_table声明这个驱动支持哪些I2C设备probe在设备匹配成功后执行初始化。4.2 i2c_driver注册函数一个简单的I2C驱动注册流程是这样static const struct i2c_device_id temp_id[] { { tmp117, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, temp_id); static const struct of_device_id temp_of_match[] { { .compatible vendor,tmp117 }, { } }; MODULE_DEVICE_TABLE(of, temp_of_match); static struct i2c_driver temp_i2c_driver { .driver { .name tmp117, .of_match_table temp_of_match, }, .probe temp_probe, .remove temp_remove, .id_table temp_id, }; module_i2c_driver(temp_i2c_driver);module_i2c_driver这个宏会展开成module_init和module_exit帮你完成i2c_add_driver和i2c_del_driver的调用。这里同时提供了of_match_table和id_table是为了兼容设备树匹配和传统board info匹配两种方式。在纯设备树环境下of_match_table就够了。注册成功之后只要设备树里有对应I2C节点probe就会被调用。如果probe没被调用先检查设备树里这段I2C总线是否status okay再用i2cdetect -y 0扫一下总线上有没有这个地址的设备。地址不对是常见原因经常有硬件上改了地址软件设备树忘了同步。4.3 probe函数里的初始化动作I2C驱动probe里要做的初始化工作通常包括读取设备ID确认芯片、初始化寄存器配置、注册中断或者混入其他子系统。以温度传感器为例static int temp_probe(struct i2c_client *client) { struct temp_dev *priv; int ret; priv devm_kzalloc(client-dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; priv-client client; i2c_set_clientdata(client, priv); // 读设备ID寄存器确认连接是否正确 ret i2c_smbus_read_word_data(client, REG_DEVICE_ID); if (ret 0) return ret; if ((ret 0xFF) ! EXPECTED_ID) { dev_err(client-dev, wrong device id: 0x%04x\n, ret); return -ENODEV; } // 配置传感器工作模式 ret i2c_smbus_write_word_data(client, REG_CONFIG, 0x0000); if (ret 0) return ret; return 0; }我建议probe里一定要做ID确认这一步不要直接配置寄存器。有些时候板子接错了芯片或者芯片地址冲突误匹配了别的设备不做ID确认会把寄存器写给一个完全不存在的地址难排查得很。ID确认就像开门先问一句“你是谁”成本很低收益却很高。4.4 读写函数与用户态交互驱动初始化完成后应用怎么拿到数据如果数据量小最简单的做法是实现一个read回调在持有信号量或者mutex之后通过I2C读寄存器然后用copy_to_user返回。读寄存器用i2c_smbus_read_word_data这类接口它能自动处理I2C通信协议里的地址数据寄存器寻址过程。写的时候也是同理。需要注意并发问题传感器可能同时被多个进程读取probe和remove也可能和你正在读数据的时间点重叠。保护共享数据要加锁最简单的是mutexstatic ssize_t temp_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) { struct temp_dev *priv filp-private_data; int raw; int temperature; if (count sizeof(temperature)) return -EINVAL; if (mutex_lock_interruptible(priv-lock)) return -ERESTARTSYS; raw i2c_smbus_read_word_data(priv-client, REG_TEMP); mutex_unlock(priv-lock); if (raw 0) return raw; // 假设12位分辨率转换公式以实际手册为准 temperature (short)raw * 10 / 16; if (copy_to_user(buf, temperature, sizeof(temperature))) return -EFAULT; return sizeof(temperature); }用mutex_lock_interruptible而不是mutex_lock是为了避免进程在等待锁的时候被信号打断导致不可中断睡眠。应用层CtrlC时如果驱动在等待锁最好能立刻返回让进程被正常杀掉否则这个进程会变成D状态很难清理。温度转换公式只是示意不同传感器数据格式差别很大。有的高位在前有的低位在前有的带符号位有的还要根据分辨率右移。拿到raw值先别急着换算拿电烙铁或热风枪改变温度打印原始值对比手册确认位定义是否正确。这一步能筛掉大部分“读数明显不对”的问题。5. 常见疑难杂症与调试排查实录5.1 insmod失败和系统卡死的典型原因驱动开发中遇到最多的问题是insmod直接报错或者系统重启。常见错误信息可以整理成下面这张速查表报错信息原因解决办法Invalid module format内核版本不匹配模块用错内核头文件编译用目标板同一个内核源码和.config重新编译Unknown symbol in module模块引用了未导出的内核符号或依赖模块未加载检查EXPORT_SYMBOL是否导出先加载依赖模块Required key not available内核开启了模块签名验证关闭CONFIG_MODULE_SIG或给模块签名No such deviceprobe没执行或设备树节点不存在检查设备树、i2cdetect、compatible匹配Kernel panic - not syncingprobe里有非法内存访问或中断处理里操作了不能睡眠的函数用JTAG/串口日志定位检查指针和原子上下文说到panic最让人头疼的就是驱动把整个系统带崩。这里有一条铁律在中断上下文、spinlock临界区和原子上下文里绝对不能调用可能睡眠的函数。典型危险动作包括kmalloc(..., GFP_KERNEL)、mutex_lock、copy_to_user。这些操作可能被调度出去而中断上下文不允许睡眠轻则调度器告警重则直接panic。我见过有人把printk放在中断里打印大量调试信息高频率中断直接拖垮系统这也是不推荐的。驱动crash之后首先要会看日志。串口控制台里如果开了earlycon内核早期的启动信息能看到驱动的printk也会从串口出来。用dmesg看当前系统日志也行但panic之后整个日志可能在内存里没来得及刷到磁盘串口才是最可靠的。5.2 让内核多说话动态调试与日志控制printk是驱动调试最基本的工具但也不能随便用。生产驱动里大量printk会拖慢系统尤其在高频路径上。内核提供了动态调试机制可以在运行时按文件、函数、甚至行号开关日志比来回改代码编译省事得多。开启CONFIG_DYNAMIC_DEBUG之后配合内核的dynamic_debug控制接口可以在运行时打开某个文件里的调试打印echo file drivers/i2c/busses/i2c-xxx.c p /sys/kernel/debug/dynamic_debug/control驱动里用pr_debug或者dev_dbg打印的信息默认不会输出。等需要排查时再动态打开问题定位完之后关掉性能基本不受影响。这比一直开着pr_info要专业得多。如果临时要开全内核的调试打印也可以调整printk的console loglevelecho 8 /proc/sys/kernel/printk但这样输出会非常多而且会明显拖慢启动速度适合偶尔看完整流程不适合常态开启。5.3 硬件侧排查从设备树到示波器有时候驱动代码看起来没问题但就是读不到数据这时候问题往往出在物理层。先用i2cdetect确认设备是否在总线上应答再用i2cget直接读寄存器排除应用层和驱动的问题。如果i2cdetect都扫不到设备优先检查硬件连接上拉电阻是否焊接地址引脚是否接对电源是否正常。I2C总线必须要上拉电阻很多开发板内部已经集成自制的板子要特别注意。然后拿示波器或者逻辑分析仪抓一下SCL和SDA的波形确认时钟频率不要超过设备上限确认ACK位是否有拉低。很多时候软件层面调了半天最后发现是板上某个电容焊错了。设备树的引脚复用也很常见。SoC的每个引脚可能有多重功能比如同一个引脚既可以是UART TX也可以是I2C SCL。如果引脚复用配置不对I2C控制器往某个引脚上输出时钟实际上信号根本没到物理引脚上。这种情况下检查pinctrl配置、复用寄存器看看有没有被别的驱动占用往往比看驱动代码更快。5.4 性能与并发注意事项驱动上线之后除了功能正确还要关注性能。一次read操作如果每次都要切换用户态和内核态、执行I2C传输可能只消耗几十微秒但如果数据量大、调用次数多整体开销不可忽略。优化的方向有几个减小锁的粒度不要把一个大的临界区包在所有操作外面使用中断驱动加等待队列而不是轮询寄存器如果数据量大考虑DMA或者内核里的IIO/regmap框架。regmap是内核提供的一套寄存器映射抽象它帮你处理锁、缓存、I2C/SPI传输细节很多新驱动都基于regmap实现代码复用率和稳定性都比自己直接调i2c_transfer高。并发问题上内核有大量机制mutex、spinlock、原子变量、RCU。选错锁的类型轻则性能下降重则死锁。我的经验是临界区里只是简单读写寄存器、操作链表且不会被外部信号长期阻塞用spinlock临界区里有I2C/SPI传输、可能睡眠的等待等不可中断操作用mutex。中断上下文里只能用spinlock不能使用mutex。这些规则具体到代码里还有更多细节但先把这两条记清楚可以避免很多低级问题。调试并发问题是最费时间的。模块加载一段时间后才出现偶发错误多半和并发、时序有关。一种有效的方法是打开内核的lockdep机制它会在运行中检测可能的死锁和锁顺序问题并输出分析信息。内核配置里开启CONFIG_PROVE_LOCKING然后在复现问题时把dmesg完整保存下来。lockdep的告警信息虽然常常很长但仔细读它会告诉你两个锁在哪些路径上以不同顺序被获取。这种问题靠人眼盯代码很难发现lockdep能省下大把时间。6. 一些实际工程里的经验技巧最后再分享几个我自己的小习惯不一定写在教科书里但真的能救命。第一个习惯是每个驱动模块里一定要有明确的版本号和作者信息并且在加载时打印出来。板子多了之后你根本分不清当前insmod的是哪个版本日志里带版本号能少很多“怎么改了没反应”的疑问。我一般在Makefile里定义EXTRA_CFLAGS来传递版本号或者在代码里直接用MODULE_VERSION。第二个习惯是每次修改驱动之前先想清楚怎么回滚。驱动开发中经常遇到的现象是改动一行代码系统起不来了可你明明记得只改了一行。这时如果手头有git管理内核和驱动源码直接git diff看改动git checkout回退一版效率极高。所以哪怕是一个人维护的小项目也一定要用版本管理工具。不要觉得自己记得住内核源码加设备树加驱动改动半小时之后你真的会忘。第三个习惯是建立自己的调试环境模板。我常用的板子环境包括串口线、网络NFS根文件系统、编译好的ko目录、自动化加载脚本。NFS根文件系统特别适合驱动开发改完驱动直接cp到NFS目录在板子上rmmod/modprobe就行不用反复烧写整个根文件系统。配合一个简单的测试脚本每次加载后自动创建设备节点、跑一遍基本功能、打印日志能快速回归验证。第四个习惯也可能是最重要的多读内核源码少看二手资料。写字符设备驱动遇到瓶颈时直接翻内核源码里的drivers目录比如drivers/misc、drivers/iio/temperature下有很多现成参考。内核源码会告诉你它到底支持哪些API、参数怎么传、返回值什么含义这些信息比大多数博客教程可靠得多。我写I2C驱动前喜欢先看一个结构简单的同类驱动照着它的probe、read、remove逻辑搭架子再对照硬件的特殊之处做调整。这比从零写要稳很多。Linux设备驱动开发是一个实践性极强的领域代码编译通过只是第一步让它在目标板上稳定跑起来、能应对各种异常情况才算是真正完成。上面这些内容是我在做过的项目里反复用到的核心套路和踩坑总结希望能帮你少走一些弯路。驱动这条路没有捷径但把这些基础框架吃透、调试手段练熟后面再复杂的设备也有章可循。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进