ARTICLE DETAIL

资讯详情

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

Linux设备驱动开发入门:从内核模块到字符设备与设备树实战

Linux设备驱动开发入门:从内核模块到字符设备与设备树实战 最近《手把手教你学Linux设备驱动开发》正式出版好几个读者在后台私信问我这本书到底值不值得啃。我自己是Linux驱动开发出身这些年带过不少新人也面过不少候选人说实话驱动开发这块能讲清楚的书并不多要么太偏理论上来就是内核源码分析新手直接劝退要么太偏应用把open、read、write封装一下就算完事根本没碰到驱动的核心。这篇文章不打算替出版社吹什么就借这个机会把我从内核模块懵懂期到能独立调通板子的完整路径拆开讲讲包括字符设备驱动的完整代码、设备树和platform总线的理解方式、以及最常见的翻车现场。想入嵌入式Linux方向或者正在准备内核面试的朋友可以参考着看。1. 为什么Linux设备驱动开发让人又爱又恨1.1 你写的代码跑在“另一层世界”里Linux驱动开发本质上是内核态编程。用户态的程序写习惯了printf随便打段错误大不了core dump一下进程崩了重新拉起来就行。但驱动一旦踩到野指针轻则内核oops重则整个系统panic甚至把开发板的文件系统写坏那种挫败感非常劝退。内核态和用户态最大的区别在于用户态有进程边界帮你兜底有MMU权限隔离有各种运行时保护机制内核态什么都没有你写的代码就是系统的一部分一个空指针解引用就能把整个系统干掉。malloc不能随便用printf也不叫printf了而是printk还要分日志级别。你在驱动里调用的每个API背后几乎都对应硬件寄存器操作、内存屏障、锁机制。驱动工程师每天要面对的核心任务就是把硬件能力安全地暴露给用户态程序。读到的是GPIO电平点亮的是屏幕转起来的是电机传出去的是一帧帧网络数据。用户态的程序不认识硬件只知道open、read、write、ioctl这些系统调用落在VFS层之后就会根据设备号找到你的驱动最终执行到你注册的函数指针上。这一整个过程就是Linux设备驱动开发的主战场。1.2 为什么内核和驱动知识越来越值钱这几年嵌入式设备数量暴增从智能家居、工业控制到车载系统几乎每个终端设备里都跑着一个定制过的Linux内核。国产操作系统的推进也让内核适配工作多了起来很多团队在招聘时发现真正能上手做BSP、做驱动适配的人非常稀缺。哪怕是应用开发的岗位面试官也会问设备树、platform总线、copy_to_user、自旋锁和mutex这种驱动基础概念因为驱动是理解整个操作系统运行机制最好的钥匙。我自己带过的实习生里凡是认真撸过一个字符设备驱动的再回头写应用层代码对阻塞、并发、内存的理解完全不一样。驱动开发不仅仅是一个就业方向更是一把能打开操作系统黑盒的钥匙。回到这本《手把手教你学Linux设备驱动开发》它的定位其实就很明确不是摆一本源码注解让你自己啃而是带着你一步步把驱动写出来用工程实践反推理论学习。下面我按自己学习的顺序把驱动开发的骨架完整拆解一遍。2. 学习驱动开发前必须搞懂的五个地基2.1 内核模块机制驱动是怎么“插”进系统的驱动在Linux里通常不是一个独立进程而是一个可加载内核模块英文叫Loadable Kernel Module简称LKM。写完代码后编译得到一个.ko文件insmod把它加载进内核rmmod把它卸载掉。模块的入口点是module_init指定的init函数退出点是module_exit指定的exit函数。很多新手第一次看到内核模块代码会懵怎么和平时写C程序完全不一样没有main函数甚至不链接任何用户态库。这里要记住一个核心规律内核模块不是一个进程它是在内核地址空间运行的代码段。它不能调用glibc的函数不能用printf、malloc所有依赖的内核函数都需要由内核导出符号来提供。还有个新手容易忽略的细节MODULE_LICENSE(GPL)这行如果不写有些内核API是不给你用的因为内核里很多符号的导出依赖GPL标记。编译也不是直接用gcc而是通过内核的Kbuild构建系统用一套专门的Makefile把.c文件交给内核源码目录下的构建脚本处理。我以前见过有人试着用gcc直接编.ko文件编出来的模块insmod进去直接报Invalid module format就是因为没走内核构建系统生成的ELF格式和内核期望的完全不一样。2.2 字符设备、块设备、网络设备先选对赛道Linux设备驱动从分类上讲可以分成三大类字符设备、块设备、网络设备。这个分类不是随便分的它决定了驱动要注册的结构体、要实现的接口、以及在/dev目录下的呈现方式。字符设备是最基础也最典型的设备类型。它按字节流访问应用程序调open、read、write、close底层就是操作struct file_operations里注册的函数。键盘、串口、GPIO、I2C控制器都是字符设备。块设备则是按块访问有缓存、有I/O调度器硬盘、eMMC、U盘这类存储介质属于块设备驱动要注册的接口是struct block_device_operations还要实现一个request处理函数。网络设备又不一样它不走/dev节点而是通过struct net_device结构体注册数据要走内核协议栈核心操作是ndo_open、ndo_start_xmit这些回调。新手入门首选字符设备没有悬念。原因很简单字符设备驱动逻辑最直白你把struct file_operations填好注册进内核剩下的就是和硬件寄存器打交道了。这个过程中涉及的所有核心机制——设备号分配、cdev注册、设备节点创建、用户态交互——都是其他类型设备的地基。2.3 设备模型platform总线、设备树、sysfs三件套现代Linux驱动离不开设备模型也就是内核里的device、driver、bus这三棵树的组织结构。设备模型的核心逻辑是总线负责把设备和驱动配对当配对成功时调用驱动的probe函数驱动的主要初始化工作全部在probe里完成。这里有个非常关键的概念叫platform总线也叫平台总线。它不是真实的硬件总线而是一条虚拟总线用来挂载那些不依附于PCI、USB、I2C等物理总线的设备。比如SoC内部集成的UART控制器、GPIO控制器、DMA控制器在物理上并没有一条“平台总线”把它们连起来但内核为了方便管理统一把它们挂到platform总线上。设备侧用struct platform_device表示驱动侧用struct platform_driver表示匹配成功后执行probe。设备树Device Tree则是另一个必须搞懂的东西。它的作用是描述硬件拓扑比如板子上有哪些外设、寄存器基地址是多少、中断号是多少、GPIO引脚接在哪里全部用一个.dts文本文件描述出来。内核启动时解析这个文件动态生成platform_device再和驱动配对。这也是老牌驱动开发者和新手最明显的分水岭懂设备树的人看驱动先看dts文件不懂的人一上来就抱着驱动代码读结果怎么都连不起来。sysfs是设备模型在用户态的映射目录挂载在/sys下。你可以通过/sys/bus/platform/devices/查看所有平台设备通过/sys/bus/platform/drivers/查看每个平台驱动。写驱动时经常要通过属性文件在用户态和内核态之间传递数据sysfs就是现成的通道比如/sys/class/xxx/yyy这样的节点底层就是驱动里注册的attribute。2.4 并发与同步多核时代驱动必须会的一课并发问题是我面试时必考的内容因为它暴露了一个人到底有没有真正写过驱动还是只会照着例程抄。内核里的并发来源比用户态多得多进程可能被抢占、中断可能随时到来、SMP多核处理器上多个CPU可能同时执行到你的驱动代码。初学者写驱动最容易犯的错误就是觉得一个全局数组很安全结果两个进程同时write数据互相覆盖。解决并发访问的主要工具是锁。临界区比较长、可能睡眠的场景用mutex原子操作、中断上下文、临界区极短的场景用自旋锁。这里有个经典误区自旋锁持有期间不能睡眠因为它忙等mutex持有期间可以睡眠但不能再中断上下文使用。此外还有原子变量、读写锁、RCU、per-cpu变量等更高级的并发机制但入门阶段先把mutex和自旋锁用熟就够了。在我下面的示例驱动里会用一个mutex保护共享缓冲区同时用一个open_count记录打开次数。这个例子看起来简单但足够演示锁的使用方式。2.5 内存与IO为什么驱动里不能随便搞个全局数组Linux驱动开发里还有一个绕不开的坑就是内存管理。内核和用户态的内存模型不一样用户态malloc失败最多返回NULL内核里kmalloc失败如果你不做检查空指针解引用直接oops。而且kmalloc还有GFP标志位GFP_KERNEL允许睡眠适合进程上下文GFP_ATOMIC不允许睡眠适合中断上下文和自旋锁内。更关键的是用户态内存访问。内核不能直接解引用用户态传进来的指针因为那个地址可能还没映射、可能被换出、也可能是恶意的非法地址。驱动必须用copy_to_user和copy_from_user这两个函数来安全地搬运数据它们会做地址合法性检查并在拷贝失败时返回错误码-EFAULT。再说硬件寄存器访问。内核里操作寄存器不是直接解引用物理地址而是先ioremap把物理地址映射为内核虚拟地址再用writel/readl这类函数读写。设备树里配置的reg属性最终就是给驱动提供物理地址和长度probe时由devm_ioremap_resource完成映射。这个流程后面讲平台驱动时会反复遇到先记住这两个函数名。3. 从零手写一个字符设备驱动完整代码拆解3.1 一个五脏俱全的mydemo驱动下面这个示例驱动我管它叫mydemo。麻雀虽小五脏俱全有设备号分配、cdev注册、设备节点自动创建、read/write实现、互斥锁保护、参考计数。你把这个驱动编译加载跑通字符设备驱动的地基就算打完了。#include linux/module.h #include linux/init.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #include linux/slab.h #include linux/mutex.h #define DEVICE_NAME mydemo #define CLASS_NAME mydemo_class #define BUF_SIZE 1024 static int major; static struct cdev my_cdev; static struct class *my_class; static char *kernel_buf; static struct mutex buf_lock; static int open_count; static int my_open(struct inode *inode, struct file *filp) { mutex_lock(buf_lock); open_count; mutex_unlock(buf_lock); pr_info([mydemo] open called, open_count%d\n, open_count); return 0; } static int my_release(struct inode *inode, struct file *filp) { pr_info([mydemo] release called\n); return 0; } static ssize_t my_read(struct file *filp, char __user *buf, size_t len, loff_t *off) { size_t to_read len BUF_SIZE ? BUF_SIZE : len; mutex_lock(buf_lock); if (copy_to_user(buf, kernel_buf, to_read)) { mutex_unlock(buf_lock); return -EFAULT; } mutex_unlock(buf_lock); *off to_read; pr_info([mydemo] read %zu bytes\n, to_read); return to_read; } static ssize_t my_write(struct file *filp, const char __user *buf, size_t len, loff_t *off) { size_t to_write len BUF_SIZE ? BUF_SIZE : len; mutex_lock(buf_lock); if (copy_from_user(kernel_buf, buf, to_write)) { mutex_unlock(buf_lock); return -EFAULT; } mutex_unlock(buf_lock); *off to_write; pr_info([mydemo] write %zu bytes\n, to_write); return to_write; } static struct file_operations my_fops { .owner THIS_MODULE, .open my_open, .release my_release, .read my_read, .write my_write, }; static int __init my_init(void) { dev_t devno; int ret; ret alloc_chrdev_region(devno, 0, 1, DEVICE_NAME); if (ret 0) { pr_err([mydemo] alloc_chrdev_region failed\n); return ret; } major MAJOR(devno); cdev_init(my_cdev, my_fops); my_cdev.owner THIS_MODULE; ret cdev_add(my_cdev, devno, 1); if (ret) { unregister_chrdev_region(devno, 1); return ret; } my_class class_create(CLASS_NAME); if (IS_ERR(my_class)) { cdev_del(my_cdev); unregister_chrdev_region(devno, 1); return PTR_ERR(my_class); } device_create(my_class, NULL, devno, NULL, DEVICE_NAME); kernel_buf kzalloc(BUF_SIZE, GFP_KERNEL); if (!kernel_buf) { device_destroy(my_class, devno); class_destroy(my_class); cdev_del(my_cdev); unregister_chrdev_region(devno, 1); return -ENOMEM; } mutex_init(buf_lock); open_count 0; pr_info([mydemo] initialized, major%d\n, major); return 0; } static void __exit my_exit(void) { dev_t devno MKDEV(major, 0); device_destroy(my_class, devno); class_destroy(my_class); cdev_del(my_cdev); unregister_chrdev_region(devno, 1); kfree(kernel_buf); mutex_destroy(buf_lock); pr_info([mydemo] exited\n); } module_init(my_init); module_exit(my_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple char device demo driver);这段代码里最需要注意的是class_create这行。在新版内核6.4以上中class_create接口去掉了owner参数只保留一个name参数而在老版本内核里必须写成class_create(THIS_MODULE, CLASS_NAME)。如果你编译报错提示参数数量不对第一反应就应该是查一下当前内核版本对应的API变化。做驱动开发内核API版本差异是躲不掉的习惯就好。3.2 Makefile驱动编译的正确姿势驱动编译不是用gcc直接编出来的而是要借助内核的构建系统。Makefile写法如下obj-m : mydemo.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解释一下原理obj-m : mydemo.o 表示把mydemo.c编译成内核模块KERNELDIR指向当前内核的构建目录它本质是一个软链接指向/usr/src/linux-headers-xxx这个目录make -C进入该目录M$(PWD)告诉内核构建系统回到当前目录编译模块。编译之前必须先装好内核头文件。在Ubuntu/Debian系下执行sudo apt update sudo apt install linux-headers-$(uname -r)CentOS/RHEL系则安装kernel-devel安装后确认/lib/modules/$(uname -r)/build目录存在。这一步如果没做报错通常是看不到Kbuild文件的路径错误。还有一点编译环境的内核版本必须和运行环境一致否则编出来的.ko会因version magic不匹配而无法加载。3.3 加载测试insmod到用户态读写全流程驱动编译好之后按下面顺序一步步验证# 加载模块 sudo insmod mydemo.ko # 确认模块已加载 lsmod | grep mydemo # 查看内核日志确认初始化信息 dmesg | tail # 查看系统分配的设备号 cat /proc/devices | grep mydemo # 如果设备节点没有自动创建手动创建 sudo mknod /dev/mydemo c 240 0 # 写数据进驱动 echo hello driver /dev/mydemo # 读数据出来 cat /dev/mydemo # 卸载模块 sudo rmmod mydemo # 再查日志确认退出信息 dmesg | tail正常情况下cat应该能打印出刚才echo的内容dmesg里也能看到对应的read和write日志。这套流程跑通之后你对驱动的加载、设备号、设备节点、文件操作函数的理解就会串联起来。关于设备节点这里有个重要机制要说明我们在init里调用了device_create它会在class下生成设备信息然后由用户态的udev守护进程根据这个信息在/dev下自动创建设备节点。如果你的系统看不到/dev/mydemo八成是class_create或device_create调用失败去dmesg里查错误原因。手动mknod只能应急真实产品里靠的是udev规则自动管理。4. 驱动开发最常见的翻车现场与排查技巧4.1 问题速查表我在带新人的过程中总结过一套驱动开发高频问题速查表先给出来后面逐个展开。现象可能原因排查方向insmod报Invalid module format内核版本不匹配或未走Kbuild编译检查version magic确认编译用的内核头文件与运行内核一致printk日志不显示日志级别高于当前控制台级别dmesg查看临时设置printk级别/dev节点找不到device_create失败或udev规则缺失查看dmesg检查class注册是否成功读写数据不对或系统卡死用户态指针直接解引用检查是否使用copy_to_user/copy_from_userrmmod报Device or resource busy设备文件仍被进程占用fuser -v /dev/mydemo关闭相关进程后卸载系统oops野指针、越界访问、锁使用错误dmesg查PC指针和函数名启用KASAN4.2 逐个排查的经验之谈第一种情况最常见也最让人头疼。insmod提示Invalid module format时千万不要加--force强上那是自欺欺人。正确做法是modinfo mydemo.ko查看vermagic字段再用cat /proc/version查当前内核版本。只要两者不一致重新用当前内核头文件编译一遍就好。很多新手在这个坑里浪费大量时间就是因为没有意识到驱动必须和内核版本绑定。第二种情况printk日志看不到通常不是驱动没运行而是日志级别问题。Linux内核的printk有8个等级控制台默认只显示级别足够高的消息低级别的调试信息会进内核环形缓冲区但不打印到终端。这个时候用dmesg查看一定能看到。我写驱动时习惯在pr_info里加模块名前缀比如[ mydemo ]这样dmesg里grep起来非常方便这个习惯值得养成。第三种情况/dev节点找不到优先检查dmesg。常见原因有两个一是device_create传参错误导致返回ERR_PTR但代码里没检查二是udev没有为这个class生成规则。调试时可以先用mknod手动创建节点验证驱动本身再把udev的问题单独处理。第四种情况是最容易造成系统崩溃的。用户态传进来的地址内核态不要去解引用。有些驱动为了省事直接把用户指针塞进memcpy结果应用传一个非法地址内核直接oops给你看。正确做法是始终用copy_to_user/copy_from_user这两个函数内部会做access_ok检查并且能处理缺页异常。类似的还有get_user/put_user用于单值拷贝。第五种情况rmmod卸载失败本质是模块的引用计数不为0。要么有进程仍然持有打开的设备文件要么有其他模块依赖这个模块。fuser -v /dev/mydemo可以看到谁占用了设备找到进程kill掉再卸载。注意如果驱动里没实现release函数即使关掉最后进程后引用计数归零模块也可能无法安全卸载因为还有指针悬挂问题。第六种情况oops是驱动开发里最严重的错误。遇到oops不要慌先看dmesg重点是PC指针指向哪个函数、调用栈里有哪些函数、以及错误类型是NULL pointer dereference还是page fault。我调试时还会打开内核的KASAN和UBSAN检测它们能帮助精确找出内存越界和未定义行为的位置虽然会拖慢性能但在开发阶段是值得开的。5. 工具选型、资源推荐与进阶路线5.1 学习环境虚拟机和开发板怎么选驱动开发必须有实际的编译运行环境。纯软件学习阶段强烈推荐虚拟机里装一个Ubuntu或Debian然后用QEMU模拟一台支持virtio驱动的虚拟设备跟着教程写一个virtio驱动这是最安全、成本最低的练手方式。QEMU的好处是方便gdb远程调试内核kmalloc踩崩了整个guestOS重启就是宿主机不受影响。等到想接触真实硬件寄存器、中断、GPIO这些物理概念时就需要一块开发板了。树莓派、瑞芯微、全志这类板子都行关键在于拿到板子之后要确认内核源码、交叉编译工具链、设备树文件是否完整。很多新手拿到板子第一件事是去折腾屏幕上跑个GUI这个方向就跑偏了。正确的姿势是先读板子原理图找到一颗可控的LED或按键写一个platform驱动用设备树描述它的GPIO然后控制它。这一步通了你对设备树、platform总线、GPIO子系统的理解会突飞猛进。5.2 代码阅读工具内核源码怎么“啃”内核源码量太大裸读不现实。工具方面Source Insight是老派选择看代码跳转最舒服开源方案里VSCode clangd或ctags也能凑合。我的个人习惯是直接用vim加tags文件加一个cscope全文搜索函数调用关系。内核源码这种东西没必要全文读带着问题去读才是效率最高的方式。这里分享一个我一直在用的笨办法遇到不认识的函数先不急着查网络而是在内核源码里grep它的定义和所有调用点把上下文读一遍很多问题就自然解开了。比如你想知道device_create到底做了什么事那就去看drivers/base/core.c里的实现你会发现它实际上创建了一个device结构体再把这个device注册到设备模型里最后通过uevent通知用户态。看懂了源码就不需要背API了。5.3 从会写到写好一条明确的主线驱动开发的进阶路线我建议按五步走第一步内核模块和字符设备驱动搞清楚insmod、设备号、file_operations把前面的mydemo驱动跑通第二步平台驱动和设备树理解driver和device的匹配机制学会在.dts里描述外设第三步中断、定时器、工作队列这是驱动响应外部事件的基础能力第四步内存映射、DMA、IOMMU处理高性能数据传输场景第五步复杂子系统比如USB gadget、MIPI DSI显示、网络phy驱动。前两步是入门关卡走完就能应付大部分嵌入式Linux岗位的日常开发。第三步开始才是硬核门槛涉及到对内核异步机制的理解。第四步和第五步通常要在特定行业里才能大量实践比如摄像头驱动、网卡驱动、存储驱动这些方向都需要长期积累。《手把手教你学Linux设备驱动开发》这本书的主线其实也是按这个顺序排的模块基础、字符设备、并发内存、platform和设备树、中断和时间管理一步步推到实战项目。它更适合作为一条学习路径的导览而不是一个函数参考手册。书里每个例程我都会建议你亲手编译、加载、测试一遍再自己改动几个参数看看行为变化这样才能把书里的知识变成自己的。我个人在实际操作中的体会是驱动开发这条路没有捷径但是可以少走弯路。最快的成长方式就是手上时刻准备一个内核源码目录遇到问题直接从源码层面找答案再配合一块能折腾的开发板把每一个例程都当成真实硬件来调。看书的作用是帮你把这套主线快速串起来少花时间去猜下一步该学什么。但最终你会依赖的东西还是那三个工具dmesg、源码grep、以及一颗愿意跟oops死磕到底的决心。
返回列表