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

资讯详情

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

手把手教你Linux设备驱动开发:从内核编程到调试实战

手把手教你Linux设备驱动开发:从内核编程到调试实战 “硬核宝典”这个说法真的不是出版社自卖自夸。拿到样书翻了几天又对着内核源码验证了几个关键章节之后我可以负责任地讲如果你想认真学 Linux 设备驱动开发这本书值得放在手边随时翻。本文不聊虚的直接讲讲驱动开发到底难在哪、这本书解决了哪些痛点、以及一套我自己验证过的入门路线。1. 驱动开发的门槛到底卡在哪应用开发与内核开发的思维鸿沟很多从应用层转过来的朋友第一个月会非常痛苦。为什么痛苦因为驱动开发的世界观和应用开发是完全相反的。你写应用的时候内存不够了可以申请用完了可以释放实在不行可以退出重来但在内核模块里你是在给整个系统提供基础服务一个空指针解引用就直接 panic整个系统宕机没有任何容错空间。你写应用的时候如果有 bug最多是进程崩溃重启一下就好但在驱动里一个并发控制没写好就是死锁就是数据竞态就是只有重启才能解决的神秘故障。《手把手教你学Linux设备驱动开发》这本书花了相当大的篇幅来解决这种思维转换问题。它不是上来就给你贴代码而是先讲清楚内核模块的运行环境、内核态和用户态的差异、系统调用的完整路径把驱动代码到底运行在什么上下文里这件事讲透。我的建议是入门阶段不要急着写代码先把下面这些概念刻在脑子里概念应用开发视角驱动开发视角运行空间用户态可以随便调用库函数内核态只能调用内核导出的函数内存管理malloc/free挂了也不影响系统kmalloc/vmalloc必须配对释放错误处理返回值检查异常捕获错误码传递不能轻易 BUG_ON并发场景多线程为主中断上下文、SMP 多核、抢占调试手段gdb、日志、加断点printk、ftrace、crash dump这个表建议打印出来贴显示器边上。我看过太多人一上来就照着网上的杂碎教程抄一个 hello world 模块然后 insmod 成功就觉得自己会写驱动了——真到写真正驱动的时候连 I/O 内存映射和 DMA 都分不清楚那才是灾难的开始。2. 动手前必须搭好的实验环境开发板选型与内核编译细节没有环境的驱动学习等于纸上谈兵。你至少需要一台 Linux 主机和一块能跑 Linux 的开发板。2.1 主机环境推荐发行版Ubuntu 22.04 LTS 或 Debian 12内核版本尽量和开发板内核保持同一个大版本交叉编译工具链arm-linux-gnueabihf-32位 ARM或 aarch64-linux-gnu-64位依赖包build-essential、libncurses-dev、u-boot-tools、device-tree-compiler主机环境最容易被忽略的是内核头文件版本必须和运行中的内核完全一致。很多新手在主机上编好了 .ko拿到板子上一 insmod 就报 Invalid module format九成都是 vermagic 不匹配。这本书里专门讲了如何处理这种问题但我在这里先给一个最常用的检查命令# 查看当前内核版本 uname -r # 查看模块的 vermagic modinfo xxx.ko | grep vermagic # 两者必须完全一致2.2 开发板怎么选纯入门QEMU vexpress-a9模拟器零成本适合练内核编译和模块加载进阶实战友善之臂 NanoPi NEO Air、正点原子 IMX6ULL、全志 V3s 这类低成本 ARM 板都行想深入 video 类驱动树莓派 4B 官方摄像头文档全、社区活跃我自己用的是一块 IMX6ULL核心原因是它的参考手册写得比较规范设备树资料也全出问题的时候你至少能在公开渠道找到答案。选板子最怕的是那种冷门芯片连 DataSheet 都找不到出了问题就只能在论坛里干瞪眼。2.3 第一次内核编译容易踩的坑内核编译是驱动开发的必修课但你不用从零开始裁剪整个内核。实际工作流一般是# 1. 获取内核源码分支切到板子对应的 BSP 版本 git clone -b imx_4.1.15_2.0.0_ga https://github.com/... # 2. 导入板卡厂商提供的默认配置 make ARCHarm imx_v7_defconfig # 3. 添加你需要的调试选项 make ARCHarm menuconfig # 打开 CONFIG_DEBUG_INFO、CONFIG_KPROBES、CONFIG_FTRACE # 4. 编译内核 make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- -j8 # 5. 单独编译设备树 make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- imx6ull-14x14-evk.dtb这里有一个非常关键的技巧驱动开发阶段不要每次改一点内核配置就全量重编内核。你应该把模块单独编译然后用 modprobe 动态加载到已经跑起来的板子里。这样调试循环从几分钟一次缩短到几秒钟一次。这本书在环境搭建部分也提到了这个工作流但我还是不放心地再强调一遍——动态加载模块是驱动开发的基本功别一上来就搞整机刷机。3. 驱动框架的核心抽象字符设备、平台设备、设备树下的一整套体系内核里驱动模型的演进是有逻辑的。你不理解这个逻辑看代码就会觉得东一块西一块。理解了整棵树的脉络就清楚了。3.1 从字符设备驱动开始字符设备是最简单的驱动类型也是学习曲线最平缓的入口。它的核心就是 file_operations 这个结构体相当于你给应用层提供的一组系统调用钩子。static const struct file_operations my_fops { .owner THIS_MODULE, .open my_open, .read my_read, .write my_write, .release my_release, .unlocked_ioctl my_ioctl, };看到这里你可能会问为什么只看到函数指针没有实现这就是初学驱动最容易犯的错误——抄代码的时候把实现体也抄了一遍但根本没搞懂这些函数是被谁在什么上下文调用的。my_read 被调用的时候进程正睡眠在 read 系统调用的等待队列上你的 read 函数运行在该进程的上下文里所以可以用 copy_to_user 安全地把数据拷贝回用户空间。my_write 类似。而 my_ioctl 则是你定义私有命令字的地方应用层通过 ioctl 系统调用传一个 command 字和一个参数指针进来。一个清晰的字符设备驱动结构应该是头文件包含 模块参数声明设备号申请静态或动态cdev 初始化和注册file_operations 实现核心数据读写逻辑模块卸载时逆序清理3.2 平台设备驱动把硬件差异抽象出来当你接触真实硬件的时候字符设备那套一套 fops 走天下就不够用了。不同板子上的同一个外设可能挂在不同的内存地址上、使用不同的中断号如果把这些硬编码在驱动里驱动就没法移植了。平台的解决方案是 platform_driver platform_device 分离机制。驱动只负责怎么操作这类硬件设备描述则放在设备树或板级文件里。static const struct of_device_id my_match_table[] { { .compatible vendor,my-device }, { } }; static struct platform_driver my_pdrv { .probe my_probe, .remove my_remove, .driver { .name my_device, .of_match_table my_match_table, }, }; module_platform_driver(my_pdrv);probe 函数什么时候被调用当内核在设备树里发现 compatible 字符串与驱动匹配的节点时。probe 里做的事情通常是获取 IO 资源、映射地址、注册中断、初始化数据结构。这对新手是个坎以前写字符设备驱动只需要 insmod 后手动 mknod现在还要写设备树节点还要理解内核是从设备树去匹配驱动的。3.3 设备树驱动的硬件配置单设备树Device Tree是新手最容易看得一头雾水的东西。它的本质就是用一个树形结构描述板载硬件资源。比如你想描述一个挂在某个 SPI 总线上的外部设备设备树里就会有这样一段ecspi1 { status okay; my_device: mydevice0 { compatible vendor,my-device; reg 0; // 片选号 spi-max-frequency 1000000; interrupt-parent gpio5; interrupts 9 IRQ_TYPE_LEVEL_LOW; }; };驱动里获取资源的代码则是struct device *dev pdev-dev; struct resource *res platform_get_resource(pdev, IORESOURCE_MEM, 0); void __iomem *base devm_ioremap_resource(dev, res); int irq platform_get_irq(pdev, 0);我见过不少人在设备树与驱动匹配这个点上卡了两三周。你只要记住一个口诀设备树提供硬件有什么驱动描述硬件怎么操作匹配靠 compatible 字符串。三者合起来一个驱动就能在任意拥有相同外设的板子上工作这就是 Linux 设备模型比传统裸机开发强太多的地方。4. 绕不开的并发控制自旋锁、互斥体与中断上下文很多搞过一段时间驱动的朋友都有这种经历明明在单核板子上测得好好的驱动一放到双核板子或者开启内核抢占之后就开始出各种诡异问题——打印错乱、数据结构损坏、偶尔死机。这时候你就要警觉了并发问题来了。驱动里常见的并发来源有四种SMP 多核同步执行—— 同一个驱动的 open/read/write 可能同时运行在不同核上中断上下文抢占—— 中断随时可能打断进程上下文如果两者操作同一份数据就出问题内核抢占—— 编译内核时开启 CONFIG_PREEMPT进程可能被更高优先级进程打断睡眠与唤醒—— wait_event/wake_up 之间的竞态窗口内核提供的两大利器是自旋锁和互斥体。特性自旋锁 spinlock互斥体 mutex睡眠不能睡眠忙等待可以睡眠等待队列适用场景临界区短、中断上下文临界区长、进程上下文开销小但浪费 CPU较大但省 CPU中断中使用可以用 spin_lock_irqsave绝对禁止最经典的教科书式死锁场景进程 A 持锁后被中断打断中断处理程序试图获取同一个自旋锁。这时候如果用的是普通 spin_lock系统直接死锁。正确做法是在进程上下文里用 spin_lock_irqsave 保存中断状态并关中断在中断上下文里用 spin_lock。我自己在实际项目里踩过一次这种坑一个网卡驱动在 ethtool 的回调函数里读寄存器没关中断恰好和网卡的中断处理函数撞上复现概率不高但一出现就是整机挂死。排查了很久最后用 ftrace 的 preemptirqsoff 追踪器抓到持锁时间超标才定位到问题。从那以后凡是中断和进程会共享的数据我一律用 spin_lock_irqsave。这本书在并发控制这一章用了很多实际内核里出现过的死锁案例来讲原理我觉得这部分写得尤其好。因为并发问题的核心不是背 API而是建立这里会不会有两个执行流同时进来的敏感性。这种敏感性只能靠大量读真实驱动的源码和踩坑来培养。5. 调试手段才是真正的分水岭printk 之外的世界驱动调试一直被认为是玄学。实际上现代内核提供的调试工具已经非常系统化了问题是你得知道用哪个、怎么用。5.1 printk 的正确打开方式printk 是新手最先接触、也是最快被滥用的调试手段。很多人直接在驱动里刷几百条 printk跑起来之后发现 dmesg 被刷得根本没法看性能也被拖垮。正确的 printk 姿势是分级打印KERN_EMERG / KERN_ALERT / KERN_ERR / KERN_WARNING / KERN_INFO / KERN_DEBUG控制台级别动态调整echo 8 /proc/sys/kernel/printk正式驱动里用 dev_info/dev_err 替代 printk设备模型会自动加上设备名如果你在调试 DMA 传输或者中断频繁的驱动printk 会把时序完全打乱反而复现不了问题。这时候就该用下面这些工具。5.2 ftrace内核的黑盒示波器ftrace 是内核自带的追踪工具最常用的功能是函数调用跟踪和中断延迟分析。# 挂载 tracefs mount -t tracefs nodev /sys/kernel/tracing # 打开函数调用跟踪 echo function /sys/kernel/tracing/current_tracer echo my_driver_function /sys/kernel/tracing/set_ftrace_filter echo 1 /sys/kernel/tracing/tracing_on cat /sys/kernel/tracing/trace这个工具对付我的函数到底有没有被调用调用参数是什么执行到哪里停了这类问题特别有效。5.3 kdump crash死机后的真相系统 panic 之后最怕的就是两眼一抹黑。配置好 kdump崩溃瞬间内核会留下一份 vmcore你用 crash 工具对着 vmlinux 符号文件分析能直接看到 panic 时的函数调用栈和各 CPU 状态。# 安装 crash crash vmlinux vmcore # 查看 panic 时的调用栈 crash bt我强烈建议每个驱动开发者把自己的实验环境配置好 kdump。因为你永远不知道下一个 bug 会以什么形式出现。有些 bug 和硬件流水线有关等你加日志去复现的时候它反而不出现了。而 vmcore 是事故发生时的第一现场信息量远超日志。5.4 硬件调试器该不该上如果你手头有 J-Link、OpenOCD 这类工具配合 ARM 内核的硬件调试单元是可以直接在开发板上打断点、单步调试内核代码的。这个手段在调试启动早期的驱动比如 console 驱动还没起来的时候几乎不可替代。但是老实说纯软件驱动的调试大部分场景用 ftrace kprobe printk 就足够了。硬件调试器是加分项不是必需品。新手不要一开始就陷入必须买开发器才能调试的误区。6. 驱动开发的进阶方向中断下半部、DMA 与常见子系统概览把基础字符设备驱动写明白之后就该往真正的工业级驱动方向走了。这个阶段有三个绕不开的知识点也是各类驱动面试题的高频考区。6.1 中断下半部为什么不能都在中断上下文里干活中断处理函数的执行条件是极其苛刻的中断被屏蔽的时间越短越好能不在中断上下文做的事就尽量不要做。但实际硬件产生一次中断后驱动往往需要接收大量数据、处理协议、通知用户态这些工作根本不适合在中断上下文里完成。于是内核提供了下半部机制softirq内核自己在特定时机执行适合高频短小的工作网络收包就是 softirqtasklet基于 softirq 的封装同一时刻只能在一个核上运行实现简单workqueue工作队列运行在进程上下文可以睡眠适合需要大量处理的任务实际驱动里最常见的模式是上半部中断处理函数只做硬件的 ACK 和数据搬运到缓冲区然后通过schedule_work把重活丢给工作队列。static irqreturn_t my_irq_handler(int irq, void *dev_id) { struct my_dev *dev dev_id; /* 屏蔽硬件中断防止风暴 */ disable_irq_nosync(irq); /* 把读取操作放到工作队列 */ schedule_work(dev-work); return IRQ_HANDLED; } static void my_work_handler(struct work_struct *work) { struct my_dev *dev container_of(work, struct my_dev, work); /* 这里可以做耗时操作、睡眠、读取寄存器 */ my_read_all_data(dev); /* 重新使能中断 */ enable_irq(dev-irq); }这里有一个很容易犯的错误上半部里用了disable_irq_nosync如果 irq 在别的核上正在执行这个调用会返回但中断仍然排着队你贸然在 worker 里操作共享资源可能和未完成的旧中断处理交叠。正确做法是确认disable_irq同步版确保中断处理真正结束后再动共享数据。6.2 DMA让硬件自己搬运数据DMA直接内存访问是高性能驱动的灵魂。基本原理就是外设和内存之间搬运数据不再经过 CPUCPU 只需要告诉 DMA 控制器从哪里搬到哪里搬多少。但驱动开发者要处理的远比这个复杂一致性和同步问题CPU 和 DMA 控制器各自有 cache数据写到内存但不能立即被外设看到地址映射问题外设要看物理地址驱动通常操作虚拟地址buffer 管理问题是要稳定的物理连续内存还是允许分散聚合Linux 内核提供的 DMA API 把这层包装得不错但你必须区分两个方向/* 数据从设备到内存读方向CPU 需要先 invalidate 再读 */ dma_map_single(dev, buf, size, DMA_FROM_DEVICE); dma_unmap_single(dev, dma_handle, size, DMA_FROM_DEVICE); /* 数据从内存到设备写方向CPU 需要先 flush 再启动传输 */ dma_map_single(dev, buf, size, DMA_TO_DEVICE); dma_unmap_single(dev, dma_handle, size, DMA_TO_DEVICE);新手写 DMA 驱动最容易犯的错误是忽略了 dma_unmap 必须在 DMA 传输真正完成后才能调用。你如果提前 unmap在 cache 一致性的处理上就会出问题表现就是数据偶发性错乱。这几乎是最难排查的一类 bug因为和内存延迟、缓存行、总线时序都有关。6.3 常见子系统如何下手Linux 内核的驱动并不是每个外设驱动都从零开始写 file_operations。大量的外设已经抽象成了子系统input 子系统按键、触摸屏、鼠标、遥控器只需要实现 input_dev 的 report 函数RTC 子系统提供 set_time/read_time 给内核统一调用pinctrl / gpio 子系统管脚复用和 GPIO 读写regmap 子系统统一封装 I2C/SPI/MMIO 寄存器读写IIO 子系统ADC、传感器、工业数据采集ALSA / V4L2音频和视频设备我的建议是按这个顺序去逐个击破先搞明白 gpio 子系统和 input 子系统两者结合就是按键驱动然后做 i2c 子系统下的传感器驱动接着做 spi 子系统下的 flash 驱动最后尝试 USB 设备驱动。每完成一个你对内核的理解都会上一个台阶。《手把手教你学Linux设备驱动开发》在子系统这块的编排也是按这个逻辑来的每个子系统都有配套的真实硬件实验例程而不是只贴源码。这比纯看文档有用得多。7. 避坑指南驱动开发中那些让老手也翻车的细节最后这部分纯粹是拿真金白银的加班时间换来的经验教训。每一条我都希望当年有人能提前告诉我。7.1 ioctl 命令号设计不规范很多人写 ioctl 就是随便填几个整数比如#define CMD_READ 1、#define CMD_WRITE 2。这在私有驱动里没问题但一旦你要进入 mainline 或者跟其他驱动共享用户态头文件命令号冲突就会造成灾难。内核推荐的_IO/_IOR/_IOW/_IOWR宏会把 type、number、size 和 direction 编码进一个 32 位的命令整数这样冲突概率极低且编译期能校验参数大小。#define MY_MAGIC M #define MY_CMD_READ _IOR(MY_MAGIC, 1, struct my_data) #define MY_CMD_WRITE _IOW(MY_MAGIC, 2, struct my_data)7.2 没有正确处理 copy_to_user 的失败返回值从内核向用户空间拷贝数据的copy_to_user是需要检查返回值的如果返回非 0说明没有完全拷贝成功此时应该向用户返回 -EFAULT。我看到不少驱动直接忽略了返回值导致用户空间拿到半截数据还完全不知情。7.3 设备号和次设备号的分配策略动态分配设备号是常规做法alloc_chrdev_region。但如果你的设备在 Linux 发行版里会被 udev 自动化识别建议使用固定的主设备号范围并在文档里写清楚。尤其是你要让用户态程序通过设备节点访问驱动时设备节点的权限、属组都要预先规划好否则会出现 root 能访问、普通用户不行的权限玄学。7.4 module_init 与对应的 module_exit 对称我见过太多驱动卸载时导致系统崩溃原因就是module_exit里释放资源的顺序错了。你注册的时候先注册了 cdev再创建了 class然后生成了设备节点卸载的时候就必须按相反顺序来先删除设备节点、再销毁 class、最后注销 cdev。逆序清理是内核编程的铁律。7.5 不要绕过内核机制直接操作寄存器早期很多 BSP 代码喜欢直接在驱动里用__raw_writel操作寄存器地址。这在特定 SoC 上看似没问题但完全没有考虑内存屏障和 cache 一致性。正确做法是iomap后用readl/writel系列接口或者干脆使用 regmap 框架。不要为了省一点调用开销去碰那些看似底层的原始接口。8. 资料与学习路径图书、内核源码和社区如何搭配最后聊一下怎么把手上的书和网上的资源组合成一条有效的学习路径。我的建议路径分成四个阶段。第一阶段是打基础读《手把手教你学Linux设备驱动开发》前五章对照内核源码看字符设备、platform 驱动和设备树这三块同时在自己的板子上完成 LED 按键驱动的实验。第二个阶段是深入并发与中断重点读并发控制、中断下半部和内核同步机制配合 ftrace 和 kprobe 做实验。第三个阶段是子系统实战从 gpio 子系统、input 子系统、i2c 子系统一个接一个做出来期间大量阅读内核现有驱动源码。第四个阶段是调试能力提升学会使用 ftrace、perf、kdump/crash 这些工具给自己造故障、排查故障。每个阶段都有对应的实验目标目标一定要具体。比如我要让板子上的按键按下后通过 input 子系统上报一个 key event并在用户空间用 evtest 工具验证。不要笼统地说把 input 子系统学完——没有验收标准的计划等于没计划。内核源码的阅读策略也要讲方法。不要从 vmlinux 顶层看起那是找死。正确入口是拿出你的板子 BSP 内核源码先看drivers/gpio/gpio-imx.c这类具体芯片的驱动学会看一个驱动的骨架然后用grep回溯它调用的通用 API遇到不懂的函数直接在include/linux和kernel/里去搜定义。一本书 一串grep 一块板子这组合拳比任何付费视频都有效。社区的利用也有讲究内核邮件列表linux-kernel、linux-arm-kernel是获取第一手设计思路的地方但新手不建议直接发问先考古再看精华Stack Overflow 和 CSDN 上中文资料很多但质量参差只能用来查具体 API 的用法不要把它当作设计思想的来源真正权威的资料永远是内核源码和 Documentation 目录下的文档。最后再分享一个小技巧学驱动开发的时候给自己建一个报错日志文件。每次遇到编译错误、运行 panic、诡异死锁都记录下来报错信息是什么、当时的上下文是什么、你排查的路径是什么、最终根因是什么。半年之后回头看你会发现这个文件比任何教程都有价值因为它完整记录了你的思维盲区。这本硬核宝典给的是知识框架和经验总结而这个日志则是你为自己编写的专属避坑手册两者配合成长的效率会高很多。
返回列表