嵌入式驱动开发这个方向,外面看着门槛高,进去之后发现真正难的不是写代码,而是搞清楚"为什么这么写"。我做了十多年BSP和驱动,从裸机寄存器一路写到Linux内核子系统,踩过的坑比写过的驱动还多。这篇文章不打算给你罗列API手册,而是把我在实际项目中反复验证过的思路、方法和教训摊开来聊。不管你是刚从单片机转过来的新手,还是已经能改内核源码但总觉得缺了点什么的老手,下面这些内容应该都能对上你的某些困惑。关键词里的嵌入式Linux、驱动开发、根文件系统挂载、非阻塞按键扫描、内核源码这些点,我都会结合真实场景展开,尽量把"为什么"讲透。
1. 驱动开发的本质:在硬件与内核之间做翻译
1.1 驱动到底在解决什么问题
很多人对驱动的理解停留在"让硬件动起来",这个说法不算错,但太浅了。驱动的核心任务是把千差万别的硬件行为,抽象成内核能理解的统一接口。你写一个GPIO驱动,本质上是在告诉内核:"这个引脚可以输出高低电平,可以读取当前状态",至于这个引脚背后接的是LED还是继电器,内核不关心,也不应该关心。
这就引出一个关键认知:驱动开发者是翻译官,不是指挥官。你的职责是把硬件手册里的时序图、寄存器定义,翻译成内核框架能接受的file_operations、platform_driver、i2c_driver这些结构体。翻译得好不好,直接决定了上层应用写起来是舒服还是难受。
我见过不少新手驱动,功能能跑,但上层调用的时候要传一堆莫名其妙的参数,或者ioctl命令定义得毫无规律。这就是翻译没做好——你把自己的实现细节暴露给了应用层,而不是提供一个干净的抽象。
1.2 从裸机思维到内核思维的转变
从单片机转过来的兄弟最容易犯的毛病,是把驱动当成裸机程序写。裸机里你直接操作寄存器,想怎么来怎么来;内核里你要是也这么干,轻则驱动加载失败,重则整个系统panic。
举个最常见的例子:裸机里延时用for循环空转,内核里你必须用msleep()、udelay()这些接口。为什么?因为内核是抢占式的,你空转的时候CPU本来可以去跑别的任务,你占着不放就是浪费。更严重的是,如果你在持有自旋锁的时候睡眠,系统直接死锁。
再比如内存分配,裸机里你可能直接定义一个全局数组当缓冲区,内核里必须用kmalloc()或kzalloc()动态分配。原因在于内核空间的内存管理是分页的,全局大数组会占用宝贵的静态内存区域,而且不利于模块化加载卸载。
提示:判断自己有没有完成思维转变,就看你会不会下意识地问"我这段代码在中断上下文里能不能跑"、"我持有锁的时候会不会睡眠"。如果这两个问题你写代码时根本不想,那说明还在裸机思维里。
1.3 驱动工程师的知识地图
驱动开发不是孤立技能,它站在好几个知识领域的交叉点上。我把它拆成四层:
| 层级 | 核心内容 | 掌握程度要求 |
|---|---|---|
| 硬件层 | 寄存器操作、时序图阅读、总线协议 | 能看懂手册,会用示波器/逻辑分析仪验证 |
| 内核层 | 内核API、并发控制、内存管理、中断处理 | 熟悉常用子系统框架,能读懂内核源码 |
| 框架层 | platform、i2c、spi、usb、字符设备等 | 至少精通两到三种,其余能快速上手 |
| 调试层 | printk、ftrace、kgdb、逻辑分析仪 | 能独立定位驱动加载失败、数据传输异常 |
这四层里,硬件层是地基,但很多人栽在调试层。驱动出问题不像应用层有明确的报错堆栈,很多时候就是"没反应"。这时候逻辑分析仪抓波形、ftrace看函数调用链,比盯着代码看半天管用得多。
2. 字符设备驱动:从注册到读写的一条完整链路
2.1 设备号分配:为什么不能随便写一个
字符设备驱动第一步是申请设备号。内核里设备号分主设备号和次设备号,主设备号标识驱动,次设备号标识具体设备实例。新手常犯的错误是直接写死一个主设备号,比如register_chrdev(240, "mydev", &fops)。
这么写能跑,但隐患很大。240这个号可能已经被别的驱动占了,你加载的时候要么失败,要么把别人的设备顶掉。正确做法是用alloc_chrdev_region()让内核动态分配:
dev_t devno; int ret = alloc_chrdev_region(&devno, 0, 1, "mydev"); if (ret < 0) { printk(KERN_ERR "alloc chrdev region failed\n"); return ret; } printk(KERN_INFO "major=%d, minor=%d\n", MAJOR(devno), MINOR(devno));动态分配的好处是内核帮你维护设备号池,不会冲突。代价是每次加载主设备号可能不同,所以应用层不能写死设备号,要用/proc/devices查或者用udev自动创建设备节点。
2.2 cdev与file_operations的绑定逻辑
拿到设备号之后,要把它和cdev结构体绑定,再把file_operations挂上去。这个流程看起来繁琐,但每一步都有存在的理由。
cdev是内核用来管理字符设备的抽象,它内部维护了一个设备号到file_operations的映射。为什么要多这一层?因为一个驱动可能管理多个设备实例,每个实例的次设备号不同,但共享同一套操作函数。cdev就是用来做这个多对一映射的。
struct cdev *my_cdev = cdev_alloc(); cdev_init(my_cdev, &my_fops); my_cdev->owner = THIS_MODULE; cdev_add(my_cdev, devno, 1);注意owner字段必须设为THIS_MODULE,否则模块卸载时内核不知道这个cdev还引用着模块,可能导致卸载后代码段被释放,再访问就崩了。
2.3 open/release里的资源管理陷阱
open和release这对函数看起来简单,但资源管理最容易出问题。核心原则是:open里申请的资源,release里必须释放;open里计数的引用,release里必须减回去。
我见过一个真实案例:驱动在open里用kmalloc分配了一个缓冲区,存在file->private_data里,但release里忘了释放。结果应用层反复打开关闭设备,内存泄漏累积,跑几个小时系统就OOM了。
更隐蔽的是并发场景。如果两个进程同时open同一个设备,你的open函数可能被并发调用。这时候如果里面有全局变量的初始化操作,必须加锁保护。我一般建议在open里只做轻量级的检查,重初始化放到probe或者模块加载时做。
2.4 read/write的阻塞与非阻塞语义
read和write是字符设备的核心。这里有个关键区分:阻塞IO和非阻塞IO的行为必须区分对待。
如果用户以非阻塞方式打开设备(O_NONBLOCK),你的read在没有数据时必须立即返回-EAGAIN,而不是傻等。如果用户以阻塞方式打开,你可以让进程睡眠,等数据来了再唤醒。
实现阻塞读的经典模式是等待队列:
static DECLARE_WAIT_QUEUE_HEAD(my_wq); static int data_ready = 0; ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *off) { if (wait_event_interruptible(my_wq, data_ready)) return -ERESTARTSYS; /* 拷贝数据到用户空间 */ if (copy_to_user(buf, kbuf, count)) return -EFAULT; data_ready = 0; return count; }wait_event_interruptible的好处是它能被信号打断,用户按Ctrl+C能退出。如果你用wait_event,进程就变成不可中断睡眠,用户杀都杀不掉,体验极差。
注意:
copy_to_user和copy_from_user的返回值是未拷贝的字节数,不是错误码。返回0表示全部拷贝成功,返回非0表示有部分没拷过去。很多新手直接判断if (copy_to_user(...)),逻辑上没错,但要知道这个返回值的含义。
3. 平台设备驱动:Linux设备模型的正确打开方式
3.1 为什么要有platform总线
Linux设备模型的核心思想是设备与驱动分离。设备描述"我有什么资源",驱动描述"我怎么用这些资源",两者通过总线匹配。platform总线是虚拟总线,专门用于那些不挂在物理总线(如I2C、SPI)上的片上外设。
为什么要这么设计?因为嵌入式SoC里大量外设是直接映射到内存地址空间的,没有枚举机制,内核启动时不知道有哪些设备。通过设备树(Device Tree)或者板级文件描述设备,驱动只管匹配和操作,这样同一份驱动可以适配不同板子,只要设备树描述不同即可。
3.2 设备树节点的编写要点
设备树是嵌入式Linux驱动的必修课。一个典型的platform设备节点长这样:
my_device: my_device@40000000 { compatible = "vendor,my-device"; reg = <0x40000000 0x1000>; interrupts = <0 42 4>; clocks = <&clk_gate 5>; status = "okay"; };compatible是匹配的关键,驱动里的of_match_table要和它一致。reg描述寄存器地址范围,interrupts描述中断号。这些属性驱动里用platform_get_resource()和platform_get_irq()来获取。
新手常犯的错误是compatible字符串写错一个字符,导致驱动死活匹配不上。调试的时候可以在/sys/bus/platform/devices/下看设备有没有注册成功,在/sys/bus/platform/drivers/下看驱动有没有绑定。
3.3 probe函数的执行时机与资源获取
probe函数是驱动的入口,它在设备和驱动匹配成功后调用。这里要做的事情包括:获取资源、初始化硬件、注册字符设备或其它子系统接口。
关键点是probe可能被多次调用,如果系统里有多个同类型设备。所以probe里不能有只执行一次的全局初始化,那些应该放在模块的init函数里。probe里只做和设备实例相关的初始化。
资源获取推荐用devm_系列函数,比如devm_kzalloc、devm_request_irq。这些函数分配的资源会在设备卸载时自动释放,省去了在remove里手动清理的麻烦,也避免了遗漏导致的资源泄漏。
static int my_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; int irq; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); base = devm_ioremap_resource(&pdev->dev, res); if (IS_ERR(base)) return PTR_ERR(base); irq = platform_get_irq(pdev, 0); if (irq < 0) return irq; return devm_request_irq(&pdev->dev, irq, my_handler, 0, "my-device", NULL); }3.4 remove函数里容易遗漏的清理动作
虽然devm_系列能自动清理大部分资源,但有些东西还是得手动处理。比如你注册了字符设备,cdev_del和unregister_chrdev_region还是要在remove里调用。再比如你创建了proc节点或者sysfs属性,也要手动删除。
还有一个容易忽略的点:remove执行时,可能还有用户进程持有设备文件描述符。这时候你不能直接释放所有资源,否则用户下次read就访问到野指针了。标准做法是在open里增加引用计数,remove里等待计数归零,或者用cdev_del让新打开失败,已打开的等release自然清理。
4. 中断处理与并发控制:驱动稳定性的分水岭
4.1 中断上下文的限制与应对
中断处理函数运行在中断上下文,这个上下文有几个硬性限制:不能睡眠、不能访问用户空间、不能调用可能睡眠的函数。kmalloc带GFP_KERNEL标志会睡眠,中断里只能用GFP_ATOMIC。mutex会睡眠,中断里只能用spinlock。
这些限制的根本原因是中断上下文没有进程上下文,没有task_struct,调度器没法把它换出去。你一旦睡眠,整个CPU可能就卡在那里了。
所以中断处理的原则是快进快出。真正耗时的数据处理放到下半部(softirq、tasklet、workqueue)去做。我一般建议中断处理函数里只做三件事:清中断标志、记录状态、唤醒下半部。
4.2 自旋锁与互斥锁的选择依据
并发控制是驱动开发里最容易出错的地方。自旋锁和互斥锁的选择,核心看临界区会不会睡眠。
| 对比项 | 自旋锁 spinlock | 互斥锁 mutex |
|---|---|---|
| 适用上下文 | 进程上下文、中断上下文 | 仅进程上下文 |
| 等待行为 | 忙等待,不睡眠 | 睡眠等待 |
| 临界区要求 | 极短,不能睡眠 | 可以较长,可以睡眠 |
| 中断里使用 | 可以(需关中断) | 禁止 |
选择逻辑很简单:中断里用自旋锁,进程上下文里如果临界区可能睡眠就用互斥锁,否则自旋锁也行但互斥锁更省CPU。
有个坑要注意:自旋锁在单核和多核上的行为不同。单核上自旋锁退化为关抢占,多核上才是真正的忙等待。所以单核上测试没问题的代码,多核上可能出并发问题。测试驱动并发性一定要在多核平台上跑。
4.3 原子操作与内存屏障的使用场景
有些场景不需要锁,用原子操作就够了。比如一个简单的计数器,用atomic_inc比加锁高效得多。原子操作的原理是CPU提供了专门的原子指令,比如ARM上的LDREX/STREX,保证读-改-写不可分割。
内存屏障(mb()、rmb()、wmb())是更底层的东西,用于保证内存访问顺序。在驱动里最常见的是DMA场景:你写完缓冲区数据后,要确保数据真正写到内存了,再启动DMA。这时候需要wmb()。
不过说实话,大部分驱动开发者不需要直接操作内存屏障,内核的readl/writel、dma_map_single这些接口内部已经处理好了。你只需要知道有这么个东西,遇到诡异的数据不一致问题时能想到这个方向就行。
4.4 工作队列与tasklet的取舍
下半部机制有好几种,最常用的是工作队列和tasklet。选择依据是能不能睡眠。
tasklet运行在软中断上下文,不能睡眠,但执行速度快,适合处理网络收包这类高频短任务。工作队列运行在进程上下文,可以睡眠,适合需要调用可能睡眠函数(如kmalloc(GFP_KERNEL)、mutex_lock)的场景。
我个人的经验是:能用工作队列就用工作队列。因为工作队列的限制少,调试方便,而且现代内核里工作队列的性能已经足够好。tasklet只在极高频、极短小的场景下才有优势。
static DECLARE_WORK(my_work, my_work_handler); static irqreturn_t my_isr(int irq, void *dev_id) { /* 清中断、记录状态 */ schedule_work(&my_work); return IRQ_HANDLED; } static void my_work_handler(struct work_struct *work) { /* 耗时的数据处理,可以睡眠 */ }5. 根文件系统挂载与NFS调试环境搭建
5.1 为什么驱动开发离不开NFS根文件系统
驱动开发有个特点:改一行代码就要重新加载模块,甚至重启内核。如果每次都用烧录的方式更新根文件系统,一天下来光烧录时间就够你受的。NFS挂载根文件系统就是解决这个痛点的。
原理很简单:开发板通过网络从宿主机挂载根文件系统,宿主机上的文件改了,开发板重启就能看到新版本。这样你编译好的驱动模块直接放到NFS目录里,开发板insmod就能加载,效率提升不是一点半点。
5.2 内核配置与启动参数设置
要支持NFS根文件系统,内核配置里必须打开:
CONFIG_NFS_FS=yCONFIG_NFS_V3=y(或V4,看服务端配置)CONFIG_ROOT_NFS=yCONFIG_IP_PNP=y(如果需要内核自动获取IP)
启动参数里要指定:
root=/dev/nfs rw nfsroot=192.168.1.100:/nfs/rootfs ip=192.168.1.200这里nfsroot是宿主机NFS共享目录,ip是开发板自己的IP。注意rw表示读写挂载,调试阶段必须读写,否则你没法在开发板上创建文件。
5.3 常见挂载失败的原因排查
NFS挂载失败是新手最常见的拦路虎。我总结了一个排查顺序:
- 网络通不通:开发板上
ping宿主机,宿主机ping开发板,先确认链路层没问题。 - NFS服务起没起:宿主机上
showmount -e看共享目录有没有导出。 - 版本匹配不匹配:内核配的NFS版本和服务端支持的版本要一致。V3和V4的配置方式不同,V4可能需要
nfsvers=4参数。 - 权限问题:NFS导出配置里
no_root_squash要加上,否则开发板的root用户会被映射成nobody,写文件权限不够。 - 文件系统内容不完整:根文件系统里必须有
/init或/sbin/init,否则内核挂载成功也起不来。
提示:如果卡在"VFS: Cannot open root device"这行,八成是NFS没挂上。可以在启动参数里加
initcall_debug和debug,看内核打印的详细错误。
5.4 调试阶段的效率提升技巧
NFS环境搭好之后,还有几个技巧能进一步提升效率:
- 用
scp或tftp传单个文件:有时候只想更新一个.ko文件,不需要重启,直接传到开发板/tmp下insmod就行。 - 内核模块用
modprobe而不是insmod:modprobe会自动处理依赖关系,前提是你把模块装到了/lib/modules/$(uname -r)/下。 - 串口终端用
minicom或picocom:比图形化工具稳定,支持日志抓取。 dmesg -w实时看内核日志:驱动加载失败时,第一时间看dmesg输出,比猜快得多。
6. 按键非阻塞扫描:一个看似简单却暗藏玄机的驱动
6.1 阻塞式按键驱动的局限性
按键驱动是很多人的入门项目,但大部分教程给的是阻塞式实现:read的时候如果没按键就睡眠,有按键了中断唤醒。这个模型在单一按键、单一进程的场景下没问题,但实际项目里往往不够用。
问题在于:一个进程阻塞在read上,就没法同时做别的事。比如你的应用既要响应按键,又要刷新屏幕,还要处理网络数据,阻塞式read就把整个线程卡住了。这时候就需要非阻塞扫描。
6.2 非阻塞模式下的轮询与事件通知
非阻塞按键驱动的核心是**poll接口**。应用层用poll或select同时监听多个文件描述符,按键有事件时poll返回可读,应用再去read。这样单线程就能处理多路IO。
驱动侧要实现poll函数:
static unsigned int my_poll(struct file *filp, poll_table *wait) { unsigned int mask = 0; poll_wait(filp, &my_wq, wait); if (key_pressed) mask |= POLLIN | POLLRDNORM; return mask; }poll_wait只是把等待队列注册到poll_table,并不睡眠。真正的睡眠在poll系统调用里完成。按键中断到来时,wake_up_interruptible唤醒等待队列,poll返回,应用层得到通知。
6.3 去抖动处理的软件实现
机械按键有抖动,按下和释放的瞬间电平会跳变多次。硬件上一般用RC滤波,软件上也要做去抖动。最简单的软件去抖是延时确认:中断里记录时间戳,如果距离上次中断小于20ms就忽略。
但中断里做延时不行,所以通常的做法是中断里启动一个定时器,定时器到期后再读一次电平,确认按键状态。或者用工作队列延时处理:
static void key_work_handler(struct work_struct *work) { msleep(20); /* 去抖延时 */ int val = gpio_get_value(key_gpio); if (val == key_pressed_level) { key_pressed = 1; wake_up_interruptible(&my_wq); } }这个方案的好处是去抖逻辑清晰,坏处是每次按键都要占用工作队列20ms。如果按键频繁,工作队列可能积压。更优雅的方案是用定时器,但实现复杂度更高。
6.4 多按键场景下的数据结构设计
单按键用全局变量就够了,多按键就得上数据结构。我一般用数组加位图的方式:
#define MAX_KEYS 8 static unsigned long key_bitmap; static struct key_info { int gpio; int irq; int code; } keys[MAX_KEYS];每个按键的中断处理函数里,根据irq找到对应的key_info,设置位图对应位,然后唤醒等待队列。应用层read的时候返回整个位图,或者返回按键事件结构体。
这里有个细节:位图的原子性。如果多个按键中断可能并发,设置位图要用set_bit这种原子操作,不能用key_bitmap |= (1 << n),后者在多核上可能丢失更新。
7. 驱动调试手段:从printk到ftrace的进阶之路
7.1 printk的日志级别与动态调试
printk是最基础的调试手段,但很多人用得不规范。printk有日志级别,从KERN_EMERG到KERN_DEBUG。级别决定了消息会不会打印到控制台。
printk(KERN_ERR "this is an error\n"); pr_err("this is also an error\n"); dev_err(&pdev->dev, "device error\n");推荐用pr_xxx和dev_xxx系列,它们自带格式和级别,代码更简洁。dev_xxx还会带上设备名,方便定位是哪个设备出的问题。
动态调试(dynamic_debug)是更高级的玩法。你可以在代码里写pr_debug,默认不打印,运行时通过/sys/kernel/debug/dynamic_debug/control动态打开。这样调试信息不用重新编译就能开关,非常适合生产环境排查问题。
7.2 ftrace跟踪函数调用链
ftrace是内核自带的跟踪框架,能记录函数调用、中断延迟、调度事件等。配置方式:
cd /sys/kernel/debug/tracing echo function > current_tracer echo my_driver_func > set_ftrace_filter echo 1 > tracing_on # 触发操作 echo 0 > tracing_on cat traceset_ftrace_filter可以只跟踪你关心的函数,避免日志被淹没。function_graph模式还能显示调用关系和耗时,定位性能瓶颈特别有用。
7.3 逻辑分析仪在时序问题中的价值
有些问题printk和ftrace都搞不定,比如I2C通信失败、SPI数据错位。这时候逻辑分析仪就是神器。抓一下SCL/SDA波形,看时序对不对、ACK有没有、时钟频率是不是超了。
我遇到过一个I2C读EEPROM失败的问题,代码怎么看都没毛病,逻辑分析仪一抓发现是上拉电阻太大,上升沿太缓,高速模式下时序不满足。这种问题纯靠软件调试永远找不到。
7.4 内核panic的现场分析方法
驱动导致内核panic是最头疼的,因为系统直接挂了,日志可能都没来得及写。这时候要靠kdump或者pstore保存现场。
pstore是内核提供的持久化存储机制,panic时把日志写到预留的内存区域,重启后从/sys/fs/pstore/读取。配置需要在内核命令行加ramoops相关参数,并在设备树里预留内存。
拿到dmesg日志后,重点看PC is at和Call trace。PC指向出错的指令地址,用addr2line或者gdb反查源码行。Call trace显示调用链,能看出是哪个函数调下来的。
8. 嵌入式驱动工程师的成长路径与常见误区
8.1 从改驱动到写驱动的跨越
很多人的驱动开发是从改现成驱动开始的:拿一个厂商的BSP,改改GPIO号、改改时钟频率,能跑就行。这个阶段没问题,但不能停在这里。
从改驱动到写驱动的跨越,标志是你能从零实现一个子系统驱动。比如给你一个I2C传感器手册,你能独立写出符合I2C子系统框架的驱动,包括设备树节点、probe、读写接口、sysfs属性。这需要你理解子系统框架的设计意图,而不只是照猫画虎。
8.2 阅读内核源码的正确姿势
读内核源码是进阶的必经之路,但方法很重要。我的建议是带着问题读,不要从头读到尾。
比如你想知道platform_driver_register到底做了什么,就从这函数入口,跟到driver_register,再到bus_add_driver,看它怎么把驱动挂到总线上,怎么触发匹配。跟完一条线,你就理解了设备模型的匹配机制。
工具上推荐用cscope或ctags建索引,配合vim或vscode跳转。elixir.bootlin.com是在线看源码的好地方,交叉引用做得很方便。
8.3 面试八股与实际能力的差距
嵌入式面试常问八股:中断上半部和下半部的区别、自旋锁和互斥锁的区别、kmalloc和vmalloc的区别。这些要会,但光会这些不够。
面试官真正想考察的是你有没有实际调试经验。比如问你"驱动加载失败怎么排查",背八股的人会说"看dmesg",有经验的人会说"先看insmod返回值,-ENODEV说明设备没匹配上,去/sys/bus/platform/devices看设备注册没有;-EBUSY说明资源被占用,查/proc/interrupts和/proc/ioports"。
差距就在这些细节里。所以我的建议是:八股要背,但更要动手做项目,把每个错误码对应的排查路径都走一遍。
8.4 持续学习的方向选择
嵌入式驱动开发往上走有几个方向:
- 深度方向:深入某个子系统,比如网络驱动、显示驱动、存储驱动,成为这个领域的专家。
- 广度方向:往系统架构走,理解整个SoC的启动流程、电源管理、性能优化。
- 交叉方向:结合AI、安全、实时性等热点,比如NPU驱动、安全启动、RT补丁。
选哪个方向看个人兴趣和项目机会。我的经验是先深后广:先把一个子系统吃透,建立信心和方法论,再横向扩展会快很多。反过来先广后深,容易什么都懂一点但什么都不精。
8.5 工装与量产测试中的驱动考量
产品走到量产阶段,驱动要考虑的东西又不一样了。比如工装测试需要驱动提供自检接口,能读取硬件版本、检测外设是否正常。这些接口通常通过sysfs或proc暴露。
还有一点是驱动的可配置性。同一份驱动要适配不同配置的板子,硬件差异通过设备树描述,驱动里用of_property_read_u32读取配置参数。这样一份驱动代码能覆盖多个SKU,维护成本大大降低。
我在实际项目中吃过亏:早期驱动里把GPIO号写死,后来硬件改版换了引脚,驱动得改代码重新编译。后来改成从设备树读,硬件改版只改设备树,驱动不用动,省了大量返工时间。
9. 写在最后:一些零散但值钱的经验
驱动开发这个行当,很多经验是文档里不会写的。比如insmod之后先dmesg | tail看一眼,确认probe有没有执行;比如改设备树之后记得重新编译dtb并更新到启动分区;比如调试I2C的时候先把时钟频率降到100kHz,通了再往上提。
还有一条最重要的:永远不要相信"应该没问题"。硬件不会骗你,逻辑分析仪不会骗你,printk不会骗你,但你的直觉会。每次我觉得"这段代码肯定没问题"的时候,往往就是问题所在。养成用工具验证的习惯,比什么都强。
嵌入式Linux驱动开发的学习曲线确实陡,但一旦跨过那个坎,你会发现底层的东西是相通的。I2C懂了,SPI上手就快;字符设备写熟了,杂项设备、平台设备都是类似的套路。关键是保持动手,保持好奇,遇到问题刨根问底,别满足于"能跑就行"。