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

资讯详情

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

Linux驱动开发实战:从内核模块到设备树,系统构建驱动知识体系

Linux驱动开发实战:从内核模块到设备树,系统构建驱动知识体系 Linux驱动开发这条路说难也难说简单也简单。难在它跟普通的应用层编程完全是两个世界一上来就是内核态、内存屏障、并发控制、设备树概念堆得比山高简单在有了一本真正能带你入门的书之后你会发现在理清了脉络之后它其实就是一门“熟练工”技术只要按正确的路径走哪怕是一路踩坑也能一步步把驱动跑起来。最近收到《手把手教你学Linux设备驱动开发》正式出版的消息我第一时间看完了一遍完整的内容整体印象是八个字够系统、够硬核、够实操。这篇文章我不打算写那种“恭喜新书发布”的客套话而是把我读完、跑完之后的真实体验和总结的干货分享出来内容包括这本书怎么用、驱动开发的关键技术点在哪里、我实际验证过的步骤和代码、以及初学者踩坑的常见问题。1. 为什么说Linux驱动开发是“硬核”技能1.1 从应用开发到内核态思维到底变在哪我见过不少做了两三年应用开发的工程师Linux API背得滚瓜烂熟多线程、网络编程、文件I/O信手拈来但一碰驱动就抓瞎。为什么因为驱动开发压根不是“换个API”的事而是整个思维模型都得换。先看一段最简单的应用层代码读取一个文件int fd open(/dev/xxx, O_RDWR); read(fd, buf, 128); close(fd);这个流程顺理成章对吧任何学习Linux编程的人都能看懂。但你知道吗这个open的调用会通过VFS虚拟文件系统进入到内核然后查找到这个设备对应的file_operations结构体再调用到你写的xxx_open函数。如果你不写这个驱动内核根本没能力响应这个open。也就是说应用层的每一次文件操作最终都要落到驱动层去执行。所以说驱动开发不是“教你调用一堆奇怪的函数”而是让你站在内核的角度去理解——用户程序发起的请求是如何一步步传进内核、被驱动接管、操作硬件、再把结果原路返回的。这个思维一转过弯你再回过头看代码真的就是一个新世界。1.2 驱动开发在嵌入式Linux里的真实地位现在嵌入式Linux的招聘需求十家有八家都要“熟悉Linux驱动开发了解设备树有字符设备驱动经验”。为什么驱动这么吃香因为这个领域看起来门类很多但实际上核心逻辑是非常稳固的。以运行Linux的嵌入式产品为例路由器、智能家居网关、工控机、车载中控它们的硬件配置五花八门不同厂家的处理器、五花八门的外设芯片WiFi模组、音频Codec、LCD屏、4G模块、传感器、各种总线接口I2C、SPI、UART、USB、PCIe、SDIO。这些外设要正常工作哪一样不得靠驱动而市面上现成的驱动往往跟主线的内核版本不匹配或者需要根据不同板子做定制修改。所以你会看到驱动开发的招聘热度不降的一个重要原因就是芯片可以买通用的板子可以画公板但驱动一定得结合具体项目和硬件去适配它是逃不掉、绕不开、也替代不了的一环。这本书的核心价值就体现在这个点上——它不教你背代码而是教你理解框架之后去适配自己的板子和芯片。1.3 这本书适合谁读完能到什么水平如果按人群划分我觉得这本书最对口的是这三类人做嵌入式Linux应用开发已经能熟练写应用层程序想往内核和驱动方向进阶的工程师电子工程、自动化、计算机等专业的学生在毕设或实验室项目里需要用到Linux驱动的已经在做驱动开发但之前主要停留在“有代码就抄有问题就搜”的状态想系统梳理一遍的从业者。我个人的建议是就算你没有任何内核源码阅读基础只要在Linux下写过基本的C语言程序了解文件操作和基本的数据结构这本书就可以直接开啃。书里对关键概念的铺垫做得挺足不会上来就甩一堆源码然后让你自己悟。“手把手”这三个字用得不虚。2. 这本书是怎么帮你构建内核驱动知识体系的2.1 从字符设备驱动到总线平台的进阶路线我读完目录和正文之后觉得很值得点个赞的是它的编排逻辑。它不是“名词百科式”的罗列也不是“一个例子走天下”的草草带过而是按照驱动开发的实际成长路径来设计的。全书前半段聚焦字符设备驱动。字符设备是Linux驱动里最基础、最容易理解的一种open对应openread对应read思路直接适合作为入门切口。这一段会把file_operations、设备号申请、cdev注册、class_create、device_create等骨架代码给你讲透并且配合一个完整的demo跑通。后半段则压在了总线平台驱动模型和实际硬件驱动上。这部分才是真正嵌入式的味道因为现在的Linux驱动基本都在设备树 platform驱动框架下运行你要会看原理图找外设地址会写设备树节点会匹配compatible字符串还要明白probe函数被调用的时机。这本书在这个环节用了不少篇幅把这种“看似玄学”的驱动匹配机制剥开来讲。2.2 内核机制不是背概念而是看代码怎么用很多Linux驱动教材爱讲大而全的内核理论什么进程调度、内存管理一写就是几十页但读者学完还是不会写驱动。这本书给我的感受是它的理论一定是为了解释代码而存在而不是为了凑知识体系。比如讲并发与竞争时它会直接给你看一段带数据竞争问题的驱动代码运行起来数据错乱然后再引出自旋锁、互斥锁告诉你什么场景该用哪种锁。这种方式远比“锁是一种同步机制分为自旋锁和信号量”这种干巴巴的定义要有用得多。再比如讲中断它不是上来就列request_irq的参数而是先给你一个用轮询方式读按键的驱动让你自己感受到CPU被白白占用的痛苦然后才引出中断下半部机制包括tasklet和工作队列。有对比、有场景、有性能焦虑原理自然就吃透了。这本书在这一点上做得挺好它就是让你先看到问题再给你一个能落地的解法。2.3 开发环境与实验平台的合理建议关于开发环境我自己最常用的组合是Ubuntu 22.04 内核源码 QEMU模拟器来学习调试而书里也给了类似的可替换路径。如果你有实体开发板比如IMX6ULL、STM32MP1这类那体验会更直观因为可以直接看到驱动控制真实的GPIO、读取真实的传感器数据。我个人的经验是入门阶段别急着买开发板先在虚拟机里用QEMU搭建一个最小的Linux系统配合内核模块的编译和加载把hello world模块、字符设备交互这个过程跑通重点理解“模块如何被内核加载、设备节点如何生成、应用层怎么和驱动通信”。搞清楚这三个环节后再转移到开发板上你会发现剩下的就只是引脚配置和具体外设寄存器的区别了。3. 手把手实操我自己跑通的内核模块与设备驱动3.1 环境准备虚拟机、内核头文件与编译工具链不管你是跟着书走还是准备自己折腾环境永远是第一个门槛。这里我详细列一下我验证过的步骤大家可以直接抄作业。我的测试环境是 Ubuntu 22.04 LTS内核版本 5.15.0用的就是系统自带的内核和头文件。如果你还没有安装内核头文件可以先执行sudo apt update sudo apt install linux-headers-$(uname -r) build-essential这个命令会把当前内核对应的头文件以及gcc、make等编译工具一并装好。强烈建议不要自己随便下载一个源码包来编模块因为内核模块的编译必须严格匹配当前运行的内核版本和配置否则会出现“版本魔术字不匹配”的问题。验证一下头文件是否装好ls /lib/modules/$(uname -r)/build如果能看到一堆softlink和目录说明环境基本就绪。3.2 第一个内核模块不止是打印hello world很多教程的第一个驱动模块都是“hello world”但说实话光打印一句Hello, kernel!对你理解驱动没有太大帮助它只是验证了模块的编译和加载流程。所以我把实验稍微升级一点写一个模块在加载时创建一条内核线程每隔一秒打印一次当前的jiffies内核时钟滴答计数卸载时销毁该线程。看代码#include linux/init.h #include linux/module.h #include linux/kthread.h #include linux/delay.h #include linux/jiffies.h static struct task_struct *test_thread; static int flag 1; static int thread_func(void *data) { while (!kthread_should_stop()) { printk(KERN_INFO jiffies %lu\n, jiffies); msleep(1000); } return 0; } static int __init test_init(void) { printk(KERN_INFO module init\n); flag 1; test_thread kthread_run(thread_func, NULL, test_thread); if (IS_ERR(test_thread)) { printk(KERN_ERR failed to create thread\n); return PTR_ERR(test_thread); } return 0; } static void __exit test_exit(void) { flag 0; kthread_stop(test_thread); printk(KERN_INFO module exit\n); } module_init(test_init); module_exit(test_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple kernel thread demo);Makefile这么写注意KERNELRELEASE变量的判断是标准写法obj-m : thread_demo.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) default: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean然后执行make sudo insmod thread_demo.ko sudo dmesg | tail -20你会看到内核日志里jiffies的值每一秒都在增长。这个demo虽然简单但它把你带进了内核的世界模块加载机制、内核线程创建与销毁、printk日志系统、jiffies时间管理以及makefile的编写规则。这几个点是后面所有驱动开发都要反复用到的。记得退出时一定要sudo rmmod thread_demo因为kthread_stop使线程退出如果你强制rmmod而不停线程很可能在卸载时导致内核崩溃。3.3 真正的字符设备驱动echo与read的完整链路有了模块基础下一步自然就是写一个可以直接跟用户程序交互的字符设备。我照着书里的结构自己搭了一遍这里把核心代码拆开讲解。字符设备驱动的骨架主要有四步分配设备号、初始化cdev、添加设备、创建设备节点。推荐用动态分配设备号的方式避免手动分配可能引发的冲突#include linux/init.h #include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #define DEVICE_NAME mychardev #define CLASS_NAME mychar static int major_num; static struct class *mychar_class NULL; static struct device *mychar_device NULL; static char kernel_buffer[256] {0}; static int mychar_open(struct inode *inode, struct file *filep) { printk(KERN_INFO Device opened\n); return 0; } static ssize_t mychar_read(struct file *filep, char __user *buf, size_t count, loff_t *offset) { size_t len strlen(kernel_buffer); size_t to_read len count ? count : len; if (copy_to_user(buf, kernel_buffer, to_read)) { return -EFAULT; } printk(KERN_INFO Read %zu bytes from device\n, to_read); memset(kernel_buffer, 0, sizeof(kernel_buffer)); return to_read; } static ssize_t mychar_write(struct file *filep, const char __user *buf, size_t count, loff_t *offset) { size_t to_write count (sizeof(kernel_buffer) - 1) ? (sizeof(kernel_buffer) - 1) : count; if (copy_from_user(kernel_buffer, buf, to_write)) { return -EFAULT; } kernel_buffer[to_write] \0; printk(KERN_INFO Received %zu bytes from user\n, to_write); return to_write; } static struct file_operations fops { .owner THIS_MODULE, .open mychar_open, .read mychar_read, .write mychar_write, }; static int __init mychar_init(void) { dev_t dev_num; alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); major_num MAJOR(dev_num); printk(KERN_INFO Major number %d\n, major_num); cdev_init(mychar_cdev, fops); mychar_cdev.owner THIS_MODULE; cdev_add(mychar_cdev, dev_num, 1); mychar_class class_create(THIS_MODULE, CLASS_NAME); mychar_device device_create(mychar_class, NULL, dev_num, NULL, DEVICE_NAME); printk(KERN_INFO mychardev driver initialized\n); return 0; } static void __exit mychar_exit(void) { dev_t dev_num MKDEV(major_num, 0); device_destroy(mychar_class, dev_num); class_destroy(mychar_class); cdev_del(mychar_cdev); unregister_chrdev_region(dev_num, 1); printk(KERN_INFO mychardev driver removed\n); } module_init(mychar_init); module_exit(mychar_exit); MODULE_LICENSE(GPL);编译加载后你会看到/dev/mychardev这个节点自动出现。用一条命令就能测echo hello linux driver | sudo tee /dev/mychardev sudo cat /dev/mychardev这里有个关键点为什么向设备节点写入字符串驱动里要对kernel_buffer做大小限制因为如果用户传入的count过大直接memcpy会越界轻则内存踩踏重则内核oops。内核态的程序没有应用层那么多保护所有内存操作必须自己严格把控边界。用copy_from_user和copy_to_user而不是直接解引用用户态指针也是同样的道理。因为用户态指针在内核态不能直接访问轻则需要access_ok校验重则直接崩溃。这两个函数会做完整的地址合法性检查。我在实际测试中还发现了另一个小坑如果echo时不加sudo普通用户连打开设备节点的权限都没有。如果你希望设备节点默认可以被普通用户访问可以在device_create时通过DEVICE_ATTR设置权限或者在udev规则里给/dev/mychardev设置mode为666。这个细节很多教程根本不提但实际项目里加权限控制又是常见的需求。3.4 设备树与platform驱动现代驱动的正确打开方式上面那个字符设备驱动是纯粹的驱动逻辑没有涉及硬件匹配。但现在的Linux驱动开发尤其是嵌入式平台大多是基于设备树(dt)和platform驱动的模型来组织代码的。设备树的作用你可以理解为“把板卡的硬件信息从源码中剥离出来用一份类似JSON/XML的文本描述硬件拓扑”。外设地址、中断号、引脚功能都在.dts文件里描述。驱动则通过compatible字段和of_match_table去匹配特定的设备节点。我之前刚接触时最大的困惑是“那我的驱动程序怎么知道用哪个设备树节点”答案是通过compatible属性匹配。举个例子在设备树里有这样一个节点mydemo: mydemo1c00000 { compatible myvendor,mydemo; reg 0x1c00000 0x1000; interrupts 0 25 4; };那么在platform驱动里你就得定义一个of_device_id匹配表static const struct of_device_id mydemo_of_match[] { { .compatible myvendor,mydemo, }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, mydemo_of_match); static struct platform_driver mydemo_driver { .probe mydemo_probe, .remove mydemo_remove, .driver { .name mydemo, .of_match_table mydemo_of_match, }, }; module_platform_driver(mydemo_driver);一旦设备树里描述了匹配的节点内核在启动时或者模块加载时就会自动调用你的mydemo_probe函数。你在probe函数里要做的就是读取设备树中预留的地址、中断等资源并将它们映射到内存中方便后续操作。这本书在讲这部分时给的一个建议我印象很深先学会看设备树再谈写驱动。驱动代码写得再漂亮设备树节点描述错了或者compatible对不上驱动根本不会被调用。实际项目中设备树调通的难度有时候比驱动本身的难度还高。我自己就踩过这样的坑因为设备树里忘记加reg属性导致probe函数里调用platform_get_resource拿不到地址返回NULL然后往下执行时就触发了空指针异常。这种问题通过dmesg能看到oops信息但第一次碰到确实容易蒙圈。所以遇到驱动加载failure优先去查设备树节点而不是怀疑自己的C语言语法。4. 实战避坑指南我从这本书和实际调试中总结的教训4.1 模块编译错误与内核版本不匹配这个问题出现频率极高。明明在Ubuntu 20.04上编译得好好的模块拷贝到Ubuntu 22.04上加载就报“version magic mismatch”之类的错误。原因是内核模块和当前运行的内核版本、编译配置必须严格匹配。你在编译模块时Makefile里的KDIR指向的是当前系统的内核头文件路径编出来的.ko就带着当前内核的 vermagic 信息。一旦换到另一个内核版本驱动就会拒绝加载。最简单的解决办法在目标机器上重新编译。交叉编译时则要保证所使用的内核源码版本、工具链版本和烧录到板子里的内核完全一致。版本差一个patch都不行。4.2 printk日志级别与查看方式很多初学者说“我驱动里的printk什么都没打印”并不是没执行而是日志级别不够被系统日志过滤掉了。printk有8个级别从KERN_EMERG到KERN_DEBUG。默认控制台日志级别通常只显示KERN_INFO以上部分KERN_DEBUG信息根本不显示。排查时最稳的操作就是用dmesg而不是cat /proc/kmsg并且加上过滤sudo dmesg -n 8 sudo dmesg | grep mychardev另外提醒一下在中断上下文或原子上下文里最好不要用printk刷屏它内部有锁长时间大量打印会影响系统的实时性。调试完一定要及时清理日志输出。这里有一个克制习惯正式发布的驱动代码里一般只保留dev_info、dev_warn和dev_err来输出关键信息调试阶段的printk尽量删掉否则后期日志里全是垃圾信息。4.3 并发与竞态用户态不容易遇见的“隐形杀手”应用层写多线程程序已经要小心翼翼了在内核态并发问题更严重。中断上下文和进程上下文可能同时访问你的驱动数据SMP多核平台上两个CPU可能同时执行你的read函数同一时刻可能有多个进程同时打开设备节点。我在测试那个字符设备驱动时就故意没加锁然后开两个shell进程同时cat /dev/mychardev程序就出现了读到半截字符串的问题。解决办法是引入互斥锁或者信号量。具体用哪种取决于临界区的性质和持有时间临界区短、不会睡眠用spinlock自旋锁占用CPU等待临界区可能睡眠、操作较久用mutex互斥锁休眠等待。这本书里专门有一节讲这个问题并且配了一个用自旋锁保护共享变量的示例。个人觉得这是整本书里最能提升代码质量的一章因为很多刚入门的驱动工程师写的代码功能都对但一上高负载就崩源头基本都是并发控制没做好。5. 除了写代码你还需要建立这些底层认知5.1 学会读芯片手册和原理图驱动开发说到底是在“翻译”芯片手册里的寄存器描述给内核听。你连SCL、SDA的电平时序都不清楚怎么写I2C驱动连GPIO控制器的基地址都不知道怎么通过ioremap去操作引脚所以我强烈建议新手在学这本书的同时手里备一份你目标芯片的datasheet和参考手册。遇到不懂的寄存器位就翻手册去核。不要怕看不懂一开始可能觉得全英文很劝退但坚持两三个芯片之后你会发现大部分芯片手册的结构其实是相似的无非就是memory map、clock controller、pin mux、外设模块寄存器、中断控制器这几大块。硬件资料看明白了驱动代码反而好写。因为框架和API是死的寄存器是活的。5.2 阅读内核源码是最快的进阶方式有同学问我“Linux内核源码那么大根本读不完到底怎么看”我的答案很直接从你file_operations调用链出发顺着源码追下去。比如你已经写了一版基本的字符设备驱动那就可以试着去源码里追__register_chrdev、cdev_add的实现。不用全部看懂重点看它如何处理主次设备号、如何挂载到内核的查找表。再比如你在写platform驱动就可以去读drivers/base/platform.c看platform_driver_register和probe的调用时机。你会发现官方内核源码远比网上零散博客要系统得多而且没有过时问题。这本书也是明确推荐读者配合内核源码一起看的很多源码片段还标注了在哪个版本的内核目录下。跟着源码去读驱动相当于有人领着你走迷宫走几遍之后你就认得路了。5.3 调试工具链的熟练度决定你的效率这里我额外整理了一个调试工具组合供大家参考工具用途我的使用心得dmesg查看内核日志驱动调试第一工具学会搭配grep过滤关键词trace-cmdkernelshark跟踪内核事件适合调性能问题时用能看到函数调用时间线/proc、/sys查看运行时信息比如/proc/devices能看到设备号分配情况/sys/class能看到设备类ftrace动态跟踪内核函数排查卡死问题很有用能看到当前CPU在执行什么函数gdb QEMU内核级调试嵌入式环境下一套好用的组合代码走查神器devmem直接读写物理地址验证寄存器配置很高效但需要确认当前地址是否能被用户态访问熟练使用这几个工具你的调试效率至少提升一倍。很多时候驱动不工作不是代码逻辑错了而是某个寄存器没有被正确设置用devmem直接对比手册读一读寄存器立即就能发现问题。6. 从这本书出发你可以继续深挖的方向6.1 内核内存管理从kmalloc到DMA缓冲区驱动入门之后你可能很快就会遇到性能瓶颈。最常见的就是数据拷贝太多、DMA传输与CPU cache一致性没处理好。Linux内核提供了完整的内存管理接口kmalloc、kzalloc、vmalloc、dma_alloc_coherent等。什么时候用哪一个它们各自的内存分布和性能特征都是什么是一个很值得深挖的高级方向。尤其是做网络驱动和音视频采集驱动时DMA缓冲区的处理直接影响吞吐量。6.2 中断下半部与高分辨率定时器中断处理讲究“快进快出”。如果在中断上下文里做大量计算或者调用可能导致睡眠的函数系统就会变得卡顿甚至触发内核警告。tasklet、workqueue、hrtimer这些东西的组合使用都是老手和新手拉开差距的地方。等你能熟练地把一个按键中断从“轮询式”优化到“中断去抖定时器上报”之后你对内核的理解会再上一个台阶。6.3 从独立驱动走向子系统框架驱动开发还有一个重要的进阶维度是编进Linux的子系统框架。同样是写LED驱动你可以单独写一个简单的字符设备也可以注册到Linux的led-class子系统中这样用户态就可以通过sysfs的brightness节点直接控制LED还能跟trigger系统无缝配合。同理输入子系统input、RTC子系统、时钟框架clk、PWM框架、GPIO子系统都是Linux已经搭建好的成熟框架。新驱动写完如果符合子系统分类尽量往子系统上靠。这样代码更规整复用性更强也能借助内核的通用机制减少自己的重复劳动。6.4 内核同步机制的性能取舍我前面提了自旋锁和互斥锁的区别但实际开发中还经常遇到RCU、原子操作、per-cpu变量、seqlock等高级并发机制。它们的性能差异和应用场景完全不同。如果对并发控制不够敏感驱动在单核上跑得好好的一到多核SoC上就随机崩这真不是危言耸听。建议大家有时间把内核的Documentation/locking目录下的文档过一遍。7. 写在最后的个人体会我做Linux相关开发这些年最大的感受就是驱动开发的门槛不在于代码本身而在于信息差。很多人不是因为笨学不会而是被一堆碎片化、过时又互相矛盾的资料带偏了方向。所以当我看到这本《手把手教你学Linux设备驱动开发》时第一反应是替那些刚入门的同学松了一口气——这本书把从环境搭建、内核模块、字符设备、设备树到platform驱动、并发控制、中断处理的完整路径梳理得非常清晰而且每个环节都给了能直接跑的demo。如果你决定啃这本书我有三个具体的建议第一不要跳着看。前面的字符设备部分就算你觉得已经会了也建议动手把代码敲一遍因为后面的内容会不断复用前面的知识点。第二一定要自己编译、加载、卸载、调试。驱动开发是一门“手上功夫”光看不练是真的没用。哪怕是最简单的hello模块你也亲手编译一次感受一下内核模块从源码到.ko文件再到被内核加载的完整链路。第三遇到问题多查内核文档。Documentation目录和内核源码就是你最强的参考书。书帮你搭好了框架源码和手册帮你解决细节。驱动开发这条路没有太多捷径但有一条正确的路能让你少走很多弯路。这本书就是那条路上的一个不错的向导剩下的就靠你手上的开发板和你自己的耐心了。
返回列表