ARTICLE DETAIL

资讯详情

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

Linux驱动开发实战:从字符设备到设备树与I2C调优

Linux驱动开发实战:从字符设备到设备树与I2C调优 1. 开发环境与最小框架先能编译、能加载、能卸载如果你刚入行时的第一反应是“驱动开发就是打开一个编辑器写一堆结构体然后 insmod 就行了”那我只能说你想得太轻松了。我这些年带过不少新人发现大部分项目前期浪费的时间根本不在写代码上而是连驱动模块都跑不起来。所以这篇文章先不谈那些高大上的算法部署和性能调优而是先用我自己最常用的流程把 Linux 设备驱动开发的地基打好。1.1 交叉编译工具链选型与内核源码准备Linux 设备驱动分为两类场景最常见的是一台 x86 的 PC 机直接装个虚拟机或者物理机 Linux 系统编译模块后在本地加载另一种是嵌入式平台比如 ARM 开发板需要交叉编译工具链。很多新手一上来就问“用哪个发行版”其实发行版不重要真正重要的是内核源码的版本必须与运行目标的内核版本高度一致。驱动模块本质上是和内核符号表、数据结构绑定在一起的uname -r显示的内核版本和你下载的内核源码版本对不上后面就是无穷无尽的Unknown symbol报错。嵌入式开发里我一般这样选先看板子厂商给的 SDK 里默认带哪套交叉编译工具链不要自己随便换。以常见的 ARM64 平台为例aarch64-linux-gnu-gcc会有很多版本必须选与 SDK 配套的版本。如果你用 Yocto 或 Buildroot通常工具的路径已经被写死在环境变量里直接在 Makefile 里用CROSS_COMPILE指定前缀即可。PC 机上做驱动验证则简单得多只需要确保make的时候能找到当前运行内核的构建目录。如果是 Ubuntu/Debian 系可以先执行sudo apt install linux-headers-$(uname -r)然后内核源码目录用/lib/modules/$(uname -r)/build指向的头文件包就行无需把整个内核源码解压出来。1.2 第一个 Hello Driver 的模块模板我习惯把最简单的驱动模块称为“Hello Driver”。它虽然不涉及任何硬件但能够验证工具链、内核源码、Makefile、模块加载机制这整条链路是否通顺。很多教程会直接给你一个充满printk的示例然后让你insmod但我建议你连 Makefile 也要亲手敲一遍。#include linux/init.h #include linux/module.h #include linux/kernel.h static int __init hello_init(void) { printk(KERN_INFO hello driver init, running on Linux %s\n, UTS_RELEASE); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO hello driver exit\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(博主);对应的 Makefile 要分两种情况。如果是在内核源码树外的独立模块最简单的写法是obj-m : hello.o KERNEL_DIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERNEL_DIR) M$(PWD) clean如果是在板子环境需要加上ARCHarm64和CROSS_COMPILEaarch64-linux-gnu-并且KERNEL_DIR指向板级 SDK 里已经配置好的内核源码目录。这一步很多人会栽跟头交叉编译时忘记给ARCH或CROSS_COMPILE导致模块用 x86 的编译器编出来放到 ARM 板子上直接提示Exec format error。另外MODULE_LICENSE(GPL)不是可有可无的如果你不声明一些内核导出符号和机制不允许使用还会在加载时打警告。1.3 模块加载失败最常见的三类报错我在带项目时几乎每周都能看到有人在群里贴同样的报错截图。这里直接总结三类第一类是版本魔术不匹配报错像version magic 6.5.0-xxx should be 6.5.0-yyy。原因就是内核头文件与当前运行内核不一致或者交叉编译时用的源码版本不对。解决办法只有一条找到与目标内核完全一致的源码重新编译不要试图用modprobe --force去绕过那只会让后面的运行更加诡异。第二类是Unknown symbol。比如你自己编译的模块里用到foo_symbol但当前内核没有导出这个符号。解决方法是先看/proc/kallsyms里有没有该符号或者检查内核源码里对应的EXPORT_SYMBOL是否被开启。很多外设驱动依赖的子系统没有被编译进内核也会报这种错。第三类是加载后没有日志输出。大多数人用insmod后执行dmesg只看了最后几行结果没有看到hello driver init。这里有个小技巧dmesg -c清掉旧日志再insmod再用dmesg单独看本次加载的输出排查起来会清爽很多。开发环境和最小框架跑通之后你才真正有资格去碰字符设备驱动。别小看这个“Hello Driver”它就像是设备的“呼吸”能加载能卸载说明你的驱动生命周期基础函数已经没有问题了。2. 字符设备驱动框架主设备号、file_operations 与用户态握手字符设备驱动是 Linux 设备驱动开发里最容易入门、也是面试题中出现频率最高的内容。热词里“字符设备驱动框架”排得很靠前说明大家对这个话题确实有刚需。我见过很多人能背出file_operations里面的函数名但一让他说出“为什么需要主设备号”“open 时节次设备号怎么用”立刻就卡壳了。这一节我按自己的理解拆一遍。2.1 设备号的分配与释放设备号由主设备号和次设备号组成内核用dev_t类型保存这 32 位数据。主设备号用于关联驱动次设备号用于区分同类设备下的不同实例。比如一个串口驱动可以管理 4 个串口主备号相同但次设备号分别是 0、1、2、3。静态注册函数是register_chrdev_region动态分配则是alloc_chrdev_region。我更喜欢动态分配因为它能避免和已有驱动的主设备号冲突尤其在做产品时不确定目标环境里已经占用了哪些主设备号。dev_t devno; int major, minor; ret alloc_chrdev_region(devno, 0, 1, my_char_dev); major MAJOR(devno); minor MINOR(devno);这里有很隐蔽的坑很多示例代码会直接写register_chrdev_region(devno, 1, xxx)然后烧进设备后才发现主设备号被占用了。解决这个问题没有太多高级办法就是分类管理好系统中已有设备的注册清单或者在驱动初始化时检查返回值。2.2 file_operations 的核心回调有了设备号还要通过cdev结构体把设备号与操作函数绑定。标准流程是struct cdev cdev; cdev_init(cdev, fops); cdev.owner THIS_MODULE; ret cdev_add(cdev, devno, 1);file_operations里最常见的一组回调是open、release、read、write、unlocked_ioctl。很多人问为什么现在用的是unlocked_ioctl而不是ioctl原因是为了避免历史遗留的 BKL大内核锁机制。在新版本内核里驱动需要实现unlocked_ioctl也就是内核不再自动加锁驱动必须自己保证并发安全。static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { switch (cmd) { case MY_IOCTL_SET_PARAM: // 从用户态拷贝参数 break; default: return -ENOTTY; } return 0; }我见过不少新手在ioctl里直接解引用用户态指针这是最容易导致内核崩溃的行为。用户态传入的地址必须用copy_from_user、copy_to_user或者access_ok校验否则一旦碰到非法地址驱动就会 oops然后整个系统日志都会漏出大量无关信息排查起来非常痛苦。2.3 与用户态交换数据的正确姿势read和write回调的内涵其实比很多人想象中要复杂。你要处理的不仅是从内核缓冲区拷贝数据到用户缓冲区还要考虑用户缓冲区的大小、剩余的读取长度、当前文件读位置*ppos是否需要更新等。最简单的内存零拷贝实现很容易但如果只应付入门级 demo或许可以这样写static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { char kernel_buf[128] hello from kernel\n; size_t len strlen(kernel_buf); if (*ppos len) return 0; if (count len - *ppos) count len - *ppos; if (copy_to_user(buf, kernel_buf *ppos, count)) return -EFAULT; *ppos count; return count; }这段代码虽然简单但已经包含三个关键点一是需要维护*ppos否则用户态读取会一直从头开始二是返回 0 代表 EOF很多新手不知道三是copy_to_user返回非零值表示拷贝失败此时应当返回负的错误码而不是返回一个非负的地址差值。在此之后如果你在用户态cat /dev/my_char_dev看到内容说明用户态与内核态的数据通路已经打通。字符设备驱动的精髓就在这个“代客取货”的流程里外部设备产生的数据放在内核缓冲区用户程序通过文件接口来取用户的配置通过文件接口写进来驱动再转给具体外设。3. 设备树不是玄学从 dts 节点到 platform_driver 自动匹配嵌入式 Linux 开发绕不开设备树热词里“设备树配置”出现频率非常高。很多人刚开始接触到 dts 文件时觉得就是一堆缩进的属性和值但真正调试起来却又一头雾水。设备树解决的核心问题是“板级信息与内核代码解耦”以往把硬件资源信息硬编码在arch/arm/mach-xxx/board-xxx.c现在统一放到 dts 中描述。3.1 设备树节点怎么写才算“够用”我在评审别人提交的设备树改动时有个最低标准节点名要规范地址属性要可读状态要明确。比如你要外接一个 GPIO 按键最简单的节点可以写成这样/ { key_gpio: key-gpio { compatible gpio-keys; pinctrl-names default; pinctrl-0 pinctrl_key0; status okay; key0 { label KEY0; gpios gpio1 18 GPIO_ACTIVE_LOW; linux,code KEY_ENTER; }; }; };从驱动开发的角度看设备树节点更像是“硬件资源的一场比赛”。驱动不关心你的引脚到底叫KEY0还是KEY1它只关心compatible字段能不能与驱动里的of_match_table匹配上以及后续通过gpiod_get之类的 API 能不能从节点中拿到 GPIO 编号。status okay更是必须的很多新手把节点写好了却忘了把默认的disabled改成okay导致驱动永远不会被 probe。3.2 匹配过程与 of_match_table一个驱动要能够被设备树节点自动发现核心就是在platform_driver中设置of_match_table。内核会遍历设备树中的所有节点当节点的compatible与驱动的of_match_table里某一项字符串完全匹配时就会触发probe函数。static const struct of_device_id my_of_match[] { { .compatible vendor,my-device }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_of_match); static struct platform_driver my_pdrv { .probe my_probe, .remove my_remove, .driver { .name my_device, .of_match_table my_of_match, }, }; module_platform_driver(my_pdrv);模块宏module_platform_driver会自动帮你注册和注销platform_driver省掉两行样板代码。需要注意的是MODULE_DEVICE_TABLE不是写给内核运行时用的而是给modpost工具生成模块别名用的。如果没有这个宏即使compatible匹配modprobe也可能无法根据设备树自动加载模块只能手动insmod。3.3 设备树踩坑记录GPIO、时钟与中断设备树调试中我踩过最大的坑是 GPIO 号理解错误。芯片手册里经常写GPIO1_IO18但设备树里gpios gpio1 18 GPIO_ACTIVE_LOW里的18到底是相对 GPIO 控制器的第几个引脚要看驱动的gpiochip基准号和 dts 引入头文件后的宏定义不能想当然。另一个高频坑是时钟与中断你明明在节点里写了interrupts gpio1 18 IRQ_TYPE_EDGE_FALLING但驱动request_irq时却拿不到中断号原因可能是在probe前未正确获取struct device_node里的interrupts属性或者中断控制器本身没有使能。更常见的问题是设备树文件的编译。很多开发板使用dtc编译器把.dts编成.dtb但编译命令里的-I dts -O dtb参数容易写错。而且改了设备树后一定要确认 u-boot 里的fdt_file环境变量指向你新生成的.dtb否则你改了半天板子内核启动用的还是旧 dtb这种现象尤其容易出现在 NXP i.MX 系列和全志平台上。调试设备树时我建议在/sys/firmware/devicetree/base下面去查看展开后的节点这是最直接的内核视角。4. I2C 设备驱动注册函数实战i2c_add_driver 前后发生了什么热词里有“linux i2c设备驱动的注册函数”说明对 I2C 驱动的需求一直很稳定。I2C 驱动是连接处理器与传感器、EEPROM、触摸屏的桥梁它的注册逻辑和平台驱动有些类似但又有自己的独特之处。4.1 从 i2c_driver 结构体到 probe写一个 I2C 设备驱动第一步通常是定义一个i2c_driver结构体static struct i2c_driver my_i2c_driver { .probe my_i2c_probe, .remove my_i2c_remove, .driver { .name my_sensor, .of_match_table my_i2c_match, }, .id_table my_i2c_id_table, }; module_i2c_driver(my_i2c_driver);id_table是传统非设备树方式用来匹配设备名字的数组如果使用设备树主要看of_match_table。注册函数i2c_add_driver其实是个封装宏内部会调用i2c_register_driver。这个函数做的事可以简单理解为把驱动挂到 I2C 子系统的驱动链表上然后遍历系统中已经注册的 I2C 适配器看看有没有挂在该适配器上的设备与驱动的of_match_table匹配。如果匹配成功就调用probe。所以probe不是由驱动自己调用的而是由 I2C 核心根据“设备树上的节点 总线上的物理设备”两者共同决定的。4.2 设备树与 I2C client 的对应关系在设备数中声明一个 I2C 外设时通常会挂在某个 I2C 控制器节点下例如i2c1 { status okay; clock-frequency 100000; temp_sensor48 { compatible ti,tmp102; reg 0x48; }; };这里的48与reg 0x48是 I2C 设备的 7 位从机地址。我看到很多新手会把从机地址理解成“从数据手册里看到的 8 位地址”。实际上I2C 地址分 7 位和 8 位两种表达数据手册给出的 0x90、0x92 之类往往是包含读写标志位的 8 位地址一般右移一位才是设备树里要填的 7 位地址。如果地址填错i2c_detect和probe都会失败你在写i2c_smbus_read_byte_data时会看到设备无应答。反过来如果probe能进去恭喜你说明这个 I2C 设备已经被内核“发现”了。4.3 实际调试中会遇到的 I2C 总线问题I2C 调试最常用的工具是i2cdetect、i2cget、i2cset它们来自i2c-tools包。有一次在板子上烧好驱动后发现probe一直不执行于是执行i2cdetect -y 1结果总线地址列表全是UU或--。后来发现是某个 GPIO 模拟的 I2C 在设备树里没有配好电平总线一直被拉低。排查问题时最好先用示波器看 SCL/SDA 波形再用工具逐个确认地址。驱动本身能正常注册但物理层不通代码写得再好也白搭。另外在probe里不要做太耗时的操作。因为 I2C 访问通常是同步的如果在probe里长时间调用i2c_transfer会阻塞整个内核的 I2C 子系统。正确做法是只完成必要的外设初始化把周期性的数据读取放在高精度定时器或内核线程里。5. 驱动的性能调优中断底半部、锁与内存屏障的选择当你不再满足于“能跑”就要开始关心“跑得好不好”。嵌入式设备驱动往往处在数据采集、传输、控制的链路第一线性能调优是躲不开的。热词里“算法嵌入式部署、性能调优”也印证了这一点。5.1 为什么不能全部在主中断里干活驱动中中断服务程序分为上下两部分。上半部分hardirq要求尽可能短因为它在关中断环境下运行长时间占用会拖慢整个系统。比如一个 SPI 设备每产生一次中断就代表一帧数据准备好你要是在中断里调用spi_sync去读 1KB 数据中断的耗时可能达到几百微秒这对于实时系统来说是灾难。常见做法是上半部分只做标记和取数据然后使用tasklet或workqueue完成耗时操作。tasklet目前基本被small task替代但它依然常见workqueue更适合可能休眠的场景。另外request_threaded_irq是解决中断中需要休眠的一种好办法它允许把中断处理线程化上半部分只是屏蔽并唤醒线程。我在一个触摸屏驱动里就用过request_threaded_irq中断来临时只调用disable_irq_nosync然后唤醒线程去执行 I2C 读取和触摸坐标上报实测下来 CPU 占用率和响应延迟都改善了很多。5.2 自旋锁、互斥锁与中断上下文的边界这个部分在面试中几乎必问。很多人背概念自旋锁不睡眠互斥锁会睡眠。但到了实际调优时怎么用才正确中断上下文只能使用自旋锁或者spin_lock_irqsave因为它不能睡眠在进程上下文中使用互斥锁是安全的。但这里有个容易被忽略的点即使你在进程上下文如果代码路径同时会被中断处理程序和进程上下文访问就必须使用spin_lock_irqsave关闭本地中断来保护共享数据避免死锁。举个例子一个 FIFO 缓冲区读操作在进程上下文写操作在中断上下文。读操作先拿自旋锁如果此时中断一直等待读侧释放锁就会导致死锁。解决办法就是读侧使用spin_lock_irqsave在持有锁期间关闭本地中断确保中断处理无法插入。5.3 内存屏障与 DMA 数据一致性很多驱动工程师调试 DMA 时发现读回来的数据总是不对。除了硬件信号、时序外还可能是 CPU 缓存与 DMA 缓冲区的一致性没有处理好。硬件 DMA 直接访问内存时不会管 CPU 缓存里的脏数据。此时驱动需要使用dma_alloc_coherent分配一致性的 DMA 缓冲区或者在使用dma_map_single/dma_unmap_single时配合dma_sync_single_for_cpu做同步。内存屏障则更多用于无锁数据结构的驱动中。比如一个运行在中断上下文写的状态标志另一个运行在进程上下文读通常需要smp_wmb()/smp_rmb()保证顺序。我在一个网卡驱动的 ring buffer 里就遇到过这种问题驱动更新了tx_tail后硬件才会开始 DMA 发送。如果tx_tail的写入对硬件不可见命令会一直不触发。后来靠wmb()确保在写tx_tail之前描述符内容已经全部写入内存问题就消失了。6. 驱动之外的需求透明加密、磁盘调度与系统裁剪有人可能觉得设备驱动只是“点亮一个灯、读一个温度”。但在实际商业项目里驱动工程师经常会接到一些看起来不太像驱动开发的任务这些冷门需求恰恰是热词中“linux 透明加密”“linux设置磁盘寻道算法”的来源。6.1 透明加密如何在驱动侧落地所谓透明加密是指用户进程读写文件时无需感知数据被加密或解密。实现这类功能常见的技术路径包括基于 FUSE、基于内核文件系统 hook以及基于块设备的加密。块设备层面的解决方案更接近“设备驱动”本行在块设备层或 dm-crypt 之上实现一个虚拟块设备驱动用户读写这个块设备的扇区时驱动在内存中完成加解密。这个领域的挑战在于加解密不能阻塞太久也不能改变块设备逻辑扇区的大小。如果你只是在驱动里加一个简单的 XOR 加密性能损失不大但安全性很差如果使用 AES 硬件加速器又需要处理 DMA 缓冲区与加密引擎的协作。无论怎样这块内容非常容易踩坑在驱动初始化时就得确定密钥管理方式如果只把密钥硬编码在源码里产品一旦量产就没办法更换密钥。6.2 磁盘寻道算法与块设备驱动的关系从用户态看磁盘寻道算法往往只是“由谁决定 I/O 请求的排队顺序”但如果你在做块设备驱动就需要理解request_queue与调度器之间的关系。Linux 支持多种 I/O 调度器如mq-deadline、bfq、none。在 NVMe 这类随机读写性能已经很高的设备上none调度器反而表现更好而在传统机械硬盘上deadline优化了寻道时间。在驱动层面如果你实现的是一个虚拟块设备驱动请求队列会收到大量不可预测顺序的 I/O。正确的做法不是自己写复杂的排序算法而是调用内核提供的blk_queue_*接口设置合理的队列参数比如最大扇区数、队列深度等。曾经我给一个数据采集系统做过虚拟块设备发现顺序读很快但随机读奇慢是因为我没有让 I/O 调度器合并相邻请求。后来调大了队列参数并选择了mq-deadline随机读性能提升了近三倍。6.3 模块化裁剪对嵌入式系统启动时间的影响热词里还有“系统裁剪优化”它和驱动开发的关系非常紧密。嵌入式系统的内核镜像如果只编译必需驱动启动时间会明显缩短但同时不能缺少关键驱动否则外设无法初始化。我常用的做法是把功能固定的驱动编入内核把偶尔调试用的驱动编成模块并放在 tiny 的 initramfs 里按需加载。这样既减少了内核镜像体积也避免了所有驱动模块在启动阶段竞争 I2C/SPI/GPIO 资源。裁剪不是只改.config里的CONFIG_XXXy/m/n还需要关注依赖的子系统。比如你想关掉不用的 USB 驱动但某个网络驱动却悄悄依赖了 USB gadget 子系统那么make menuconfig会自动帮你选上如果你没留意最终镜像大小可能降不下来。这时候我会用size vmlinux和lsmod两个命令组合起来检查先看清哪些模块真实被加载再反推内核配置里的选项。7. 面试中最可能被追问的驱动开发问题与答题思路热词里“linux面试题”出现了多次。很多人以为面试官只问“字符设备驱动框架是什么”就完了实际上会顺着你的回答一路往下追。这里列几个我既作为面试官、也作为求职者都遇到过的高频问题顺便给出答题思路。7.1 字符设备驱动的高频题面试官大概率会这样问register_chrdev和register_chrdev_region的区别是什么你可以回答register_chrdev是老接口它会自动分配一个主设备号并且在内部创建一个struct cdev但它不容易管理多个次设备号register_chrdev_region更底层需要配合cdev_add使用。面试官接着可能追问为什么老接口不推荐这时可以提老接口会使用一个全局的表来管理主设备号扩展性和维护性都不如cdev方案。另一个常考问题是open和release里到底能做什么很多人回答“初始化硬件”。其实open主要用来增加模块引用计数、判断设备是否被独占、初始化文件私有数据硬件初始化更合理的做法是在probe阶段完成。这样回答会让面试官觉得你对驱动生命周期理解更完整。7.2 并发、中断与锁的高频题“中断上下文中能不能使用mutex”几乎是送分题答案是不能因为mutex可能睡眠。但如果追问“那为什么自旋锁可以在中断上下文使用”你要回答出自旋锁在等待期间原地旋转不会睡眠但持有时间不能太长。更重要的一点是在中断上下文使用自旋锁时如果同一个自旋锁可能被其他模式的上半部也获取就需要用spin_lock_irqsave关中断避免自身被打断。还有一个容易答错的点local_irq_disable和spin_lock_irqsave有什么区别前者只关闭本地 CPU 的中断后者不仅关中断还保存中断状态等解锁时恢复。如果只关中断不保存状态在嵌套环境下可能破坏原有的中断使能状态。7.3 平台驱动和设备树的高频题面试官经常会拿出一个实际设备树节点问你驱动probe是怎么被调用的。答题思路不要只说“compatible 匹配”要把流程说完整内核启动时解析设备树生成platform_device驱动注册时通过bus子系统遍历设备链表匹配成功后调用probe。如果设备树节点status为disabled内核可以直接忽略这个节点即便 compatible 匹配也不会 probe。接着可能问of_property_read_u32读取属性失败会怎样你先看返回值返回非 0 表示读取失败通常不该继续往下走。很多驱动在 probe 里连续读取多个属性一个失败就return -EINVAL这会让整个设备不可用。更稳妥的做法是给每个属性失败打不同日志方便调试。最后再分享一个小技巧去面试前最好自己编译一个最小驱动并在 qemu 或开发板上跑一遍。面试官问到的很多问题比如设备号、设备树、I2C probe都能在实际操作中加深理解。纸上谈兵背答案远不如自己动手踩一次坑来得扎实。驱动开发的学习曲线很陡但每跨过一个坑你解决下一个问题的速度会快很多。
返回列表