
做了几年 Linux 设备驱动开发我必须先承认一个事实驱动难不是难在写代码而是难在搞懂内核那套机制和硬件之间的配合关系。网上关于驱动开发的学习资料非常多但多数要么讲得太理论要么直接甩一个 hello world 模块就没了。真正遇到项目里那个“设备突然不工作”的问题时很多人还是不知道从哪里下手。这篇文章我不打算给你复述教科书而是想从一个实际做项目的角度把 Linux 设备驱动开发里最常用的框架、设备树配置、调试手段、并发安全这些内容完整地串一遍。无论你是刚接触嵌入式 Linux 的新手还是已经从应用层转到底层的开发者只要你能跟着把字符设备驱动、设备树匹配、I2C 总线驱动这几条主线走通你在工作中遇到的绝大多数驱动问题就都能找到方向了。1. 驱动开发的第一步先把内核模块跑起来1.1 驱动到底是什么先区分几类驱动很多人一提到“驱动”就联想到一个 .ko 文件然后 insmod 一下就完事。实际上 Linux 下的驱动类型是可以分成几个大方向的。最常见的三种网络设备驱动、块设备驱动、字符设备驱动。字符设备驱动是门槛最低也是最适合入门的一条路因为它抽象出来的接口特别直白——就是 open、read、write、ioctl、close 这一套文件操作。做驱动开发之前你得先有一台能编译内核模块的机器。这里有个常见误区不是说你装了个 Ubuntu 就能直接编译 .ko你得装好内核头文件也就是 linux-headers 包。Ubuntu 上一条命令就能搞定sudo apt-get install linux-headers-$(uname -r)如果是嵌入式平台比如用 Buildroot 或者 Yocto 编译出来的系统那你需要对应这个内核版本的内核源码树同时交叉编译工具链也必须匹配。这个“版本必须严格对应”的要求是很多新手第一次编译驱动就出错的主要原因。1.2 一个最小内核模块示例写驱动和写应用最大的区别是应用跑在用户态驱动跑在内核态。内核态没有那么多库函数可以用也不能随便访问用户态指针更不能用 printf。最基础的输出函数是 printk它的输出会进内核日志缓冲区用 dmesg 命令查看。一个最小可编译的内核模块长这样#include linux/module.h #include linux/kernel.h #include linux/init.h static int __init hello_init(void) { printk(KERN_INFO hello driver loaded\n); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO hello driver unloaded\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(your name); MODULE_DESCRIPTION(a simple hello module);对应的 Makefileobj-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看到这里你可能觉得很简单但这里已经藏着一个必须理解的“为什么”为什么 CPP 里用 __init 和 __exit 宏因为 __init 宏会把函数放到一个专门的初始化段里在驱动加载完成后这段内存可以被释放省下内核内存。__exit 同理只不过在编译进内核而不是模块时这个段会被直接丢弃。这些都是教科书上容易忽略、实际又很重要的细节。1.3 编译模块时的几处关键细节我在实际项目里碰到过不少次“编译报错几百行仔细一看是交叉编译工具链没配好”的情况。如果你是给 ARM 开发板编译驱动Makefile 必须指定 CROSS_COMPILE 和 ARCH否则默认用宿主机 x86 工具链编出来的东西根本不能加载obj-m : hello.o CROSS_COMPILE : arm-linux-gnueabihf- KERNEL_DIR : /path/to/kernel/source PWD : $(shell pwd) all: $(MAKE) ARCHarm CROSS_COMPILE$(CROSS_COMPILE) -C $(KERNEL_DIR) M$(PWD) modules还有一个很容易踩的坑目标板内核和模块内核版本不一致。加载模块的时候系统会检查 vermagic如果版本字符串不匹配直接报 invalid module format。遇到这种情况第一反应不应该是“换个 insmod 方式”而是回头确认内核源码树是不是和目标板完全同一个版本。2. 字符设备驱动框架用户态读写背后的完整链路2.1 file_operations 与打开流程字符设备驱动的核心结构是 file_operations。这个结构体里的每个成员对应的是用户态调用某个系统调用时内核会回调进来的函数指针。比如你在用户态写了一个 int fd open(“/dev/mydrv”, O_RDWR)最终触发的是驱动里 .open 成员指的那个函数。read 和 write 同理。一个比较完整的字符设备框架代码通常包含这些部分static int mydrv_open(struct inode *inode, struct file *filp) { // 通常在这里做硬件初始化、引用计数 return 0; } static ssize_t mydrv_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { // 把内核缓冲区的数据复制给用户态 // 绝对不能用 memcpy要用 copy_to_user return 0; } static ssize_t mydrv_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset) { // 从用户态接受数据用 copy_from_user return count; } static int mydrv_release(struct inode *inode, struct file *filp) { return 0; } static const struct file_operations mydrv_fops { .owner THIS_MODULE, .open mydrv_open, .read mydrv_read, .write mydrv_write, .release mydrv_release, };这里有个我见过很多新手都会犯的错误在内核里直接使用 memcpy 把用户态传进来的 buf 拷贝到内核空间。这个操作在 x86 上碰巧能工作但换成 ARM 平台后轻则访问非法地址导致应用段错误重则整机 panic。正确的做法就是上面代码注释里写的 copy_to_user 和 copy_from_user。这两个函数会做指针合法性检查和缺页处理驱动开发里凡是涉及用户态地址的操作一律走这两个接口。2.2 设备号分配与设备节点自动创建字符设备在内核里有自己的身份证也就是设备号由主设备号和次设备号构成。主设备号表示设备类型次设备号表示同类型设备里的第几个。传统的写法是自己指定一个主设备号然后调用 register_chrdev_region。问题是你随便选的数字很可能已经被别的驱动占用了。更好的方式是用 alloc_chrdev_region 让内核动态分配主设备号这样省心也不容易冲突。分配完设备号之后还要把 cdev 结构体初始化并添加到内核里。cdev_init 负责把 cdev 和 file_operations 绑定cdev_add 则把设备正式注册进去。但只有 cdev 还不够。用户态访问设备靠的是 /dev 下的节点这个节点怎么创建网上很多教程让你手动 mknod实际项目中根本不会这么干。正确流程是在驱动里创建 class再在 class 下面创建设备这样 udev 会自动在 /dev 下生成对应的节点而且权限、属主都能自动配置好。static struct class *mydrv_class; static struct device *mydrv_device; dev_t mydrv_devno; mydrv_class class_create(THIS_MODULE, mydrv_class); mydrv_device device_create(mydrv_class, NULL, mydrv_devno, NULL, mydrv);注意 class_create 在比较新的内核里只接受两个参数老内核是三个参数。如果你是从老驱动代码往新内核移植这个 API 差异会让你编译报错。我给出的代码是基于较新内核风格的实际开发中要看你目标内核版本对应调整。2.3 cdev、class、device 三者是怎么串起来的这三者的关系可以这样理解cdev 是内核里代表字符设备的对象负责把设备号和 file_operations 关联起来class 是设备分类用来在 sysfs 里提供一个“类”的视角device 则是具体设备它在 sysfs 里会出现在 /sys/class/类名/设备名。udev 监听内核 uevent当驱动里 device_create 执行时内核会广播一个 ueventudev 根据规则在 /dev 下创建节点。这套机制的好处是设备节点和硬件生命周期绑定。驱动加载时节点自动出现卸载时节点自动消失你不必手动管理。我早期做驱动时就是因为没有创建 class 和 device每次加载完驱动都要自己 mknod而且一旦设备号变了节点路径就失效调试起来非常痛苦。后来统一改成 class device 的写法才知道这本来就不该手动做。3. 设备树让驱动和硬件信息解耦的关键3.1 设备树的结构和基本语法设备树Device Tree是嵌入式 Linux 里描述硬件信息的核心手段。它的本质是一棵由节点node和属性property构成的树形数据结构从根节点 / 出发描述 CPU、内存、总线、外设、中断、时钟等所有硬件资源。之所以要引入设备树是为了解决 ARM Linux 早期那套“板级文件写死一切”的弊端。以前每换一个板子就要改一次 arch/arm/mach-xxx 下的代码导致内核里塞满了各家厂商的板级补丁。引入设备树之后硬件信息从内核代码里剥离出来同一个内核镜像可以启动不同板卡只要换一个 .dtb 文件就行。一个简单的设备树节点长这样/ { compatible vendor,board-name; i2c1: i2c40012000 { compatible st,stm32-i2c; reg 0x40012000 0x400; interrupts 31 0, 32 0; clock-frequency 100000; sensor48 { compatible bosch,bme280; reg 0x48; }; }; };这里 i2c40012000 是 I2C 控制器节点reg 描述寄存器基地址 0x40012000 和地址长度 0x400interrupts 描述两个中断号。它下面挂着一个 sensor48reg 是 I2C 从设备地址 0x48compatible 用于和驱动匹配。3.2 compatible 匹配与 probe 触发原理设备树里的 compatible 字符串是驱动和设备建立联系的核心纽带。驱动侧通过 of_match_table 声明自己支持哪些设备内核在启动或热插拔时会拿设备树节点的 compatible 和驱动里的 of_match_table 做字符串匹配。匹配成功内核就调用驱动的 probe 回调。注意一个细节匹配不是光看字符串是否一样还有优先级问题。of_match_table 里可以有多个匹配项还可以通过 .data 字段携带每个匹配项对应的私有数据这样同一套驱动可以兼容多个型号。实际项目里常见做法是写一套驱动of_match_table 放好几个 compatible用 .data 区分不同型号的参数这样处理多型号硬件非常方便。probe 回调是驱动初始化的核心入口。它被调用时意味着内核认为这个驱动就是这个设备对应的“主人”。在 probe 里你要完成资源获取、寄存器映射、中断申请、设备节点注册等所有初始化动作。xilinx 平台的某些外设驱动、I2C 设备驱动基本都是这个套路。3.3 资源解析 API 与常见的坑在 probe 里获取设备树节点里配置的资源常用 API 有of_property_read_u32 / of_property_read_u32_array读取 32 位整数或整数数组属性of_property_read_string读取字符串属性of_iomap把设备树 reg 里描述的物理地址映射成虚拟地址irq_of_parse_and_map解析中断属性得到中断号platform_get_resourceplatform 设备中获取资源的标准接口我见过最典型的问题有两个。第一个是物理地址映射失败原因多半是设备树 reg 属性写错了或者驱动里地址长度算错了。第二个是中断号拿不到原因往往是设备树里 interrupt 属性格式不对或者中断控制器配置有问题。这时候不要急着改代码先用 /proc/device-tree 或 dtc 反编译一下实际加载的 dtb确认设备树内容和你想的完全一致再查驱动逻辑。另外很多人会忽略 status 属性。设备树节点如果写成 status disabled内核就不会把这个节点注册成 platform device驱动 probe 自然不会被调用。调试时如果发现驱动没有加载先看看节点是不是被 disable 了。这个坑我踩过不止一次特别是从参考板卡移植设备树的时候原厂模板里经常有几个节点被注释掉或者设置为 disabled。4. 实操编写一个 I2C 温湿度传感器驱动4.1 I2C 子系统的基本构成I2C 是嵌入式系统里最常用的低速总线之一。Linux 的 I2C 子系统分成三层I2C 核心、I2C 控制器驱动adapter、I2C 设备驱动client driver。控制器驱动负责 I2C 时序的底层实现设备驱动负责操作挂在总线上的具体传感器、EEPROM 等芯片。平时我们写的是 client driver也就是设备驱动。它的关键数据结构有两个i2c_driver 描述驱动本身i2c_client 描述一个具体的 I2C 从设备。i2c_client 里有 adapter 指针和 addr 字段adapter 指向它挂在哪个 I2C 控制器上addr 是从设备地址。设备树里 sensor48 这个节点会被内核解析成 struct i2c_client而驱动的 probe 回调收到的就是这个 client 结构体。4.2 驱动代码实现与 Makefile下面以一颗常见的温湿度传感器为例写一个最简但可运行的 I2C 驱动框架#include linux/module.h #include linux/i2c.h #include linux/of.h static int sensor_probe(struct i2c_client *client, const struct i2c_device_id *id) { int ret; u8 buf[2]; dev_info(client-dev, sensor probe, addr0x%02x\n, client-addr); // 往传感器写入寄存器地址触发一次测量 buf[0] 0x01; // 假设 0x01 是启动测量寄存器 ret i2c_master_send(client, buf, 1); if (ret 0) { dev_err(client-dev, write sensor reg failed\n); return ret; } // 读取测量结果 ret i2c_master_recv(client, buf, 2); if (ret 0) { dev_err(client-dev, read sensor data failed\n); return ret; } return 0; } static const struct of_device_id sensor_of_match[] { { .compatible vendor,sensor }, { } }; MODULE_DEVICE_TABLE(of, sensor_of_match); static struct i2c_driver sensor_driver { .driver { .name sensor, .of_match_table sensor_of_match, }, .probe sensor_probe, .id_table sensor_id_table, }; module_i2c_driver(sensor_driver); MODULE_LICENSE(GPL);这里有个很值得说的设计从驱动里主动发起读操作用的是 i2c_master_recv。但如果传感器是那种“先写寄存器地址、再读数据”的标准流程那么更常见的做法是 i2c_transfer一次传输组织两个消息段。struct i2c_msg msgs[2]; msgs[0].addr client-addr; msgs[0].flags 0; msgs[0].len 1; msgs[0].buf reg_addr; msgs[1].addr client-addr; msgs[1].flags I2C_M_RD; msgs[1].len read_len; msgs[1].buf data; ret i2c_transfer(client-adapter, msgs, 2);这样做的价值在于整个 I2C 读周期里总线不会被其他设备抢占保证了操作的原子性。如果拆成两次独立调用中间可能混入别的 I2C 设备访问对某些时序敏感的传感器就会读回错误数据。4.3 从注册到 probe 调用数据是怎么流动的这里的调用链很多人不清楚我拆开说一遍。设备树里 i2c1 节点下的传感器子节点会在 I2C 控制器驱动注册 i2c_adapter 之后被内核的 I2C 核心解析。核心会把子节点变成 i2c_client注册进 i2c bus 的驱动模型。之后每当有 i2c_driver 注册到同一条总线时I2C 核心就会调用总线匹配逻辑拿驱动的 of_match_table 和已有 client 的 of_node 里的 compatible 做匹配。匹配上了就调用 sensor_driver 里的 probe。所以驱动加载的时机有两种。一种是 insmod 驱动 .ko 时设备树解析已经完成i2c_client 已经存在insmod 一进来就能匹配并立刻触发 probe。另一种是设备树热插拔或控制器驱动后加载probe 会在注册时被总线触发。无论如何probe 的触发不由你手动调用而是由内核机制决定的。如果你发现 probe 没跑八成是 compatible 不匹配、节点 status 被禁用或者 I2C 控制器本身没工作。5. 驱动调试与排障没有调试器也能定位问题5.1 printk 分级与动态调试内核驱动最常见的调试手段就是 printk。printk 的日志级别从高到低有 KERN_EMERG、KERN_ALERT、KERN_CRIT、KERN_ERR、KERN_WARNING、KERN_NOTICE、KERN_INFO、KERN_DEBUG。默认输出级别由 /proc/sys/kernel/printk 控制如果某个 KERN_DEBUG 级别的日志没有出现在 dmesg 里不一定是没执行可能是被日志级别过滤了。查看日志用 dmesg。如果日志太多可以这样过滤dmesg | grep sensor dmesg -w # 实时跟踪内核日志还有一个比 printk 更优雅的调试方式是动态调试dynamic debug。如果你编译内核时开启了 CONFIG_DYNAMIC_DEBUG那么可以用 pr_debug 和 dev_dbg 这类带调试级别的打印然后在运行时通过 debugfs 动态打开某个文件或某行日志。比如echo file sensor.c p /sys/kernel/debug/dynamic_debug/control这个方式在生产环境特别有用不需要重新编译驱动就能在关键时刻把调试信息打开。我维护现场设备时最常用的就是这个命令因为现场工程师不方便重新编译设备树和内核只能靠这种运行时手段辅助排查。5.2 问题排查insmod 失败、probe 不执行等我把实际项目里出现频率最高的几个问题整理了一份排查路径。先说 insmod 失败。常见报错是 Exec format error这个基本就是模块编译架构和目标机器不匹配。如果报 Unknown symbol通常是驱动调用了没有导出的内核符号要么是你漏编了依赖模块要么是符号版本不一致需要先加载依赖模块或者重新对内核源码。再说 probe 不执行的问题。第一步确认设备树节点确实存在查看 /sys/firmware/devicetree/base/ 下的树结构。第二步确认节点的 status 不是 disabled。第三步查看驱动注册信息grep 一下 /sys/bus/platform/devices/ 或 /sys/bus/i2c/devices/ 下有没有应该出现的设备。第四步检查 compatible 是否完全一致。注意设备树里的 compatible 可以写多个字符串匹配的时候是逐个尝试驱动的 of_match_table顺序和大小写都敏感。有段时间我调试一个板载加速度计驱动probe 怎么都不触发查遍了代码没发现问题。后来把实际加载的设备树 dump 出来才发现 I2C 控制器的 status 被设置成 disabled根本没扫描到挂接的子设备。这在别人移植设备树时很容易攒下这种“隐含开关”排查时一定要优先确认。5.3 常见问题速查表现象可能原因排查方法insmod 报 Exec format error交叉编译架构不对file 查看 .ko 格式确认 ARCH/CROSS_COMPILEinsmod 报 Unknown symbol依赖模块未加载查看 /proc/kallsyms先加载依赖模块dmesg 没有打印日志级别过滤调低 printk 级别或修改内核 printk 配置设备节点不存在没有创建 class/device检查驱动里 device_create 是否执行成功probe 未调用compatible 不匹配反编译 dtb严格比对兼容字符串probe 未调用status 为 disabled检查设备树节点使能状态读取数据全为 0xffI2C 上拉问题或地址错误用 i2cdetect 扫描总线地址用户态 read 崩溃内核里直接 copy 了用户指针改用 copy_to_user / copy_from_user6. 并发、性能与安全驱动质量的分水岭6.1 原子上下文与锁的选择很多网上教程写字符设备驱动时read 和 write 回调里直接操作全局变量完全不考虑并发问题。实际上驱动是运行在内核态的高并发代码多个进程可以同时打开同一个设备中断随时可能打断当前执行流SMP 系统上多个 CPU 可能同时进入同一个驱动函数。锁的选择要看使用场景。如果临界区很短可以用原子操作或者自旋锁。自旋锁在持有期间不能睡眠所以绝对不能在自旋锁保护的区域内调用 i2c_transfer 这种可能会睡眠的函数。如果临界区需要等待硬件响应要使用互斥锁mutex因为互斥锁允许进程睡眠等待。用错了锁轻则死锁重则整个系统挂起。这里有一个我自己踩过的很实在的教训有一次我在 read 回调里加了 mutex 保护但又在一个可能被中断上下文调用的路径里尝试加同一个 mutex结果在某个极端时序下发生了死锁整机 watchdog 复位。后来改成用 atomic_t 做状态标记 工作队列的方式才彻底解决。驱动里锁的选择一定要考虑你的函数运行在进程上下文还是中断上下文这是驱动开发里特别基础又特别容易出事的点。6.2 DMA 方向与内存一致性问题如果驱动直接访问硬件 FIFO 或者外设 DMA 缓冲区必须考虑内存一致性问题。处理器和 DMA 控制器对 Cache 的处理策略不一样如果驱动往缓冲区里写数据后立刻让 DMA 去读数据可能还停留在 Cache 里没有回到主存DMA 读到的就是旧数据。反过来DMA 写完缓冲区后CPU 去读也有可能读到 Cache 里的旧数据。针对这种情况标准的做法是使用 DMA API。一致性映射用 dma_alloc_coherent它会在分配时就保证 CPU 和外设看到的是同一份内存视图。流式映射用 dma_map_single 配合 dma_unmap_single在传输前做好 Cache 失效或回写传输完成后同步 Cache。很多从单片机转过来的开发者在写 Linux 驱动时没有这个概念直接用 kmalloc 分配缓冲区就交给 DMA结果数据错误根本找不到原因。dma_mask 也值得注意。有些 DMA 控制器只能寻址低 32 位物理内存如果你没设置 dev-dma_maskdma_alloc_coherent 可能分配失败。在 probe 里一般会看到类似 dma_set_mask_and_coherent(dev, DMA_BIT_MASK(32)) 的代码这行代码就是告诉内核这个设备支持多大的 DMA 地址范围。6.3 内核 API 版本差异带来的兼容性问题Linux 内核更新速度很快很多 API 都会变。比如 i2c_driver 的 probe 回调在 6.x 之前是 int probe(struct i2c_client *, const struct i2c_device_id *)6.x 之后改成了 int probe_new(struct i2c_client *)。字符设备注册接口也有变化老内核常用 register_chrdev新内核更推荐 cdev_add。这导致很多老驱动代码在新内核上根本编译不过。我的建议是项目里固定一个内核版本然后围绕这个版本做开发。跟厂商拿 BSP 时一定要确认 BSP 对应的内核版本不要拿 A 版本的源码到 B 版本的内核上硬编那样会出现大量莫名其妙的问题。如果必须跨版本移植先认真读 include/linux 下相关头文件的改动记录再逐个适配 API。7. 驱动开发的学习路径与一些个人习惯驱动开发的门槛确实比应用开发高但它是一条完全可以走通的路。我自己的学习路径是先用一个字符设备驱动把 open、read、write 的整个流程跑通然后手动创建设备节点观察 /sys 和 /proc 的变化接着给一块开发板适配设备树点亮一个 LED 或者读取一颗按键再之后做一颗 I2C 传感器驱动把 i2cdetect、dmesg、sysfs 这些调试手段全都用熟。这样走下来Linux 设备驱动开发的整体轮廓就比较清晰了。做驱动开发时我有几个坚持了很久的习惯可能对你也有参考价值。第一每个板子我都会先准备一份“驱动调试备忘”把设备树 dump 命令、内核日志查询命令、i2c 扫描命令、常用模块加载参数都写进去现场调试的时候效率高很多。第二写驱动前我会先去内核源码里搜一下有没有类似设备的驱动复用自己的驱动框架远比从零写要稳妥。第三遇到问题先 dump 设备树和内核日志而不是马上改驱动代码因为多数问题的根源并不在驱动代码本身而是硬件描述和内核配置不匹配。最后再说一个非常实用的小技巧。驱动开发初期别急着把完整功能做完先把框架搭好probe 函数里只留一个 dev_info确定测完可以正常加载卸载再逐步添加硬件操作逻辑。每加一块功能都重新测试一次模块加载卸载和对应的读写路径。这样即使出了问题也能快速定位是哪一块新增代码引起的。这个习惯让我省下了很多在茫茫代码里定位 bug 的时间也推荐你从这个思路开始自己的第一块驱动开发。