1. 为什么我要写《驱动之路》这本书动笔写这个系列之前我犹豫了很长时间。市面上关于硬件驱动开发的中文资料并不算少但真正能让人从零开始、一步步跟着做下来还不掉坑里的内容说实话不多。大部分资料要么是芯片原厂的英文数据手册动辄上千页新手翻三页就犯困要么是某些论坛里零散的帖子东一榔头西一棒子不成体系。我自己当年入门的时候就是在这两种材料之间反复横跳踩了无数的坑浪费了大量的时间。所以《驱动之路》这个系列我想做的事情很简单把驱动开发这件事用一个人话讲清楚。从最基础的环境搭建到字符设备驱动的编写再到中断处理、内存映射、设备树解析最后到实际项目中的调试技巧和性能优化我会尽量按照一个真实项目的推进节奏来组织内容。每一章都会有可运行的代码每一段代码我都会解释为什么这么写而不是只告诉你复制粘贴就能跑。这个系列适合什么人看如果你是一个有C语言基础、了解基本的操作系统概念比如进程、内存管理、文件系统但从来没有写过驱动的开发者那这个系列就是为你准备的。如果你已经写过一些简单的驱动但在实际项目中总是遇到各种莫名其妙的问题比如insmod之后内核直接panic、中断进不去、DMA传输数据对不上那这个系列里关于调试和排错的部分应该也能帮到你。甚至如果你只是一个对底层技术好奇的软件工程师想了解操作系统是怎么跟硬件打交道的那也不妨读一读我会尽量把原理讲得通俗一些。需要说明的是驱动开发是一个实践性极强的领域光看书是学不会的。我在每一章都会给出完整的代码和操作步骤你需要有一块真实的开发板或者至少能在QEMU这样的模拟环境里跑起来。纸上得来终觉浅这句话在驱动开发领域尤其正确。你可能会在编译内核时遇到各种依赖问题可能会在加载模块时看到一堆看不懂的报错可能会在调试时发现硬件的行为和手册描述的不一致——这些都是正常的也是驱动开发的日常。我会在文中尽量把这些常见问题都覆盖到但真正的经验还是得你自己动手才能积累起来。2. 驱动开发到底在开发什么2.1 从一个最简单的LED灯说起很多人第一次接触驱动开发都是从点一个LED灯开始的。这个传统很好因为它足够简单又足够典型。假设我们有一块开发板上面有一颗LED连接到SoC的某个GPIO引脚上。我们要做的事情就是写一个内核模块加载之后能让这颗LED亮起来。听起来很简单对吧但这里面涉及到的知识点其实不少。首先你需要知道这个GPIO引脚在SoC的地址空间里对应哪个寄存器。然后你需要把这个寄存器的物理地址映射到内核的虚拟地址空间因为内核代码不能直接访问物理地址。接着你需要配置这个引脚的功能复用因为SoC的引脚通常可以工作在多种模式下比如GPIO模式、UART模式、I2C模式等等。最后你还需要设置引脚的方向为输出然后写入正确的电平值。这整个过程就是驱动开发的一个缩影。驱动程序的本质工作就是充当硬件和操作系统之间的翻译官。硬件只认识寄存器和电平信号操作系统只认识文件描述符和系统调用驱动程序要做的就是把操作系统的请求翻译成硬件的操作再把硬件的事件翻译成操作系统能理解的格式。2.2 驱动程序的几种典型类型在实际项目中驱动程序并不是只有一种形态。根据硬件的特点和操作系统的架构驱动程序可以分为几种典型的类型每种类型的设计思路和实现方式都有很大的差异。字符设备驱动是最常见的一种。它的特点是数据以字节流的形式进行读写不支持随机访问。串口、键盘、鼠标、LED、蜂鸣器这些设备通常都用字符设备驱动来实现。字符设备驱动的核心是实现一套file_operations结构体里面包含了open、read、write、ioctl、release等函数指针。当用户空间的程序调用open打开设备文件时内核就会调用驱动注册的open函数调用read时就会调用驱动注册的read函数。这种一一对应的关系让字符设备驱动的逻辑非常直观。块设备驱动则不同它的特点是数据以固定大小的块为单位进行读写支持随机访问。硬盘、SSD、SD卡、NAND Flash这些存储设备通常都用块设备驱动来实现。块设备驱动的核心是实现一套block_device_operations结构体以及一个请求队列的处理函数。块设备驱动的复杂性在于它需要处理请求的合并、排序、调度等操作以最大化存储设备的吞吐量。此外块设备驱动还需要实现缓冲区的管理因为内核会对块设备的读写进行缓存以提高性能。网络设备驱动是另一种特殊的类型。它的特点是数据以数据包的形式进行收发不需要对应的设备文件。网卡、WiFi模块、蓝牙模块这些设备通常都用网络设备驱动来实现。网络设备驱动的核心是实现一套net_device_operations结构体里面包含了open、stop、hard_start_xmit、set_config等函数指针。网络设备驱动的特殊性在于它直接与内核的网络协议栈交互而不是与文件系统交互。当网卡收到一个数据包时驱动程序需要把数据包封装成sk_buff结构体然后交给协议栈处理当协议栈要发送一个数据包时驱动程序需要从sk_buff结构体中提取数据然后写入网卡的发送缓冲区。除了这三种基本类型还有一些特殊的驱动类型比如USB驱动、PCI驱动、I2C驱动、SPI驱动等等。这些驱动通常是在上述三种基本类型的基础上针对特定总线的特点进行了封装和扩展。比如USB驱动它需要处理USB设备的枚举、配置、端点管理、数据传输等操作但最终呈现给用户空间的接口可能仍然是一个字符设备或者网络设备。2.3 驱动开发与应用程序开发的根本差异很多从应用开发转过来的开发者一开始会很不适应驱动开发因为两者的思维方式有根本性的差异。应用程序开发运行在用户空间有操作系统提供的各种保护机制。你的程序崩溃了最多就是自己挂掉不会影响其他程序更不会导致整个系统崩溃。你可以随意使用malloc申请内存即使申请失败也只需要检查返回值不会有什么严重后果。你可以使用各种高级语言特性比如异常处理、垃圾回收、动态类型这些都能帮你省去很多底层的烦恼。驱动开发则完全不同。驱动程序运行在内核空间与操作系统共享同一个地址空间。你的驱动里有一个空指针解引用整个系统就会立刻崩溃屏幕上可能会打印出一堆oops信息然后系统就死机了。你在驱动里申请内存必须小心翼翼因为内核空间的内存是有限的而且不能睡眠的上下文里不能使用可能睡眠的分配函数。你不能使用浮点数因为内核通常不保存浮点寄存器的状态。你不能使用标准C库的大部分函数因为内核有自己的API。你甚至不能随意使用递归因为内核栈通常只有8KB或者16KB递归稍微深一点就会栈溢出。这些限制听起来很可怕但换个角度看也正是这些限制让驱动开发变得有趣。你是在一个资源极度受限的环境里用最原始的工具完成最底层的任务。每当你解决了一个棘手的问题那种成就感是应用开发很难体会到的。3. 写驱动之前需要准备什么3.1 硬件环境的选择与搭建驱动开发离不开硬件。虽然理论上你可以在纯软件环境里学习驱动开发比如用QEMU模拟一块开发板但我的建议是如果条件允许尽量准备一块真实的开发板。因为真实硬件会给你带来很多模拟环境里遇不到的问题而解决这些问题正是积累经验的最好方式。选择开发板的时候有几个因素需要考虑。首先是芯片的文档是否齐全。有些芯片的原厂文档写得非常详细寄存器手册、数据手册、应用笔记一应俱全这种芯片就非常适合学习。有些芯片的文档则写得非常简略很多细节都需要靠猜或者靠社区里的零散信息来补全这种芯片就不太适合新手。其次是社区是否活跃。如果一块开发板有活跃的社区支持你在遇到问题的时候就更容易找到答案。最后是价格。学习用的开发板不需要太贵几百块钱的就已经足够覆盖大部分驱动开发的学习场景了。除了开发板本身你还需要一些基本的调试工具。一个USB转串口的模块是必须的因为串口是嵌入式开发中最常用的调试输出方式。一个JTAG调试器也很有帮助虽然价格可能比开发板还贵但在调试一些复杂问题的时候它能帮你省下大量时间。此外你可能还需要一个逻辑分析仪或者示波器用来观察硬件上的信号波形这在调试时序相关的问题时非常有用。3.2 软件环境的搭建软件环境的搭建是很多新手遇到的第一个拦路虎。你需要一台运行Linux的电脑因为驱动开发几乎都是在Linux环境下进行的。如果你用的是Windows可以装一个虚拟机或者用WSL2。如果你用的是macOS也可以装虚拟机或者直接用Linux服务器。在Linux电脑上你需要安装交叉编译工具链。因为开发板的CPU架构通常和你的电脑不一样比如你的电脑是x86_64而开发板是ARM64你就需要一个能在x86_64上运行、但能生成ARM64代码的编译器。交叉编译工具链的安装方式取决于你的发行版在Ubuntu上你可以用apt安装gcc-aarch64-linux-gnu这样的包。你还需要下载开发板对应的内核源码。注意这里说的内核源码必须是开发板厂商提供的版本而不是Linux官方的主线版本。因为厂商通常会对内核进行大量的修改添加自己的驱动和配置如果你用主线内核很多硬件可能根本无法工作。内核源码的下载方式通常在开发板的用户手册里会有说明。有了内核源码之后你需要配置和编译内核。这个过程可能会比较漫长取决于你的电脑性能。编译内核的时候你需要确保你的交叉编译工具链的路径已经添加到PATH环境变量里否则编译会失败。编译完成后你会得到内核镜像和设备树文件这两个文件需要烧录到开发板上。3.3 第一个内核模块的编写与加载环境搭建好之后我们就可以写第一个内核模块了。这个模块不做任何事情只是在加载的时候打印一条信息在卸载的时候再打印一条信息。虽然简单但它能帮你验证整个开发环境是否正常工作。#include linux/module.h #include linux/kernel.h #include linux/init.h static int __init hello_init(void) { printk(KERN_INFO Hello, kernel!\n); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO Goodbye, kernel!\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple hello world module);这段代码里module_init和module_exit是两个宏它们分别指定了模块加载和卸载时调用的函数。__init和__exit也是宏它们告诉内核这两个函数只在初始化或卸载时使用用完之后可以释放它们占用的内存。printk是内核里的打印函数类似于用户空间的printf但它的输出会进入内核日志缓冲区你可以用dmesg命令查看。MODULE_LICENSE指定了模块的许可证对于内核模块来说通常必须指定为GPL否则内核会认为这是一个专有模块可能会限制某些功能的使用。编译这个模块需要一个Makefile。这个Makefile的写法比较特殊因为它需要调用内核的构建系统。obj-m hello.o KERNEL_DIR ? /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERNEL_DIR) M$(PWD) clean这个Makefile里obj-m指定了要编译的模块目标。KERNEL_DIR指定了内核源码的路径如果你是在为开发板编译这个路径应该指向你下载的开发板内核源码。$(MAKE) -C $(KERNEL_DIR) M$(PWD) modules这条命令的意思是切换到内核源码目录然后调用内核的Makefile但只编译当前目录下的模块。编译完成后你会得到一个hello.ko文件。用insmod hello.ko命令加载它然后用dmesg查看内核日志你应该能看到Hello, kernel!这条信息。用rmmod hello卸载它再查看日志你应该能看到Goodbye, kernel!。如果你能看到这两条信息恭喜你你的开发环境已经可以正常工作了。4. 驱动开发中最容易踩的坑4.1 内核崩溃的常见原因与排查方法驱动开发中最让人头疼的事情莫过于内核崩溃。屏幕上突然打印出一大堆看不懂的十六进制数字和函数名然后系统就死机了。对于新手来说这往往意味着需要重启电脑然后从头再来。但其实这些崩溃信息里包含了非常有价值的线索学会阅读它们能帮你快速定位问题。内核崩溃时打印的信息通常被称为oops或者panic。oops表示内核遇到了一个无法处理的错误但系统可能还能继续运行panic则表示内核遇到了致命错误系统必须停止运行。无论是哪种情况你首先需要关注的是出错时的程序计数器PC的值以及调用栈call trace。PC的值告诉你出错时CPU正在执行哪条指令调用栈则告诉你这个指令是在哪个函数里被调用的以及这个函数又是被谁调用的。如果你编译内核的时候开启了调试信息CONFIG_DEBUG_INFO你可以用gdb或者addr2line这样的工具把PC的值转换成对应的源码行号。这样你就能知道具体是哪一行代码出了问题。调用栈里的函数名也能帮你理解代码的执行路径从而推断出问题可能出在哪里。空指针解引用是最常见的崩溃原因之一。在内核里解引用一个空指针会直接导致oops。这种问题通常比较容易发现因为oops信息里会明确告诉你Unable to handle kernel NULL pointer dereference。你只需要检查出错位置的代码看看哪个指针可能是空的然后加上必要的检查就可以了。另一个常见的崩溃原因是栈溢出。内核栈通常只有8KB或者16KB如果你的函数里定义了很大的局部变量数组或者递归调用层次太深就会导致栈溢出。栈溢出的oops信息通常不会直接告诉你stack overflow而是会显示一些奇怪的症状比如调用栈看起来是乱的或者PC的值指向一个完全不相关的函数。遇到这种情况你可以检查一下出错函数里有没有大的局部变量或者有没有递归调用。4.2 内存管理中的陷阱内核里的内存管理和用户空间有很大的不同这里有几个容易踩的坑。首先kmalloc和vmalloc的区别。kmalloc分配的内存在物理上是连续的而vmalloc分配的内存在物理上不一定连续但在虚拟地址空间上是连续的。kmalloc适合分配较小的内存块通常不超过128KBvmalloc适合分配较大的内存块但它的开销也更大。如果你需要DMA传输那必须用kmalloc或者更底层的分配函数因为DMA需要物理地址连续的缓冲区。其次GFP标志的选择。kmalloc的第二个参数是GFP标志它决定了分配内存时的行为。GFP_KERNEL是最常用的标志它表示分配内存时可以睡眠因此只能在进程上下文里使用。如果你在中断处理函数里分配内存就不能用GFP_KERNEL而要用GFP_ATOMIC因为中断上下文里不能睡眠。用错了GFP标志轻则分配失败重则内核崩溃。第三内存泄漏。内核里的内存泄漏比用户空间更危险因为内核内存是有限的泄漏多了会导致系统变慢甚至崩溃。每次kmalloc之后都要确保在适当的时候kfree。特别是在错误处理路径上很容易忘记释放已经分配的内存。我个人的习惯是在函数入口处就把所有需要释放的资源列出来然后在每个错误返回之前检查一遍这些资源是否已经释放。4.3 并发与竞态的处理内核是一个高度并发的环境。多个进程可能同时调用你的驱动中断可能在任何时候发生内核线程可能在你不知情的情况下访问你的数据结构。如果你的驱动没有正确处理并发就会出现竞态条件导致数据损坏或者内核崩溃。处理并发最常用的工具是自旋锁和互斥锁。自旋锁适合保护很短的临界区因为它在等待锁的时候会一直占用CPU不会睡眠。互斥锁适合保护较长的临界区因为它在等待锁的时候会睡眠让出CPU给其他任务。在中断上下文里只能使用自旋锁不能使用互斥锁因为中断上下文里不能睡眠。除了锁还有一些其他的并发控制机制。原子变量适合保护简单的计数器它不需要加锁就能保证操作的原子性。完成量completion适合一个任务等待另一个任务完成某个操作的场景。工作队列workqueue适合把一些不紧急的任务推迟到以后执行从而减少中断处理函数的执行时间。处理并发的时候有一个原则非常重要尽量缩小临界区。临界区越大锁的争用就越严重系统的性能就越差。而且临界区越大你越容易在临界区里调用可能睡眠的函数从而导致死锁。我个人的经验是在写代码的时候先把所有可能被并发访问的数据结构列出来然后仔细分析每个数据结构的访问模式最后再决定用什么机制来保护它。5. 调试驱动程序的实用技巧5.1 printk的使用与日志级别printk是驱动开发中最常用的调试工具但很多人并没有充分利用它的功能。printk的第一个参数是日志级别它决定了这条信息的重要性。内核定义了八个日志级别从KERN_EMERG0到KERN_DEBUG7。如果你不指定日志级别printk会使用默认级别通常是KERN_WARNING。日志级别的作用是你可以通过配置内核的日志输出级别来控制哪些信息会被打印到控制台。比如你可以把控制台的日志级别设置为KERN_WARNING这样只有警告及以上级别的信息才会显示在屏幕上而调试信息则只会进入内核日志缓冲区你可以用dmesg命令查看。这在调试的时候非常有用因为你可以用printk打印大量的调试信息而不会把屏幕刷得乱七八糟。printk还有一些有用的格式说明符。%pK可以打印内核指针但只有具有相应权限的用户才能看到真实的地址值这有助于保护内核地址空间布局。%px可以打印未经处理的指针值适合在调试时使用。%pS可以打印符号名和偏移量这在打印函数指针的时候非常有用。5.2 使用debugfs查看驱动状态debugfs是一个专门用于调试的文件系统它提供了一种在用户空间查看和修改驱动内部状态的便捷方式。你可以在驱动里创建debugfs文件然后把一些关键的变量暴露出来这样在调试的时候你只需要cat一下对应的文件就能看到这些变量的值。创建debugfs文件的代码很简单。首先你需要在模块初始化的时候调用debugfs_create_dir创建一个目录。然后你可以用debugfs_create_file在这个目录下创建文件。对于简单的变量你可以用debugfs_create_u32、debugfs_create_bool这样的辅助函数它们会自动处理文件的读写操作。对于复杂的数据结构你可以自己实现file_operations在read函数里把数据格式化后返回给用户空间。debugfs的一个好处是它不需要像procfs或者sysfs那样遵循严格的接口规范。你可以随意创建文件和目录不用担心会破坏用户空间的兼容性。而且debugfs默认挂载在/sys/kernel/debug只有root用户才能访问所以不用担心安全问题。5.3 用ftrace追踪函数调用ftrace是内核内置的一个追踪框架它可以帮你追踪函数的调用关系、执行时间、中断延迟等信息。对于驱动开发者来说ftrace最有用的功能是function tracer和function_graph tracer。function tracer可以记录所有内核函数的调用你可以通过设置过滤器只记录你关心的函数。比如你可以只记录你的驱动里的函数这样就能清楚地看到函数的调用顺序和调用次数。function_graph tracer则更进一步它不仅能记录函数的调用还能记录函数的返回从而让你看到函数的执行时间。这对于分析性能问题非常有帮助。使用ftrace的步骤很简单。首先你需要挂载tracefs文件系统通常挂载在/sys/kernel/tracing。然后你可以通过写入current_tracer文件来选择追踪器通过写入set_ftrace_filter文件来设置过滤器。最后读取trace文件就能看到追踪结果。ftrace的开销很小对系统性能的影响可以忽略不计所以你可以放心地在生产环境里使用它。6. 从学习到实战的进阶路径6.1 如何阅读芯片数据手册芯片数据手册是驱动开发中最重要的参考资料但很多人不知道该怎么读。一本典型的数据手册可能有上千页里面包含了芯片的所有细节从引脚定义到寄存器描述从电气特性到封装尺寸应有尽有。如果你从头到尾一页一页地读那可能一个月都读不完而且读完之后也记不住多少。我的建议是带着问题去读。比如你要写一个GPIO驱动那你只需要关注数据手册里关于GPIO的章节。通常数据手册会有一个章节专门介绍GPIO控制器里面会告诉你GPIO的寄存器基地址、每个寄存器的位定义、引脚的功能复用配置方法等等。你只需要把这些信息提取出来就可以开始写驱动了。阅读寄存器描述的时候要特别注意保留位和只读位。保留位通常必须写入特定的值通常是0否则可能会导致未定义的行为。只读位则不能写入写入会被忽略或者导致错误。此外还要注意寄存器的访问宽度有些寄存器必须用32位访问有些可以用8位或者16位访问用错了宽度可能会导致访问失败。6.2 从模仿到创新的学习曲线学习驱动开发最开始一定是模仿。找一个类似的驱动把它的代码读懂然后照着它的结构写自己的驱动。这个过程可能会持续几个月甚至更长时间。但模仿不是目的目的是通过模仿来理解驱动开发的模式和套路。当你写过几个驱动之后你会发现虽然不同的硬件有不同的寄存器但驱动的整体结构是相似的。字符设备驱动总是要实现file_operations总是要处理open、read、write、ioctl这些操作。块设备驱动总是要实现请求队列的处理函数总是要处理bio结构体。网络设备驱动总是要实现net_device_operations总是要处理sk_buff结构体。一旦你掌握了这些模式写新的驱动就会变得容易很多。从模仿到创新的转折点通常是你遇到一个现有的驱动无法解决的问题的时候。比如你需要实现一个功能但现有的驱动框架没有提供相应的接口或者你需要优化性能但现有的驱动实现效率不够高。这时候你就需要深入理解驱动框架的内部机制然后在此基础上进行扩展或修改。这个过程可能会很痛苦但也是成长最快的时候。6.3 参与开源社区的正确姿势参与开源社区是提升驱动开发能力的一个很好的途径。你可以从提交小的补丁开始比如修复一个拼写错误、改进一段注释、优化一小段代码。这些补丁虽然小但能帮你熟悉社区的流程包括怎么用git生成补丁、怎么发邮件、怎么回复review意见等等。当你对社区流程比较熟悉之后可以尝试解决一些标记为good first issue的问题。这些问题通常比较简单而且社区里的资深开发者会耐心地指导你。通过解决这些问题你不仅能提升技术能力还能建立自己在社区里的声誉。参与开源社区的时候有几点需要注意。首先要尊重社区的规则和文化。不同的社区有不同的风格有些社区比较严格有些社区比较宽松你需要花时间去了解。其次要有耐心。你的补丁可能不会一次就被接受reviewer可能会提出很多修改意见你需要认真对待每一条意见然后修改你的补丁。最后不要害怕犯错。每个人都会犯错重要的是从错误中学习然后继续前进。7. 关于这个系列的一些说明《驱动之路》这个系列我会尽量保持一个稳定的更新节奏但具体多久更新一篇说实话我自己也说不准。因为每一篇的内容都需要大量的实践和验证我不想为了更新而更新写一些没有经过验证的内容。如果某一篇的内容比较复杂可能需要更长的时间来准备希望大家能理解。这个系列的文章我会尽量保持独立性和完整性。每一篇都会有一个明确的主题围绕这个主题展开不会为了凑字数而写一些无关的内容。同时我也会尽量保持系列的整体连贯性让每一篇都能自然地衔接到下一篇。如果你是从中间某一篇开始读的也不会觉得突兀因为我会在必要的时候回顾前面讲过的内容。最后我想说的是驱动开发是一个需要长期积累的领域没有什么捷径可走。你可能会在某个问题上卡住好几天可能会因为一个莫名其妙的bug而熬夜到凌晨可能会在终于解决问题之后兴奋得睡不着觉。这些都是驱动开发的日常也是它的魅力所在。我希望这个系列能帮你少走一些弯路但真正的路还是得你自己走。