上文说了最本质的事情,所有的协议在最底层都以 1bit 的最小单位进行传输,上层的数据都是 bit 的组装;但是在具体使用的时候还有很多的设计,那就是我们接下来的研究任务。
因为 SPI 这个东西是放在一个数字系统里面的,所以要不停的构建周围的东西;看似是 SPI,其实写的是计算机的组成原理。(现在其实和八十年前的构架没有本质的区别,当然 AI 时代,也有大佬重新开发了不一样的东西)

Mask ROM Recall Fabric
(又不做芯片,所以我们要了解是编程模型)
在嵌入式和底层开发中,异步事件(Asynchronous Events) 是打破“线性思维”的关键。它指的是那些不随程序主流程(Clock)同步发生,而是由外部触发、随机出现的信号。
理解异步事件,本质上是在处理“不可预知性”。
同步(Synchronous),发一个指令,等硬件回一个结果(如 HAL_SPI_Transmit 阻塞等待);异步(Asynchronous)扔出一个任务就不管了,等硬件自己处理完,通过某种方式通知你做好了。”
按键按下、触摸屏点击(你不知道用户什么时候会动);还有传感器超过阈值触发的报警引脚(由硬件比较器自动触发);以及通信数据到达,如串口突然收到一串数据,或 SPI 从机收到了主机的片选选中信号。
单片机中,处理异步事件有三个层级:
中断 (Interrupt) — 最直接的响应,当异步事件发生(如引脚电平变化),CPU 强行停下手中的活去执行 ISR,它的实时性极高(微秒级);缺点是如果异步事件太频繁,CPU 会被淹没在中断里,导致主程序卡死。
(中断是个好东西,但是有副作用,因为CPU 要保存当前的工作状态,然后开一个新的状态处理中断,接着返回,如果中断处理的时候还有中断,那么就是所谓的嵌套操作,这就是开销)
异步的数据流在后台默默进入内存,CPU 只需要定期检查;占用 CPU 时间不多,适合处理高速、连续的异步数据流。

这个是 LH32 的一个外设
因为 MCU 内部的每一步都会看做是一个状态机的一部分,那每个外设的状态我们就可以做操作,可以触发,倒计时等等。比如外部引脚的一个异步信号,直接触发另一个硬件(如定时器开始计数或 ADC 开始转换),全程不需要 CPU 介入;大部分都会做到极低功耗,CPU 甚至可以在这个过程中处于 Deep Sleep(深度睡眠) 模式。(因为就是一些硬件小结构在工作)
处理异步事件最怕的是 “数据竞争” (Race Condition);比如主程序正在读一个 16 位的变量(分两次读 8 位),读到一半时,异步中断进来了,修改了这个变量;那主程序读到的数据是“半新半旧”的垃圾值;需要使用 volatile 关键字告知编译器不要优化,或者在读取关键数据时关闭中断(进入临界区)。
库里面看到的 _IT 和 _DMA 后缀的函数,本质上都是在开启一个异步任务:调用 HAL_SPI_Transmit_IT “开启异步发送”,执行其他代码 “主流程继续”,HAL_SPI_TxCpltCallback “异步事件完成的回调”。
在复杂的系统中,通常使用 “异步触发 + 队列处理”:中断里只负责把异步事件丢进一个队列(Queue),主循环(或 RTOS 任务)慢慢消化。这样可以防止中断函数占用时间过长;它的本质是解耦(Decoupling),把“紧急的响应”与“耗时的处理”分开。
就好像餐厅的运作模式一样:客人(外部事件)随时会点菜。服务员(中断)飞快地把菜名写在小票上,贴在厨房窗口。(这是异步触发);厨师(主程序/任务)按照小票的顺序一个一个炒,也就是队列处理(厨师炒菜)。

我们每个人都是主理人
如果在中断(ISR)里直接处理复杂逻辑(比如打印串口、计算 FFT、写 Flash),由于中断优先级高,它会长时间霸占 CPU;其他更紧急的中断(如电机控制、通信心跳)会被堵死,导致系统掉线或崩溃;所以中断里应只做“最快的事”。
[外部事件] (如: SPI数据到、按键按下)
|
▼
[硬件中断 (ISR)] <-- 异步触发 (极其短促)
|
|-- 1. 清除中断标志
|-- 2. 将原始数据打包存入 [队列 (Queue)]
|-- 3. 退出中断
▼
[主循环 / RTOS 任务] <-- 队列处理 (按序执行)
|-- 1. 不断检查队列是否有新数据
|-- 2. 从队列取出数据包
|-- 3. 执行耗时逻辑 (如协议解析、算法、UI更新)
在中断中(快进快出):
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
uint32_t event_id = 0x01;
// 将事件 ID 发送到队列,不等待 (0代表不阻塞)
// 使用 FromISR 后缀的特殊函数,确保中断安全
xQueueSendFromISR(xEventQueue, &event_id, NULL);
}
在主任务中(慢慢消化):
void StartDefaultTask(void const * argument) {
uint32_t received_event;
for(;;) {
// 等待队列消息,如果没有消息就让出 CPU (阻塞等待)
if (xQueueReceive(xEventQueue, &received_event, portMAX_DELAY) == pdPASS) {
// 这里执行耗时 100ms 的复杂算法
Handle_Complex_Event(received_event);
}
}
}
这种处理模式可以让中断处理时间极短且固定,系统对新事件的响应极快;如果短时间内爆发了 10 个事件,队列可以先把它们“存”起来,主程序按顺序慢慢消峰填谷,不会丢失事件;可以让一个高优先级的任务处理紧急队列,低优先级的任务处理日志队列;在没有操作系统(Bare-Metal)的情况下,通常用 环形缓冲区 (Ring Buffer) 代替队列,主循环里通过 while(!BufferIsEmpty) 来实现。
不要小看这个东西,这几乎是现代 MCU 的全部精华了,剩下的都是它的擦屁股行为而已。
在计算机的世界里面就这两个数据传输模型,串行的话省地方,但是满,反过来可以扩展传输通道,但是这样一做就有了额外的事情。

这个非常好
串和并其实没有特别明显的边界,它们在不停的变换。
在处理器内部都是处理的数据流,直观的看是线性的,串的,像一个流水线的事情;也可以这样说,所有的数据在内部处理都是一个长链。(软件视角看,数据是以字节/字为单位“顺序”呈现的。)
如果是并行的,需要在进入之前在一个 buffer 里面进行并行数据的转换或者是组装。
这个 QSPI 是 NXP(买了摩托罗拉的 MCU 业务),推出来的 ColdFire:

上面搭载的

这个是 w25 的 flash
H7 的 SPI 没有找到这个时序图,没关系;但是我们需要仔细的说明这个问题。

找到了

然后这个 SQPI 在 D1 的总线域里面
在 H7 中:QSPI 连接在 AXI 总线上,支持 DMA,可被 MDMA 加速
访问路径:
CPU -> AXI -> QSPI -> Flash
所以性能与:Cache,AXI arbitration,时钟分频都有关。

首先是物理上面多出了 3 个 IO,做输出
因为4 个 IO 就是 4bit(也叫半比特-nibble),然后每两个 SCLK 周期的数据(即两个 nibble)才组合成一个 byte;在每个 SCLK 边沿(如上升沿),4 个引脚的电平被同时锁存到 QSPI 控制器内部的采样寄存器中;并行接收 4 位。

我们可以瞬间进行采样,有了个 4 个 01,一般是 IO3,2,1 ,0这样的,4 位数据被同时锁存到 QSPI 控制器的输入寄存器中
第1个周期:采样 nibble_high = [IO3, IO2, IO1, IO0]
第2个周期:采样 nibble_low = [IO3, IO2, IO1, IO0]
然后自动组合: byte = (nibble_high << 4) | nibble_low(当然这里硬件可能不是这样工作的,但是也是这样的拼接方式) ,组合后的字节被放入 RX FIFO(接收缓冲区)。

在 FIFO 里面,不停的半个字节的数据进来
CPU 或 DMA 后续从 FIFO 中读取的是完整的字节(或 32 位字);MCU 外设自动完成“并行→串行化字节流”的转换,但这个过程是硬件并行+重组,不是传统意义上的“串行移位”。
当调用 HAL_QSPI_Receive() 或读取数据寄存器时,得到的是:
uint8_t data; // 一个接一个的字节
无法直接看到“哪 4 位来自哪个周期”,因为硬件已经组装好了;数据按地址顺序连续输出,表现得像一个高速串行字节流;但这只是抽象结果,不代表内部是串行接收。
SPI 接收端内部一定有:
┌─────────┐
MISO → │ D-FF │
│ │
SCLK → │ CLK │
└─────────┘
当采样边沿到来时:
也就是说:
每个时钟边沿锁存 1 bit。
内部结构类似这样:
┌───────┐
bit0 │ │
bit1 │ │
bit2 │ 8-bit │
... │ shift │
bit7 │ │
└───────┘
假设 MSB first:
shift_reg = b7
shift_reg = b7 b6
实际上硬件执行的是:
每个边沿执行一次。
当移位计数器数到 8:
bit_counter == 8
SPI 外设会:把 shift_reg 的内容复制到 DR(Data Register),置位 RXNE(接收非空标志),产生中断或 DMA 请求,此时这个 8bit 才成为“一个字节”。(这个是普通SPI 外设的寄存器,知道这个概念就行)
在 STM32H7 这种 MCU 中SPI 外设里有 FIFO。
流程:
shift_reg -> RX FIFO -> DR -> CPU/DMA
FIFO 允许减少中断压力,支持高速连续流,实际上DMA 不理解 bit。
DMA 只理解:
当 RXNE 触发,搬运 DR
因此每完成 N bit(8/16/32),DR 写入,DMA 搬走
如果 LSB first:
这只是移位方向不同。
在 QSPI Quad 模式下,每个时钟周期采样:IO0,IO1,IO2,IO3
相当于:
内部会:
8bit 数据只需要 2 个时钟。
接收过程就是:
而 b_i 是在时钟 i 的采样结果。
线多了,在处理的时候就需要一个步骤,进行并转串的操作,本质上还是 bit 的拼接。
这里就说高云的 FPGA 了!我们可以方便的完成上面说的操作,是物理级别的。因为 IO 引脚也就是做这些应用,输出时钟,输入数据等,以及进行采样。

这里就是看一个并转串的 IO 原语

8 位到 D0
8 根线到一个串的数据;没有时序图,我觉得是太占地方了就没有画。

还有 10 转 1
还是整理一下。
IDES4:1 → 4 解串
IDES8:1 → 8 解串
IDES10:1 → 10 解串(常见于 10:1、8b/10b 相关接口)
IDES16:1 → 16 解串(更宽的并行字),这些原语都属于 “DDR 模式输入逻辑”。
OSER4:4 → 1 串化(同时还有 Q1 辅助输出用于 OEN/三态控制等)
OSER8:8 → 1 串化(同样 Q0 数据、Q1 给 OEN)
OSER10:10 → 1 串化
OSER16:16 → 1 串化
继续看,我们知道采样是需要时钟的,所以放心,肯定有。

image-20260223214848591
这些并串/串并原语几乎都用同一套时钟哲学:
FCLK:高速“位时钟”(bit clock)——串行线上每个 bit 的节拍主要靠它
PCLK:低速“并行字时钟”(word clock)——每 N bit 形成一个并行字(Q 总线)给 FPGA 逻辑,或者从 FPGA 逻辑装载一组并行数据(D 总线)
文档对分频关系给得很直接:
IDES4:PCLK = FCLK / 2
OSER8:PCLK 通常由 FCLK 分频得到,1/4
IDES10:PCLK = FCLK / 5
IDES16:PCLK = FCLK / 8
DDR 情况下,一个 FCLK 周期可以采两次(上升沿一次、下降沿一次),所以 “4、8、16” 这些数字背后通常对应 “每个 PCLK 周期内要吞吐的 bit 数”(和 DDR 的 2 倍采样有关)。
一般的 SPI 是一个单边采样,那很自然的就在时钟的两个沿去采样:

它来了!

全球的脑机接口都离不开的芯片
32 和 64 通道的数据都很多:

好像是 16 到 32

应该是都有
它的输出是支持 SPI 的:

这里的接口就可见一斑了
IDES8 的数据输入 D 可以直接来自 IBUF,也可以先过 IODELAY 再送入(从 IODELAY 的 DO 输出接入),IDES4 也是同样规则;这句话的重要性是:高速串行输入经常需要 输入延时微调(把采样点移到眼图中心),IODELAY 就是干这个的。
题外话,所以多个线的传输看上去非常的完美,但是现实情况是害怕多个线上面的数据稍微有点对不齐,有的快有点慢,那每一个瞬间的采样就不准了,所以需要进行微调,只能让快的变慢一些,和慢的步调一致,这是好理解的。
看 IDES4 的端口表最直观(其它只是 Q 宽度不同):

输入:D、FCLK、PCLK、CALIB、RESET
输出:Q3~Q0
IDES8 则是 Q0~Q7,例化模板里列得很清楚。
IDES8 uut(
.Q0(Q0),
.Q1(Q1),
.Q2(Q2),
.Q3(Q3),
.Q4(Q4),
.Q5(Q5),
.Q6(Q6),
.Q7(Q7),
.D(D),
.FCLK(FCLK),
.PCLK(PCLK),
.CALIB(CALIB),
.RESET(RESET)
);
defparam uut.GSREN="false";
defparam uut.LSREN ="true";
“组装到字节”的答案:通常把 Q[7:0] 当作一个 byte,在 PCLK 时钟域里打一拍/写 FIFO/送 DMA; 如果用 IDES16,那就是一次出来 16 bit(两个 byte 或一个 16-bit word)。

文档对 IDES10/IDES16 的描述非常像我们熟悉的 BITSLIP:IDES10支持 CALIB 调整输出数据顺序,“每个脉冲数据移位一位,移位十次后,数据输出将与移位前相同”。
串行流里往往有帧头(comma / sync pattern),在 PCLK 域检查 Q 总线上是否出现目标帧头;如果没对齐,就给 CALIB 一个脉冲;每来一个 CALIB 脉冲,并行输出位序整体旋转/滑动 1 bit;最多 N 次就会遍历所有相位(N=10/16/8…),一定能把帧头对齐到希望的 bit 边界。
“bit 数据如何被采样和组装到字节”:采样靠 FCLK 边沿(DDR 还会用到双边沿);组装成字节/字靠内部移位 + 在 PCLK 边沿“并行推出”;字节边界/帧边界对齐靠 CALIB 做 bit-slip。
直觉过程就是:在 PCLK 边沿把 D0~D7 装载进内部寄存器(一次装 8 bit);接下来在 FCLK 的连续边沿,把这 8 bit 依次从 Q0 串行送出,Q1 同步给出 OEN 相关控制(需要时三态)。

应该是好理解的,就是串和并的转换。
假设要做:串行进来 -> FPGA 内部得到 byte -> 再串行发出去,做法是:
输入端:IBUF →(可选 IODELAY)→ IDES8(IDES8 的 D 可来自 IBUF 或 IODELAY。 )
PCLK 域:Q[7:0] 打拍,检查帧头,如果帧头不在预期 bit 边界,那就给 CALIB 脉冲做 bit-slip(IDES10/16 的描述说明这就是“每次移位一位”的对齐机制)。
输出端:OSER8 把 byte 串化出去(Q0 是串行输出,PCLK/FCLK 分频关系按文档配。 )
实际上,FPGA 也是 bit 在流转,我不想从数字电路说起,那没有意思,不忘我们的初心才是最好的。