十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux设备驱动开发实战:4.0内核源码包从解压到编译全解析

Linux设备驱动开发实战:4.0内核源码包从解压到编译全解析 简介在嵌入式开发领域Linux设备驱动是连接硬件与操作系统的关键纽带而内核模块的编译与加载则是驱动开发的基础技能。理解字符设备、platform总线、中断处理等核心概念能够帮助开发者快速上手实际项目。内核源码的获取与解压看似简单却常因版本不匹配、文件损坏或编码问题导致项目停滞。本文从通用技术视角出发梳理Linux内核模块的编译流程、设备驱动框架的搭建方法以及常见zip源码包问题的排查技巧结合工程实践给出可复用的解决方案。通过掌握这些基础原理与工具链用法开发者可以更高效地学习经典教材中的驱动示例并将其迁移至更新的内核环境中为深入嵌入式底层开发奠定扎实基础。 拿到《Linux设备驱动开发详解——基于最新的Linux4.0内核》这套源码的时候我第一反应其实是有点纠结的。这本书在驱动开发圈子里算是老面孔了宋宝华老师那版基于2.6内核的经典教材陪伴了整整一代嵌入式工程师后来出的4.0版我身边不少朋友都第一时间入手了。但说实话很多人买了书之后源码包往硬盘里一扔就再也没打开过。不是不想学而是被环境搭建、内核版本不匹配、编译报错这一连串问题给劝退了。所以这篇博文不打算跟你聊这本书里的理论知识那些你自己看书就行。我要做的是另外一件事把这套源码包真正跑起来。完整讲清楚拿到这个zip之后应该做什么、每个目录里到底是什么、哪些代码值得你逐行去读、哪些模块在4.0内核上编译会遇到什么坑、以及怎么把这些驱动代码迁移到更新的内核版本上。这条路我走过多遍踩过的坑不少写出来帮你省点时间。1. 源码包整体设计与内核版本选型思路1.1 为什么这本书选择Linux 4.0内核作为基线在动手折腾源码之前得先搞清楚一个关键问题为什么书里要基于4.0内核来写。Linux内核版本迭代速度极快4.0于2015年4月发布它本身并没有太多革命性的技术突破但它是Linus力排众议把版本号从3.x跳到了4.x的一个重要节点。更重要的是4.0内核在设备模型、regmap框架、gpio子系统、pinctrl子系统等方面已经相当成熟同时又比2.6时代的内核干净得多非常适合用来做驱动开发的教学基线。从学习角度来说4.0内核的驱动API和现在主流的5.x、6.x内核相比核心框架没有翻天覆地的变化。也就是说你在4.0上学到的字符设备驱动框架、platform总线模型、中断处理机制放到新内核上照样能看懂只是个别API的签名变了、有些函数换了名字或者被重命名到了别的头文件里。这比直接从2.6跳到6.x要平滑得多。1.2 源码包目录结构与内容规划我把这套源码解压之后第一件事就是完整梳理了一遍目录结构。整个包的组织方式是按照书的章节来的每一章对应一个或几个独立的驱动模块工程。这种组织方式非常友好你可以单独编译任何一章的代码不需要把整个源码树全部构建一遍。典型的结构大概是这样linux_driver_source/ ├── ch04_hello_module/ # 最简单的模块示例 ├── ch05_memdev/ # 内存设备驱动 ├── ch06_key_irq/ # 按键中断驱动 ├── ch07_globalmem/ # 全局内存设备 ├── ch08_globalfifo/ # 全局FIFO设备 ├── ch09_async_notify/ # 异步通知 ├── ch10_platform_driver/ # platform总线驱动 ├── ch11_linux_driver_ioctl/ # ioctl命令实现 ├── ch12_pci_driver/ # PCI驱动 ├── ch13_usb_driver/ # USB驱动 ├── ch14_network_driver/ # 网络设备驱动 ├── ch15_block_driver/ # 块设备驱动 ├── ch16_spi_driver/ # SPI驱动 ├── ch17_i2c_driver/ # I2C驱动 ├── ch18_led_class/ # LED类设备 ├── ch19_input_subsys/ # 输入子系统 ├── ch20_rtc_driver/ # RTC驱动 └── Makefile # 顶层Makefile每个子目录里通常包含一个或几个.c文件、对应的Makefile有的还有针对开发板的说明文档。这样组织的好处是你可以按图索骥学习哪一章就编译哪一章的代码不需要把整个工程一次搞定。对于初学者来说这种渐进式的推进方式特别重要因为一口气编译十几个驱动模块一旦出了问题根本不知道去哪里排查。1.3 需要准备的基础开发环境在真正开始编译源码之前需要先确认你的开发环境。这套源码的核心是加载驱动模块到内核中运行所以你需要一个完整的Linux开发环境而不是Windows上的虚拟机直接凑合。我的建议是准备一台Ubuntu 16.04或18.04的物理机或者虚拟机内核版本选择4.x系列。如果你手头正好有个4.0的内核环境那是最理想的但即便没有退而求其次用4.4、4.9、4.14这些长期支持版也基本够用大部分模块可以在这些内核版本上编译通过。当然个别模块因为内核API的变化可能会报错后面我会具体讲怎么处理。需要安装的工具如下# 基础编译工具链 sudo apt-get install build-essential sudo apt-get install linux-headers-$(uname -r) sudo apt-get install git sudo apt-get install vim注意linux-headers这个包必须装而且要跟你的内核版本严格对应。这是编译内核模块的核心依赖没有它你连一个hello world模块都编译不了。装完之后可以验证一下ls /lib/modules/$(uname -r)/build如果这个目录存在且里面有内容说明编译环境基本就绪了。2. 源码包解压方法与常见错误排查2.1 正确解压zip源码包的方式这个源码包是zip格式的在Linux下解压zip文件首选unzip命令。很多新手在这一步就会卡住因为如果系统里没装unzip直接解压就会报错。我见过不少人在网上发帖问为什么我解压不了这个文件结果就是unzip没装。安装和使用的完整流程是这样的# 如果系统里没有unzip先安装 sudo apt-get install unzip # 进入源码包所在目录执行解压 unzip Linux设备驱动开发详解——基于最新的Linux4.0内核源码.zip -d linux_driver_source参数-d指定了解压目标目录这样可以把所有源码文件统一放到linux_driver_source这个文件夹里避免文件散落一地。如果你的zip文件名特别长或者含有特殊字符建议先重命名一下比如改成driver.zip省得后面每次敲命令都麻烦。2.2 遇到file is not a zip file错误怎么办这是解压时最常见的报错之一。很多人看到这个提示就懵了以为是源码包损坏了。其实这个错误背后一般有两种可能。第一种可能是文件根本没有下载完整。很多网盘下载工具在下载中断后并不会报错而是留下一个残缺文件。你拿到手之后表面上看起来文件大小差不多但实际内容已经截断了。判断方法很简单# 查看文件大小对比源文件的预期大小 ls -lh Linux设备驱动开发详解——基于最新的Linux4.0内核源码.zip # 用zip命令测试文件完整性 zip -T Linux设备驱动开发详解——基于最新的Linux4.0内核源码.zip如果zip -T这一行返回OK说明文件是完好的。如果返回error或者在某个百分比处卡住不动那基本可以断定是下载不完整重新下载一次就好。第二种可能也是比较隐蔽的这个zip文件里面还嵌套了另一个zip或者编码方式比较特殊。有时候网站会二次打包导致外层文件实际上是某种压缩流或者自解压格式。这时可以用file命令查看文件的真实类型file Linux设备驱动开发详解——基于最新的Linux4.0内核源码.zip正常输出应该是Zip archive data如果显示的是HTML document或者gzip compressed data说明这个文件被改过扩展名或者下载到了错误页面需要重新获取。2.3 解决invalid zip archive: could not find eocd问题这个报错比起上一种更棘手。eocd是End of Central Directory的缩写也就是zip文件的中央目录尾记录。zip文件的读取逻辑是先读文件末尾的eocd然后根据它记录的偏移量去定位每个压缩条目。如果找不到eocd说明这个zip文件的结尾部分缺失了之前说的下载不完整是最常见的原因。但还有一种情况可能不太容易想到你手里的zip是在Windows上压缩的传输到Linux过程中以文本模式传送导致文件尾部的换行符被转换破坏了zip结构的完整性。这在通过FTP传输时经常会发生。解决办法是使用FTP的binary模式重新传输或者直接用scp、rsync这类不会篡改文件内容的工具。如果文件确实损坏且无法重新下载还有一个补救思路很多zip文件在损坏的情况下压缩包中大部分文件其实还是可以抢救出来的。Linux下的zip工具提供了-F修复参数zip -F damaged.zip --out recovered.zip这个命令会尝试修复zip文件的中央目录成功率取决于损坏的程度。如果运气好大部分源码文件都能恢复出来。我遇到过几次类似情况修复后基本能拿到90%以上的文件只有极少数文件确实无法恢复。对于学习用途来说这往往已经够用了。2.4 解压后文件权限问题处理源码解压出来之后还有一件容易被忽略的事文件权限。Windows上压缩的文件在Linux下解压后默认权限可能不是可读可写的尤其是.c文件和Makefile如果权限不对编译的时候会报Permission denied的错误。建议解压完顺手把整个目录的权限统一修正一下chmod -R urwx linux_driver_source这样做虽然粗暴了点但能省掉后面一堆麻烦。特别是当你要在源码目录里新建文件、修改Makefile或者执行编译脚本时没有写权限会非常痛苦。3. 核心字符设备驱动框架拆解与实操解析3.1 从hello模块看内核模块的基本结构这套源码里第4章的hello模块是整个学习的起点。虽然它简单但我建议你千万别跳过因为内核模块的基本骨架就是从这一个模块建立起来的。我直接给你看一个简化版的代码这是宋老师书里例子的变体也是所有内核模块的统一模板#include linux/init.h #include linux/module.h #include linux/kernel.h static int __init hello_init(void) { printk(KERN_INFO Hello, Linux kernel module!\n); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO Goodbye, Linux kernel module!\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这两个宏它们的作用是把hello_init和hello_exit这两个函数注册到内核的模块加载机制中。当你使用insmod命令加载模块时内核会调用module_init指定的函数当你使用rmmod卸载模块时内核会调用module_exit指定的函数。__init和__exit这两个宏涉及到内存优化。标记了__init的函数在初始化完成后它的内存可以被释放掉因为以后不会再被调用了。标记了__exit的函数在模块编译进内核而不是以模块方式加载时会被直接丢弃因为内嵌的驱动不需要卸载函数。这些细节在内核源码的include/linux/init.h中有注释说明闲下来翻一翻挺有意思的。还有一个容易被忽视的点printk函数中的KERN_INFO。这个日志级别很重要它决定了这条消息会显示在哪个终端上。如果你在图形界面的终端里执行insmod可能看不到printk的输出因为内核日志默认不输出到控制台。这时你需要用dmesg命令查看sudo insmod hello.ko dmesg | tail -20这个习惯要养成开发驱动时看内核打印的第一选择永远是dmesg而不是盯着终端窗口。3.2 字符设备驱动的完整框架从设备号到file_operations如果说hello模块只是热身那么第5章的内存设备驱动才是真正进入字符设备驱动的核心。这一章的代码量不大但它涵盖了字符设备驱动的全部核心要素设备号分配、cdev结构体初始化、file_operations操作集、模块加载与卸载时的设备注册与注销。核心的代码逻辑大致是这样的static int __init memdev_init(void) { int result; dev_t devno; /* 分配设备号 */ result alloc_chrdev_region(devno, 0, 1, memdev); if (result 0) return result; /* 动态注册cdev */ cdev_init(memdev_cdev, memdev_fops); memdev_cdev.owner THIS_MODULE; result cdev_add(memdev_cdev, devno, 1); if (result 0) { unregister_chrdev_region(devno, 1); return result; } /* 创建设备节点 */ memdev_class class_create(THIS_MODULE, memdev); device_create(memdev_class, NULL, devno, NULL, memdev); return 0; }这里有几个关键点需要展开说。第一是设备号分配。alloc_chrdev_region是动态分配设备号的函数它会让内核自动选择一个空闲的主设备号和次设备号。动态分配的好处是不会跟系统里已有的设备号冲突但缺点是设备号不固定可能导致每次加载模块后/dev下的设备节点需要重新创建。书里也讲了静态注册的方式用register_chrdev_region指定主设备号这个在生产环境中更可控但你需要自己确保设备号没有被其他驱动占用。第二是cdev结构体。cdev是内核中表示字符设备的核心数据结构它把一个设备号和一个file_operations操作集绑定在一起。cdev_init负责初始化这个结构体cdev_add则正式把它加入到内核的设备管理框架中。注意cdev_add之后的代码需要检查返回值因为如果设备号已经被占用或者参数非法这个调用会失败。第三是设备节点的创建。在旧版本内核中你需要手动用mknod命令在/dev目录下创建设备节点。但在4.0内核上常用的做法是使用class_create和device_create这一对函数内核会自动在/dev目录下生成对应的设备节点。这就是为什么现代Linux发行版上你插入一个USB设备/dev下会自动出现对应的设备文件背后就是这套机制在起作用。3.3 file_operations结构体的实现要点file_operations是字符设备驱动中最核心的数据结构它定义了用户空间程序可以对该设备执行哪些操作。这本书里用了大量篇幅来讲解这个结构体源码中也有完整的实现案例。我挑几个关键的成员函数来解析一下。首先是open函数。很多初学者以为open函数只是简单返回0就行了但实际上open是驱动初始化每此访问的时机。你可以在这里做资源分配、设备状态初始化、权限检查等操作。比如一个全局内存设备open时可以初始化读写指针一个串口设备open时可以配置波特率。书中的memdev驱动在open函数中就做了类似的事情。其次是read和write。这两个函数是字符设备驱动最核心的数据通路。需要注意的一个重要概念是用户空间的read/write系统调用与内核空间的read/write函数指针之间隔着一层copy_to_user/copy_from_user的拷贝。内核空间不能直接访问用户空间的内存地址必须使用这两个函数进行安全的数据拷贝。这不仅是功能上的要求更关系到系统的安全稳定。如果驱动直接访问用户空间指针很可能导致内核崩溃或者被恶意程序利用。看一个典型的read实现片段static ssize_t memdev_read(struct file *filp, char __user *buf, size_t size, loff_t *ppos) { int ret; /* 检查读取长度是否超出有效数据范围 */ if (*ppos MEMDEV_SIZE) return 0; if (size MEMDEV_SIZE - *ppos) size MEMDEV_SIZE - *ppos; /* 将内核缓冲区中的数据拷贝到用户空间 */ ret copy_to_user(buf, memdev_buffer *ppos, size); if (ret ! 0) return -EFAULT; *ppos size; return size; }注意这里对*ppos的处理。ppos是文件读写位置的指针每次read/write之后都要更新它否则应用程序调用read两次只会读到相同的数据。这是驱动开发中一个非常容易踩的坑很多新人在调试时发现数据重复读取就是忘了更新ppos。3.4 并发与竞争处理信号量与自旋锁驱动模块运行在内核空间最大的特点之一就是可能被多个进程同时访问。如果多个进程同时对同一个设备执行open/read/write操作就可能出现数据竞争的问题。这本书在第8章专门讲解并发控制源码中也给出了使用信号量和自旋锁的完整示例。信号量在驱动中的用法其实和用户空间编程非常相似获取信号量、访问共享资源、释放信号量。但需要注意一个区别内核中的信号量操作函数名是down和up对应的还有一组down_interruptible和down_trylock变体。down_interruptible在等待信号量时可以被信号中断返回非零值表示被中断这时驱动应该返回-ERESTARTSYS让系统调用可以被信号打断。这个细节在写驱动程序时很重要因为如果驱动中的函数不响应信号用户空间的进程可能会变得无法用CtrlC终止。自旋锁则是另一种并发控制机制。它适用于临界区代码非常短的情况因为自旋锁在被持有时不会睡眠而是在原地循环等待。如果在持有自旋锁的代码中调用了可能睡眠的函数比如copy_to_user、kmalloc等就会导致内核崩溃。书中的源码对这一点的处理非常谨慎把自旋锁保护的临界区限制在最精简的代码段内。3.5 阻塞型IO与等待队列的实现对于一个实际的设备驱动来说仅仅支持读写还不够还需要处理数据还没准备好时用户来读的情况。比如一个按键设备用户程序调用read想读取按键值但如果此时没有任何按键被按下read应该怎么办是立即返回错误还是阻塞在那里等待按键事件这就是阻塞型IO要解决的问题。书中的globalfifo驱动给出了一个完整的实现核心是使用等待队列wait queue来实现阻塞唤醒机制。基本模式是当用户调用read时如果缓冲区为空就把当前进程加入到等待队列中并进入睡眠状态当数据写入缓冲区时唤醒等待队列中的进程。等待队列的典型使用方式是这样的static ssize_t globalfifo_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { int ret; struct globalfifo_dev *dev filp-private_data; /* 如果缓冲区为空则进入睡眠等待 */ if (mutex_lock_interruptible(dev-mutex)) return -ERESTARTSYS; while (dev-current_len 0) { mutex_unlock(dev-mutex); if (filp-f_flags O_NONBLOCK) return -EAGAIN; ret wait_event_interruptible(dev-r_wait, dev-current_len ! 0); if (ret) return ret; if (mutex_lock_interruptible(dev-mutex)) return -ERESTARTSYS; } /* ... 读取数据 ... */ }注意这里对O_NONBLOCK标志的判断。如果用户在open时指定了O_NONBLOCK标志那么当数据不可用时驱动应该立即返回-EAGAIN而不是进入睡眠。这是非阻塞IO的基本约定也是驱动开发中必须处理的细节。4. 编译驱动模块全流程Makefile分析与实操验证4.1 内核模块的Makefile写法解析源码包里每个驱动模块都附带了自己的Makefile这个Makefile的写法非常有代表性。先看一个典型的例子obj-m : memdev.o KERNELDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KERNELDIR) M$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M$(PWD) clean这个Makefile的核心是调用内核源码树的顶层Makefile来编译模块。关键点在于obj-m变量它告诉内核构建系统把memdev.o编译成一个可加载的内核模块即.ko文件。如果源文件是memdev.c那么obj-m的值就是memdev.o。如果你有多个源文件比如main.c和helper.c编译成一个模块那就要写成obj-m : mydriver.o mydriver-objs : main.o helper.oKERNELDIR指向内核头文件和构建脚本所在的位置通常就是/lib/modules/$(uname -r)/build。这个目录本质上是一个符号链接指向实际的内核源码或头文件目录。如果你换了一个内核版本这个路径也会跟着变化所以使用$(shell uname -r)来动态获取版本号是非常通用的做法。编译时make命令会进入KERNELDIR然后回到当前目录M$(PWD)查找需要编译的模块。最终生成的文件是.koKernel Object这个文件才是可以被insmod加载的真正模块。4.2 Linux 4.0内核下编译的完整实测过程我以书中的memdev驱动为例在Ubuntu 16.04 Linux 4.4内核的环境下实际编译一遍。步骤大致如下# 进入源码目录 cd ch05_memdev # 执行make命令编译 make # 编译成功后生成memdev.ko文件 ls -l memdev.ko # 加载模块 sudo insmod memdev.ko # 查看加载是否成功 lsmod | grep memdev # 查看设备节点是否生成 ls -l /dev/memdev # 卸载模块 sudo rmmod memdev在编译过程中最常见的问题是头文件缺失或者内核版本不匹配导致的API错误。比如在4.4内核上编译书中的代码有极少数地方会因为头文件的改名而报错。#include linux/uaccess.h在新内核中是不同的位置但4.0之后基本都在这里了。遇到这类错误解决思路很简单查找该函数或宏在当前内核版本中的实际定义位置修改include语句即可。另一个编译问题是warning: initialization from incompatible pointer type。这个警告通常是因为你的函数指针类型和file_operations定义的不完全一致。比如read函数的第三个参数在旧内核中是size_t在新内核中是size_t这倒没有变化但有些函数比如ioctl在2.6内核之后从int (*ioctl)变成了long (*unlocked_ioctl)如果你声明的是旧格式编译器就会报警告。这种警告在4.0内核下通常不影响编译通过但最好还是改成新格式避免在更严格的内核版本上直接编译失败。4.3 编写测试程序验证驱动功能驱动写完编译通过、模块成功加载这还不能算完。你得真正编写一个用户空间的测试程序调用open、read、write这些系统调用验证驱动的功能是否正常。对于memdev这个内存设备驱动一个简单的测试程序可以这样写#include stdio.h #include stdlib.h #include string.h #include fcntl.h #include unistd.h int main(void) { int fd; char buf[128]; int len; fd open(/dev/memdev, O_RDWR); if (fd 0) { perror(open); exit(1); } /* 向设备写入数据 */ strcpy(buf, Hello from userspace!); write(fd, buf, strlen(buf) 1); /* 从设备读取数据 */ memset(buf, 0, sizeof(buf)); len pread(fd, buf, sizeof(buf), 0); printf(Read %d bytes: %s\n, len, buf); close(fd); return 0; }这个测试程序验证了最关键的功能设备打开、数据写入、数据读出。如果一切正常你会看到Read 20 bytes: Hello from userspace!之类的输出。如果读取到的数据为空或者乱码那就要回头检查驱动的read/write实现重点看copy_to_user和copy_from_user是否使用正确以及ppos是否被正确更新。一个调试小技巧在驱动的read和write函数里多加点printk打印比如记录写入的长度和ppos值。然后用dmesg观察内核日志。这样做能帮你快速定位问题出在用户空间还是内核空间省去很多瞎猜的功夫。4.4 内核版本升级带来的API变化与代码兼容调整这本书面向的是4.0内核但现在主流发行版已经用上了5.x甚至6.x内核。把书里的驱动代码从4.0迁移到新内核会遇到哪些变化根据我的实际经验主要涉及这么几件事。第一是头文件路径的调整。内核在4.x到5.x的过程中对很多头文件做了重新组织。比如#include linux/uaccess.h在新内核中的路径没变但内容有微调#include linux/slab.h在一段时间内被移到了linux/slab.h但在6.x中又回来了。如果你的代码报file not found用grep -r在内核源码树的include目录里搜一下对应的符号定义在哪个头文件改一下include就行。第二是API签名的变化。比如在5.10内核之后struct file_operations中read和write函数的原型发生了变化size_t和loff_t这些类型在某些架构上不再兼容。还有proc_create函数在5.6内核之后去掉了mode参数导致旧代码会编译失败。第三是设备模型的变化。4.0内核中struct device的字段和现在有不小的差异特别是和电源管理相关的runtime PM接口在5.x后做了大量的扩展。如果你的驱动涉及休眠唤醒等电源管理功能迁移时需要特别注意。关于迁移我个人的建议是如果学习时间充裕尽量创建一台配置了4.x内核的虚拟机来做书上的实验这样能避免被API差异分散注意力。等书里的知识点都掌握了再找一台新内核的机器尝试迁移代码那时候你会发现自己对内核驱动的理解已经上了一个台阶。5. 进阶驱动模块的实操深挖与调试经验5.1 中断处理驱动的实现方法与共享中断号问题书里的按键中断驱动是学习中断处理的经典案例。Linux的中断处理模式分上半部和下半部上半部是快速处理中断的上下文要求执行时间尽可能短下半部则处理剩余的工作可以延后执行。书中对这两种机制的讲解非常清楚源码里也给出了使用tasklet和工作队列的两种实现方式。在实现按键中断时有一个细节特别容易出错按键去抖。机械按键在按下和释放的瞬间会产生抖动如果不做去抖处理一次按键可能触发多次中断导致设备上报错误的按键状态。书中的源码采用了定时器去抖的方案在中断触发后启动一个定时器如果在定时器到期之前又有新的中断到来就重置定时器只有当定时器真正到期时才认为按键状态稳定了。这是一个非常实用的技巧在真实的嵌入式项目中同样适用。共享中断号的问题也值得单独拿出来说。现代硬件中多个设备共享同一个中断号的情况并不少见。如果驱动在申请中断时用了request_irq(irq, handler, IRQF_SHARED, mydev, dev_id)那么在中断处理函数中必须先从dev_id参数中判断是否是自己的设备触发的中断。否则共享同一个中断号的另一个设备触发中断时你的驱动也会被调用如果没有进行判断就可能错误地处理不属于自己的中断。5.2 异步通知机制从poll到信号驱动IO用户程序读取设备数据除了同步阻塞和轮询之外还有一种更高效的机制叫异步通知。它的核心思想是当设备有数据可读时内核主动向用户程序发送一个信号通常是SIGIO用户程序收到信号后在信号处理函数中读取数据不需要一直轮询或阻塞等待。书中的异步通知示例展示了如何在驱动中实现这一机制。关键点有两个第一在fasync函数中调用fasync_helper把需要通知的进程添加到驱动的异步通知队列中第二当有数据到达时调用kill_fasync向队列中的所有进程发送信号。这个机制的代码量不大但概念上有点绕。我建议你按照这个顺序来理解用户程序注册信号处理函数然后通过fcntl(fd, F_SETFL, O_ASYNC)把fd设置为异步通知模式再通过fcntl(fd, F_SETOWN, getpid())设置接收信号的进程。之后当数据到达时内核驱动会调用kill_fasync触发用户程序的信号处理函数。对于网络编程或串口编程经验丰富的读者来说这个机制应该不陌生它和socket编程中的O_ASYNC模式本质上是一样的。5.3 平台设备驱动设备与驱动的分离思想很多人学了字符设备驱动、中断处理之后开始接触platform总线模型时都会有点懵。为什么好好的设备驱动不直接写非要搞个platform_driver和platform_device出来这个问题的答案藏在Linux设备模型的设计哲学中设备与驱动分离。简单来说硬件平台千差万别设备可能挂在不同的地址上使用不同的中断号。如果把设备的具体信息硬编码在驱动代码里那么每换一块开发板驱动代码就得跟着改。platform总线的做法是设备信息放在platform_device中描述驱动逻辑放在platform_driver中实现两者通过匹配机制建立起联系。书中第10章的例子很好地展示了这一过程。platform_driver结构体中定义了probe和remove函数当设备和驱动匹配成功时内核会调用probe函数来初始化设备当设备被移除时调用remove函数来释放资源。这种设计模式现在的大部分子系统I2C、SPI、USB等都在沿用。理解了platform总线再去看其他子系统的驱动代码就会有种豁然开朗的感觉。5.4 使用printk调试与动态调试开关在内核驱动开发中调试工具远不如用户空间编程那么丰富。printk是最基础也是最常用的调试手段。但在真实项目中如果到处都塞满了printk内核日志会被刷爆影响系统性能。一个比较好的实践是给驱动模块的printk加上统一的前缀比如[memdev] 这样在dmesg中可以用grep快速筛选出你的驱动日志。另外可以用pr_debug和pr_info来区分调试信息和普通信息。pr_debug默认不输出需要打开动态调试或设置日志级别才可以看到非常适合放那些详细的调试打印。如果需要打开模块的动态调试可以使用echo module memdev p /sys/kernel/debug/dynamic_debug/control注意这要求内核配置了CONFIG_DYNAMIC_DEBUG选项。发行版的内核通常默认开启了这个功能但嵌入式交叉编译的内核就不一定了。如果发现/sys/kernel/debug目录不存在可能需要先挂载debugfssudo mount -t debugfs none /sys/kernel/debug6. 打不开源码包处理zip文件踩坑实录6.1 网络下载的zip文件总是不完整在我的实际经验中至少有一半的源码包打不开案例根源都是下载不完整。尤其是从一些网盘、论坛附件下载的zip文件因为服务器限制、网速不稳定等原因下载到一半就断了但下载工具并不会明确提示你文件损坏。判断文件是否完整的标准做法是比对校验值。如果源码提供方给出了MD5或者SHA256值下载完后先执行比对md5sum Linux设备驱动开发详解——基于最新的Linux4.0内核源码.zip sha256sum Linux设备驱动开发详解——基于最新的Linux4.0内核源码.zip比对标称值如果差了一位也算不完整。如果提供方没有给校验值那就用zip -T测试完整性这是最直接的验证方式。6.2 zip文件编码问题导致的乱码还有一类问题在中文环境下特别容易出现zip包内的文件名乱码。原因很简单Windows上的zip默认把文件名编码为GBK而Linux下的unzip默认按UTF-8解码。如果你的源码包里有些文件名包含中文解压后就会变成一堆乱码字符。解决这个问题有两个办法。一是用支持编码转换的unzip版本unzip -O gbk Linux设备驱动开发详解——基于最新的Linux4.0内核源码.zip-O参数指定了源编码为GBK。不过这个参数在有些发行版自带的unzip中并不支持会提示unrecognized option。这时候可以用Python的zipfile模块来解压在解压时手动处理编码import zipfile with zipfile.ZipFile(driver.zip) as zf: for info in zf.infolist(): # 重新用gbk解码文件名 filename info.filename.encode(cp437).decode(gbk) zf.extract(info, linux_driver_source)这段代码的关键在于encode(cp437).decode(gbk)因为Python的zipfile在读取文件名时默认按cp437来处理非UTF-8字符所以需要先恢复原始字节再用GBK解码。6.3 源码内嵌压缩包的嵌套解压问题有些情况下你从网上下载的源码包是一个压缩包套压缩包的组合。外层是zip里面可能还嵌套了rar或tar.gz。面对这种嵌套结构最优雅的方式是写个小脚本批量解压。但如果只是学习使用完全可以手动一层层解开。具体做法先用file命令检查内层文件的真实格式然后根据格式选择对应的解压工具。# 查看内层文件类型 file inner_file # 根据类型解压 # 如果是tar.gz tar -zxvf inner_file # 如果是rar unrar x inner_file # 如果是tar.bz2 tar -jxvf inner_file6.4 密码保护的zip源码包另外还要提一下密码问题。有些付费电子书或源码包会设置解压密码这通常是因为作者不想让资源被随意传播。如果书名页或购买页面没有提供密码就需要联系对应的渠道获取。暴力破解zip密码当然是一种思路但我不建议在这上面费时间。一个可靠的zip密码最少也有6位暴力破解的耗时可能长达数小时甚至数天。作为学习者直接把时间花在环境准备和代码学习上比跟密码较劲有意义得多。7. 常见问题速查表与驱动学习路线建议7.1 解压与编译问题速查表问题现象可能原因解决方案unzip: command not found系统没装unzipsudo apt-get install unzipfile is not a zip file下载不完整或格式错误file命令检查真实格式重新下载could not find eocdzip文件结尾损坏zip -F修复或重新下载解压后文件名乱码编码问题GBK/UTF-8使用unzip -O gbk或Python脚本Permission denied文件权限问题chmod -R urwxmissing linux/xxx.h内核头文件缺失安装linux-headers包编译出现API警告内核版本API变化修改函数签名更新调用方式insmod: Module format not recognized.ko文件与当前内核版本不匹配使用当前内核头文件重新编译7.2 驱动开发学习路线的个人建议如果这本书的源码包是你的第一个驱动开发项目我建议按照下面的路径来推进第一步先把hello模块编译通过并成功加载这个目标别看简单但它能帮你建立信心也能确认整个工具链和环境没有问题。第二步实现一个最简单的字符设备驱动比如书本里的memdev。不要急着加中断、加并发控制这些复杂功能先把设备号分配、cdev注册、open/read/write这几个基础环节跑通。第三步研究一下虚拟文件系统VFS和设备驱动之间的关系。理解为什么用户空间的open(/dev/memdev, ...)会调用到你的驱动里的open函数。这一步很关键它能把整个系统调用链路串起来。第四步开始玩中断、并发控制、阻塞IO这些进阶内容。这时候你会接触到很多内核的经典机制如果觉得代码量大可以先把书里的代码照抄编译跑起来再慢慢消化。第五步如果条件允许买一块开发板比如基于ARM Cortex-A系列的板子做真实的硬件驱动开发。模拟器和真实硬件之间的差别只有亲自做过才能体会到。7.3 源码后续应用方向的拓展思路当你把这本书的源码全部跑通之后可以尝试做几件有挑战性的事情进一步提升自己对Linux内核驱动的理解。一个方向是把书中的驱动代码移植到树莓派或其它开发板上运行。树莓派有完整的GPIO、I2C、SPI、USB等外设你可以在上面复现书中的大部分示例并针对真实硬件做修改。这个过程能帮你打通从理论学习到实际落地的最后一公里。另一个方向是去看Linux内核主线中真实的驱动程序代码。以你的memdev驱动为例内核中其实有很多类似的内存字符设备比如/dev/zero、/dev/null、/dev/random的实现它们的代码量不大但质量非常高读起来会很有收获。对比自己写的驱动和内核大牛写的内置驱动你会发现差距并不是功能完成度而是代码的健壮性和对边界情况的处理。最后如果工作和嵌入式相关可以深入研究一下设备树Device Tree。4.0内核时代的很多驱动还在用platform data但现在的内核已经全面转向设备树来描述硬件信息。书中关于设备模型的内容能帮你理解设备树的工作原理但要写出健壮的生产级驱动还需要单独学习设备树的语法和使用方法。7.4 真实项目中的驱动调试经验分享最后分享几条我在实际驱动开发项目中的调试习惯这些经验不一定写在任何一本书里但非常管用。第一内核崩溃之后不要慌先看oops信息。oops信息会明确告诉你崩溃发生在哪个函数、哪一行代码、访问了什么地址。只要你的内核符号表没有完全剥离objdump或者addr2line工具可以把地址转换成具体的函数名和行号定位问题非常高效。第二使用JTAG调试或者内核内置的kgdb功能。如果你的驱动在开发板上运行JTAG调试是查看内核寄存器状态和内存内容的最可靠方式。如果是在虚拟机上学习kgdb配合串口输出也可以在主机端调试客户机内核。这些工具需要花时间配置但一旦配好调试效率会提升几个量级。第三写驱动时要有意识地做参数校验。很多驱动崩溃的根源是用户空间传入了非法参数而驱动里没有做边界检查。比如在copy_from_user之前检查count是否超过缓冲区大小在ioctl中检查命令号是否合法。这些看似繁琐的检查在真实项目中最能体现一个驱动的成熟度。我个人在实际项目中的体会是驱动程序开发的难度并不在于代码量多少而在于它运行的环境远比用户空间程序复杂。多进程并发访问、中断上下文、内核版本差异、硬件时序问题每一个都能让你调试到怀疑人生。但也正因如此当驱动终于稳定运行的时候那种成就感是用户空间程序无法比拟的。这套书和源码的价值不在于代码本身而在于它把那些内核中最底层的机制一点一点讲透了。你每跑通一个模块对Linux系统的理解就会加深一层。拿到这套源码包把它当成一座矿来挖一次解压、一行代码编译、一步步调试收获远比只读书大得多。本文还有配套的精品资源点击获取
返回列表