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"; sht30@44 { 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和B,file_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通常设为4(KERN_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、内存泄漏。这类问题单靠看代码很难发现在,必须上工具。
- KASAN(Kernel AddressSanitizer)能检测越界访问和use-after-free。在内核配置里打开CONFIG_KASAN,然后复现问题,KASAN会直接打印出具体的出错地址、访问长度、分配和释放的调用栈。
- kmemleak专门检测内核内存泄漏。开启后周期性地扫描内存对象,把疑似泄漏的分配点和对象地址打出来。调试I2C、SPI这类有大量缓冲区分配和释放的驱动时非常有用。
- UBSAN(Undefined 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的phandle,probe阶段请求GPIO失败,直接return -EPROBE_DEFER。
eprobe_defer是另一个值得单独说的概念:驱动probe时需要的资源暂时不可用,返回-EPROBE_DEFER告诉内核“我还没准备好,等依赖到位后再试一次”。内核会在依赖资源注册后重新触发probe。但内核重新触发probe的次数是有限制的,超过限制后会被放弃。裁剪时如果砍掉了某个延时加载的子系统,就可能导致外围驱动反复probe失败,最终放弃加载。
7.2 驱动与系统启动顺序:module_init的优先级
驱动是编译进内核(built-in)还是编成模块(module),probe时机完全不同。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和耐心去解决。