Linux SPI驱动开发实战:从总线模型到字符设备实现

发布时间:2026/8/29 2:18:49
Linux SPI驱动开发实战:从总线模型到字符设备实现 1. 项目概述从零开始理解Linux SPI驱动最近在调试一块新的传感器板卡核心通信接口是SPI。在嵌入式Linux开发中SPI驱动是连接主控芯片SoC与各类外设如Flash、传感器、显示屏的桥梁。很多朋友尤其是从单片机裸机开发转向Linux驱动的工程师初次接触Linux SPI驱动框架时往往会感到一头雾水框架复杂、概念抽象、调试困难。这个实验的目的就是剥开Linux SPI驱动这层“神秘面纱”通过一个具体的、可复现的实验带你从零开始理解并亲手编写一个简单的SPI字符设备驱动最终实现通过应用层程序读写一个SPI从设备比如一块SPI Flash或一个虚拟的测试设备。无论你是想为新的传感器添加驱动还是单纯想深入理解Linux内核的SPI子系统这个实验都能提供一个清晰的路径。2. SPI驱动框架核心思想与设计在动手写代码之前我们必须先理解Linux内核设计SPI子系统的哲学。这能让你明白每一步操作背后的“为什么”而不是机械地复制粘贴代码。2.1 总线-设备-驱动模型Linux内核采用了一种高度解耦的设计模式即总线-设备-驱动模型。对于SPI而言SPI总线 内核中抽象出来的一个虚拟实体spi_bus_type。它不关心具体是哪个芯片只定义了一套规则比如如何匹配设备和驱动以及设备与驱动交互的通用接口。SPI设备 描述一个具体的、挂在SPI总线上的硬件。它需要告诉内核“我是谁”设备名、“我挂在哪条SPI总线上”总线号、“我的片选是哪个”CS引脚、“我通信的时钟模式和速度是多少”spi_device结构体参数。这些信息通常来自设备树Device Tree。SPI驱动 描述一个知道如何操作特定型号或类型SPI设备的软件模块。它需要声明“我能驱动哪些设备”通过设备树兼容性字符串匹配并提供操作这些设备的具体函数如probe,remove, 以及自定义的读写接口。这种设计的最大好处是动态匹配和可扩展性。内核启动时会根据设备树信息注册SPI设备。当加载一个SPI驱动模块时内核会在SPI总线上寻找与该驱动声明“兼容”的设备。一旦找到就调用驱动的probe函数进行绑定。这样驱动和设备是独立的同一个驱动可以服务多个兼容的设备同一个设备树描述也可以在不同的内核版本中被不同的驱动使用只要兼容字符串匹配。2.2 关键数据结构解析理解以下几个核心结构体是编写驱动的基础struct spi_device 代表一个SPI从设备。内核根据设备树信息创建它。驱动开发者最关心的是通过spi-mode设置模式CPOL, CPHA通过spi-max_speed_hz设置最大频率以及通过spi-chip_select知道片选线。在驱动代码中我们通过probe函数的参数获得指向该设备的指针。struct spi_driver 这是驱动模块的“名片”。你需要填充它static struct spi_driver my_spi_driver { .driver { .name “my_spi_device”, .owner THIS_MODULE, .of_match_table my_spi_of_match, // 指向设备树匹配表 }, .probe my_spi_probe, .remove my_spi_remove, };其中of_match_table至关重要它是一个of_device_id数组包含了驱动所兼容的设备树节点字符串。例如{ .compatible “vendor,my-spi-sensor” }。struct spi_transfer和struct spi_message 这是执行一次SPI通信的核心。spi_transfer描述了一次数据传输的细节发送和接收缓冲区地址、长度、速度、片选变化延迟等。spi_message则是一个传输队列你可以将多个spi_transfer通过spi_message_add_tail添加到一个消息中然后使用spi_sync或spi_async一次性、原子性地发送出去。这对于需要连续发送命令和数据的设备如Flash非常高效。注意 很多初学者会困惑于直接使用spi_write或spi_read与使用spi_transfer/spi_message的区别。前者是内核提供的简化API内部也是基于后者实现的。对于简单单次读写可以用简化API。但对于复杂时序如先发命令字再读数据且中间片选保持有效或者需要精确控制传输间延迟的场景必须使用spi_message。3. 实验环境搭建与驱动框架实现我们假设实验平台是一块常见的ARM开发板如树莓派、i.MX6UL等连接了一个SPI接口的测试设备。我们将在内核源码树外编写这个驱动不修改内核源码方便模块化编译和调试。3.1 环境与代码准备首先确保你的开发环境有目标平台对应的Linux内核源码和交叉编译工具链。我们的驱动模块主要包含以下文件my_spi_driver.c 驱动主体代码。Makefile 编译驱动模块的Makefile。设备树覆盖文件.dts或.dtbo 描述我们的SPI硬件连接信息。一个最简化的驱动骨架如下#include linux/module.h #include linux/spi/spi.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #define DRIVER_NAME “my_spi_dev” #define DEVICE_NAME “my_spi” #define CLASS_NAME “myspi” static int major_num; static struct class *spi_class NULL; static struct device *spi_device NULL; static struct cdev my_cdev; // 假设我们的设备有一个简单的寄存器读版本号 #define CMD_GET_VERSION 0x01 static const struct of_device_id my_spi_of_match[] { { .compatible “embeded,my-spi-test” }, {}, }; MODULE_DEVICE_TABLE(of, my_spi_of_match); // 设备的私有数据结构用于在驱动函数间传递上下文 struct my_spi_data { struct spi_device *spi; struct mutex lock; // 互斥锁防止并发访问冲突 }; static int my_spi_probe(struct spi_device *spi) { struct my_spi_data *data; int ret; printk(KERN_INFO “My SPI Device: Probing device on bus %d, CS %d\n”, spi-master-bus_num, spi-chip_select); // 1. 设置SPI模式Mode 0 (CPOL0, CPHA0) 1MHz spi-mode SPI_MODE_0; spi-max_speed_hz 1000000; ret spi_setup(spi); if (ret 0) { dev_err(spi-dev, “Failed to setup SPI\n”); return ret; } // 2. 分配并初始化设备私有数据 data devm_kzalloc(spi-dev, sizeof(*data), GFP_KERNEL); if (!data) return -ENOMEM; >obj-m my_spi_driver.o KDIR : /path/to/your/kernel/source ARCH ? arm CROSS_COMPILE ? arm-linux-gnueabihf- all: make -C $(KDIR) M$(PWD) ARCH$(ARCH) CROSS_COMPILE$(CROSS_COMPILE) modules clean: make -C $(KDIR) M$(PWD) ARCH$(ARCH) CROSS_COMPILE$(CROSS_COMPILE) clean3.2 设备树Device Tree配置设备树是告知内核硬件信息的关键。你需要为你的SPI设备添加一个节点。假设你的主控SPI控制器是spi0使用CS0引脚连接我们的测试设备。// 文件my-spi-overlay.dts /dts-v1/; /plugin/; / { fragment0 { target spi0; // 目标SPI控制器 __overlay__ { #address-cells 1; #size-cells 0; status “okay”; my_spi_test: my_spi_test0 { compatible “embeded,my-spi-test”; // 必须与驱动中的匹配字符串一致 reg 0; // 使用CS0 spi-max-frequency 1000000; // 1MHz // 可以在这里添加其他设备特定属性比如GPIO中断引脚等 // pinctrl-names “default”; // pinctrl-0 pinctrl_spi0_cs0; }; }; }; };使用设备树编译器DTC将其编译为.dtbo文件并在启动时加载到系统中。这样内核在初始化spi0时就会创建出一个名为my_spi_test0的spi_device实例其compatible属性会与我们的驱动匹配从而触发probe函数。实操心得 设备树配置错误是驱动无法成功probe的最常见原因之一。务必检查compatible字符串是否与驱动代码中的完全一致包括大小写和逗号。SPI控制器节点如spi0的status是否为“okay”。reg属性指定的片选号是否正确是否与硬件连接匹配。使用ls /sys/bus/spi/devices/命令可以查看系统中已成功注册的SPI设备这是验证设备树是否生效的快速方法。4. 字符设备接口与SPI通信实现仅有总线驱动还不够我们需要为用户空间提供访问接口。最常用的方式就是将SPI设备实现为一个字符设备这样应用程序就可以通过标准的open()、read()、write()、ioctl()系统调用来操作它。4.1 字符设备创建与文件操作集在probe函数中我们需要补充字符设备的创建逻辑static int my_spi_open(struct inode *inode, struct file *filp) { struct my_spi_data *data; data container_of(inode-i_cdev, struct my_spi_data, cdev); // 从cdev获取私有数据 filp-private_data data; // 将私有数据存入文件指针供其他操作函数使用 return 0; } static ssize_t my_spi_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) { struct my_spi_data *data filp-private_data; u8 rx_buf[32]; u8 tx_buf[1] {CMD_GET_VERSION}; // 假设读版本号的命令 struct spi_transfer tr[2] { { .tx_buf tx_buf, .len 1, .cs_change 0, // 传输开始前片选有效传输后保持不变 }, { .rx_buf rx_buf, .len min(count, sizeof(rx_buf)), .cs_change 1, // 这次传输结束后片选失效 }, }; struct spi_message msg; int ret; if (mutex_lock_interruptible(data-lock)) return -ERESTARTSYS; spi_message_init(msg); spi_message_add_tail(tr[0], msg); spi_message_add_tail(tr[1], msg); ret spi_sync(data-spi, msg); // 同步传输 mutex_unlock(data-lock); if (ret) { dev_err(data-spi-dev, “SPI transfer failed: %d\n”, ret); return ret; } // 将内核空间数据拷贝到用户空间 if (copy_to_user(buf, rx_buf, tr[1].len)) { return -EFAULT; } return tr[1].len; // 返回实际读取的字节数 } static ssize_t my_spi_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) { struct my_spi_data *data filp-private_data; u8 *tx_buf; int ret; if (count 256) // 简单的长度检查 return -EINVAL; tx_buf kmalloc(count, GFP_KERNEL); if (!tx_buf) return -ENOMEM; if (copy_from_user(tx_buf, buf, count)) { kfree(tx_buf); return -EFAULT; } if (mutex_lock_interruptible(data-lock)) { kfree(tx_buf); return -ERESTARTSYS; } ret spi_write(data-spi, tx_buf, count); // 使用简化API进行单次写 mutex_unlock(data-lock); kfree(tx_buf); if (ret) return ret; return count; } static const struct file_operations my_spi_fops { .owner THIS_MODULE, .open my_spi_open, .read my_spi_read, .write my_spi_write, // 可以添加 .unlocked_ioctl 用于更复杂的控制命令 }; // 在 probe 函数中创建字符设备 static int my_spi_probe(struct spi_device *spi) { // ... 之前的设置代码 ... dev_t dev_num; // 动态申请主设备号 ret alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); if (ret 0) { dev_err(spi-dev, “Failed to allocate chrdev region\n”); return ret; } major_num MAJOR(dev_num); // 初始化cdev结构并关联文件操作集 cdev_init(my_cdev, my_spi_fops); my_cdev.owner THIS_MODULE; ret cdev_add(my_cdev, dev_num, 1); if (ret) { dev_err(spi-dev, “Failed to add cdev\n”); goto err_cdev; } // 在 /sys/class 下创建类方便udev自动创建设备节点 spi_class class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(spi_class)) { ret PTR_ERR(spi_class); goto err_class; } // 在 /dev 下创建设备节点名字为 “my_spi” spi_device device_create(spi_class, NULL, dev_num, NULL, DEVICE_NAME); if (IS_ERR(spi_device)) { ret PTR_ERR(spi_device); goto err_device; } dev_info(spi-dev, “Device node created at /dev/%s\n”, DEVICE_NAME); return 0; // 错误处理路径略 }4.2 SPI通信时序的精确控制上面的read函数示例演示了一个典型的“先写命令后读数据”的SPI传输。这里有几个关键点cs_change标志 这是控制片选CS引脚行为的关键。当cs_change 0时表示本次传输结束后片选保持有效当cs_change 1时表示本次传输结束后片选失效。在包含多个spi_transfer的spi_message中合理设置cs_change可以精确控制CS引脚在连续传输过程中的电平这对于那些要求命令和数据在同一个CS有效周期内完成的设备至关重要。spi_sync与spi_asyncspi_sync是阻塞的会等待整个消息传输完成才返回适合绝大多数场景。spi_async是异步的通过回调函数通知完成适合高性能、非阻塞的场景但编程模型更复杂。速度与延迟 可以在spi_transfer中单独设置speed_hz和delay_usecs。delay_usecs用于定义本次传输结束后、片选变化前的延迟或者传输之间的延迟对于某些有严格时序要求的低速设备如某些传感器非常有用。注意事项 SPI通信是全双工的。即使你只想读数据rx_buf非空也必须提供一个有效的tx_buf哪怕全是0。反之亦然。内核驱动在硬件层面总是同时进行发送和接收。如果你的设备在读取时不需要发送有效数据可以提供一个虚拟的发送缓冲区。5. 驱动调试与常见问题排查实录驱动开发的大部分时间都在调试。以下是我在实际项目中积累的一些排查技巧和常见问题。5.1 调试手段与信息获取内核日志dmesg 你的第一道防线。在驱动代码的关键路径probe,remove,read/write入口和错误处添加printk或dev_info/dev_err。使用dmesg -w实时查看日志。sysfs 接口 SPI子系统在/sys/bus/spi/devices/下为每个设备提供了丰富的信息。例如/sys/bus/spi/devices/spi0.0/modalias显示了设备匹配的别名/sys/bus/spi/devices/spi0.0/of_node/compatible可以查看设备树中的兼容字符串。这能帮你确认设备是否被正确识别。逻辑分析仪或示波器 这是硬件调试的终极武器。抓取SPI的CLK、MOSI、MISO、CS信号可以直观地看到通信波形、数据内容、时序是否符合预期模式、频率、CS行为。当软件层面一切正常但设备无响应时必须用硬件工具验证信号。5.2 常见问题速查表问题现象可能原因排查思路与解决方案驱动probe函数未被调用1. 设备树节点未启用status ! “okay”。2.compatible字符串不匹配。3. SPI控制器驱动未加载或有问题。4. 设备树覆盖未正确加载。1. 检查设备树源文件确保节点status “okay”。2. 用cat /sys/bus/spi/devices/*/of_node/compatible对比驱动代码中的字符串。3. 检查/sys/bus/spi/devices/目录下是否有你的设备节点如spi0.0。4. 确认设备树覆盖文件已编译并加载使用dtoverlay命令或修改config.txt。SPI通信失败返回错误码如 -EIO1. 硬件连接问题线缆、电源。2. SPI模式CPOL, CPHA设置错误。3. 时钟频率超出设备支持范围。4. 片选CS引脚配置或控制错误。1. 检查硬件连接确保VCC、GND、四线连接正确。2. 查阅设备数据手册确认正确的SPI模式Mode 0/1/2/3并在驱动或设备树中设置spi-mode。3. 降低spi-max_speed_hz尝试。4. 有些SoC需要配置引脚复用为SPI功能并确认是硬件片选还是GPIO模拟片选。检查设备树中的pinctrl配置。能probe成功但读写数据不对1. 数据传输时序问题cs_change,delay。2. 字节序Endianness问题。3. 驱动与设备协议理解不一致如命令字、数据格式。4. 缓冲区管理错误内核/用户空间拷贝。1. 使用逻辑分析仪抓取波形检查CS在传输过程中的变化是否符合设备要求。2. 确认设备是多字节数据的MSB先行还是LSB先行spi-mode中的SPI_LSB_FIRST。3. 仔细核对设备数据手册的通信协议章节。4. 检查copy_to_user和copy_from_user的返回值。应用层调用open失败1. 设备节点未成功创建权限问题或创建失败。2. 设备号冲突。1. 检查/dev/下是否存在预期的设备节点如/dev/my_spi并检查其权限。2. 查看dmesg中字符设备注册和创建设备节点时的日志。并发访问导致数据错乱驱动未处理并发访问。在驱动的read/write/ioctl等可能被多进程/线程同时调用的函数中使用互斥锁mutex进行保护如示例代码所示。5.3 一个真实的调试案例CS引脚的“幽灵”电平我曾遇到一个奇怪的问题驱动能正常probe单次读写也正常但一旦进行快速连续的读写操作数据就会错乱。用逻辑分析仪抓波形发现在两个独立的spi_message之间CS引脚会有一个非常短暂几十纳秒的拉高又拉低的过程看起来像是一个“毛刺”。但我的设备要求CS在一次完整的事务包含多个命令字中必须持续保持低电平。排查过程首先怀疑是驱动中cs_change设置错误但检查代码无误。然后怀疑是SoC的SPI控制器硬件问题但查阅芯片勘误表无相关记录。最后通过深入研究内核SPI子系统源码特别是控制器驱动发现某些SPI控制器驱动在完成一个spi_message后会默认将CS置为无效状态高电平即使下一个spi_message马上到来。这个短暂的空闲状态就被我的设备解读为事务结束。解决方案软件片选 放弃硬件CS引脚改用普通的GPIO来模拟片选。在驱动的probe中申请并配置这个GPIO在每次spi_message前后手动控制其电平。这样可以实现绝对精确的控制。// 在设备树中定义cs-gpios属性 // my_spi_test0 { // compatible “embeded,my-spi-test”; // reg 0; // spi-max-frequency 1000000; // cs-gpios gpio4 15 GPIO_ACTIVE_LOW; // 例如使用GPIO4_15作为片选 // }; struct my_spi_data { struct spi_device *spi; struct gpio_desc *cs_gpio; // 新增 struct mutex lock; }; // 在probe中获取GPIO>#include linux/ioctl.h #define MY_SPI_MAGIC ‘s’ #define MY_SPI_GET_VERSION _IOR(MY_SPI_MAGIC, 0, int) #define MY_SPI_SET_MODE _IOW(MY_SPI_MAGIC, 1, int) static long my_spi_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct my_spi_data *data filp-private_data; int ret 0; u8 buffer[32]; switch (cmd) { case MY_SPI_GET_VERSION: // 执行读取版本号的SPI操作 buffer[0] CMD_GET_VERSION; ret spi_write_then_read(data-spi, buffer, 1, buffer, 2); if (ret 0) { int version (buffer[0] 8) | buffer[1]; if (copy_to_user((int __user *)arg, version, sizeof(version))) ret -EFAULT; } break; case MY_SPI_SET_MODE: // 设置设备模式示例 // 注意这里需要根据设备协议实现 break; default: ret -ENOTTY; // 未知命令 } return ret; } // 并将 .unlocked_ioctl my_spi_ioctl 加入到 file_operations 结构体中。6.2 中断与轮询如果SPI设备可以产生中断例如数据准备就绪你应该在设备树中指定中断引脚并在驱动中使用devm_request_irq注册中断处理函数。这比轮询效率高得多。// 设备树interrupt-parent gpio4; interrupts 14 IRQ_TYPE_EDGE_RISING; // 在probe中>