Linux设备驱动开发:从总线模型到设备树的实战指南

发布时间:2026/9/15 3:44:21
Linux设备驱动开发:从总线模型到设备树的实战指南 1. 为什么说“写驱动之前先看懂总线模型”我第一次接触Linux设备驱动开发时犯过一个很典型的错误以为驱动开发的核心是搞懂GPIO、中断、寄存器这些硬件操作。后来被一个老工程师点破寄存器操作只是驱动开发的“手”总线模型才是“骨架”。不理解骨架写出来的驱动要么在别的板子上起不来要么换个内核版本就挂。这篇内容我就把自己这几年在Linux设备驱动开发里的实际经验梳理一遍重点不是罗列API而是把那些真正影响项目成败的模型认知、配置细节、调试路径讲清楚适合正在学驱动入门、或者已在嵌入式项目里被设备和驱动不匹配问题折磨过的朋友参考。1.1 驱动开发的真正门槛是“模型”不是C语言很多初学者拿到内核源码后第一反应是去找某个驱动的.c文件然后从头读到尾。读完之后发现好像看懂了但自己动手写还是一头雾水。原因就在于驱动代码是挂在“总线-设备-驱动”这个三角关系上的单独看任何一方的代码都是残缺的。Linux内核里device和driver是两套独立的注册体系一侧是硬件物理存在或设备树描述出来的device另一侧是你写的driver。它们之间靠bus总线来牵线搭桥。总线上维护着两个链表一个是已注册的设备列表一个是已注册的驱动列表一旦有新的device或driver加入总线就会遍历另一端尝试为它们配对。配对成功probe函数被调用驱动才算真正“接手”了这个硬件。这里的核心思路是驱动不是主动去找硬件的而是把自己注册到总线上等待硬件来“认领”。刚开始写驱动的人通常不习惯这种“被动”思维总是想当然地认为驱动应该直接readl/writel操作物理地址。实际上在Linux驱动框架里第一步永远是先回答三个问题硬件挂在哪个总线上设备节点长什么样驱动怎么声明自己支持哪些设备1.2 总线-设备-驱动三角关系以及match的机制以最常用的platform总线为例。Linux把那些不挂在PCI、USB、I2C、SPI这些标准总线上而是直接集成在SoC内部或简单连接的外设统一归为platform设备。platform总线上的匹配规则有好几种按优先级排序大致是设备树中的compatible属性与driver中of_match_table的compatible字符串完全匹配。设备名与driver中id_table里的名字匹配。设备名与driver-name字段匹配。实际项目中绝大多数情况走的是第一条路。设备树里写一个节点指定compatible驱动里声明一个of_device_id数组两个字符串一致匹配就成立。static const struct of_device_id my_led_of_match[] { { .compatible vendor,my-led, }, { /* 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);这里有个重要的细节of_match_table里的compatible字符串必须和设备树节点里写的完全一致包括厂商前缀和逗号。很多驱动的probe不执行查到最后就是这里多了一个空格或者大小写不一致。我在实际项目中见过太多次这种“低级但致命”的失误。1.3 一个具体的从设备树到驱动匹配的过程用我做过的一个温湿度传感器驱动来走一遍完整流程。硬件是I2C接口的SHT30挂在SoC的I2C2总线上设备地址0x44。设备树里这样描述i2c2 { status okay; sht3044 { compatible sensirion,sht30; reg 0x44; }; };内核启动时i2c2控制器驱动注册了一个i2c adapter设备树解析后生成i2c_client设备挂到i2c总线上。我的驱动里声明static const struct i2c_device_id sht30_id[] { { sht30, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, sht30_id); static const struct of_device_id sht30_of_match[] { { .compatible sensirion,sht30 }, { } }; MODULE_DEVICE_TABLE(of, sht30_of_match); static struct i2c_driver sht30_driver { .probe sht30_probe, .id_table sht30_id, .driver { .name sht30, .of_match_table sht30_of_match, }, }; module_i2c_driver(sht30_driver);i2c总线match时优先用of_driver_match_device检查compatible匹配成功后就调用probe。probe里拿到i2c_client指针后续所有寄存器读写都通过它来完成。这个过程的本质就是设备树告诉内核“我这里有一个sht30”驱动告诉内核“我支持sht30”两者通过compatible字符串建立联系。把这一环吃透后面写字符设备、操作寄存器、处理中断都是在“联系建立之后”的事情。2. 设备树驱动与硬件之间的“接线表”设备树在Linux设备驱动开发里的地位相当于一张接线表。传统方式下硬件信息写死在驱动代码里换一块板子就得改驱动重编译。设备树把硬件拓扑和资源配置从驱动里剥离开来用dts/dtsi文件描述硬件长什么样驱动通过统一接口去读取这些配置真正做到“一份驱动多板适配”。2.1 设备树在驱动开发里到底扮演什么角色设备树Device Tree本质上是一个描述硬件的数据结构从根节点“/”开始往下展开成树状。每个节点表示一个硬件设备或总线节点里的property描述设备的属性。比如reg描述寄存器地址和长度interrupts描述中断号clocks描述时钟。驱动代码里用一套标准API来获取这些信息device_property_read_u32()读取整型属性。device_property_read_string()读取字符串。of_iomap()把reg里的物理地址映射为虚拟地址。platform_get_irq()获取中断号。devm_gpiod_get()获取GPIO描述符。这套API的好处是不直接绑定设备树也兼容ACPI和传统的platform_data。我在新写的驱动里尽量都用device_property系列函数而不是老的of_开头的接口这样代码在不同固件接口之间移植时改动最小。2.2 设备树节点与驱动代码的对应关系看一个带中断和GPIO的典型节点gpio-keys { compatible gpio-keys; pinctrl-names default; pinctrl-0 key_pin; key-power { label Power; gpios gpio1 3 GPIO_ACTIVE_LOW; linux,code KEY_POWER; wakeup-source; }; };驱动里对应获取struct gpio_desc *gpio; gpio devm_gpiod_get_optional(dev, NULL, GPIOD_IN); if (IS_ERR(gpio)) return PTR_ERR(gpio); /* gpiod_get_value(gpio) 读取电平 */这里要注意gpios属性里第一个字段是GPIO控制器的phandle后面跟着的是控制器内部编号不是SoC引脚编号。不同厂商的GPIO控制器编号规则差异很大有的按bank算有的按全局序号算写设备树之前一定要查清楚自家SoC的GPIO编号方式。我踩过最狠的一次坑就是把控制器内部编号当成了SoC引脚序号结果驱动操控的GPIO完全不是预期的那根硬件上直接把一个电源芯片的使能脚给拉低了整板掉电。2.3 设备树配置里最容易踩的四个坑第一个坑忘记加status okay。很多SoC默认把大部分外设节点设为disabled不加这一句设备树节点解析了但不生效。驱动probe不执行查了半天发现status还是disabled。第二个坑reg属性长度不匹配。比如reg 0x12000000 0x1000表示基地址0x12000000、长度0x1000。of_iomap会按这个长度做ioremap。如果实际硬件寄存器范围超过0x1000访问到超出映射范围的地地址就会触发内核oops。第三个坑中断号的认知偏差。设备树里interrupts属性指定的是中断控制器视角的中断号不是SoC数据手册里的中断号。比如有些SoC内部把GPIO中断和外部中断统一编址需要加偏移。写驱动时直接用platform_get_irq拿返回值就行但排查问题时要清楚这个号是怎么来的。第四个坑pinctrl缺失或配置错误。很多外设的引脚默认不是复用成目标功能必须通过pinctrl节点把引脚复用成I2C、UART或GPIO模式。少了pinctrl-0属性I2C控制器可能根本没有引脚接到外部通信自然失败。3. 字符设备框架从hello到可用的寄存器读写驱动模型跑通后下一步是让应用层能访问硬件。90%以上的简单设备驱动最终都实现为字符设备。字符设备的本质是向内核注册一个设备号绑定一组file_operations操作函数应用层通过open/read/write/ioctl来间接操作硬件。3.1 字符设备的三件套做字符设备驱动核心三件事分配设备号、初始化cdev、创建设备节点。设备号分为主设备号和次设备号。主设备号标识设备类型次设备号标识同类型下的不同实例。分配方式有两种静态指定和动态分配。dev_t dev_num; int ret; /* 动态分配设备号 */ ret alloc_chrdev_region(dev_num, 0, 1, my_device); if (ret 0) { pr_err(Failed to alloc chrdev region\n); return ret; } /* 或用静态指定MKDEV(major, minor)前提是确认该主设备号未被占用 */动态分配的好处是避免主设备号冲突但设备节点需要靠udev/mdev动态创建或者自己在驱动里用class_create device_create创建。后者在嵌入式开发里更常见因为很多环境没有完整的udev。static struct class *my_class; my_class class_create(my_device_class); device_create(my_class, NULL, dev_num, NULL, my_device);这样会在/dev下生成my_device节点应用层直接open /dev/my_device即可。3.2 次设备号管理次设备号虽然是个int但很多新手会忽略它的管理价值。一个驱动可以占用连续的多个次设备号每个次设备号对应不同的硬件实例或不同的访问通道。比如一个双通道ADC主设备号相同次设备号0和1分别对应通道A和Bfile_operations里的open函数根据iminor(inode)来区分访问的是哪个通道。我的习惯是用宏定义来管理次设备号偏移而不是在代码里写死。因为项目后期加通道改动最小。#define MY_ADC_CH0_MINOR 0 #define MY_ADC_CH1_MINOR 1 #define MY_ADC_MINOR_CNT 23.3 一个简单的寄存器读写驱动骨架下面是一个最简单的寄存器读写驱动的核心骨架演示了ioremap、copy_to_user、read/write/ioctl的配合方式。struct my_reg_dev { void __iomem *base; struct cdev cdev; struct device *dev; }; static ssize_t my_reg_read(struct file *file, char __user *buf, size_t len, loff_t *offset) { struct my_reg_dev *priv file-private_data; u32 val; int ret; if (len sizeof(val)) return -EINVAL; val readl(priv-base *offset); ret copy_to_user(buf, val, sizeof(val)); if (ret) return -EFAULT; *offset sizeof(val); return sizeof(val); } static ssize_t my_reg_write(struct file *file, const char __user *buf, size_t len, loff_t *offset) { struct my_reg_dev *priv file-private_data; u32 val; int ret; if (len sizeof(val)) return -EINVAL; ret copy_from_user(val, buf, sizeof(val)); if (ret) return -EFAULT; writel(val, priv-base *offset); return sizeof(val); } static int my_reg_probe(struct platform_device *pdev) { struct resource *res; struct my_reg_dev *priv; priv devm_kzalloc(pdev-dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; res platform_get_resource(pdev, IORESOURCE_MEM, 0); priv-base devm_ioremap_resource(pdev-dev, res); if (IS_ERR(priv-base)) return PTR_ERR(priv-base); /* cdev_init cdev_add device_create ... */ platform_set_drvdata(pdev, priv); return 0; }read/write里用file-private_data保存驱动私有数据结构指针是Linux驱动的标准做法。open里要记得用container_of或直接赋值的方式把这个指针挂上去。否则后续read/write拿不到硬件操作所需的信息。还要记住内核和用户空间之间传数据禁止直接解引用用户指针。必须用copy_to_user/copy_from_user或者用get_user/put_user。直接访问用户空间地址会导致内核oops也容易引入安全漏洞。4. 并发与锁驱动最容易翻车的地方驱动跑的上下文不是只有一个。中断处理函数随时可能抢占普通执行路径多核SoC上两个CPU可能同时执行你的驱动代码用户空间也可能有多个进程同时调用驱动接口。这些并发场景叠加在一起如果没有正确的同步机制就会出现数据覆盖、寄存器操作交错、状态错乱之类的问题而且这类bug极难复现和定位。4.1 内核态并发的来源先列一下我实际遇到过并发生问题的几个场景一个进程在读驱动的状态寄存器同时另一个进程在写控制寄存器两者都先“读-改-写”同一个变量。中断处理程序里修改了某个标志主路径也修改这个标志二者没有互斥。SMP下两个CPU核同时进入驱动的同一个函数共享缓冲区被并发写入。卸载驱动时release还没处理完新的open请求又进来了。这些问题的共性就是共享资源没有受到保护。所谓的共享资源包括全局变量、设备寄存器、内存缓冲区、硬件状态标志等等。4.2 自旋锁和互斥锁的实际选择内核里同步机制很多但驱动开发中最常用的是自旋锁spinlock和互斥锁mutex。选型规则其实很清晰临界区执行时间很短不能睡眠用自旋锁。比如中断上下文、寄存器序列操作。临界区可能执行较长时间或需要调用可能睡眠的函数比如i2c_transfer、msleep、copy_to_user用互斥锁。自旋锁等待期间会一直忙等占用CPU适合短临界区但要注意持锁期间绝对不能调用睡眠函数。我一个同事在自旋锁里调了msleep结果系统直接卡死因为spinlock持有期间被调度出去其他CPU等待时自旋如果有中断去唤醒调度又会尝试获取同一把锁直接死锁。互斥锁允许睡眠所以临界区可以较长但也有代价进程会进入睡眠状态有调度开销。对于像I2C、SPI这种每次操作就要等硬件应答的场景mutex是合理选择。4.3 实测案例并发读写的竞态问题我之前写一个数据采集驱动时用了一个全局环形缓冲区来暂存中断里收到的数据用户态通过read取走。最初没加锁测试单进程读写都很正常加了多进程压力测试后数据偶发错乱。排查路径是这样的把系统负载拉高多开几个进程同时read。在内核里临时增加统计计数统计缓冲区读写指针是否出现回退。确认是写指针中断上下文和读指针进程上下文发生了竞争。因为中断上下文不能睡眠最终选择用spinlock保护环形缓冲区的索引更新。static DEFINE_SPINLOCK(ring_lock); static void irq_handler(int irq, void *data) { unsigned long flags; spin_lock_irqsave(ring_lock, flags); /* 更新写指针写入数据 */ spin_unlock_irqrestore(ring_lock, flags); } static ssize_t my_read(struct file *file, char __user *buf, size_t len, loff_t *offset) { unsigned long flags; ssize_t ret; spin_lock_irqsave(ring_lock, flags); /* 从缓冲区取数据更新读指针 */ spin_unlock_irqrestore(ring_lock, flags); ret copy_to_user(buf, temp, cnt); return ret; }几个关键的细节中断上下文里用spin_lock_irqsave而不是spin_lock因为要保证在处理临界区期间本地中断是关闭的否则中断嵌套会再次进入同一临界区。进程上下文里也用spin_lock_irqsave是防止当前进程正持有锁时来了中断中断处理函数又尝试取同一把锁导致死锁。这个锁只保护缓冲区索引和数据拷贝到临时区的过程不保护copy_to_user期间的数据因为copy_to_user可能睡眠不能持锁。5. 调试驱动的几种手段以及我常用的排查路径驱动开发里写代码的时间和调bug的时间往往是一比三。调试手段是否熟练直接决定项目进度。内核的调试工具链其实非常丰富从最基础的printk到动态追踪工具我按实际使用频率排个序。5.1 printk的等级和动态调试别小看printk它是驱动调试的第一手段关键是用对等级。printk有八个优先级从KERN_EMERG(0)到KERN_DEBUG(7)。默认情况下终端只显示小于console_loglevel的日志。生产内核的console_loglevel通常设为4KERN_WARNING这意味着你写的pr_info、pr_debug在正常开机后看不到。所以调试期我一般直接把console_loglevel调到8或者使用dynamic_debug机制只开启指定文件的调试信息echo file my_driver.c p /sys/kernel/debug/dynamic_debug/controldynamic_debug的好处是不用重编译内核运行态临时开某个文件的调试输出排查完再关掉。建议在驱动代码里用pr_debug而不是pr_info输出频繁的调试信息这样发布时可以靠dynamic_debug开关控制。5.2 ftrace与函数调用追踪当驱动的执行流程和预期不符时用ftrace看函数调用序列非常高效。比如probe没执行可以用ftrace确认内核是否真的走到了platform_driver_register以及of_match_table的匹配结果。# 挂载tracefs mount -t tracefs nodev /sys/kernel/tracing # 开启函数追踪 echo function /sys/kernel/tracing/current_tracer echo platform_probe /sys/kernel/tracing/set_ftrace_filter echo 1 /sys/kernel/tracing/tracing_on cat /sys/kernel/tracing/traceftrace的function tracer开销比较大排查问题时可以只过滤关键函数定位后再关掉。除了函数追踪ftrace还有irqsoff、wakeup等tracer可以用来分析驱动导致的中断延迟、调度延迟问题。5.3 内存问题排查KASAN、kmemleak、UBSAN驱动最常见的崩溃原因不是逻辑错而是内存问题越界访问、use-after-free、内存泄漏。这类问题单靠看代码很难发现在必须上工具。KASANKernel AddressSanitizer能检测越界访问和use-after-free。在内核配置里打开CONFIG_KASAN然后复现问题KASAN会直接打印出具体的出错地址、访问长度、分配和释放的调用栈。kmemleak专门检测内核内存泄漏。开启后周期性地扫描内存对象把疑似泄漏的分配点和对象地址打出来。调试I2C、SPI这类有大量缓冲区分配和释放的驱动时非常有用。UBSANUndefined Behavior Sanitizer检测未定义行为比如有符号整数溢出、数组越界、空指针运算。它不像KASAN那样常用但有时能抓到一些KASAN不覆盖的隐藏问题。我遇到过最典型的案例驱动里用kmalloc分配了一个缓冲区但probe失败时直接return忘了kfree导致每一次probe失败就泄漏一块内存。反复重复“加载驱动-probe失败-卸载驱动”的操作后系统内存逐渐吃紧最终触发OOM。用kmemleak跑一遍泄漏点一目了然。6. 中断与性能从下半部到环形缓冲区的实战取舍有硬件事件需要及时响应时通常用中断。但中断处理有个铁律耗时不能太长。因为中断处理期间其他中断可能被屏蔽或延迟这直接影响系统实时性。所以Linux把中断处理分成上半部和下半部上半部只做最紧急的事情比如读取硬件状态、清中断标志、激活下半部真正的数据处理放下半部做。6.1 上半部与下半部的分工上半部就是request_irq注册的handler函数运行在中断上下文必须快速返回。我第一次写UART驱动时在中 hanler里做了不少数据处理结果发现接收数据一多系统就变得卡顿。后来把所有数据搬移操作放下半部上半部只把数据从FIFO搬到中间缓冲区并调度下半部卡顿问题消失。下半部的机制有三种常用选择softirq和tasklet运行在软中断上下文不能睡眠处理快适合高频小数据量场景。tasklet是基于softirq实现的更友好封装同一个tasklet在多个CPU上是串行执行的所以写起来不用加锁非常省心。workqueue运行在进程上下文可以睡眠适合耗时较长、可能阻塞的操作。threaded irq把整个中断处理都放到内核线程里handler是进程上下文可以直接在handler里做完整处理不再显式拆下半部。6.2 tasklet/workqueue/threaded irq怎么选我的选择逻辑是这样的如果下半部只是搬数据、清状态没有可能要睡眠的操作用tasklet最合适。如果需要调用i2c_transfer、读取GPIO寄存器、或者处理比较耗时的逻辑用workqueue或threaded irq。如果硬件中断比较“重”每次中断都要做一系列复杂操作threaded irq最省事因为你不需要手工管理下半部的调度。举个workqueue的例子static irqreturn_t sample_irq_handler(int irq, void *dev_id) { struct my_dev *priv dev_id; schedule_work(priv-work); return IRQ_HANDLED; } static void sample_work_handler(struct work_struct *work) { struct my_dev *priv container_of(work, struct my_dev, work); /* 在这里做耗时操作 */ }注意work_struct需要嵌入到设备的私有结构体里用container_of反推回去拿到设备指针。很多新手把work_struct定义成全局变量多个设备实例时就会串数据。6.3 用户态读写路径的优化中断把数据搬到内核缓冲区只是第一步用户态能不能及时、高效地把数据读走同样影响性能。早期SPI驱动测试时我发现大量小数据read会导致吞吐率很低。分析发现瓶颈不是中断而是每次read都要copy_to_user整块数据。解决办法是在驱动里加一个聚合逻辑用户态传入的缓冲区大小不足一块时驱动先把数据累积到内核侧的一个buffer里等累积到一定大小或者超过某个时间阈值再一次性拷贝给用户。这样减少了copy_to_user的次数吞吐率提升明显。时间阈值用hrtimer实现保证低延迟场景下数据不会一直攒着不发给用户态。还要注意read时尽量用“用户缓冲区长度”而不是固定长度来做拷贝避免应用层只能按驱动定义的块大小来读数据灵活性差。内核里copy_to_user前先检查用户传入的count是否合法避免拷贝越界。7. 嵌入式场景下最容易被忽视的裁剪与系统集成问题设备驱动开发到后期真正的挑战往往不是那几千行驱动代码而是驱动怎么和整个系统协作。Linux内核裁剪和系统集成是驱动工程师从“会写驱动”到“能交付完整产品”之间的一道分水岭。7.1 内核裁剪时的驱动配置依赖裁剪内核时如果开启了某个驱动但不满足它的依赖项可能出现两种情况编译报错或者配置被Kbuild自动忽略。很多情况下是后者驱动的menuconfig条目显示是“选中”的但实际没编进内核因为它的依赖比如某个regmap框架、某个GPIO controller驱动没使能。因此在裁剪菜单里取消一个配置项之前一定要用make menuconfig里的搜索功能按“/”查看有哪些驱动依赖它。我处理过一个实际案例裁剪掉了某个GPIO controller驱动结果系统里三个外设驱动全部失联因为它们的设备树节点里引用了同一个GPIO controller的phandleprobe阶段请求GPIO失败直接return -EPROBE_DEFER。eprobe_defer是另一个值得单独说的概念驱动probe时需要的资源暂时不可用返回-EPROBE_DEFER告诉内核“我还没准备好等依赖到位后再试一次”。内核会在依赖资源注册后重新触发probe。但内核重新触发probe的次数是有限制的超过限制后会被放弃。裁剪时如果砍掉了某个延时加载的子系统就可能导致外围驱动反复probe失败最终放弃加载。7.2 驱动与系统启动顺序module_init的优先级驱动是编译进内核built-in还是编成模块moduleprobe时机完全不同。built-in驱动的初始化顺序由链接顺序决定也和initcall的级别有关。内核把initcall分成很多层级pure_initcall、core_initcall、postcore_initcall、arch_initcall、subsys_initcall、fs_initcall、device_initcall等。大多数driver用的是device_initcall也就是module_init。如果两个驱动之间有依赖关系比如ADC驱动依赖I2C控制器先准备好那么I2C控制器的probe必须在ADC probe之前执行。虽然-EPROBE_DEFER机制可以缓解顺序问题但反复defer会增加启动时间。最好的做法是在设备树中通过phandle引用隐式表达依赖内核会根据这些依赖关系调整probe顺序。实际项目里我发现真正需要手工调initcall级别的场景并不多。只要设备树引用关系正确内核的deferred probe机制能处理大多数“设备依赖另一个设备”的情况。需要手工调整时可以在驱动里选择更高的initcall级别但代价是如果依赖的设备还没就绪probe仍然会失败。7.3 实际项目中的一点体会从只会写hello world字符设备到能独立交付一个带中断、DMA、设备树配置、系统裁剪的完整嵌入式驱动我最大的体会是驱动开发不是孤立的编程工作它是硬件、系统、应用三方之间的桥梁。写驱动前花时间读芯片手册、画清楚硬件连接比上来就写代码重要得多。调试时经验的积累恰恰来自一次次“查设备树、看dmesg、上ftrace”的正向循环。最后分享一个小技巧新板子bring-up阶段驱动加载失败后先别急着改代码。打开内核的probe调试信息、确认设备树节点status、确认pinctrl复用是否正确、确认中断号有没有被其他设备占用这四步能排查掉80%的“probe不执行”问题。剩下的20%再靠KASAN、ftrace和耐心去解决。