关于spi_message,spi_transfer的再理解
2026/6/30 0:38:35
网站开发
核心概念理解spi_message与spi_transfer在 Linux 内核的 SPI 驱动框架中spi_transfer和spi_message是最核心的两个数据结构。如果你用前面我们聊过的“分层”和“打包”的思维来理解它们就会非常直观spi_transfer是真正负责硬件传输的最小原子单位。它对应的是一段连续的、传输特性相同的读写数据流。spi_message是一个传输队列的载体/事务Transaction。它本身不包含具体的传输数据而是作为一个“打包的容器”把一个或多个spi_transfer串联成一个不可分割的、完整的通信任务。我们可以用一个形象的现实生活比喻然后结合代码来彻底理清它们。1. 形象的比喻快递总装箱 vs 里面的独立包裹假设你通过 SPI 接口去读写一个 SPI 闪存Flash芯片。要读取某个地址的数据你通常需要先发送1字节的读命令3字节的地址然后紧接着接收64字节的数据。在这个过程中spi_transfer就像是单个独立的包裹。* 包裹 A 装着“写出去的命令和地址”4字节。包裹 B 装着“准备收回来的数据”64字节。spi_message就像是快递公司的大总装箱。它负责把包裹 A 和包裹 B 塞进同一个大箱子里封好口打上标签。快递员SPI 控制器驱动在运送这个大箱子spi_message期间绝对不能停下来去送别人的件必须一口气把这个箱子里所有的包裹spi_transfer按顺序送完。这就保证了原子性。2. 为什么不合并成一个spi_transfer你可能会问既然都是要发数据为什么不把命令、地址、数据直接拼成一个大数组用一个spi_transfer发过去呢这里有两个核心原因读写方向切换半双工/全双工混合SPI 是全双工的。但在很多实际应用中前 4 个字节我们只需要发送TX后 64 个字节我们只需要接收RX。通过拆分成两个spi_transfer一个只填tx_buf一个只填rx_buf内核驱动就能精准控制硬件的 DMA 或 FIFO避免内存浪费和混乱。硬件参数的动态调整每一个独立的spi_transfer都可以有自己独立的硬件行为例如你可以在第一个 transfer 结束后让 CS 线保持拉低cs_change 0并在第二个 transfer 改变时钟频率speed_hz或者增加一段延迟delay_usecs。这种颗粒度的控制只能在spi_transfer级别实现。3. 核心结构体看关键字段看看 Linux 内核源码include/linux/spi/spi.h中它们的长相我们只挑最核心的字段struct spi_transfer原子包裹struct spi_transfer { const void *tx_buf; /* 要发送的数据缓冲区指针CPU内存 */ void *rx_buf; /* 要接收的数据缓冲区指针 */ unsigned len; /* 本次传输的字节长度 */ u32 speed_hz; /* 可选临时改变本次传输的时钟频率 */ u16 delay_usecs; /* 可选本次传输结束后延迟多少微秒再进行下一个 transfer */ u8 bits_per_word; /* 可选字长如 8位、16位 */ u8 cs_change; /* 关键本次传输结束后是否要改变拉高片选CS线 */ struct list_head transfer_list; /* 链表节点用于将自己挂载到 spi_message 中 */ };struct spi_message大装箱struct spi_message { struct list_head transfers; /* 链表头用来串联所有挂载进来的 spi_transfer */ struct spi_device *spi; /* 目标 SPI 从设备 */ void (*complete)(void *context); /* 异步传输完成后的回调函数指针 */ void *context; /* 传递给回调函数的参数 */ unsigned actual_length; /* 整个 message 实际传输成功的总字节数 */ };4. 举例说明在驱动中如何使用它们我们以读取一个 SPI 传感器的寄存器为例需要先写 1 字节寄存器地址再读 2 字节数据。场景读取传感器数据#include linux/spi/spi.h int read_sensor_register(struct spi_device *spi, u8 reg_addr, u8 *res_buf) { struct spi_message msg; struct spi_transfer xfers[2]; // 我们需要两个阶段两个包裹 u8 tx_data reg_addr; int status; // 步骤 1: 初始化 spi_message 容器 spi_message_init(msg); // 步骤 2: 填充第一个包裹 —— 发送寄存器地址 memset(xfers, 0, sizeof(xfers)); xfers[0].tx_buf tx_data; // 只写 xfers[0].len 1; // 1 字节 // 注意默认情况下cs_change0意味着发完这个字节后CS片选线保持拉低不释放 spi_message_add_tail(xfers[0], msg); // 塞入大箱子 // 步骤 3: 填充第二个包裹 —— 接收传感器返回的数据 xfers[1].rx_buf res_buf; // 只读 xfers[1].len 2; // 2 字节 spi_message_add_tail(xfers[1], msg); // 塞入大箱子 // 步骤 4: 把大箱子交给 SPI 控制器同步阻塞传输 // 控制器驱动会严格保证拉低CS - 执行xfers[0] - 执行xfers[1] - 拉高CS status spi_sync(spi, msg); if (status 0) { dev_err(spi-dev, SPI transfer failed: %d\n, status); return status; } return 0; }快捷封装内核提供的“懒人工具”因为“先写后读”或者“只写/只读”的场景太常见了内核开发者在spi.h里利用spi_message和spi_transfer封装了很多好用的简化函数省去了你手动去init和add_tail的麻烦。例如上面的代码在实际开发中往往可以直接用一行内核 API 替代// 内核内部会自动帮你创建 2 个 transfer 和 1 个 message 并调用 spi_sync status spi_write_then_read(spi, reg_addr, 1, res_buf, 2);总结在 Linux SPI 驱动的世界里面对硬件行为和内存分布的复杂性内核用spi_transfer提供了精细到“单次连续脉冲”的控制能力。面对高并发和事务原子性的需求内核用spi_message提供了一个“大包大揽、一次通关”的容器确保多线程下对 SPI 总线竞争时一个完整的协议事务不会被其他设备的请求无情打断。二、 多线程并发与总线竞争处理机制在 Linux 内核中当一个spi_message正在总线上被处理时如果有其他驱动程序或线程也想通过同一个 SPI 总线发送消息Linux 的 SPI 核心框架SPI Core和主控驱动Master/Host Driver已经设计了一套完善的队列与互斥机制来处理这种竞争。简单来说它的处理原则是串行化、排队、绝不打断。具体是如何协作的我们可以从以下几个维度来理解1. 核心机制基于队列的串行化SerializationLinux 内核的 SPI 子系统特别是现代内核版本内部实现了一个基于工作队列Workqueue的内核线程。所有的 SPI 传输请求无论是来自传感器 A、闪存 B 还是屏幕 C最终都会被放入 SPI 控制器spi_controller/spi_master维护的一个硬件消息队列中。当一个spi_message正在总线上被执行时新消息的处理另外的请求不会直接冲到硬件总线上而是被作为新的节点挂载到这个spi_controller-queue队列的末尾。按顺序调度内核的 SPI 工作线程Worker Thread会像食堂排队打饭一样一个接一个地从队列头部取出spi_message交给底层硬件驱动去执行。只有前一个spi_message里的所有spi_transfer全部传输完毕、片选释放下一个spi_message才会获得总线控制权。2. 发起请求的线程会怎样同步 vs 异步另一个消息“需要总线”时发起这个请求的软件线程会处于什么状态取决于它调用的是同步接口还是异步接口场景 A调用spi_sync()同步阻塞—— 最常见如果另外的线程调用了spi_sync(spi, new_msg)spi_sync会把new_msg放入控制器的排队队列中。放入队列后调用线程会立刻进入休眠状态Sleep让出 CPU 给其他任务。当轮到new_msg并在硬件上全部传输完成后SPI 子系统会触发一个完成信号Completion唤醒这个休眠的线程。线程醒来spi_sync()函数返回 0成功接着往下执行。场景 B调用spi_async()异步非阻塞如果另外的线程比如在中断处理函数或高频定时器中调用了spi_async(spi, new_msg)spi_async同样把new_msg放入排队队列。它不会等待而是立刻返回 0。发起请求的线程可以继续去干别的事情。当总线空闲并轮到new_msg传输完成后内核会自动调用你在new_msg.complete字段里注册的回调函数通知你“数据已经发完了”。3. 完美的硬件级物理隔离CS 片选线在物理层上SPI 是通过硬连线的片选线CS/SS来区分不同设备的。即使队列里堆满了来自不同设备设备 A 和设备 B的spi_message硬件上也绝对不会发生“数据串线”或“互相污染”因为当处理设备 A 的spi_message时控制器的硬件驱动会只拉低设备 A 的 CS 线此时设备 B 的 CS 线保持高电平。即使总线上的 CLK、MOSI 信号在剧烈跳变设备 B 的硬件接口由于没有被片选使能会完全无视这些信号处于高阻态。当 A 的spi_message执行完A 的 CS 被拉高轮到 B 时B 的 CS 才会被拉低。4. 特殊机制抢占与独占总线Bus Locking在极少数极其看重实时性或者需要连续霸占总线的场景下内核还提供了两种高级机制机制一SPI 消息的优先级如果内核配置了实时调度SPI 的工作队列线程可以运行在很高的实时优先级如SCHED_FIFO。虽然它不能“掐断”当前正在传输的字节但它能确保一旦当前spi_message结束高优先级的消息能立刻插队处理。机制二总线锁spi_bus_lock/spi_bus_unlock如果你有某些非常特殊的操作例如必须连续向一个设备发送好几个spi_message期间绝对不允许其他设备的spi_message插队打断否则该设备就会复位你可以使用总线锁spi_bus_lock(spi-controller); // 锁住整个 SPI 总线 // 此时其他任何设备调用 spi_sync/spi_async 都会在这里阻塞排队 spi_sync(spi, msg1); spi_sync(spi, msg2); spi_bus_unlock(spi-controller); // 释放总线锁队列里的其他消息开始处理总结在 Linux 内核里SPI 总线是一个受到严格监管的单窗口独占资源。任何“另外的消息”想要使用总线都必须通过spi_message提交给内核队列。正在发送的spi_message拥有绝对的原子执行权后来的消息只会在队列中静静排队或让调用线程休眠直到前人优雅退场。这种机制完美确保了 Linux 在多线程并发驱动外设时的稳定与安全。三、spi_message传输期间的当前线程调度与睡眠状态在spi_message还没有结束的时候发起请求的当前线程完全有可能甚至这正是最常见的情况会被内核调度出去。很多初学者容易把“总线事务的原子性不被其他 SPI 设备打断”和“线程的可调度性当前 CPU 线程是否会被切换”混淆。实际上spi_message在总线上跑的时候发起调用的这个线程大概率已经被内核切换出去睡觉了等硬件传完了它才会被重新唤醒。这背后的核心逻辑取决于底层 SPI 控制器驱动是用“中断/DMA异步通知”还是“轮询Polling”方式来实现的。1. 常见情况使用 中断/DMA 驱动线程会被调度出去在绝大多数嵌入式平台的主控驱动中SPI 数据的发送和接收都是靠硬件中断或DMA完成的。如果你在线程中调用了同步接口spi_sync()整个事件的发展脉络是这样的当前线程 (CPU) SPI 内核工作队列 硬件控制器 (SPI Controller) | | | 1. 调用 spi_sync() | | |----------------------------| | 2. 线程[进入休眠]让出CPU | 3. 配置硬件、启动 DMA 传输 | | |------------------------------| | | | 4. 硬件疯狂干活... | X 此时 CPU 空闲跑其他线程 | (传输 spi_message) | X | | X | 5. 传输完毕触发硬件中断 | |------------------------------| | | 6. 中断处理函数 (ISR) | | 发出 Completion 信号 |----------------------------| 7. 线程[被唤醒]重新进入就绪队列 | 8. spi_sync() 返回继续执行由于 SPI 总线的速度对于 CPU 来说极其缓慢如果让 CPU 在原地死等一个数据传完会极大地浪费 CPU 算力。所以内核在把spi_message送入队列并启动硬件传输后当前线程就会主动调用调度器进入休眠状态。此时CPU 会无缝切换去执行系统里的其他高优先级线程。直到 SPI 硬件通过中断喊一声“我传完了”发起调用的线程才会被重新唤醒。2. 特殊情况使用 轮询Polling驱动线程不会被调度出去场景 A数据量极小驱动采用轮询模式如果底层 SPI 驱动发现这次要传输的spi_transfer只有 1 或 2 个字节它可能会认为触发中断、引发上下文切换的开销比让 CPU 在原地死等还要大。此时底层驱动会选择用while循环死等硬件状态寄存器的标志位。在这种“轮询模式”下当前线程会牢牢霸占 CPU不会被调度出去。场景 B在不可调度的上下文原子上下文中发起传输如果你在中断处理函数ISR、定时器回调函数或者持有自旋锁Spinlock的原子上下文中。在这些地方Linux 内核是严禁发生休眠和调度的。在这些地方你只能调用异步接口spi_async()。spi_async()只是把spi_message扔进队列就立刻返回了绝对不休眠所以当前线程紧接着往下跑同样不会被调度出去。3. 区分两个“原子性”线程调度软件层当spi_sync()正在传输spi_message时发起请求的线程通常会被调度出去进入休眠。CPU 属于全人类它要去服务整个 Linux 系统。总线排队硬件层尽管发起调用的软件线程去睡觉了但内核的 SPI 控制器线程和硬件正在死死守护这条总线。在当前spi_message彻底结束前任何其他驱动提交的 SPI 消息都无法插队抢占硬件总线。四、 核心运作脉络具体代码追踪完成“线程休眠、让出 CPU”以及“配置硬件、启动 DMA”这两个动作实际上是由SPI 核心框架SPI Core和底层主控驱动Host Driver协同完成的。在内核实际执行时是先启动硬件然后线程立刻进去睡觉。1. 配置硬件、启动 DMA 传输主控驱动视角的代码分析内核的工作线程从队列里拿到spi_message后会回调具体 SoC 平台的驱动代码。底层驱动会通过读写寄存器或调用内核 DMA 引擎来启动传输/* 伪代码源自各 SoC 厂商的 SPI 主控驱动 (如 spi-fsl-dspi.c 或 spi-imx.c) */ static int platform_spi_one_transfer(struct spi_controller *host, struct spi_device *spi, struct spi_transfer *xfer) { unsigned long flags; u32 dma_ctrl; // 1. 设置硬件参数波特率、时钟极性等 platform_spi_config_hardware(host, xfer-speed_hz, xfer-bits_per_word); // 2. 映射内存缓冲区准备给 DMA 使用 // 将 CPU 的虚拟地址 xfer-tx_buf 和 xfer-rx_buf 转换为 DMA 能认的物理地址 platform_dma_map_buffers(host, xfer); // 3. 配置控制器的 DMA 寄存器 dma_ctrl readl(host-regs REG_DMA_CTRL); dma_ctrl | (DMA_CTRL_TX_EN | DMA_CTRL_RX_EN); // 开启 TX/RX DMA 通道 writel(dma_ctrl, host-regs REG_DMA_CTRL); // 4. 触发 DMA 引擎开始搬运数据配置源地址、目的地址、长度 // 此时SPI 硬件控制器开始在物理总线上疯狂产生时钟并发送比特流 dmaengine_submit(host-tx_desc); dma_async_issue_pending(host-tx_chan); // 5. 硬件已经跑起来了当前函数可以返回了 // 注意这里只是“启动”了硬件数据并没传完硬件自己靠 DMA 在后台跑 return 1; }2. 线程[进入休眠]让出 CPUSPI 核心框架视角的代码分析当我们作为驱动开发者在自己的线程里调用spi_sync(spi, msg)时内核利用了内核非常经典的等待队列Wait Queue和完成量Completion机制来让我们“睡觉”/* 源码路径drivers/spi/spi.c 简化版核心逻辑 */ int spi_sync(struct spi_device *spi, struct spi_message *message) { DECLARE_COMPLETION_ONSTACK(done); // 在栈上定义一个“完成量”结构体 (内部包含一个等待队列) int status; // 1. 将这个完成量绑定 to 当前要发送的 spi_message 上 message-complete spi_sync_complete; // 注册完成后的回调函数 message-context done; // 把完成量指针作为上下文参数传入 // 2. 把消息丢进 SPI 核心框架的硬件队列中 (触发上面第3步的硬件启动) status __spi_async(spi, message); if (status 0) { /* * 【关键点】代码执行到这里硬件已经启动了。 * 接下来当前线程调用 wait_for_completion()。 * 这个函数会把当前线程的状态设置为 TASK_UNINTERRUPTIBLE不可中断休眠 * Then 调用 schedule() 主动触发内核调度器把当前 CPU 让给别的线程跑 */ wait_for_completion(done); // --- 线程在此处“断层/冬眠” ---------------------------------------- // --- 直到硬件中断触发 complete()线程被唤醒才会从这里醒来往下走 --- status message-status; // 获取硬件层返回的最终传输状态 } return status; }如果再往 Linux 内核的调度层kernel/sched/completion.c看一眼wait_for_completion本质上在做这件事/* 抽象底层的调度逻辑 */ do { // 1. 检查硬件传完没有如果有中断进来了done.done 会大于 0 if (x-done) { x-done--; return; // 传完了直接返回不需要睡觉 } // 2. 没传完把当前线程挂载到完成量的等待链表里 __prepare_to_wait(x-wait, wait, TASK_UNINTERRUPTIBLE); // 3. 【真正让出 CPU 的一枪】 // 调用全局调度器切换上下文。此时 CPU 彻底和当前线程说拜拜 schedule(); } while (1);