
简介基于《Linux设备驱动开发详解》一书这份rar压缩包收录了围绕Linux 4.0内核的驱动样例代码面向正在学习字符设备、块设备、网络设备等驱动开发的开发者帮助将书中理论转化为可编译、可加载的实验项目。压缩包内共包含122个文件大小仅659KB以C源文件、Makefile构建脚本、.ko内核模块以及.cmd编译命令为主并包含部分.o目标文件与调试符号文件可直接对照书本章节进行编译与加载演练。目前已有197人学习该资源。样例涵盖字符设备、块设备、内存及虚拟磁盘等多种典型驱动涉及设备模型注册、中断处理、I/O调度、内存管理及模块化加载等关键知识点通过阅读这些源码与构建配置读者可快速掌握驱动框架的搭建思路、设备文件节点的创建方法以及内核模块的加载流程为后续独立开发Linux驱动打下扎实基础。这些示例代码结构相对完整适合边读边改、逐步验证驱动行为。1. 别被文件名骗了drivers 目录才是内核的地基第一次翻开 Linux 内核源码的人大概率会被drivers目录吓一跳——里面躺着一千多个子目录从gpio到net/wireless从staging到char命名五花八门找不着北是常态。这个drivers_drivers_linux_Kernel_标题看似是个没补全的路径其实恰好戳中了内核开发最核心的一个事实Linux 内核功能的八成都在驱动目录里而不是在kernel/或mm/里。本文面向的是被 BSP 厂商代码折磨过的嵌入式工程师、刚接触内核模块的入门者以及想把设备驱动这件事从头到尾跑通的人。我会从搭建最小编译环境开始讲到驱动框架怎么选再到调试技巧和常见的血泪坑最后落到可执行的行为验证手段。2. 搭建最小驱动开发环境内核源码、工具链与配置的三角关系在动手写第一行驱动代码之前我建议你先回答三个问题我的内核源码在哪编译工具链是什么版本目标机的内核版本和编译配置是否一致这三者构成一个三角任何一个不平衡编译出来的.ko文件轻则无法加载重则一加载就把内核崩掉连后悔药都来不及吃。2.1 为什么发行版自带的 headers 经常不够用很多人图省事直接apt install linux-headers-$(uname -r)然后在 Makefile 里指向/lib/modules/xxx/build觉得这就能编译驱动了。这个做法在只写独立模块、不碰内核内部未导出接口的前提下是可以的。可一旦你的驱动需要访问没被 EXPORT 的内部数据结构、改设备树、或者依赖一个你没开启的 config这套“头文件包”就露馅了——它只能保证你编译出一个和当前内核版本号匹配的模块不保证功能项齐全。我一般会建议在开发机上单独准备一份内核源码树版本尽量和目标机一致。嵌入式场景直接找厂家的 BSP 内核源码PC 上做实验就用内核官网对应版本解压到本地。# 以 5.15 版本为例解压源码后先做一次配置 tar xf linux-5.15.tar.xz cd linux-5.15 # 复制当前系统内核的配置作为起点保证 config 基本吻合 cp /boot/config-$(uname -r) .config make olddefconfig这里有两个关键点一是.config文件决定了很多头文件里#ifdef分支是否展开驱动代码里写#ifdef CONFIG_FOO时如果配置项没开代码路径直接消失你说气不气人。二是olddefconfig会把当前配置里新增的选项填成默认值避免老配置缺失新选项导致编译中断。检查配置合理性用make menuconfig做针对性微调然后单独编译内核的模块部分即可。编译驱动时Makefile 里指到这份源码树的路径而不是发行版的 headers 目录KERN_DIR ? /home/me/linux-5.15 all: make -C $(KERN_DIR) M$(PWD) modules clean: make -C $(KERN_DIR) M$(PWD) clean注意M$(PWD)这个变量它告诉内核构建系统“我这里有外部模块要编”构建系统会先处理内核自身的 include 路径和配置再进入M指向的目录找obj-m变量。参数说明KERN_DIR一旦指向完整的源码树编译时用的编译器也必须和构建工具链一致否则会报cc1: error: unknown argument这是另一个高频坑。2.2 第一个内核模块从 make 到 insmod 的关键路径先写一个最朴素的内核模块不碰任何硬件目的是验证工具链和开发环境通不通。// hello_mod.c #include linux/module.h #include linux/init.h #include linux/kernel.h static int __init hello_init(void) { pr_info(hello_mod: module loaded, jiffies%lu\n, jiffies); return 0; } static void __exit hello_exit(void) { pr_info(hello_mod: module unloaded\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(someone); MODULE_DESCRIPTION(minimal test module);对应的 Makefile 改成obj-m hello_mod.o KERN_DIR ? /home/me/linux-5.15 all: make -C $(KERN_DIR) M$(PWD) modules clean: make -C $(KERN_DIR) M$(PWD) clean然后在当前目录执行make如果没有意外会生成hello_mod.ko。至此你已经完成了驱动的编译链路。加载前先做一次“体检”modinfo hello_mod.ko # 观察 vermagic 这一行是否和目标内核版本、SMP 预编译等配置一致modinfo输出的vermagic字符串包含内核版本号、是否开启抢占、是否 SMP 等信息。只要有一项不一致insmod就会给你脸色看。加载模块sudo insmod hello_mod.ko dmesg | tail -20 # 应看到 hello_mod: module loaded如果 dmesg 里出现Invalid module format一定是 vermagic 或代码模型比如-mcmodelkernel对不上。卸载用rmmod hello_mod。这一套流程通下来环境问题才算彻底排除。新手最容易在第一步就卡住常见的原因是把obj-m写成了obj-y编译出的.o文件被扔进了内核镜像而不是独立模块连报错都报得不明不白。2.3 内核配置里和驱动强相关的 5 个开关写过几次驱动之后我总结出五个和驱动开发关系最密切的配置开关每次拿到一块新板子都会先查一遍配置项作用检查命令CONFIG_MODULES内核是否支持模块加载关闭后 insmod 直接失效grep CONFIG_MODULES .configCONFIG_MODULE_UNLOAD是否允许卸载模块调试时关掉会很痛苦grep CONFIG_MODULE_UNLOAD .configCONFIG_DEVTMPFS设备节点是否由内核自动在 /dev 下创建影响驱动加载后设备文件是否出现grep CONFIG_DEVTMPFS .configCONFIG_OF设备树支持现代 ARM 平台驱动几乎离不开grep CONFIG_OF .configCONFIG_FW_LOADER固件加载框架涉及请求固件的驱动必须开启grep CONFIG_FW_LOADER .config其中CONFIG_DEVTMPFS是最容易被忽略的。很多驱动初始化的最后一步是构建struct device并期望 udev 在/dev下生成节点如果内核配置没开这个节点不会出现驱动却“看起来”正常加载了排查时极容易让人误判成设备端问题。3. 驱动框架怎么选字符设备、platform 还是设备树驱动的判断标准写驱动第一步不是在键盘上敲#include而是决定用哪套框架。选错框架的代价不在编译期而在调试期——你会被困在“明明逻辑没问题但代码就是不跑”的尴尬境地。这部分讲清楚三类框架的边界然后给出一个可运行的 platform 驱动骨架最后说说设备树匹配的坑。3.1 三类框架的适用边界别用大炮打蚊子按设备的发现方式和生命周期管理Linux 驱动通常分三类第一类是纯字符设备驱动自己用register_chrdev注册主设备号在file_operations里接读写回调。它不关心设备如何被发现适合简单的虚拟设备、不需要匹配硬件地址的软件功能模块。缺点是没有把设备和总线解耦热插拔和电源管理几乎要自己包办。第二类是 platform 驱动专门为“挂在 SoC 内部总线上的设备”设计。判断标准很简单设备地址在编译时就写死在代码里或者写在设备树里而不是通过 PCI/USB 这类可枚举总线发现。比如 SoC 上的 GPIO 控制器、I2C 控制器、串口控制器都是 platform 设备的典型代表。第三类是真正的总线驱动模型比如 PCI、USB、I2C 等子系统设备由总线枚举机制发现驱动通过id_table匹配。嵌入式 Linux 开发里九成场景落在 platform 加上设备树这一段。DSA 网络交换机驱动、PHY 驱动、各种外设控制器走的基本都是这个路线。我的判断标准很简单硬件地址写死且不会变 → platform硬件地址由固件动态分配 → 总线驱动软件模拟 → 字符设备。3.2 用 platform_driver 和设备树写一个 GPIO 点灯驱动下面是一段可运行的 platform 驱动骨架它从设备树里读取两个属性一个 GPIO 编号一个默认电平然后在probe时请求该 GPIO 并输出电平。#include linux/module.h #include linux/platform_device.h #include linux/gpio/consumer.h #include linux/of.h struct led_priv { struct gpio_desc *led_gpio; }; static int led_probe(struct platform_device *pdev) { struct led_priv *priv; struct device *dev pdev-dev; u32 default_level 0; priv devm_kzalloc(dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; priv-led_gpio devm_gpiod_get(dev, led, GPIOD_OUT_HIGH); if (IS_ERR(priv-led_gpio)) { dev_err(dev, failed to get led gpio\n); return PTR_ERR(priv-led_gpio); } of_property_read_u32(dev-of_node, default-level, default_level); gpiod_set_value(priv-led_gpio, default_level); platform_set_drvdata(pdev, priv); dev_info(dev, led probe ok, level%d\n, default_level); return 0; } static int led_remove(struct platform_device *pdev) { struct led_priv *priv platform_get_drvdata(pdev); gpiod_set_value(priv-led_gpio, 0); return 0; } static const struct of_device_id led_of_match[] { { .compatible myvendor,led, }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, led_of_match); static struct platform_driver led_driver { .probe led_probe, .remove led_remove, .driver { .name my-led, .of_match_table led_of_match, }, }; module_platform_driver(led_driver); MODULE_LICENSE(GPL);这段代码值得细读的地方有三处。一是devm_gpiod_get用的是devm_前缀资源释放交给设备管理机制remove里不需要手动调用gpiod_put减少了内存泄漏的机会。二是of_property_read_u32读取属性时给了一个默认值防止设备树里漏写属性导致读到不确定数据。三是MODULE_DEVICE_TABLE(of, led_of_match)用于生成模块的别名信息modprobe才能通过设备树 compatible 字段自动匹配并加载模块。配套的设备树片段长这样iomuxc { pinctrl_led: ledgrp { fsl,pins MX8MP_IOMUXC_NAND_READY_B_GPIO3_IO16 0x19 ; }; }; led_test { compatible myvendor,led; pinctrl-names default; pinctrl-0 pinctrl_led; led-gpios gpio3 16 GPIO_ACTIVE_HIGH; default-level 1; };注意设备树里的led-gpios属性名会映射到驱动里devm_gpiod_get(dev, led, ...)中的第二个参数-号替换成_GPIO 的_会被去掉。如果你在驱动里写led-gpio英文名拼成单数对不上就请求失败这是非常细节的坑。3.3 设备树 compatible 匹配的 3 个常见误解关于设备树匹配我踩过三次比较有代表性的翻车列出来供参考误解一认为 compatible 只要字符串一样就能匹配。实际上内核先查of_match_table里的名再查内核自带的platform ID table最后才查驱动 name 字段三者优先级不同顺序错了就匹配不上。误解二认为改了设备树就一定会触发重新 probe。如果内核已启动修改 DTB 后必须重启/sys/devices/platform下不会因为你在 DTS 文件里改了字符串就热刷新。误解三认为status disabled的节点只要驱动 probe 就能绕过。平台总线在创建设备时就会检查 statusdisabled 节点根本不会生成 platform device你的 probe 函数一次都不会被调用。排查这类问题先ls /sys/devices/platform看节点在不在不在就别怪驱动。4. 驱动跑起来之后的 4 个硬核调试技巧驱动编译通过、成功加载只意味着万里长征走完了第一步。真正耗时的是运行行为调试为什么 probe 没被调用为什么中断没有触发为什么数据写进去读出来是错的这章讲四个我在实际调试里最常依赖的手段。4.1 printk 的正确姿势优先级、动态调试和 dmesg 的时间戳printk是最直接的调试工具但很多人用错了。内核日志等级由优先级决定只有优先级高于 current console 级别的消息才会打印到串口。pr_info、pr_debug、pr_err的区别不只是措辞而是决定消息会不会被 console 输出。调试时最省心的方式是# 把控制台日志级别调到 8让所有等级都输出 sudo sh -c echo 8 /proc/sys/kernel/printk这会把KERN_DEBUG的消息也打到串口上代价是性能下降适合开发阶段。更精细的做法是用动态调试只要代码里用了pr_debug就能按文件或函数开关输出# 打开某个文件的全部 pr_debug sudo sh -c echo file drivers/misc/hello_mod.c p /sys/kernel/debug/dynamic_debug/control这招在不重新编译的前提下控制日志输出特别适合定位疑难问题。打开 dmesg 时间戳可以分析时序问题sudo dmesg -T-T显示可读时间格式否则是内核 tick 计数查阅时要换算时间非常别扭。4.2 用 /proc、/sys 和 debugfs 把驱动内部状态暴露出来printk 决策不了所有问题。当驱动内部状态复杂或者你想在运行期修改某些参数用 debugfs 创建一个自定义调试接口是高效做法。#include linux/debugfs.h #include linux/uaccess.h static struct dentry *led_debug_dir; static u32 reg_debug_value; static ssize_t reg_read(struct file *file, char __user *buf, size_t count, loff_t *ppos) { char tmp[32]; int len scnprintf(tmp, sizeof(tmp), 0x%x\n, reg_debug_value); return simple_read_from_buffer(buf, count, ppos, tmp, len); } static const struct file_operations reg_fops { .read reg_read, }; static int __init debugfs_init(void) { led_debug_dir debugfs_create_dir(myled, NULL); if (IS_ERR(led_debug_dir)) return PTR_ERR(led_debug_dir); debugfs_create_file(reg, 0444, led_debug_dir, NULL, reg_fops); return 0; }debugfs_create_file的第三个参数是数据指针会通过file-private_data传给 read 回调但上面的写法没用到它。simple_read_from_buffer负责把内核缓冲拷贝到用户态同时处理ppos的偏移。挂载 debugfs 后你可以在 shell 里直接cat /sys/kernel/debug/myled/reg查看寄存器的当前值。这种接口还支持用echo写值进内核把那几个write函数补全即可。相比每个特性都加一个 ioctldebugfs 的改动成本更低也不用为调试接口设计一套用户态库。4.3 崩溃现场怎么抓Oops 信息和 addr2line 的搭配用法驱动崩溃时内核会打印一段 Oops 信息看起来像一堆乱码其实里面有黄金。重点是这两行RIP: 0010:my_func0x20/0x50 [my_driver] Call Trace:my_func0x20/0x50表示崩溃发生在my_func函数的偏移0x20处函数总长度0x50。要拿到准确源码行号用交叉工具链的addr2line# 假设内核未压缩镜像在 vmlinux崩溃地址在 Oops 信息中以 Code 行给出 nm vmlinux | grep my_func # 如果 my_func 的基址是 ffffffff81234560加上偏移 0x20 addr2line -e vmlinux -f -C ffffffff81234580如果你的模块是独立的.ko用objdump -d hello_mod.ko找到函数位置更直接。另一种常见办法是在代码里加dump_stack()手动打出一个调用栈配合 Oops 能精确定位是哪一个调用者触发的崩溃。还有个小技巧把panic_on_oops设置为 1崩溃后就直接 panic配合 kdump 抓取完整内存转储但这属于锦上添花不是每个环境都配了 kdump。5. 驱动开发避坑指南从编译失败到内核崩溃的 5 条血泪记录这一章全是我实际项目里遇到过的场景按“现象 → 原因 → 解决”的方式写。每个问题都值得你刻进脑子里因为它们不会在编译期报错而是潜伏在运行时。5.1 现象insmod 报 Unknown symbol 或 version magic 不匹配你在 A 机器上编译的驱动拿到 B 机器上insmod大概率遇到version magic 5.15.0-rc1 SMP preempt mod_unload should be 5.15.0-rc1 SMP preempt mod_unload ...或者Unknown symbol。前者说明配置差异后者说明某个内核符号根本不存在。原因驱动和被加载内核的配置不一致或者用了某一内核特有的内部接口换内核版本符号消失。解决开发机编译环境必须与目标机内核版本严格一致模块依赖的内核符号用nm查下导出状态# 查看 hello_mod.ko 引用了哪些未解析的符号 nm -u hello_mod.ko # 在内核符号表里查这些符号是否存在 grep symbol_name /proc/kallsyms/proc/kallsyms需要 root 权限才能看全并且内核开启了CONFIG_KALLSYMS_ALL。如果没有导出你有两个选择一个是换一个已导出的接口另一个是先将驱动编译进内核而不是做成模块。5.2 现象设备树改完驱动 probe 就是不被调用板子启动后ls /sys/bus/platform/devices里看不到你的设备名或者看到了设备但probe没跑dmesg也干干净净。原因一是设备树节点 status 不是 okay二是compatible没匹配上驱动表三是设备已经被别的驱动占用了资源devm_gpiod_get返回-EBUSY导致 probe 返回负数。解决先确认设备节点存在ls /sys/firmware/devicetree/base/myled 2/dev/null cat /sys/firmware/devicetree/base/myled/compatible如果节点存在但无 platform device说明总线没扫描。执行echo 1 /sys/bus/platform/devices/myled/uevent手动触发一次事件看看会不会创建 device。dmesg 里如果出现request_module相关的调用说明 udev 正在尝试为你的 compatible 找模块modprobe能不能找到取决于MODULE_DEVICE_TABLE(of, ...)是否正确生成 alias。这是一个环环相扣的链路每查一环都用dmesg配合ls验证。5.3 现象并发访问把驱动状态改坏偶发死机驱动里用了共享变量或共享寄存器多次操作后随机出现错误行为严重时内核 Oops。这种问题极难复现但有一个共同点并发访问没有加锁。原因驱动的 read/write 回调可以同时被多个进程调用中断上下文也可以访问同一块数据区。如果不做保护数据竞争是家常便饭。解决明确访问发生的上下文分类加锁。用mutex保护进程上下文之间的互斥用spinlock或bit_spinlock保护与中断上下文的交互。常见写法static int mydev_write(struct file *file, const char __user *buf, size_t count, loff_t *offset) { struct mydev *dev file-private_data; char local_buf[64]; if (count sizeof(local_buf)) return -EINVAL; if (copy_from_user(local_buf, buf, count)) return -EFAULT; mutex_lock(dev-lock); /* 把 local_buf 写入硬件寄存器 */ mutex_unlock(dev-lock); return count; }copy_from_user放在锁外是刻意之举避免持锁时执行可能阻塞的内存操作。内核里的锁顺序问题特别容易引起死锁新手常犯的另一个错是在关中断的情况下调用mutex_lock直接死等。记住中断上下文只能用 spinlock。5.4 现象mmap 或 DMA 地址不正确数据全是 0驱动给用户态提供了 mmap 接口用户程序映射后读到的寄存器全是 0或者 DMA 完成后数据没出现在预期地址。原因寄存器物理地址和设备树里的 reg 属性不一致或者 mmap 实现没有按页对齐。内核的 mmap 回调里常见的正确做法是重映射设备内存static int mydev_mmap(struct file *file, struct vm_area_struct *vma) { struct mydev *dev file-private_data; unsigned long size vma-vm_end - vma-vm_start; if (size dev-reg_size) return -EINVAL; vma-vm_page_prot pgprot_noncached(vma-vm_page_prot); if (io_remap_pfn_range(vma, vma-vm_start, dev-reg_phys PAGE_SHIFT, size, vma-vm_page_prot)) return -EAGAIN; return 0; }pgprot_noncached是关键否则 CPU 缓存会把寄存器数据缓存住读回来的值跟你想象中的完全不一样。io_remap_pfn_range的第一个地址参数必须页对齐寄存器物理地址最好也按页对齐不然最后一项的访问会超出映射范围。DMA 场景还要注意一致性缓存问题用dma_alloc_coherent分配内存或者显式调用dma_map_single加dma_unmap_single做内存屏障。5.5 现象内核崩溃后连 log 都看不到只能死等驱动把内核干崩了但串口上只有最后几十行崩溃瞬间的信息全丢了或者串口直接卡死不输出。原因console 缓冲区在崩溃瞬间没有 flush或者串口驱动自身已经瘫痪日志根本出不来。解决先开启内核的pstore/ramoops把崩溃日志存到内存保留区重启后再读取dmesg | grep -i ramoops ls /sys/fs/pstore/如果平台支持在 DTS 里预留一段 ramoops 区域这是尽早发现崩溃实情的可靠办法。另一个低成本做法是靠外部设备录日志——开发机串口工具开 DTR 日志保持连接 постоян, 崩溃日志写进文件里至少比没有强。这个方法很“土”但确实救过我几次别因为简单而瞧不起它。6. 驱动开发最后一道工序用工具验证而不是用眼睛检查驱动能跑不算完要通过代码工具和运行时跟踪确认没有隐性问题。这块的价值容易被低估——它决定了你的驱动是“碰巧工作”还是“稳定工作”。6.1 用 sparse 和 smatch 做静态检查内核构建系统自带 sparse 支持对驱动里的地址空间、自旋锁使用做基础检查make C1 CHECKsparse M$(pwd) modulesSparse 能抓到诸如__user指针误用、混合地址空间访问等常见错误。Smatch 是另一个更深的静态分析工具能查空指针引用、数组越界。如果代码量大跑一次 smatch 会花时间但值得。尤其__user相关的问题只靠肉眼很难看全。6.2 用 ftrace 跟踪驱动里的关键函数# 进入跟踪目录清空过滤器只跟踪你的驱动函数 cd /sys/kernel/debug/tracing echo my_driver:* set_ftrace_filter echo function current_tracer echo 1 tracing_on # 运行你的测试用例 cat trace_pipe这能拿到调用序列和耗时特别适合排查 probe 失败和中断上下文异常跳转这类问题。和 printk 不同ftrace 不开的时候零开销开的时候也只对指定函数插桩对运行时影响极小。6.3 让驱动可测试把硬件行为 mock 成内核模块最后一条心得驱动写得再漂亮没有硬件可测也是白搭。我习惯的做法是给上层提供一个 mock 层内核模块内部用软件状态模拟硬件寄存器的读写行为这样用户态测试程序就能在板子没回来之前先行验证逻辑。为这一层单独建一个驱动目录用MODULE_PARAM_DESC描述测试标记运行时挂载真正的硬件驱动之前先加载 mock 驱动把设备树里的 compatible 临时改一改跑一遍测试套件。等真实硬件回来再切回真驱动跑同一套用例能筛掉七成以上的逻辑 bug。说句实在话驱动开发这行能力不是比谁代码写得花而是比谁能在不看文档的情况下用工具把问题边界切准。我最早写驱动的时候迷信 printk把日志打到满天飞后来被一次崩溃教训了——printk 在 irq context 里也有风险锁得不对照样死锁。现在我把“验证”放在“实现”同等重要的位置出问题先跑 sparse、再看 ftrace、最后才开 printk 救火。这套习惯帮我省了不少重启时间也把那些“神出鬼没”的问题变成可复现的流程图。希望帮到你。本文还有配套的精品资源点击获取