ARTICLE DETAIL

资讯详情

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

嵌入式Linux驱动开发实战:从设备树到字符设备完整指南

嵌入式Linux驱动开发实战:从设备树到字符设备完整指南 做嵌入式Linux开发这几年被问得最多的一个问题就是驱动开发到底难不难、怎么下手。尤其是刚接触这块的朋友拿到一块开发板对着厂商给的BSP和设备树文件往往一头雾水感觉驱动开发是内核里最玄学的部分。其实拆开了看Linux设备驱动开发的整套流程是有固定套路的环境怎么搭、设备树怎么写、字符设备框架怎么填、调试工具怎么用每一步都有章可循。这篇文章我就从实际开发的角度把这套流程完整走一遍把该踩的坑也一并讲清楚希望能帮正在入门或者卡在半路的你理清思路。1. 先把开发环境搭好不然后面全是折腾1.1 从一台虚拟机开始的环境选型驱动开发的第一步不是写代码而是准备好一个能编译内核、能反复折腾不至于搞坏主力机的环境。我自己的习惯是Windows主机上跑VMware或VirtualBox里面装一个Ubuntu LTS版本比如20.04或22.04。选择虚拟机方案最大的优势是快照。每次配置好干净的内核源码树和工具链打个快照之后编译内核或者改配置把系统搞崩了直接还原十分钟就能回到正常状态这比在实体机上重装系统省心太多了。内存建议分配8GB以上磁盘至少给80GB。内核编译过程吃内存编一次全量内核16GB的机器占用能到70%以上内存不够容易直接OOM。CPU核数尽量多分几个现在内核编译大都支持并行make -j$(nproc)能把多核利用率拉满编译时间能缩短到原来的四分之一。Ubuntu装好后有几个基础包必须装齐编译工具链、内核头文件、构建工具和文本处理工具。执行下面这条命令就能全部装好sudo apt update sudo apt install -y build-essential git vim flex bison libncurses-dev libssl-dev libelf-dev这套包里最容易忽略的是libssl-dev和libelf-dev新版内核编译时如果缺了这两个会直接报找不到头文件的错误尤其是开发4.18以上内核时特别常见。flex和bison是内核配置和解析器的依赖缺了会在make menuconfig阶段报错。1.2 交叉编译工具链与内核源码树的准备如果你的驱动目标是ARM板卡或者其它嵌入式平台就涉及交叉编译。这里有两类选择一类是用芯片厂商直接提供的交叉编译工具链这种通常已经打包好了带完整的sysroot比如Buildroot或Yocto输出的工具链另一类是从Linaro或ARM官网下载的通用工具链。我的建议是优先用厂商自带的SDK因为内核、bootloader和根文件系统都是用它编的编译器版本、C库匹配度最好能避免很多版本不兼容的坑。准备内核源码是另一个关键环节。如果目标是X86开发机上先调通逻辑可以直接用发行版提供的内核头文件但编写设备树和深度调试时最好还是把完整的Linux内核源码下载下来。内核源码怎么选版本有讲究跑在板子上的系统用的是哪个内核版本源码就对应那个版本不要图新用最新主线。很多厂商BSP会用带修改的内核分支这种情况下直接用厂商提供的仓库最稳。# 以5.15为例 wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.15.tar.xz tar xf linux-5.15.tar.xz cd linux-5.15 make ARCHarm64 defconfig # 按目标平台选择也可以用厂商的config编译模块前如果是针对本机的模块需要先生成一份与本机运行内核匹配的配置。最稳的方法是直接拷贝/boot目录下当前内核的configcp /boot/config-$(uname -r) .config make olddefconfig make modules_prepare这一步很多新手会漏导致编译模块时提示找不到Module.symvers或者generated/autoconf.h。这些文件就是modules_prepare阶段生成的它把内核源码树和当前运行内核关联起来了。换句话说模块不是独立编译出来的它必须和确切的内核版本以及配置一一对应。2. 设备树驱动开发的硬件活点地图2.1 为什么现在的Linux驱动离不开设备树老一代内核里板级硬件信息是用C语言结构体硬编码在arch/arm/mach-xxx目录下的比如注册一个I2C设备、配置一下GPIO全在platform代码里写死。这样做的坏处非常明显一块板卡一个分支换个引脚就要改内核代码重新编译代码里全是平台相关的判断逻辑杂乱难维护。设备树Device Tree把硬件描述和内核驱动逻辑彻底分开了。板子上有什么设备、设备挂在哪个总线、寄存器地址是多少、中断号是多少这些信息全部写在一个dts/dtsi文件中变成一个独立于内核的二进制dtb由bootloader加载后传给内核。驱动程序只需要根据自己的compatible字符串去匹配设备节点拿到资源后就能工作。这种架构下同一份内核镜像可以同时支持多块不同配置的板卡驱动开发者的工作重点也从“改板级文件”变成了“写好设备树节点和匹配逻辑”。如果你把设备树理解成一张“硬件活点地图”就很好上手了。每个设备节点就是地图上标注的一个点位指明“我在这里、我占用这些资源、我可以和什么驱动对接”内核拿到这张地图以后按图索骥把设备和驱动逐个配对。2.2 设备树节点的关键语法与匹配流程设备树源码分为dts、dtsi和dtb三种形态。dtsi是公共部分芯片厂商会把SoC内部的定义放在里面dts是具体板卡的文件通过#include引用dtsi。dtc编译器把这两种文本格式编译成二进制dtb。一个最简单的I2C设备节点大概长这样i2c2 { status okay; clock-frequency 100000; tmp101: temperature-sensor4c { compatible ti,tmp101; reg 0x4c; interrupt-parent gpio1; interrupts 15 IRQ_TYPE_LEVEL_LOW; }; };节点名里的temperature-sensor4c中的地址看着有点怪其实指的是设备在I2C总线上的从地址0x4c。reg属性必须和这个名字里的地址保持一致这是设备树规范里的约定不一致时解析阶段很容易出现奇怪问题。驱动端的匹配则由compatible字段主导。在内核驱动源码里会有一个of_device_id数组例如static const struct of_device_id tmp101_of_match[] { { .compatible ti,tmp101 }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, tmp101_of_match); static struct i2c_driver tmp101_driver { .probe tmp101_probe, .id_table tmp101_id, .driver { .name tmp101, .of_match_table tmp101_of_match, }, }; module_i2c_driver(tmp101_driver);内核注册驱动时会遍历设备树中所有挂载在I2C总线上的节点把节点的compatible和of_match_table逐项比对匹配上就调用probe函数把device节点信息作为参数传进去驱动随后从节点里读取寄存器地址、中断号等资源。整个流程很像快递分拣设备树是包裹上的运单驱动是分拣口的识别器运单信息匹配上以后包裹才被送进对应流水线。2.3 设备树配置中最容易翻车的四个坑设备树写错表现出的故障通常很隐蔽。比如设备明明在总线上扫描得到驱动却从未probe或者内核启动日志里报gpio和irq已经被占用。排查半天最后发现都是设备树配置里的低级错误。第一个坑是status属性没写对。SoC的dtsi默认把大部分外设设为status disabled板级dts必须显式改成okay才能开启外设。漏掉这个是最常见的没有之一。第二个坑是reg和interrupts属性写错。寄存器地址要结合父节点的#address-cells和#size-cells来看例如父节点定义的是两个32位地址、一个32位长度子节点却只写了一组解析出的地址就错位了。中断号则需要查看芯片手册确认中断控制器和GPIO的映射关系不同芯片的GPIO到中断控制器的编号方式差异很大。第三个坑是io属性比如pinctrl没配置。很多SoC要求外设引脚先配置成对应的复用功能比如把GPIO1的Pin15复用成UART3的TXD。如果你只打开了UART节点pinctrl配错了或者缺了pinctrl-0属性引脚默认状态可能是高阻或其它功能串口要么完全没输出要么数据乱码。第四个坑是bootloader层面。修改了dts后要确保bootloader加载的是新编译的dtb。很多开发板默认从特定分区加载dtb或者dtb被打包进kernel镜像里如果只改了源码、忘了重新打包或者更新分区改动不会生效。排查时可以在u-boot环境变量里确认fdt_file指向哪个文件。3. 第一个字符设备驱动从封装到实战3.1 设备号分配与设备文件节点字符设备是Linux驱动里最基础也最常用的一类每个字符设备都对应一个设备号设备号由主设备号和次设备号组成。主设备号标识设备对应的驱动次设备号通常用来区分同一驱动管理的多个物理设备。查看系统已分配的设备号可以看/proc/devices。设备号有两种分配方式。静态分配是开发者自己选一个主设备号通过register_chrdev_region注册适合知名设备号比如1对应内存设备、4对应tty设备大多数情况下推荐用动态分配也就是alloc_chrdev_region让内核自动分配一个未使用的主设备号开发者通过返回值拿到。动态分配的好处是不用担心冲突模块卸载时把设备号释放掉即可。设备节点是用户空间访问驱动的入口。老式做法是手动mknod创建/dev/xxx现在的标准做法是在驱动里注册class和设备让内核通过devtmpfs自动生成节点。用class_create加device_create后驱动加载时/dev下会自动多出一个设备文件卸载时自动删除省心很多。dev_t dev_num; struct class *my_class; struct device *my_device; alloc_chrdev_region(dev_num, 0, 1, mydev); my_class class_create(THIS_MODULE, mydev_class); my_device device_create(my_class, NULL, dev_num, NULL, mydev);3.2 file_operations结构体与数据拷贝file_operations是字符设备驱动的核心它把用户空间的open、read、write、release等系统调用连接到了内核驱动的具体函数。这个结构体是驱动开发者填得最多的地方。static int mydev_open(struct inode *inode, struct file *filp); static ssize_t mydev_read(struct file *filp, char __user *buf, size_t len, loff_t *off); static ssize_t mydev_write(struct file *filp, const char __user *buf, size_t len, loff_t *off); static int mydev_release(struct inode *inode, struct file *filp); static struct file_operations mydev_fops { .owner THIS_MODULE, .open mydev_open, .read mydev_read, .write mydev_write, .release mydev_release, };理解read和write里为什么不能直接用指针就明白驱动和用户空间的分工了。用户空间传入的buf指针位于用户态虚拟地址空间内核不能直接解引用必须通过copy_to_user和copy_from_user来安全地拷贝数据。这两个函数内部实现了用户态地址校验在非法地址上会返回错误不会引发内核崩溃。来看一个实现了基本逻辑的完整示例#define MYDEV_BUF_SIZE 256 static char kernel_buf[MYDEV_BUF_SIZE] hello from kernel; static int mydev_open(struct inode *inode, struct file *filp) { printk(KERN_INFO mydev: open called\n); return 0; } static ssize_t mydev_read(struct file *filp, char __user *buf, size_t len, loff_t *off) { ssize_t ret; size_t size strlen(kernel_buf); if (*off size) return 0; if (len size - *off) len size - *off; ret copy_to_user(buf, kernel_buf *off, len); if (ret ! 0) return -EFAULT; *off len; return len; } static ssize_t mydev_write(struct file *filp, const char __user *buf, size_t len, loff_t *off) { if (len MYDEV_BUF_SIZE) return -EINVAL; if (copy_from_user(kernel_buf, buf, len)) return -EFAULT; kernel_buf[len] \0; return len; }驱动返回值的含义和用户空间的errno是一一对应的例如-EFAULT对应段错误-EINVAL对应Invalid argument-ENOMEM对应内存不足。用户程序里用perror就能打出明确的错误原因这个惯例一定不要破坏否则排查问题时会非常头疼。3.3 模块加载流程与用户态联调写好驱动源码后需要一份Makefile来和内核构建系统打交道。模块的编译方式和普通应用程序完全不同它不是独立编译链接的而是通过内核的Kbuild系统引用内核源码树里大量的头文件和导出符号。标准写法如下obj-m : mydev.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需要注意的是Makefile里的命令行必须以Tab开头不能用空格替代这是老生常谈却仍然有人踩坑的地方。编译完成后会在目录下生成.ko文件这个就是可加载内核模块。加载和测试的完整流程sudo insmod mydev.ko # 加载模块 dmesg | tail # 查看内核日志确认注册信息 cat /proc/devices | grep mydev # 确认设备号 ls -l /dev/mydev # 确认设备节点已生成 echo kernel test | sudo tee /dev/mydev sudo cat /dev/mydev # 读取验证 sudo rmmod mydev # 卸载模块模块卸载时cleanup函数负责释放设备号、删除设备和class。顺序和多分配时的顺序相反通常是先删除设备和class再注销cdev最后释放设备号。如果设备正被用户进程占用rmmod会返回Device or resource busy需要先停掉占用进程这一点在调试时很常见别以为是无故卸载不了。4. 驱动调试三板斧和常见问题排查4.1 printk、动态打印与dmesg的正确用法驱动开发和用户态调试最大的区别就是没法用gdb一步步跟不能printf到处插。所以printk就成了最朴素的调试工具但它也不是随便用的。printk的打印级别控制着消息是否输出到控制台一共有8个级别0是最高优先级的emergency7是最低的debug。开发阶段可以直接用KERN_INFO甚至KERN_DEBUG但正式发布的驱动里调试信息建议编译开关控制比如#define DBG(fmt, args...) printk(KERN_DEBUG mydev: fmt, ##args)这样在量产阶段把宏定义改成空实现就行生产环境不会被打日志拖垮性能。更专业的做法是用内核的动态打印机制dynamic_debug它可以在不重新编译模块的情况下通过debugfs控制某个文件的打印开关。先用CONFIG_DYNAMIC_DEBUG编译内核然后执行echo file mydev.c p /sys/kernel/debug/dynamic_debug/controldmesg是查看内核日志的主要入口。配上-k、-c、-w等参数很好用比如dmesg -w就是持续等待新日志输出相当于用户态的tail -f调试驱动时开两个终端一个跑测试程序一个看着dmesg -w代码走到哪个分支一目了然。4.2 通过proc、sysfs和debugfs观察驱动状态除了日志内核还提供了三个向用户态暴露信息的文件系统。procfs是进程相关的也挂载着设备号、内存信息、中断信息等比如上面提到的/proc/devices和/proc/interrupts。sysfs在驱动开发里用得非常多它把设备和驱动模型完整地以目录结构展示出来/sys/class、/sys/bus、/sys/block下面可以查到注册设备的属性信息。debugfs则专门为调试而存在驱动开发者可以在debugfs里创建自定义节点读取硬件寄存器状态、导出内部数据结构、甚至通过写节点直接修改某些参数非常方便。一个实用技巧是调试I2C设备时可以在用户态直接用i2c-tools来访问不写一行驱动代码。比如i2cdetect -y 2扫描总线2上的设备能立刻看到挂了多少个设备、地址是多少。先通过用户态工具确认硬件链路没问题再写驱动排查范围能缩小很多这是老手和新手思路上的一个重要区别。4.3 驱动开发常见问题速查与定位思路这里整理了一份驱动调试高频问题的速查表基本覆盖了新手期到中期会踩的大量坑。问题现象常见原因排查与解决insmod提示Invalid module format内核算号、编译器版本或配置与模块不匹配用uname -r对比内核版本检查编译器是否和内核编译时一致模块加载成功但probe不执行compatible匹配不上或设备树节点status没写okay检查/sys/bus/.../devices下是否有对应设备核对compatible字符串打开/dev/mydev提示Operation not permitted设备节点权限或SELinux拦截chmod 666 /dev/mydev或添加udev规则读文件一直返回0read回调函数里*off没有正确更新或copy_to_user失败检查返回值逻辑确保*off推进用dmesg看是否报-EFAULTrmmod提示Device or resource busy设备被进程占用fuser /dev/mydev定位进程kill或关闭句柄内核报scheduling while atomic在原子上下文中断、自旋锁保护区调用了可能睡眠的函数检查是否使用了mutex、kmalloc(GFP_KERNEL)等系统启动死在某个驱动初始化设备树资源冲突或驱动注册顺序问题内核cmdline加initcall_debug查看初始化函数调用顺序每个问题如果展开讲都是文章但定位思路基本一致先确认内核有没有收到中断或总线事件再确认设备节点有没有被正确创建最后才怀疑驱动代码里的逻辑问题。不要一上来就盯着寄存器值对着手册发呆先从系统层面缩小范围效率高很多。4.4 一种好用的“用户态模拟”调试法驱动编写的逻辑调试不一定非要加载到内核里走。很多纯逻辑的部分比如数据解析、状态机转换、协议算法可以先把核心处理函数抽出来在用户态编译运行用普通printf来调试。内核态里大量的数据处理逻辑本质上就是纯C代码不涉及硬件寄存器时完全可以在用户态验证。等到用户态把逻辑都调通再将其移植为驱动内的处理函数只留硬件访问和中断处理等必要内核代码。这个方法在调试一个自定义LED灯效控制驱动时特别有用。灯效状态机、闪烁时序在用户态调试了整整一个星期每次改动几秒就能看到效果。验证逻辑没问题之后把这些函数原样搬进内核驱动里只额外加一层设备树解析和定时器配置一次加载就正常工作了。5. 从能跑到跑得好驱动性能优化与内核裁剪5.1 中断下半部机制与并发访问控制当你写的中断处理函数越来越长比如处理完硬件数据还要唤醒等待队列、更新统计信息、触发下一轮传输主中断上下文里的时间就会超支。这时必须把任务拆成两部分上半部只做最紧迫的硬件应答比如读走FIFO数据、清中断标志其余耗时的处理交给下半部去完成。常用的下半部机制有tasklet、workqueue和threaded irq它们的区别在于tasklet在软中断上下文执行不能睡眠workqueue在工作线程上下文执行可以睡眠threaded irq则是把中断处理程序整体放到内核线程里跑适合需要频繁睡眠的驱动。选择依据很简单如果下半部只是做一些快速的数据搬运用tasklet如果涉及I2C/SDIO这类可能需要等待硬件响应的操作必须用workqueue或者threaded irq。曾经见过一个WiFi驱动在中断上下文里调用了usleep_range结果整个系统的实时性瞬间崩塌打印刷了满屏的scheduling while atomic。这个错误定位起来特别费劲因为表面现象是系统卡顿和大量进程被饿死谁也想不到是一个驱动里的sleep引起的。并发安全是驱动开发里另一个隐形雷区。自旋锁、信号量、互斥锁分别适用不同场景自旋锁适用于临界区极短、不会睡眠的代码路径信号量允许进程在等待时睡眠适合长时间持锁的场景。有个容易忽略的点是锁的粒度和用法会影响并发性能用一个全局大锁保护所有操作简单是简单但多核并发的吞吐量会非常难看。正确做法是区分read-mostly和write-mostly的不同路径用读写锁或RCU优化读多写少的场景。RCU在内核里用得很广泛网络协议栈和文件系统里到处都是它的核心思想是让读路径完全无锁写路径通过延迟回收来保证安全。不过RCU使用难度偏高新手阶段不建议直接上先用好mutex加atomic_t就够应付绝大多数驱动的并发问题了。5.2 Linux系统裁剪与优化思路嵌入式产品量产阶段裁剪内核往往是必须做的工作。一个完整内核镜像动辄十几兆对带eMMC或者大容量NAND的板子还好但如果用的是几兆大小的NOR Flash内核体积只要大个1MB都会带来成本压力。裁剪的核心工具是make menuconfig图形化界面里可以按功能模块开关驱动和子系统。优先裁剪方向去掉用不到的总线驱动、文件系统、网络协议栈组件、块设备驱动的各种调度器等。裁剪时有一个比较稳的做法先用默认配置编译一版可用内核然后逐个模块递减每次裁剪完必须跑一遍完整的功能回归测试。千万不要一次性勾掉几十个选项出了问题根本没有方向去定位是哪个配置引起的不兼容。内核配置项之间还存在隐式依赖比如关掉了CONFIG_INET所有和网络相关的驱动和协议都会跟着失效但有些依赖关系在Kconfig里标记不全这时候只能靠反复实验确认。启动速度优化上也有讲究。内核启动阶段最耗时的三个部分bootloader加载镜像、内核初始化和根文件系统挂载。deferred_probe机制能帮助定位外设初始化慢的问题串口驱动可以配置成earlycon提前输出日志方便观测开机过程。系统运行时的性能调优则可以通过perf工具分析热点比如secure shell性能、网络收发路径、DMA搬运等再返回去优化驱动的buffer管理或中断合并策略。6. 驱动开发进阶心得写给想继续深入的你如果说写这篇文章有什么必须强调的那就是驱动开发的核心能力不是背结构体和API而是学会自己看内核源码。很多新手的共同困境是照着教程写了代码可一旦出了问题就完全不知道从哪里入手。内核驱动和用户态的代码不一样它有大量现成的实现模式和框架学会在源码里搜索、阅读、理解比背任何文档都重要。比如你不确定i2c_driver的probe函数签名在新内核里变了没有不要猜。直接找到include/linux/i2c.h看一眼结构体定义一目了然。内核源码就是最权威、最新鲜的开发文档所有外部世界的文章都可能过时只有源码树里的真实定义不会骗人。这里再推荐一个实用的学习方法给自己找一个小项目不要太大比如写一个温湿度传感器的I2C驱动或者一个简单的LED呼吸灯驱动。这个项目要能覆盖设备树编写、字符设备注册、中断处理或定时器使用、用户态交互这几个完整环节。自己动手从头到尾做一遍中间遇到问题、解决问题、翻源码、查手册这套经历下来比看十篇教程都管用。驱动开发这条路开始时确实有门槛但它跟所有工程实践一样本质上就是一门熟能生巧的手艺。把环境、设备树、字符设备框架、调试工具这几个基础模块吃透再通过一个真实项目走通全流程你已经比大多数停留在“看过教程”阶段的人强很多了。剩下的就交给时间去积累经验吧。
返回列表