资讯详情

资讯详情

驱动开发从零到一:嵌入式Linux内核模块与设备树实战手记

1. 为什么我要写一本关于“驱动”的书动笔写这个系列之前我在一个技术社区里做过一次小范围调研问那些刚入行做嵌入式或者系统底层的朋友你觉得“写驱动”这件事最难的地方是什么收到的答案五花八门但排在前两位的一个是“不知道从哪下手”另一个是“能跑起来但说不清为什么能跑起来”。这两个答案其实指向同一个问题——驱动开发的知识体系太碎了碎到你在网上搜到的每一篇文章都只讲一个点拼不起来一张完整的图。我自己在这个领域摸爬滚打了十多年从最早对着芯片手册一行一行啃寄存器到后来带团队做完整的板级支持包中间踩过的坑、绕过的弯路、半夜对着示波器抓时序的经历攒了厚厚一摞。这些东西散落在各种项目笔记、邮件列表和内部文档里时间久了有些细节连我自己都记不清了。所以我想不如趁还能回忆起来把它们系统地整理出来写成一本“从零到一”的驱动开发手记。这就是《驱动之路》这个系列的由来。“驱动”这个词在不同语境下含义差别很大。有人觉得驱动就是写几个寄存器操作有人觉得驱动是操作系统和硬件之间的翻译官还有人把驱动等同于内核模块开发。这些理解都没错但都不完整。我倾向于把驱动看作一套让硬件“开口说话”的软件契约——它规定了操作系统怎么问硬件怎么答中间出了岔子谁来兜底。这套契约的复杂程度取决于硬件的脾气和操作系统的规矩。而《驱动之路》要做的就是把这套契约的起草、谈判、执行和调试过程掰开揉碎了讲清楚。这个系列适合谁看如果你是一个刚接触嵌入式或者底层开发的新手它能帮你建立一套完整的认知框架让你知道每一步操作背后的“为什么”而不是照抄代码。如果你已经有一定经验但总觉得自己的知识体系有缺口它能帮你把零散的知识点串成线、连成面。甚至如果你只是一个对“软件怎么控制硬件”这件事感到好奇的开发者我也尽量用生活化的类比把原理讲明白让你能看懂这场“软硬对话”的基本逻辑。2. 驱动开发的核心思路与方案选型2.1 从“点灯”到“对话”驱动开发的本质是什么很多人学驱动的第一个实验都是点灯——写一段代码让开发板上的LED亮起来。这个实验很经典因为它足够简单简单到你能在一两个小时里看到结果获得正反馈。但点灯和真正的驱动开发之间隔着一道巨大的鸿沟。点灯是你直接命令硬件“把那个引脚拉高”而驱动开发是你告诉操作系统“有这么个设备它需要被这样操作”然后操作系统在合适的时机替你完成操作。这个区别很关键。直接操作寄存器你面对的是物理地址和比特位代码和硬件是一对一绑死的。而写一个真正的驱动你面对的是操作系统提供的抽象层——文件接口、设备模型、电源管理、中断子系统等等。你的代码不再直接控制硬件而是通过一层层的框架和回调间接地影响硬件的行为。这就像你从“自己动手拧螺丝”变成了“设计一套自动化装配线的控制程序”后者要考虑的维度多得多。所以《驱动之路》的第一个核心思路就是先建立操作系统的视角再回头看硬件。很多教程一上来就讲寄存器怎么配时钟怎么开这没错但容易让人陷入细节的泥潭。我的做法是先讲清楚一个驱动在操作系统里长什么样它怎么被加载、怎么被识别、怎么和用户空间通信、怎么处理并发和电源事件。把这些框架性的东西搞明白了再去填寄存器的细节你会发现那些细节不再是孤立的数字而是有明确目的的操作。2.2 为什么选择“手记”而不是“教程”市面上讲驱动开发的书和教程不少有偏理论的有偏实战的有按芯片平台组织的也有按操作系统子系统划分的。我在构思《驱动之路》的时候反复问自己一个问题我写的这个东西和已有的资料相比独特价值在哪里想来想去我觉得最大的不同在于经验的颗粒度。教程通常告诉你“应该怎么做”但很少告诉你“为什么这么做”以及“不这么做会怎样”。比如配置一个GPIO引脚教程会列出寄存器地址和位域定义告诉你写1是输出写0是输入。但它不会告诉你如果你在配置之前忘了使能时钟读回来的寄存器值可能全是0你会对着手册怀疑人生它也不会告诉你某些芯片的引脚复用功能在复位后的默认状态是模拟输入你必须先把它切到数字模式否则外部信号根本进不来。这些细节只有真正在项目里被坑过的人才会知道。所以《驱动之路》的定位是一本“手记”而不是“教程”。手记的特点是有温度、有现场感、有失败记录。我会在文章里保留那些调试过程中的曲折包括我一开始想错的方向、试过的无效方案、以及最后找到问题时的那个“啊哈”时刻。这些东西对读者的价值可能比一段正确的代码更大因为它们能帮你建立调试的直觉——当你遇到一个没见过的问题时你知道从哪个方向去怀疑而不是盲目地试。2.3 内容组织的逻辑从框架到细节从通用到专用整个系列的编排我打算遵循一个“漏斗式”的结构。最上面是通用的驱动模型和操作系统接口这部分内容不管你用的是哪款芯片、哪个操作系统核心思想是相通的。中间层是具体的子系统比如GPIO、中断、时钟、DMA、I2C、SPI这些每个子系统我会挑一个典型的硬件模块来讲讲透它的工作原理和驱动实现。最下面才是具体芯片平台的适配细节这部分会因平台而异但有了上面两层的基础换一个平台无非是换一套寄存器定义和初始化流程。这种组织方式的好处是知识可迁移。你学的是“怎么为一个I2C设备写驱动”而不是“怎么为某款特定芯片上的某个传感器写驱动”。前者是能力后者是信息。信息会过时能力不会。我在带新人的时候最怕看到的就是那种“只会调某款芯片”的工程师换一个平台就束手无策。我希望《驱动之路》的读者在跟着走完一遍之后拿到任何一款新芯片的手册都能自己找到切入点把驱动框架搭起来。3. 核心细节解析与实操要点3.1 驱动开发的环境搭建别在工具上浪费时间在正式开始写代码之前有一个环节容易被轻视但又极其影响效率那就是开发环境的搭建。我见过不少新手代码还没写几行先花了两天折腾编译器和调试器最后热情都被磨没了。所以这里我先把这个环节的要点讲清楚让你能快速进入“写代码-编译-烧录-调试”的正循环。首先是交叉编译工具链的选择。如果你做的是嵌入式Linux驱动通常芯片厂商会提供一个预编译好的工具链直接用那个就行不要自己从头编译。自己编译工具链的坑非常多版本不匹配、库缺失、路径配置错误每一个都能耗掉你半天时间。厂商提供的工具链虽然可能不是最新的但它和你的内核源码、根文件系统是配套的能省掉大量兼容性问题。如果你确实需要自己构建建议用Buildroot或者Yocto这类框架它们能帮你处理好大部分依赖关系。其次是调试手段。驱动开发和应用程序开发最大的不同在于应用程序崩溃了你能看到调用栈和错误信息驱动崩溃了往往直接导致内核恐慌屏幕上只剩几行看不懂的十六进制地址。所以调试驱动的第一课是学会看内核日志。printk是最基础的武器但它的输出级别和缓冲区大小需要提前配置好否则关键信息可能被冲掉。更高级的手段包括使用ftrace跟踪函数调用、用kprobe动态插桩、用perf分析性能瓶颈。这些工具不需要你一开始就全部掌握但至少要知道它们的存在遇到问题时知道往哪个方向找工具。注意在调试驱动时尽量不要在中断上下文里使用会睡眠的函数比如kmalloc(GFP_KERNEL)或者mutex_lock。中断上下文没有进程上下文一旦睡眠可能导致系统死锁。如果确实需要分配内存用GFP_ATOMIC如果需要保护临界区用spinlock而不是mutex。3.2 设备树硬件描述的“说明书”现代嵌入式Linux驱动开发绕不开设备树Device Tree。很多从老式平台转过来的工程师一开始对设备树很抵触觉得“以前直接在代码里写死寄存器地址多简单现在非要搞一个单独的文件来描述硬件不是多此一举吗”我理解这种感受但设备树带来的好处是实实在在的。设备树的核心思想是把硬件描述和驱动代码分离。同一份驱动代码可以支持不同板子上的同一个芯片只要设备树里描述的寄存器地址、中断号、时钟源不同驱动就能自动适配。这解决了Linux内核里长期存在的“板级文件泛滥”问题——以前每支持一块新板子就要往内核里加一堆C文件内核源码树越来越臃肿。设备树把这些硬件差异从C代码里抽出来放到一种结构化的文本格式里由引导程序在启动时传给内核。写设备树节点的时候有几个细节容易出错。一个是寄存器地址的表示方法设备树里的reg属性通常是“地址长度”的成对出现但地址和长度的位宽由父节点的#address-cells和#size-cells决定如果这两个值没设对解析出来的地址就是错的。另一个是中断号的映射设备树里的中断号是“中断控制器引脚号”的组合不是物理中断号需要经过中断控制器的映射才能用。这些细节在第一次写的时候很容易搞混我的建议是找一个同类型的参考板把它的设备树文件拿来对照着改比从头写要快得多。3.3 并发与竞态驱动里的“交通规则”驱动代码和应用程序代码有一个本质区别应用程序通常是一个线程从头跑到尾而驱动代码可能同时被多个进程调用还可能被中断处理程序打断。这就引入了并发和竞态的问题。如果你写的驱动没有处理好并发轻则数据错乱重则系统崩溃而且这种崩溃往往很难复现因为它的触发依赖于特定的时序。处理并发的基本手段是加锁。Linux内核提供了多种锁机制每种适用于不同的场景。mutex是互斥锁适用于可能睡眠的上下文比如进程上下文里的文件操作。spinlock是自旋锁适用于不能睡眠的上下文比如中断处理程序。rwlock是读写锁允许多个读者同时访问但写者独占。rcu是读-拷贝-更新机制适用于读多写少的场景性能很好但使用起来比较复杂。选择哪种锁取决于你的数据被访问的模式。如果临界区很短只是改几个变量用spinlock就够了。如果临界区里需要调用可能睡眠的函数比如copy_to_user那就必须用mutex。如果数据主要是被读很少被写rcu或者rwlock可能更合适。我个人的经验是宁可保守一点用更严格的锁也不要为了追求性能而引入难以调试的竞态问题。性能优化可以后面再做但数据一致性一旦出问题排查成本非常高。提示在持有自旋锁的时候一定要关闭当前CPU的中断否则如果中断处理程序也去抢同一把锁就会导致死锁。Linux内核提供了spin_lock_irqsave和spin_unlock_irqrestore这对宏它们会在加锁的同时保存和恢复中断状态用起来更安全。4. 实操过程与核心环节实现4.1 从一个最简单的字符设备驱动开始理论讲再多不如动手写一个。这一节我带你走一遍最简单的字符设备驱动的完整流程。选择字符设备作为起点是因为它的接口最简单不涉及复杂的子系统框架能让你把注意力集中在驱动的基本结构上。第一步是定义文件操作集合。字符设备驱动需要实现一组file_operations结构体里的回调函数最核心的是open、read、write和release。open在用户空间调用open()打开设备文件时被调用通常用来做初始化或者引用计数。read和write分别处理用户空间的读和写请求你需要在这两个函数里实现数据在内核空间和用户空间之间的拷贝。release在设备文件被关闭时调用用来做清理工作。第二步是注册设备号。每个字符设备都有一个主设备号和次设备号主设备号用来标识驱动次设备号用来标识具体的设备实例。你可以用register_chrdev_region静态指定设备号也可以用alloc_chrdev_region让内核动态分配。动态分配的好处是不会和已有的设备号冲突但你需要通过/proc/devices或者udev来获取实际分配到的设备号。第三步是创建设备节点。设备节点是用户空间访问驱动的入口通常位于/dev目录下。你可以用mknod命令手动创建也可以依赖udev在驱动注册时自动创建。手动创建的好处是可控适合调试阶段自动创建的好处是方便适合产品环境。下面是一个最简字符设备驱动的代码骨架你可以直接拿去改#include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/uaccess.h #define DEVICE_NAME mychardev #define BUF_SIZE 1024 static dev_t dev_num; static struct cdev my_cdev; static char kernel_buf[BUF_SIZE]; static int buf_len 0; static int my_open(struct inode *inode, struct file *file) { printk(KERN_INFO mychardev: opened\n); return 0; } static ssize_t my_read(struct file *file, char __user *buf, size_t count, loff_t *offset) { int bytes_to_read; if (*offset buf_len) return 0; bytes_to_read min(count, (size_t)(buf_len - *offset)); if (copy_to_user(buf, kernel_buf *offset, bytes_to_read)) return -EFAULT; *offset bytes_to_read; return bytes_to_read; } static ssize_t my_write(struct file *file, const char __user *buf, size_t count, loff_t *offset) { int bytes_to_write; if (*offset BUF_SIZE) return -ENOSPC; bytes_to_write min(count, (size_t)(BUF_SIZE - *offset)); if (copy_from_user(kernel_buf *offset, buf, bytes_to_write)) return -EFAULT; *offset bytes_to_write; if (*offset buf_len) buf_len *offset; return bytes_to_write; } static int my_release(struct inode *inode, struct file *file) { printk(KERN_INFO mychardev: released\n); return 0; } static struct file_operations my_fops { .owner THIS_MODULE, .open my_open, .read my_read, .write my_write, .release my_release, }; static int __init my_init(void) { int ret; ret alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); if (ret 0) { printk(KERN_ERR mychardev: failed to alloc chrdev region\n); return ret; } cdev_init(my_cdev, my_fops); my_cdev.owner THIS_MODULE; ret cdev_add(my_cdev, dev_num, 1); if (ret 0) { unregister_chrdev_region(dev_num, 1); printk(KERN_ERR mychardev: failed to add cdev\n); return ret; } printk(KERN_INFO mychardev: registered with major %d, minor %d\n, MAJOR(dev_num), MINOR(dev_num)); return 0; } static void __exit my_exit(void) { cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); printk(KERN_INFO mychardev: unregistered\n); } module_init(my_init); module_exit(my_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple character device driver);这段代码虽然简单但它包含了字符设备驱动的所有关键要素设备号分配、cdev注册、文件操作实现、模块加载和卸载。你可以把它编译成.ko文件用insmod加载然后用mknod创建/dev/mychardev节点最后用echo和cat测试读写功能。4.2 编译和加载从源码到运行写好的驱动代码需要编译成内核模块才能加载。编译内核模块需要一个Makefile它告诉编译系统去哪里找内核头文件以及怎么把源码编译成.ko文件。下面是一个典型的Makefileobj-m mychardev.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的关键是-C $(KERNEL_DIR)它让make先切换到内核源码目录利用内核的构建系统来编译模块。M$(PWD)告诉内核构建系统外部模块的源码在当前目录。如果你是为嵌入式目标板编译需要把KERNEL_DIR指向目标板的内核源码目录并且用交叉编译工具链来执行make。编译成功后你会得到一个mychardev.ko文件。加载模块用insmod mychardev.ko卸载用rmmod mychardev。加载之后用dmesg查看内核日志应该能看到驱动打印的注册信息。如果加载失败dmesg里通常会有错误原因比如“Unknown symbol”表示有未定义的符号“Invalid module format”表示模块格式和当前内核不匹配。注意在开发阶段频繁加载和卸载模块是常态。但有些驱动在卸载时没有正确释放资源导致第二次加载失败。所以写驱动的时候一定要确保module_exit函数里释放了所有在module_init里申请的资源包括设备号、内存、中断、时钟等。我习惯在开发时反复加载卸载十几次确认没有资源泄漏再进入下一步。4.3 用户空间测试验证驱动的基本功能驱动加载成功只是第一步还需要验证它能不能正确地处理用户空间的请求。对于字符设备最简单的测试方法是用echo和cat命令。假设你已经用mknod /dev/mychardev c major 0创建了设备节点那么echo hello driver /dev/mychardev cat /dev/mychardev如果驱动实现正确第二条命令应该输出hello driver。如果输出为空或者报错就需要检查read和write函数的实现。常见的错误包括copy_to_user和copy_from_user的返回值没有检查导致拷贝失败时没有返回错误码offset没有正确更新导致重复读取同一段数据缓冲区边界没有检查导致越界访问。更复杂的测试可以写一个小的用户空间程序用open、read、write、close系统调用来操作设备文件。这样可以更灵活地控制测试用例比如测试并发访问、测试大块数据的读写、测试错误处理路径。我通常会用Python写一个快速的测试脚本因为Python的os模块直接封装了系统调用写起来很快import os fd os.open(/dev/mychardev, os.O_RDWR) os.write(fd, btest data) os.lseek(fd, 0, os.SEEK_SET) data os.read(fd, 1024) print(read back:, data) os.close(fd)这个脚本打开设备文件写入一段数据然后把文件偏移量重置到开头再读回来。如果一切正常你会看到read back: btest data。如果读回来的是空或者乱码就需要回到驱动代码里找原因。5. 常见问题与排查技巧实录5.1 驱动加载失败从日志里找线索驱动加载失败是最常见的问题但也是最容易排查的因为内核会给出明确的错误信息。关键是要知道去哪里看这些信息。dmesg命令会打印内核环形缓冲区里的所有消息包括驱动加载时的错误。如果你用的是insmod它本身也会在终端输出一些错误信息但不如dmesg详细。下面是一个常见错误和对应原因的速查表错误信息可能原因排查方向Unknown symbol引用了未导出的内核符号检查是否包含了正确的头文件或者依赖的模块是否已加载Invalid module format模块编译时使用的内核版本与当前运行的内核不匹配确认KERNEL_DIR指向的内核源码版本和uname -r一致Operation not permitted没有加载模块的权限确认是否以root权限执行或者检查内核的模块加载策略Device or resource busy设备号已被占用用cat /proc/devices查看已注册的设备号换一个主设备号No such device设备树节点没有匹配到驱动检查设备树的compatible属性和驱动的of_match_table是否一致排查驱动加载问题时我习惯从最简单的地方开始先确认模块编译时用的内核版本和运行的内核版本一致再确认依赖的模块都已经加载最后才去检查代码逻辑。很多时候问题就出在版本不匹配上而不是代码写错了。5.2 读写异常数据不对或者程序卡死读写异常比加载失败更难排查因为驱动可能已经加载成功了但功能不正常。常见的表现包括读回来的数据是乱码、写入的数据丢失、程序在read或write时卡死不动。数据乱码通常和copy_to_user/copy_from_user的使用有关。这两个函数在拷贝失败时会返回未拷贝的字节数如果驱动没有检查返回值就会把错误当成成功导致用户空间拿到无效数据。另一个常见原因是缓冲区没有初始化内核分配的内存默认是随机的如果你没有清零就直接读读回来的就是垃圾数据。程序卡死通常和锁或者等待队列有关。如果驱动在持有锁的时候睡眠或者等待一个永远不会发生的条件用户空间的进程就会一直阻塞在系统调用里。排查这种问题可以用ps命令查看进程状态如果状态是D不可中断睡眠说明进程卡在内核里了。这时候可以用echo w /proc/sysrq-trigger触发内核打印所有阻塞任务的调用栈从调用栈里能看到驱动卡在哪个函数。提示在驱动里使用等待队列时一定要用wait_event_interruptible而不是wait_event前者可以被信号打断后者会让进程进入不可中断睡眠连kill -9都杀不掉。如果你的驱动在等待队列上卡死了唯一的办法是重启系统。5.3 并发问题最难复现的“幽灵bug”并发问题是驱动开发里最头疼的一类问题因为它的触发依赖于特定的时序往往在测试环境里跑几百次都不出问题一到生产环境就偶发崩溃。而且崩溃的现象可能千奇百怪有时候是数据错乱有时候是内核恐慌有时候是系统直接挂起。排查并发问题的第一步是确认临界区。哪些数据会被多个执行流同时访问哪些操作必须原子完成把这些问题想清楚才能决定在哪里加锁、加什么锁。我通常会在代码里用注释标出每个临界区写明保护的是什么数据、用的是哪把锁。这样在review代码的时候一眼就能看出有没有遗漏。第二步是用工具辅助排查。Linux内核提供了一些运行时检查工具比如KASAN内核地址消毒器可以检测越界访问和释放后使用lockdep可以检测锁的使用是否正确比如死锁、锁顺序不一致。这些工具需要在编译内核时开启对应的配置选项会带来一定的性能开销但在调试阶段非常值得。第三步是压力测试。并发问题只有在高并发场景下才容易暴露所以测试的时候要尽量模拟多进程、多线程同时访问驱动的情况。我通常会用stress-ng或者自己写一个多线程的测试程序开几十个线程反复读写设备跑上几个小时看能不能复现问题。5.4 性能问题驱动成了瓶颈有时候驱动功能正常但性能不达标比如吞吐量上不去、延迟太高、CPU占用率过高。这类问题的排查思路和功能问题不同需要从数据流和资源使用的角度去分析。如果吞吐量上不去首先要看是不是拷贝次数太多。用户空间和内核空间之间的数据拷贝是有开销的如果每次读写只拷贝几个字节大部分时间都花在系统调用的上下文切换上了。优化方向是增大每次传输的数据量或者使用mmap把内核缓冲区映射到用户空间避免拷贝。如果延迟太高要看是不是中断处理太慢。中断处理程序应该尽可能短把耗时的操作放到下半部去执行。Linux内核提供了多种下半部机制比如tasklet、工作队列、线程化中断。选择哪种取决于你的具体需求tasklet在中断上下文执行不能睡眠工作队列在进程上下文执行可以睡眠线程化中断把中断处理程序变成内核线程优先级可调。如果CPU占用率过高要看是不是轮询太多。有些驱动为了简化设计会用轮询的方式等待硬件状态变化这会白白消耗CPU。更好的做法是用中断让硬件在状态变化时主动通知CPU。如果硬件不支持中断至少要在轮询之间加入适当的延时比如usleep_range让出CPU给其他任务。6. 从驱动开发延伸出去的几个方向写完一个能跑的驱动只是起点真正的驱动开发还有很多值得深入的方向。比如电源管理现代嵌入式设备对功耗非常敏感驱动需要支持运行时电源管理在设备空闲时自动进入低功耗状态。这涉及到runtime PM框架的使用需要实现runtime_suspend和runtime_resume回调并且处理好和系统休眠的关系。再比如设备树覆盖有些硬件配置在启动时不确定需要在运行时动态加载。设备树覆盖允许你在系统启动后修改设备树添加或者修改节点然后触发驱动的重新匹配。这在原型开发阶段特别有用因为你不需要每次改硬件描述都重新编译内核和设备树。还有用户空间驱动不是所有驱动都必须写在内核里。有些场景下用UIO或者VFIO框架把设备暴露给用户空间由用户空间的程序直接操作寄存器可以避免内核驱动的复杂性和调试难度。这种方案适合那些对性能要求极高、或者硬件行为非常特殊的设备。《驱动之路》这个系列我会沿着这些方向逐步展开。每一篇都会尽量保持“手记”的风格有原理、有代码、有踩坑记录、有调试技巧。我希望它不仅能帮你学会写驱动更能帮你建立一套面对未知硬件和未知问题时的思考方式。毕竟驱动开发的核心能力不是记住多少寄存器定义而是知道当硬件不听话的时候从哪里开始怀疑用什么工具去验证以及怎么在纷繁复杂的现象里找到那个真正的根因。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →