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-A7,PC 上是 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 Float)ABI。如果你的内核配置了硬浮点,而工具链用软浮点,编译出来的模块加载时会直接报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 ARCH=arm mesa_defconfig # 编译内核和设备树 make ARCH=arm CROSS_COMPILE=arm-none-linux-gnueabihf- -j4 zImage make ARCH=arm CROSS_COMPILE=arm-none-linux-gnueabihf- dtbs # 编译模块,并把模块安装到临时目录 make ARCH=arm CROSS_COMPILE=arm-none-linux-gnueabihf- modules make ARCH=arm CROSS_COMPILE=arm-none-linux-gnueabihf- INSTALL_MOD_PATH=/home/user/rootfs modules_installARCH=arm告诉内核 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) ARCH=arm CROSS_COMPILE=$(ARM_TOOLCHAIN) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) ARCH=arm 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: si7020@40 { 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 协议核心概念回顾
I2C(Inter-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_SYMBOL |
No such device | 设备树配置了禁用状态,或 compatible 不匹配 | 检查节点 status = "okay",核对 compatible |
resource busy | 设备已被其他驱动占用 | 查看 /proc/iomem 和 /proc/interrupts 确认占用者 |
| 加载后 dmesg 无任何输出 | 模块 init 未执行或 pr_info 被 log level 屏蔽 | 用dmesg -n 8或设置loglevel=8内核参数 |
关于Unknown symbol,我再多说一句。内核模块之间共享函数的方式是EXPORT_SYMBOL。如果你自己写了一个公共模块(比如一个寄存器读写抽象层),然后另一个模块要调用它的函数,必须在这个公共模块里用EXPORT_SYMBOL(func_name)导出符号。同时加载顺序上,被依赖的模块要先加载,否则依赖它的模块在解析符号时会失败。
6.4 使用 printk 的正确姿势
初学者最常见的做法是在驱动里到处printk,但 printk 是有级别的。内核日志级别从高到低依次是:KERN_EMERG(0)、KERN_ALERT(1)、KERN_CRIT(2)、KERN_ERR(3)、KERN_WARNING(4)、KERN_NOTICE(5)、KERN_INFO(6)、KERN_DEBUG(7)。用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看上下文切换和中断频率,如果cs(context 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 也有自己的限制:第一代 WSL(WSL1)根本不支持真实的内核模块加载,第二代 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 设备驱动开发的门槛了。