1. 从裸机到内核驱动开发到底在解决什么问题很多人刚接触嵌入式时觉得“驱动开发”这四个字特别唬人仿佛是要去写什么高深莫测的底层黑魔法。但如果你真正在项目里摸爬滚打过几年就会发现驱动开发的核心任务其实非常朴素让操作系统知道硬件长什么样并且给上层应用提供一套统一的、稳定的操作接口。我刚开始做嵌入式那会儿用的是STM32跑裸机程序。那时候操作一个GPIO直接写寄存器就行简单粗暴。后来项目升级换到了嵌入式Linux平台突然发现连点个灯都要经过一堆子系统GPIO子系统、pinctrl子系统、设备树、platform总线……当时特别不理解觉得这不是脱裤子放屁吗但踩过几次坑之后才明白这套复杂的机制换来的是可移植性和可维护性。当你面对的是几十个外设、几百个引脚、多个硬件版本并行开发的时候裸机那种“直接怼寄存器”的方式就是灾难。驱动开发要解决的核心矛盾说白了就是三件事。第一是硬件差异的屏蔽同一款芯片不同封装、不同板级设计引脚定义和时钟配置可能完全不同驱动要能适配这些变化。第二是并发与同步中断随时可能来用户态程序随时可能调用驱动接口多个进程可能同时操作同一个设备怎么保证数据不错乱。第三是电源管理嵌入式设备很多是电池供电的驱动要能配合系统进入低功耗状态该关的时钟要关该断的电要断。这三个问题在裸机时代也存在但裸机程序通常只跑一个任务并发问题不突出电源管理也做得粗糙。一旦上了操作系统这些问题就被放大了驱动开发的价值也就体现出来了。所以如果你现在还在写裸机想往驱动方向转我的建议是先别急着看内核源码先把一个简单的字符设备驱动从零写一遍感受一下“注册设备号、实现file_operations、处理用户态读写”这个完整流程然后再去理解背后的设计思想。1.1 驱动工程师和硬件工程师的分工边界在实际项目里驱动工程师和硬件工程师的配合非常紧密但职责边界一定要清晰。硬件工程师负责原理图设计、PCB布局、器件选型他们关心的是信号完整性、电源纹波、EMC这些。驱动工程师关心的是这个芯片的寄存器怎么配、时序怎么满足、中断怎么接、DMA通道怎么分配。我遇到过最典型的问题就是I2C通信失败。硬件工程师说波形没问题驱动工程师说代码没问题最后发现是上拉电阻阻值选大了导致上升沿太缓在高速率下数据采样出错。这种问题如果双方没有良好的沟通机制就会互相甩锅。我的经验是驱动工程师一定要学会看原理图和芯片手册至少要知道你的设备挂在哪个总线上、地址是多少、中断引脚是哪个、复位引脚怎么控制。这些信息在设备树里都要体现如果你不懂硬件设备树都写不对。另一个常见的边界问题是引脚复用。现在的SoC引脚功能非常多一个引脚可能同时支持GPIO、UART、I2C、PWM等功能。pinctrl子系统就是干这个的但配置的时候需要硬件工程师确认这个引脚在板级上实际接了什么。我见过有人把调试串口的引脚配置成了普通GPIO结果系统启动日志都打不出来排查了半天才发现是设备树里pinctrl配置冲突了。1.2 为什么设备树是绕不过去的坎设备树这个概念刚出来的时候很多老驱动工程师特别抵触觉得以前在板级文件里写得好好的为什么要换成这种看起来像“配置文件”的东西。但如果你经历过同一款芯片给不同客户做定制每个客户的板子引脚定义都不一样你就知道设备树的好处了。以前的做法是每块板子一个board-xxx.c文件里面硬编码了所有硬件信息。内核里这种文件多了之后维护成本极高而且很多代码是重复的。设备树把硬件描述从代码里剥离出来内核只保留驱动逻辑硬件差异通过设备树传递。这样同一个驱动可以适配多块板子内核镜像也可以通用。设备树的语法看起来简单但坑非常多。比如compatible属性它决定了驱动和设备怎么匹配。我见过有人写设备树的时候compatible字符串和驱动里的of_match_table差了一个字符结果驱动死活加载不上系统也不报错就是设备节点下面没有对应的驱动。这种问题用ls /sys/bus/platform/devices/和ls /sys/bus/platform/drivers/对比一下就能发现但新手往往不知道去哪里找线索。还有一个常见问题是设备树覆盖。很多开发板的基础设备树已经定义好了大部分外设你如果要修改某个引脚配置不能直接改基础设备树而是要用覆盖层。覆盖层的语法和合并规则需要仔细理解否则会出现“改了没生效”或者“把别的配置覆盖掉了”的情况。2. 字符设备驱动的骨架从注册到卸载的完整链路字符设备驱动是嵌入式Linux驱动开发里最基础、也是最常打交道的类型。按键、LED、传感器、串口这些基本都是字符设备。虽然内核提供了很多子系统来简化开发但理解字符设备驱动的原始骨架对于排查问题和理解上层子系统非常有帮助。一个完整的字符设备驱动核心就是三件事申请设备号、实现file_operations、注册cdev。设备号分为主设备号和次设备号主设备号标识驱动次设备号标识具体设备实例。申请设备号有两种方式静态指定和动态分配。静态指定需要你确保这个主设备号没有被占用动态分配则由内核帮你找一个空闲的。我一般推荐动态分配用alloc_chrdev_region这样不容易冲突。file_operations结构体是驱动和用户态交互的桥梁。用户态调用open、read、write、ioctl、close最终都会映射到驱动里对应的函数。这里有个关键点用户态和内核态的数据拷贝必须用copy_to_user和copy_from_user不能直接解引用用户态指针。我见过有人在驱动里直接memcpy用户态缓冲区在x86上跑得好好的换到ARM上就出问题因为ARM有内存对齐和权限检查。cdev是内核用来管理字符设备的抽象。注册cdev之后内核就知道这个设备号对应哪个file_operations。卸载驱动的时候要按相反的顺序释放资源先cdev_del再unregister_chrdev_region最后释放内存。顺序错了会导致内核崩溃或者资源泄漏。2.1 设备号和次设备号的分配策略设备号的分配看起来简单但在实际项目里有很多讲究。主设备号是全局唯一的内核里有一个major数组来管理。如果你用静态指定需要查Documentation/admin-guide/devices.txt看看哪些号是保留的哪些是可以用的。但更推荐的方式是用alloc_chrdev_region动态申请内核会从空闲号里分配。次设备号的使用就灵活多了。一个驱动可以管理多个设备实例每个实例分配一个次设备号。比如一个GPIO驱动管理16个按键可以用主设备号0到15的次设备号来区分。在open函数里可以通过iminor(inode)获取当前打开的次设备号然后找到对应的设备数据结构。这里有个容易踩的坑次设备号的位宽。内核里dev_t是32位主设备号占12位次设备号占20位。也就是说主设备号最多4095个次设备号最多1048575个。一般项目够用了但如果你要管理大量设备需要注意不要超出范围。另外MINOR和MAJOR宏是用来提取主次设备号的不要自己手动移位容易出错。还有一个实际问题是设备节点的创建。以前需要手动mknod现在一般用class_create和device_create让udev自动创建。但嵌入式系统里很多没有udev用的是mdev或者静态节点。这时候就需要在启动脚本里手动创建或者用devtmpfs。我建议在驱动里同时支持这两种方式用class_create创建类然后在启动脚本里根据/sys/class/xxx/下的信息来创建设备节点。2.2 file_operations里最容易写错的几个回调file_operations里有很多回调函数但常用的就那么几个open、release、read、write、ioctl、llseek。每个都有一些容易写错的地方。open函数里一般要做的事情包括检查设备是否可用、初始化硬件、分配私有数据结构、把私有数据挂到file-private_data上。这里有个坑如果open失败要确保已经申请的资源被释放。我见过有人在open里申请了内存然后检查硬件失败直接返回错误结果内存泄漏了。正确的做法是用goto错误处理标签统一释放资源。read和write的返回值语义要特别注意。返回正数表示成功读写的字节数返回0表示EOF对于read返回负数表示错误。如果返回了错误的负数但没有设置errno用户态会得到一个莫名其妙的错误码。另外非阻塞读写要正确处理如果设备暂时没有数据应该返回-EAGAIN而不是阻塞等待。ioctl是驱动里最灵活也最容易滥用的接口。它既可以传命令也可以传数据。命令的编码有讲究_IO、_IOR、_IOW、_IOWR分别表示无数据、读数据、写数据、读写数据。方向位、大小位、类型位、序号位都要正确设置否则用户态和内核态对命令的理解可能不一致。我一般建议用_IOW和_IOR来明确数据传输方向避免用_IO传复杂结构体。release函数里要做和open相反的操作释放内存、关闭硬件、清理私有数据。注意release不一定在每次close时都被调用因为文件描述符可能被复制fork之后。真正释放是在最后一个引用计数归零时。2.3 并发控制自旋锁、互斥锁和原子操作的选择驱动运行在内核态随时可能被中断打断也可能被多个进程同时调用。所以并发控制是驱动开发里必须认真对待的问题。自旋锁适用于保护短小的临界区特点是不会睡眠可以在中断上下文里使用。但自旋锁会忙等待如果临界区太长会浪费CPU。互斥锁适用于可能睡眠的场景比如在临界区里要拷贝大量数据或者调用可能睡眠的函数。但互斥锁不能在中断上下文里使用。原子操作适用于简单的计数器或者标志位。比如引用计数、状态标志用原子操作就够了不需要锁。我踩过的一个坑是在中断处理函数里用了互斥锁结果系统直接死机。因为中断上下文不允许睡眠而互斥锁在竞争时会睡眠。后来改成了自旋锁并且用spin_lock_irqsave来保存中断状态问题才解决。另一个坑是锁的顺序。如果驱动里有多个锁获取顺序不一致会导致死锁。比如线程A先拿锁1再拿锁2线程B先拿锁2再拿锁1两者并发时就死锁了。解决办法是约定一个全局的锁获取顺序所有代码都按这个顺序来。还有读写锁和RCU机制适用于读多写少的场景。RCU在驱动里用得不多但在内核核心代码里很常见。如果你的驱动需要高性能的读操作可以考虑RCU但使用复杂度较高需要仔细理解宽限期和引用计数的概念。3. 平台设备驱动模型让驱动和硬件解耦platform总线是嵌入式Linux里最重要的虚拟总线之一。它把SoC内部的外设比如I2C控制器、SPI控制器、GPIO控制器、UART控制器抽象成platform设备驱动通过platform总线来匹配设备。这样做的好处是驱动代码里不再硬编码硬件地址硬件信息通过设备树或者板级文件传递。platform驱动模型的核心是platform_driver结构体里面包含probe、remove、shutdown等回调以及of_match_table用于设备树匹配。当内核启动时会遍历设备树里的节点为每个节点创建platform设备然后和注册的platform驱动进行匹配。匹配成功后调用probe函数。probe函数是驱动初始化的核心。在这里要做的事情包括获取设备树里的资源寄存器地址、中断号、时钟、GPIO等、初始化硬件、注册字符设备或者输入设备等子系统接口。probe函数的返回值很重要返回0表示成功返回负数表示失败内核会尝试其他驱动或者延迟探测。3.1 probe函数的资源获取顺序和错误处理probe函数里获取资源的顺序很关键。一般来说先获取内存资源platform_get_resource然后映射寄存器devm_ioremap_resource接着获取中断号platform_get_irq再获取时钟devm_clk_get和GPIOdevm_gpiod_get。这个顺序不是绝对的但有一个原则先获取不依赖其他资源的再获取依赖的。错误处理是probe函数里最容易写乱的地方。我见过很多驱动probe函数里一堆if判断每个失败分支都手动释放之前申请的资源代码又长又容易漏。正确的做法是用devm_系列函数这些函数申请的资源会在驱动卸载或者probe失败时自动释放。比如devm_kzalloc、devm_ioremap_resource、devm_clk_get、devm_gpiod_get都能自动管理生命周期。但devm_不是万能的。有些资源不支持devm_管理比如request_irq虽然有devm_request_irq但中断处理函数里如果访问了已经释放的内存仍然会出问题。所以remove函数里还是要小心处理顺序先注销子系统接口比如input_unregister_device再关闭硬件最后释放内存。还有一个常见问题是probe的延迟。有些设备依赖的时钟或者电源还没有准备好probe会返回-EPROBE_DEFER内核会稍后重试。这个机制很有用但如果驱动里没有正确处理会导致设备一直探测失败。我建议在获取时钟和 regulator 的时候如果返回-EPROBE_DEFER就直接向上返回不要自己重试。3.2 设备树匹配的几种方式和优先级设备树匹配是通过compatible属性来完成的。驱动里的of_match_table是一个数组每个元素包含一个compatible字符串和一个data指针。内核会拿设备树节点的compatible属性和驱动里的of_match_table逐个比较匹配成功就调用probe。compatible字符串的格式一般是vendor,device比如fsl,imx6q-uart。vendor部分通常是芯片厂商的缩写device部分是具体设备型号。匹配的时候是从左到右精确匹配所以字符串必须完全一致。除了compatible匹配platform总线还支持设备名匹配和ID表匹配。设备名匹配是拿platform_device的name和platform_driver的name比较这种方式在设备树之前用得比较多现在主要用于一些虚拟设备。ID表匹配是通过platform_device_id数组来匹配适用于非设备树的场景。匹配优先级是of_match_tableplatform_device_idname。也就是说如果设备树里有compatible属性内核会优先用of_match_table来匹配。如果匹配失败再尝试其他方式。实际项目中我建议统一用设备树匹配这样硬件信息都在设备树里驱动代码更干净。3.3 从probe到remove生命周期管理的坑驱动的生命周期从probe开始到remove结束。remove函数里要做和probe相反的操作但顺序很重要。一般来说先注销对外接口比如cdev_del、input_unregister_device、misc_deregister确保没有新的用户态调用进来。然后关闭硬件禁用中断、关闭时钟、复位设备最后释放内存。这里有个坑remove执行的时候可能还有用户态程序持有设备文件描述符。如果直接释放了file-private_data指向的内存用户态再调用read或者ioctl就会访问非法内存。正确的做法是在release函数里释放私有数据而不是在remove里。remove里只需要标记设备不可用让后续的open失败即可。另一个坑是中断处理函数和remove的竞争。如果remove里释放了中断处理函数用到的内存但中断还没有被禁用中断来了就会崩溃。所以remove里要先disable_irq或者free_irq确保中断不会再触发然后再释放内存。还有电源管理相关的回调suspend、resume、runtime_suspend、runtime_resume。这些回调在系统进入低功耗或者运行时电源管理时被调用。如果驱动没有正确实现这些回调系统可能无法进入低功耗状态或者唤醒后设备不工作。我建议至少实现suspend和resume在suspend里保存寄存器状态并关闭时钟在resume里恢复寄存器和时钟。4. 中断处理上半部和下半部的分工艺术中断是驱动开发里最需要小心处理的部分。中断处理函数运行在中断上下文不能睡眠不能调用可能睡眠的函数执行时间要尽可能短。但实际的中断处理往往需要做很多事情比如读取数据、处理协议、唤醒等待队列。所以Linux把中断处理分成了上半部和下半部。上半部就是中断处理函数本身通过request_irq注册。它应该只做最紧急的事情读取硬件状态、清除中断标志、把数据保存到缓冲区、调度下半部。下半部负责处理耗时的操作比如解析数据、拷贝到用户态、唤醒等待的进程。下半部的实现方式有几种软中断、tasklet、工作队列、线程化中断。软中断和tasklet运行在中断上下文不能睡眠。工作队列运行在进程上下文可以睡眠。线程化中断是把中断处理函数变成内核线程也可以睡眠。选择哪种方式取决于你的需求。如果下半部需要睡眠比如要调用I2C读写就用工作队列或者线程化中断。如果只是简单的数据处理用tasklet就够了。我一般推荐用线程化中断因为它的优先级比普通进程高又比硬中断上下文灵活可以睡眠调试也方便。4.1 request_irq的参数陷阱和中断共享request_irq的参数看起来简单但有几个地方容易出错。第一个是中断号必须是从设备树或者platform_get_irq获取的虚拟中断号不能直接用硬件中断号。第二个是中断标志IRQF_TRIGGER_RISING、IRQF_TRIGGER_FALLING、IRQF_TRIGGER_HIGH、IRQF_TRIGGER_LOW要根据硬件实际的中断触发方式来设置。如果设置错了要么中断一直触发要么永远不触发。第三个是中断共享。如果多个设备共享同一个中断线需要设置IRQF_SHARED标志。共享中断的处理函数里要先判断这个中断是不是自己的设备触发的如果不是就返回IRQ_NONE。我见过有人忘了判断结果一个设备的中断触发了所有共享这个中断的驱动都去处理导致数据错乱。第四个是中断处理函数的返回值。返回IRQ_HANDLED表示中断被正确处理了返回IRQ_NONE表示不是这个设备的中断。如果返回错误的值内核可能会禁用这个中断线导致设备再也收不到中断。还有一个实际问题是中断嵌套。Linux默认不支持中断嵌套也就是说一个中断处理函数执行期间同类型的中断会被屏蔽。如果中断处理函数执行时间太长可能会导致中断丢失。所以上半部一定要短把耗时操作放到下半部。4.2 线程化中断的适用场景和配置方法线程化中断是request_threaded_irq注册的它有两个处理函数一个是硬中断处理函数handler一个是内核线程处理函数thread_fn。硬中断处理函数做最紧急的事情然后返回IRQ_WAKE_THREAD唤醒内核线程。内核线程处理函数运行在进程上下文可以睡眠。线程化中断特别适合那些需要调用可能睡眠的函数的中断处理比如I2C设备的中断处理函数里要读寄存器而I2C读写可能睡眠。如果用普通的中断处理函数在中断上下文里调用I2C读写会直接崩溃。用线程化中断就没这个问题。配置线程化中断的时候handler可以传NULL这样内核会用默认的硬中断处理函数直接唤醒线程。也可以自己实现handler在里面做快速的状态判断如果不是自己的中断就返回IRQ_NONE。线程化中断的优先级可以通过sched_setscheduler来调整默认是SCHED_FIFO优先级50。如果系统里有多个线程化中断可以通过调整优先级来控制响应顺序。4.3 中断上下文中不能做的事情清单中断上下文里有很多限制新手很容易踩坑。我整理了一个清单这些都是绝对不能做的不能调用可能睡眠的函数比如kmalloc(GFP_KERNEL)、mutex_lock、copy_to_user、copy_from_user、msleep、ssleep。不能访问用户态内存因为用户态地址空间可能已经被换出。不能调用schedule因为中断上下文没有进程上下文。不能获取信号量因为信号量可能导致睡眠。不能调用printk大量输出因为printk在某些配置下可能睡眠而且会拖慢中断响应。如果确实需要做这些事情就放到下半部。工作队列和线程化中断都可以睡眠适合做这些操作。还有一个容易忽略的点中断处理函数里访问的全局变量要用volatile或者原子操作保护。因为编译器可能会优化掉一些看似多余的读写导致中断处理函数看到的数据不是最新的。用READ_ONCE和WRITE_ONCE宏可以避免这个问题。5. 调试手段从printk到ftrace的实战选择驱动调试是嵌入式开发里最考验经验的部分。因为驱动运行在内核态不能像用户态程序那样随便加断点、单步调试。而且很多问题只在特定硬件上出现仿真环境复现不了。最常用的调试手段是printk。但printk用不好会拖慢系统甚至导致死锁。我一般用pr_debug和dev_dbg这些在定义DEBUG宏之后才会输出发布版本里可以关掉。printk的日志级别也要注意KERN_ERR、KERN_WARNING、KERN_INFO、KERN_DEBUG级别越低越紧急。默认情况下控制台只输出KERN_WARNING及以上级别的日志。printk的一个大坑是在中断上下文里大量输出。因为printk的缓冲区有限如果输出太快会覆盖之前的日志或者导致系统卡顿。我建议在中断处理函数里只输出最关键的信息而且用printk_deferred或者ratelimit来限制输出频率。除了printkftrace是更强大的调试工具。它可以跟踪函数调用、中断延迟、调度事件等。ftrace的配置在/sys/kernel/debug/tracing/目录下可以通过echo命令来启用和配置。比如echo function current_tracer可以跟踪所有函数调用echo 1 tracing_on开始跟踪cat trace查看结果。ftrace的一个实用功能是函数图跟踪可以显示函数的调用关系和执行时间。用echo function_graph current_tracer启用然后cat trace就能看到类似调用栈的输出每个函数的执行时间都标出来了。这对于分析驱动初始化流程和性能瓶颈非常有用。5.1 动态调试和dev_dbg的配合使用动态调试是内核提供的一种运行时调试机制可以在不重新编译内核的情况下动态开启和关闭调试信息。配置内核时开启CONFIG_DYNAMIC_DEBUG然后在驱动里用pr_debug或者dev_dbg输出调试信息。运行时通过/sys/kernel/debug/dynamic_debug/control文件来控制哪些文件的调试信息输出。动态调试的好处是发布版本里可以关掉所有调试信息需要排查问题时再动态打开不用重新编译。而且可以针对特定文件、特定行号、特定函数来开启非常灵活。dev_dbg是dev_printk的调试级别版本它会自动在日志前面加上设备名和驱动名方便过滤。我一般推荐用dev_dbg而不是pr_debug因为设备信息在调试的时候很有用。使用动态调试的时候要注意调试信息的格式。dev_dbg的第一个参数是设备指针后面是格式字符串和可变参数。格式字符串里可以用%d、%s、%p等但要注意%p输出的是哈希后的地址不是真实地址这是内核的安全机制。5.2 用gpio和示波器做硬件级调试软件调试手段有时候不够用特别是涉及到硬件时序的问题。这时候就需要示波器、逻辑分析仪这些硬件工具。我一般会在驱动里预留几个调试GPIO在关键代码路径上翻转GPIO然后用示波器观察时序。比如调试I2C通信失败的问题可以在I2C开始传输前拉高一个GPIO传输结束后拉低然后用示波器同时抓SCL、SDA和这个GPIO。这样就能看到驱动发出的时序和实际波形是否一致快速定位是驱动问题还是硬件问题。调试中断响应延迟的时候可以在中断处理函数入口翻转GPIO在中断处理函数出口再翻转一次用示波器测量高电平的持续时间就是中断处理时间。如果时间太长就需要优化上半部把耗时操作移到下半部。还有一个技巧是用GPIO模拟SPI或者I2C在驱动里用GPIO手动翻转来产生时序然后对比硬件控制器的时序。如果GPIO模拟能正常工作硬件控制器不行那问题就在控制器配置上。如果GPIO模拟也不行那问题可能在设备本身或者硬件连接上。5.3 常见oops和panic的定位方法内核崩溃oops和死机panic是驱动开发里最头疼的问题。oops会打印一堆寄存器信息和调用栈看起来像天书。但掌握方法之后定位并不难。首先看PC指针它指向出错的指令地址。用addr2line或者gdb可以把地址转换成函数名和行号。比如arm-linux-gnueabihf-addr2line -e vmlinux 0xc0123456就能知道是哪个函数哪一行出的错。然后看调用栈它显示了函数调用关系。从下往上看找到第一个属于你驱动的函数问题大概率就在那里。调用栈里的地址也可以用addr2line转换。还要看寄存器信息特别是LR寄存器它保存了函数返回地址。如果PC指针指向的地址不在内核代码段可能是函数指针被破坏了这时候要检查是不是有内存越界或者释放后使用的问题。常见的oops原因包括空指针解引用、内存越界、释放后使用、栈溢出、对齐错误。空指针解引用最常见一般是probe函数里获取资源失败但没有检查返回值直接用了NULL指针。内存越界一般是数组下标越界或者memcpy长度算错了。释放后使用一般是并发控制没做好一个线程释放了内存另一个线程还在访问。6. 从驱动到子系统输入子系统、LED子系统和IIO的接入方式写字符设备驱动是基础但实际项目里很少直接写裸字符设备更多是接入内核现有的子系统。输入子系统用于按键、触摸屏、鼠标、键盘等输入设备。LED子系统用于控制LED。IIO子系统用于ADC、陀螺仪、加速度计等模拟器件。接入子系统的好处是上层应用可以用统一的接口来操作设备。比如输入子系统所有输入设备都通过/dev/input/eventX来上报事件应用层不需要关心底层是按键还是触摸屏。LED子系统通过/sys/class/leds/来控制LED支持亮度调节和触发器。IIO子系统通过/sys/bus/iio/devices/来读取传感器数据支持缓冲区、触发器和事件。接入子系统的代价是驱动代码更复杂需要理解子系统的框架和API。但长远来看这是值得的因为子系统帮你处理了很多通用逻辑比如设备节点创建、并发控制、电源管理。6.1 输入子系统按键驱动的标准写法按键驱动是输入子系统里最简单的例子。核心是申请一个input_dev设置支持的事件类型和键值然后注册。在中断处理函数或者定时器里用input_report_key上报按键事件用input_sync同步。按键驱动的一个关键问题是去抖。机械按键在按下和释放时会有抖动产生多次中断。去抖可以用硬件RC电路也可以用软件定时器。软件去抖的做法是中断触发后启动一个定时器定时器到期后再读取GPIO状态如果状态稳定就上报事件。另一个问题是非阻塞扫描。如果按键数量多用中断方式会占用大量中断资源。这时候可以用轮询方式用一个定时器定期扫描所有按键的GPIO状态。轮询的周期要根据按键的响应要求来定一般10ms到20ms就够了。输入子系统还支持重复按键也就是按住不放时自动重复上报。这个功能通过input_enable_softrepeat或者input_set_autorepeat来配置。对于键盘类设备重复按键是必须的对于普通按键一般不需要。6.2 LED子系统trigger机制和亮度控制LED子系统比输入子系统简单但trigger机制很实用。trigger是LED的触发源比如heartbeat触发器让LED像心跳一样闪烁mmc0触发器在SD卡有读写时闪烁timer触发器让LED按固定频率闪烁。注册LED设备用devm_led_classdev_register需要提供一个led_classdev结构体里面包含亮度设置函数brightness_set和可选的闪烁函数blink_set。亮度值一般是0到2550表示灭255表示最亮。trigger的注册用led_trigger_register需要提供一个led_trigger结构体里面包含activate和deactivate函数。activate函数在trigger被激活时调用deactivate在trigger被禁用时调用。在activate里可以启动定时器或者注册其他子系统的回调。LED子系统的一个坑是亮度设置函数的上下文。brightness_set可能在原子上下文里被调用所以不能睡眠。如果LED是通过I2C或者SPI控制的brightness_set里不能直接调用I2C读写要用工作队列或者线程化中断来异步处理。6.3 IIO子系统传感器驱动的数据上报流程IIO子系统用于各种传感器特别是那些输出模拟信号或者数字信号的传感器。IIO的核心概念是通道channel每个通道代表一个测量维度。比如三轴加速度计有三个通道X轴、Y轴、Z轴。IIO驱动需要注册iio_dev配置通道信息实现read_raw回调。read_raw在用户态读取传感器数据时被调用驱动在这里读取硬件寄存器转换成物理量返回给用户态。IIO还支持缓冲区和触发器。缓冲区用于连续采样数据先存到内核缓冲区用户态通过/dev/iio:deviceX读取。触发器用于触发采样可以是软件触发器也可以是硬件中断。配置缓冲区和触发器之后传感器可以以固定频率采样适合振动分析、姿态解算等应用。IIO驱动的一个常见问题是数据格式。IIO支持多种数据格式IIO_VAL_INT表示整数IIO_VAL_INT_PLUS_MICRO表示整数加微数IIO_VAL_INT_PLUS_NANO表示整数加纳数。选择哪种格式取决于传感器的精度。比如温度传感器精度是0.001度用IIO_VAL_INT_PLUS_MICRO就够了如果是高精度ADC可能需要IIO_VAL_INT_PLUS_NANO。7. 驱动开发的工程化实践代码分层、版本管理和测试驱动开发不是写完能跑就行还要考虑可维护性、可移植性和可测试性。我见过很多驱动代码功能是实现了但代码结构混乱换个平台就编译不过出了问题也不知道从哪里查。代码分层是提高可维护性的关键。我一般把驱动分成三层硬件抽象层、核心逻辑层、接口层。硬件抽象层封装寄存器读写、GPIO操作、时钟控制核心逻辑层实现设备的功能接口层对接内核子系统。这样分层之后换硬件只需要改硬件抽象层核心逻辑和接口层不用动。版本管理也很重要。驱动代码要纳入版本控制每次修改都要有记录。我建议用git管理驱动代码每个功能修改一个提交提交信息写清楚改了什么、为什么改。这样出了问题可以快速回滚也方便代码审查。测试是驱动开发里最容易被忽略的环节。驱动测试比应用测试难因为很多问题只在特定硬件上出现。我一般会写一个简单的测试程序覆盖驱动的所有接口打开、关闭、读、写、ioctl。然后在不同的硬件版本上跑一遍确保兼容性。对于中断驱动的设备还要测试中断触发、数据上报、错误处理等场景。7.1 驱动代码的静态检查和风格规范内核代码有严格的风格规范checkpatch.pl脚本可以检查代码是否符合规范。我建议每次提交代码前都跑一遍checkpatch.pl把明显的风格问题修掉。常见的风格问题包括行太长超过80列、缩进用空格而不是Tab、大括号位置不对、注释格式不对。除了风格检查静态分析工具也能发现潜在问题。sparse可以检查地址空间注解比如__user、__iomem、__force。smatch可以检查逻辑错误比如空指针解引用、资源泄漏。cppcheck可以检查一般的C语言问题。我一般会在Makefile里加上C1来启用sparse检查用make C1 Mdrivers/xxx编译驱动。如果sparse报错说明地址空间注解有问题需要修正。7.2 驱动模块的编译、加载和参数传递驱动可以编译进内核也可以编译成模块。编译成模块的好处是可以在不重启系统的情况下加载和卸载方便调试。insmod加载模块rmmod卸载模块lsmod查看已加载的模块。模块加载的时候可以传递参数用module_param宏定义。参数类型可以是int、charp、bool等。比如module_param(debug, int, 0644)定义了一个名为debug的整型参数权限是0644。加载的时候用insmod xxx.ko debug1来传递参数。模块参数的一个坑是权限设置。如果权限设置成0参数在/sys/module/xxx/parameters/下不可见也不能在运行时修改。如果设置成0644可以在运行时通过echo命令修改。但要注意运行时修改参数可能触发驱动的重新配置如果驱动没有正确处理可能导致崩溃。模块依赖关系也要注意。如果模块A依赖模块B加载A之前要先加载B。modprobe会自动处理依赖关系insmod不会。所以生产环境一般用modprobe调试的时候用insmod。7.3 从单板到产品驱动兼容性测试清单驱动开发完成后要经过严格的兼容性测试才能上产品。我整理了一个测试清单覆盖了常见的兼容性问题测试项测试方法常见问题不同硬件版本在多块板子上跑相同测试引脚定义不同导致初始化失败不同内核版本在多个内核版本上编译和运行API变更导致编译错误或运行时崩溃不同编译器用gcc不同版本编译优化行为不同导致时序问题高低温测试在高温和低温环境下运行时钟漂移导致通信失败电源波动测试调节电源电压复位不彻底导致初始化失败并发测试多进程同时操作设备竞态条件导致数据错乱长时间运行连续运行72小时以上内存泄漏导致系统变慢这个清单不是绝对的具体项目还要根据实际情况调整。但核心思想是驱动不仅要能跑还要在各种边界条件下都能稳定运行。8. 驱动工程师的成长路径从看懂到改到写驱动开发的学习曲线比较陡因为涉及的知识面很广硬件、操作系统、C语言、内核框架。我见过很多人卡在“看懂驱动代码”这一步能看懂别人写的驱动但自己写就不知道从哪里下手。我的建议是分三步走。第一步是看懂找一份简单的驱动代码比如LED驱动或者按键驱动逐行理解每一行代码的作用。遇到不懂的函数就去查内核源码或者文档搞清楚它的参数和返回值。这一步的目标是能给别人讲清楚这个驱动是怎么工作的。第二步是修改在现有驱动的基础上做功能扩展。比如给LED驱动增加亮度调节功能给按键驱动增加长按和双击检测。修改的过程中会遇到各种问题通过解决问题来积累经验。这一步的目标是能独立完成一个小功能的开发。第三步是重写从零开始写一个完整的驱动。选一个简单的设备比如GPIO扩展芯片或者I2C温度传感器按照字符设备驱动的骨架一步步实现。写完之后和内核里现有的驱动对比看看别人的实现有什么优点自己的实现有什么不足。这一步的目标是能独立完成一个驱动的设计和实现。这个过程可能需要半年到一年取决于你的基础和学习强度。但只要你坚持下来驱动开发的能力会有质的飞跃。而且驱动开发的经验是可以迁移的你理解了字符设备驱动再学输入子系统、IIO子系统就会快很多因为它们的设计思想是相通的。8.1 阅读内核源码的正确姿势阅读内核源码是驱动工程师的必修课但内核源码有上千万行不可能全部看完。我的经验是按需阅读遇到什么问题就去看相关的源码。比如你不理解platform_driver_register做了什么就去drivers/base/platform.c里看它的实现。你不理解设备树怎么解析的就去drivers/of/目录下看。你不理解输入子系统怎么上报事件的就去drivers/input/目录下看。阅读源码的时候不要一开始就陷入细节。先看函数的注释和文档了解它的功能和参数。然后看主要的代码路径跳过错误处理和边界条件。最后再回头看细节理解为什么这么实现。还有一个技巧是用ftrace跟踪函数调用。在运行的系统上启用ftrace然后操作设备看内核里哪些函数被调用了调用顺序是什么。这样比静态阅读源码更直观也更容易理解代码的执行流程。8.2 面试中驱动相关问题的回答思路嵌入式面试里驱动相关的问题很常见但面试官不是要你背API而是考察你对驱动模型的理解和解决问题的能力。常见的问题包括“字符设备和块设备有什么区别”“platform总线和I2C总线有什么区别”“中断上半部和下半部为什么要分开”“设备树里的compatible属性有什么用”回答这些问题的时候不要只背概念要结合实际项目经验。比如问字符设备和块设备的区别你可以说“我在项目里用字符设备驱动控制LED和按键用块设备驱动管理SD卡。字符设备按字节流访问不支持随机访问块设备按块访问有缓冲区支持随机访问。字符设备的file_operations里实现read和write块设备用gendisk和request_queue。”如果问到你没做过的领域不要瞎编可以说“这个领域我没有直接经验但根据我的理解……”然后说出你的推理过程。面试官更看重你的思考方式而不是标准答案。8.3 持续学习跟踪内核社区和硬件动态驱动开发的技术更新很快新的SoC、新的内核版本、新的子系统不断出现。要保持竞争力必须持续学习。我一般会关注几个信息源内核邮件列表LKML的摘要看看最近有什么重要的补丁和讨论内核文档的更新特别是Documentation/driver-api/目录下的内容芯片厂商的BSP更新了解新芯片的驱动支持情况。动手实践也很重要。我会定期找一块新的开发板把它的基本驱动跑通然后尝试修改和优化。这个过程不仅能学到新东西还能保持对驱动开发的敏感度。最后我建议驱动工程师也要懂一点应用开发。因为驱动最终是给应用用的如果你不理解应用的需求驱动接口设计可能就不合理。比如应用需要低延迟驱动里就不能有太多缓冲应用需要高吞吐驱动里就要考虑DMA和零拷贝。懂应用之后你设计的驱动接口会更实用也更容易被团队接受。