被低估的 SPI 协议,其实已经写过很多相关的文章了,但还是有不少新的感悟;那我决定来重新写这部分的内容,毕竟它是 MCU 的三大数据接口之一,而且相关的变种非常多,也异常灵活,相比于 IIC 和 UART 之外是性价比超高的通讯方式。
这次我也不想按照市面上有的东西写,总之就是按照信号链的方式,把所有奇奇怪怪的疑问全都写出来。(目标是理解到 bit 这种深度,当然也会到处盗图,都是我的!!!)
认识一个概念不应该从其本身出发,而是先理解其周围所有的概念。
我们经常可以看到:全双工(Full Duplex)是指通信双方在同一时刻既能发送数据,也能接收数据的工作模式;全双工就像一条双向车道,两个方向的车辆可以互不干扰地同时行驶。它通常使用两套独立的传输线路,一套用于发送,一套用于接收。
模式 | 数据流向 | 同时收发 | 典型例子 |
|---|---|---|---|
单工 | 仅单向 | 否 | 广播电视、遥控器 |
半双工 | 双向 | 否(需交替) | 对讲机(需说“Over”)、I2C 协议 |
全双工 | 双向 | 是 | 手机通话、以太网,SPI |
因为通讯有来有回,所以是一种分类的方式。
学过计算机组成的应该都知道,计算机最底层就是两个状态,我们这里只关心数据层,其实也是最低层级;也就是 0 和1,接着通过约定速成的规范来完成信息更加多的表达。
在计算机科学中,8-bit(8位)是一个核心计量单位,代表 1个字节(Byte);一个 8-bit 的二进制位可以表示 2^8=256种不同的状态
无符号整数:0 到 255。
有符号整数(通常使用补码):-128 到 +127。

这是 Xcode 里面的
虽然在大多数系统中 uint8_t 就是 unsigned char 的别名,但使用 uint8_t 一看就知道它是为了处理 0-255 的数值,而不是为了存储“字符”。
相当于 8 个格子,每个格子里面都是 0 或者 1,每个字节,都是一组的看,可以当成开关,这里就是寄存器的概念:

每一个位置,都是一个 0 或者 1
可以一组来看当做一个数字,也可以在 bit 的粒度看一位的定义。

看我在用的一个芯片,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 | 现代电脑、手机 |
8x4:0 到 4,294,967,295。42.9 亿
那对于我们现在的 Cortex 来讲每一个寄存器都是 32bit 的,所有有着大量的空缺:

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

这叫位宽为 3
8 个 0,1:

控制 8 个电压
也就是我说的连起来是范围。好~~~,恭喜你大概完成了百分之 10 的知识。
其实我们现在的目标也是明确的,我们就是想传输 byte 出去,这些里面的内容我们自己来定义,所以 SPI 只是物理层的协议,只管把你的 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 的“节拍器”,它消除了双方对时间感知的差异,让高速、精准的全双工数据交换成为可能。

这是一个看起来牛逼哄哄的时序图
这里纯粹就是一个数学问题,两个状态,两个状态,乘起来有几个情况。
它决定了时钟线(SCLK)在空闲(不发数据)时的状态。
CPOL = 0:空闲时是低电平;时钟跳变顺序:低 高(上升沿) 低(下降沿)。
CPOL = 1:空闲时是高电平。;时钟跳变顺序:高 低(下降沿) 高(上升沿)。
它决定了数据在时钟的第几个边沿被采样(捕捉)。

很明显在上边沿判断
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
Mode 3 的时序看时钟,一开始就是高的,采样在上升沿。

数据的组织形式
我们知道 spi 要做的就是传输数据喽~但是需要组装一下,这里就开始了,可以确定是是单次传输的 8 个 bit,一个 fs+读写+地址,正好八位+八位的数据。
好,先有这样的感觉,来看看软件的事情。
我们现在知道了,最小的传输单位是 byte,软件上面要时刻记住。

也就是说可以传输的 8 个 01 或者 16 个 01 单次
插个题外话,有人问 I2S 协议可以传输普通的数据吗?可以!如果真真理解了 01 的传输流:

甚至还可以使用 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 闪存)时,通常需要先发指令再收数据:
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 可以继续执行后续代码。
// 定义数据缓冲区(必须是全局变量或静态变量,防止函数退出后内存释放)
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)函数来处理接收到的数据。
// 在 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
}
}
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 还没跳完就拉高),对方设备收到的数据就会受损。
上面模型也看到了,一个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 控制器发现设定的计数器归零,触发中断。
// 启动 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 半传输中断(Half Transfer, HT) 构建不间断缓冲区。
设想有一个大小为 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
在 main.c 中启动传输后,需要实现两个回调函数:
#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);
}
}
还没有写完,持续期待!