嵌入式Linux驱动开发实战:从字符设备到子系统接入

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)和GPIO(devm_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_table>platform_device_id>name。也就是说,如果设备树里有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来限制输出频率。

除了printk,ftrace是更强大的调试工具。它可以跟踪函数调用、中断延迟、调度事件等。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到255,0表示灭,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里加上C=1来启用sparse检查,用make C=1 M=drivers/xxx编译驱动。如果sparse报错,说明地址空间注解有问题,需要修正。

7.2 驱动模块的编译、加载和参数传递

驱动可以编译进内核,也可以编译成模块。编译成模块的好处是,可以在不重启系统的情况下加载和卸载,方便调试。insmod加载模块,rmmod卸载模块,lsmod查看已加载的模块。

模块加载的时候可以传递参数,用module_param宏定义。参数类型可以是int、charp、bool等。比如module_param(debug, int, 0644)定义了一个名为debug的整型参数,权限是0644。加载的时候用insmod xxx.ko debug=1来传递参数。

模块参数的一个坑是权限设置。如果权限设置成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和零拷贝。懂应用之后,你设计的驱动接口会更实用,也更容易被团队接受。