ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Linux设备驱动开发实战:从设备树到中断与并发调优

2026/9/14 2:53:07 拓冰建站 浏览量
Linux设备驱动开发实战:从设备树到中断与并发调优 去年接手的那个工业控制器项目让我印象很深。板子是i.MX6ULL内核4.19要挂一个I2C接口的温度传感器和一个外部中断输入。当时我满脑子想的还是驱动开发就是对着寄存器手册写一堆读写代码等真正下水才发现Linux设备驱动开发的核心压根不是操作寄存器而是怎么让驱动融进内核的框架里——设备模型、设备树匹配、中断下半部、并发保护、调试手段每一样都比单纯控制硬件更考验功底。这篇文章就把我从零做驱动到产品量产的完整经验梳理一遍给准备入坑嵌入式Linux驱动、或者正在被设备树和内核机制折磨的同行做个参考。1. 先把驱动到底在干什么这件事想清楚1.1 驱动不是独立的程序而是内核与硬件之间的翻译层很多新手最大的误区是认为驱动就是一堆操作硬件寄存器的代码。实际上寄存器操作只是驱动的一小部分。当一个应用层程序执行open(/dev/temp_sensor, O_RDONLY)时调用路径是这样的应用进程发起系统调用 → VFS虚拟文件系统根据设备节点找到对应的inode → 根据设备号定位到已经注册的字符设备 → 通过file_operations结构体里的回调函数进入驱动代码 → 驱动再操作底层硬件寄存器或通过总线控制器读写数据。换句话说驱动做的是把内核期望看到的接口翻译成硬件能理解的时序。上层系统不关心你的传感器是I2C还是SPI也不关心寄存器地址是多少它只知道调用read()就能拿到温度数据。这个抽象过程带来的好处是巨大的应用代码可以做到完全与硬件无关换一块传感器板卡只要驱动接口保持一致应用层一行代码都不用动。这个设计思路和现实的接纳逻辑很像——你不需要关心插座背后是火电还是水电插头标准一致就行。驱动的插头标准就是file_operations、设备号、设备模型这几个机制。理解了这一点驱动开发就不再是点对点地操作寄存器而是设计一个符合内核规范的接口层。1.2 先判断你的设备该走哪条驱动框架写驱动前必须回答一个问题这个硬件属于哪种类型应该挂在内核的哪条框架下。Linux内核的设备驱动大致分三大类字符设备按字节流读写是嵌入式设备中最常见的类型。传感器、GPIO、串口控制器、ADC、LED灯大多属于这一类。块设备以块为单位读写典型是eMMC、SD卡、NVMe固态盘。块设备驱动要通过块层block layer注册普通应用开发涉及较少。网络设备以数据包为单位收发走struct net_device框架。Wi-Fi模组、以太网MAC、USB网卡都属于这一类。但这里还有一个更关键的维度。同样是字符设备硬件挂载方式不同驱动写法完全不同。如果你的传感器挂在I2C总线上内核已经为你封装好了i2c_driver框架你只需要注册一个I2C设备的驱动结构体内核会在设备树上自动匹配并帮你把struct i2c_client递到你手上。如果你要驱动的外设是芯片内部的一个寄存器映射外设比如UART控制器、DMA控制器、以太网MAC则需要走平台设备platform_device框架和platform_driver配对。这些框架不是让你绕远路而是内核在告诉你一个最佳实践不要自己发明一套发现硬件的机制用内核已经定义好的匹配规则。否则你会在设备枚举、电源管理、休眠唤醒这些地方做得非常痛苦。2. 字符设备驱动的骨架每加一个外设都要走一遍的流程2.1 设备号分配与cdev注册其实就四行代码的事字符设备驱动的第一步是获得内核唯一识别的设备号。设备号由主设备号次设备号组成主设备号用来区分驱动类型次设备号用来区分同一驱动下的不同设备实体。经典用法dev_t dev_id; int major 0; /* 0表示动态分配 */ int minor_base 0; int count 1; if (major) { /* 指定主设备号 */ register_chrdev_region(dev_id, count, my_sensor); } else { /* 动态分配推荐方式 */ alloc_chrdev_region(dev_id, minor_base, count, my_sensor); major MAJOR(dev_id); }为什么推荐动态分配因为指定主设备号很容易冲突这个数字不是你想用就有的虽然Documentation/devices.txt里有一个建议分配表但嵌入式产品往往没有固定编号的约束。动态分配虽然拿到的设备号可能每次启动不同但设备节点可以用udev/mdev自动生成应用层通过固定路径/dev/my_sensor来访问根本不需要知道设备号是多少。拿到设备号之后还要把cdev结构体初始化并添加到内核struct cdev cdev; cdev_init(cdev, fops); cdev.owner THIS_MODULE; cdev_add(cdev, dev_id, count);这三步之后内核就已经知道了设备号对应的操作函数表。但这里有个常用的简化技巧如果你的外设是挂载在设备树上的平台设备或I2C设备驱动其实不用自己去注册字符设备而是可以利用misc_register()注册一个miscdevice——它会自动申请一个次设备号、自动创建设备节点配合udev处理逻辑清爽得多。很多重要的子系统比如leds-gpio、gpio-keys内部就是用的miscdevice。2.2 file_operations你真正要交付的核心内容设备号只是名片驱动真正干活靠的是file_operations这张表。它定义了应用层每个系统调用进入驱动后对应的处理函数。嵌入式字符设备驱动中最常用的是这几个open打开设备通常在这里做初始化比如申请物理内存、配置GPIO方向。read读取设备数据例如读取传感器结果。关键点是数据要拷贝到用户空间必须用copy_to_user()不能直接赋值。因为内核空间和用户空间的虚拟地址隔离直接*buf data大概率段错误。write写数据到设备例如配置寄存器、下发固件。ioctl通用控制命令接口。所有不适合用开关式数据流表达的控制命令比如改变采样率、启动校准、设置阈值都通过ioctl传递。release关闭设备做资源清理。举个实际例子。用户态执行read(fd, buf, 4)想要读取4字节温度数据驱动侧read接口需要先检查用户传的count是否足够然后从硬件寄存器读出16位的温度值赋值到一个内核buffer再通过copy_to_user拷贝回去。整个流程听起来很简单但真正的坑都埋在细节里——比方说read被调用时硬件数据还没准备好怎么办答案是用等待队列。驱动里维护一个wait_queue_head_t类型的等待队列当没有数据时read调用wait_event_interruptible()让当前进程进入睡眠当中断处理函数检测到硬件数据就绪时调用wake_up_interruptible()唤醒等待的进程。这样read看起来就像阻塞读一样数据到了才返回。这个机制是并发场景下驱动正确性的基础也是新手最容易忽略的地方——很多人的第一版驱动是空转轮询硬件寄存器白白烧掉CPU。2.3 ioctl命令码定义千万别自己随便编数字ioctl的第二个参数是命令码内核有统一的编码规范定义在linux/ioctl.h里#define MY_IO_SET_RATE _IOW(T, 0x10, int) #define MY_IO_GET_STATUS _IOR(T, 0x11, int)编码中包含了方向读/写和数据大小信息内核据此做参数合法性检查。很多驱动开发者在项目早期图省事直接定义#define CMD_SET_RATE 1、CMD_GET_STATUS 2这样的连续数字。短期内能用但一旦驱动扩展、命令增多或者需要在不同驱动类型之间做区分很容易冲突、踩踏。用规范宏定义编译期就能发现参数类型不匹配运行时也能通过_IOC_SIZE做合法性校验长期维护的成本低很多。ioctl还有一个很实用的点里面可以专门加一个调试命令比如MY_IO_DEBUG_REGS把当前驱动持有的寄存器值全部打印出来。在设备调试阶段这个命令配合dmesg可以快速定位寄存器状态异常比反复重新编译内核快得多。2.4 最简驱动的模块化编译驱动代码可以直接编入内核也可以作为模块.ko加载。开发阶段强烈建议编成模块这样每次改动只需要重新编译模块不用整编内核、不用反复烧录启动镜像。Makefile 的经典写法KERNELDIR ? /lib/modules/$(shell uname -r)/build obj-m : my_sensor_drv.o all: make -C $(KERNELDIR) M$(PWD) modules clean: make -C $(KERNELDIR) M$(PWD) clean注意交叉编译时KERNELDIR要指向目标板内核源码路径另外需要设置ARCH和CROSS_COMPILE环境变量否则编译出来的模块在ARM板子上加载会报Unknown symbol或invalid module format。这个坑我踩过不止一次绝大多数情况就是内核版本号或配置不一致查一下模块的vermagic就明白了。3. 设备树不是背参数表而是驱动和硬件资源之间的接口契约3.1 设备树取代了当年硬编码的注册资源老一代的Linux内核里平台驱动注册硬件资源靠的是板级文件里的platform_device硬编码。每换一块板子就要重新定义内存基址、中断号、是否使能某些GPIO代码里到处是#ifdef板级宏。设备树出现后彻底改变了这个局面硬件资源由一个树形结构的描述文件定义内核在启动时解析该文件驱动通过标准API动态获取资源。换板卡时驱动源码可以保持不变只需要修改设备树源文件.dts中对应节点的属性。这在产品线扩展时价值巨大——同一款控制器芯片衍生出五六个规格的产品驱动代码往往只有一个设备树却有五六份。这也是为什么现在的嵌入式开发招聘设备树配置几乎是必备技能。3.2 驱动侧解析设备树的标准姿势假设设备树里有一个温度传感器的节点i2c1 { tmp1075: tmp107548 { compatible ti,tmp1075; reg 0x48; interrupt-parent gpio5; interrupts 17 IRQ_TYPE_EDGE_FALLING; sample-rate 1000; }; };在I2C框架的驱动侧匹配过程是这样的。struct i2c_driver的id_table里写入compatible字符串和驱动名字static const struct i2c_device_id tmp1075_id[] { { tmp1075, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, tmp1075_id); static const struct of_device_id tmp1075_of_match[] { { .compatible ti,tmp1075 }, { } }; MODULE_DEVICE_TABLE(of, tmp1075_of_match);内核在枚举I2C总线时会依次读取每个I2C从设备节点的compatible属性与已注册的驱动匹配表对比。匹配成功后驱动侧的probe函数就会收到一个struct i2c_client *指针里面已经填好了设备地址、中断号等信息。在probe里获取自定义属性static int tmp1075_probe(struct i2c_client *client, const struct i2c_device_id *id) { struct device_node *np client-dev.of_node; u32 sample_rate; int irq; /* 读取自定义属性 */ of_property_read_u32(np, sample-rate, sample_rate); /* 获取中断号 */ irq client-irq; dev_info(client-dev, temp sensor probed, addr0x%02x, irq%d\n, client-addr, irq); return 0; }注意interrupt-parent和interrupts两个属性会被内核自动解析client-irq直接就是解析好的中断号。这就是设备树的威力——驱动开发者根本不需要知道这块板子的GPIO控制器是怎么编号的内核的irq domain机制已经把设备树上写的GPIO标号翻译成了内核虚拟中断号。3.3 平台设备的资源获取reg与ioremap对于挂在芯片内部总线上的平台设备比如一个GPIO控制器、一个DMA通道设备树的写法是my_controller: my-controller20000000 { compatible my-company,my-controller; reg 0x20000000 0x1000; interrupts 33 4; };驱动侧通过platform_get_resource获取内存资源再映射到虚拟地址空间static int my_ctrl_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; res platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) return -EINVAL; base devm_ioremap(pdev-dev, res-start, resource_size(res)); if (IS_ERR(base)) return PTR_ERR(base); /* 现在可以用 base offset 读写寄存器 */ writel(0x01, base 0x04); return 0; }这段代码里我特意用了devm_ioremap而不是ioremap。devm_系列API的含义是设备管理资源驱动卸载或probe失败时内核会自动释放之前申请的资源。手动调用ioremap你需要自己跟踪每一个iounmap、release_mem_region的匹配少写一个就是内存泄漏。用内核推荐的资源管理函数干净且不易出错。3.4 设备树调试的几个高频踩坑点坑一compatible 匹配不上。最常见的低级错误是设备树里写的是ti,tmp1075驱动匹配表里写的是tmp1075两边不一致设备根本不会挂载。改完后可以用cat /sys/bus/i2c/devices/1-0048/name查看设备是否被识别。另一个排查方法是dmesg | grep i2c看内核枚举I2C总线时的输出。坑二reg 地址位数和 #address-cells 不匹配。设备树里有两个关键属性父总线的#address-cells和#size-cells分别决定reg里的地址和长度的位数。比如I2C子系统的I2C控制器节点里如果定义了#address-cells 1那么每个子节点的reg 0x48就是1个cell。如果你从别处抄了一段设备树#address-cells是1但reg写了两个值内核解析时默认第二个值是长度还是第二个地址结果往往不是你预期的那样。这种错误排查成本高因为编译不一定报错运行时才出问题。坑三修改设备树后忘记验证语法。开发阶段改完 .dts至少要用设备树编译器检查一下有没有语法错误。在内核源码目录下执行make dtbs或者单独命令dtc -I dts -O dtb -o test.dtb my_board.dts如果编译通过但设备依然没工作可以先把设备树反编译回文本检查你改的内容是否真的编译进去了dtc -I dtb -O dts -o dump.dts my_board.dtb坑四中断属性不完整导致无中断触发。设备树里写了interrupt-parent和interrupts但实际因为GPIO控制器节点的#interrupt-cells不对导致中断号解析错乱。这时候可以用/proc/interrupts查看驱动申请到的中断号实际触发了几次如果数字一直为零大概率是设备树中断属性配置有误。4. 中断、并发与性能驱动从能跑到跑得好4.1 中断处理的下半部机制tasklet、workqueue还是threaded irq很多硬件设备需要CPU在事件发生时立刻做出反应这就是中断的用武之地。但中断处理函数有个硬性约束它运行在特殊上下文中不能睡眠不能调用可能阻塞的函数比如mutex_lock、copy_to_user、kmalloc(GFP_KERNEL)而且必须尽快返回否则整个系统实时性都会受影响。所以内核引入了中断上下半部的机制。上半部中断处理函数只做最紧急的事——读取硬件状态寄存器、清除中断标志、把需要后续处理的数据保存起来下半部做真正的数据处理——比如唤醒等待队列、投递到内核线程、触发数据拷贝。下半部有很多种实现方案我梳理下实际选型的逻辑机制是否睡眠适用场景优先级软中断否网络收发等高性能路径最高tasklet否简单快速的后处理数据量小高workqueue是耗时的数据处理可能阻塞低threaded irq是独立内核线程大多数嵌入式外设驱动兼顾简便和完整可配置实际写嵌入式设备驱动时我推荐优先考虑threaded irq。用法devm_request_threaded_irq(client-dev, irq, NULL, tmp1075_irq_handler, 0, tmp1075, client);NULL表示没有硬中断上半部内核会默认创建一个线程化的中断处理函数tmp1075_irq_handler。这个处理函数运行在独立的内核线程上下文可以睡眠可以用互斥锁可以直接调用那些需要GFP_KERNEL的分配函数。对于I2C温度传感器这种中断来了之后需要去读I2C寄存器拿数据的场景再合适不过了——因为I2C读写本来就是慢速操作完全没必要用tasklet这种跑在原子上下文里的机制把自己捆死。tasklet 或软中断适合什么场景我在写DMA完成中断时常用因为DMA搬运完成后的收尾工作非常轻量更新一下状态标志、释放缓冲区、唤醒等待队列。这类操作不需要睡眠在原子上下文直接跑完效率最高。关键是别把耗时操作硬塞进去。4.2 自旋锁和信号量选错了真会死锁驱动运行在多个并发上下文里——应用进程、内核线程、中断处理。共享数据结构必须加锁保护。但锁的选择直接决定了驱动会不会死锁。先说自旋锁它的特点是不休眠、忙等待在多核处理器上一个CPU持有锁时另一个CPU在spin循环等待。所以自旋锁绝不能长时间持有。它适合保护只有几十条指令的临界区比如一次寄存器读写、一个状态标志的更新。但如果临界区里有writel这种短操作用自旋锁没问题一旦临界区里还有usleep_range或者等待某个别的设备就完蛋了——持有自旋锁睡眠是内核开发最严厉的禁忌会直接导致系统挂起。互斥锁mutex则相反它是睡眠锁。当临界区被占用请求锁的进程会进入睡眠直到锁被释放才醒来。所以临界区里可以做任何事包括kmalloc、copy_to_user、i2c_transfer这种耗时操作。但有个很关键的细节中断处理函数里不能直接拿互斥锁因为中断上下文和睡眠是冲突的。这就是为什么很多驱动在中断里用自旋锁把耗时操作转移到下半部去由下半部用互斥锁完成。我踩过的一个实际死锁案例是在read接口里持有了互斥锁然后又调用了copy_to_user。如果这时候用户态进程正巧因为我自己的某种设计缺陷陷入异常page fault处理时可能会等待同一把锁导致系统卡死。教训是读设备数据时必须先把硬件数据读进内核buffer、释放锁再把数据拷贝到用户空间。锁保护的是内核资源本身不是保护整个系统调用过程。4.3 性能调优方向减少拷贝、降低中断开销、注意DMA缓存一致性驱动性能优化的核心思路是减少不必要的开销。三个方面最见效。第一减少数据拷贝。普通read流程里数据从硬件寄存器到用户空间至少经过内核buffer、拷贝一次、再经copy_to_user拷贝一次。如果数据量很大比如ADC连续采集回传4096字节这些拷贝会被无限放大。解决思路之一是实现mmap接口将内核buffer直接映射到用户空间用户态读写这个buffer时零拷贝或者实现多块缓冲区轮转double buffer驱动在填满一块的同时应用在读另一块互相不阻塞。第二合并中断。有些外设每个数据包都产生一个中断。如果数据产生频率非常高中断上下文切换的开销会吃掉大量CPU。NIC驱动里常见的NAPI机制本质是中断上半部只标记有数据来了然后软中断轮询批量处理数据而不是每个包触发一次中断。这个思路可以借用到嵌入式采集驱动上。如果你的外设支持FIFO尽量让它攒了多个数据再产生中断一次处理一批性能提升立竿见影。第三DMA缓存一致性。使用DMA时硬件直接读写内存不走CPU的缓存这就会造成一个经典问题CPU在内存里看到的值是旧缓存而DMA写入的新数据还在cache里没被刷新到内存。解决方式是好好使用DMA API。分配DMA缓冲区时用dma_alloc_coherent()它返回的地址是缓存一致的用流式DMA时要手动调用dma_map_single()/dma_unmap_single()并在合适时机调用缓存同步接口。新手最常见的bug就是拿到了dma_alloc_coherent的地址就开心地往里塞数据根本没意识到方向标志的含义。DMA驱动调试时遇到数据是乱的或者偶尔少几个字节优先查缓存一致性。5. 调试手段和一次真实问题的完整排查链路5.1 这组调试工具够用了驱动开发百分之七十的时间在调试一套顺手的工具组合能救命。我的标配是这样的dmesg 动态printk最基础的调试手段。printk的日志级别用dev_info/dev_err而不是直接printk因为前者会带上设备名日志一看就知道是哪个设备在说话。动态调试支持运行时开启某些文件的日志echo file drivers/iio/temp/tmp1075.c p /sys/kernel/debug/dynamic_debug/control。/proc/interrupts查看中断号、中断触发次数。判断硬件是否真的产生中断很有用。/sys/kernel/debug很多内核子系统会在debugfs下暴露节点。I2C的总线trace、GPIO的状态、DMA的寄存器快照都能在这里找到。/sys/class和/sys/bus/platform/devices查看设备树枚举结果确认设备有没有被挂到总线上、驱动和设备的匹配状态。devmem直接在用户态读写物理地址。调试寄存器映射时不用写一堆测试代码直接命令行读devmem 0x20a00004 32i2c-toolsi2cdetect、i2cget、i2csetI2C设备的定位和读写利器。i2cdetect -y 1扫描I2C总线上的从设备地址秒判设备是否在线。ftrace查看内核函数调用路径。这对理解一处中断到底走过了哪些函数非常有效比打印日志更细粒度。5.2 一次设备树改了但设备就是不挂载的排查实录说一个我最近遇到的实际问题完整走一遍排查链路。现象在板子上新增了一颗温度传感器设备树节点写好了驱动也编译进内核但/sys/bus/i2c/devices/下看不到新设备应用层读取/dev/temp_sensor报没有该设备节点。第一步确认设备树真的生效了。开机后执行cat /proc/device-tree/ocp/i2c1/tmp107548/如果目录存在说明设备树节点已经被内核解开了。如果目录不存在问题出在设备树编译或启动加载环节直接查引导日志。第二步确认I2C设备有没有被枚举。执行i2cdetect -y 1扫描总线。结果发现地址0x48下确实有设备在回应说明硬件信号没问题I2C控制器也没问题。第三步查驱动匹配。dmesg | grep tmp1075没有任何输出这说明内核根本没有尝试过匹配这个设备。再看/sys/bus/i2c/devices/列表发现I2C总线上只显示了一个空设备没有绑定驱动。第四步查驱动是不是真的编进去了。检查内核配置zcat /proc/config.gz | grep TMP1075发现CONFIG_SENSORS_TMP1075是not set。问题就出来了——驱动构建配置默认没打开。改内核defconfig重新编译烧录设备顺滑挂载。这个问题如果在项目早期五秒就能定位但在产品晚期的目标板上升级驱动时很容易习惯性以为是设备树写法出了问题。这些经验提醒我出问题时先查最容易忽略的环节编译配置、模块加载顺序、设备树语法、硬件地址冲突按从简单到复杂的顺序排查。5.3 驱动开发中值得养成的好习惯不要自己管理资源生命周期的每一步。能用devm_系列API就尽量用devm_ioremap、devm_request_irq、devm_kmalloc自动帮你释放probe失败时不用层层清理代码量骤减。驱动里加调试节点。实现一个debugfs节点支持在运行时读取内部状态、修改采样率、强制触发某段逻辑。这个习惯在联调阶段能节约数小时。读芯片手册比看别人代码重要。很多驱动bug的根源是对硬件工作模式理解不透彻。寄存器的每一位在手册里都写得很清楚对照手册验证你的代码逻辑是排查疑难问题的最终手段。每个外设驱动都写一个自测脚本。驱动写完不是看insmod成功就完事要跑一遍完整的读写、中断、休眠唤醒循环记录日志比对。理解内核版本差异。不同内核版本间API会变比如module_platform_driver宏的引入、of_property_read_u32的参数调整这些变化会让老代码在新内核上编译失败。升级内核时先跑一遍make oldconfig并检查驱动API兼容性。写在最后驱动开发的真正门槛不在写寄存器做了几年设备驱动我最大的感触是Linux设备驱动开发的难点从来不在某个外设怎么操作而在于你对内核框架的理解深度。设备树继承和匹配规则、驱动和设备的生命周期、中断上下文的约束、并发模型的粒度控制这些看不见摸不着的机制决定了驱动是稳定运行还是隔三差五挂掉。给准备入坑的朋友一个可落地的学习路线先在虚拟机里编译一个主线内核再在QEMU或者一块廉价的ARM开发板上跑起来买一个I2C或SPI接口的传感器用字符设备驱动框架写一遍然后把设备树节点从零写一遍用i2c-tools验证接着给它加一个中断通知试试threaded irq最后用ftrace优化一次中断路径的延迟。这四个环节走完你对Linux设备驱动开发的整体轮廓就有了剩下的就是遇到问题、排查问题、积累经验的过程。驱动这条路没有捷径但每一步实践都会变成你口袋里的工具下次遇到新硬件时你会发现自己已经不再害怕了。