尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

LDD3 深度解读:Linux 设备驱动开发入门与内核模块实操指南

LDD3 深度解读:Linux 设备驱动开发入门与内核模块实操指南 1. 为什么一本二十年前的驱动开发书至今还在被反复翻出来如果你在嵌入式或者内核开发圈子里待过一阵子大概率会听到有人提到这本书——《LINUX设备驱动程序》第三版。圈内人一般直接叫它LDD3全称是 Linux Device Drivers, 3rd Edition。它最早由 OReilly 出版三位作者 Jonathan Corbet、Alessandro Rubini 和 Greg Kroah-Hartman 都是内核社区里说话有分量的人其中 Greg KH 至今仍是稳定版内核的主要维护者之一。这本书讲的事情其实很聚焦怎么在 Linux 内核里写设备驱动。字符设备、块设备、网络接口、并发控制、中断处理、内存映射、PCI 总线、USB 驱动基本上一个驱动工程师日常要碰的东西它都覆盖了。它对应的内核版本是 2.6.x这个版本号在今天看来确实有点年头但奇怪的是直到现在还有大量的人在找它的中英文版高清电子书。原因不复杂。内核的驱动框架虽然一直在演进但核心思想的变化远比 API 的变化慢。file_operations 这套接口、字符设备的主次设备号机制、并发与竞态的解决思路、中断上下半部的划分逻辑这些东西的内核哲学从 2.6 到今天的 6.x 并没有被推翻只是接口细节在调整。所以 LDD3 更像是一本讲“为什么这么设计”的书而不是一本查 API 的手册。你拿它入门建立的是对驱动模型的整体认知这个认知不会因为内核版本更新就作废。这篇文章我打算围绕这本书本身把它的内容结构、阅读方法、配套实操环境怎么搭、代码怎么跑起来、以及我在实际工作中踩过的坑系统地聊一遍。适合刚接触嵌入式 Linux 驱动的同学也适合做了几年应用层想往内核方向转的开发者。哪怕你只是想搞清楚“设备驱动到底是个什么东西”看完应该也能有个清晰的轮廓。2. 这本书到底讲了什么内容结构与知识地图拆解2.1 从“设备驱动是什么”到“怎么写一个能跑的驱动”LDD3 的章节编排是有明显递进逻辑的不是随便堆知识点。开篇几章先把概念铺清楚什么是设备驱动、内核模块怎么加载卸载、字符设备的基本框架长什么样。然后逐步往深处走进入并发控制、中断、时间管理这些内核编程的硬骨头最后落到具体总线类型PCI、USB和实际设备类别TTY、块设备、网络设备上。我把它的知识地图大致整理成这么几块知识模块对应章节主题核心解决的问题基础框架设备驱动简介、内核模块、字符设备驱动怎么被内核加载、怎么和用户空间交互内核编程基础并发与竞态、中断处理、时间与延迟多核环境下如何保证数据安全、如何响应硬件事件内存与硬件交互内存分配、mmap、DMA、硬件寄存器访问驱动如何管理内存、如何直接操作硬件总线与设备类型PCI、USB、TTY、块设备、网络设备具体总线上的设备如何枚举和驱动这个结构的好处是你不需要一上来就懂 PCI 或者 USB先把字符设备写明白就能理解驱动的基本骨架。等骨架清楚了再往具体总线上套学习曲线是平滑的。2.2 字符设备是全书的主线别跳过很多人看这本书的时候心急觉得字符设备太简单想直接跳到 USB 或者网络设备。我的建议是千万别跳。字符设备这一章第三章是整本书的地基它把file_operations结构体、open/read/write/ioctl这套用户空间到内核空间的调用链路讲透了。你后面看块设备、看网络设备本质上都是这套模型的不同变体。举个具体的例子。字符设备里你会学到register_chrdev和cdev两套注册方式前者是老接口后者是 2.6 之后推荐的方式。书里会解释为什么cdev更好——因为它把设备号和操作函数的绑定关系做得更清晰支持动态分配设备号避免了老接口里主设备号冲突的问题。这种“为什么换接口”的解释才是这本书真正值钱的地方API 你查文档就有但设计动机只有这种书会讲。2.3 并发控制这一章是区分新手和老手的分水岭如果让我挑这本书里最重要的一章我会选第五章“并发与竞态”。内核编程和应用层编程最大的区别就在这里应用层你写个多线程加个锁基本就完事了内核里你要面对的是多核 CPU、中断上下文、软中断、抢占式调度同时作用下的竞态问题。书里把信号量、自旋锁、读写锁、原子操作、完成量这些同步原语挨个讲了一遍而且明确告诉你什么场景该用哪个。比如自旋锁不能睡眠所以中断处理函数里只能用自旋锁信号量可以睡眠所以进程上下文里可以用。这种“场景决定选型”的判断力是你在实际写驱动时最需要的东西。我见过太多新手驱动功能能跑通但一上多核压力测试就出问题十有八九是并发控制没做对。这一章值得反复读读到你能条件反射地判断“这段代码在中断里跑还是在进程里跑”为止。3. 阅读这本书的正确姿势别当字典要当教材3.1 第一遍通读第二遍精读第三遍当参考这本书的厚度不小英文版六百多页中文版也差不多。我的经验是分三遍读第一遍快速通读不求每行代码都看懂目标是建立整体框架。知道驱动分几类、内核模块的生命周期是什么、用户空间怎么和驱动通信。这一遍大概花一周每天两三个小时。第二遍精读核心章节也就是字符设备、并发控制、中断处理、内存分配这四块。这一遍要动手敲代码把书里的示例模块编译加载跑一遍。遇到看不懂的 API去查当前内核版本的文档对比书里的写法有什么变化。第三遍就是当参考书用了。实际写驱动遇到问题翻到对应章节看看思路。这时候你不需要从头读直接定位到相关小节就行。3.2 中英文版对照读英文版看术语中文版看逻辑中英文版各有优势。英文版的好处是术语准确内核社区的原文档、邮件列表、代码注释都是英文的你读英文版能建立起术语的对应关系。比如 “race condition” 对应“竞态”“spinlock”对应“自旋锁”这些对应关系在你查英文资料的时候很有用。中文版的好处是读起来快尤其是并发控制、内存管理这种逻辑复杂的章节中文表述能让你更快抓住作者的推理链条。我的做法是概念和原理读中文版代码和 API 读英文版。这样既保证了理解效率又保证了术语准确。3.3 配合内核源码读效果翻倍光读书是不够的你得配合内核源码看。书里讲file_operations结构体你就去内核源码里找include/linux/fs.h看看这个结构体在当前版本里长什么样多了哪些成员少了哪些成员。书里讲cdev你就去fs/char_dev.c里看cdev_add的实现。这种对照阅读能让你明白一件事书里的代码是某个时间点的快照而内核是活的。你会看到 API 怎么演进、哪些接口被废弃、哪些新接口被引入。这种“活的”认知比死记书里的代码有用得多。4. 把书里的代码跑起来实操环境搭建全流程4.1 为什么建议用虚拟机而不是物理机跑驱动代码是有风险的一个空指针解引用就可能让整个系统崩溃。用物理机跑崩一次就得重启运气不好还会丢数据。所以强烈建议用虚拟机。虚拟机的选择上VirtualBox 和 VMware 都可以我个人习惯用 VirtualBox免费而且够用。分配资源的时候注意几点内存至少给 2GB因为编译内核模块和加载模块都需要内存硬盘至少 20GB因为你要装内核头文件、编译工具链空间小了很快就不够用。提示虚拟机里跑驱动模块崩溃了直接重启虚拟机就行不会影响宿主机。这是最省心的做法。4.2 发行版选择Ubuntu LTS 是最省事的起点发行版我推荐 Ubuntu 的 LTS 版本比如 20.04 或者 22.04。原因很简单内核头文件的安装最方便社区资料最多遇到问题容易搜到答案。装好系统之后第一件事是安装编译工具链和内核头文件。命令如下sudo apt update sudo apt install build-essential linux-headers-$(uname -r)build-essential提供了 gcc、make 这些基础工具linux-headers-$(uname -r)会自动匹配你当前运行的内核版本安装对应的头文件。这两步做完你就有编译内核模块的基本环境了。4.3 验证环境写一个最简单的 Hello World 模块环境搭好之后别急着看书的第三章先写一个最小的内核模块验证环境是否正常。创建一个目录比如~/ldd3-lab然后在里面建两个文件。第一个是hello.c#include linux/init.h #include linux/module.h #include linux/kernel.h MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A minimal kernel module); static int __init hello_init(void) { printk(KERN_INFO Hello, kernel world!\n); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO Goodbye, kernel world!\n); } module_init(hello_init); module_exit(hello_exit);第二个是Makefileobj-m hello.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean然后在终端里执行make sudo insmod hello.ko dmesg | tail如果dmesg里能看到 “Hello, kernel world!”说明环境没问题。卸载模块用sudo rmmod hello再看dmesg应该能看到 “Goodbye, kernel world!”。这个流程看起来简单但它是你后面所有实验的基础。先确保这个能跑通再去看书里的复杂示例否则你会分不清是环境问题还是代码问题。4.4 书里的代码在新内核上编译不过怎么办这是必然会遇到的问题。LDD3 对应的是 2.6.x 内核你现在的系统大概率是 5.x 或者 6.xAPI 肯定有变化。常见的编译错误和解决办法我整理成了一张表报错信息原因解决办法implicit declaration of function register_chrdev老接口在新内核里可能被标记为废弃改用alloc_chrdev_regioncdev_addunknown field ioctl in file_operations新内核用unlocked_ioctl替代了ioctl把函数名和结构体成员都改成unlocked_ioctltoo few arguments to function proc_createproc_create的参数在新内核里增加了查当前内核文档补上缺失的参数struct task_struct has no member named ...内核内部结构体成员变了查当前内核源码找到替代的成员或接口遇到编译错误不要慌先看报错信息再去查当前内核的文档或者源码。这个过程本身就是学习你会逐渐理解内核 API 是怎么演进的。5. 核心章节的实操要点与避坑经验5.1 字符设备驱动从注册到读写的完整链路字符设备是全书最核心的实操内容。我以书里的scull设备为例把关键步骤拆一遍。第一步是申请设备号。书里用的是register_chrdev但新内核推荐用alloc_chrdev_regiondev_t dev; int ret alloc_chrdev_region(dev, 0, 1, mydev); if (ret 0) { printk(KERN_ERR Failed to allocate device number\n); return ret; }alloc_chrdev_region会自动分配一个可用的主设备号避免了手动指定可能带来的冲突。这是比老接口更稳妥的做法。第二步是初始化并添加 cdevstruct cdev *my_cdev cdev_alloc(); cdev_init(my_cdev, my_fops); my_cdev-owner THIS_MODULE; ret cdev_add(my_cdev, dev, 1);cdev_init把file_operations和 cdev 绑定起来cdev_add把设备注册到内核。这两步做完用户空间就能通过设备文件访问你的驱动了。第三步是创建设备节点。老办法是用mknod手动创建新办法是用class_create和device_create自动创建struct class *my_class class_create(THIS_MODULE, myclass); device_create(my_class, NULL, dev, NULL, mydev);自动创建的好处是设备号变了也不用改脚本/dev/mydev始终指向正确的设备。注意class_create在新内核里的参数从三个变成了两个第一个参数THIS_MODULE被去掉了。这个变化在 6.x 内核里很常见编译报错的时候留意一下。5.2 并发控制自旋锁和信号量到底怎么选这是我在实际工作中被问得最多的问题。书里讲得很清楚但新手还是容易搞混。我总结了一个简单的判断标准能在中断上下文里用的只有自旋锁和原子操作。因为中断处理函数不能睡眠而信号量、互斥锁这些会导致睡眠的原语在中断里用会直接崩。进程上下文里优先用信号量或互斥锁。因为自旋锁会忙等浪费 CPU只有在临界区极短比如几行代码的时候才用自旋锁。读写场景用读写锁。如果多个读者可以并发访问只有写者需要互斥读写锁比普通自旋锁效率高。书里有一个例子我印象很深一个驱动在read和write里都要访问共享缓冲区作者用了信号量来保护。为什么不用自旋锁因为read和write都是进程上下文可以睡眠而且缓冲区操作可能涉及内存分配内存分配本身就可能睡眠。这种情况下用自旋锁就是错的。5.3 中断处理上半部和下半部怎么划分中断处理是驱动开发里另一个容易出问题的地方。书里把中断处理分成上半部top half和下半部bottom half上半部就是中断处理函数本身下半部是延后执行的部分。划分原则很简单上半部只做最紧急、最快能做完的事比如读取硬件寄存器、清除中断标志耗时的处理放到下半部比如数据处理、内存拷贝、唤醒等待队列。下半部的实现方式书里讲了 tasklet、工作队列、软中断几种。我的经验是能用工作队列就用工作队列因为它运行在进程上下文可以睡眠用起来最灵活。tasklet 运行在软中断上下文不能睡眠限制比较多。软中断一般不用自己写内核已经用得很充分了。提示中断处理函数里绝对不能调用可能睡眠的函数比如kmalloc带GFP_KERNEL标志、copy_to_user、mutex_lock等。这些函数在中断上下文里调用会直接导致内核崩溃。5.4 内存分配kmalloc 和 vmalloc 的区别书里讲内存分配的时候重点区分了kmalloc和vmalloc。简单说kmalloc分配的内存在物理上是连续的适合 DMA 操作但分配大块内存容易失败。vmalloc分配的内存在虚拟地址上连续物理上不一定连续适合分配大块内存但不能用于 DMA。选择的时候看你的用途如果内存要给硬件 DMA 用必须用kmalloc或者专门的 DMA 分配接口如果只是软件内部使用大块内存用vmalloc更稳妥。还有一个细节是GFP_KERNEL和GFP_ATOMIC的区别。GFP_KERNEL可以睡眠用在进程上下文GFP_ATOMIC不能睡眠用在中断上下文或者持有自旋锁的时候。用错了标志轻则分配失败重则内核崩溃。6. 常见问题与排查技巧实录6.1 模块加载失败insmod 报错怎么查insmod失败是最常见的问题报错信息通常很简短比如 “Invalid parameters” 或者 “Unknown symbol”。排查思路是这样的先看dmesg的输出内核会把详细的错误信息打到内核日志里。如果是 “Unknown symbol”说明你的模块引用了一个没有被导出的内核符号可能是你调用了某个没有EXPORT_SYMBOL的函数。如果是 “Invalid parameters”检查一下模块参数的定义和传递方式。还有一种情况是模块版本不匹配。如果你的模块是用一个内核版本编译的却在另一个版本上加载会报 “version magic” 错误。解决办法是用uname -r确认当前内核版本然后用对应的头文件重新编译。6.2 设备文件打不开权限和设备号问题驱动加载成功了但/dev下的设备文件打不开通常是两个原因权限不够或者设备号不对。权限问题好办sudo chmod 666 /dev/mydev临时解决正式的做法是在 udev 规则里设置权限。设备号不对的话用cat /proc/devices查看你的驱动注册的主设备号然后确认/dev下的设备文件的主设备号是否匹配。如果是用device_create自动创建的设备节点一般不会有设备号问题。手动mknod创建的才容易出错。6.3 内核崩溃oops 信息怎么读内核崩溃的时候会打印一大段 oops 信息新手看了容易懵。其实关键信息就几行EIP 或者 RIP出错的指令地址配合objdump可以定位到具体哪行代码。Call Trace函数调用栈能看出崩溃是从哪个函数一路调过来的。CR2 或者 fault address出错的地址如果是空指针解引用这里通常是 0 或者一个很小的值。我一般的排查顺序是先看 Call Trace 找到出错的函数再看 EIP 定位到具体指令最后结合代码分析是什么原因。空指针解引用、数组越界、使用了未初始化的变量这三个是最常见的原因。6.4 常见问题速查表问题现象可能原因排查方法insmod 报 Unknown symbol引用了未导出的内核符号检查函数是否有 EXPORT_SYMBOLinsmod 报 version magic模块和内核版本不匹配用当前内核头文件重新编译设备文件打不开权限不足或设备号错误检查 /proc/devices 和文件权限内核 oops空指针、越界、竞态读 Call Trace 和 EIP结合代码分析模块卸载不掉模块引用计数不为零检查是否有进程正在使用设备并发测试崩溃竞态条件检查共享数据的保护是否到位7. 从 LDD3 到现代内核这本书的局限和补充学习路径7.1 书里没讲或者讲得少的东西LDD3 毕竟是 2.6 时代的书有些现代驱动开发的重要内容它没有覆盖或者讲得比较浅。设备树Device Tree是最大的缺口。在 ARM 嵌入式领域设备树已经是描述硬件的主流方式驱动通过设备树获取硬件资源寄存器地址、中断号、时钟等。书里完全没有涉及这块你需要另外找资料补。sysfs 和 debugfs书里提了一些但现代驱动里这两个文件系统的使用频率更高了。sysfs 用于导出设备属性debugfs 用于调试信息实际项目中几乎离不开。电源管理也是书里讲得比较少的部分。现代移动设备和嵌入式设备对功耗很敏感驱动的 suspend/resume 回调、runtime PM 框架都是必须掌握的。7.2 补充学习资源怎么选补设备树可以看内核源码里的Documentation/devicetree/bindings/目录里面有各种设备的设备树绑定文档。补 sysfs 和 debugfs可以看内核源码里其他驱动的实现照葫芦画瓢。电源管理这块内核文档Documentation/power/目录下有比较系统的说明。另外实际项目里多看几个成熟驱动的代码比看文档学得快。7.3 我的建议把 LDD3 当起点别当终点LDD3 最大的价值是帮你建立驱动开发的思维模型驱动是内核和硬件之间的翻译层它要处理并发、要响应中断、要管理内存、要暴露接口给用户空间。这个思维模型不会过时。但具体的 API、具体的总线框架、具体的调试工具你需要跟着内核版本更新。我的做法是遇到问题先想 LDD3 里有没有讲过类似的场景有的话看它的思路然后去查当前内核的文档和源码找到对应的现代实现。这样既不会迷失在 API 的海洋里也不会被老代码带偏。8. 一些实操中攒下来的零碎经验编译内核模块的时候Makefile里的KDIR路径一定要指向当前运行内核的 build 目录。如果你用的是apt安装的头文件路径通常是/lib/modules/$(uname -r)/build。如果你自己编译过内核路径可能不一样用ls -l /lib/modules/$(uname -r)/build确认一下它指向哪里。调试驱动的时候printk是最常用的工具但它的日志级别要注意。KERN_INFO级别的信息默认可能不会打印到控制台但dmesg里能看到。如果你希望信息直接打到控制台用KERN_EMERG或者KERN_ALERT但这两个级别一般只在严重错误时用。还有一个小技巧用printk的时候加上函数名和行号比如printk(KERN_INFO %s:%d\n, __func__, __LINE__)这样排查问题的时候能快速定位到是哪一行打印的。这个习惯在调试复杂驱动的时候特别有用。最后说一个我踩过的坑。有一次我写了一个字符设备驱动read函数里用了copy_to_user测试的时候一直返回-EFAULT。查了半天才发现用户空间的缓冲区指针没有做有效性检查传进来的是一个非法地址。后来在read函数开头加了if (!access_ok(VERIFY_WRITE, buf, count)) return -EFAULT;就好了。这个检查书里其实提过但我当时没在意结果浪费了一个下午。所以书里的注意事项真的不是废话每一条背后都是有人踩过坑的。
返回列表