首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >低速协议系列:SPI(初入殿堂)

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

作者头像
云深无际
发布2026-03-03 12:50:40
发布2026-03-03 12:50:40
2870
举报
文章被收录于专栏:云深之无迹云深之无迹

被低估的 SPI 协议,其实已经写过很多相关的文章了,但还是有不少新的感悟;那我决定来重新写这部分的内容,毕竟它是 MCU 的三大数据接口之一,而且相关的变种非常多,也异常灵活,相比于 IIC 和 UART 之外是性价比超高的通讯方式。

这次我也不想按照市面上有的东西写,总之就是按照信号链的方式,把所有奇奇怪怪的疑问全都写出来。(目标是理解到 bit 这种深度,当然也会到处盗图,都是我的!!!)

Action

认识一个概念不应该从其本身出发,而是先理解其周围所有的概念。

我们经常可以看到:全双工(Full Duplex)是指通信双方在同一时刻既能发送数据,也能接收数据的工作模式;全双工就像一条双向车道,两个方向的车辆可以互不干扰地同时行驶。它通常使用两套独立的传输线路,一套用于发送,一套用于接收。

模式

数据流向

同时收发

典型例子

单工

仅单向

广播电视、遥控器

半双工

双向

否(需交替)

对讲机(需说“Over”)、I2C 协议

全双工

双向

手机通话、以太网,SPI

因为通讯有来有回,所以是一种分类的方式。

bit

学过计算机组成的应该都知道,计算机最底层就是两个状态,我们这里只关心数据层,其实也是最低层级;也就是 0 和1,接着通过约定速成的规范来完成信息更加多的表达。

在计算机科学中,8-bit(8位)是一个核心计量单位,代表 1个字节(Byte);一个 8-bit 的二进制位可以表示 2^8=256种不同的状态

无符号整数:0 到 255。

有符号整数(通常使用补码):-128 到 +127。

这是 Xcode 里面的
这是 Xcode 里面的

这是 Xcode 里面的

虽然在大多数系统中 uint8_t 就是 unsigned char 的别名,但使用 uint8_t 一看就知道它是为了处理 0-255 的数值,而不是为了存储“字符”。

相当于 8 个格子,每个格子里面都是 0 或者 1,每个字节,都是一组的看,可以当成开关,这里就是寄存器的概念:

每一个位置,都是一个 0 或者 1
每一个位置,都是一个 0 或者 1

每一个位置,都是一个 0 或者 1

可以一组来看当做一个数字,也可以在 bit 的粒度看一位的定义。

看我在用的一个芯片,ADAQ7768-1
看我在用的一个芯片,ADAQ7768-1

看我在用的一个芯片,ADAQ7768-1

寄存器一般是一位,或者一位不够,要连着用,比如一个参数的设置用了三个 bit,其实就是 0-7 的范围,4bit 就是 0-15,这样去扩展的。

所以 bit 的语义可以看作开关和多个 bit 集成的数值概念。

字节

在计算机发展史上,16-bit(16位)是从“入门”迈向“生产力”的关键阶段。它代表一次能处理 2个字节(Word) 的数据;一个 16-bit 的二进制位可以表示 65536种状态:

无符号整数:0 到 65,535(常说的“64K”限制)。

有符号整数:-32,768 到 +32,767。

寻址能力

寻址能力(Addressing Capability)是指 CPU 能够“看到”并管理的最大内存空间范围;可以把它想象成一个快递员(CPU)能送达的所有门牌号(内存地址)的总量。如果门牌号的位数不够,哪怕你盖了再大的仓库(内存条),快递员也找不到多出来的房间。

8位机通常只能直接访问 64KB 内存,而 16位处理器可以通过分段寻址访问 1MB 甚至更多空间,让运行大型软件成为可能。

这里还可以拓展一些内容,应该是不难理解的。

嵌入式中的寻址

虽然 Cortex -M 是 32 位处理器,理论寻址 4GB,但它内部的 Flash 和 RAM 往往只有几百 KB。它是如何利用这 4GB 空间的?

统一编址:STM32 将 4GB 空间切成块(Block)

就是这样
就是这样

0x0800 0000 开始的地方映射到 Flash(存代码)。

0x2000 0000 开始的地方映射到 SRAM(存变量)。

0x4000 0000 开始的地方映射到 外设寄存器。

好处是CPU 访问内存和访问 SPI 硬件,用的都是同一套逻辑(指针),这就是为什么可以通过函数指针直接操作硬件。

!!!

有趣的地方来了,因为我在上面一直强化一个概念,就是 bit 是重要的,单个是开关是状态,连起来是数值是范围,那更高层来看,一串 001010101010 它到底是什么就取决于我们对它的定义,可以是一个数值,字符,也可以是一串寄存器的值。

如果数据没放在 CPU 喜欢的地址(比如 4 的倍数),32 位 CPU 可能要寻址两次才能读完一个数据,效率减半,所以要数据对齐(Alignment);但是需要在上层使用的时候把多加的数据去掉,因为语义正确大于一切。

处理器类型

地址位宽

理论最大寻址

典型代表

8位机 (如 C51)

16位地址线

64 KB

传统 51 单片机

16位机 (如 8086)

20位地址线

1 MB

早期 IBM PC

32位机 (如 STM32)

32位地址线

4 GB

现代工业控制

64位机 (如 Apple M3)

64位地址线

16 EB

现代电脑、手机

32bit来了

8x4:0 到 4,294,967,295。42.9 亿

那对于我们现在的 Cortex 来讲每一个寄存器都是 32bit 的,所有有着大量的空缺:

一般都是高位 MSB 空缺,接着就是我说的,图中就是一个控制功能。

这叫位宽为 3
这叫位宽为 3

这叫位宽为 3

8 个 0,1:

控制 8 个电压
控制 8 个电压

控制 8 个电压

也就是我说的连起来是范围。好~~~,恭喜你大概完成了百分之 10 的知识。

其实我们现在的目标也是明确的,我们就是想传输 byte 出去,这些里面的内容我们自己来定义,所以 SPI 只是物理层的协议,只管把你的 01 数据传到位,至于这些东西怎么解读就是上面的事情了。

先看 01 如何传输?

第一个设计就是时钟;简单来说,SPI 需要时钟线(SCLK)是为了让发送端和接收端在同一瞬间“对齐”数据;如果没有时钟,SPI 这种同步通信就没法玩了。

就是我们有一个步调,这里也是 FPGA 里面时钟的作用,高层来讲,高速稳定的时钟就是快速交互数据的第一要素。

告诉接收端:什么时候该读数据?

在串行传输中,数据线(MOSI/MISO)上的电平跳变非常快;如果没有时钟接收端不知道这一段高电平是代表 1 个 1,还是连续 3 个 1有时钟的情况时钟信号就像一个指挥棒双方约定好:时钟每跳一下(比如从低变高),接收端就去采样一次电平。

时钟就是方波
时钟就是方波

时钟就是方波

一个上,一个下,自己来定义;比如跳 8 下,就精准读入 8 个 bit;用来适应不同的“步调”(极高的灵活性);这是 SPI 比 UART(异步串口)强大的地方。

异步 (UART):双方必须死守固定的波特率(如 115200),差一点就会乱码。

同步 (SPI):从机完全“听命”于时钟;主机 CPU 忙的时候,可以把时钟放慢;需要高速传输时(如刷新屏幕),时钟可以跑到几百 MHz;无论快慢,只要有时钟脉冲,数据就不会错;实现“移位寄存器”的硬件联动。

就是这样的,流水线
就是这样的,流水线

就是这样的,流水线

正如在 HAL 库看到的 HAL_SPI_TransmitReceive

SPI 内部有两个移位寄存器;时钟脉冲每响一次,发送寄存器就把最高位(MSB)推出去,同时接收寄存器把进来的位收进来;时钟是这个“物理推拉过程”的动力源;没有时钟,移位过程就会停止。

我们的数据传输过程是这样的,一般是主机先发,可以是读取数据,设置什么的;从机收到以后响应主机的指令。

需要知道的一点是
需要知道的一点是

需要知道的一点是

数据线上面没有有用数据传输的时候,其实也是有数据传输的,不过这个对我们来说是无用的,因为是循环的,而且大多数时候这个是用来让主机驱动时钟的。

消除累积误差

异步通信(无时钟)随着传输位数变多,双方的微小时间差会不断累积,最后导致数据错位。而 SPI 每一位都由时钟边沿重置对齐,理论上可以无限长地传输数据而不产生位偏移

总之呢~时钟线是 SPI 的“节拍器”,它消除了双方对时间感知的差异,让高速、精准的全双工数据交换成为可能。

01数据在哪里获得?

这是一个看起来牛逼哄哄的时序图
这是一个看起来牛逼哄哄的时序图

这是一个看起来牛逼哄哄的时序图

这里纯粹就是一个数学问题,两个状态,两个状态,乘起来有几个情况。

CPOL (Clock Polarity) —— 时钟极性

它决定了时钟线(SCLK)在空闲(不发数据)时的状态。

CPOL = 0:空闲时是低电平;时钟跳变顺序:低 高(上升沿) 低(下降沿)。

CPOL = 1:空闲时是高电平。;时钟跳变顺序:高 低(下降沿) 高(上升沿)。

CPHA (Clock Phase) —— 时钟相位

它决定了数据在时钟的第几个边沿被采样(捕捉)。

很明显在上边沿判断
很明显在上边沿判断

很明显在上边沿判断

CPHA = 0:在时钟的第一个边沿采样。

如果是 CPOL=0,就在上升沿采样。

CPHA = 1:在时钟的第二个边沿采样。

如果是 CPOL=0,就在下降沿采样。

也就是说数据必须先“准备好”(打在总线上),然后才能被“采样”;如果选错了相位,读到的数据就会错位 1 个 bit。

确定了时钟的采样边沿,然后对应的看下面数据线的高低电平情况,01 就有了,八个时钟,判断 8 次,组成一个 byte。是不是很简单?

这么复杂?

因为不同的硬件设计对“稳定时间”的要求不同:有的芯片希望在时钟一跳的瞬间就读数据(追求快);有的芯片希望时钟跳了一下,等电平在总线上稳定一会儿,在时钟跳回来的时候再读(追求稳)。

SPI 模式

CPOL

CPHA

空闲电平

采样时刻(读数据)

Mode 0

0

0

第一个边沿(上升沿)

Mode 1

0

1

第二个边沿(下降沿)

Mode 2

1

0

第一个边沿(下降沿)

Mode 3

1

1

第二个边沿(上升沿)

看这个 ADC
看这个 ADC

看这个 ADC

Mode 3 的时序看时钟,一开始就是高的,采样在上升沿。

数据的组织形式
数据的组织形式

数据的组织形式

我们知道 spi 要做的就是传输数据喽~但是需要组装一下,这里就开始了,可以确定是是单次传输的 8 个 bit,一个 fs+读写+地址,正好八位+八位的数据。

好,先有这样的感觉,来看看软件的事情。

HAL:你爹来了~

我们现在知道了,最小的传输单位是 byte,软件上面要时刻记住。

也就是说可以传输的 8 个 01 或者 16 个 01 单次
也就是说可以传输的 8 个 01 或者 16 个 01 单次

也就是说可以传输的 8 个 01 或者 16 个 01 单次

插个题外话,有人问 I2S 协议可以传输普通的数据吗?可以!如果真真理解了 01 的传输流:

甚至还可以使用 32bit 的宽度
甚至还可以使用 32bit 的宽度

甚至还可以使用 32bit 的宽度

当然也是寄存器控制的
当然也是寄存器控制的

当然也是寄存器控制的

这里就是一个 16bit 的寄存器:

这里
这里

这里

嗯呐
嗯呐

嗯呐

我们来研究全双工的工作函数
我们来研究全双工的工作函数

我们来研究全双工的工作函数

它的作用就是在发送数据的同时接收数据

&tx_data:发送缓冲区地址;要发给从机的数据。

&rx_data:接收缓冲区地址;从机在同一时刻回传的数据将存放在这里。

1:数据长度。指发送/接收的数据量(单位是 Data Size,可能是 8-bit 或 16-bit)。

HAL_MAX_DELAY:超时时间。这是一个阻塞式调用,意味着 CPU 会在这里“死等”直到传输完成或超时。

这里的 1 就是 byte 的个数,一般器件都是 8bit 的。

上面说了SPI 是同步通信。当时钟(SCLK)跳变时,数据在 MOSI 线上出的同时,MISO 线上的数据也在进。所以“发送”和“接收”是同时发生的;这个函数不使用中断DMA。在数据传完之前,CPU 不会执行下一行代码。对于简单的传感器读取(如加速度计、压力传感器)这很方便,但在高性能需求下会浪费 CPU 资源。

常见用法示例(读取寄存器)

在读取 SPI 设备(如 W25Q128 闪存)时,通常需要先发指令再收数据:

代码语言:javascript
复制
uint8_t cmd = 0x9F; // 读取 ID 指令
uint8_t id_buf[3];
// 发送指令
HAL_SPI_Transmit(&hspi1, &cmd, 1, 10);
// 接收 3 字节 ID
HAL_SPI_Receive(&hspi1, id_buf, 3, 10);

现在看到的是最无脑的版本,性能不好,这里要写的高级点。

中断版本会立即返回(不等待传输完成),CPU 可以继续执行后续代码。

代码语言:javascript
复制
// 定义数据缓冲区(必须是全局变量或静态变量,防止函数退出后内存释放)
uint8_t tx_buf[1] = {0xAA};
uint8_t rx_buf[1];
// 调用函数(去掉超时参数 HAL_MAX_DELAY)
if (HAL_SPI_TransmitReceive_IT(&hspi1, tx_buf, rx_buf, 1) != HAL_OK) {
    // 错误处理:可能是 SPI 忙(上一次传输还没结束)
    Error_Handler();
}
// 此时 CPU 会立即执行到这里,而 SPI 硬件在后台自动收发
Do_Something_Else(); 

处理中断回调

当 SPI 硬件完成最后 1 个 bit 的传输后,会自动触发中断并调用回调函数;需要重写这个弱定义(weak)函数来处理接收到的数据。

代码语言:javascript
复制
// 在 main.c 的 /* USER CODE BEGIN 4 */ 区域编写
void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) {
    if (hspi->Instance == SPI1) {
        // 传输完成!此时 rx_buf 里的数据才是有意义的
        Process_New_Data(rx_buf[0]);
        
        // 如果需要连续传输,可以在这里再次启动 HAL_SPI_TransmitReceive_IT
    }
}

最后1bit的挣扎

SPI 通信的底层逻辑中,这个“最后 1 bit” 指的是数据帧(Data Frame)的物理传输终点。

8-bit 模式:当发送一个 uint8_t 数据时,时钟线(SCLK)会产生 8 个脉冲。最后 1 bit 就是第 8 个脉冲对应的 MOSI/MISO 数据位。

16-bit 模式:时钟线产生 16 个脉冲。最后 1 bit 就是第 16 个脉冲对应的数据位。

硬件寄存器的工作流程

SPI 硬件内部有两个关键寄存器:移位寄存器 (Shift Register)数据寄存器 (DR/FIFO);CPU 将数据写入数据寄存器;硬件自动将数据搬运到移位寄存器;移位寄存器随着时钟脉冲,一个 bit 一个 bit 地把电平发出去,同时把收到的电平存进来;当最后 1 bit 移位完成,硬件判定“传输结束”;此时硬件会将状态寄存器(SR)中的 TXE(发送缓冲区空)和 RXNE(接收缓冲区非空)置位,并最终触发 BSY (Busy) 位清除;HAL 库的中断服务程序(ISR)检测到最后一个字节收发完毕,会关闭 SPI 中断,并执行 HAL_SPI_TxRxCpltCallback;由于 HAL 库不自动控制片选,必须在回调函数里(或确认传输完成后)将 CS 引脚拉高。如果拉早了(比如最后 1 bit 还没跳完就拉高),对方设备收到的数据就会受损。

DMA永远的YYDS

上面模型也看到了,一个byte 就和 CPU 说一下,烦死了,哪有那么多事情来汇报!!!傻逼!想办法不打扰 CPU,直接 SPI 的数据移动到 SRAM 里面,满了告诉 CPU,搬走。

DMA (Direct Memory Access) 模式下,传输完成的判断从“CPU 等硬件”变成了“硬件通知 CPU”,效率达到了最高;CPU 只需告诉 DMA 控制器:“这里有 100 个字节,你帮我搬到 SPI 寄存器去,搬完了再叫我。” 整个搬运过程 完全不占用 CPU

传输完成的阶段

在 DMA 模式下,有三个关键的“完成”节点。

半传输完成 (Half Transfer - HT):当搬运完总数据量的一半时触发;这在连续流处理中极度有用。可以趁 DMA 搬运后半段时,赶紧处理已经收到的前半段数据,实现“乒乓缓存”,数据永不断流。

传输完成 (Transfer Complete - TC):这就是上面关心的“最后 1 bit”搬运结束,DMA 控制器发现设定的计数器归零,触发中断。

代码语言:javascript
复制
// 启动 DMA 传输(100 个数据)
HAL_SPI_TransmitReceive_DMA(&hspi1, tx_buf, rx_buf, 100);

// ... CPU 可以去处理屏幕显示、算法计算等 ...

// 传输完成后,HAL 库自动调用此函数
void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) {
    if (hspi->Instance == SPI1) {
        // 100 个 uint8_t 已经全部整整齐齐地躺在 rx_buf 里的
        HAL_GPIO_WritePin(GPIOA, CS_PIN, GPIO_PIN_SET); // 拉高片选
    }
}

进击的DMA

上面说了可以使用乒乓缓冲区,可以利用 DMA 半传输中断(Half Transfer, HT) 构建不间断缓冲区。

乒乓操作 (Ping-Pong)

设想有一个大小为 100 字节 的数组作为接收缓冲区。

0-50 字节:称为 前半段 (Buffer A)。

51-100 字节:称为 后半段 (Buffer B)。

工作流程:

DMA 开始往 Buffer A 搬运数据;当第 50 个字节搬完,硬件触发 HAL_SPI_RxHalfCpltCallback。此时 Buffer A 已经填满,但 DMA 不停下,自动开始搬运 Buffer B;CPU 趁着 DMA 搬运 B 的时间,赶紧把 A 里的数据处理掉(如存入 SD 卡或进行 FFT 运算);当第 100 个字节搬完,触发 HAL_SPI_RxCpltCallback。此时 Buffer B 已经填满,由于开启了 Circular(循环)模式,DMA 自动跳回 Buffer A 开头;CPU 转身去处理 B 的数据,此时 DMA 正在覆盖刚才已经处理完的 A。

Mode 必须选为 Circular(循环模式),因为我们的外设一般是传感器,他们的地址里面是写死了数据区域的,所以 DMA 就是不停在这个地址上面重复读取就可以取到数据:

3 个 byte
3 个 byte

3 个 byte

main.c 中启动传输后,需要实现两个回调函数:

代码语言:javascript
复制
#define BUF_SIZE 100
uint8_t rx_buf[BUF_SIZE];

// 1. 启动循环 DMA 接收
HAL_SPI_Receive_DMA(&hspi1, rx_buf, BUF_SIZE);

// 2. 半传输完成回调:处理前半部分 (0 ~ 49)
void HAL_SPI_RxHalfCpltCallback(SPI_HandleTypeDef *hspi) {
    if (hspi->Instance == SPI1) {
        // 此时 DMA 正在往 rx_buf[50...99] 写数据
        // CPU 赶紧处理 rx_buf[0...49]
        Process_Data(&rx_buf[0], BUF_SIZE / 2);
    }
}

// 3. 全传输完成回调:处理后半部分 (50 ~ 99)
void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) {
    if (hspi->Instance == SPI1) {
        // 此时 DMA 已经回到开头,正在往 rx_buf[0...49] 写数据
        // CPU 赶紧处理 rx_buf[50...99]
        Process_Data(&rx_buf[BUF_SIZE / 2], BUF_SIZE / 2);
    }
}

后记

还没有写完,持续期待!

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

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

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

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

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • Action
  • bit
  • 字节
  • 寻址能力
  • 嵌入式中的寻址
  • !!!
  • 32bit来了
  • 先看 01 如何传输?
    • 告诉接收端:什么时候该读数据?
  • 消除累积误差
  • 01数据在哪里获得?
    • CPOL (Clock Polarity) —— 时钟极性
    • CPHA (Clock Phase) —— 时钟相位
  • 这么复杂?
  • HAL:你爹来了~
    • 常见用法示例(读取寄存器)
    • 处理中断回调
  • 最后1bit的挣扎
    • 硬件寄存器的工作流程
  • DMA永远的YYDS
    • 传输完成的阶段
  • 进击的DMA
    • 乒乓操作 (Ping-Pong)
      • 工作流程:
  • 后记
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档