很多人第一次写 Linux 内核驱动,最先关心的往往不是结构,而是“怎么把设备先跑起来”。于是大家一上来就开始写 probe()、映射寄存器、注册中断、写收发函数,板子一旦有反应,心里就觉得这事差不多成了。可真正一进项目,你很快就会发现,能跑起来的驱动和能长期维护、能稳定量产、能扛住异常路径的驱动,根本不是一回事。
最常见的问题往往都不是“API 不会用”,而是驱动结构一开始就搭歪了。有人在 probe() 里堆了上百行初始化代码,出错路径靠 goto 勉强回收;有人中断函数里什么都做,结果一上压力 CPU 就被中断拖死;还有人觉得 remove() 反正量产设备不常卸载,随手写写就行,最后一遇到 overlay 重载、模块卸载或者 suspend/resume,问题全暴露出来。
Linux 内核自己的 platform driver 文档其实把大框架说得很清楚:平台设备和平台驱动的职责是分开的,驱动只负责 probe()、remove() 等回调,资源、匹配关系和设备存在性验证要在正确的阶段处理。很多工程问题,说到底并不是“不会写代码”,而是没有按这个模型去组织代码结构。[来源:Linux Kernel Platform Devices and Drivers 文档搜索结果]
这篇文章不打算给你堆一堆零碎 API,而是从真正做 SoC 外设驱动、板级控制器驱动、GPIO/I2C/SPI 附属驱动时最容易踩的坑出发,总结 6 个最有价值的 C 语言实现技巧。你会看到,写驱动的关键,从来不只是“把寄存器读写通”,而是把资源生命周期、快慢路径分离、错误回滚、匹配关系和可维护性一起设计好。
技巧一:先把驱动写成“生命周期清晰”的结构,再谈功能堆叠
内核里的 platform driver 模型之所以重要,不是因为它“形式规范”,而是因为它天然帮你划清了驱动的生命周期边界。设备匹配到了,进入 probe();设备解绑、模块卸载或某些热移除场景下,进入 remove();关机和电源管理再走各自回调。这套结构如果一开始就没站稳,后面功能越加越乱。
很多工程师写驱动时喜欢从“先把业务逻辑塞进去”开始,这样短期看进度快,长期几乎一定会出问题。更稳的方式应该是先把骨架写清楚:
下面是一个比较接近量产写法的 platform_driver 骨架:

staticconststruct of_device_id fastdrv_of_match[] = { { .compatible = "xingu,fast-device" }, { }};MODULE_DEVICE_TABLE(of, fastdrv_of_match);staticint fastdrv_probe(struct platform_device *pdev);staticint fastdrv_remove(struct platform_device *pdev);staticstruct platform_driver fastdrv_driver = { .probe = fastdrv_probe, .remove = fastdrv_remove, .driver = { .name = "fastdrv", .of_match_table = fastdrv_of_match, },};module_platform_driver(fastdrv_driver);
这段代码看起来没什么“高级技巧”,但它的价值恰恰在于把匹配关系和生命周期先稳稳搭起来。官方 platform driver 文档明确提到,平台驱动的发现和枚举由系统侧处理,驱动侧的主要职责就是在 probe() 里验证硬件、获取资源并完成初始化。[来源:Linux Kernel Platform Devices and Drivers 文档搜索结果]
技巧二:优先用 devm_* 管资源,别把 probe() 写成回收地狱
如果说 Linux 驱动开发里有什么东西最能显著提升代码质量,devm_* 绝对算一个。很多旧代码或者教程里,会手写一长串资源申请和错误回滚逻辑:申请内存、映射寄存器、开时钟、申请中断、注册子设备,一步失败就跳回去做一层层释放。这种写法不是不能用,但一旦驱动复杂起来,错误路径通常比正常路径更容易出 bug。
devm_* 的价值就在于,它把大部分和设备生命周期绑定的资源自动托管了。你在 probe() 里申请的托管资源,设备解绑或初始化失败时会自动回收。这样你就能把注意力放在“初始化顺序是否合理”,而不是“有没有漏一层 free”。
一个更高效的典型写法是:

struct fastdrv_data {void __iomem *base;int irq;struct clk *clk; spinlock_t lock;};staticint fastdrv_probe(struct platform_device *pdev){struct fastdrv_data *d;struct resource *res;int ret; d = devm_kzalloc(&pdev->dev, sizeof(*d), GFP_KERNEL);if (!d)return -ENOMEM; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); d->base = devm_ioremap_resource(&pdev->dev, res);if (IS_ERR(d->base))return PTR_ERR(d->base); d->irq = platform_get_irq(pdev, 0);if (d->irq < 0)return d->irq; d->clk = devm_clk_get(&pdev->dev, NULL);if (IS_ERR(d->clk))return PTR_ERR(d->clk); ret = clk_prepare_enable(d->clk);if (ret)return ret; ret = devm_add_action_or_reset(&pdev->dev, (void(*)(void *))clk_disable_unprepare, d->clk);if (ret)return ret; spin_lock_init(&d->lock); platform_set_drvdata(pdev, d);return0;}
这里真正关键的不是某个单独 API,而是思路:能托管的资源尽量托管,不能托管的资源通过 devm_add_action_or_reset() 这类方式收口。这样一来,probe() 的失败路径会短很多,remove() 也不会充满重复劳动。
技巧三:probe() 只做“设备上线必须动作”,别把慢逻辑全塞进去
很多驱动写慢,不是因为寄存器访问慢,而是因为 probe() 太贪心。有人喜欢在 probe() 里顺便做固件版本探测、完整自检、批量校准、日志初始化、debugfs 节点创建、统计线程拉起、用户态接口注册,甚至连设备默认参数都一次性写满。结果就是:驱动看起来初始化很完整,实际上绑定变慢、失败路径复杂、调试也困难。
你要记住一件事:probe() 的目标不是“把所有能做的都做完”,而是让设备以最小必要状态可用。特别是 SoC 上的大量 platform 设备,本身就不是热插拔设备,probe() 如果写得又重又慢,会直接拖累启动时间和绑定稳定性。
更合理的做法通常是:
probe()只完成资源获取、硬件基础初始化和中断注册
内核文档里也提到,对非热插拔场景,甚至可以考虑 platform_driver_probe() 这种更偏一次性探测的方式,以减少运行期占用。但无论用哪种形式,核心思想都一样:初始化路径要尽量瘦。[来源:Linux Kernel Platform Devices and Drivers 文档搜索结果]
技巧四:中断只做快路径,重活一律下放
这是驱动性能里最经典、也最容易知错不改的一条。很多人明明知道“中断里别做重活”,但真写业务时还是忍不住把状态机推进、数据搬运、错误恢复、唤醒链路、打印日志都放进去。结果一到高频事件场景,系统延迟就开始飘。
中断上下文里最该做的事只有几类:
下面这个模式就比“在中断里全做完”稳得多:

static irqreturn_t fastdrv_irq(int irq, void *dev_id){struct fastdrv_data *d = dev_id; u32 status; status = readl(d->base + FASTDRV_INT_STATUS);if (!(status & FASTDRV_INT_PENDING))return IRQ_NONE; writel(status, d->base + FASTDRV_INT_STATUS); schedule_work(&d->bottom_half_work);return IRQ_HANDLED;}
如果数据吞吐很大,或者你本身就在做流式收发,那中断上半部甚至只保留 ack 和唤醒,下半部再做 FIFO drain、错误判断和上报。让中断函数保持短小,是 C 语言写内核驱动时非常硬核的效率技巧。
技巧五:匹配表、资源名和私有数据结构要“读得懂”,别只求能编译
很多驱动后期难维护,不是因为逻辑太复杂,而是因为可读性太差。比如:
regs1 、regs2这种资源命名没人知道干什么tmp、priv2、cfgx这种字段看不出职责irq0、irq1没有和业务事件对应起来- 设备树里的
compatible、驱动名、日志前缀三套命名互不一致
你要意识到,嵌入式 Linux 驱动绝大多数不是“一次性实验代码”,而是未来会被别人移植、裁剪、回溯、修 bug 的代码。写得读不懂,最终伤的还是团队自己。
一个更好的习惯是:
- 资源按功能命名,比如
"regs"、"rx_irq"、"tx_irq" compatible、驱动名、日志标签尽量统一
这种“看起来不提升性能”的写法,实际上提升的是团队开发效率。驱动开发从来不是只有 CPU 周期,工程维护时间同样是成本。
技巧六:把 remove()、shutdown() 和异常路径当正式路径写
很多驱动之所以一到边界场景就出问题,本质原因是开发者默认“设备不会卸载”。但现实里你会遇到很多变体:模块反复装卸、调试时 bind/unbind、设备树 overlay 重载、系统 reboot/shutdown、电源管理进入 suspend/resume。只要你有一个回调没认真写,问题就会在这些场景爆出来。
特别是 shutdown(),很多人根本不写,结果系统重启时 DMA 还在跑,外设状态没收干净,下一次启动直接踩雷。对于工业控制和车载场景,这种问题往往非常隐蔽,但代价很大。
如果你大量用了 devm_*,remove() 确实可以简化很多,但“简化”不等于“不思考”。你仍然应该明确:
简单说就是:资源能自动释放,不代表硬件状态能自动正确收尾。
一个更像量产工程的驱动开发顺序
如果你想把驱动写得又稳又快,我建议按下面这个顺序推进,而不是想到哪写到哪。
第一步:先搭骨架
先把 of_match_table、platform_driver、私有数据结构、资源入口理清楚。不要上来就写一堆寄存器细节。
第二步:先跑通最短上线路径
先实现最小可用版本:映射资源、开时钟、注册中断、做一次基础收发。先证明驱动结构没问题。
第三步:再补性能路径
等设备真的能稳定工作,再去优化中断、锁粒度、buffer 组织和工作队列划分。不要在一个不稳定的结构上提前卷性能。
第四步:最后补完边界场景
把 remove()、shutdown()、错误路径、自检路径、调试接口都补齐。做到这一步,你的驱动才更接近“可交付”而不是“可演示”。
总结
Linux 内核驱动开发真正难的,从来不是“某个 API 记不住”,而是你能不能用 C 语言把驱动的结构组织得足够稳、足够清晰、足够能扛后续迭代。
如果你只想记住这篇文章的核心结论,那就是:
- 能用
devm_* 托管的资源尽量别手搓probe()要瘦,中断要快,重活要下放remove()和 shutdown()不是摆设
很多人把驱动写成“能工作的一坨代码”,最后越改越怕;而真正高效的驱动实现,往往不是炫技,而是把资源、路径和边界收得足够干净。这样的代码,才更像是能走进量产项目的 Linux 内核驱动。
大家好,我是四哥,一个深耕嵌入式14年的老工程师。
分享大家一份不错的C语言电子书,以非常通俗的语言跟大家讲解C语言,把复杂的技术讲得连小学生都能听得懂,绝不是AI生成那种晦涩难懂的电子垃圾。
免费领取,下方扫码添加,备注「C语言」👇:
C语言电子书目录如下: