ARTICLE DETAIL

资讯详情

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

嵌入式Linux驱动开发实战:从字符设备、设备树到I2C与调试

嵌入式Linux驱动开发实战:从字符设备、设备树到I2C与调试 刚接手嵌入式 Linux 驱动时我花了大量时间在“把模块编译出来、加载进去”这件事上后来才发现真正的门槛不是编译而是搞清楚驱动在系统里到底站在什么位置、该用哪种框架、数据怎么从内核态安全地流到用户态。你随便搜“Linux 设备驱动开发”能找到一堆代码模板但很少有人告诉你为什么字符设备驱动要这么写、设备树节点为什么非要挂 compatible、I2C 驱动为什么用 regmap 比手动发消息更稳。这篇文章会从一个实际项目的视角把 Linux 设备驱动开发里最核心的环节拆开讲最小可加载字符设备驱动、设备树配置与匹配、I2C 驱动的完整落地以及调试和性能调优的手段。无论你是刚准备入门驱动开发的新人还是已经在嵌入式 Linux 里做过几版应用、想往下探一层的老手这篇文章都值得你按顺序读一遍。1. 驱动开发前必须想清楚的事角色、边界与前置条件1.1 驱动在内核中的位置它不是“控制硬件的代码”这么简单很多初学者会把驱动理解成“操作寄存器的 C 语言程序”这个说法对了一半。驱动确实是直接操作硬件的那一层但它不是一个独立程序而是内核的一部分。它向内核其他子系统提供标准接口同时把硬件能力转换成用户态能调用的文件操作接口比如 read、write、ioctl。你在应用层打开 /dev/xxx 后实际上是通过虚拟文件系统进入内核经过字符设备子系统最后落到驱动里的 file_operations 函数。这个过程涉及文件描述符、inode、cdev、设备号等一整套机制。这就引出一个重要结论写驱动之前首先要决定这个设备属于哪一类。字符设备按字节流读写适合串口、GPIO、传感器这类设备块设备以块为单位读写适合存储介质网络设备则不走文件接口而是通过内核网络协议栈收发报文。绝大多数量身定制的硬件外设走的都是字符设备这条路径。我见过不少人一上来就照着网上的模板建一个 miscdevice结果遇到多个同类设备时设备号管理混乱又重新回来学 cdev 注册流程白白浪费了时间。决定好设备类型后接着要面对的是“用户态还是内核态”的边界问题。不是所有硬件访问都必须放在驱动里。比如一个 SPI 闪存你完全可以用 spidev 直接暴露给应用层让用户态程序自己决定读写时序但如果要做掉电保护、坏块管理、文件系统就必须交给内核里的 MTD 子系统。驱动开发的第一课其实是判断硬件行为的复杂度、访问频率、安全性要求再决定代码放在哪一层。1.2 设备树的引入改变了驱动开发的思考方式在老版本 Linux 内核里板级硬件信息是散落在 arch/arm/mach-xxx 这样的大文件里的每换一块板子就要改内核、重新编译驱动代码里也到处是“板子相关”的宏。设备树Device Tree出现后硬件描述和驱动逻辑被彻底分开板子上有哪些外设、地址是多少、中断号是多少、引脚复用怎么配这些信息都写进 dts 文件驱动只负责解析这些描述然后按描述去操作硬件。这意味着你现在写驱动默认都要写成“可被设备树描述的驱动”。比如一个 I2C 温度传感器你不再在驱动里写死设备地址而是通过 struct i2c_client 拿到来自设备树的地址信息。驱动和板子解耦之后同一份驱动代码可以在不同开发板上复用只要 dts 里的节点信息正确驱动就能在 probe 阶段被自动唤起。设备树也不是没有学习成本。它自成一门 DSL有节点、属性、引用、覆盖overlay等概念。dts 里一个简单的 reg 属性背后牵扯到 #address-cells 和 #size-cells 的位数问题写错一位驱动里拿到的地址就可能是错的。设备树排错是驱动开发中最容易卡壳的环节后面我会专门讲怎么定位设备树解析失败。1.3 开发环境选型虚拟机、交叉编译与内核源码版本驱动开发环境有三样东西缺一不可Linux 主机或者装好 Linux 的虚拟机、目标平台对应的交叉编译工具链、与目标运行内核版本一致的内核源码。新手最容易踩的坑是主机内核源码版本和开发板内核版本对不上编译模块时带了不一样的 vermagic结果 insmod 时报 “version magic” 错误。我的建议是入门阶段不要急着买开发板用 QEMU 模拟 ARM 平台就够了。QEMU 配合主线内核可以在纯软件环境里完成设备树、字符驱动、GPIO、I2C 的基本实验。缺点是你无法接触真实外设寄存器但学习驱动框架的收益完全不受影响。等到需要调试中断、DMA、电源管理这些跟硬件强相关的功能时再上真实开发板。内核源码推荐直接从 kernel.org 拉主线或长期维护分支不建议用发行版自带的 kernel-source 包练习因为发行版打了大量补丁源码结构和主线有出入。交叉工具链可以用 Linaro 或发行版自带的 gcc-arm-linux-gnueabihf记得先确认目标环境是 glibc 还是 musl、是 32 位还是 64 位工具链选错后面所有编译错误都会让你怀疑人生。2. 从零写一个最小字符设备驱动加载、设备号、文件操作2.1 模块骨架与 file_operations 的底层逻辑一个最小的字符设备驱动至少包含四件事模块加载/卸载函数、设备号分配、cdev 初始化与添加、file_operations 实现。下面这段代码我刻意去掉了杂项设备的封装让你能看到字符设备最原始的结构#include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #define DEVICE_NAME demo_dev #define CLASS_NAME demo static dev_t dev_num; static struct cdev demo_cdev; static struct class *demo_class; static struct device *demo_device; static int demo_open(struct inode *inode, struct file *filp) { return 0; } static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { char tmp[] hello from kernel\n; size_t len strlen(tmp); if (copy_to_user(buf, tmp, len)) return -EFAULT; return len; } static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset) { char kbuf[256]; if (count sizeof(kbuf) - 1) return -EINVAL; if (copy_from_user(kbuf, buf, count)) return -EFAULT; kbuf[count] \0; pr_info(recv: %s\n, kbuf); return count; } static const struct file_operations demo_fops { .owner THIS_MODULE, .open demo_open, .read demo_read, .write demo_write, }; static int __init demo_init(void) { int ret; ret alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); if (ret 0) return ret; cdev_init(demo_cdev, demo_fops); ret cdev_add(demo_cdev, dev_num, 1); if (ret 0) goto err_cdev; demo_class class_create(CLASS_NAME); if (IS_ERR(demo_class)) { ret PTR_ERR(demo_class); goto err_class; } demo_device device_create(demo_class, NULL, dev_num, NULL, DEVICE_NAME); if (IS_ERR(demo_device)) { ret PTR_ERR(demo_device); goto err_device; } pr_info(demo driver init, major%d minor%d\n, MAJOR(dev_num), MINOR(dev_num)); return 0; err_device: class_destroy(demo_class); err_class: cdev_del(demo_cdev); err_cdev: unregister_chrdev_region(dev_num, 1); return ret; } static void __exit demo_exit(void) { device_destroy(demo_class, dev_num); class_destroy(demo_class); cdev_del(demo_cdev); unregister_chrdev_region(dev_num, 1); pr_info(demo driver exit\n); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL); MODULE_DESCRIPTION(A minimal char device driver);这段代码的核心在 file_operations 结构体。内核在应用层 read 一个设备节点时sys_read 最后会调到这里登记的 demo_readwrite 同理。所以在驱动里你不是在“实现 read 函数”而是在给整个内核的读写路径提供一个回调。要注意 demo_read 里没有用到 offset这只是为了演示真实驱动必须根据 offset 处理“从文件哪个位置开始读”的语义否则应用层 lseek 后读到的数据会乱。copy_to_user/copy_from_user 是内核态与用户态数据交换的必经之路。直接传用户指针给内核函数是行不通的因为内核不能假设用户态指针一定有效也必须防止用户传一个非法地址导致内核崩溃。这两个函数会检查地址范围并在拷贝期间处理缺页因此返回非零值时驱动要把这个错误透传给应用层通常返回 -EFAULT。这里有个常被忽略的细节中断上下文中不能调用 copy_to_user因为用户态页可能不在内存中缺页处理需要睡眠。字符设备的 read/write 通常运行在进程上下文可以安全拷贝但如果你在中断处理函数里试图往用户缓冲区写数据几乎必然导致系统不稳定。正确的做法是把数据放到内核缓冲区等进程上下文再拷贝。2.2 Makefile 与内核源码树的约定不是普通程序编译内核模块不是用你常用的 gcc 命令直接编译的。它需要进入内核源码树的 Kbuild 体系由内核的 Makefile 决定编译选项。驱动源码目录下的 Makefile 至少是这个样子obj-m : demo_dev.o KERN_DIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KERN_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERN_DIR) M$(PWD) clean这里的关键是 obj-m : demo_dev.o它告诉 Kbuild 把这一个目标文件编译成内核模块。KERN_DIR 指向内核编译目录里面必须存在已经配置过的源码树。在你自己的 PC 上如果已经装了 linux-headers 包可以直接用 uname -r 定位交叉编译时KERN_DIR 要换成目标平台内核的源码目录同时还要指定 ARCH 和 CROSS_COMPILEall: $(MAKE) ARCHarm CROSS_COMPILEarm-linux-gnueabihf- \ -C $(KERN_DIR) M$(PWD) modules为什么不直接 gcc -c demo_dev.c因为内核模块的编译依赖大量内核头文件和特殊编译选项例如 -fno-pic、-mno-mmx 这类架构相关的参数还有模块重定位、符号版本信息等。Kbuild 体系会帮你把这些都处理好。如果编译出来 insmod 时报版本不匹配多半是 KERN_DIR 指错了源码树或源码树没有先完成配置。不要试图绕过这个机制老老实实走 Kbuild 是效率最高的路径。2.3 设备节点自动生成device_create 背后的 udev 机制老式驱动里分配完设备号之后要手动 mknod /dev/demo c 主设备号 次设备号用户才能访问。因为主设备号由 alloc_chrdev_region 动态分配手动 mknod 根本不知道实际分到的是多少。后来内核引入了 class 和 device 模型驱动调用 class_create 创建一个设备类再以这个类和设备号调用 device_create内核就会通过 uevent 通知用户空间的 udev 守护进程udev 根据类信息和设备名自动在 /dev 下创建节点。这个过程看着不起眼但它引出了一个驱动开发者必须理解的原则驱动和 udev 规则是配套的。如果你的产品板里没有 udev或者 udev 规则没写对insmod 之后 /dev 下看不到节点这时候不应该怀疑驱动代码而应该去检查 busybox/mdev 或 systemd-udevd 的配置。我在一个 arm 板项目里就吃过这个亏驱动加载正常、class 创建正常但就是没有节点最后发现是文件系统里 udev 规则过滤掉了未签名的设备节点。2.4 printk 级别与日志排查驱动开发早期最常用的调试手段是 printk但它看似简单实际上有级别之分。pr_info、pr_warn、pr_err 分别对应 KERN_INFO、KERN_WARNING、KERN_ERR。内核根据 console_loglevel 决定哪些消息能输出到控制台。默认情况下低级别的 pr_info 可能被丢弃只出现在 dmesg 里。如果你发现控制台没打印先 /proc/sys/kernel/printk 看一下当前级别cat /proc/sys/kernel/printk一般会输出 7 4 1 7 这样的四个数字。第一个数字是控制台日志级别表示高于该级别的日志会打印到控制台。调试时可以临时调低让调试信息都打出来echo 8 /proc/sys/kernel/printk注意这只是调试手段。产品发布时printk 输出会拖慢系统尤其在高频中断里不建议打印日志。更好的方式是使用内核的动态调试做法是驱动代码里用 dev_dbg编译时或运行时通过 debugfs 开关启用。后面我会专门讲动态调试。3. 设备树配置驱动和板子的“翻译层”3.1 compatible 匹配链路从 dts 到 probe 函数的完整过程设备树里一个外设节点通常长这样i2c1 { status okay; temperature_sensor: tmp11748 { compatible ti,tmp117; reg 0x48; interrupt-parent gpio1; interrupts 13 IRQ_TYPE_LEVEL_LOW; }; };当内核启动后I2C 子系统会扫描 I2C 总线上的子节点为每个节点创建一个 i2c_client。真正让驱动 probe 函数执行的条件是驱动里的 of_match_table 里存在一个 compatible 字符串能与节点里的 compatible 属性匹配。这个匹配不在驱动里手工写死而是由内核的设备模型自动完成。驱动的匹配表这样写static const struct of_device_id tmp117_of_match[] { { .compatible ti,tmp117 }, { } }; MODULE_DEVICE_TABLE(of, tmp117_of_match); static struct i2c_driver tmp117_driver { .probe tmp117_probe, .remove tmp117_remove, .id_table tmp117_id_table, .driver { .name tmp117, .of_match_table tmp117_of_match, }, }; module_i2c_driver(tmp117_driver);这里的核心是模块加载后i2c-core 会拿到 i2c_client 的 compatible然后遍历已注册的 i2c_driver比较驱动中 of_device_id 数组里的 compatible。匹配成功后调用 probeprobe 收到的 struct i2c_client * 里就包含了设备地址、irq、dts 节点信息。很多新人以为只要 dts 里有节点驱动 probe 就自动执行。实际上还少了一步I2C 控制器本身要 enableI2C 节点里的 status 不能是 “disabled”总线编号要对应。如果 I2C 控制器没有正常工作probe 函数根本不会被调用因为扫描总线的底层传输就失败了。排查这类问题不要先看驱动代码先用 i2cdetect 确认总线上能看到设备地址。3.2 自定义属性读取让驱动适配不同硬件版本设备树的意义不只是提供 compatible 和 reg你可以在节点里定义自己的属性比如校准系数、默认增益、通道数驱动初始化时读取这些属性决定行为。读取方法有三类传统 OF APIof_property_read_u32、of_property_read_string、of_get_named_gpio需要先拿到 struct device_node *。新式 unified device property APIdevice_property_read_u32、device_property_read_string不关心硬件描述来自设备树还是 ACPI代码更干净。GPIO 和中断是高频读取项有专门的 helper 函数比如 gpiod_get、irq_of_parse_and_map。我建议新驱动优先使用 device_property_ 系列它让代码在设备树体系和 ACPI 体系间可移植。不过在解析中断时device_property_ 系列对中断描述的支持不如 of_irq_get 直观还是要按场景选。读取自定义属性的真正难点不是 API 用法而是格式约定。属性名一旦定下来就要写进设备树绑定文档并且做兼容性考虑。比如原来读取 afe-calibration 是 u32后来硬件版本改成数组老 dts 就失效了。驱动里要处理“属性不存在”的默认值不能假设所有板子都会写全属性。3.3 platform_driver 与设备树配合非 I2C/SPI 外设的标准姿势I2C 设备有 i2c_driverSPI 设备有 spi_driver那一个挂在内存总线上的普通设备比如 FPGA 映射到某个地址空间、自定义 DMA 控制器、板级逻辑用什么框架答案是 platform_driver。platform 总线是一种虚拟总线专门用于挂载那些“不在标准总线协议上”的设备。设备树里的节点在 platform 设备创建时会转换成一个 platform_device驱动通过 of_match_table 匹配后进入 probe。static int my_fpga_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; res platform_get_resource(pdev, IORESOURCE_MEM, 0); base devm_ioremap_resource(pdev-dev, res); if (IS_ERR(base)) return PTR_ERR(base); return 0; } static const struct of_device_id my_fpga_of_match[] { { .compatible vendor,my-fpga }, { } }; static struct platform_driver my_fpga_driver { .probe my_fpga_probe, .driver { .name my_fpga, .of_match_table my_fpga_of_match, }, }; module_platform_driver(my_fpga_driver);platform_get_resource 的作用是从 dts 节点的 reg 属性里解析出物理地址区间devm_ioremap_resource 再把它映射成内核虚拟地址。后面访问寄存器就通过 base 指针加偏移量。这里为什么用 devm_ 前缀因为 managed device resource 机制会在 probe 失败或设备注销时自动释放已申请的资源大大减少错误处理分支遗漏。我在早期代码里手动 ioremap、手动 request_irq异常路径一多就漏释放改用 devm 之后这类问题几乎绝迹。3.4 设备树写错后的定位手段设备树写错了最典型的现象是 probe 没执行但内核不报错。这是因为内核只把不匹配当一个无关紧要的扫描过程。定位手段要按层来查看 /proc/device-tree 或 /sys/firmware/devicetree/base确认内核实际拿到的设备树是不是你编译的那份。有时你改了 dts 但忘记编译 dtb或烧写镜像里打包的是旧 dtb就会出现这种困惑。反编译运行时的 dtb 看看内容。设备树编译工具自带 dtc可以这样反编译dtc -I fs -O dts /proc/device-tree -o actual.dts打开驱动里 of_match_table 对应 compatible 字符串跟 dts 里的字符串逐个字符比对包括大小写、下划线、逗号、厂商前缀。compatible 是一个字符串数组匹配时只要任意一个元素相同即可。动态调试打开 driver core 的匹配过程。常见做法是传入内核命令行参数 dyndbgfile drivers/i2c/i2c-core-base.c p然后 dmesg 里能看到 i2c 扫描和匹配的详细日志。设备树排错最怕的是“看起来正确但没匹配”。我遇到过 compatible 完全一样、节点也在但就是 probe 不跑的情况最后发现是 dts 里节点被 include 的文件覆盖成了 disabled或者是 i2c 节点的 status 为 “okay” 但控制器时钟没使能。设备树问题往往是多个层面叠加出来的单看某一层很难定位所以建议你排查时先把“驱动是否注册成功” “总线是否枚举” “设备树内容是否属实”这三件事分开确认。4. 一个真实 I2C 设备驱动的完整落地以温度传感器为例4.1 从 datasheet 到寄存器操作以 TI 的 TMP117 为例这是一颗高精度数字温度传感器I2C 接口7 位从机地址引脚可配默认地址 0x48。寄存器有两个关键0x00 是温度结果寄存器16 位有符号数分辨率 0.0078125℃0x01 是配置寄存器。读温度就是读 0x00转换公式是static int tmp117_get_temp(struct i2c_client *client, int *temp_milli) { u16 raw; s16 sraw; int ret; ret i2c_smbus_read_word_data(client, 0x00); if (ret 0) return ret; raw (u16)ret; sraw (s16)le16_to_cpu(raw); *temp_milli sraw * 78125 / 10; return 0; }写驱动前我强烈建议先拿 devmem2 或 i2c-tools 直接在开发板上读一遍真实芯片的寄存器确认地址、字节序、返回格式再动手写代码。你节省下来的调试时间远超这点前期成本。这里最容易出问题的是字节序I2C smbus 读回来的 16 位数据在 Linux 里默认是大端存储格式而 TMP117 的数据手册描述的小端。很多驱动 bug 就出在忘了做 le16_to_cpu 转换读出来的温度一下子变成两三百摄氏度。4.2 驱动结构设计用 i2c_driver 挂接还是用 regmap 封装如果只是读温度你可以像上面那样直接调用 i2c_smbus_read_word_data。但如果设备有多个寄存器、需要批量读写、还要考虑缓存和睡眠控制手动维护 i2c_transfer 会非常啰嗦。内核里的 regmap 框架就是为这种场景准备的。它不是 I2C 专属SPI、MMIO 都支持核心作用是把“寄存器访问”抽象成 regmap_read/regmap_write/regmap_bulk_read并帮你处理缓存、锁、字节序。TMP117 的 regmap 初始化和读取示例static const struct regmap_config tmp117_regmap_config { .reg_bits 8, .val_bits 16, .val_format_endian REGMAP_ENDIAN_LITTLE, .max_register 0x0F, }; static int tmp117_probe(struct i2c_client *client) { struct regmap *regmap; regmap devm_regmap_init_i2c(client, tmp117_regmap_config); if (IS_ERR(regmap)) return PTR_ERR(regmap); i2c_set_clientdata(client, regmap); // 继续注册字符设备或 hwmon 接口 return 0; }regmap 的好处在于你定义好 reg_bits、val_bits 和 endian后续所有寄存器访问都不用关心 I2C 传输细节。而且 regmap 自带调试接口驱动加载后在 debugfs 下可以导出寄存器内容配合 /sys/kernel/debug/regmap/ 使用排查硬件状态非常便利。不过 regmap 也不是银弹。如果你需要自定义 I2C 消息序列比如写一个寄存器前必须先写另一个寄存器“read-modify-write”语义很复杂或者需要非标准 10 位地址直接操作 i2c_transfer 会更灵活。我的习惯是寄存器简单、访问模式标准优先 regmap寄存器访问有特殊时序就直接用 i2c_smbus_ 或 i2c_transfer。4.3 用户态接口实现read 返回温度ioctl 处理配置设备驱动最终要给应用层使用。温度传感器最简单的用户态接口就是 read 一次返回当前温度。更完整的设计是把设备当作一个小型数据源read 返回最后采样的温度ioctl 用来设置采样频率、校准参数和使能中断输出。static ssize_t tmp117_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { struct tmp117_data *data filp-private_data; char kbuf[32]; int temp_milli; int len; int ret; if (mutex_lock_interruptible(data-lock)) return -ERESTARTSYS; ret tmp117_get_temp(data-client, temp_milli); mutex_unlock(data-lock); if (ret 0) return ret; len snprintf(kbuf, sizeof(kbuf), %d\n, temp_milli); return simple_read_from_buffer(buf, count, offset, kbuf, len); }filp-private_data 通常在 open 里赋值成驱动私有数据这样 read/write 才能拿到设备实例。mutex_lock_interruptible 的目的是如果用户进程在等待锁时收到信号可以被中断唤醒而不是一直睡眠。这是驱动与用户程序交互时很关键但容易遗漏的地方。ioctl 部分根据命令码区分操作。命令码设计要注意 _IO/_IOR/_IOW/_IOWR 宏它们把命令编号与数据方向、大小编码在一起。不要自己随便定义一个数字不然不同架构下兼容性有问题。需要读写配置寄存器时ioctl 里还要再次调用 regmap 或 smbus 访问。设计用户态接口时我建议把访问粒度设计得尽量“薄”不要在 ioctl 里塞太多业务逻辑。驱动只负责访问硬件、保护并发、返回结果业务逻辑放应用层维护起来才轻松。4.4 实测中遇到的中断与并发问题温度传感器本身中断频率不高但项目里把它接到 GPIO 中断上做温度告警时并发问题就来了。驱动里既要有内核线程在中断触发时读取温度又要响应用户态 read 命令两处可能同时访问硬件。如果不去管理并发I2C 总线传输会被打乱轻则数据错误重则导致总线挂死。我采用的方案是硬件访问统一用 mutex 保护。中断处理函数里只设置一个标志位或唤醒等待队列具体的读操作放到kthread_work或irq_thread中执行。原因是 I2C 传输函数内部可能睡眠不能在 hardirq context 直接调用。用devm_request_threaded_irq注册线程化中断可以把这个约束直接交给框架static irqreturn_t tmp117_irq_handler(int irq, void *dev_id) { struct tmp117_data *data dev_id; schedule_work(data-work); return IRQ_HANDLED; }workqueue 执行温度读取读完再更新内核中的缓存值同时唤醒等待 poll/read 的用户进程。这里要特别注意不要在 workqueue 里调用 msleep 等待硬件准备除非你能确认驱动可以被阻塞。IRQ 触发的 workqueue 虽然运行在进程上下文但如果它与高优先级实时任务竞争可能导致中断响应不及时。更稳妥的做法是使用preemptible_work或者用kthread_worker控制执行线程。具体取舍要根据硬件的实时性要求决定。5. 调试工具链与性能优化从“能跑”到“跑得稳”5.1 动态调试比 printk 更可控的日志开关printk 一多系统日志被刷爆产品性能下降。动态调试可以让你把 dev_dbg/pr_debug 的开关放到运行时控制不用重新编译驱动。前提是编译内核时开启了 CONFIG_DYNAMIC_DEBUG。运行时的控制方式是这样# 打开某文件的全部调试信息 echo file kernel_dir/tmp117.c p /sys/kernel/debug/dynamic_debug/control # 打开某个函数的调试信息 echo func tmp117_probe p /sys/kernel/debug/dynamic_debug/control驱动代码里用 dev_dbg 写日志比 pr_debug 更好因为 dev_dbg 会带上设备名字多实例设备时日志可读性高。产品发布时这些调试日志默认关闭对性能几乎无影响。排查现场问题时再通过动态调试打开对应文件和函数的日志不必重新部署驱动效率差距非常大。内核还提供了 ftrace可以用它跟踪驱动的函数调用栈定位哪些函数频繁被调用。比如你想知道 read 路径上除了自己的驱动函数内核还走了哪些函数可以直接echo function /sys/kernel/debug/tracing/current_tracer echo tmp117_read /sys/kernel/debug/tracing/set_ftrace_filter echo 1 /sys/kernel/debug/tracing/tracing_on cat /sys/kernel/debug/tracing/trace这些工具的共性是不改变代码执行路径适合排查真实运行环境下的问题。5.2 设备寄存器验证devmem 和 debugfs 组合拳很多硬件问题其实是寄存器配置不对跟驱动代码无关。在板上验证时devmem 可以直接读写物理地址。假设 TMP117 挂在 I2C1 上我可以通过 i2c-tools 来验证;但如果是内存映射设备devmem 就是首选# 读取 0x01E80000 物理地址处一个 32 位寄存器 devmem 0x01E80000 32它的原理是向 /dev/mem 发起 mmap从物理地址映射到用户空间后直接读。使用时务必小心不要写坏正在使用的寄存器。内核驱动的寄存器访问经过 ioremap与 devmem 访问的物理地址一致所以 devmem 看到的值就是驱动看到的值这个特性在排查“为什么驱动读到 0 但示波器上信号正常”之类问题时非常有用。对于 regmap 管理的设备debugfs 导出信息更方便。/sys/kernel/debug/regmap/device/registers会列出当前缓存的寄存器值还能通过/sys/kernel/debug/regmap/device/access查看可读性、可写性。调试 I2C 设备时我先用 i2cdetect 确认设备存在再用 regmap debugfs 看寄存器最后用 devmem 或示波器验证物理层信号基本能覆盖绝大多数问题。5.3 驱动层面性能调优的几个关键指标驱动性能不是“把 read 函数写快点”这么简单。你需要先确认瓶颈在哪个环节。常见的驱动性能指标有几类单次 I/O 延迟从应用层调用 read 到返回数据的总时间用 ftrace 或时间戳工具测量。吞吐量单位时间能完成多少次读写典型如 SPI 屏幕显存、ADC 采集。中断处理耗时中断处理函数过长会导致其他中断被延迟严重时触发 khungtaskd。锁等待时间多个进程并发访问同一个设备时mutex 竞争是否严重。如果发现单次 I/O 延迟高首先看是否每次 read 都执行了一次完整的硬件同步访问。很多设备支持缓存上次采样结果如果数据更新频率不高完全可以在中断或内核定时器里异步刷新缓存read 只返回最近值延迟会大幅下降。如果吞吐量上不去重点检查是否频繁进入低速模式。比如 I2C 高速模式需要切换时序参数频繁切换反而比稳定运行标准模式更慢。另外copy_to_user 是不可避免的开销但如果每次只拷贝几个字节函数调用本身占比就会很大。可以考虑提供 mmap 接口让用户态直接映射 DMA 缓冲区绕开 read 路径。当然这会引入缓存一致性问题后面会讲。5.4 系统裁剪优化与驱动裁剪的关系“Linux 系统裁剪”听起来是做文件系统和内核镜像瘦身其实驱动裁剪是其中最关键的一步。裁剪的核心目标是去掉当前硬件平台上不存在的设备驱动、关闭用不到的内核子系统、压缩启动时间。具体做起来先用make menuconfig打开内核配置界面按设备类型逐项排查未使用的架构无关驱动比如各种网卡、声卡驱动全部取消。用到的驱动尽量编译成模块M而不是内建y模块不加载时不影响启动也便于运行时替换。内核自带的大量 debug 选项和 ftrace 选项在产品版本中要关闭既能减小内核镜像也能减少运行时开销。文件系统驱动只保留用到的类型比如只保留 ext4 和 overlayfs不要保留所有文件系统。驱动裁剪不只是删配置还要配合设备树。既然设备树描述的是实际硬件只要板子上没有的功能dts 里就不写对应节点驱动自然不会被 probe即使内核里编译进了这个驱动也不会占运行资源。不过内核镜像中驱动的占用还是存在想减小镜像体积就必须在配置阶段裁掉。裁剪完成后用size工具查看模块大小用lsmod确认当前已加载模块。启动时间优化可以用initcall_debug内核参数看看每个驱动的 probe 耗时。我见过一个嵌入式项目本来启动要 8 秒把一块无关音效芯片的驱动 probe 延后加载、关闭 SPI NOR 的调试日志后启动时间压到 3 秒以内。裁剪的收益往往比你想的大。6. 我在多个项目里踩过的驱动坑6.1 中断上下文里不能做的事远比文档里写得多中断上下文限制的第一条是不能睡眠。这意味着很多常规内核函数都不能调用。kmalloc要带 GFP_ATOMICmutex_lock不能直接用copy_to_user不能用i2c_transfer不能用。这些约束分散在大量内核文档里新手最容易踩的是在中断处理里调用i2c_smbus_read_word_data运行时内核直接报 “BUG: scheduling while atomic”。但更隐蔽的是“间接睡眠”。比如中断处理里调用某个函数它内部可能会去获取一个 mutex而这个 mutex 正被一个睡眠中的进程持有。这种问题不会稳定复现只会在特定并发条件下偶尔触发是驱动里最难排查的一类 bug。我的经验是中断处理函数只做“标记事件、唤醒线程、提交 workqueue”一切可能阻塞或耗时的操作都挪到线程化中断或 workqueue 里。用devm_request_threaded_irq注册时把第二个参数设为 NULL就能得到一个专用的内核线程来执行你的irq_handler回调这个回调运行在进程上下文很多限制自动解除。6.2 内存屏障与 cache 一致性DMA 缓存数据不对的根源DMA 是驱动性能优化无法绕开的主题。它的问题是 CPU 有 cacheDMA 直接访问物理内存两者可能看到不一致的数据。假设 CPU 往缓冲区里写了数据准备让 DMA 控制器发送但 DMA 读到的却是 cache 中还没回写的旧数据反过来DMA 写入新数据后CPU 读到的可能是 cache 里的旧值。正确做法是用 DMA 专用的分配函数分配缓冲区而不是用 kmalloc。最常见的是dma_alloc_coherent它返回的虚拟地址和物理地址在 DMA 操作期间保持一致驱动不需要手动做 cache 刷新或失效。如果非要复用已有缓冲区就必须在启动 DMA 前调用dma_map_single并在完成后调用dma_unmap_single并指定 DMA_TO_DEVICE/DMA_FROM_DEVICE 方向。方向选错会导致数据错乱而且这种错乱极难复现。在多核 CPU 场景下还要注意内存屏障。Linux 内核提供了dma_wmb()、dma_rmb()等屏障函数。通常的顺序是先往 DMA 描述符写入信息加写屏障再启动 DMA。否则 CPU 可能因为乱序执行导致 DMA 控制器看到未完成的描述符。6.3 热插拔和电源管理时的状态机probe 不是一切很多驱动只在 probe 里初始化和清理但从电源管理的角度看probe 只是“设备开始存在”的时刻后面还会经历 suspend/resume、runtime PM、可能的热插拔移除。devm_ 的资源管理虽然能自动释放 probe 中分配的资源但中断、DMA 通道、电源时钟这些需要显式的 suspend/resume 处理。电源管理对驱动的影响最常见的坑是系统进入 suspend 后设备时钟被关闭但驱动的数据缓存没有保存resume 后设备状态恢复驱动却还认为寄存器是之前的值。正确做法是在 suspend 回调里保存必要状态在 resume 回调里重新初始化硬件寄存器、重新启用中断、重新配置 DMA。设备树节点里的status或 PM 框架的pm_runtime_enable/pm_runtime_get也要配套。如果你不实现 suspend/resume内核会调用默认的“普通”流程很多设备在睡眠唤醒后行为异常源头就在这里。热插拔场景下还要考虑 open 状态与设备移除的竞争。用户进程正握着 /dev 节点设备却被拔掉此时 remove 回调已经在执行。解决办法通常是给设备实例加引用计数open 时 get、release 时 putremove 时直接设置一个“设备不存在”标志后续所有 read/write 都返回 -ENODEV等最后一个 release 再真正释放内存。6.4 给新人的建议先读真实驱动再动手写我现在带新人一般会建议他们先读三类源码一是内核源码里的drivers/misc/下的小驱动结构简单二是drivers/iio/里的传感器驱动能学到总线框架和并发处理三是drivers/regulator/或drivers/clk/里的驱动能学到与内核子系统交互的套路。读驱动时不要只看代码还要找对应的设备树绑定文档比如Documentation/devicetree/bindings/下的 txt 或 yaml 文件。绑定文档告诉你厂商约定的 compatible、中断类型、时钟属性这是代码注释里不会写的。动手写的时候从“只读不写”的驱动开始比如只读一个设备 ID 寄存器并把它通过 /sys 或 debugfs 暴露出去。不要一上来就写一个带 DMA、中断、并发、电源管理的完整平台驱动那样调试面太大出了问题根本分不清是总线、时钟、cache 还是并发导致的。把功能拆成最小可验证的步骤每走一步都在真实硬件上验证一步。很多所谓“玄学问题”其实是前面某步验证不充分导致的连锁反应。最后再说一点驱动调试不是看你写了多少日志而是看你能否用工具把内核的执行流程和硬件状态对上。学会读 dmesg、动态调试、devmem、i2c-tools、ftrace熟练之后你再遇到 probe 不执行、数据错乱、中断风暴这类问题就不会慌了。
返回列表