1. 从裸机到Linux驱动开发到底在开发什么很多人刚接触嵌入式时对“驱动开发”这四个字的理解是模糊的。有人觉得它就是写写寄存器配置有人觉得它是内核里那些看不懂的C文件还有人把它和“底层”画等号觉得只要够底层就是驱动。这些理解都不算错但都不完整。我在带新人的时候最常被问到的问题就是“我到底在开发什么”这个问题如果一开始没想清楚后面学起来会非常痛苦因为你不知道自己写的每一行代码在整个系统里处于什么位置。先把结论放在这里驱动开发的核心任务是充当硬件和操作系统之间的翻译官。硬件只认电信号和寄存器操作操作系统只认统一的接口和数据结构驱动就是那个把“写0x1F到偏移0x20”翻译成“打开LED”的中间层。你写的每一个驱动本质上都在做三件事向内核注册自己、响应内核的调用、操控具体的硬件。1.1 驱动在系统中的位置三层视角从系统架构来看一个典型的嵌入式Linux系统可以分成四层硬件层CPU、外设控制器、传感器、存储芯片等物理器件驱动层直接操作硬件寄存器向上提供统一接口内核层VFS、设备模型、内存管理、中断子系统等基础设施应用层用户空间的程序通过系统调用访问设备驱动层夹在中间它既要“向下看”理解硬件的时序图和寄存器手册又要“向上看”符合内核的框架规范。这就是为什么很多从单片机转过来的工程师会觉得不适应——在裸机上你直接操作寄存器就完事了但在Linux下你操作寄存器的方式、时机、上下文都有严格的约束。我见过太多人写驱动时犯的一个典型错误在驱动的probe函数里做太多耗时操作比如延时等待硬件稳定。在裸机上这没问题但在Linux内核里probe函数的执行会阻塞设备模型的初始化流程如果延时过长可能导致整个系统启动变慢甚至卡死。正确的做法是用工作队列或者延迟探测机制把耗时操作推到后面去执行。1.2 字符设备、块设备、网络设备三条不同的路Linux把设备分成三大类每类对应不同的驱动框架设备类型访问方式典型代表驱动框架字符设备字节流顺序访问串口、按键、LEDcdev file_operations块设备数据块随机访问eMMC、SD卡、NANDblock_device_operations 请求队列网络设备数据包协议栈交互以太网、WiFinet_device NAPI初学者建议从字符设备入手因为它的框架最简单file_operations结构体里的open、read、write、ioctl这几个回调函数就能覆盖大部分场景。但要注意字符设备虽然简单但它的“简单”是框架层面的简单真正写好一个字符设备驱动你需要处理并发控制、内存映射、中断处理、电源管理等一系列问题。块设备驱动的复杂度会陡增因为你要理解请求队列、I/O调度器、bio结构体这些概念。网络设备驱动则是另一套逻辑它不走VFS那套文件接口而是直接和协议栈交互。我的建议是先把字符设备吃透再根据实际项目需要去深入某一类。1.3 设备树驱动和硬件解耦的关键现代嵌入式Linux驱动开发绕不开设备树Device Tree。在设备树出现之前硬件的描述信息是硬编码在驱动里的换个板子就要改驱动代码维护成本极高。设备树把硬件描述从驱动代码里抽离出来用一套独立的语法来描述板级硬件信息。一个典型的设备树节点长这样led_device { compatible mycompany,my-led; reg 0x020C406C 0x04; gpios gpio1 3 GPIO_ACTIVE_LOW; status okay; };驱动里通过of_match_table来匹配compatible属性匹配成功后就能用of_get_gpio、platform_get_resource这些API来获取硬件资源。这样做的好处是同一个驱动可以支持多个硬件平台只要设备树写对了就行。但设备树也有坑。最常见的问题是compatible字符串写错一个字符驱动就匹配不上而且内核不会报错只是默默地不加载你的驱动。我建议在驱动里加一句打印确认probe函数被调用了这是排查设备树问题的第一步。2. 从零写一个按键驱动非阻塞扫描的完整实现按键驱动看起来简单但它是检验一个驱动工程师基本功的最好题目。为什么这么说因为按键涉及了驱动开发的几乎所有核心问题GPIO操作、中断处理、去抖动、阻塞与非阻塞、并发控制、文件接口设计。把这一个驱动写好了其他字符设备驱动基本都能触类旁通。2.1 为什么不用轮询中断驱动的设计思路最朴素的按键驱动写法是在read函数里轮询GPIO状态但这种方式有两个致命问题第一CPU会一直空转浪费算力第二如果应用层不调用read按键事件就丢了。所以实际项目中按键驱动几乎都是用中断来触发的。中断驱动的基本流程是在probe函数里申请GPIO和中断号注册中断处理函数在中断处理函数里记录按键状态唤醒等待队列在read函数里等待等待队列返回按键状态这里有一个关键的设计决策中断处理函数里应该做多少事Linux内核把中断处理分成上半部top half和下半部bottom half。上半部要尽可能快不能睡眠不能做耗时操作下半部可以做更多事情但要用工作队列或tasklet来调度。对于按键驱动我的做法是上半部只做一件事——记录时间戳和按键状态然后调度一个工作队列去处理去抖动。去抖动需要延时而延时在中断上下文里是不允许的所以必须放到工作队列里。2.2 去抖动的两种实现延时与计数按键抖动是机械开关的固有特性按下和松开时会产生几十毫秒的毛刺。去抖动有两种常见方案方案一延时确认法在检测到电平变化后延时20ms再读一次如果电平一致就确认按键有效。这种方案实现简单但会引入20ms的延迟对于需要快速响应的场景不太合适。方案二定时器计数法用一个定时器每隔5ms扫描一次按键状态连续3次读到相同状态才确认。这种方案响应更快但需要额外的定时器资源。我在实际项目中更倾向于方案一因为它的代码更简洁而且20ms的延迟对人手按键来说完全可以接受。但要注意延时不能用mdelay因为mdelay是忙等待会浪费CPU。应该用msleep它会让出CPU给其他进程。static irqreturn_t button_isr(int irq, void *dev_id) { struct button_dev *dev dev_id; dev-timestamp jiffies; schedule_work(dev-work); return IRQ_HANDLED; } static void button_work(struct work_struct *work) { struct button_dev *dev container_of(work, struct button_dev, work); msleep(20); int state gpio_get_value(dev-gpio); if (state ! dev-last_state) { dev-last_state state; wake_up_interruptible(dev-waitq); } }2.3 阻塞与非阻塞read函数的两套逻辑按键驱动的read函数需要同时支持阻塞和非阻塞两种模式。阻塞模式下如果没有按键事件read会一直等待非阻塞模式下read会立即返回-EAGAIN。实现的关键是判断文件标志static ssize_t button_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { struct button_dev *dev filp-private_data; int ret; if (filp-f_flags O_NONBLOCK) { if (!dev-key_pressed) return -EAGAIN; } else { ret wait_event_interruptible(dev-waitq, dev-key_pressed); if (ret) return ret; } if (copy_to_user(buf, dev-key_value, sizeof(int))) return -EFAULT; dev-key_pressed 0; return sizeof(int); }这里有一个容易忽略的细节wait_event_interruptible返回后要再次检查条件是否真的满足。因为等待队列可能被信号打断也可能被虚假唤醒。虽然wait_event_interruptible内部已经做了条件检查但在并发场景下从等待队列返回到读取数据之间条件可能又变了。所以更严谨的写法是在返回后加一个二次确认。2.4 并发控制自旋锁还是互斥锁按键驱动里有多处共享数据按键状态、时间戳、等待队列。中断处理函数和工作队列都会访问这些数据所以必须加锁。选择锁的原则很简单能在中断上下文里用的只有自旋锁能睡眠的上下文用互斥锁。因为中断处理函数不能睡眠所以如果中断处理函数要访问共享数据就必须用自旋锁。但自旋锁有个问题它关中断。如果临界区太长会影响系统响应。所以我的做法是中断处理函数里只操作最少的数据用自旋锁保护工作队列里的操作可以用互斥锁因为工作队列运行在进程上下文可以睡眠。static irqreturn_t button_isr(int irq, void *dev_id) { struct button_dev *dev dev_id; spin_lock(dev-lock); dev-timestamp jiffies; spin_unlock(dev-lock); schedule_work(dev-work); return IRQ_HANDLED; }注意自旋锁的临界区里不能调用可能睡眠的函数包括kmalloc(GFP_KERNEL)、copy_to_user、msleep等。如果确实需要这些操作说明你的设计有问题应该把操作移到工作队列里。3. 驱动开发中最容易踩的五个坑驱动开发有一个特点代码写完了不代表能跑能跑了不代表稳定稳定了不代表没问题。很多bug不是逻辑错误而是对内核机制理解不到位导致的。下面这五个坑是我这些年踩过或者看别人踩过的每一个都值得单独拿出来说。3.1 内存泄漏kmalloc和kfree的配对问题内核空间的内存管理和用户空间不一样用户空间有垃圾回收内核空间没有。你kmalloc了多少就必须kfree多少否则就是永久泄漏。最常见的泄漏场景是错误处理路径。比如probe函数里申请了三块内存第二块申请失败时直接return了第一块就泄漏了。正确的做法是用goto链static int my_probe(struct platform_device *pdev) { int ret; dev-buf1 kmalloc(SIZE1, GFP_KERNEL); if (!dev-buf1) return -ENOMEM; dev-buf2 kmalloc(SIZE2, GFP_KERNEL); if (!dev-buf2) { ret -ENOMEM; goto err_free_buf1; } dev-buf3 kmalloc(SIZE3, GFP_KERNEL); if (!dev-buf3) { ret -ENOMEM; goto err_free_buf2; } return 0; err_free_buf2: kfree(dev-buf2); err_free_buf1: kfree(dev-buf1); return ret; }这种goto链的写法在内核代码里非常常见虽然看起来不够优雅但它能保证每条错误路径都正确释放资源。我建议在写probe函数时先把所有资源申请列出来然后从后往前写错误处理标签。3.2 竞态条件你以为不会同时发生的事偏偏同时发生了竞态条件是驱动开发中最难排查的问题因为它依赖时序可能运行一万次才出现一次。我遇到过一个案例按键驱动在快速连按时会丢失事件排查了很久才发现是中断处理函数和工作队列之间的竞态。问题的根源是中断处理函数在记录按键状态后工作队列还没开始处理下一次中断又来了覆盖了之前的状态。解决方案是用一个环形缓冲区来暂存按键事件而不是只记录最后一次状态。struct button_event { int key_value; unsigned long timestamp; }; struct button_dev { struct button_event events[16]; int head; int tail; spinlock_t lock; };中断处理函数往缓冲区里写read函数从缓冲区里读用head和tail来管理读写位置。这样即使中断来得再快只要缓冲区没满事件就不会丢。3.3 中断上下文误用在错误的地方做了错误的事中断上下文有严格的限制不能睡眠、不能访问用户空间、不能调用可能阻塞的函数。但很多初学者会不自觉地违反这些限制。我见过最典型的错误是在中断处理函数里调用printk打印大量信息。printk本身不会睡眠但如果打印频率太高会导致日志缓冲区溢出进而影响系统性能。更严重的是如果printk的目标是串口控制台而串口驱动本身也在等待中断就可能形成死锁。正确的做法是用printk_ratelimited或者dev_dbg并且只在调试时打开。生产环境的驱动应该尽量少打印或者用tracepoint来替代。3.4 设备树匹配失败compatible字符串的坑设备树匹配失败是新手最常遇到的问题而且它的表现很隐蔽驱动模块加载了但probe函数没被调用/dev下也没有设备节点。排查这个问题的第一步是确认compatible字符串是否完全一致。设备树里的写法和驱动里的写法必须逐字符匹配包括大小写和连字符。我建议在驱动里加一句打印static const struct of_device_id my_of_match[] { { .compatible mycompany,my-device }, { } }; MODULE_DEVICE_TABLE(of, my_of_match); static int my_probe(struct platform_device *pdev) { dev_info(pdev-dev, probe called\n); ... }如果probe没被调用就检查设备树是否被正确编译进了dtb以及设备树节点的status是否为okay。还有一个容易忽略的点设备树节点的父节点必须是一个支持子节点的总线节点比如platform、i2c、spi等。3.5 电源管理suspend和resume的对称性如果你的驱动需要支持电源管理就必须实现suspend和resume回调。这两个回调必须严格对称suspend里保存了什么状态resume里就要恢复什么状态suspend里关闭了什么时钟resume里就要打开什么时钟。我见过一个案例驱动在suspend时关闭了时钟但resume时忘记打开导致系统唤醒后设备无法工作。更隐蔽的是有些硬件在时钟关闭后需要重新初始化而不仅仅是打开时钟。static int my_suspend(struct device *dev) { struct my_dev *mdev dev_get_drvdata(dev); clk_disable_unprepare(mdev-clk); return 0; } static int my_resume(struct device *dev) { struct my_dev *mdev dev_get_drvdata(dev); clk_prepare_enable(mdev-clk); my_hw_init(mdev); /* 重新初始化硬件 */ return 0; }提示在实现电源管理回调时建议先用dev_info打印进入和退出的日志确认回调被正确调用。很多电源管理问题不是代码逻辑错误而是回调根本没被触发。4. 驱动工程师的进阶路线从会写到会调驱动开发这个方向入门容易精通难。会写一个能跑的驱动只是起点真正的分水岭在于当驱动出问题时你能不能快速定位并解决。这需要你对内核机制有深入理解而不仅仅是记住API的用法。4.1 调试工具链printk之外的选择printk是最常用的调试手段但它有局限性打印太多会影响性能打印太少又不够用。随着经验增长你需要掌握更多的调试工具。动态调试dynamic debug允许你在运行时开关打印不需要重新编译内核。用法是在编译时打开CONFIG_DYNAMIC_DEBUG然后通过/sys/kernel/debug/dynamic_debug/control来控制echo file my_driver.c p /sys/kernel/debug/dynamic_debug/controlftrace可以跟踪函数的调用关系对于分析驱动和内核框架的交互非常有用。比如你想知道probe函数被调用时内核走了哪些路径echo function /sys/kernel/debug/tracing/current_tracer echo my_probe /sys/kernel/debug/tracing/set_ftrace_filter cat /sys/kernel/debug/tracing/tracekprobe可以在任意内核函数上插入探测点对于分析第三方驱动的行为特别有用。你不需要修改源码就能知道某个函数被调用了多少次、参数是什么。4.2 内核源码阅读从调用链入手读内核源码是驱动工程师的必修课但内核源码有上千万行不可能从头读到尾。我的方法是从你使用的API入手沿着调用链往上读。比如你用了platform_get_irq就去看它的实现发现它最终调用了of_irq_get再去看of_irq_get怎么解析设备树里的中断信息。这样读源码你不仅知道了API怎么用还知道了它背后的机制遇到问题时就能快速定位。读源码时要注意版本差异。不同内核版本的API可能有变化比如gpio_request在新版本里被gpiod_get取代了。我建议以你实际使用的内核版本为准不要盲目参考网上的文章。4.3 面试中的驱动问题面试官到底想考什么嵌入式驱动岗位的面试技术问题通常围绕三个维度机制理解、调试能力、项目经验。机制理解类的问题比如“中断上半部和下半部有什么区别”“自旋锁和互斥锁的使用场景有什么不同”“设备树是怎么匹配驱动的”这些问题考察的是你对内核框架的理解答案要准确但更重要的是能说出“为什么这样设计”。调试能力类的问题比如“驱动加载了但probe没被调用你怎么排查”“系统运行一段时间后内存泄漏你怎么定位”这些问题没有标准答案面试官想看的是你的排查思路是否清晰、是否有系统性的方法。项目经验类的问题比如“你写过最复杂的驱动是什么”“遇到过最难排查的bug是什么”回答这类问题时要具体、有细节能说出你当时是怎么想的、试了哪些方法、最后怎么解决的。泛泛而谈“我写过很多驱动”是没有说服力的。4.4 持续学习驱动开发的知识更新驱动开发不是学会一套API就能吃一辈子的。内核在演进硬件在更新新的框架和工具不断出现。比如近几年比较重要的变化有设备树的普及老代码里的板级文件board file逐渐被设备树取代GPIO描述符接口gpiod_*系列API取代了旧的gpio_*API设备链接device link用于管理设备之间的依赖关系电源域power domain更精细的电源管理框架保持学习的方法很简单订阅内核邮件列表至少看看你关心的子系统的邮件关注内核版本的release note遇到新问题时先查内核文档Documentation目录而不是直接搜网上文章。5. 项目实战从需求到交付的完整流程前面讲的都是知识点和技巧但实际项目中驱动开发不是孤立的技术活动它是一整个工程流程的一部分。从需求分析到最终交付中间有很多技术之外的因素会影响你的工作。5.1 需求分析硬件手册是第一手资料拿到一个驱动开发任务时第一件事不是写代码而是读硬件手册。硬件手册里包含了所有你需要的信息寄存器地址、位定义、时序要求、电气特性。读硬件手册时我习惯先画一张框图把硬件模块的输入输出、控制寄存器、状态寄存器标出来。然后对照框图确定驱动需要实现哪些功能、需要操作哪些寄存器。有一个容易忽略的点硬件手册里的“典型值”和“最大值/最小值”要区分清楚。比如某个延时参数典型值是10ms但最大值可能是50ms。如果你按典型值来写代码在极端情况下就可能出问题。5.2 驱动框架设计先画图再写代码在动手写代码之前先设计驱动框架。我的做法是画三张图硬件连接图标出驱动需要控制的GPIO、中断、时钟、电源数据流图标出数据从硬件到用户空间的路径状态机图标出设备的各种状态和状态转换条件这三张图画清楚了代码结构基本就确定了。比如数据流图会告诉你需不需要缓冲区、缓冲区多大、用环形缓冲区还是链表状态机图会告诉你需不需要互斥锁、锁的粒度多大。5.3 测试验证单元测试和集成测试驱动测试比应用测试难因为驱动依赖硬件很多场景在开发机上无法模拟。我的做法是分两层测试单元测试在开发机上用mock硬件的方式测试驱动逻辑。比如把GPIO操作替换成内存变量把中断替换成函数调用。这样可以快速验证驱动的核心逻辑不需要真实硬件。集成测试在目标板上测试真实硬件。重点测试边界条件按键快速连按、数据大量传输、系统频繁休眠唤醒。这些场景在单元测试里很难覆盖但恰恰是最容易出问题的地方。5.4 代码审查别人看你的代码能发现什么代码审查是提高代码质量的有效手段。驱动代码审查要重点关注错误处理是否完整每条错误路径是否都释放了资源并发控制是否正确共享数据是否都有保护中断上下文是否安全有没有在中断里做不该做的事电源管理是否对称suspend和resume是否配对我自己的经验是代码审查时最容易发现的问题是错误处理不完整。因为写代码时通常只关注正常路径错误路径容易被忽略。审查时专门盯着错误路径看往往能发现不少问题。6. 嵌入式Linux根文件系统与驱动的关系很多驱动工程师觉得根文件系统是系统集成的事和自己没关系。但实际上驱动和根文件系统之间有紧密的联系理解这种联系能帮你更好地排查问题。6.1 驱动模块的加载时机驱动可以编译进内核built-in也可以编译成模块.ko文件。built-in驱动在内核启动时初始化模块驱动在根文件系统挂载后由用户空间加载。这两种方式各有优劣built-in驱动启动快但会增加内核体积模块驱动灵活但依赖根文件系统。如果你的驱动是模块而根文件系统挂载失败驱动就无法加载设备就无法工作。排查这类问题时先确认根文件系统是否挂载成功。如果用的是NFS根文件系统检查网络是否通、NFS服务是否正常。如果用的是Flash上的文件系统检查分区表和文件系统镜像是否正确。6.2 设备节点的创建驱动加载后需要在/dev下创建设备节点用户空间才能访问。创建设备节点有两种方式手动创建用mknod命令需要知道主设备号和次设备号。这种方式简单但不灵活设备号变了就要重新创建。自动创建用udev或mdev驱动通过class_create和device_create注册设备用户空间自动创建节点。这种方式更现代推荐使用。static int __init my_init(void) { my_class class_create(THIS_MODULE, my_device); if (IS_ERR(my_class)) return PTR_ERR(my_class); device_create(my_class, NULL, MKDEV(major, 0), NULL, my_device); return 0; }注意class_create在新版本内核里需要两个参数旧版本只需要一个。写代码时要确认你的内核版本否则编译会报错。6.3 驱动调试与根文件系统的配合调试驱动时根文件系统里的工具非常重要。比如dmesg查看内核日志、lsmod查看已加载模块、cat /proc/interrupts查看中断统计。如果根文件系统太精简这些工具都没有调试会非常困难。我建议在开发阶段使用功能完整的根文件系统比如Buildroot或Yocto生成的标准镜像。等驱动稳定了再根据产品需求裁剪。7. 写在最后一些个人体会驱动开发这条路入门的时候觉得难是因为要学的东西太多内核框架、硬件手册、调试工具、并发控制。但做久了会发现它的核心逻辑其实很稳定理解硬件、符合框架、处理并发、保证稳定。把这四件事做好大部分驱动问题都能解决。我自己的经验是写驱动最忌讳“想当然”。你觉得硬件会按手册的时序工作但实际可能有偏差你觉得中断不会来得那么快但实际可能比你想象的快得多你觉得错误处理不重要但实际出问题时就是这些地方没处理好。所以多测试、多审查、多读内核源码是提高驱动开发能力最实在的方法。另外不要忽视文档和注释。驱动代码往往过几个月自己都看不懂了更别说别人接手。在关键的地方写清楚“为什么这样做”比写“做了什么”更有价值。
阅读完成 · 觉得有帮助?