news 2026/8/29 2:18:46

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 关键数据结构解析

理解以下几个核心结构体是编写驱动的基础:

  1. struct spi_device: 代表一个SPI从设备。内核根据设备树信息创建它。驱动开发者最关心的是通过spi->mode设置模式(CPOL, CPHA),通过spi->max_speed_hz设置最大频率,以及通过spi->chip_select知道片选线。在驱动代码中,我们通过probe函数的参数获得指向该设备的指针。

  2. 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” }

  3. struct spi_transferstruct spi_message: 这是执行一次SPI通信的核心。spi_transfer描述了一次数据传输的细节:发送和接收缓冲区地址、长度、速度、片选变化延迟等。spi_message则是一个传输队列,你可以将多个spi_transfer通过spi_message_add_tail添加到一个消息中,然后使用spi_syncspi_async一次性、原子性地发送出去。这对于需要连续发送命令和数据的设备(如Flash)非常高效。

注意: 很多初学者会困惑于直接使用spi_writespi_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 (CPOL=0, CPHA=0), 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) clean

3.2 设备树(Device Tree)配置

设备树是告知内核硬件信息的关键。你需要为你的SPI设备添加一个节点。假设你的主控SPI控制器是spi0,使用CS0引脚连接我们的测试设备。

// 文件:my-spi-overlay.dts /dts-v1/; /plugin/; / { fragment@0 { target = <&spi0>; // 目标SPI控制器 __overlay__ { #address-cells = <1>; #size-cells = <0>; status = “okay”; my_spi_test: my_spi_test@0 { 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_test@0spi_device实例,其compatible属性会与我们的驱动匹配,从而触发probe函数。

实操心得: 设备树配置错误是驱动无法成功probe的最常见原因之一。务必检查:

  1. compatible字符串是否与驱动代码中的完全一致(包括大小写和逗号)。
  2. SPI控制器节点(如&spi0)的status是否为“okay”
  3. reg属性指定的片选号是否正确,是否与硬件连接匹配。
  4. 使用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_transferspi_message中,合理设置cs_change可以精确控制CS引脚在连续传输过程中的电平,这对于那些要求命令和数据在同一个CS有效周期内完成的设备至关重要。
  • spi_syncspi_asyncspi_sync是阻塞的,会等待整个消息传输完成才返回,适合绝大多数场景。spi_async是异步的,通过回调函数通知完成,适合高性能、非阻塞的场景,但编程模型更复杂。
  • 速度与延迟: 可以在spi_transfer中单独设置speed_hzdelay_usecsdelay_usecs用于定义本次传输结束后、片选变化前的延迟,或者传输之间的延迟,对于某些有严格时序要求的低速设备(如某些传感器)非常有用。

注意事项: SPI通信是全双工的。即使你只想读数据(rx_buf非空),也必须提供一个有效的tx_buf(哪怕全是0)。反之亦然。内核驱动在硬件层面总是同时进行发送和接收。如果你的设备在读取时不需要发送有效数据,可以提供一个虚拟的发送缓冲区。

5. 驱动调试与常见问题排查实录

驱动开发的大部分时间都在调试。以下是我在实际项目中积累的一些排查技巧和常见问题。

5.1 调试手段与信息获取

  1. 内核日志(dmesg): 你的第一道防线。在驱动代码的关键路径(probe,remove,read/write入口和错误处)添加printkdev_info/dev_err。使用dmesg -w实时查看日志。
  2. sysfs 接口: SPI子系统在/sys/bus/spi/devices/下为每个设备提供了丰富的信息。例如,/sys/bus/spi/devices/spi0.0/modalias显示了设备匹配的别名,/sys/bus/spi/devices/spi0.0/of_node/compatible可以查看设备树中的兼容字符串。这能帮你确认设备是否被正确识别。
  3. 逻辑分析仪或示波器: 这是硬件调试的终极武器。抓取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通信失败,返回错误码(如 -EIO)1. 硬件连接问题(线缆、电源)。
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_usercopy_from_user的返回值。
应用层调用open失败1. 设备节点未成功创建(权限问题或创建失败)。
2. 设备号冲突。
1. 检查/dev/下是否存在预期的设备节点(如/dev/my_spi),并检查其权限。
2. 查看dmesg中字符设备注册和创建设备节点时的日志。
并发访问导致数据错乱驱动未处理并发访问。在驱动的read/write/ioctl等可能被多进程/线程同时调用的函数中,使用互斥锁(mutex)进行保护,如示例代码所示。

5.3 一个真实的调试案例:CS引脚的“幽灵”电平

我曾遇到一个奇怪的问题:驱动能正常probe,单次读写也正常,但一旦进行快速连续的读写操作,数据就会错乱。用逻辑分析仪抓波形发现,在两个独立的spi_message之间,CS引脚会有一个非常短暂(几十纳秒)的拉高又拉低的过程,看起来像是一个“毛刺”。但我的设备要求CS在一次完整的事务(包含多个命令字)中必须持续保持低电平。

排查过程

  1. 首先怀疑是驱动中cs_change设置错误,但检查代码无误。
  2. 然后怀疑是SoC的SPI控制器硬件问题,但查阅芯片勘误表无相关记录。
  3. 最后,通过深入研究内核SPI子系统源码(特别是控制器驱动)发现,某些SPI控制器驱动在完成一个spi_message后,会默认将CS置为无效状态(高电平),即使下一个spi_message马上到来。这个短暂的空闲状态就被我的设备解读为事务结束。

解决方案

  1. 软件片选: 放弃硬件CS引脚,改用普通的GPIO来模拟片选。在驱动的probe中申请并配置这个GPIO,在每次spi_message前后手动控制其电平。这样可以实现绝对精确的控制。
    // 在设备树中定义cs-gpios属性 // my_spi_test@0 { // 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中>
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/29 2:18:34

粒子群算法在配电网重构中的应用与Matlab实现

1. 项目概述&#xff1a;当粒子群算法遇上配电网重构 在电力系统领域&#xff0c;配电网重构是一个经典且极具挑战性的优化问题。简单来说&#xff0c;它就像是在一个庞大的、由无数开关连接起来的城市电网中&#xff0c;通过改变某些开关的“开”或“关”状态&#xff0c;来重…

作者头像 李华
网站建设 2026/8/29 2:16:15

C++嵌入式实战:从国赛模拟题看高性能计算与实时系统开发

1. 项目概述&#xff1a;从一道模拟题看国赛C的实战准备最近在整理资料时&#xff0c;翻到了之前为NCCCU&#xff08;全国大学生智能汽车竞赛&#xff09;20国赛准备的一套C模拟题。这套题不是为了炫技&#xff0c;而是当时我们团队为了应对国赛中可能出现的、需要高性能计算的…

作者头像 李华
网站建设 2026/8/29 2:10:38

从空泛构思到可交付论文初稿:教育学写作,AI 辅助工具实践记录

教育学论文写作常常面临选题模糊、文献梳理繁琐、理论落地困难、格式规范耗时长等现实困境。传统写作模式下&#xff0c;从零散想法到产出一份逻辑完整、可提交修改的初稿&#xff0c;往往耗费数周时间。随着学术 AI 辅助工具迭代&#xff0c;PaperRed、笔捷 AI、毕业之家等平台…

作者头像 李华
网站建设 2026/8/29 2:07:01

OpenWorker新版:内置网络安全智能体的工作流平台部署实践

这次我们来看 OpenWorker 的新版本。它的卖点很直接&#xff1a;在智能体工作流平台里内置了网络安全方向的智能体能力。也就是说&#xff0c;你不再只是搭建通用 AI Agent&#xff0c;而是可以直接用它来处理安全日志分析、脆弱性信息梳理、安全基线核查、报告整理这一类偏安全…

作者头像 李华
网站建设 2026/8/29 2:06:31

Replit免费模式深度解析:从云端开发到配额管理的完整指南

把“本地开发环境一团糟”这件事&#xff0c;摊开到大多数人的日常里&#xff0c;大概是这样的&#xff1a;装了 Python 却配不好虚拟环境&#xff0c;想用 Node 又被 npm 版本卡住&#xff0c;想给朋友看个演示页面&#xff0c;还得先学会买服务器、配 Nginx、处理公网 IP。就…

作者头像 李华
网站建设 2026/8/29 2:04:41

NFC无电池安全支付:Secora Connect X能量采集方案解析

去年接了个智能戒指的预研项目&#xff0c;需求一句话就能说完&#xff1a;戒指要能开门、能支付&#xff0c;而且不装电池。听着挺科幻&#xff0c;但真正动手后你会发现&#xff0c;市面上大多数NFC芯片只能做到“读个ID给你看”&#xff0c;真要做安全支付还得再挂一颗独立安…

作者头像 李华