ARTICLE DETAIL

资讯详情

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

《手把手教你学Linux设备驱动开发》:内核模块到设备树实战

《手把手教你学Linux设备驱动开发》:内核模块到设备树实战 刚看到《手把手教你学Linux设备驱动开发》正式出版的消息我立刻去入手了一本。做Linux驱动开发这些年我见过太多人抱着《Linux设备驱动程序》啃结果卡在“怎么把驱动跑起来”这一步就放弃了。这本书敢叫“手把手”说明它确实想解决这个痛点不是泛泛讲内核源码而是带着你从搭建环境、写第一个模块开始一步步把字符设备、平台驱动、设备树、中断并发这些硬骨头啃下来。今天这篇就结合我自己入行时的经历聊聊这本书为什么值得读以及驱动开发这条路上真正绕不开的那些知识点。1. 为什么驱动开发这么难很多人学着学着就放弃了1.1 内核不是你平时写的应用程序如果你只写过C语言的用户态程序第一次打开内核源码大概率是懵的。用户态程序里有printf、malloc、sleep想干嘛都有一堆现成库函数兜底。内核态不一样你不能随便打印、不能申请任意大小内存、不能睡眠、不能访问用户态指针甚至一个空指针直接就是整机崩溃而不是段错误。这种“游戏规则完全不同”的转变是劝退的第一个门槛。驱动开发的学习曲线之所以陡峭是因为它要求你同时具备三种能力懂硬件寄存器操作、懂内核框架的设计思路、懂并发与内存管理的底层约束。市面上很多教材一上来就扔给你一个几百行的PCI驱动里面全是pci_driver结构体、resource、irq_handler新手根本不知道这些字段从哪来、为什么要填。这本书的做法是先把最小可运行的模块讲透再把复杂度一层层加上去这种梯度设计其实很像我们平时写业务代码时的“先跑通再优化”。1.2 硬件的“不可见性”让调试无从下手写应用崩了有core dump有gdb有日志大不了多打几个printf。但驱动跑起来之后你面对的是没有屏幕的开发板通过串口或网络看到的只有内核日志。更麻烦的是驱动一旦访问了错误的寄存器地址CPU可能直接挂死这时候连日志都刷不出来了。我第一次调GPIO中断时按键一按系统就重启找了两天最后发现是中断处理函数里调用了printk而那个串口驱动本身也依赖同一个中断典型的“死锁式”日志输出。这种问题书上很难描述清楚只有踩过坑的人才懂。所以读驱动开发的书一定要边读边在真实或模拟环境里跑代码光看不练等于没看。这本书里每个章节都配了可编译的示例代码这一点对新手极其友好。很多老教材的示例代码还是2.6内核时代的放到现在的6.x内核上根本编译不过而“手把手”最值钱的地方就在于它能让你在自己的电脑上亲眼看到一个设备驱动被加载、被读写、被卸载。1.3 学习资料的老化与断层Linux内核变化太快了设备模型从platform_device到设备树中断API从request_irq到devm_request_irq再到threaded_irq内存分配从kmalloc到devm_kzalloc稍微老一点的书就过时了。很多初学者去网上搜教程搜到一篇2015年的文章按着敲却编译不过然后就开始怀疑人生。这本书好就好在它紧扣当前内核主线的通用框架讲的是那些“十年不会大变”的东西字符设备框架、platform总线模型、设备树匹配规则、中断与并发控制。这些底层机制一旦掌握哪怕以后内核API又改了你也能对着Documentation目录快速迁移。换句话说它教的不是某个版本的一招一式而是驱动开发的“内功心法”。2. 吃透内核模块从hello world到真正的字符设备2.1 一个最小的内核模块长什么样驱动最开始其实就是一个内核模块kernel module它允许你动态地向运行中的内核添加代码而不需要重新编译整个内核。这个机制是驱动开发的地基也是“手把手”路线图的第一步。#include linux/init.h #include linux/module.h #include linux/kernel.h static int __init demo_init(void) { pr_info(demo module loaded\n); return 0; } static void __exit demo_exit(void) { pr_info(demo module unloaded\n); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple demo module);这一段代码值得逐行解释。__init和__exit是内核宏告诉编译器这个函数只在初始化和卸载时使用初始化完成后可以释放内存。pr_info是printk的封装输出级别为KERN_INFO在终端上默认可能看不到需要用dmesg查看。module_init和module_exit是模块的入口和出口。编译它需要内核头文件通常在安装linux-headers-$(uname -r)之后才有。Makefile的写法也很有讲究obj-m demo.o KDIR : /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean这里obj-m表示编译成模块KDIR指向当前内核的构建目录。编译完成后会生成demo.ko用sudo insmod demo.ko加载再sudo rmmod demo卸载。整个过程没有任何硬件却能让你彻底搞清楚模块的生命周期。2.2 实现file_operations让设备可以被读写纯粹能加载卸载的模块没实际意义驱动最终是要和用户态交互的。经典的做法是把驱动注册成一个字符设备用户通过文件系统访问它。字符设备的核心是file_operations结构体它是一张“函数表”把用户态的open、read、write、close系统调用映射到驱动里的具体函数。#include linux/fs.h #include linux/uaccess.h #include linux/device.h #define DEVICE_NAME demo #define CLASS_NAME demo_class static int major; static struct class *demo_class; static int dev_open(struct inode *inode, struct file *file) { pr_info(device opened\n); return 0; } static ssize_t dev_read(struct file *file, char __user *buf, size_t count, loff_t *offset) { const char msg[] hello from kernel\n; size_t len strlen(msg); if (*offset len) return 0; if (copy_to_user(buf, msg, len)) return -EFAULT; *offset len; return len; } static ssize_t dev_write(struct file *file, const char __user *buf, size_t count, loff_t *offset) { char kbuf[128]; if (count sizeof(kbuf) - 1) return -EINVAL; if (copy_from_user(kbuf, buf, count)) return -EFAULT; kbuf[count] \0; pr_info(user wrote: %s\n, kbuf); return count; } static struct file_operations fops { .owner THIS_MODULE, .open dev_open, .read dev_read, .write dev_write, };这里最关键的就是copy_to_user和copy_from_user。内核不能直接解引用用户空间的指针因为那是一个未受保护的地址可能引发崩溃或安全漏洞。这两个函数会做指针合法性检查和数据拷贝返回值是未拷贝成功的字节数碰到错误要返回负的错误码。新手最容易犯的错是直接memcpy或者对copy_to_user的返回值判断反。2.3 加载、卸载与设备节点的自动创建有了file_operations还需要把它注册进内核并在/dev下创建设备节点。注册字符设备用register_chrdev一个函数搞定分配主设备号和注册虽然老派但足够教学使用。static int __init demo_init(void) { major register_chrdev(0, DEVICE_NAME, fops); if (major 0) { pr_err(failed to register device\n); return major; } demo_class class_create(DEVICE_NAME); if (IS_ERR(demo_class)) { unregister_chrdev(major, DEVICE_NAME); return PTR_ERR(demo_class); } device_create(demo_class, NULL, MKDEV(major, 0), NULL, DEVICE_NAME); pr_info(device registered with major number %d\n, major); return 0; } static void __exit demo_exit(void) { device_destroy(demo_class, MKDEV(major, 0)); class_destroy(demo_class); unregister_chrdev(major, DEVICE_NAME); pr_info(device unregistered\n); }这样在加载模块后/dev/demo节点就会自动出现用户态程序直接open(/dev/demo, O_RDWR)就能和驱动通信。register_chrdev(0, ...)的第一个参数0表示让内核动态分配主设备号省去了查号、冲突的麻烦。需要注意的是现代内核中class_create只需要一个参数早期版本是两个编译报错时可以优先检查这里。3. 平台驱动与设备树现代驱动的正确打开方式3.1 从“硬编码”到设备树需要的是一次思维转变很多人写驱动习惯在代码里直接写死寄存器地址和中断号比如base_addr 0x01c20800。这种写法在单一型号的芯片上没问题但换一颗SoC就要改代码重新编译非常不优雅。设备树Device TreeDT就是来解决这个问题的它用一份文本文件描述“硬件长什么样”驱动只负责“这个设备怎么工作”两者通过节点node和属性property匹配。设备树的思维转变有一点像前后端分离以前前端代码里硬编码后端地址现在通过配置中心动态获取。设备树就是内核的“配置中心”compatible属性是驱动的“身份证”。比如某颗LED对应的设备树节点长这样leds { compatible vendor,myled; reg 0x01c20800 0x10; interrupts 0 29 4; };驱动侧通过of_match_table声明自己支持的compatible内核在启动时会扫描设备树找到匹配的节点后调用驱动的probe函数。这样一套驱动代码不用改就能适配不同板卡只要修改设备树里的寄存器地址和中断号即可。3.2 写一个platform_driver的骨架挂在平台总线platform bus上的驱动是目前Linux驱动的主流形态。它的核心结构是platform_driver里面包含probe、remove、id_table等回调。#include linux/platform_device.h #include linux/of.h #include linux/of_address.h #include linux/of_irq.h static int my_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; int irq; res platform_get_resource(pdev, IORESOURCE_MEM, 0); base devm_ioremap_resource(pdev-dev, res); if (IS_ERR(base)) return PTR_ERR(base); irq platform_get_irq(pdev, 0); if (irq 0) return irq; dev_info(pdev-dev, probe success, base%px irq%d\n, base, irq); return 0; } static int my_remove(struct platform_device *pdev) { dev_info(pdev-dev, remove called\n); return 0; } static const struct of_device_id my_of_match[] { { .compatible vendor,myled }, {} }; MODULE_DEVICE_TABLE(of, my_of_match); static struct platform_driver my_driver { .probe my_probe, .remove my_remove, .driver { .name my_driver, .of_match_table my_of_match, }, }; module_platform_driver(my_driver);这里module_platform_driver是一个宏展开后自动注册和卸载平台驱动省去手动写module_init和module_exit。platform_get_resource从设备树节点中取出reg属性对应的地址资源devm_ioremap_resource会申请并映射I/O内存同时自动处理资源释放避免了手动ioremap后的泄漏问题。3.3 probe函数里该干什么probe函数是驱动初始化的核心几乎所有“准备工作”都在这里完成获取硬件资源、映射寄存器、注册中断、创建设备节点、初始化数据结构、启动工作队列。一个容易忽略的原则是probe失败时一定要清理已申请的资源。传统的写法里你要手动iounmap、free_irq、destroy_workqueue等而推荐的做法是尽量使用devm_device resource management系列API比如devm_kzalloc、devm_ioremap_resource、devm_request_irq。这些API在驱动卸载或probe失败时会自动回收资源代码直接少一半也基本告别了资源泄漏。我在实际项目里见过很多probe函数长达几百行的老代码中间某个分支返回-ENOMEM前面申请的资源全漏了。如果用devm_函数重写逻辑会清爽很多。这本书里花了不小篇幅讲这些资源管理API我觉得很对——这不是技巧问题而是驱动安全性的底线。4. 并发、中断与延时驱动里最容易翻车的地方4.1 并发场景为什么全局变量不能随便用驱动运行在内核态随时可能被中断打断也可能被多个进程同时访问还可能在多核CPU上并行执行。如果驱动里有一个全局变量buffer两个进程同时写会发生什么数据错乱、panic、设备状态未知。用户态程序还可以用锁内核态对锁的要求更苛刻因为稍不注意就可能睡眠在原子上下文中。最基础的保护手段是自旋锁和互斥体。自旋锁适合保护临界区极短的场景它不会睡眠而是原地空转等待互斥体则会睡眠适合临界区里有耗时操作的场景。选择的标准很关键临界区里有没有可能调用会导致睡眠的函数如果有就必须用互斥体。static DEFINE_MUTEX(my_mutex); static ssize_t dev_write(struct file *file, const char __user *buf, size_t count, loff_t *offset) { mutex_lock(my_mutex); /* 访问共享资源 */ mutex_unlock(my_mutex); return count; }这只是一个最简单的例子。真正的驱动还要考虑原子操作、读写锁、RCU等。新手不需要一次学全但必须要建立“共享数据必须加锁”的意识。这本书在并发章节专门强调了“临界区越小越好”与“锁的顺序一致性”这两个原则能避免90%以上的死锁问题。我当年就是没当回事写了个自旋锁里调msleep的驱动一加载CPU直接死锁最后只能按复位键。4.2 中断上下文能做和不能做的事硬中断处理函数是驱动的另一个高危地带。它在中断上下文中运行很多操作是被禁止的不能睡眠、不能调用kmalloc的阻塞版本、不能访问用户态内存、不能使用获取信号量的函数。因为中断处理程序一旦睡眠整个系统可能就hung住了。正确做法是“上半部只干最紧急的事”读取或清除中断状态寄存器、标记有数据待处理、唤醒下半部。下半部机制有tasklet、工作队列、软中断等。简单场景下我推荐使用request_threaded_irq加IRQF_ONESHOT的方式把大部分工作放到线程化中断上下文里做static irqreturn_t my_threaded_irq_handler(int irq, void *dev_id) { /* 这里可以睡眠可以执行耗时操作 */ return IRQ_HANDLED; } static irqreturn_t my_irq_handler(int irq, void *dev_id) { /* 上半部快速处理不睡眠 */ return IRQ_WAKE_THREAD; }注册的时候ret devm_request_threaded_irq(pdev-dev, irq, my_irq_handler, my_threaded_irq_handler, IRQF_TRIGGER_RISING, my_irq, pdev);这种“硬中断线程化处理”的组合在实际项目中非常流行既保证了响应速度又给了下半部足够的灵活性。很多USB、网络驱动的真实实现都是这个套路。4.3 内核延时与睡眠的坑驱动程序经常需要等待硬件完成某个操作比如写寄存器后要等几微秒。这时不能直接mdelay了事要知道mdelay是忙等待会占着CPU空转在允许睡眠的上下文中应该使用usleep_range或msleep。还有一个更隐蔽的坑msleep的精度和实际睡眠时间可能偏移尤其是在高负载系统上如果你对时序要求很严格得用usleep_range配合ktime来校准。另外用户在驱动里直接用for循环做短延时编译器可能优化掉或者时机不准。内核提供了ndelay、udelay、mdelay系列以及readl_poll_timeout这种轮询等待宏。这个宏非常实用比如等待寄存器某位变为1u32 val; ret readl_poll_timeout(reg_addr, val, (val BIT(0)), delay_us, timeout_us);readl_poll_timeout会以delay_us间隔读取寄存器直到条件满足或超时。它内部处理了忙等待和睡眠的多种情况在你调试寄存器初始化时序的时候能省不少事。5. 调试驱动比写驱动更能看出水平5.1 老牌printk的进阶用法很多新手觉得printk太low但事实是内核调试里90%的问题都能靠日志定位。printk有八个级别从KERN_EMERG到KERN_DEBUG级别低于控制台loglevel的才会被打印到串口或终端。默认情况下pr_info不一定能看到pr_debug几乎肯定看不到。想看到调试信息最简单的办法是启动时加loglevel8或者动态修改/proc/sys/kernel/printk。但日志多了也有问题高频路径里的printk会把系统拖慢甚至造成时序变化导致bug不再复现。这就像在真实线上环境里加了一堆日志问题反而消失了。更合理的做法是用dev_dbg和动态调试dynamic debug机制先编译时开启CONFIG_DYNAMIC_DEBUG运行时可以通过debugfs动态开关某个文件或某个函数的日志完全不打扰正常流程。5.2 动态调试与ftraceftrace是我调试驱动性能问题时最常用的工具。它可以在不改代码的情况下跟踪内核函数的调用栈和执行时间。比如你想知道驱动里某个函数被谁调用了# 挂载tracefs mount -t tracefs tracefs /sys/kernel/tracing # 查看可用跟踪器 cat /sys/kernel/tracing/available_tracers # 使用function_graph跟踪器 echo function_graph /sys/kernel/tracing/current_tracer echo my_function* /sys/kernel/tracing/set_ftrace_filter echo 1 /sys/kernel/tracing/tracing_on cat /sys/kernel/tracing/traceftrace的开销远小于无脑printk而且能看到调用链这是单靠日志很难得到的。还有kprobe和uprobe机制可以直接在任意内核函数入口插入探针采集参数和返回值。这本书的调试章节把这几种手段串了起来我觉得这正是“硬核”二字的体现不是教你死记API而是教你一整套定位问题的思路。5.3 用QEMU和开发板打配合不是所有人手头都有一块开发板好在有QEMU这样的人工模拟器。QEMU可以模拟virt机器配合-kernel参数直接启动一个编译好的内核再用initramfs跑一个最小根文件系统。这样你可以在PC上反复测试驱动任意加断点、改代码完全不用担心把板子搞坏。我个人的习惯是新写一个驱动先在QEMU里跑通确认逻辑正确后再部署到真实硬件。QEMU模拟不了所有硬件细节比如某些寄存器时序、DMA行为但对90%的框架性验证绰绰有余。这本书里的示例代码也照顾到了没有开发板的读者在虚拟机里就能跑降低了入门的物理门槛。6. 常见问题与避坑实录6.1 模块加载失败“Invalid module format”这个问题90%的原因是模块编译用的内核头文件和当前运行的内核版本不一致。检查一下uname -r ls /lib/modules/$(uname -r)/build如果build目录不存在需要先安装对应的内核头文件。还有一个隐藏坑如果你自己重新编译过内核但是模块用的还是旧版本的Module.symvers同样会报错。建议编译模块前先make clean确保KDIR指向正确。6.2 设备节点无法自动生成很多新手用register_chrdev注册了字符设备也调了device_create但/dev下就是没有节点。这时候需要检查使用的class_create和device_create的devt参数是否正确。老内核的device_create函数签名的确不同class_create也可能会需要THIS_MODULE多版本兼容时建议用条件编译或直接看头文件。另外确认udev/mdev是否正常工作有些精简根文件系统里没有mdev需要手动mknod。6.3 insmod没问题但open失败或崩溃如果open返回-ENODEV通常是设备号不对应或者设备没有被正确创建。返回-EFAULT则说明用户态指针非法或者copy_to_user的参数有误。如果直接系统重启多半是内核指针越界或非法访问。此时第一件事不是改代码而是用dmesg把最后的日志抓出来看是Unable to handle kernel NULL pointer dereference还是page fault。信息里会给出出错地址和当前寄存器配合objdump反汇编就能定位。我踩过最深的一个坑是设备树里寄存器地址写错probe函数里又用了devm_ioremap导致访问了一个完全不存在的物理地址结果系统启动后一加载模块就hang死。后来通过busybox的devmem命令反复核对寄存器才发现是设备树里少了一层ranges地址转换。所以遇到硬件访问问题先别怀疑代码用devmem读一遍硬件寄存器确认地址是否存在。7. 写在最后这本书的打开方式前面写了不少技术细节最后聊聊我对这本书的看法。我自己买过很多Linux内核相关的书大多数是当工具书查真正能从头到尾读完的很少。《手把手教你学Linux设备驱动开发》和其他书不一样的地方在于它把“学驱动”这件事拆成了一个个小实验而且每个实验都能在真实环境里看到效果。这种即时反馈对学习特别重要就像你学编程时第一次跑通“hello world”的成就感驱动开发里的“hello world”就是看到一个设备节点出现并且能读写它。如果你是刚接触Linux内核的新手我的建议是不要跳着读。老老实实从模块的编译加载开始把字符设备、并发、中断、设备树这几个主线章节吃透。期间哪怕只用一个QEMU虚拟机也一定要亲手把代码敲一遍不要复制粘贴。遇到编译错误、运行崩溃不要慌先用dmesg看日志再回到书里找对应的解释。这本书的代码示例都比较精简非常适合在qemu-system-arm或开发板上反复实验。我也要提醒一句驱动开发不是看会的是调出来的。你可能花一晚上把一个spinlock死锁问题搞定然后发现真正的收获不是那行spin_unlock而是学会了如何用ftrace看函数调用栈。这本书能把你领进门但“硬核”的内功还得靠你在板子和串口前慢慢磨出来。至少现在我们终于有了一本可以安心跟着敲代码的教程而不是翻两页就要去搜索引擎里求救的“天书”。
返回列表