尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

嵌入式Linux驱动开发实战:从字符设备到I2C传感器

嵌入式Linux驱动开发实战:从字符设备到I2C传感器 1. 从一个点灯需求说起驱动开发到底在解决什么问题先讲一个我早些年做项目时的场景。硬件工程师把一块新板子交到我手里说屏亮了串口能打印剩下的你来。然后就扔过来一份芯片的 datasheet和一颗不知道型号的温湿度传感器。当时我脑子里第一个念头是用户空间的应用程序根本摸不到这些硬件GPIO 的寄存器地址、I2C 控制器的时序、中断怎么触发统统是内核的事儿。而内核默认又不认识这颗传感器。要让系统看见它就得写一个驱动——这本质上就是在硬件和内核的通用框架之间补上最后那一层翻译官。很多人刚开始学 Linux 设备驱动容易被《Linux设备驱动开发详解》那种几百页的书吓住。其实驱动的核心就两件事第一把你的设备挂到内核总线上让系统知道它存在第二把设备的读写能力暴露给用户空间让程序能通过 open、read、write 这些熟悉的接口来操作它。剩下那些复杂的东西都是在解决怎么挂得更规范、跑得更快、调起来更顺手的问题。这篇文章我打算围绕一个完整的小项目来讲在一块嵌入式 ARM 板子上从零写一个简单的字符设备驱动再接一颗 I2C 接口的传感器跑通整个链路。涉及设备树配置、模块动态加载、文件操作拦截、系统裁剪和性能调优。内容不算高深但都是我实际项目里踩过的路。适合刚接触嵌入式 Linux 驱动的开发者也适合那些能编译模块但不知道为什么这么写的兄弟帮你们把整条链路串起来。先给个整体地图下面这些章节对应驱动开发里几个绕不开的环节章节解决的问题对应热词环境准备怎么搭一套可用的交叉编译环境嵌入式Linux、系统安装file_operations驱动怎么给用户空间提供接口动态加载、拦截read/write设备树配置硬件信息怎么描述给内核设备树配置I2C驱动怎么和一颗真实传感器通信linux i2c、嵌入式驱动动态加载与调试模块怎么加载、出问题怎么查内核动态加载、性能调优系统裁剪和优化怎么让驱动在资源受限的板上跑好系统裁剪优化、算法嵌入式部署2. 环境准备交叉编译链、内核源码与最小根文件系统的搭建2.1 为什么一定要交叉编译而不是直接在板子上编译如果你之前在 x86 的 PC 上写过 hello world可能觉得编译不就是 gcc 一下的事儿。但嵌入式板子的 CPU 架构通常不是 x86比如我用的是 ARM Cortex-A7PC 上是 x86_64。直接在板子上跑 gcc 不是不行但板子资源有限编译一个内核模块可能得等小十分钟而且你还得先把工具链装进板子的根文件系统里非常被动。所以常规做法是交叉编译。所谓交叉编译就是在一台性能强的 PC 上用一套面向目标架构的编译器编译出能在板子上运行的二进制。这套编译器就叫交叉编译工具链。2.2 工具链的选择与安装细节在实际项目中工具链一般由 SoC 厂商提供比如用 buildroot 生成的工具链或者 Linaro 的 GCC 工具链。我比较推荐用 buildroot 统一管理因为它能把交叉编译工具链、内核、根文件系统一次性搞定版本之间也匹配。我之前偷懒直接去 Linaro 官网下了最新的工具链结果内核编译到一半报了一堆莫名其妙的头文件错误最后发现是 GCC 版本太新和内核源码的兼容性出了问题。安装工具链其实很简单解压后把 bin 目录加进 PATH 就行export PATH$PATH:/opt/gcc-arm-10.3-x86_64-arm-none-linux-gnueabihf/bin这里有个细节很多人会忽略工具链的前缀很重要。arm-none-linux-gnueabihf-gcc里gnueabihf表示使用硬浮点Hard FloatABI。如果你的内核配置了硬浮点而工具链用软浮点编译出来的模块加载时会直接报module: unknown relocation之类的错误。所以选工具链之前先确认内核的浮点配置。2.3 内核源码准备与模块编译依赖驱动模块编译需要内核源码而且必须是和你板子上运行的内核版本一致的源码最好连配置都一致。我之前图省事用开发板出厂自带的内核版本去编模块然后放到自己重新编译的内核里加载结果直接Invalid module format。原因就是内核的 vermagic版本魔法字符串对不上。比较稳妥的做法是自己完整编一遍内核然后用这套源码和配置去编模块# 解压内核源码 tar xJf linux-5.15.32.tar.xz cd linux-5.15.32 # 加载厂商提供的默认配置不同平台不一样有的在 arch/arm/configs 下 make ARCHarm mesa_defconfig # 编译内核和设备树 make ARCHarm CROSS_COMPILEarm-none-linux-gnueabihf- -j4 zImage make ARCHarm CROSS_COMPILEarm-none-linux-gnueabihf- dtbs # 编译模块并把模块安装到临时目录 make ARCHarm CROSS_COMPILEarm-none-linux-gnueabihf- modules make ARCHarm CROSS_COMPILEarm-none-linux-gnueabihf- INSTALL_MOD_PATH/home/user/rootfs modules_installARCHarm告诉内核 Makefile 按 ARM 架构处理CROSS_COMPILE指定交叉编译工具链前缀。编译内核本身要花不少时间但这一步是整个驱动开发的基石值得等。2.4 根文件系统与模块拷贝模块编好后在INSTALL_MOD_PATH指定的目录下会生成lib/modules/5.15.32/目录里面有各种 .ko 文件。我习惯把整个lib/modules/目录直接拷贝到板子的根文件系统根目录下sudo cp -r /home/user/rootfs/lib/modules /nfs/rootfs/lib/这里我多说一句调试阶段强烈建议用 NFS 挂载根文件系统。什么意思呢就是板子本身不存 rootfs而是通过网络从 PC 上挂载过来。这样你改了驱动、改了应用程序直接在 PC 上替换文件板子重启就能生效不用反复烧写 SD 卡或 eMMC。开发效率提升不是一点半点。等调试稳定了再把它固化到板载存储里。3. file_operations 实战字符设备驱动的骨架与读写拦截3.1 核心结构体的理解所有字符设备驱动的基础都是struct file_operations。这个结构体就是驱动和用户空间之间的接口契约。用户在用户态调用open()时内核最终会调用驱动里注册的.open函数调用read()就对应驱动里的.read。搞明白这张映射表驱动就懂了一半。下面是我在实际项目里写的一个最小但完整的字符设备驱动骨架带读写拦截#include linux/init.h #include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/uaccess.h #include linux/slab.h #define DEVICE_NAME mydrv #define CLASS_NAME mychrdev static dev_t dev_num; static struct cdev my_cdev; static struct class *my_class; static char *kernel_buffer; #define BUF_SIZE 1024 static int mydrv_open(struct inode *inode, struct file *file) { pr_info(mydrv: open called\n); return 0; } static ssize_t mydrv_read(struct file *file, char __user *buf, size_t count, loff_t *offset) { size_t len; if (*offset BUF_SIZE) return 0; if (*offset count BUF_SIZE) len BUF_SIZE - *offset; else len count; if (copy_to_user(buf, kernel_buffer *offset, len)) { pr_err(mydrv: copy_to_user failed\n); return -EFAULT; } *offset len; pr_info(mydrv: read %zu bytes\n, len); return len; } static ssize_t mydrv_write(struct file *file, const char __user *buf, size_t count, loff_t *offset) { size_t len; if (count BUF_SIZE) len BUF_SIZE; else len count; if (copy_from_user(kernel_buffer *offset, buf, len)) { pr_err(mydrv: copy_from_user failed\n); return -EFAULT; } *offset len; pr_info(mydrv: write %zu bytes\n, len); return len; } static int mydrv_release(struct inode *inode, struct file *file) { pr_info(mydrv: release called\n); return 0; } static struct file_operations fops { .owner THIS_MODULE, .open mydrv_open, .read mydrv_read, .write mydrv_write, .release mydrv_release, }; static int __init mydrv_init(void) { int ret; /* 动态分配主设备号 */ ret alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); if (ret 0) { pr_err(mydrv: failed to alloc region\n); return ret; } pr_info(mydrv: major %d, minor %d\n, MAJOR(dev_num), MINOR(dev_num)); /* 初始化 cdev 并添加到内核 */ cdev_init(my_cdev, fops); my_cdev.owner THIS_MODULE; ret cdev_add(my_cdev, dev_num, 1); if (ret 0) { unregister_chrdev_region(dev_num, 1); pr_err(mydrv: cdev_add failed\n); return ret; } /* 在 /sys/class 下创建设备类配合 udev 自动创建设备节点 */ my_class class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(my_class)) { cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(my_class); } device_create(my_class, NULL, dev_num, NULL, DEVICE_NAME); /* 分配内核缓冲区 */ kernel_buffer kzalloc(BUF_SIZE, GFP_KERNEL); if (!kernel_buffer) { device_destroy(my_class, dev_num); class_destroy(my_class); cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); return -ENOMEM; } pr_info(mydrv: driver initialized\n); return 0; } static void __exit mydrv_exit(void) { kfree(kernel_buffer); device_destroy(my_class, dev_num); class_destroy(my_class); cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); pr_info(mydrv: driver unloaded\n); } module_init(mydrv_init); module_exit(mydrv_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Embedded Engineer); MODULE_DESCRIPTION(A simple character device driver);3.2 设备号的分配动态和静态这里我用了alloc_chrdev_region动态分配设备号好处是不用自己去查哪个主设备号没被占用。但动态分配带来一个问题你不知道内核给了你哪个主设备号所以应用程序没法写死设备节点。解决办法有两个。一个是像我上面代码里那样配合class_create和device_create在 /sys/class 下创建类和设备信息然后让 udev设备管理器自动在 /dev 下生成节点。系统里跑的是完整版 Linux 的话插入模块后你直接能看到/dev/mydrv文件就是 udev 根据device_create传进去的信息自动生成的。另外一个办法是静态指定主设备号比如register_chrdev_region(MKDEV(240, 0), 1, DEVICE_NAME)。这种方式适合设备号已经固定下来的产品化场景能保证每次模块加载都得到同一个设备节点。我之前维护的老项目里板子用的是 busybox mdev一个轻量版的 udev 替代mdev 需要手动配置热插拔规则我嫌麻烦就直接用了静态设备号然后让脚本在模块加载后手工mknod创建设备节点mknod /dev/mydrv c 240 03.3 读写拦截的真实用途不只是传数据前面说的拦截可能让人觉得有点 hack 的感觉其实在正经的驱动开发里拦截 read/write 是非常常规的需求。举几个我实际做过的东西第一个是数据过滤。驱动在把内核里收集到的原始数据传给用户空间之前先在 read 回调里对数据做一次校验和格式整理。比如我做过的一个 CAN 总线驱动硬件 FIFO 里出来的帧有时候会有 CRC 错误我不会直接把脏数据丢给应用层而是在 read 里过滤掉错误帧只把完好的帧交上去。第二个是透明加密。这个在企业数据安全场景很常见在内核文件系统层拦截 read/write读的时候自动解密写的时候自动加密对用户空间的应用程序完全透明。这个功能用常规 VFS 层的 hook 或者 fanotify 机制做而不是直接去改 file_operations但思路是一样的——在你关心的那个读写路径上插入自己的逻辑。第三个是统计与审计。我在.read和.write回调里加了原子计数器记录累计读写了多少字节。用户空间通过 ioctl 接口可以随时查询用来做流量统计或者调试分析。这种不改变数据内容只记录行为的拦截在生产环境中非常有用。注意copy_to_user和copy_from_user是必须用的不能用memcpy。因为用户空间的指针其实是一个逻辑地址在你访问它的那一刻对应的物理页可能并不在内存里可能会触发缺页异常。这两个 API 会帮你处理这种异常情况同时也会做权限检查。3.4 编译模块的 Makefile编这个模块的 Makefile 非常简单但第一次接触时容易懵obj-m : mydrv.o KDIR : /home/user/linux-5.15.32 ARM_TOOLCHAIN : arm-none-linux-gnueabihf- all: $(MAKE) ARCHarm CROSS_COMPILE$(ARM_TOOLCHAIN) -C $(KDIR) M$(PWD) modules clean: $(MAKE) ARCHarm CROSS_COMPILE$(ARM_TOOLCHAIN) -C $(KDIR) M$(PWD) clean这里obj-m : mydrv.o是告诉内核构建系统把mydrv.c编译成内核模块。M$(PWD)表示当前目录是模块源码所在目录。我早期一直想不通为什么我明明在模块目录里执行 make还要指定 M 参数后来才明白真正干活的 Makefile 是内核源码里的那一套它需要知道去哪儿找你的模块源码M就是干这个的。4. 设备树配置从注册混乱到描述清晰4.1 为什么需要设备树如果你接触过老一点的内核比如 2.6 时代会知道当时的做法是每来一块新板子就在内核源码的arch/arm/mach-xxx目录里加一个板级文件用 C 代码描述板子上有哪些设备、用的哪个中断、寄存器地址在哪儿。这种方式在硬件少的时候还能凑合但后来 ARM 平台碎片化越来越严重一个内核要支持几十上百块板子每个板级文件都是一堆重复的注册代码内核社区很快就受不了了。设备树Device Tree就是干这个的把硬件信息从内核代码里剥出来用一种结构化的文本描述它编译成二进制后随内核一起加载。驱动的职责被简化成识别某个 compatible 字符串然后从设备树节点里读资源。硬件怎么接的那是设备树的事情驱动怎么访问硬件是驱动自己的事情。两者彻底解耦。4.2 一个实际的设备树节点我项目中那颗温湿度传感器是 I2C 接口的在设备树里就得把它的节点挂到对应的 I2C 控制器下i2c2 { status okay; clock-frequency 100000; /* 100kHz 标准模式 */ si7020: si702040 { compatible silabs,si7020; reg 0x40; interrupt-parent gpio1; interrupts 13 IRQ_TYPE_EDGE_FALLING; measurement-timeout-ms 25; }; };几个要点compatible字符串是驱动和设备节点之间匹配的关键。驱动里用of_device_id数组声明自己支持哪些 compatible内核据此把设备和驱动程序绑定在一起。reg 0x40是设备在 I2C 总线上的地址。注意I2C 地址是 7 位地址不带读写位。很多人把 datasheet 里的 8 位地址比如 0x81直接填进来结果驱动发现设备回 NACK报 device not found。interrupt-parent和interrupts描述了设备的中断引脚接到哪个 GPIO 控制器、哪个脚、什么触发方式。这里我的传感器 ALERT 引脚接到了 GPIO1_13下降沿触发。自定义属性measurement-timeout-ms是我自己加的用来告诉驱动测量需要等多久。设备树允许你定义私有属性驱动通过of_property_read_u32来读。4.3 驱动的匹配方式对应地I2C 驱动的 probe 函数得知道怎么找到自己的设备static const struct of_device_id si7020_of_match[] { { .compatible silabs,si7020 }, { } }; MODULE_DEVICE_TABLE(of, si7020_of_match); static struct i2c_driver si7020_driver { .probe si7020_probe, .remove si7020_remove, .id_table si7020_id, /* 传统 I2C 设备ID表 */ .driver { .name si7020, .of_match_table si7020_of_match, }, }; module_i2c_driver(si7020_driver);编译好驱动后把它 .ko 文件拷贝到板子 rootfs 里然后执行insmod si7020.ko。此时驱动会注册成一个 i2c_driver内核会在总线上查找 compatible 字符串为 silabs,si7020 的设备。如果设备树里配置正确就会自动调用 probe 函数设备就算正式上线了。如果 probe 没有被调用我一般按这个顺序排查查看/sys/bus/i2c/devices/目录确认设备树里 i2c2 总线上有没有出现0-0040这个设备。查看/sys/firmware/devicetree/base/对应的节点路径确认设备树内容是否真的编译进去了。三次检查设备树节点的 reg 地址和 datasheet 里写的是不是一致特别是地址到底是十进制还是十六进制。4.4 反编译设备树一个实用的调试技巧设备树源文件 .dts 是给人看的板子引导时用的是编译后的 .dtb 二进制。有时候你拿到的 .dtb 是厂商编好的没有对应的 .dts 源文件或者你改了 .dts 但不确定编译进去没有。这时候 Linux 提供一个非常好用的工具dtc反编译器。dtc -I dtb -O dts -o output.dts board.dtb这条命令能把 .dtb 转回可读的 .dts。我在适配一个新板子的时候第一件事就是反编译一下厂商的 .dtb看看他们默认打开了哪些外设、GPIO 复用了哪些引脚、各个控制器被禁用掉没有。这比自己瞎猜省事得多。反正我现在拿到一块陌生板子的第一反应已经不是看原理图了而是先反编译设备树。5. I2C 驱动实战与一颗传感器的完整对话5.1 I2C 协议核心概念回顾I2CInter-Integrated Circuit总线是嵌入式里最常用的低速总线之一特点是只有两根线SDA数据和 SCL时钟所有设备都挂在这两根线上通过地址区分。总线上的设备分为主设备Master和从设备Slave。大多数时候SoC 的 I2C 控制器是 Master外设传感器是 Slave。一次 I2C 通信流程大概是这样的主机发出起始信号接着发送 7 位从机地址 1 位读写标志被寻址的从机回 ACK然后开始传输数据最后发停止信号。这套时序对于驱动开发者来说通常不用手动去控制——内核的 i2c-core 和 I2C 控制器驱动已经帮你封装好了。你要做的事情就是调用i2c_transfer或者i2c_smbus_read_byte_data这类接口。5.2 驱动的 probe 函数里做了什么当设备树和驱动匹配成功后内核会调用 probe 函数这个函数是一个 I2C 设备驱动的初始化主体static int si7020_probe(struct i2c_client *client, const struct i2c_device_id *id) { struct si7020_data *data; struct i2c_adapter *adapter client-adapter; int ret; /* 检查当前 I2C 控制器是否支持 SMBUS 操作 */ if (!i2c_check_functionality(adapter, I2C_FUNC_SMBUS_BYTE_DATA | I2C_FUNC_SMBUS_WORD_DATA)) { dev_err(client-dev, i2c adapter lacks smbus capability\n); return -EOPNOTSUPP; } /* 分配私有数据结构 */ data devm_kzalloc(client-dev, sizeof(*data), GFP_KERNEL); if (!data) return -ENOMEM; >static int si7020_read_temp(struct si7020_data *data) { s32 ret; /* 0xE3 是触发温度测量的命令 */ ret i2c_smbus_read_word_data(data-client, 0xE3); if (ret 0) { dev_err(data-client-dev, temp read failed: %d\n, ret); return ret; } return ret; }看起来简单吧但里面有一个非常经典的坑字节序问题。I2C 设备传输数据时绝大多数遵循高位在前大端序。你从i2c_smbus_read_word_data拿到的返回值虽然是一个 u16但它的字节顺序可能和你想的正好相反。我之前第一次读传感器数据读出来的温度显示 -12℃ 而实际室温是 25℃最后发现是字节序没做转换。正确做法是用be16_to_cpu或swab16把从设备读到的原始字节转换成 CPU 序。另外一个坑是传感器的测量时间。有些传感器发起测量命令后不能立刻读取结果需要等待一段时间比如 20ms。我第一次没等直接去读返回的是一堆 0x00。后来在设备树里自定义了measurement-timeout-ms属性probe 时读出来存到私有数据里每次测量后调usleep_range(20000, 30000)等一个调度周期以上再读结果就稳定了。5.4 用户空间怎么访问这个 I2C 驱动驱动注册成 hwmon 设备后用户空间就可以通过标准的 sysfs 接口读取数据cat /sys/class/hwmon/hwmon0/temp1_input cat /sys/class/hwmon/hwmon0/humidity1_input这里我做了一个设计决策没有为这个传感器驱动单独创建 /dev 设备节点而是复用了内核已有的 hwmon 子系统。为什么因为 hwmon 子系统的接口约定非常成熟用户空间工具比如lm-sensors直接就能读不用专门写一个配套的应用程序。内核里其实有很多这种现成的框架——input 子系统管输入设备、regmap 管寄存器映射、hwmon 管传感器。写驱动前先想想你要接的设备属于哪一类能不能直接挂在对应的子系统上往往能省下一大堆代码。6. 动态加载与模块调试insmod 之后的那些事6.1 模块加载、卸载与依赖管理前面我们已经编译出了 si7020.ko接下来就是加载和测试。基础命令大家都会但实际项目里有一些容易被忽略的坑。insmod si7020.ko # 加载模块不处理依赖 modprobe si7020.ko # 自动处理依赖会查找 /lib/modules/$(uname -r)/ lsmod # 查看已加载模块列表 rmmod si7020 # 卸载模块按名字删insmod和modprobe的区别简单理解就是insmod笨只负责把指定文件加载进去modprobe聪明它会先分析模块的依赖关系.ko 文件头里记录了依赖哪些其他模块然后按顺序把依赖的模块也一起加载而且是从/lib/modules/$(uname -r)/这个标准目录里找模块文件。所以我强烈建议哪怕只是为了调试也把.ko 装到标准目录里cp si7020.ko /nfs/rootfs/lib/modules/5.15.32/extra/ depmod -a modprobe si7020depmod -a是重新生成模块依赖文件 modules.dep这样modprobe才能正确解析依赖关系。6.2 我看过的最有用的一组调试手段加载驱动后第一件事永远是看 dmesg内核日志缓冲看 probe 有没有被调用、有没有报错dmesg | tail -50如果dmesg显示输出太多太乱可以用dmesg -w实时跟踪然后另一个终端操作设备这样能精确看到每次用户空间调用驱动时打印的信息。第二件事是用debugfs。内核会为每个 I2C 客户端生成一个 debugfs 节点mount -t debugfs none /sys/kernel/debug ls /sys/kernel/debug/i2c/0-0040/有些 I2C 控制器驱动支持 direct register access 模式你可以直接从一个 shell 里读写寄存器不用写任何代码# 在 i2c-2 总线上读地址为 0x40 的设备的寄存器 0xE3 i2cget -y 2 0x40 0xE3 # 写寄存器 0x01 为 0x00 i2cset -y 2 0x40 0x01 0x00这套i2c-tools工具是嵌入式 Linux 调试 I2C 设备的神器。我一般会在根文件系统里预装它遇到设备探测不出来的情况先不写驱动直接命令行试探性地读写传感器寄存器——如果命令能返回合理数据说明硬件链路没问题问题出在驱动或设备树如果返回 NACK 或超时基本就是接线错误、地址错误或者设备没上电。这样能把硬件问题和软件问题快速切分开。6.3 模块加载失败时的常见错误与定位我整理了一份最常见的加载失败错误对照表这些都是我实际踩过的坑错误信息根因解决办法Invalid module format内核版本/配置与模块编译时不匹配用与当前内核完全相同的源码和 .config 重编模块Unknown symbol xxx模块依赖的符号未导出或依赖模块未加载检查 /proc/kallsyms加载依赖模块检查符号是否 EXPORT_SYMBOLNo such device设备树配置了禁用状态或 compatible 不匹配检查节点 status okay核对 compatibleresource busy设备已被其他驱动占用查看 /proc/iomem 和 /proc/interrupts 确认占用者加载后 dmesg 无任何输出模块 init 未执行或 pr_info 被 log level 屏蔽用dmesg -n 8或设置loglevel8内核参数关于Unknown symbol我再多说一句。内核模块之间共享函数的方式是EXPORT_SYMBOL。如果你自己写了一个公共模块比如一个寄存器读写抽象层然后另一个模块要调用它的函数必须在这个公共模块里用EXPORT_SYMBOL(func_name)导出符号。同时加载顺序上被依赖的模块要先加载否则依赖它的模块在解析符号时会失败。6.4 使用 printk 的正确姿势初学者最常见的做法是在驱动里到处printk但 printk 是有级别的。内核日志级别从高到低依次是KERN_EMERG0、KERN_ALERT1、KERN_CRIT2、KERN_ERR3、KERN_WARNING4、KERN_NOTICE5、KERN_INFO6、KERN_DEBUG7。用pr_info输出的是级别 6在默认的 console loglevel通常是 7下能显示但如果用户开的是quiet模式级别低于 4 的日志都会被丢弃。调试阶段我习惯直接用pr_info简单直接。但要发布给客户或丢到产品里的驱动我建议适当保留一些pr_err级别的错误日志把频繁刷屏的调试日志要么删掉要么用#ifdef DEBUG包起来。我见过有驱动模块加载后每秒刷几十条日志把串口控制台直接卡死整个系统显得像死机了一样其实就是日志风暴引起的。7. 性能调优与系统裁剪让驱动在资源受限的板上跑得更稳7.1 先定位是驱动慢还是整个系统慢嵌入式项目的性能问题第一步不是优化代码而是定位瓶颈。我之前接手过一个项目方案是在 ARM 板子上跑一个算法推理但输入数据的采集经常卡顿用户抱怨处理一帧要好几秒。一开始大家都以为是算法太慢后来我用perf top看了一眼发现 CPU 时间大部分耗在驱动的中断处理和一个无锁队列的竞争上算法本身只占了 20% 都不到。所以在嵌入式 Linux 上调优我建议按这套顺序排查top或htop看 CPU 占用区分是用户态程序还是内核态占了 CPU。vmstat看上下文切换和中断频率如果cscontext switch特别高说明系统中存在频繁的调度或中断。/proc/interrupts看中断数量。如果一个设备的中断数每秒在几万次以上那驱动的中断处理逻辑一定有问题。perf top精确到函数级别的热点。7.2 驱动层面的优化技巧减少中断、合并读写在驱动层面最有效的优化手段往往是减少不必要的中断。拿我那个 I2C 传感器举例最初我实现了周期性轮询每 100ms 读一次温湿度用hrtimer高精度定时器定时触发。但在低功耗场景下每次 I2C 通信都会唤醒 CPU而且频繁中断会拉高系统的平均功耗。后来我改成了中断触发 延迟读取模式传感器数据准备好后通过 ALERT 引脚拉低通知 GPIO 中断驱动在中断上下文里只做一个标志位真正耗时的 I2C 读取放到工作队列workqueue里执行。这样系统空闲时几乎完全没有 I2C 通信功耗大幅下降。另一个常见优化是合并读写的粒度。如果你每次只读 1 个字节而传感器的输出是 16 位数据那一次完整的读数需要两次 I2C 事务中间还有等待时间。合理做法是把两次读取合并成一次i2c_transfer传一个i2c_msg数组进去让控制器驱动在底层一次性完成所有传输static int si7020_read_raw(struct si7020_data *data, u16 *temp) { u8 cmd 0xE3; u8 buf[2]; struct i2c_msg msg[2] { { .addr ># 查看中断号 cat /proc/interrupts | grep i2c # 将 I2C 中断绑定到 CPU1掩码为 0x02 echo 2 /proc/irq/51/smp_affinity这个优化对单核系统无效但对多核系统往往效果立竿见影。我测试过把图像采集中断和算法主线程分开到两个核上帧率达到的稳定性明显改善。8. 踩坑清单我在驱动开发中最常遇到的几个问题8.1 解压源码乱码的问题这个看着像系统管理问题实际上驱动开发里也经常碰到。有次我从 Windows 机器上拿到的内核源码压缩包在 Linux 下解压后一堆文件名变成乱码编译直接失败。原因是压缩包是在 Windows 下用非 UTF-8 编码创建的文件名编码和 Linux 的不一致。解决办法分两种如果是有问题的 .zip 包用unzip -O CP936指定编码方式如果是 .tar.gz 包一般不会有这个问题。但最好还是一开始就在 Linux 下解压并且用 git 或 rsync 传输代码避免各种编码和换行符的糟心事。# 处理 Windows 下压缩的 zip 包乱码 unzip -O CP936 source.zip8.2 删了文件但磁盘空间没释放这也是嵌入式开发里很容易撞上的问题你的应用程序持续打开一个文件比如日志文件然后你用rm把它删了但df -h一看空间还是满的。原因很简单文件被进程打开了inode 还被引用着内核不会真正释放磁盘块直到进程关闭这个文件描述符。我在调试板子存储空间不足时遇到过好几次。解决方法是找到那个还拽着文件的进程lsof | grep deleted # 或者 ls -l /proc/*/fd/ | grep deleted然后重启对应进程或者干脆用echo /proc/sys/vm/drop_caches清理缓存。这个问题的教训是写到一半的大文件千万别用rm删应该用truncate -s 0 filename先把文件清空再用rm删除。8.3 外接显示器无画面这个属于显示驱动的范畴。有次我在一个 ARM 板子上外接 HDMI 显示器启动后屏幕黑屏但系统本身运行正常。排查过程先看内核日志dmesg | grep -i hdmi发现 DRM 驱动成功初始化了但没检测到显示器。用cat /sys/class/drm/card0-*/status发现 HDMI-A-1 状态是 disconnected。检查设备树发现 HDMI 的 5V 供电使能引脚 GPIO 没有配置输出高电平。原来硬件设计上HDMI 接口的 5V 需要 SoC 的一个 GPIO 去控制开关这个 GPIO 默认是低电平导致显示器检测不到信号。这个问题的本质是设备树配置缺失补上 GPIO 控制后恢复正常。类似的还有屏幕背光不亮、触摸屏没反应等很大概率都是 GPIO 复用或供电控制引脚没配好而不是驱动本身的问题。8.4 虚拟机和 WSL 里跑内核模块的坑现在不少朋友会在 Windows 上用虚拟机或 WSL 来学习 Linux 驱动。这里要泼一盆冷水虚拟机里不能直接运行你编译的内核模块因为模块需要和真实硬件交互而虚拟机里的硬件是虚拟设备驱动模型和物理硬件完全不同。比如你在 QEMU 虚拟机里加载一个写好的 I2C 驱动会发现根本找不到对应的 I2C 控制器——因为虚拟机的 I2C 控制器和真实 ARM 板子的 I2C 控制器根本不是一回事。我自己早期的做法是在虚拟机里只做交叉编译和代码验证真正跑模块用 QEMU 的-M vexpress-a9模拟板子加载测试。如果要学真实的嵌入式驱动还是老老实实弄一块开发板。另外 WSL 也有自己的限制第一代 WSLWSL1根本不支持真实的内核模块加载第二代 WSL2 虽然跑在轻量级虚拟机上但内核是微软定制版模块签名和版本号跟标准内核不一样自己编译的模块大概率加载不进去。我建议用 WSL 学 Linux 命令、写应用代码没问题但学驱动开发还是走交叉编译 开发板的正路。8.5 一个我永远会先查的检查项是否忘记MODULE_LICENSE最后分享一个很基础但极其常见的坑模块文件头忘记加MODULE_LICENSE(GPL)。在较新的内核里这个问题不一定会报错但它会导致一个非常隐蔽的问题如果你驱动里用了某些 GPL-only 的导出符号比如EXPORT_SYMBOL_GPL声明的函数链接阶段可能直接失败或者加载时提示 Unknown symbol。所以我的习惯是每个新模块的源码头部一律先写好三件套MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(Driver description);不要觉得这些只是形式化的声明MODULE_LICENSE不仅仅是一个法律声明它在编译链接阶段就决定了你可以使用哪些内核符号。我见过同事因为把 GPL 写成了 Proprietary结果驱动源码里用了一个EXPORT_SYMBOL_GPL的函数编译时直接报错排查了大半天。9. 写在最后的一点建议驱动开发这个方向入门门槛确实比应用开发要高一点因为它要求你同时对硬件、内核框架、编译工具链有认识。但说实话一旦你把前面说的这条链路完整跑通过一次——从环境搭建到设备树配置从写 file_operations 到接上真实传感器从 insmod 到调优——后面再碰到其他设备基本都是同一套方法论只是换了个寄存器地址和时序。我在这个项目里最大的体会是不要一开始就去啃内核源码的细节邓爷爷说过实践是检验真理的唯一标准把它套在这里也一样——先把最简单的字符设备驱动跑起来再去碰设备树、中断、DMA。当你有了一个可以跑、可以测、可以破坏的最小系统之后学任何新概念都有了依托。最后给新手一个具体的行动建议准备一块开发板不一定很贵百元级的 Linux 开发板就够、一个传感器模块I2C 的最好、一根串口线。把这篇博客里的例子照着敲一遍然后试着改一下缓冲区大小、改一下设备树的中断引脚、加一个 ioctl 命令。等你能把这个小项目玩出花来你就算正式迈过 Linux 设备驱动开发的门槛了。
返回列表