首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >低速协议系列:SPI.2(多根数据线的采样哲学)

低速协议系列:SPI.2(多根数据线的采样哲学)

作者头像
云深无际
发布2026-03-03 14:05:02
发布2026-03-03 14:05:02
1240
举报
文章被收录于专栏:云深之无迹云深之无迹

低速协议系列:SPI(初入殿堂)

上文说了最本质的事情,所有的协议在最底层都以 1bit 的最小单位进行传输,上层的数据都是 bit 的组装;但是在具体使用的时候还有很多的设计,那就是我们接下来的研究任务。

因为 SPI 这个东西是放在一个数字系统里面的,所以要不停的构建周围的东西;看似是 SPI,其实写的是计算机的组成原理。(现在其实和八十年前的构架没有本质的区别,当然 AI 时代,也有大佬重新开发了不一样的东西)

Mask ROM Recall Fabric
Mask ROM Recall Fabric

Mask ROM Recall Fabric

(又不做芯片,所以我们要了解是编程模型)

异步事件(Asynchronous Events)

在嵌入式和底层开发中,异步事件(Asynchronous Events) 是打破“线性思维”的关键。它指的是那些不随程序主流程(Clock)同步发生,而是由外部触发、随机出现的信号。

理解异步事件,本质上是在处理“不可预知性”。

什么是异步?

同步(Synchronous),发一个指令,等硬件回一个结果(如 HAL_SPI_Transmit 阻塞等待);异步(Asynchronous)扔出一个任务就不管了,等硬件自己处理完,通过某种方式通知你做好了。”

异步事件的三种典型来源

按键按下、触摸屏点击(你不知道用户什么时候会动);还有传感器超过阈值触发的报警引脚(由硬件比较器自动触发);以及通信数据到达,如串口突然收到一串数据,或 SPI 从机收到了主机的片选选中信号。

处理异步事件的三个做法

单片机中,处理异步事件有三个层级:

中断 (Interrupt) — 最直接的响应,当异步事件发生(如引脚电平变化),CPU 强行停下手中的活去执行 ISR,它的实时性极高(微秒级);缺点是如果异步事件太频繁,CPU 会被淹没在中断里,导致主程序卡死。

(中断是个好东西,但是有副作用,因为CPU 要保存当前的工作状态,然后开一个新的状态处理中断,接着返回,如果中断处理的时候还有中断,那么就是所谓的嵌套操作,这就是开销)

DMA + 循环缓冲区 — 无感处理

异步的数据流在后台默默进入内存,CPU 只需要定期检查;占用 CPU 时间不多,适合处理高速、连续的异步数据流。

DMA:去你妈的CPU,数据我自己搬!

事件联动 (Event Linkage / EXTI) —— 纯硬件响应

这个是 LH32 的一个外设
这个是 LH32 的一个外设

这个是 LH32 的一个外设

因为 MCU 内部的每一步都会看做是一个状态机的一部分,那每个外设的状态我们就可以做操作,可以触发,倒计时等等。比如外部引脚的一个异步信号,直接触发另一个硬件(如定时器开始计数或 ADC 开始转换),全程不需要 CPU 介入;大部分都会做到极低功耗,CPU 甚至可以在这个过程中处于 Deep Sleep(深度睡眠) 模式。(因为就是一些硬件小结构在工作)

LH32M0G3的 ADC 性能上限(含竞品对比)

异步编程的核心挑战:原子性与竞争

处理异步事件最怕的是 “数据竞争” (Race Condition);比如主程序正在读一个 16 位的变量(分两次读 8 位),读到一半时,异步中断进来了,修改了这个变量;那主程序读到的数据是“半新半旧”的垃圾值;需要使用 volatile 关键字告知编译器不要优化,或者在读取关键数据时关闭中断(进入临界区)。

从 HAL 库看异步

库里面看到的 _IT_DMA 后缀的函数,本质上都是在开启一个异步任务:调用 HAL_SPI_Transmit_IT “开启异步发送”,执行其他代码 “主流程继续”,HAL_SPI_TxCpltCallback “异步事件完成的回调”。

在复杂的系统中,通常使用 “异步触发 + 队列处理”:中断里只负责把异步事件丢进一个队列(Queue),主循环(或 RTOS 任务)慢慢消化。这样可以防止中断函数占用时间过长;它的本质是解耦(Decoupling),把“紧急的响应”与“耗时的处理”分开。

就好像餐厅的运作模式一样:客人(外部事件)随时会点菜。服务员(中断)飞快地把菜名写在小票上,贴在厨房窗口。(这是异步触发);厨师(主程序/任务)按照小票的顺序一个一个炒,也就是队列处理(厨师炒菜)。

我们每个人都是主理人
我们每个人都是主理人

我们每个人都是主理人

如果在中断(ISR)里直接处理复杂逻辑(比如打印串口、计算 FFT、写 Flash),由于中断优先级高,它会长时间霸占 CPU;其他更紧急的中断(如电机控制、通信心跳)会被堵死,导致系统掉线或崩溃;所以中断里应只做“最快的事”。

代码语言:javascript
复制
[外部事件] (如: SPI数据到、按键按下)

      |
      ▼
[硬件中断 (ISR)]  <-- 异步触发 (极其短促)
      | 
      |-- 1. 清除中断标志
      |-- 2. 将原始数据打包存入 [队列 (Queue)]
      |-- 3. 退出中断
      ▼
[主循环 / RTOS 任务] <-- 队列处理 (按序执行)
      |-- 1. 不断检查队列是否有新数据
      |-- 2. 从队列取出数据包
      |-- 3. 执行耗时逻辑 (如协议解析、算法、UI更新)

在中断中(快进快出):

代码语言:javascript
复制
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
    uint32_t event_id = 0x01;
    // 将事件 ID 发送到队列,不等待 (0代表不阻塞)
    // 使用 FromISR 后缀的特殊函数,确保中断安全
    xQueueSendFromISR(xEventQueue, &event_id, NULL);
}

在主任务中(慢慢消化):

代码语言:javascript
复制
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 的全部精华了,剩下的都是它的擦屁股行为而已。

串行 VS 并行

在计算机的世界里面就这两个数据传输模型,串行的话省地方,但是满,反过来可以扩展传输通道,但是这样一做就有了额外的事情。

这个非常好
这个非常好

这个非常好

串和并其实没有特别明显的边界,它们在不停的变换。

在处理器内部都是处理的数据流,直观的看是线性的,串的,像一个流水线的事情;也可以这样说,所有的数据在内部处理都是一个长链。(软件视角看,数据是以字节/字为单位“顺序”呈现的。)

如果是并行的,需要在进入之前在一个 buffer 里面进行并行数据的转换或者是组装。

QSPI :更多的数据线

这个 QSPI 是 NXP(买了摩托罗拉的 MCU 业务),推出来的 ColdFire:

上面搭载的
上面搭载的

上面搭载的

这个是 w25 的 flash
这个是 w25 的 flash

这个是 w25 的 flash

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

找到了
找到了

找到了

然后这个 SQPI 在 D1 的总线域里面
然后这个 SQPI 在 D1 的总线域里面

然后这个 SQPI 在 D1 的总线域里面

在 H7 中:QSPI 连接在 AXI 总线上,支持 DMA,可被 MDMA 加速

访问路径:

代码语言:javascript
复制
CPU -> AXI -> QSPI -> Flash

所以性能与:Cache,AXI arbitration,时钟分频都有关。

并行的数据在何时被串行

首先是物理上面多出了 3 个 IO,做输出
首先是物理上面多出了 3 个 IO,做输出

首先是物理上面多出了 3 个 IO,做输出

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

控制器层(QSPI 外设)→ 自动拼接成字节

我们可以瞬间进行采样,有了个 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 里面,不停的半个字节的数据进来
在 FIFO 里面,不停的半个字节的数据进来

在 FIFO 里面,不停的半个字节的数据进来

CPU 或 DMA 后续从 FIFO 中读取的是完整的字节(或 32 位字);MCU 外设自动完成“并行→串行化字节流”的转换,但这个过程是硬件并行+重组,不是传统意义上的“串行移位”。

软件层(CPU 视角)→ 看起来像“串行字节流”

当调用 HAL_QSPI_Receive() 或读取数据寄存器时,得到的是:

代码语言:javascript
复制
uint8_t data; // 一个接一个的字节

无法直接看到“哪 4 位来自哪个周期”,因为硬件已经组装好了;数据按地址顺序连续输出,表现得像一个高速串行字节流;但这只是抽象结果,不代表内部是串行接收。

更加精细的组装过程

采样的本质:边沿触发 D 触发器

SPI 接收端内部一定有:

代码语言:javascript
复制
        ┌─────────┐
MISO →  │  D-FF   │
        │         │
SCLK →  │  CLK    │
        └─────────┘

当采样边沿到来时:

也就是说:

每个时钟边沿锁存 1 bit。

移位寄存器如何拼装成字节

内部结构类似这样:

代码语言:javascript
复制
     ┌───────┐
bit0 │       │
bit1 │       │
bit2 │ 8-bit │
...  │ shift │
bit7 │       │
     └───────┘

假设 MSB first:

第一个采样边沿:

代码语言:javascript
复制
shift_reg = b7

第二个采样边沿:

代码语言:javascript
复制
shift_reg = b7 b6

实际上硬件执行的是:

每个边沿执行一次。

什么时候变成“一个字节”?

当移位计数器数到 8:

代码语言:javascript
复制
bit_counter == 8

SPI 外设会:把 shift_reg 的内容复制到 DR(Data Register),置位 RXNE(接收非空标志),产生中断或 DMA 请求,此时这个 8bit 才成为“一个字节”。(这个是普通SPI 外设的寄存器,知道这个概念就行)

在 STM32H7 这种 MCU 中SPI 外设里有 FIFO。

流程:

代码语言:javascript
复制
shift_reg -> RX FIFO -> DR -> CPU/DMA

FIFO 允许减少中断压力,支持高速连续流,实际上DMA 不理解 bit。

DMA 只理解:

代码语言:javascript
复制
当 RXNE 触发,搬运 DR

因此每完成 N bit(8/16/32),DR 写入,DMA 搬走

MSB First vs LSB First

如果 LSB first:

这只是移位方向不同。

在 QSPI Quad 模式下,每个时钟周期采样:IO0,IO1,IO2,IO3

相当于:

内部会:

8bit 数据只需要 2 个时钟。

从数学角度看

接收过程就是:

而 b_i 是在时钟 i 的采样结果。

小结一下

线多了,在处理的时候就需要一个步骤,进行并转串的操作,本质上还是 bit 的拼接。

FPGA时间到!

这里就说高云的 FPGA 了!我们可以方便的完成上面说的操作,是物理级别的。因为 IO 引脚也就是做这些应用,输出时钟,输入数据等,以及进行采样。

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

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

8 位到 D0
8 位到 D0

8 位到 D0

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

还有 10 转 1
还有 10 转 1

还有 10 转 1

还是整理一下。

串转并(Deserializer,输入侧)

IDES4:1 → 4 解串

IDES8:1 → 8 解串

IDES10:1 → 10 解串(常见于 10:1、8b/10b 相关接口)

IDES16:1 → 16 解串(更宽的并行字),这些原语都属于 “DDR 模式输入逻辑”。

并转串(Serializer,输出侧)

OSER4:4 → 1 串化(同时还有 Q1 辅助输出用于 OEN/三态控制等)

OSER8:8 → 1 串化(同样 Q0 数据、Q1 给 OEN)

OSER10:10 → 1 串化

OSER16:16 → 1 串化

继续看,我们知道采样是需要时钟的,所以放心,肯定有。

image-20260223214848591
image-20260223214848591

image-20260223214848591

两个关键时钟:FCLK vs PCLK(为什么要两个?)

这些并串/串并原语几乎都用同一套时钟哲学:

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 是一个单边采样,那很自然的就在时钟的两个沿去采样:

它来了!
它来了!

它来了!

一个题外话:intan

全球的脑机接口都离不开的芯片
全球的脑机接口都离不开的芯片

全球的脑机接口都离不开的芯片

32 和 64 通道的数据都很多:

好像是 16 到 32
好像是 16 到 32

好像是 16 到 32

应该是都有
应该是都有

应该是都有

它的输出是支持 SPI 的:

这里的接口就可见一斑了
这里的接口就可见一斑了

这里的接口就可见一斑了

串转并 IDES:bit 如何被采样、如何组装成并行输出

数据输入从哪里来

IDES8 的数据输入 D 可以直接来自 IBUF,也可以先过 IODELAY 再送入(从 IODELAY 的 DO 输出接入),IDES4 也是同样规则;这句话的重要性是:高速串行输入经常需要 输入延时微调(把采样点移到眼图中心),IODELAY 就是干这个的。

题外话,所以多个线的传输看上去非常的完美,但是现实情况是害怕多个线上面的数据稍微有点对不齐,有的快有点慢,那每一个瞬间的采样就不准了,所以需要进行微调,只能让快的变慢一些,和慢的步调一致,这是好理解的。

端口:在 RTL 里怎么“拿到一字节/一字”

看 IDES4 的端口表最直观(其它只是 Q 宽度不同):

输入:D、FCLK、PCLK、CALIB、RESET

输出:Q3~Q0

IDES8 则是 Q0~Q7,例化模板里列得很清楚。

代码语言:javascript
复制
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)。

CALIB:它到底是不是“字节对齐/bit-slip”?是的

文档对 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。

并转串 OSER:并行数据如何被“吐”成串行 bit 流

直觉过程就是:在 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 在流转,我不想从数字电路说起,那没有意思,不忘我们的初心才是最好的。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-02-24,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 云深之无迹 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 异步事件(Asynchronous Events)
    • 什么是异步?
    • 异步事件的三种典型来源
    • 处理异步事件的三个做法
    • DMA + 循环缓冲区 — 无感处理
    • 事件联动 (Event Linkage / EXTI) —— 纯硬件响应
  • 异步编程的核心挑战:原子性与竞争
    • 从 HAL 库看异步
  • 串行 VS 并行
  • QSPI :更多的数据线
  • 并行的数据在何时被串行
    • 控制器层(QSPI 外设)→ 自动拼接成字节
    • 软件层(CPU 视角)→ 看起来像“串行字节流”
  • 更加精细的组装过程
    • 采样的本质:边沿触发 D 触发器
  • 移位寄存器如何拼装成字节
    • 第一个采样边沿:
    • 第二个采样边沿:
    • 什么时候变成“一个字节”?
    • MSB First vs LSB First
    • 从数学角度看
  • 小结一下
  • FPGA时间到!
    • 串转并(Deserializer,输入侧)
    • 并转串(Serializer,输出侧)
  • 两个关键时钟:FCLK vs PCLK(为什么要两个?)
  • 一个题外话:intan
  • 串转并 IDES:bit 如何被采样、如何组装成并行输出
    • 数据输入从哪里来
    • 端口:在 RTL 里怎么“拿到一字节/一字”
  • CALIB:它到底是不是“字节对齐/bit-slip”?是的
  • 并转串 OSER:并行数据如何被“吐”成串行 bit 流
  • 小结一下
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档