关于spi_message,spi_transfer的再理解
核心概念理解: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_change=0,意味着发完这个字节后,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, ®_addr, 1, res_buf, 2);总结
在 Linux SPI 驱动的世界里:
- 面对硬件行为和内存分布的复杂性,内核用
spi_transfer提供了精细到“单次连续脉冲”的控制能力。 - 面对高并发和事务原子性的需求,内核用
spi_message提供了一个“大包大揽、一次通关”的容器,确保多线程下对 SPI 总线竞争时,一个完整的协议事务不会被其他设备的请求无情打断。
二、 多线程并发与总线竞争处理机制
在 Linux 内核中,当一个spi_message正在总线上被处理时,如果有其他驱动程序或线程也想通过同一个 SPI 总线发送消息,Linux 的 SPI 核心框架(SPI Core)和主控驱动(Master/Host Driver)已经设计了一套完善的队列与互斥机制来处理这种竞争。
简单来说,它的处理原则是:串行化、排队、绝不打断。
具体是如何协作的,我们可以从以下几个维度来理解:
1. 核心机制:基于队列的串行化(Serialization)
Linux 内核的 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. 线程[进入休眠],让出 CPU(SPI 核心框架视角的代码分析)
当我们作为驱动开发者在自己的线程里调用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);