嵌入式Linux驱动开发实战:从字符设备、设备树到I2C与调试
2026/9/15 5:24:10 网站建设 项目流程

刚接手嵌入式 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_read;write 同理。所以在驱动里,你不是在“实现 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_COMPILE:

all: $(MAKE) ARCH=arm CROSS_COMPILE=arm-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: tmp117@48 { 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。匹配成功后调用 probe,probe 收到的 struct i2c_client * 里就包含了设备地址、irq、dts 节点信息。

很多新人以为只要 dts 里有节点,驱动 probe 就自动执行。实际上还少了一步:I2C 控制器本身要 enable,I2C 节点里的 status 不能是 “disabled”,总线编号要对应。如果 I2C 控制器没有正常工作,probe 函数根本不会被调用,因为扫描总线的底层传输就失败了。排查这类问题,不要先看驱动代码,先用 i2cdetect 确认总线上能看到设备地址。

3.2 自定义属性读取:让驱动适配不同硬件版本

设备树的意义不只是提供 compatible 和 reg,你可以在节点里定义自己的属性,比如校准系数、默认增益、通道数,驱动初始化时读取这些属性决定行为。读取方法有三类:

  • 传统 OF API:of_property_read_u32、of_property_read_string、of_get_named_gpio,需要先拿到 struct device_node *。
  • 新式 unified device property API:device_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_driver,SPI 设备有 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 没执行,但内核不报错。这是因为内核只把不匹配当一个无关紧要的扫描过程。定位手段要按层来:

  1. 查看 /proc/device-tree 或 /sys/firmware/devicetree/base,确认内核实际拿到的设备树是不是你编译的那份。有时你改了 dts 但忘记编译 dtb,或烧写镜像里打包的是旧 dtb,就会出现这种困惑。
  2. 反编译运行时的 dtb 看看内容。设备树编译工具自带 dtc,可以这样反编译:
dtc -I fs -O dts /proc/device-tree -o actual.dts
  1. 打开驱动里 of_match_table 对应 compatible 字符串,跟 dts 里的字符串逐个字符比对,包括大小写、下划线、逗号、厂商前缀。compatible 是一个字符串数组,匹配时只要任意一个元素相同即可。
  2. 动态调试打开 driver core 的匹配过程。常见做法是传入内核命令行参数 dyndbg="file 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_workirq_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_ATOMIC,mutex_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 有 cache,DMA 直接访问物理内存,两者可能看到不一致的数据。假设 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 时 put,remove 时直接设置一个“设备不存在”标志,后续所有 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 不执行、数据错乱、中断风暴这类问题,就不会慌了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询