1. 为什么我要写这个系列动笔写这个系列之前我犹豫了差不多有小半年。原因很简单市面上关于设备驱动开发的中文资料要么是翻译腔浓重的教科书要么是零散的技术笔记真正能把“一个驱动从想法到跑通”这件事讲透的内容少得可怜。我自己当年入门的时候翻遍了各种文档和论坛帖子踩过的坑一个没少绕过的弯路一条没落。后来带过几个新人发现他们遇到的问题和我当年几乎一模一样——不是智力问题也不是基础不够而是缺少一份“过来人”视角的完整记录。所以这个系列我想做一件事把驱动开发这件事从“为什么要这么写”到“怎么调通”再到“怎么避坑”用我自己的话完整地讲一遍。它不是手册不是API文档的复述更不是学术论文。它更像是一个干了十来年的老工程师坐在你旁边一边敲代码一边跟你唠这个地方我当时是怎么想的那个参数为什么选这个值这个报错我查了三天最后发现是个什么低级问题。“驱动之路”这个名字其实我想了很久。驱动开发这条路说宽也宽从嵌入式到桌面系统从内核模块到用户态驱动框架方向很多说窄也窄核心的东西就那么些——总线模型、设备树、中断处理、内存映射、并发控制。但不管哪个方向底层逻辑是相通的。我希望这个系列能成为一条“路”不是那种高速公路而是那种有人走过、路边有标记、岔路口有指示牌的山路。你跟着走不一定最快但大概率不会掉沟里。这个系列适合谁看如果你是完全没接触过驱动开发的新手我建议你至少先写过一些C语言项目理解指针和结构体的基本用法知道什么是编译和链接。如果你已经写过一些简单的字符设备驱动但对Linux设备模型、platform总线、设备树这些概念还模模糊糊那这个系列应该能帮你把很多碎片串起来。如果你是个老手纯粹想看看别人的思路那也欢迎说不定某个细节的讨论能给你一点启发。2. 这个系列会讲什么不会讲什么2.1 内容范围与边界先说说这个系列会覆盖的内容。我计划从最基础的字符设备驱动开始逐步过渡到platform驱动、设备树、中断处理、并发控制、内存映射、DMA、以及一些常见的子系统框架。每一篇都会围绕一个具体的、可运行的小项目展开不会只讲理论。代码我会尽量写完整关键部分会逐行解释但不会把整个文件贴出来凑字数——那样没意义你直接去看源码就行。具体来说前几篇会聚焦在“怎么让一个驱动跑起来”这件事上。很多人学驱动卡在第一步写了个最简单的hello world模块编译加载都成功了但接下来不知道该干什么。我会从字符设备入手讲清楚file_operations结构体里每个成员什么时候被调用用户态read/write是怎么一步步走到你的驱动函数里的。这部分看起来简单但真正理解透了后面学什么都快。中间部分会重点讲设备模型和总线。这是驱动开发的分水岭。很多人写驱动就是“能跑就行”但一旦系统里有多个设备、多个驱动或者需要热插拔、电源管理没有设备模型的概念就会寸步难行。我会用platform总线作为例子把device和driver的匹配过程、probe函数的调用时机、sysfs下的目录结构怎么来的这些讲清楚。后面会涉及中断和并发。中断处理是驱动开发里最容易出问题的地方之一上半部下半部的划分、中断共享、中断线程化这些概念不理解清楚写出来的驱动轻则丢中断重则死锁。并发控制也是自旋锁、互斥锁、原子操作、RCU什么时候用哪个为什么用错了会出问题我会结合具体场景来讲。再说说不会讲什么。第一我不会讲太偏门或者太新的内核特性因为那些东西我自己也没在实际项目里大规模用过讲出来容易误导人。第二我不会讲具体的硬件手册内容比如某个特定芯片的寄存器怎么配置——那种东西看手册就行而且不同芯片差异太大讲一个型号的意义不大。第三我不会讲用户态驱动框架的细节比如UIO或者VFIO这些虽然有用但和内核态驱动是两套思路混在一起讲容易乱。第四我不会讲太底层的汇编和体系结构相关的内容除非是理解某个概念必须的否则默认你有基本的计算机体系结构知识。2.2 写作风格与代码约定这个系列的写作风格我尽量保持“口语化但不随意”。技术概念该严谨的地方一定严谨比如内存屏障的语义、锁的粒度、中断上下文的限制这些不能含糊。但表达方式上我会尽量用大白话能用类比的地方就用类比。比如讲设备模型的时候我会把它比作一个“相亲平台”——device是征婚者driver是应征者bus是红娘match函数是筛选条件probe是见面成功后的婚礼。这种类比不一定完全准确但能帮你先建立一个直觉再去抠细节。代码方面我会以Linux内核模块为主内核版本选择5.x的某个长期支持版本。为什么选这个版本因为太老的版本很多API已经废弃了你照着写编译都过不了太新的版本又不够稳定而且很多公司实际项目里用的也不是最新内核。5.x是一个比较平衡的选择大部分API和现在的主流内核兼容。代码风格上我会尽量遵循内核的编码规范但不会为了凑格式把代码写得很散。关键函数和结构体会加注释但不会每行都注释——那样反而干扰阅读。另外说明一点我不会在文章里贴大段的完整代码文件。原因很简单驱动代码往往依赖具体的硬件和内核配置直接复制粘贴大概率跑不起来。我会把核心逻辑和关键片段写出来配合解释你需要做的是理解思路然后根据自己的环境去调整。如果你想要完整的可编译代码我会在每篇末尾说明代码的组织方式和获取途径如果有的话但不会把几百行代码直接堆在文章里。3. 驱动开发到底难在哪3.1 从“能跑”到“跑得好”的鸿沟很多人对驱动开发的印象是“难”。但难在哪我观察下来主要不是难在写代码而是难在“看不见”。应用层开发你printf一下就能看到变量值gdb跟一下就能知道执行路径。驱动开发不一样你的代码运行在内核态出了问题轻则oops重则死机调试手段有限而且很多问题不是逻辑错误而是时序问题、并发问题、硬件行为不符合预期。这些东西光靠读代码是看不出来的。举个例子。我曾经写过一个I2C设备的驱动读一个寄存器偶尔会返回0xFF。一开始以为是硬件没接好查了电路、换了板子、示波器也上了都没问题。后来发现是I2C总线的时钟频率设得太高而那个设备对时序要求比较严格偶尔采样出错。这种问题你在代码里怎么看都看不出来因为代码逻辑完全正确。只有当你理解了“驱动是软件和硬件之间的翻译官”这个角色你才会去关注时序、电气特性、信号完整性这些看起来和“写代码”无关的东西。所以这个系列我会花不少篇幅讲“怎么调试”和“怎么排查”。不是讲gdb怎么用、printk怎么加而是讲遇到一个现象你怎么一步步缩小范围怎么判断是软件问题还是硬件问题怎么用有限的工具获取最多的信息。这些经验比具体的API用法值钱得多。3.2 内核世界的“潜规则”另一个让驱动开发显得难的原因是内核世界有很多“潜规则”——文档里不会写但你不遵守就会出问题。比如中断上下文里不能睡眠。这个大家都知道但什么是“睡眠”调用kmalloc(GFP_KERNEL)算不算调用mutex_lock算不算调用printk算不算这些边界情况文档不会一一列举但实际写代码时经常遇到。自旋锁不能递归获取。同一个CPU上重复获取同一个自旋锁会死锁但如果你在中断处理里获取了锁在中断返回前又触发了同一个中断就会出问题。这种场景没有实际踩过坑很难想到。内存屏障不是可有可无的。在单核系统上很多乱序执行的问题不会暴露但到了多核或者弱内存序架构上缺少屏障就会导致数据不一致。这种问题往往在压力测试下才出现平时跑得好好的。这些“潜规则”我会在相关章节里结合具体场景讲。不是列一条条规则让你背而是讲清楚背后的原因——为什么中断上下文不能睡眠因为调度器依赖中断来触发调度如果中断处理里睡眠了调度器可能永远得不到执行。理解了原因你自然就知道边界在哪。4. 我踩过的那些坑4.1 一个让我通宵的引用计数问题说个具体的例子。早些年我写一个字符设备驱动设备打开时申请一块内存关闭时释放。代码看起来没问题open里kmallocrelease里kfree。但测试的时候发现如果多个进程同时打开这个设备偶尔会崩溃。查了很久发现是引用计数的问题——我没有在open里增加模块的引用计数导致模块可能在设备还打开着的时候被卸载。这个问题在单进程测试时永远不会出现只有并发打开才会暴露。修复方法很简单在open里调用try_module_get(THIS_MODULE)在release里调用module_put(THIS_MODULE)。但当时我不知道这个机制也不知道为什么需要它。后来理解了内核模块的卸载不是“立即消失”而是等引用计数归零后才真正卸载。如果你不增加引用计数用户态程序还在用你的驱动模块却被卸载了函数指针就指向了无效内存不崩溃才怪。这个坑让我明白一个道理驱动开发里每一个“看起来多余”的操作背后都有原因。你觉得不需要往往是因为你还没遇到需要它的场景。4.2 设备树地址写错导致的“灵异事件”再讲一个设备树的坑。有一次调试一个platform设备设备树里reg属性写的是0x10000000长度0x1000。驱动里request_mem_region和ioremap都成功了但读写寄存器就是没反应。查了两天最后发现是设备树里地址写错了——应该是0x10001000我少写了一个0。但奇怪的是request_mem_region居然成功了没有报冲突。原因是那个地址范围在系统里没有被其他驱动占用所以内核认为你可以用。但硬件上那个地址根本没有映射到任何寄存器读写自然没反应。这个问题的教训是request_mem_region成功不代表地址正确它只检查冲突不检查物理存在。所以调试驱动时如果寄存器读写异常第一件事就是确认设备树里的地址和手册是否一致。而且最好在驱动里加一些打印把request_mem_region和ioremap返回的地址打出来和手册对比。这个习惯帮我省了很多时间。5. 这个系列的组织方式5.1 每篇的结构每篇文章我会尽量保持一个固定的结构方便你按需阅读开头部分说明这篇要解决什么问题为什么这个问题重要适合什么基础的读者。原理部分讲清楚背后的机制不堆砌术语用类比和图示帮助理解。实操部分给出可运行的代码框架解释关键步骤说明参数选择的依据。调试部分列出常见问题、排查思路、以及我自己的经验教训。小结部分总结核心要点预告下一篇的内容。这个结构不是死的有些主题可能原理部分很短实操部分很长有些主题可能反过来。但整体上我希望每篇都能让你“看完就能动手动手就能跑通”。5.2 代码和环境的约定代码默认在x86_64架构的Linux上开发和测试内核版本5.10。为什么选x86_64因为大部分人手头没有开发板用虚拟机或者PC就能跑。为什么选5.10因为这是一个长期支持版本很多发行版都在用资料也相对好找。如果你用的是ARM开发板大部分代码也是兼容的只需要注意架构相关的部分比如内存屏障和DMA操作。编译方面我会用内核自带的kbuild系统Makefile的写法会给出模板。加载和卸载用insmod/rmmod查看日志用dmesg。调试工具方面printk是最常用的我也会介绍一些其他的手段比如ftrace、kprobe、以及一些静态分析工具。但不会过度依赖工具很多时候仔细读代码和加打印就能解决大部分问题。6. 写给准备上路的你驱动开发这条路入门确实有门槛但绝对没有很多人想象的那么高。你不需要是内核专家不需要读过几万行内核源码也不需要精通汇编。你需要的是扎实的C语言基础对操作系统基本概念的理解进程、内存、中断以及最重要的——耐心和好奇心。耐心是因为驱动调试往往不是一蹴而就的一个看似简单的问题可能花掉你一天时间。好奇心是因为当你遇到一个奇怪的现象时不要只想着“怎么绕过它”而是想“为什么会这样”。很多时候你为了解决一个问题而深入研究的某个机制会在以后解决另一个问题时派上大用场。这个系列不会让你成为驱动专家——那需要多年的积累和大量的项目实践。但它可以帮你跨过最初的那道坎让你从“不知道从哪下手”变成“知道怎么一步步往前走”。剩下的路需要你自己走。但至少路边会有我留下的标记。如果你准备好了我们就从下一篇开始从最简单的字符设备驱动写起。我会尽量把每一步都讲清楚包括那些看起来理所当然、但对新手来说可能是障碍的细节。比如为什么模块初始化函数要返回int为什么卸载函数没有返回值为什么printk的日志级别要加KERN_INFO这些“为什么”我都会一一解释。驱动之路道阻且长行则将至。我们下一篇见。